feat: Faz 1 - framework, klasör yapısı ve altyapı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 01:08:17 +03:00
50 changed files with 6820 additions and 283 deletions
+24
View File
@@ -0,0 +1,24 @@
# Bağımlılıklar ve build çıktıları imaj içinde yeniden üretilir
node_modules
.next
out
coverage
*.tsbuildinfo
next-env.d.ts
# Sırlar ve kullanıcı verisi asla imaja girmez
.env*
!.env.example
uploads
# Geliştirme araçları
.git
.gitignore
.vscode
.claude
docker-compose.yml
Dockerfile
.dockerignore
*.md
!README.md
npm-debug.log*
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
+22
View File
@@ -0,0 +1,22 @@
# BÜTÇEM ortam değişkenleri — kopyala: cp .env.example .env.local
# Gerçek değerler yalnızca .env.local (lokal) ve Dokploy ortam değişkenlerinde (canlı) tutulur.
# Lokal: docker compose ile tek düğümlü replica set (transaction için zorunlu)
MONGODB_URI="mongodb://localhost:27019/butcem?replicaSet=rs0&directConnection=true"
# Üret: openssl rand -base64 32
BETTER_AUTH_SECRET=""
# Lokal: http://localhost:3000 — Canlı: https://butcem.ta1sph.website
BETTER_AUTH_URL="http://localhost:3000"
# Faz 4 seed'i için ilk admin hesabı
SEED_ADMIN_EMAIL="admin@butcem.local"
SEED_ADMIN_PASSWORD=""
# Ek dosyalar (canlıda Docker volume: /app/uploads)
UPLOAD_DIR="./uploads"
MAX_UPLOAD_MB="10"
# Build sırasında Dockerfile tarafından doldurulur; lokalde boş bırakılabilir
APP_VERSION=""
APP_COMMIT=""
+8 -1
View File
@@ -30,8 +30,9 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
# env files (only the example is committed)
.env*
!.env.example
# vercel
.vercel
@@ -39,3 +40,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# user uploads (never committed; backed up separately)
/uploads/
# local tooling
/scratch/
+1
View File
@@ -0,0 +1 @@
26
+7
View File
@@ -0,0 +1,7 @@
.next
node_modules
package-lock.json
coverage
uploads
next-env.d.ts
docs/PROMPT.md
+9
View File
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/app/globals.css",
"tailwindFunctions": ["cn", "cva"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
"vitest.explorer",
"mongodb.mongodb-vscode",
"editorconfig.editorconfig"
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"files.eol": "\n",
"tailwindCSS.experimental.classRegex": [["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]],
"files.associations": {
"*.css": "tailwindcss"
}
}
+29
View File
@@ -0,0 +1,29 @@
# Değişiklik Günlüğü
Bu projedeki tüm önemli değişiklikler bu dosyada belgelenir.
Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ve proje [Semantic Versioning](https://semver.org/lang/tr/) kullanır.
## [Yayınlanmamış]
## [0.1.0] - 2026-11-05
Faz 1: Framework, klasör yapısı ve altyapı.
### Eklendi
- Next.js 16.4 (App Router, Turbopack, Cache Components), React 19.3, TypeScript strict (`noUncheckedIndexedAccess`), Tailwind CSS 4.
- shadcn/ui (Radix, Nova preset) başlangıç yapılandırması.
- Bağımlılıklar: Better Auth, MongoDB sürücüsü 7.7 (Mongoose ile ortak), Mongoose 9, zod 4, react-hook-form, next-themes, sonner, TanStack Table.
- `src/` tabanlı klasör yapısı (`features`, `shared`, `models`, `lib/db`, `lib/storage`, `i18n`, `scripts/migrations`).
- zod ile doğrulanan `env.ts` (tembel doğrulama, build sırasında sır gerektirmez) ve birim testleri.
- `src/i18n/tr.ts` arayüz metinleri dosyası.
- Lokal MongoDB 9 tek düğümlü replica set (`docker-compose.yml`, `rs0`, otomatik başlatma, PRIMARY healthcheck, host portu 27019).
- Çok aşamalı `Dockerfile` (standalone çıktı, root olmayan kullanıcı, `/app/uploads` volume, HEALTHCHECK) ve `.dockerignore`.
- Geçici `GET /api/health` (sürüm ve commit bilgisi).
- Vitest, Prettier (Tailwind eklentisi), EditorConfig, VSCode önerilen eklentiler.
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.1.0...HEAD
[0.1.0]: https://gitea.ta1sph.info/suphicakir/butcem/releases/tag/v0.1.0
+54
View File
@@ -0,0 +1,54 @@
# syntax=docker/dockerfile:1
# BÜTÇEM — çok aşamalı üretim imajı (Next.js standalone çıktı).
# Build: docker build --build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
# Çalıştır: docker run -p 3000:3000 --env-file .env.production -v butcem-uploads:/app/uploads butcem
ARG NODE_VERSION=26
# 1) Bağımlılıklar — yalnızca lock dosyası değişince yeniden çalışır.
FROM node:${NODE_VERSION}-alpine AS deps
WORKDIR /app
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
# 2) Build
FROM node:${NODE_VERSION}-alpine AS builder
WORKDIR /app
ARG APP_VERSION=""
ARG APP_COMMIT="unknown"
ENV NEXT_TELEMETRY_DISABLED=1 \
APP_VERSION=${APP_VERSION} \
APP_COMMIT=${APP_COMMIT}
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# 3) Çalışma zamanı — yalnızca standalone çıktı, root olmayan kullanıcı.
FROM node:${NODE_VERSION}-alpine AS runner
WORKDIR /app
ARG APP_VERSION=""
ARG APP_COMMIT="unknown"
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0 \
UPLOAD_DIR=/app/uploads \
APP_VERSION=${APP_VERSION} \
APP_COMMIT=${APP_COMMIT}
RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs \
&& mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
VOLUME ["/app/uploads"]
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "server.js"]
+43 -26
View File
@@ -1,36 +1,53 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# BÜTÇEM
## Getting Started
Aile bütçesi, banka/kart/cari hesapları, birikimler (altın, döviz, ziynet), aylık bütçe, ev stoğu ve akıllı alışveriş listelerini tek yerde yöneten, çok kullanıcılı web uygulaması.
First, run the development server:
- Ürün tanımı, mimari kurallar ve faz planı: [docs/PROMPT.md](docs/PROMPT.md)
- Sürüm geçmişi: [CHANGELOG.md](CHANGELOG.md)
## Teknoloji
Next.js 16 (App Router, Turbopack, Cache Components) · React 19 · TypeScript (strict) · Tailwind CSS 4 · shadcn/ui (Radix) · Better Auth · MongoDB 9 (native driver + Mongoose) · zod · Vitest
## Gereksinimler
- Node.js **26** (Active LTS) — `nvm use` (`.nvmrc`)
- Docker + Docker Compose (lokal MongoDB replica set)
## Lokal geliştirme
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
# 1) Bağımlılıklar
npm install
# 2) Ortam değişkenleri — BETTER_AUTH_SECRET için: openssl rand -base64 32
cp .env.example .env.local
# 3) MongoDB (tek düğümlü replica set rs0, host portu 27019) — sağlıklı olana kadar bekler
npm run db:up
# 4) Uygulama
npm run dev # http://localhost:3000
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Scriptler
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
| Komut | Açıklama |
| ----------------------------- | -------------------------------- |
| `npm run dev` | Geliştirme sunucusu |
| `npm run build` / `start` | Üretim build'i / sunucusu |
| `npm run lint` | ESLint |
| `npm run typecheck` | `tsc --noEmit` |
| `npm run test` | Vitest birim testleri |
| `npm run format` | Prettier |
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Docker imajı
## Learn More
```bash
docker build --build-arg APP_VERSION=$(node -p "require('./package.json').version") \
--build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
```
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Canlı ortam (Dokploy, `butcem.ta1sph.website`) kurulumu Faz 15'te `docs/DEPLOY.md` içinde anlatılacak.
-26
View File
@@ -1,26 +0,0 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
-29
View File
@@ -1,29 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
-69
View File
@@ -1,69 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+39
View File
@@ -0,0 +1,39 @@
# Lokal geliştirme: MongoDB tek düğümlü replica set (rs0) — transaction için zorunlu.
# Başlat: npm run db:up Durdur: npm run db:down Veriyi sil: docker compose down -v
name: butcem
services:
mongo:
image: mongo:9
container_name: butcem-mongo
command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--port", "27017"]
ports:
# Yalnızca bu makineden erişilebilir. Host portu 27019 (27017/27018 bu makinede dolu);
# değiştirmek için: BUTCEM_MONGO_PORT=xxxxx npm run db:up
- "127.0.0.1:${BUTCEM_MONGO_PORT:-27019}:27017"
volumes:
- mongo-data:/data/db
- mongo-config:/data/configdb
restart: unless-stopped
# Healthcheck replica set'i ilk çalıştırmada başlatır; düğüm PRIMARY olana kadar "unhealthy" kalır.
# Üye adresi konteyner içinden "localhost:27017"; uygulama host'tan directConnection=true ile
# bağlandığı için üye listesini kullanmaz, port eşlemesi sorun olmaz.
healthcheck:
test:
- CMD
- mongosh
- --quiet
- --eval
- >-
try { rs.status() } catch (e) {
rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] }) }
if (!db.hello().isWritablePrimary) { quit(1) }
interval: 5s
timeout: 10s
start_period: 20s
start_interval: 2s
retries: 30
volumes:
mongo-data:
mongo-config:
@@ -258,8 +258,8 @@ Hesap türleri:
### 8.1 Lokal geliştirme
- MongoDB, `docker-compose.yml` ile **tek düğümlü replica set** (`rs0`) olarak çalışır (transaction için zorunlu); kalıcı volume, healthcheck, replica set'i otomatik başlatan init.
- Lokal bağlantı: `mongodb://localhost:27017/butcem?replicaSet=rs0&directConnection=true`.
- `env.ts` zod ile doğrular: `MONGODB_URI`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD`, `UPLOAD_DIR`, `MAX_UPLOAD_MB`, `APP_VERSION` (build sırasında).
- Lokal bağlantı: `mongodb://localhost:27019/butcem?replicaSet=rs0&directConnection=true` (bkz. 10.1).
- `env.ts` zod ile doğrular: `MONGODB_URI`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD`, `UPLOAD_DIR`, `MAX_UPLOAD_MB`, `APP_VERSION` ve `APP_COMMIT` (build sırasında).
### 8.2 Canlı ortam (homeserver: "Atlas")
- Kaynak kod: Atlas üzerindeki **Gitea** reposu (`origin`).
@@ -309,6 +309,16 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
- Stok eşiği hedef stoğun %20'si; ürün tipi bazında değiştirilebilir.
- Ek dosya sınırı: dosya başına 10 MB, kayıt başına 10 dosya.
### 10.1 Faz 0 kararları (2026-11-05, onaylandı)
- Yukarıdaki varsayılanların tümü kabul edildi.
- **Giriş:** kullanıcı adı **veya** e-posta + şifre (Better Auth `username` plugin). Kayıt yine kapalı.
- **Admin veri erişimi:** sistem admini kullanıcıları, haneleri ve üyelikleri yönetir; üyesi olmadığı hanenin finansal verisini **göremez**.
- **Bütçe ayı başlangıcı:** hane ayarı `budgetMonthStartDay` (1–28); varsayılan 1 (takvim ayı). Ay sınırı hesapları bu ayara göre yapılır.
- **Değerleme:** varlıklar ve aynî alacaklar **alış** fiyatıyla, aynî borçlar **satış** fiyatıyla değerlenir. Tek fiyat girilirse ikisine de yazılır.
- **URL'ler:** ekran yolları Türkçe ASCII (`/islemler`, `/butce`); kod, koleksiyon adları ve `/api/v1` İngilizce.
- **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı).
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
---
## 11. Ek İstekler
+3 -1
View File
@@ -1,7 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// Docker imajı için yalnızca gerekli dosyaları .next/standalone altına kopyala.
output: "standalone",
poweredByHeader: false,
experimental: {
agentFeedback: true,
},
+6070 -118
View File
File diff suppressed because it is too large Load Diff
+39 -5
View File
@@ -2,25 +2,59 @@
"name": "butcem",
"version": "0.1.0",
"private": true,
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
"engines": {
"node": ">=26 <27"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write .",
"format:check": "prettier --check .",
"db:up": "docker compose up -d --wait",
"db:down": "docker compose down",
"seed": "tsx --env-file=.env.local scripts/seed.ts",
"seed:reset": "tsx --env-file=.env.local scripts/seed.ts --reset"
},
"dependencies": {
"@hookform/resolvers": "^7.0.2",
"@tanstack/react-table": "^9.3.1",
"better-auth": "^1.7.17",
"class-variance-authority": "^0.7.1",
"cn": "^0.5.2",
"lucide-react": "^1.68.0",
"mongodb": "~7.7",
"mongoose": "^9.11.4",
"next": "16.4.5",
"react": "19.3.0",
"react-dom": "19.3.0"
"next-themes": "^0.4.6",
"radix-ui": "^1.8.1",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.91.0",
"server-only": "^0.0.1",
"shadcn": "^4.23.0",
"sonner": "^2.0.8",
"tw-animate-css": "^1.4.0",
"zod": "^4.6.7"
},
"devDependencies": {
"@faker-js/faker": "^10.6.0",
"@tailwindcss/turbopack": "^4",
"@types/node": "^20",
"@types/node": "^26.6.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.4.5",
"prettier": "^3.9.10",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4",
"typescript": "^5"
"tsx": "^4.23.15",
"typescript": "^5",
"vitest": "^5.1.0"
}
}
View File
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View File
+10
View File
@@ -0,0 +1,10 @@
/**
* Seed betiği — Faz 4'te doldurulacak (admin, örnek hane, aile üyeleri ve alan verisi).
* Çalıştırma: npm run seed | Sıfırlayıp doldurma: npm run seed:reset
*/
if (process.env.NODE_ENV === "production" && process.argv.includes("--reset")) {
console.error("seed:reset production ortamında çalıştırılamaz.");
process.exit(1);
}
console.log("Seed henüz uygulanmadı (Faz 4).");
+20
View File
@@ -0,0 +1,20 @@
import { connection } from "next/server";
import { APP_COMMIT, APP_VERSION } from "@/lib/version";
/**
* Sağlık kontrolü (Dokploy / Docker HEALTHCHECK).
* Faz 3'te veritabanı ping'i eklenecek; DB erişilemezse 503 dönecek.
*/
export async function GET() {
// Cache Components açıkken GET handler'ı build'de prerender edilmesin.
await connection();
return Response.json(
{
status: "ok",
version: APP_VERSION,
commit: APP_COMMIT,
time: new Date().toISOString(),
},
{ headers: { "Cache-Control": "no-store" } },
);
}

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { tr } from "@/i18n/tr";
import "./globals.css";
// Türkçe karakterler (ğ, ş, ı, İ) latin-ext alt kümesinde.
const geistSans = Geist({
variable: "--font-sans",
subsets: ["latin", "latin-ext"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin", "latin-ext"],
});
export const metadata: Metadata = {
title: { default: tr.app.name, template: `%s · ${tr.app.name}` },
description: tr.app.tagline,
applicationName: tr.app.name,
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="tr" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="flex min-h-full flex-col">{children}</body>
</html>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { tr } from "@/i18n/tr";
import { APP_VERSION } from "@/lib/version";
/** Geçici açılış sayfası — Faz 2'de /login'e yönlendirme ile değişecek. */
export default function Home() {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
<h1 className="text-3xl font-semibold tracking-tight">{tr.app.name}</h1>
<p className="max-w-md text-muted-foreground">{tr.app.setupDescription}</p>
<p className="text-sm text-muted-foreground">
{tr.app.version} {APP_VERSION}
</p>
</main>
);
}
View File
View File
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import { Slot } from "radix-ui";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
View File
+15
View File
@@ -0,0 +1,15 @@
/**
* Arayüz metinleri (tr-TR). Tüm kullanıcıya görünen metinler burada toplanır.
* Şimdilik tek dil; ileride çok dil gerekirse bu nesnenin şekli sözleşme olur.
*/
export const tr = {
app: {
name: "BÜTÇEM",
tagline: "Aile bütçesi, birikim, ev stoğu ve akıllı alışveriş",
setupInProgress: "Kurulum sürüyor",
setupDescription: "Altyapı hazır. Giriş ekranı ve arayüz bir sonraki fazda eklenecek.",
version: "Sürüm",
},
} as const;
export type Messages = typeof tr;
View File
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { parseServerEnv } from "./env";
const valid = {
MONGODB_URI: "mongodb://localhost:27019/butcem?replicaSet=rs0&directConnection=true",
BETTER_AUTH_SECRET: "x".repeat(32),
BETTER_AUTH_URL: "http://localhost:3000",
};
describe("parseServerEnv", () => {
it("geçerli değerleri kabul eder ve varsayılanları uygular", () => {
const env = parseServerEnv(valid);
expect(env.UPLOAD_DIR).toBe("./uploads");
expect(env.MAX_UPLOAD_MB).toBe(10);
expect(env.NODE_ENV).toBe("development");
});
it("MAX_UPLOAD_MB string'ini sayıya çevirir", () => {
expect(parseServerEnv({ ...valid, MAX_UPLOAD_MB: "25" }).MAX_UPLOAD_MB).toBe(25);
});
it("boş string'i tanımsız sayar", () => {
const env = parseServerEnv({ ...valid, SEED_ADMIN_EMAIL: "", APP_VERSION: "" });
expect(env.SEED_ADMIN_EMAIL).toBeUndefined();
expect(env.APP_VERSION).toBeUndefined();
});
it("eksik ve hatalı değişkenleri tek hata mesajında listeler", () => {
expect(() =>
parseServerEnv({ MONGODB_URI: "postgres://x", BETTER_AUTH_SECRET: "kisa" }),
).toThrowError(/MONGODB_URI[\s\S]*BETTER_AUTH_SECRET[\s\S]*BETTER_AUTH_URL/);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { z } from "zod";
/**
* Sunucu ortam değişkenleri şeması.
*
* Doğrulama tembel (lazy) yapılır: `next build` sırasında (ör. Docker build aşamasında)
* sırlar henüz tanımlı olmadığı için modül yüklenirken değil, ilk `env()` çağrısında çalışır.
* Sunucu ilk isteği işlerken eksik/hatalı bir değişken varsa açık bir hata ile durur.
*/
export const serverEnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
MONGODB_URI: z
.string()
.min(1, "MONGODB_URI zorunlu")
.refine((v) => v.startsWith("mongodb://") || v.startsWith("mongodb+srv://"), {
message: "MONGODB_URI mongodb:// veya mongodb+srv:// ile başlamalı",
}),
BETTER_AUTH_SECRET: z.string().min(32, "BETTER_AUTH_SECRET en az 32 karakter olmalı"),
BETTER_AUTH_URL: z.url("BETTER_AUTH_URL geçerli bir URL olmalı"),
SEED_ADMIN_EMAIL: z.email().optional(),
SEED_ADMIN_PASSWORD: z.string().min(8).optional(),
UPLOAD_DIR: z.string().min(1).default("./uploads"),
MAX_UPLOAD_MB: z.coerce.number().int().positive().max(100).default(10),
APP_VERSION: z.string().optional(),
APP_COMMIT: z.string().optional(),
});
export type ServerEnv = z.infer<typeof serverEnvSchema>;
/** Boş string'leri tanımsız say (`.env` dosyalarında `KEY=""` yaygın). */
function normalize(source: Record<string, string | undefined>) {
return Object.fromEntries(Object.entries(source).map(([k, v]) => [k, v === "" ? undefined : v]));
}
export function parseServerEnv(source: Record<string, string | undefined>): ServerEnv {
const result = serverEnvSchema.safeParse(normalize(source));
if (!result.success) {
const issues = result.error.issues
.map((i) => ` - ${i.path.join(".") || "(kök)"}: ${i.message}`)
.join("\n");
throw new Error(`Geçersiz ortam değişkenleri:\n${issues}`);
}
return result.data;
}
let cached: ServerEnv | undefined;
/** Doğrulanmış sunucu ortam değişkenleri. Yalnızca sunucu kodunda kullan. */
export function env(): ServerEnv {
cached ??= parseServerEnv(process.env);
return cached;
}
View File
+1
View File
@@ -0,0 +1 @@
export { cn } from "cn";
+7
View File
@@ -0,0 +1,7 @@
import pkg from "../../package.json";
/** Uygulama sürümü: build sırasında APP_VERSION verilirse o, yoksa package.json. */
export const APP_VERSION: string = process.env.APP_VERSION || pkg.version;
/** Git commit kısa hash'i (Docker build argümanı APP_COMMIT ile gelir). */
export const APP_COMMIT: string = process.env.APP_COMMIT || "dev";
View File
View File
View File
+2 -1
View File
@@ -5,6 +5,7 @@
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
@@ -19,7 +20,7 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": ["./src/*"]
}
},
"include": [
+14
View File
@@ -0,0 +1,14 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},
test: {
environment: "node",
include: ["src/**/*.test.ts", "scripts/**/*.test.ts"],
// "server-only" paketi test ortamında hata fırlatmasın.
server: { deps: { inline: ["server-only"] } },
},
});