init: lab project bootstrapped from isletmem-kovakcrm

- CRM domain modules removed (customers, services, software, calendar, tasks, invoices, leads, finance, etc.)
- DLS branding: package name=lab, logo wordmark, sidebar nav, header CTA
- Tenant layer extended with kind dimension (lab|clinic) + requireTenantKind helper
- Schema rewritten for DLS domain: jobs, job_files, job_status_history, prosthetics, connections, finance_entries, notifications
- Onboarding form: clinic/lab account-type selection + auto-generated memberNumber
- Placeholder routes for jobs/{inbound,outbound,new}, products, finance, connections
- PDF spec + spec.md under belgeler/
- db: lab database + 13 collections + indexes + storage bucket (job-files) provisioned via Appwrite MCP

Ref: belgeler/dls-ui-tasarim.pdf
This commit is contained in:
kovakmedya
2026-05-21 18:28:38 +03:00
commit cb150f7a24
215 changed files with 54262 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Appwrite (self-hosted, v1.9.0)
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://db.kovaksoft.com/v1
NEXT_PUBLIC_APPWRITE_PROJECT_ID=
NEXT_PUBLIC_APPWRITE_DATABASE_ID=lab
# Server-only — Appwrite API key with sufficient scopes
# (databases.read/write, tables.read/write, users.read/write, teams.read/write)
APPWRITE_API_KEY=
# App
APP_URL=https://lab.kovakcrm.com
+46
View File
@@ -0,0 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# claude code (local-only)
.claude/
+126
View File
@@ -0,0 +1,126 @@
# DLS — Dental Lab System
Diş klinikleri ↔ diş laboratuvarları arasında iş alışverişi, dosya paylaşımı ve finansal takip platformu. KovakSoft müşterileri ya **Klinik** ya da **Laboratuvar** olarak kayıt olur, "bağlantı kodu" ile karşı tarafla eşleşir, iş gönderir/alır ve ödeme akışını yönetir.
Baz: [isletmem-kovakcrm](https://git.kovaksoft.com/kovakmedya/isletmem-kovakcrm) (silicondeck/shadcn-dashboard-landing-template türevi). Auth + tenant katmanı + tema + server actions altyapısı aynen kullanılıyor; sadece domain modülleri DLS için yeniden yazıldı.
## Stack
- **Next.js 16** + **React 19** (App Router, TypeScript)
- **Tailwind CSS v4** + **shadcn/ui v3** (Radix primitives)
- **Zustand** — client state
- **TanStack Table**, **react-hook-form** + **Zod**, **Recharts**
- **Appwrite** — DB, Auth, Storage, Teams (tenant izolasyonu)
- **pnpm** — package manager
- **Coolify** — Gitea webhook ile auto-deploy
## Multi-tenancy modeli
| Concept | Appwrite primitive |
|---|---|
| Tenant | Appwrite **Team** (1 team = 1 klinik veya 1 lab) |
| Tenant türü (`kind`) | `tenant_settings.kind: 'lab' \| 'clinic'` |
| Tenant üyesi | Team membership (rol: `owner` / `admin` / `member`) |
| Bağlantı kodu | `tenant_settings.memberNumber` (6 hane unique) |
| Veri izolasyonu | Her doküman `tenantId` + `Permission.read/update/delete(Role.team(tenantId))` |
| Cross-tenant erişim | `jobs`, `job_files`, `job_status_history`, `finance_entries` ek olarak karşı tarafın team permission'ına açılır |
**Kural:** Tek shared tenant değil — her klinik ve her lab kendi Appwrite Team'idir, izolasyon CRM ile aynı. `kind` boyutu sadece UI/route koruması ve iş akışı için. `requireTenantKind(ctx, ['lab'])` route guard helper'ı kullanılır.
## Modüller
| Modül | Rol | Collection(lar) |
|---|---|---|
| Anasayfa | her ikisi | (özet kartlar) |
| Gelen İşler | her ikisi | `jobs` (lab tarafında baskın) |
| Giden İşler | her ikisi | `jobs` (klinik tarafında baskın) |
| Yeni İş Yayınla | sadece klinik | `jobs` + `job_files` |
| İş detay (durum stepper) | her ikisi | `jobs` + `job_status_history` (Ölçü → Alt Yapı → Üst Yapı → Cila/Bitim) |
| Ürünler | sadece lab | `prosthetics` |
| Finans | her ikisi | `finance_entries` |
| Bağlantı Kur | her ikisi | `connections` (bağlantı kodu ile eşleşme) |
| Ayarlar | her ikisi | `tenant_settings`, üye yönetimi |
Tüm collection'larda ortak: `tenantId` (sahip), `createdBy` (userId), `$createdAt`, `$updatedAt`. Cross-tenant collection'lar (jobs, job_files, finance_entries) ek olarak `clinicTenantId` + `labTenantId` taşır.
## Bağlantı (connections) akışı
1. Klinik karşı laboratuvarın `memberNumber`'ını girer → `connections` row oluşur (`status: pending`).
2. Lab kendi tarafında pending talebi görür, **Onayla** veya **Reddet**.
3. Onaylanan bağlantı `status: approved`, sonrasında `Yeni İş Yayınla` formunda klinik bağlı lab'lerden birini seçebilir.
4. İş oluştuğunda dokümana karşı tarafın `Role.team(<otherTenantId>)` read/update permission'ı eklenir → karşı taraf otomatik görür.
## Auth
Appwrite Auth — email/password. CRM'deki akış aynen geçerli.
- Register → onboarding → **Klinik / Laboratuvar** seçimi + şirket bilgileri → `tenant_settings.kind` atanır, `memberNumber` üretilir → dashboard.
- `lib/appwrite/server.ts` — server SDK (API key ile admin ops)
- `lib/appwrite/client.ts` — browser SDK (session JWT)
- Cookie: `lab-session`, `lab-tenant`. Middleware: korumalı `(dashboard)/*`, public `(auth)/*` + marketing.
## Klasör yapısı
```
src/
├── app/
│ ├── (auth)/ login, register, reset-password
│ ├── (dashboard)/ dashboard, jobs/{inbound,outbound,new,[id]}, products, finance, connections, settings
│ ├── d/[code]/ team davet kabul
│ ├── landing/ marketing
│ └── onboarding/ ilk workspace + kind seçimi
├── components/ shadcn/ui + custom (logo, sidebar, header)
├── lib/
│ ├── appwrite/ client.ts, server.ts, schema.ts, tenant-guard.ts, ...
│ └── validation/
├── middleware.ts
└── ...
```
## Komutlar
```bash
pnpm dev # localhost:3000
pnpm build
pnpm lint
pnpm typecheck # tsc --noEmit
```
## Appwrite — MCP üzerinden işlemler
`DATABASE_ID = "lab"` — isletmem-kovakcrm projesi (ID `69f27b51000a5bee46ce`) altında ayrı database. Tüm collection / attribute / index / permission CRUD'u **Appwrite MCP** ile yapılır. Migration mantığı: yeni collection / attribute eklendiğinde MCP komutu çalıştır + `lib/appwrite/schema.ts` güncellenir (tek source of truth).
## Gitea + Coolify deploy
- **Repo:** `ssh://git@git.kovaksoft.com:2222/kovakmedya/lab.git`
- **Coolify host:** `kovaksoft-coolify` (`ssh -p 22 root@194.31.52.65`)
- **Production domain:** `https://lab.kovakcrm.com`
- **Workflow:** `main` branch'e push → Gitea webhook → Coolify auto-deploy.
- **Webhook URL (Coolify Gitea handler):** `https://admin.kovaksoft.com/webhooks/source/gitea/events/manual`
## Environment variables
```
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://db.kovaksoft.com/v1
NEXT_PUBLIC_APPWRITE_PROJECT_ID=69f27b51000a5bee46ce
NEXT_PUBLIC_APPWRITE_DATABASE_ID=lab
APPWRITE_API_KEY= # server-only
APP_URL=https://lab.kovakcrm.com
```
`.env.local` git'e gitmez. Coolify'da ayrı set edilir.
## Geliştirme prensipleri
- **Mevcut temayı koru, brand değiştir.** Sidebar / header / theme customizer aynen kalır; sadece içerik DLS modüllerine bağlı.
- **Tenant filtresi şart.** Server actions / route handlers'da `tenantId` (veya cross-tenant durumda `clinicTenantId`/`labTenantId`) her query'ye eklenmeden veri çekme.
- **Server actions tercih edilir** — client'tan direkt Appwrite write yerine, server action içinden server SDK ile.
- **Schema değişikliği = MCP çağrısı + `schema.ts` update + migration notu** (commit mesajına `db:` prefix).
- **Türkçe UI**, kod İngilizce.
## Faydalı referanslar
- Gitea CLI: `tea repos list --login git.kovaksoft.com`
- Coolify VPS SSH: `ssh kovaksoft-coolify`
- Appwrite docs: https://appwrite.io/docs
- Baz repo: https://git.kovaksoft.com/kovakmedya/isletmem-kovakcrm
- UI spec: `belgeler/dls-ui-tasarim.pdf` (orijinal Figma export)
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
# DLS — Ürün Tasarım Spec'i
Bu dosya `dls-ui-tasarim.pdf` (orijinal Figma export) baz alınarak hazırlanmış geliştirilmiş ürün spec'idir. PDF UI mock'ları içerir; bu doküman akış, veri modeli, rol ayrımı ve karar gerektiren noktaları somutlaştırır.
## Sistem özeti
DLS, **diş klinikleri** ile **diş laboratuvarları** arasındaki iş alışverişini dijitalleştirir:
- Klinik bir hasta için protez işi açar (tür, üye sayısı, renk, ölçü taraması, görseller).
- İşi bağlı bir laboratuvara yollar.
- Laboratuvar gelen kutusundan görür, durum adımlarını işler (Ölçü → Alt Yapı → Üst Yapı → Cila/Bitim).
- Tamamlandığında klinik gönderim aşamasına geçer.
- Her iki taraf finansal akışı (ödenmiş/bekleyen) kendi tarafında izler.
## Roller
Tek bir hesap modeli, iki tenant türü (`kind`):
| `kind` | İlgili eylemler |
|---|---|
| `clinic` | İş yayınla, kendi gönderdiklerini takip et, lab bağlantısı talep et, ödeme yap |
| `lab` | Ürün katalog yönet, gelen işleri al/onayla, durum güncelle, fatura kes |
`requireTenantKind(ctx, ['lab' \| 'clinic'])` server-side route guard — sayfa render edilmeden önce yetki sorgular.
## Sayfa haritası (PDF'e göre)
| URL | PDF sayfa | İçerik |
|---|---|---|
| `/dashboard` | 1 | Anasayfa — özet kartlar (açık işler, işlem bekleyen, bildirimler, istatistik chart) |
| `/jobs/inbound` | 2 | Gelen İşler tablosu (Klinik / Hasta Kodu / Üye / Renk / Tür / Açıklama / İşlem) |
| `/jobs/[id]` | 3 | İş detay — bilgiler + durum stepper (Ölçü / Alt Yapı Prova / Üst Yapı Prova / Cila/Bitim) + Görselleri Görüntüle / Taranan Dosyalar / Kaydet |
| `/jobs/outbound` | 4 | Giden İşler tablosu (aynı sütunlar) |
| `/jobs/new` | (header CTA) | "Yeni İş Yayınla" formu — klinik tarafı |
| `/products` | 5 | Eklenen Ürünler (sadece lab) — Protez Türü / Fiyat tablo + ekleme formu |
| `/finance` | 6 | Finans (Bekliyor) — tahsilat/ödeme listesi |
| `/connections` | 7 | Bağlantı Kur — kendi bağlantı kodun + bekleyen talepler + onaylı bağlantılar |
| `/settings/*` | 8 | Ayarlar (workspace, üye, hesap, görünüm) |
| `/sign-in`, `/sign-up` | 9 | Giriş/kayıt — PDF "Labaratuvar Giriş" + "Klinik Giriş" iki ayrı form gösteriyor; biz tek form + onboarding'de `kind` seçimi yapıyoruz |
> PDF'teki "Üye Numarası" alanı login'de **kullanılmıyor**. Üye numarası `tenant_settings.memberNumber` olarak sistem tarafından üretiliyor ve **bağlantı kodu** olarak işlev görüyor (PDF'teki "Bağlantı Kodu" panelinin de aynısı).
## Veri modeli (`lib/appwrite/schema.ts`)
Tüm collection'lar `tenant_settings.tenantId = Appwrite Team.$id`.
### `tenant_settings`
- `tenantId` (unique, indexed)
- `kind: 'lab' | 'clinic'`
- `memberNumber: string` (6 hane, unique, indexed) — bağlantı kodu
- `companyName`, `companyTaxId`, `companyAddress`, `companyEmail`, `companyPhone`, `logo`
- `defaultCurrency` (varsayılan `TRY`)
### `profiles`
Kullanıcı başına ek bilgi (display name, telefon, ünvan). Auth identity Appwrite Auth'tadır; ek alanlar burada.
### `connections`
İki tenant arası bağlantı.
- `clinicTenantId`, `labTenantId`
- `status: 'pending' | 'approved' | 'rejected'`
- `requestedBy`, `requestedAt`, `approvedAt?`, `rejectedAt?`
- Permission: `Role.team(clinicTenantId)` + `Role.team(labTenantId)` (her ikisi de görür)
### `jobs`
- `clinicTenantId`, `labTenantId`, `createdBy`
- `patientCode`, `prostheticType` (`metal_porselen`, `zirkonyum`, `implant_ustu_zirkonyum`, `gecici`, `e_max`, `diger`)
- `memberCount` (üye sayısı), `color` (Vita renk kodu örn. A2), `description?`, `price?`, `currency?`, `dueDate?`
- `status: 'pending' | 'in_progress' | 'sent' | 'delivered' | 'cancelled'`
- `currentStep: 'olcu' | 'alt_yapi_prova' | 'ust_yapi_prova' | 'cila_bitim'`
- Permission: `Role.team(clinicTenantId)` + `Role.team(labTenantId)`
### `job_files`
- `jobId`, `clinicTenantId`, `labTenantId`, `uploadedBy`
- `kind: 'scan' | 'image' | 'document'`
- `fileId` (Appwrite Storage bucket: `job-files`), `name`, `size`, `mimeType?`
- Permission: aynı job permission'ı
### `job_status_history`
Stepper geçişleri — denetim izi.
- `jobId`, `clinicTenantId`, `labTenantId`, `step`, `completedBy`, `completedAt`, `note?`
### `prosthetics` (lab katalog)
- `tenantId` (lab'in tenant id'si), `createdBy`
- `name`, `type`, `unitPrice`, `currency?`, `archived?`
- Permission: `Role.team(tenantId)` (sadece kendi tarafı)
### `finance_entries`
- `tenantId`, `createdBy`, `jobId?`, `counterpartTenantId?`
- `type: 'income' | 'expense' | 'receivable' | 'payable'`
- `amount`, `currency?`, `status: 'pending' | 'paid' | 'cancelled'`, `date`, `description?`
- Permission: `Role.team(tenantId)` (klinik ve lab kendi defterlerini görür; karşı taraf görmez)
### `notifications`
- `tenantId`, `userId?`, `jobId?`, `connectionId?`, `message`, `read`
### `audit_logs`, `invite_links`, `password_resets`, `user_preferences`
CRM'den birebir kullanılıyor.
## Bağlantı akışı (`connections` yaşam döngüsü)
1. **Klinik**`/connections` → "Bağlantı talep et" → karşı tarafın `memberNumber`'ını gir.
2. Server action `connections` row yaratır (`status: pending`), her iki team'e permission açar.
3. **Lab**`/connections` → bekleyen talepler listesinde görür → **Onayla** veya **Reddet**.
4. Onaylanırsa `status: approved`. Klinik artık `/jobs/new` formunda bu lab'i seçebilir.
5. Reddedilirse `status: rejected`, tekrar talep edilebilir.
## İş akışı (`jobs` yaşam döngüsü)
1. Klinik `/jobs/new` formuyla iş açar (lab seçimi onaylı bağlantılardan), durum `pending`.
2. Lab gelen kutusunda görür, **İşleme Al**`status: in_progress`, `currentStep: olcu`.
3. Lab her aşamayı tamamlayınca `job_status_history` row eklenir + `currentStep` ilerler.
4. Cila/Bitim sonrası `status: sent`. Klinik teslim alınca `delivered`.
5. Tamamlandığında lab `finance_entries` (income, pending) açar; klinik tarafında (expense, pending) açılır (idempotent sync helper ile).
## Onboarding akışı
`/onboarding` sayfası:
1. Kayıt sonrası ilk girişte. Mevcut workspace'i yoksa zorunlu.
2. **Hesap türü seç** (Klinik / Lab) → state.
3. Şirket adı + opsiyonel vergi/telefon.
4. Submit → `createWorkspaceAction`:
- Appwrite Team yarat
- `tenant_settings` row yarat (`kind`, `memberNumber` üretilmiş)
- Active tenant cookie + user prefs
5. Redirect `/dashboard`.
İmport akışı (cross-app team import) için de `kind` seçimi zorunlu.
## Açık tasarım kararları
- **Login formunda "Üye Numarası"** PDF'te var; biz e-posta+şifre kullanıyoruz. `memberNumber` register/login için **kullanılmaz**, sadece bağlantı kurmak için.
- **Cila/Bitim** sonrası kargo/teslim alanı PDF'te yok; spec'te `status: sent → delivered` ile temsil edildi. Kargo takip eklenirse `jobs.shipmentTracking?` opsiyonel field eklenir.
- **Finans entegrasyonu** PDF'te "Finans (Bekliyor)" başlığı dışında detay vermiyor. Spec'te `finance_entries` (income/expense/receivable/payable) ile başlattık; faturalama, ödeme provider entegrasyonu sonraki faz.
- **PDF'in "Bağlantı Kodu" tek bir kalıcı kod** olarak modellendi (`memberNumber`). Her connection için ayrı OTP/kod istenirse `connection_codes` collection eklenir.
## Yapılacaklar (oturum kapanışı sonrası)
- [ ] Appwrite MCP ile `lab` database + collection'lar (bu commit'te schema TS hazır, fiziksel oluşturma bir sonraki adım)
- [ ] `jobs` modülü (liste + form + detay)
- [ ] `connections` modülü (request/approve akışı)
- [ ] `products` modülü (lab katalog CRUD)
- [ ] `finance` modülü (cross-tenant idempotent sync helper)
- [ ] Coolify app + DNS `lab.kovakcrm.com`
- [ ] Landing page DLS'e uyarla
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+16
View File
@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;
+62
View File
@@ -0,0 +1,62 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ["lucide-react", "@radix-ui/react-icons"],
serverActions: {
bodySizeLimit: "3mb",
},
},
turbopack: {},
// Image optimization
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'ui.shadcn.com',
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
],
formats: ['image/webp', 'image/avif'],
},
// Headers for better security and performance
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'origin-when-cross-origin',
},
],
},
];
},
// Redirects for better SEO
async redirects() {
return [
{
source: '/home',
destination: '/dashboard',
permanent: true,
},
];
},
};
export default nextConfig;
+77
View File
@@ -0,0 +1,77 @@
{
"name": "lab",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-table": "^8.21.3",
"appwrite": "^24.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"next-themes": "^0.4.6",
"node-appwrite": "^23.1.0",
"postcss": "^8.5.6",
"react": "19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"react-hook-form": "^7.69.0",
"react-resizable-panels": "^3.0.4",
"recharts": "3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.2",
"zustand": "^5.0.9"
},
"pnpm": {
"onlyBuiltDependencies": ["sharp", "unrs-resolver"]
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3"
}
}
+6099
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
sharp: true
unrs-resolver: true
+6
View File
@@ -0,0 +1,6 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 842 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function ForbiddenError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>403</h1>
<h2 className="mb-3 text-2xl font-semibold">Forbidden</h2>
<p>Access to this resource is forbidden. You don&apos;t have the necessary permissions to view this page.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { ForbiddenError } from "./components/forbidden-error"
export default function ForbiddenPage() {
return <ForbiddenError />
}
@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function InternalServerError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>500</h1>
<h2 className="mb-3 text-2xl font-semibold">Internal Server Error</h2>
<p>Something went wrong on our end. We&apos;re working to fix the issue. Please try again later.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
import { InternalServerError } from "./components/internal-server-error"
export default function InternalServerErrorPage() {
return <InternalServerError />
}
@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function NotFoundError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>404</h1>
<h2 className="mb-3 text-2xl font-semibold">Page Not Found</h2>
<p>The page you are looking for doesn&apos;t exist or has been moved to another location.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { NotFoundError } from "./components/not-found-error"
export default function NotFoundPage() {
return <NotFoundError />
}
@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function UnauthorizedError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>401</h1>
<h2 className="mb-3 text-2xl font-semibold">Unauthorized</h2>
<p>You don&apos;t have permission to access this resource. Please sign in or contact your administrator.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
import { UnauthorizedError } from "./components/unauthorized-error"
export default function UnauthorizedPage() {
return <UnauthorizedError />
}
@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function UnderMaintenanceError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>503</h1>
<h2 className="mb-3 text-2xl font-semibold">Under Maintenance</h2>
<p>The service is currently unavailable. Please try again later.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
import { UnderMaintenanceError } from "./components/under-maintenance-error"
export default function UnderMaintenancePage() {
return <UnderMaintenanceError />
}
@@ -0,0 +1,37 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function ForgotPasswordForm2({
className,
...props
}: React.ComponentProps<"form">) {
return (
<form className={cn("flex flex-col gap-6", className)} {...props}>
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">Forgot your password?</h1>
<p className="text-muted-foreground text-sm text-balance">
Enter your email address and we&apos;ll send you a link to reset your password
</p>
</div>
<div className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="m@example.com" required />
</div>
<Button type="submit" className="w-full cursor-pointer">
Send Reset Link
</Button>
</div>
<div className="text-center text-sm">
Remember your password?{" "}
<a href="/auth/sign-in-2" className="underline underline-offset-4">
Back to sign in
</a>
</div>
</form>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { ForgotPasswordForm2 } from "./components/forgot-password-form-2"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export default function ForgotPassword2Page() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-center gap-2 md:justify-start">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
ShadcnStore
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md">
<ForgotPasswordForm2 />
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</div>
)
}
@@ -0,0 +1,72 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export function ForgotPasswordForm3({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8">
<div className="flex flex-col gap-6">
<div className="flex justify-center mb-2">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
<span className="text-xl">ShadcnStore</span>
</Link>
</div>
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold">Forgot your password?</h1>
<p className="text-muted-foreground text-balance">
Enter your email to reset your ShadcnStore account password
</p>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
/>
</div>
<Button type="submit" className="w-full cursor-pointer">
Send Reset Link
</Button>
<div className="text-center text-sm">
Remember your password?{" "}
<a href="/auth/sign-in-3" className="underline underline-offset-4">
Back to sign in
</a>
</div>
</div>
</form>
<div className="bg-muted relative hidden md:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
and <a href="#">Privacy Policy</a>.
</div>
</div>
)
}
@@ -0,0 +1,9 @@
import { ForgotPasswordForm3 } from "./components/forgot-password-form-3"
export default function ForgotPassword3Page() {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<ForgotPasswordForm3 className="w-full max-w-sm md:max-w-4xl" />
</div>
)
}
@@ -0,0 +1,88 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { ArrowLeft, Loader2, MailCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { requestPasswordResetAction } from "@/lib/appwrite/password-reset-actions";
import { initialAuthState } from "@/lib/appwrite/auth-types";
export function ForgotPasswordForm1({ className, ...props }: React.ComponentProps<"div">) {
const [state, formAction, isPending] = useActionState(requestPasswordResetAction, initialAuthState);
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Şifremi unuttum</CardTitle>
<CardDescription>
Email adresinizi girin, sıfırlama bağlantısı gönderelim.
</CardDescription>
</CardHeader>
<CardContent>
{state.ok ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<div className="bg-primary/10 text-primary flex size-12 items-center justify-center rounded-full">
<MailCheck className="size-6" />
</div>
<p className="text-sm">
Sıfırlama kodunuz e-posta adresinize gönderildi. Kodu girerek şifrenizi yenileyebilirsiniz.
</p>
<Link
href="/sign-in"
className="text-muted-foreground hover:text-foreground mt-2 flex items-center gap-1 text-sm underline-offset-4 hover:underline"
>
<ArrowLeft className="size-3.5" />
Giriş sayfasına dön
</Link>
</div>
) : (
<form action={formAction} className="flex flex-col gap-4">
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="ornek@firma.com"
autoComplete="email"
required
/>
</div>
{state.error && (
<p className="text-destructive text-sm text-center" role="alert">
{state.error}
</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Gönderiliyor...
</>
) : (
"Sıfırlama bağlantısı gönder"
)}
</Button>
<Link
href="/sign-in"
className="text-muted-foreground hover:text-foreground flex items-center justify-center gap-1 text-sm underline-offset-4 hover:underline"
>
<ArrowLeft className="size-3.5" />
Giriş sayfasına dön
</Link>
</form>
)}
</CardContent>
</Card>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
import { ForgotPasswordForm1 } from "./components/forgot-password-form-1";
import { Logo } from "@/components/logo";
export default function ForgotPasswordPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<Link href="/" className="flex items-center gap-2 self-center font-medium">
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
<Logo size={24} />
</div>
<span className="text-lg font-semibold">DLS</span>
</Link>
<ForgotPasswordForm1 />
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "DLS — Giriş",
description: "DLS hesabınıza giriş yapın veya yeni hesap oluşturun.",
};
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return <div className="min-h-screen bg-background">{children}</div>;
}
@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { ArrowLeft, Loader2, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { resetPasswordAction } from "@/lib/appwrite/password-reset-actions";
import { initialAuthState } from "@/lib/appwrite/auth-types";
interface Props extends React.ComponentProps<"div"> {
token: string;
}
export function ResetPasswordForm({ token, className, ...props }: Props) {
const [state, formAction, isPending] = useActionState(resetPasswordAction, initialAuthState);
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<div className="bg-primary/10 text-primary mx-auto mb-2 flex size-12 items-center justify-center rounded-full">
<ShieldCheck className="size-6" />
</div>
<CardTitle className="text-xl">Yeni şifre belirle</CardTitle>
<CardDescription>
Kod doğrulandı. Yeni şifrenizi girin.
</CardDescription>
</CardHeader>
<CardContent>
<form action={formAction} className="flex flex-col gap-4">
<input type="hidden" name="token" value={token} />
<div className="grid gap-3">
<Label htmlFor="password">Yeni şifre</Label>
<Input
id="password"
name="password"
type="password"
placeholder="En az 8 karakter"
autoComplete="new-password"
required
minLength={8}
autoFocus
/>
</div>
<div className="grid gap-3">
<Label htmlFor="confirmPassword">Şifre tekrar</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
placeholder="Şifreyi tekrar girin"
autoComplete="new-password"
required
/>
</div>
{state.error && (
<p className="text-destructive text-center text-sm" role="alert">
{state.error}
</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Güncelleniyor...
</>
) : (
"Şifreyi güncelle"
)}
</Button>
<Link
href="/sign-in"
className="text-muted-foreground hover:text-foreground flex items-center justify-center gap-1 text-sm underline-offset-4 hover:underline"
>
<ArrowLeft className="size-3.5" />
Giriş sayfasına dön
</Link>
</form>
</CardContent>
</Card>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from "next/link";
import { XCircle } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { verifyResetToken } from "@/lib/appwrite/password-reset-actions";
import { ResetPasswordForm } from "./components/reset-password-form";
interface Props {
searchParams: Promise<{ token?: string }>;
}
export default async function ResetPasswordPage({ searchParams }: Props) {
const { token } = await searchParams;
if (!token) {
return <InvalidToken message="Geçersiz bağlantı. Yeni bir sıfırlama kodu talep edin." />;
}
const { valid } = await verifyResetToken(token);
if (!valid) {
return <InvalidToken message="Bu kod geçersiz veya süresi dolmuş. Yeni bir sıfırlama kodu talep edin." />;
}
return (
<div className="flex min-h-svh items-center justify-center p-6">
<div className="w-full max-w-sm">
<ResetPasswordForm token={token} />
</div>
</div>
);
}
function InvalidToken({ message }: { message: string }) {
return (
<div className="flex min-h-svh items-center justify-center p-6">
<div className="w-full max-w-sm">
<Card>
<CardHeader className="text-center">
<div className="text-destructive mx-auto mb-2 flex size-12 items-center justify-center rounded-full bg-red-50">
<XCircle className="size-6" />
</div>
<CardTitle className="text-xl">Geçersiz kod</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4 text-center">
<p className="text-muted-foreground text-sm">{message}</p>
<Link
href="/forgot-password"
className="text-primary text-sm underline underline-offset-4"
>
Yeni kod talep et
</Link>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,63 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function LoginForm2({
className,
...props
}: React.ComponentProps<"form">) {
return (
<form className={cn("flex flex-col gap-6", className)} {...props} action="/dashboard">
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">Login to your account</h1>
<p className="text-muted-foreground text-sm text-balance">
Enter your email below to login to your account
</p>
</div>
<div className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="test@example.com" defaultValue="test@example.com" required />
</div>
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password-2"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input id="password" type="password" defaultValue="password" required />
</div>
<Button type="submit" className="w-full cursor-pointer">
Login
</Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-background text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<Button variant="outline" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
fill="currentColor"
/>
</svg>
Login with GitHub
</Button>
</div>
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a href="/auth/sign-up-2" className="underline underline-offset-4">
Sign up
</a>
</div>
</form>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { LoginForm2 } from "./components/login-form-2"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export default function LoginPage() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-center gap-2 md:justify-start">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
ShadcnStore
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md">
<LoginForm2 />
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</div>
)
}
@@ -0,0 +1,119 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export function LoginForm3({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8" action="/dashboard">
<div className="flex flex-col gap-6">
<div className="flex justify-center mb-2">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
<span className="text-xl">ShadcnStore</span>
</Link>
</div>
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold">Welcome back</h1>
<p className="text-muted-foreground text-balance">
Login to your ShadcnStore account
</p>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="test@example.com"
defaultValue="test@example.com"
required
/>
</div>
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password-3"
className="ml-auto text-sm underline-offset-2 hover:underline"
>
Forgot your password?
</a>
</div>
<Input id="password" type="password" defaultValue="password" required />
</div>
<Button type="submit" className="w-full cursor-pointer">
Login
</Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-card text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<div className="grid grid-cols-3 gap-4">
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
<span className="sr-only">Login with Apple</span>
</Button>
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
<span className="sr-only">Login with Google</span>
</Button>
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
fill="currentColor"
/>
</svg>
<span className="sr-only">Login with Meta</span>
</Button>
</div>
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a href="/auth/sign-up-3" className="underline underline-offset-4">
Sign up
</a>
</div>
</div>
</form>
<div className="bg-muted relative hidden md:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
and <a href="#">Privacy Policy</a>.
</div>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { LoginForm3 } from "./components/login-form-3"
export default function LoginPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<LoginForm3 />
</div>
</div>
)
}
@@ -0,0 +1,169 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/logo";
import { cn } from "@/lib/utils";
import { signInAction } from "@/lib/appwrite/auth-actions";
import { initialAuthState } from "@/lib/appwrite/auth-types";
export function LoginForm1({
className,
inviteCode,
...props
}: React.ComponentProps<"div"> & { inviteCode?: string }) {
const [state, formAction, isPending] = useActionState(signInAction, initialAuthState);
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form action={formAction} className="p-6 md:p-10">
{inviteCode && <input type="hidden" name="inviteCode" value={inviteCode} />}
<div className="flex flex-col gap-6">
<div className="flex justify-center">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
<Logo size={22} />
</div>
<span className="text-xl font-semibold">DLS</span>
</Link>
</div>
{inviteCode && (
<p className="text-muted-foreground rounded-md border bg-muted/50 px-3 py-2 text-center text-xs">
Davete katılmak için giriş yapın.
</p>
)}
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold tracking-tight">Tekrar hoş geldiniz</h1>
<p className="text-muted-foreground text-sm text-balance mt-1">
Hesabınıza giriş yaparak işletmenizi yönetmeye devam edin
</p>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="ornek@firma.com"
autoComplete="email"
required
/>
</div>
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Şifre</Label>
<Link
href="/forgot-password"
className="ml-auto text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline"
>
Şifremi unuttum
</Link>
</div>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
</div>
{state.error && (
<p className="text-destructive text-sm text-center" role="alert">
{state.error}
</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Giriş yapılıyor...
</>
) : (
"Giriş yap"
)}
</Button>
<div className="text-center text-sm text-muted-foreground">
Hesabınız yok mu?{" "}
<Link
href="/sign-up"
className="text-foreground font-medium underline-offset-4 hover:underline"
>
Hesap oluştur
</Link>
</div>
</div>
</form>
<BrandPanel />
</CardContent>
</Card>
<p className="text-muted-foreground text-center text-xs text-balance">
Giriş yaparak{" "}
<Link href="#" className="underline-offset-4 hover:underline">
Kullanım Şartları
</Link>{" "}
ve{" "}
<Link href="#" className="underline-offset-4 hover:underline">
Gizlilik Politikası
</Link>
&apos;nı kabul etmiş olursunuz.
</p>
</div>
);
}
function BrandPanel() {
return (
<div className="bg-primary text-primary-foreground relative hidden md:flex md:flex-col md:justify-between overflow-hidden p-10">
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage:
"radial-gradient(circle at 20% 20%, rgba(255,255,255,0.4) 0%, transparent 40%), radial-gradient(circle at 80% 80%, rgba(255,255,255,0.25) 0%, transparent 45%)",
}}
aria-hidden
/>
<div
className="absolute -top-24 -right-24 size-72 rounded-full bg-white/10 blur-3xl"
aria-hidden
/>
<div
className="absolute -bottom-32 -left-20 size-80 rounded-full bg-black/10 blur-3xl"
aria-hidden
/>
<div className="relative z-10 flex items-center gap-2">
<div className="bg-primary-foreground/15 ring-1 ring-primary-foreground/20 backdrop-blur flex size-10 items-center justify-center rounded-md">
<Logo size={22} />
</div>
<span className="text-lg font-medium">DLS</span>
</div>
<div className="relative z-10 flex flex-col gap-3">
<h2 className="text-3xl font-semibold leading-tight">
Müşteriden faturaya, tek panelden işletmenizi yönetin.
</h2>
<p className="text-primary-foreground/80 text-sm">
Müşteriler, hizmetler, takvim, görevler ve finans hepsi tek yerde, multi-tenant ve ekibinize özel.
</p>
<div className="text-primary-foreground/70 mt-4 text-xs">Kovak Yazılım tarafından</div>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { redirect } from "next/navigation";
import { LoginForm1 } from "./components/login-form-1";
import { getCurrentUser } from "@/lib/appwrite/server";
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ invite?: string; reset?: string }>;
}) {
const { invite, reset } = await searchParams;
const user = await getCurrentUser();
if (user) redirect(invite ? `/d/${invite}` : "/dashboard");
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
{reset === "success" && (
<p className="mb-4 rounded-lg bg-green-50 px-4 py-3 text-center text-sm text-green-700">
Şifreniz güncellendi. Giriş yapabilirsiniz.
</p>
)}
<LoginForm1 inviteCode={invite} />
</div>
</div>
);
}
@@ -0,0 +1,83 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
export function SignupForm2({
className,
...props
}: React.ComponentProps<"form">) {
return (
<form className={cn("flex flex-col gap-6", className)} {...props}>
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">Create your account</h1>
<p className="text-muted-foreground text-sm text-balance">
Enter your information to create a new account
</p>
</div>
<div className="grid gap-6">
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-3">
<Label htmlFor="firstName">First Name</Label>
<Input id="firstName" placeholder="John" required />
</div>
<div className="grid gap-3">
<Label htmlFor="lastName">Last Name</Label>
<Input id="lastName" placeholder="Doe" required />
</div>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="m@example.com" required />
</div>
<div className="grid gap-3">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required />
</div>
<div className="grid gap-3">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input id="confirmPassword" type="password" required />
</div>
<div className="flex items-center space-x-2">
<Checkbox id="terms" required />
<Label htmlFor="terms" className="text-sm">
I agree to the{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary">
Terms of Service
</a>{" "}
and{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</a>
</Label>
</div>
<Button type="submit" className="w-full cursor-pointer">
Create Account
</Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-background text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<Button variant="outline" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
fill="currentColor"
/>
</svg>
Sign up with GitHub
</Button>
</div>
<div className="text-center text-sm">
Already have an account?{" "}
<a href="/auth/sign-in-2" className="underline underline-offset-4">
Sign in
</a>
</div>
</form>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { SignupForm2 } from "./components/signup-form-2"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export default function SignUp2Page() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-center gap-2 md:justify-start">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
ShadcnStore
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md">
<SignupForm2 />
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</div>
)
}
@@ -0,0 +1,146 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export function SignupForm3({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8">
<div className="flex flex-col gap-6">
<div className="flex justify-center mb-2">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
<span className="text-xl">ShadcnStore</span>
</Link>
</div>
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold">Create your account</h1>
<p className="text-muted-foreground text-balance">
Enter your information to create a new account
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-3">
<Label htmlFor="firstName">First Name</Label>
<Input
id="firstName"
placeholder="John"
required
/>
</div>
<div className="grid gap-3">
<Label htmlFor="lastName">Last Name</Label>
<Input
id="lastName"
placeholder="Doe"
required
/>
</div>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
/>
</div>
<div className="grid gap-3">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required />
</div>
<div className="grid gap-3">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input id="confirmPassword" type="password" required />
</div>
<div className="flex items-center space-x-2">
<Checkbox id="terms" required />
<Label htmlFor="terms" className="text-sm">
I agree to the{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary">
Terms of Service
</a>{" "}
and{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</a>
</Label>
</div>
<Button type="submit" className="w-full cursor-pointer">
Create Account
</Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-card text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<div className="grid grid-cols-3 gap-4">
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
<span className="sr-only">Sign up with Apple</span>
</Button>
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
<span className="sr-only">Sign up with Google</span>
</Button>
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
fill="currentColor"
/>
</svg>
<span className="sr-only">Sign up with Meta</span>
</Button>
</div>
<div className="text-center text-sm">
Already have an account?{" "}
<a href="/auth/sign-in-3" className="underline underline-offset-4">
Sign in
</a>
</div>
</div>
</form>
<div className="bg-muted relative hidden md:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
and <a href="#">Privacy Policy</a>.
</div>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { SignupForm3 } from "./components/signup-form-3"
export default function SignUp3Page() {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<SignupForm3 className="w-full max-w-5xl" />
</div>
)
}
@@ -0,0 +1,181 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/logo";
import { cn } from "@/lib/utils";
import { signUpAction } from "@/lib/appwrite/auth-actions";
import { initialAuthState } from "@/lib/appwrite/auth-types";
export function SignupForm1({
className,
inviteCode,
prefilledEmail,
...props
}: React.ComponentProps<"div"> & { inviteCode?: string; prefilledEmail?: string }) {
const [state, formAction, isPending] = useActionState(signUpAction, initialAuthState);
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<BrandPanel />
<form action={formAction} className="p-6 md:p-10">
{inviteCode && <input type="hidden" name="inviteCode" value={inviteCode} />}
<div className="flex flex-col gap-6">
<div className="flex justify-center">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
<Logo size={22} />
</div>
<span className="text-xl font-semibold">DLS</span>
</Link>
</div>
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold tracking-tight">
{inviteCode ? "Davete katıl" : "Hesap oluşturun"}
</h1>
<p className="text-muted-foreground text-sm text-balance mt-1">
{inviteCode
? "Hesap oluşturduktan sonra çalışma alanına otomatik katılacaksınız"
: "Birkaç saniye içinde hesabınız hazır"}
</p>
</div>
<div className="grid gap-3">
<Label htmlFor="name">Adınız Soyadınız</Label>
<Input
id="name"
name="name"
type="text"
placeholder="Ahmet Yılmaz"
autoComplete="name"
required
/>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="ornek@firma.com"
autoComplete="email"
defaultValue={prefilledEmail}
readOnly={Boolean(prefilledEmail)}
required
/>
</div>
<div className="grid gap-3">
<Label htmlFor="password">Şifre</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
minLength={8}
required
/>
<p className="text-muted-foreground text-xs">En az 8 karakter</p>
</div>
{state.error && (
<p className="text-destructive text-sm text-center" role="alert">
{state.error}
</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Hesap oluşturuluyor...
</>
) : (
"Hesap oluştur"
)}
</Button>
<div className="text-center text-sm text-muted-foreground">
Zaten hesabınız var mı?{" "}
<Link
href="/sign-in"
className="text-foreground font-medium underline-offset-4 hover:underline"
>
Giriş yap
</Link>
</div>
</div>
</form>
</CardContent>
</Card>
<p className="text-muted-foreground text-center text-xs text-balance">
Hesap oluşturarak{" "}
<Link href="#" className="underline-offset-4 hover:underline">
Kullanım Şartları
</Link>{" "}
ve{" "}
<Link href="#" className="underline-offset-4 hover:underline">
Gizlilik Politikası
</Link>
&apos;nı kabul etmiş olursunuz.
</p>
</div>
);
}
function BrandPanel() {
return (
<div className="bg-primary text-primary-foreground relative hidden md:flex md:flex-col md:justify-between overflow-hidden p-10">
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage:
"radial-gradient(circle at 20% 20%, rgba(255,255,255,0.4) 0%, transparent 40%), radial-gradient(circle at 80% 80%, rgba(255,255,255,0.25) 0%, transparent 45%)",
}}
aria-hidden
/>
<div
className="absolute -top-24 -left-24 size-72 rounded-full bg-white/10 blur-3xl"
aria-hidden
/>
<div
className="absolute -bottom-32 -right-20 size-80 rounded-full bg-black/10 blur-3xl"
aria-hidden
/>
<div className="relative z-10 flex items-center gap-2">
<div className="bg-primary-foreground/15 ring-1 ring-primary-foreground/20 backdrop-blur flex size-10 items-center justify-center rounded-md">
<Logo size={22} />
</div>
<span className="text-lg font-medium">DLS</span>
</div>
<div className="relative z-10 flex flex-col gap-3">
<h2 className="text-3xl font-semibold leading-tight">
İşletmenizi büyütecek tek araç.
</h2>
<p className="text-primary-foreground/80 text-sm">
Hesap oluşturduktan sonra çalışma alanınızı kuruyor, ekibinizi davet ediyor ve hemen kullanmaya başlıyorsunuz.
</p>
<ul className="text-primary-foreground/85 mt-2 space-y-1 text-sm">
<li> Müşteri & hizmet yönetimi</li>
<li> Görev ve takvim</li>
<li> Finans ve fatura</li>
</ul>
<div className="text-primary-foreground/70 mt-4 text-xs">Kovak Yazılım tarafından</div>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { SignupForm1 } from "./components/signup-form-1";
import { getCurrentUser } from "@/lib/appwrite/server";
export default async function SignUpPage({
searchParams,
}: {
searchParams: Promise<{ invite?: string; email?: string }>;
}) {
const { invite, email } = await searchParams;
const user = await getCurrentUser();
if (user) redirect(invite ? `/d/${invite}` : "/dashboard");
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<SignupForm1 inviteCode={invite} prefilledEmail={email} />
</div>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
export default async function ConnectionsPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Bağlantı Kur</h1>
<p className="text-muted-foreground text-sm">
Klinik ve laboratuvar arasında bağlantı taleplerini yönetin.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Bağlantı kodunuz</CardTitle>
<CardDescription>Karşı taraf bu kodu girerek size bağlantı talebi gönderir.</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-muted/40 rounded-md border px-4 py-3 font-mono text-lg tracking-widest">
{ctx.settings?.memberNumber ?? "—"}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Bağlantı talepleri ve bağlı taraflar listesi sonraki sürümde eklenecek.</CardDescription>
</CardHeader>
<CardContent />
</Card>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import React from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
import { ThemeCustomizer, ThemeCustomizerTrigger } from "@/components/theme-customizer";
import { PrefsInitializer } from "@/components/theme-customizer/prefs-initializer";
import { useSidebarConfig } from "@/hooks/use-sidebar-config";
import type { UserPrefs as ThemePrefs } from "@/lib/appwrite/user-prefs-actions";
export type ShellUser = {
id: string;
name: string;
email: string;
};
export type TenantKind = "lab" | "clinic";
export type ShellCompany = {
id: string;
name: string;
logoUrl?: string | null;
kind: TenantKind;
};
export function DashboardShell({
user,
company,
children,
initialPrefs,
}: {
user: ShellUser;
company: ShellCompany;
children: React.ReactNode;
initialPrefs: ThemePrefs;
}) {
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
const { config } = useSidebarConfig();
return (
<SidebarProvider
style={
{
"--sidebar-width": "16rem",
"--sidebar-width-icon": "3rem",
"--header-height": "calc(var(--spacing) * 14)",
} as React.CSSProperties
}
className={config.collapsible === "none" ? "sidebar-none-mode" : ""}
>
<PrefsInitializer prefs={initialPrefs} />
{config.side === "left" ? (
<>
<AppSidebar
user={user}
company={company}
variant={config.variant}
collapsible={config.collapsible}
side={config.side}
/>
<SidebarInset>
<SiteHeader company={company} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">{children}</div>
</div>
</div>
<SiteFooter />
</SidebarInset>
</>
) : (
<>
<SidebarInset>
<SiteHeader company={company} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">{children}</div>
</div>
</div>
<SiteFooter />
</SidebarInset>
<AppSidebar
user={user}
company={company}
variant={config.variant}
collapsible={config.collapsible}
side={config.side}
/>
</>
)}
<ThemeCustomizerTrigger onClick={() => setThemeCustomizerOpen(true)} />
<ThemeCustomizer
open={themeCustomizerOpen}
onOpenChange={setThemeCustomizerOpen}
initialPrefs={initialPrefs}
/>
</SidebarProvider>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { getActiveContext } from "@/lib/appwrite/active-context";
export default async function DashboardPage() {
const ctx = await getActiveContext();
if (!ctx) redirect("/onboarding");
const firstName = ctx.user.name?.split(" ")[0] ?? "";
const companyName = ctx.settings?.companyName ?? "Çalışma alanı";
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{companyName}</p>
<h1 className="text-2xl font-bold tracking-tight">
{firstName ? `Hoş geldiniz, ${firstName}` : "Anasayfa"}
</h1>
<p className="text-muted-foreground text-sm">
Açık işleri, bildirimleri ve istatistikleri buradan takip edin.
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Açık işler</CardTitle>
<CardDescription>Gelen ve giden özetleri burada listelenecek.</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Modül yapım aşamasında.
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>İşlem bekleyen</CardTitle>
<CardDescription>Onay/işlem bekleyen kalemler.</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Modül yapım aşamasında.
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Bildirimler</CardTitle>
<CardDescription>Bağlantılarınızdan gelen son bildirimler.</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Modül yapım aşamasında.
</CardContent>
</Card>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function FinancePage() {
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Finans</h1>
<p className="text-muted-foreground text-sm">
Gelen ödemeler, ödenen hesaplar ve bekleyen tahsilatlar.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Finans hareketleri, durum takibi ve raporlar sonraki sürümde.</CardDescription>
</CardHeader>
<CardContent />
</Card>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function InboundJobsPage() {
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Gelen İşler</h1>
<p className="text-muted-foreground text-sm">
Bağlı kliniklerden gelen protez işleri burada listelenecek.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Gelen listesi, filtreleme ve detay görünümü sonraki sürümde eklenecek.</CardDescription>
</CardHeader>
<CardContent />
</Card>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
export default async function NewJobPage() {
let ctx;
try {
ctx = await requireTenant();
requireTenantKind(ctx, ["clinic"]);
} catch {
redirect("/dashboard");
}
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Yeni İş Yayınla</h1>
<p className="text-muted-foreground text-sm">
Bağlı laboratuvarınıza yeni bir protez işi gönderin.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>
Form (lab seçimi, hasta kodu, protez türü, renk, dosya yükleme) sonraki sürümde eklenecek.
</CardDescription>
</CardHeader>
<CardContent />
</Card>
</div>
);
}
@@ -0,0 +1,21 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function OutboundJobsPage() {
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Giden İşler</h1>
<p className="text-muted-foreground text-sm">
Karşı tarafa gönderilen protez işleri burada listelenecek.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Giden listesi sonraki sürümde eklenecek.</CardDescription>
</CardHeader>
<CardContent />
</Card>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { getActiveContext } from "@/lib/appwrite/active-context";
import { getLogoUrl } from "@/lib/appwrite/storage";
import { getUserPrefs } from "@/lib/appwrite/user-prefs-actions";
import type { UserPrefs as ThemePrefs } from "@/lib/appwrite/user-prefs-actions";
import { DashboardShell } from "./dashboard-shell";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const ctx = await getActiveContext();
if (!ctx) redirect("/onboarding");
const themePrefs: ThemePrefs = await getUserPrefs();
const company = {
id: ctx.tenantId,
name: ctx.settings?.companyName ?? "Çalışma alanı",
logoUrl: getLogoUrl(ctx.settings?.logo) ?? null,
kind: (ctx.settings?.kind ?? "lab") as "lab" | "clinic",
};
const user = {
id: ctx.user.id,
name: ctx.user.name || ctx.user.email,
email: ctx.user.email,
};
return (
<DashboardShell user={user} company={company} initialPrefs={themePrefs}>
{children}
</DashboardShell>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
export default async function ProductsPage() {
let ctx;
try {
ctx = await requireTenant();
requireTenantKind(ctx, ["lab"]);
} catch {
redirect("/dashboard");
}
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Ürünler</h1>
<p className="text-muted-foreground text-sm">
Sunduğunuz protez türleri ve fiyatlandırma katalogu.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Ürün ekleme/düzenleme sonraki sürümde eklenecek.</CardDescription>
</CardHeader>
<CardContent />
</Card>
</div>
);
}
@@ -0,0 +1,83 @@
"use client";
import { useActionState, useEffect, useRef } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateEmailAction } from "@/lib/appwrite/profile-actions";
import { initialProfileState } from "@/lib/appwrite/profile-types";
export function EmailForm({ currentEmail }: { currentEmail: string }) {
const [state, formAction, isPending] = useActionState(updateEmailAction, initialProfileState);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.ok) {
toast.success("Email güncellendi.");
// Clear password field after success
formRef.current?.reset();
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
return (
<Card>
<CardHeader>
<CardTitle>Email adresi</CardTitle>
<CardDescription>
Email değiştirmek için mevcut şifrenizi de girin. Yeni email ile giriş yapmaya devam edersiniz.
</CardDescription>
</CardHeader>
<CardContent>
<form ref={formRef} action={formAction} className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">Yeni email</Label>
<Input
id="email"
name="email"
type="email"
defaultValue={currentEmail}
required
/>
{state.fieldErrors?.email && (
<p className="text-destructive text-xs">{state.fieldErrors.email}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="email-password">Şifre (doğrulama)</Label>
<Input
id="email-password"
name="password"
type="password"
autoComplete="current-password"
required
/>
{state.fieldErrors?.password && (
<p className="text-destructive text-xs">{state.fieldErrors.password}</p>
)}
</div>
<div className="md:col-span-2 flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Güncelleniyor...
</>
) : (
<>
<Save className="size-4" />
Email'i güncelle
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,56 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateNameAction } from "@/lib/appwrite/profile-actions";
import { initialProfileState } from "@/lib/appwrite/profile-types";
export function NameForm({ currentName }: { currentName: string }) {
const [state, formAction, isPending] = useActionState(updateNameAction, initialProfileState);
useEffect(() => {
if (state.ok) toast.success("İsim güncellendi.");
else if (state.error) toast.error(state.error);
}, [state]);
return (
<Card>
<CardHeader>
<CardTitle>Görünür isim</CardTitle>
<CardDescription>
Header'da, davetlerde ve takım listesinde görünecek isim.
</CardDescription>
</CardHeader>
<CardContent>
<form action={formAction} className="grid gap-4 md:grid-cols-[1fr_auto] md:items-end">
<div className="grid gap-2">
<Label htmlFor="name">İsim</Label>
<Input id="name" name="name" defaultValue={currentName} required maxLength={128} />
{state.fieldErrors?.name && (
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
)}
</div>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
Kaydet
</>
)}
</Button>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,100 @@
"use client";
import { useActionState, useEffect, useRef } from "react";
import { KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updatePasswordAction } from "@/lib/appwrite/profile-actions";
import { initialProfileState } from "@/lib/appwrite/profile-types";
export function PasswordForm() {
const [state, formAction, isPending] = useActionState(
updatePasswordAction,
initialProfileState,
);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.ok) {
toast.success("Şifre değiştirildi.");
formRef.current?.reset();
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
return (
<Card>
<CardHeader>
<CardTitle>Şifre</CardTitle>
<CardDescription>
Şifrenizi değiştirmek için mevcut şifrenizi ve yeni şifreyi iki kez girin.
</CardDescription>
</CardHeader>
<CardContent>
<form ref={formRef} action={formAction} className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="oldPassword">Mevcut şifre</Label>
<Input
id="oldPassword"
name="oldPassword"
type="password"
autoComplete="current-password"
required
/>
{state.fieldErrors?.oldPassword && (
<p className="text-destructive text-xs">{state.fieldErrors.oldPassword}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="newPassword">Yeni şifre</Label>
<Input
id="newPassword"
name="newPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
/>
{state.fieldErrors?.newPassword && (
<p className="text-destructive text-xs">{state.fieldErrors.newPassword}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="confirmPassword">Yeni şifre (tekrar)</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
/>
{state.fieldErrors?.confirmPassword && (
<p className="text-destructive text-xs">{state.fieldErrors.confirmPassword}</p>
)}
</div>
<div className="md:col-span-3 flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Güncelleniyor...
</>
) : (
<>
<KeyRound className="size-4" />
Şifreyi değiştir
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/appwrite/server";
import { formatDateTime } from "@/lib/format";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { NameForm } from "./components/name-form";
import { EmailForm } from "./components/email-form";
import { PasswordForm } from "./components/password-form";
export const metadata: Metadata = {
title: "DLS — Profil",
};
export default async function AccountSettingsPage() {
const user = await getCurrentUser();
if (!user) redirect("/sign-in");
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">Profil ayarları</p>
<h1 className="text-2xl font-bold tracking-tight">{user.name || user.email}</h1>
<p className="text-muted-foreground text-sm">
Hesap bilgilerinizi ve şifrenizi buradan yönetin.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Hesap bilgileri</CardTitle>
<CardDescription>Kayıt tarihi ve hesap durumu</CardDescription>
</CardHeader>
<CardContent>
<dl className="grid gap-4 text-sm md:grid-cols-2">
<div>
<dt className="text-muted-foreground text-xs uppercase">Hesap ID</dt>
<dd className="mt-1 font-mono text-xs">{user.$id}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs uppercase">Kayıt tarihi</dt>
<dd className="mt-1">{formatDateTime(user.registration)}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs uppercase">Email doğrulanmış</dt>
<dd className="mt-1">{user.emailVerification ? "Evet" : "Hayır"}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs uppercase">İki faktör (2FA)</dt>
<dd className="mt-1">{user.mfa ? "Açık" : "Kapalı"}</dd>
</div>
</dl>
</CardContent>
</Card>
<NameForm currentName={user.name || ""} />
<EmailForm currentEmail={user.email} />
<PasswordForm />
</div>
);
}
@@ -0,0 +1,229 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const appearanceFormSchema = z.object({
theme: z.enum(["light", "dark"]),
fontFamily: z.string().optional(),
fontSize: z.string().optional(),
sidebarWidth: z.string().optional(),
contentWidth: z.string().optional(),
})
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>
export default function AppearanceSettings() {
const form = useForm<AppearanceFormValues>({
resolver: zodResolver(appearanceFormSchema),
defaultValues: {
theme: "dark",
fontFamily: "",
fontSize: "",
sidebarWidth: "",
contentWidth: "",
},
})
function onSubmit(data: AppearanceFormValues) {
console.log("Form submitted:", data)
// Here you would typically save the data
}
return (
<div className="space-y-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Appearance</h1>
<p className="text-muted-foreground">
Customize the appearance of the application.
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Theme Section */}
<h3 className="text-lg font-medium mb-2">Theme</h3>
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<FormItem className="space-y-3">
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4"
>
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary cursor-pointer">
<FormControl>
<RadioGroupItem value="light" className="sr-only" />
</FormControl>
<div className="rounded-md border-2 border-muted p-4 hover:border-accent transition-colors">
<div className="space-y-2">
<div className="w-20 h-20 bg-white border rounded-md p-3">
<div className="space-y-2">
<div className="h-2 bg-gray-200 rounded w-3/4"></div>
<div className="h-2 bg-gray-200 rounded w-1/2"></div>
<div className="flex space-x-2">
<div className="h-2 w-2 bg-gray-300 rounded-full"></div>
<div className="h-2 bg-gray-200 rounded flex-1"></div>
</div>
<div className="flex space-x-2">
<div className="h-2 w-2 bg-gray-300 rounded-full"></div>
<div className="h-2 bg-gray-200 rounded flex-1"></div>
</div>
</div>
</div>
<span className="text-sm font-medium">Light</span>
</div>
</div>
</FormLabel>
</FormItem>
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary cursor-pointer">
<FormControl>
<RadioGroupItem value="dark" className="sr-only" />
</FormControl>
<div className="rounded-md border-2 border-muted p-4 hover:border-accent transition-colors">
<div className="space-y-2">
<div className="w-20 h-20 bg-gray-900 border border-gray-700 rounded-md p-3">
<div className="space-y-2">
<div className="h-2 bg-gray-600 rounded w-3/4"></div>
<div className="h-2 bg-gray-600 rounded w-1/2"></div>
<div className="flex space-x-2">
<div className="h-2 w-2 bg-gray-500 rounded-full"></div>
<div className="h-2 bg-gray-600 rounded flex-1"></div>
</div>
<div className="flex space-x-2">
<div className="h-2 w-2 bg-gray-500 rounded-full"></div>
<div className="h-2 bg-gray-600 rounded flex-1"></div>
</div>
</div>
</div>
<span className="text-sm font-medium">Dark</span>
</div>
</div>
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fontFamily"
render={({ field }) => (
<FormItem>
<FormLabel>Font Family</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select a font" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="inter">Inter</SelectItem>
<SelectItem value="roboto">Roboto</SelectItem>
<SelectItem value="system">System Default</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fontSize"
render={({ field }) => (
<FormItem>
<FormLabel>Font Size</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select font size" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="small">Small</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="large">Large</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Layout Section */}
<FormField
control={form.control}
name="sidebarWidth"
render={({ field }) => (
<FormItem>
<FormLabel>Sidebar Width</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select sidebar width" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="compact">Compact</SelectItem>
<SelectItem value="comfortable">Comfortable</SelectItem>
<SelectItem value="spacious">Spacious</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contentWidth"
render={({ field }) => (
<FormItem>
<FormLabel>Content Width</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select content width" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fixed">Fixed</SelectItem>
<SelectItem value="fluid">Fluid</SelectItem>
<SelectItem value="container">Container</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex space-x-2 mt-12">
<Button type="submit" className="cursor-pointer">
Save Preferences
</Button>
<Button variant="outline" type="button" className="cursor-pointer">Cancel</Button>
</div>
</form>
</Form>
</div>
)
}
@@ -0,0 +1,283 @@
"use client"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Github, Slack, Twitter, Zap, Globe, Database, Apple, Chrome, Facebook, Instagram, Dribbble } from "lucide-react"
import { useState } from "react"
export default function ConnectionSettings() {
// Controlled state for switches
const [appleConnected, setAppleConnected] = useState(true)
const [googleConnected, setGoogleConnected] = useState(false)
const [githubConnected, setGithubConnected] = useState(true)
const [slackConnected, setSlackConnected] = useState(false)
const [zapierConnected, setZapierConnected] = useState(true)
const [webhooksConnected, setWebhooksConnected] = useState(false)
const [dbConnected, setDbConnected] = useState(true)
return (
<div className="space-y-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Connections</h1>
<p className="text-muted-foreground">
Connect your account with third-party services and integrations.
</p>
</div>
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Connected Accounts</CardTitle>
<CardDescription>
Display content from your connected accounts on your site
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Apple className="h-8 w-8" />
<div>
<div className="font-medium">Apple</div>
<div className="text-sm text-muted-foreground">Calendar and contacts</div>
</div>
</div>
<Switch
className="cursor-pointer"
checked={appleConnected}
onCheckedChange={setAppleConnected}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Chrome className="h-8 w-8" />
<div>
<div className="font-medium">Google</div>
<div className="text-sm text-muted-foreground">Calendar and contacts</div>
</div>
</div>
<Switch
className="cursor-pointer"
checked={googleConnected}
onCheckedChange={setGoogleConnected}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Github className="h-8 w-8" />
<div>
<div className="font-medium">Github</div>
<div className="text-sm text-muted-foreground">Manage your Git repositories</div>
</div>
</div>
<Switch
className="cursor-pointer"
checked={githubConnected}
onCheckedChange={setGithubConnected}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Slack className="h-8 w-8" />
<div>
<div className="font-medium">Slack</div>
<div className="text-sm text-muted-foreground">Communication</div>
</div>
</div>
<Switch
className="cursor-pointer"
checked={slackConnected}
onCheckedChange={setSlackConnected}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Social Accounts</CardTitle>
<CardDescription>
Display content from your connected accounts on your site
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Facebook className="h-8 w-8" />
<div>
<div className="font-medium">
Facebook
<Badge variant="outline" className="ml-2">Not Connected</Badge>
</div>
<div className="text-sm text-muted-foreground">Share updates on Facebook</div>
</div>
</div>
<Button variant="outline" size="icon" className="cursor-pointer">
<Globe className="h-4 w-4" />
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Twitter className="h-8 w-8" />
<div>
<div className="font-medium">
Twitter
<Badge variant="secondary" className="ml-2">connected</Badge>
</div>
<div className="text-sm text-muted-foreground">Share updates on Twitter</div>
</div>
</div>
<Button variant="outline" size="icon" className="cursor-pointer text-destructive">
<Globe className="h-4 w-4" />
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Instagram className="h-8 w-8" />
<div>
<div className="font-medium">
Instagram
<Badge variant="secondary" className="ml-2">connected</Badge>
</div>
<div className="text-sm text-muted-foreground">Stay connected at Instagram</div>
</div>
</div>
<Button variant="outline" size="icon" className="cursor-pointer text-destructive">
<Globe className="h-4 w-4" />
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Dribbble className="h-8 w-8" />
<div>
<div className="font-medium">
Dribbble
<Badge variant="outline" className="ml-2">Not Connected</Badge>
</div>
<div className="text-sm text-muted-foreground">Stay connected at Dribbble</div>
</div>
</div>
<Button variant="outline" size="icon" className="cursor-pointer">
<Globe className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>API Integrations</CardTitle>
<CardDescription>
Configure API connections and webhooks.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Zap className="h-8 w-8" />
<div>
<div className="font-medium">Zapier</div>
<div className="text-sm text-muted-foreground">Automate workflows with Zapier</div>
</div>
</div>
<Switch
className="cursor-pointer"
checked={zapierConnected}
onCheckedChange={setZapierConnected}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Globe className="h-8 w-8" />
<div>
<div className="font-medium">Webhooks</div>
<div className="text-sm text-muted-foreground">Configure custom webhook endpoints</div>
</div>
</div>
<Switch
className="cursor-pointer"
checked={webhooksConnected}
onCheckedChange={setWebhooksConnected}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Database className="h-8 w-8" />
<div>
<div className="font-medium">Database Sync</div>
<div className="text-sm text-muted-foreground">Sync data with external databases</div>
</div>
</div>
<Switch
className="cursor-pointer"
checked={dbConnected}
onCheckedChange={setDbConnected}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>API Keys</CardTitle>
<CardDescription>
Manage your API keys and access tokens.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<div className="font-medium">Production API Key</div>
<div className="text-sm text-muted-foreground font-mono">sk_live_4234</div>
</div>
<div className="flex space-x-2">
<Button variant="outline" size="sm" className="cursor-pointer">
Regenerate
</Button>
<Button variant="outline" size="sm" className="cursor-pointer">
Copy
</Button>
</div>
</div>
<Separator />
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<div className="font-medium">Development API Key</div>
<div className="text-sm text-muted-foreground font-mono">sk_test_5678</div>
</div>
<div className="flex space-x-2">
<Button variant="outline" size="sm" className="cursor-pointer">
Regenerate
</Button>
<Button variant="outline" size="sm" className="cursor-pointer">
Copy
</Button>
</div>
</div>
<Separator />
<div className="pt-4">
<Button variant="outline" className="cursor-pointer">Add New API Key</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
@@ -0,0 +1,114 @@
"use client";
import { useActionState, useEffect, useRef, useState } from "react";
import { Check, Copy, Loader2, UserPlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { inviteMemberAction } from "@/lib/appwrite/team-actions";
import { initialInviteState } from "@/lib/appwrite/team-types";
export function InviteForm() {
const [state, formAction, isPending] = useActionState(inviteMemberAction, initialInviteState);
const [copied, setCopied] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.ok && formRef.current) {
formRef.current.reset();
}
}, [state.ok, state.shortUrl]);
const copy = async () => {
if (!state.shortUrl) return;
try {
await navigator.clipboard.writeText(state.shortUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* ignore */
}
};
return (
<Card>
<CardHeader>
<CardTitle>Üye davet et</CardTitle>
<CardDescription>
Email ve rol girin, oluşturulan kısa linki kopyalayıp davet edeceğiniz kişiye gönderin.
</CardDescription>
</CardHeader>
<CardContent>
<form ref={formRef} action={formAction} className="grid gap-4 md:grid-cols-[1fr_180px_auto]">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="ornek@firma.com"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="role">Rol</Label>
<Select name="role" defaultValue="member">
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Üye</SelectItem>
<SelectItem value="admin">Yönetici</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button type="submit" disabled={isPending} className="w-full md:w-auto">
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Gönderiliyor...
</>
) : (
<>
<UserPlus className="size-4" />
Davet et
</>
)}
</Button>
</div>
</form>
{state.error && (
<p className="text-destructive mt-3 text-sm" role="alert">
{state.error}
</p>
)}
{state.ok && state.shortUrl && (
<div className="bg-muted/50 mt-4 flex flex-col gap-2 rounded-md border p-3">
{state.message && (
<p className="text-muted-foreground text-xs">{state.message}</p>
)}
<div className="flex items-center gap-2">
<Input value={state.shortUrl} readOnly className="font-mono text-xs" />
<Button type="button" variant="outline" size="sm" onClick={copy}>
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
{copied ? "Kopyalandı" : "Kopyala"}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,286 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { DoorOpen, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
leaveWorkspaceAction,
removeMemberAction,
updateMemberRoleAction,
} from "@/lib/appwrite/team-actions";
type Member = {
id: string;
userId: string;
name: string;
email: string;
role: string;
joined: string;
invited: string;
confirm: boolean;
};
const ROLE_LABEL: Record<string, string> = {
owner: "Sahip",
admin: "Yönetici",
member: "Üye",
};
export function MembersTable({
members,
currentUserId,
isOwner,
canManage,
}: {
members: Member[];
currentUserId: string;
isOwner: boolean;
canManage: boolean;
}) {
const router = useRouter();
const [busy, setBusy] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [removing, setRemoving] = useState<Member | null>(null);
const [leaving, setLeaving] = useState(false);
const setRole = (membershipId: string, role: string) => {
setBusy(membershipId);
startTransition(async () => {
const result = await updateMemberRoleAction({ ok: false }, formDataFor({
membershipId,
role,
}));
if (result.ok) toast.success("Rol güncellendi.");
else toast.error(result.error ?? "Rol güncellenemedi.");
setBusy(null);
});
};
const handleRemove = () => {
if (!removing) return;
setBusy(removing.id);
startTransition(async () => {
const result = await removeMemberAction({ ok: false }, formDataFor({
membershipId: removing.id,
}));
if (result.ok) {
toast.success(`${removing.name} ekipten çıkarıldı.`);
setRemoving(null);
} else {
toast.error(result.error ?? "İşlem başarısız.");
}
setBusy(null);
});
};
const handleLeave = () => {
setBusy("leave");
startTransition(async () => {
const result = await leaveWorkspaceAction();
if (result.ok) {
toast.success("Çalışma alanından ayrıldınız.");
setLeaving(false);
router.push("/dashboard");
} else {
toast.error(result.error ?? "Ayrılma başarısız.");
}
setBusy(null);
});
};
return (
<>
<Card>
<CardHeader>
<CardTitle>Üyeler ({members.length})</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>İsim</TableHead>
<TableHead>Email</TableHead>
<TableHead>Rol</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((m) => {
const isSelf = m.userId === currentUserId;
const isMemberOwner = m.role === "owner";
return (
<TableRow key={m.id}>
<TableCell className="font-medium">
{m.name}
{isSelf && (
<Badge variant="secondary" className="ml-2 text-xs">
Siz
</Badge>
)}
</TableCell>
<TableCell className="text-muted-foreground">{m.email}</TableCell>
<TableCell>
{isOwner && !isMemberOwner && !isSelf ? (
<Select
value={m.role}
disabled={busy === m.id}
onValueChange={(v) => setRole(m.id, v)}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Üye</SelectItem>
<SelectItem value="admin">Yönetici</SelectItem>
</SelectContent>
</Select>
) : (
<Badge variant={isMemberOwner ? "default" : "outline"}>
{ROLE_LABEL[m.role] ?? m.role}
</Badge>
)}
</TableCell>
<TableCell className="text-right">
{isSelf ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground"
disabled={busy === "leave"}
onClick={() => setLeaving(true)}
>
{busy === "leave" ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<DoorOpen className="size-3.5" />
)}
Ayrıl
</Button>
) : canManage && !isMemberOwner ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={busy === m.id}
onClick={() => setRemoving(m)}
>
{busy === m.id ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
Çıkar
</Button>
) : null}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog
open={Boolean(removing)}
onOpenChange={(v) => !v && busy === null && setRemoving(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Üyeyi ekipten çıkar</DialogTitle>
<DialogDescription>
{removing && (
<>
<strong>{removing.name}</strong> ({removing.email}) ekipten çıkarılacak.
Verileri silinmez ama bu çalışma alanına erişimi kalkar.
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setRemoving(null)}
disabled={busy !== null}
>
Vazgeç
</Button>
<Button
variant="destructive"
onClick={handleRemove}
disabled={busy !== null}
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Çıkar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={leaving} onOpenChange={(v) => !v && busy === null && setLeaving(false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Çalışma alanından ayrıl</DialogTitle>
<DialogDescription>
Bu çalışma alanındaki tüm verilere erişiminiz kalkar. Tekrar davet edilmedikçe
giremezsiniz. Devam etmek istediğinize emin misiniz?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setLeaving(false)}
disabled={busy !== null}
>
Vazgeç
</Button>
<Button
variant="destructive"
onClick={handleLeave}
disabled={busy !== null}
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <DoorOpen className="size-4" />}
Ayrıl
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function formDataFor(fields: Record<string, string>): FormData {
const fd = new FormData();
for (const [k, v] of Object.entries(fields)) fd.set(k, v);
return fd;
}
@@ -0,0 +1,138 @@
"use client";
import { useTransition, useState } from "react";
import { Check, Copy, Loader2, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cancelInviteAction } from "@/lib/appwrite/team-actions";
type Invite = {
id: string;
code: string;
email: string;
role: string;
expiresAt?: string;
createdAt: string;
};
export function PendingInvitesTable({
invites,
canManage,
}: {
invites: Invite[];
canManage: boolean;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [copiedId, setCopiedId] = useState<string | null>(null);
const baseUrl =
typeof window !== "undefined" ? window.location.origin : "";
const copy = async (code: string, id: string) => {
try {
await navigator.clipboard.writeText(`${baseUrl}/d/${code}`);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
} catch {
/* ignore */
}
};
const cancel = (id: string) => {
setBusy(id);
startTransition(async () => {
const fd = new FormData();
fd.set("inviteId", id);
await cancelInviteAction({ ok: false }, fd);
setBusy(null);
});
};
const formatDate = (iso?: string) => {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("tr-TR", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
return (
<Card>
<CardHeader>
<CardTitle>Bekleyen davetler ({invites.length})</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Rol</TableHead>
<TableHead>Geçerlilik</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((inv) => (
<TableRow key={inv.id}>
<TableCell className="font-medium">{inv.email}</TableCell>
<TableCell>
<Badge variant="outline">
{inv.role === "admin" ? "Yönetici" : "Üye"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(inv.expiresAt)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copy(inv.code, inv.id)}
>
{copiedId === inv.id ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
Linki kopyala
</Button>
{canManage && (
<Button
type="button"
variant="ghost"
size="sm"
disabled={busy === inv.id}
onClick={() => cancel(inv.id)}
>
{busy === inv.id ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<X className="size-3.5" />
)}
İptal
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,95 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Query } from "node-appwrite";
import { createAdminClient } from "@/lib/appwrite/server";
import { DATABASE_ID, TABLES, type InviteLink } from "@/lib/appwrite/schema";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { InviteForm } from "./components/invite-form";
import { MembersTable } from "./components/members-table";
import { PendingInvitesTable } from "./components/pending-invites-table";
export const metadata: Metadata = {
title: "DLS — Ekip üyeleri",
};
export default async function MembersPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const canManage = ctx.role === "owner" || ctx.role === "admin";
const isOwner = ctx.role === "owner";
const { teams, tablesDB } = createAdminClient();
const [memberships, invites] = await Promise.all([
teams.listMemberships(ctx.tenantId).catch(() => ({ memberships: [], total: 0 })),
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.inviteLinks,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("status", "pending"),
Query.orderDesc("$createdAt"),
Query.limit(50),
],
})
.catch(() => ({ rows: [] as unknown[] })),
]);
const members = memberships.memberships.map((m) => ({
id: m.$id,
userId: m.userId,
name: m.userName || m.userEmail,
email: m.userEmail,
role: m.roles[0] ?? "member",
joined: m.joined,
invited: m.invited,
confirm: m.confirm,
}));
const pendingInvites = (invites.rows as unknown as InviteLink[]).map((row) => ({
id: row.$id,
code: row.code,
email: row.email,
role: row.role ?? "member",
expiresAt: row.expiresAt,
createdAt: row.$createdAt,
}));
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Ekip üyeleri</h1>
<p className="text-muted-foreground text-sm">
Çalışma alanına üye davet edin, rolleri yönetin.
</p>
</div>
{canManage ? (
<InviteForm />
) : (
<p className="text-muted-foreground text-sm">
Yeni üye davet etmek için yönetici yetkisine ihtiyacınız var.
</p>
)}
{pendingInvites.length > 0 && (
<PendingInvitesTable invites={pendingInvites} canManage={canManage} />
)}
<MembersTable
members={members}
currentUserId={ctx.user.id}
isOwner={isOwner}
canManage={canManage}
/>
</div>
);
}
@@ -0,0 +1,669 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Checkbox } from "@/components/ui/checkbox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Bell, Mail, MessageSquare } from "lucide-react"
const notificationsFormSchema = z.object({
emailSecurity: z.boolean(),
emailUpdates: z.boolean(),
emailMarketing: z.boolean(),
pushMessages: z.boolean(),
pushMentions: z.boolean(),
pushTasks: z.boolean(),
emailFrequency: z.string(),
quietHoursStart: z.string(),
quietHoursEnd: z.string(),
channelEmail: z.boolean(),
channelPush: z.boolean(),
channelSms: z.boolean(),
// New notification table fields
orderUpdatesEmail: z.boolean(),
orderUpdatesBrowser: z.boolean(),
orderUpdatesApp: z.boolean(),
invoiceRemindersEmail: z.boolean(),
invoiceRemindersBrowser: z.boolean(),
invoiceRemindersApp: z.boolean(),
promotionalOffersEmail: z.boolean(),
promotionalOffersBrowser: z.boolean(),
promotionalOffersApp: z.boolean(),
systemMaintenanceEmail: z.boolean(),
systemMaintenanceBrowser: z.boolean(),
systemMaintenanceApp: z.boolean(),
notificationTiming: z.string(),
})
type NotificationsFormValues = z.infer<typeof notificationsFormSchema>
export default function NotificationSettings() {
const form = useForm<NotificationsFormValues>({
resolver: zodResolver(notificationsFormSchema),
defaultValues: {
emailSecurity: false,
emailUpdates: true,
emailMarketing: false,
pushMessages: true,
pushMentions: true,
pushTasks: false,
emailFrequency: "instant",
quietHoursStart: "22:00",
quietHoursEnd: "06:00",
channelEmail: true,
channelPush: true,
channelSms: false,
// New notification table defaults
orderUpdatesEmail: true,
orderUpdatesBrowser: true,
orderUpdatesApp: true,
invoiceRemindersEmail: true,
invoiceRemindersBrowser: false,
invoiceRemindersApp: true,
promotionalOffersEmail: false,
promotionalOffersBrowser: true,
promotionalOffersApp: false,
systemMaintenanceEmail: true,
systemMaintenanceBrowser: true,
systemMaintenanceApp: false,
notificationTiming: "online",
},
})
function onSubmit(data: NotificationsFormValues) {
console.log("Notifications settings submitted:", data)
// Here you would typically save the settings
}
return (
<div className="space-y-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Notifications</h1>
<p className="text-muted-foreground">
Configure how you receive notifications.
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-6 grid-cols-1 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Email Notifications</CardTitle>
<CardDescription>
Choose what email notifications you want to receive.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<FormField
control={form.control}
name="emailSecurity"
render={({ field }) => (
<FormItem className="flex items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>Security alerts</FormLabel>
<p className="text-sm text-muted-foreground">
Get notified when there are security events on your account.
</p>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailUpdates"
render={({ field }) => (
<FormItem className="flex items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>Product updates</FormLabel>
<p className="text-sm text-muted-foreground">
Receive updates about new features and improvements.
</p>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailMarketing"
render={({ field }) => (
<FormItem className="flex items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>Marketing emails</FormLabel>
<p className="text-sm text-muted-foreground">
Receive emails about our latest offers and promotions.
</p>
</div>
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Push Notifications</CardTitle>
<CardDescription>
Configure browser and mobile push notifications.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<FormField
control={form.control}
name="pushMessages"
render={({ field }) => (
<FormItem className="flex items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>New messages</FormLabel>
<p className="text-sm text-muted-foreground">
Get notified when you receive new messages.
</p>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="pushMentions"
render={({ field }) => (
<FormItem className="flex items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>Mentions</FormLabel>
<p className="text-sm text-muted-foreground">
Get notified when someone mentions you.
</p>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="pushTasks"
render={({ field }) => (
<FormItem className="flex items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>Task updates</FormLabel>
<p className="text-sm text-muted-foreground">
Get notified about task assignments and updates.
</p>
</div>
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Notification Frequency</CardTitle>
<CardDescription>
Control how often you receive notifications.
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="emailFrequency"
render={({ field }) => (
<FormItem>
<FormLabel>Email Frequency</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="instant">Instant</SelectItem>
<SelectItem value="hourly">Hourly digest</SelectItem>
<SelectItem value="daily">Daily digest</SelectItem>
<SelectItem value="weekly">Weekly digest</SelectItem>
<SelectItem value="never">Never</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>Quiet Hours</FormLabel>
<div className="flex space-x-2">
<FormField
control={form.control}
name="quietHoursStart"
render={({ field }) => (
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-50">
<SelectValue placeholder="Start" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="22:00">10:00 PM</SelectItem>
<SelectItem value="23:00">11:00 PM</SelectItem>
<SelectItem value="00:00">12:00 AM</SelectItem>
</SelectContent>
</Select>
)}
/>
<span className="self-center">to</span>
<FormField
control={form.control}
name="quietHoursEnd"
render={({ field }) => (
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-50">
<SelectValue placeholder="End" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="06:00">6:00 AM</SelectItem>
<SelectItem value="07:00">7:00 AM</SelectItem>
<SelectItem value="08:00">8:00 AM</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
</FormItem>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notification Preferences</CardTitle>
<CardDescription>
We need permission from your browser to show notifications.{" "}
<Button variant="link" className="p-0 h-auto text-primary">
Request Permission
</Button>
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">TYPE</TableHead>
<TableHead className="text-center">EMAIL</TableHead>
<TableHead className="text-center">BROWSER</TableHead>
<TableHead className="text-center">APP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">Order updates</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="orderUpdatesEmail"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="orderUpdatesBrowser"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="orderUpdatesApp"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Invoice reminders</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="invoiceRemindersEmail"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="invoiceRemindersBrowser"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="invoiceRemindersApp"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Promotional offers</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="promotionalOffersEmail"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="promotionalOffersBrowser"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="promotionalOffersApp"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">System maintenance</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="systemMaintenanceEmail"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="systemMaintenanceBrowser"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="systemMaintenanceApp"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
<div className="space-y-4">
<FormField
control={form.control}
name="notificationTiming"
render={({ field }) => (
<FormItem>
<FormLabel>When should we send you notifications?</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full max-w-sm">
<SelectValue placeholder="Select timing" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="online">Only When I&apos;m online</SelectItem>
<SelectItem value="always">Always</SelectItem>
<SelectItem value="never">Never</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notification Channels</CardTitle>
<CardDescription>
Choose your preferred notification channels for different types of alerts.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<FormField
control={form.control}
name="channelEmail"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Mail className="h-5 w-5 text-muted-foreground" />
<div>
<FormLabel className="font-medium mb-1">Email</FormLabel>
<div className="text-sm text-muted-foreground">Receive notifications via email</div>
</div>
</div>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="channelPush"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Bell className="h-5 w-5 text-muted-foreground" />
<div>
<FormLabel className="font-medium mb-1">Push Notifications</FormLabel>
<div className="text-sm text-muted-foreground">Receive browser push notifications</div>
</div>
</div>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="channelSms"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<MessageSquare className="h-5 w-5 text-muted-foreground" />
<div>
<FormLabel className="font-medium mb-1">SMS</FormLabel>
<div className="text-sm text-muted-foreground">Receive notifications via SMS</div>
</div>
</div>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<div className="flex space-x-2">
<Button type="submit" className="cursor-pointer">Save Preferences</Button>
<Button variant="outline" type="reset" className="cursor-pointer">Cancel</Button>
</div>
</form>
</Form>
</div>
)
}
+361
View File
@@ -0,0 +1,361 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Card, CardContent,CardHeader, CardDescription, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { Upload } from "lucide-react"
import { useRef, useState } from "react"
import { Separator } from "@/components/ui/separator"
import { Logo } from "@/components/logo"
const userFormSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
phone: z.string().optional(),
website: z.string().optional(),
location: z.string().optional(),
role: z.string().optional(),
bio: z.string().optional(),
company: z.string().optional(),
timezone: z.string().optional(),
language: z.string().optional(),
})
type UserFormValues = z.infer<typeof userFormSchema>
export default function UserSettingsPage() {
const fileInputRef = useRef<HTMLInputElement>(null)
const [profileImage, setProfileImage] = useState<string | null>(null)
const [useDefaultIcon, setUseDefaultIcon] = useState(true)
const form = useForm<UserFormValues>({
resolver: zodResolver(userFormSchema),
defaultValues: {
firstName: "",
lastName: "",
email: "",
phone: "",
website: "",
location: "",
role: "",
bio: "",
company: "",
timezone: "",
language: "",
},
})
function onSubmit(data: UserFormValues) {
console.log("Form submitted:", data)
// Here you would typically save the data
}
const handleFileUpload = () => {
fileInputRef.current?.click()
}
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (e) => {
setProfileImage(e.target?.result as string)
setUseDefaultIcon(false)
}
reader.readAsDataURL(file)
}
}
const handleReset = () => {
setProfileImage(null)
setUseDefaultIcon(true)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
return (
<div className="px-4 lg:px-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>Profile Settings</CardTitle>
<CardDescription>Update your personal information and preferences</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Profile Picture Section */}
<div className="flex items-center gap-6 ">
{useDefaultIcon ? (
<div className="flex h-20 w-20 items-center justify-center rounded-lg">
< Logo size={56} />
</div>
) : (
<Avatar className="h-20 w-20 rounded-lg">
<AvatarImage src={profileImage || undefined} />
<AvatarFallback>SS</AvatarFallback>
</Avatar>
)}
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Button
variant="default"
size="sm"
onClick={handleFileUpload}
className="cursor-pointer"
>
<Upload className="mr-2 h-4 w-4" />
Upload new photo
</Button>
<Button
variant="outline"
size="sm"
onClick={handleReset}
className="cursor-pointer"
>
Reset
</Button>
</div>
<p className="text-xs text-muted-foreground">
Allowed JPG, GIF or PNG. Max size of 800K
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/gif,image/png"
onChange={handleFileChange}
className="hidden"
/>
</div>
<Separator className="mb-10" />
{/* Form Fields */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* First Name */}
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="Enter your first name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Last Name */}
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Enter your last name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Email */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>E-mail</FormLabel>
<FormControl>
<Input type="email" placeholder="Enter your email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Company */}
<FormField
control={form.control}
name="company"
render={({ field }) => (
<FormItem>
<FormLabel>Company</FormLabel>
<FormControl>
<Input placeholder="Enter your company" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Phone Number */}
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone Number</FormLabel>
<FormControl>
<Input type="tel" placeholder="Enter your phone number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Location */}
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Location</FormLabel>
<FormControl>
<Input placeholder="Enter your location" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Website */}
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormLabel>Website</FormLabel>
<FormControl>
<Input type="url" placeholder="Enter your website" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Language */}
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select Language" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="english">English</SelectItem>
<SelectItem value="spanish">Spanish</SelectItem>
<SelectItem value="french">French</SelectItem>
<SelectItem value="german">German</SelectItem>
<SelectItem value="italian">Italian</SelectItem>
<SelectItem value="portuguese">Portuguese</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Role */}
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<FormControl>
<Input placeholder="Enter your role" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Timezone */}
<FormField
control={form.control}
name="timezone"
render={({ field }) => (
<FormItem>
<FormLabel>Timezone</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select Timezone" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="pst">PST (Pacific Standard Time)</SelectItem>
<SelectItem value="est">EST (Eastern Standard Time)</SelectItem>
<SelectItem value="cst">CST (Central Standard Time)</SelectItem>
<SelectItem value="mst">MST (Mountain Standard Time)</SelectItem>
<SelectItem value="utc">UTC (Coordinated Universal Time)</SelectItem>
<SelectItem value="cet">CET (Central European Time)</SelectItem>
<SelectItem value="jst">JST (Japan Standard Time)</SelectItem>
<SelectItem value="aest">AEST (Australian Eastern Standard Time)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Bio - Full Width */}
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Tell us a little about yourself..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Action Buttons */}
<div className="flex justify-start gap-3">
<Button type="submit" className="cursor-pointer">
Save Changes
</Button>
<Button variant="outline" type="button" className="cursor-pointer">
Cancel
</Button>
</div>
</CardContent>
</Card>
</form>
</Form>
</div>
)
}
@@ -0,0 +1,211 @@
"use client";
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
import { Building2, ImagePlus, Loader2, Trash2, Upload } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import {
removeLogoAction,
uploadLogoAction,
} from "@/lib/appwrite/logo-actions";
import { initialLogoState } from "@/lib/appwrite/logo-types";
type Props = {
canEdit: boolean;
currentLogoUrl: string | null;
companyName: string;
};
const MAX_BYTES = 2 * 1024 * 1024;
const ALLOWED_MIME = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"];
export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
const [state, formAction, isPending] = useActionState(
uploadLogoAction,
initialLogoState,
);
const [removing, startRemove] = useTransition();
const [previewUrl, setPreviewUrl] = useState<string | null>(currentLogoUrl);
const [dragOver, setDragOver] = useState(false);
const [selectedName, setSelectedName] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setPreviewUrl(currentLogoUrl);
}, [currentLogoUrl]);
useEffect(() => {
if (state.ok) {
toast.success("Logo güncellendi.");
setSelectedName(null);
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
const handleFile = (file: File | null) => {
if (!file) return;
if (!ALLOWED_MIME.includes(file.type)) {
toast.error("Sadece PNG, JPG, WebP veya SVG yükleyebilirsiniz.");
return;
}
if (file.size > MAX_BYTES) {
toast.error("Dosya 2MB'dan büyük olamaz.");
return;
}
setSelectedName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
setPreviewUrl(typeof e.target?.result === "string" ? e.target.result : null);
};
reader.readAsDataURL(file);
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file && inputRef.current) {
const dt = new DataTransfer();
dt.items.add(file);
inputRef.current.files = dt.files;
handleFile(file);
}
};
const handleRemove = () => {
startRemove(async () => {
const result = await removeLogoAction();
if (result.ok) {
toast.success("Logo kaldırıldı.");
setPreviewUrl(null);
setSelectedName(null);
if (inputRef.current) inputRef.current.value = "";
} else {
toast.error(result.error ?? "Logo kaldırılamadı.");
}
});
};
const submitDisabled = isPending || removing || !selectedName;
const busy = isPending || removing;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-4" />
Logo
</CardTitle>
<CardDescription>
Faturalarda, panel başlığında ve dış paylaşımlarda görünür. PNG, JPG, WebP veya SVG
en fazla 2 MB.
</CardDescription>
</CardHeader>
<CardContent>
<form ref={formRef} action={formAction} className="space-y-4">
<div className="grid items-start gap-6 md:grid-cols-[200px_1fr]">
<div className="bg-muted flex aspect-square w-full items-center justify-center overflow-hidden rounded-lg border">
{previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewUrl}
alt={`${companyName} logo`}
className="size-full object-contain"
/>
) : (
<div className="text-muted-foreground flex flex-col items-center gap-2 p-4 text-center text-xs">
<Building2 className="size-8 opacity-40" />
<span>Henüz logo yok</span>
</div>
)}
</div>
<div className="space-y-3">
<label
onDragOver={(e) => {
e.preventDefault();
if (canEdit) setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={canEdit ? handleDrop : undefined}
className={cn(
"flex min-h-[120px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors",
dragOver && "border-primary bg-primary/5",
!canEdit && "cursor-not-allowed opacity-60",
!dragOver && "hover:bg-muted/30",
)}
>
<input
ref={inputRef}
type="file"
name="logo"
accept={ALLOWED_MIME.join(",")}
className="sr-only"
disabled={!canEdit || busy}
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
/>
<ImagePlus className="text-muted-foreground size-6" />
<div className="text-sm font-medium">
{selectedName ?? "Logo yüklemek için tıkla veya sürükle bırak"}
</div>
<div className="text-muted-foreground text-xs">
Önerilen: kare, en az 256×256 px, şeffaf arka plan (PNG/SVG)
</div>
</label>
<div className="flex flex-wrap gap-2">
{canEdit && (
<Button type="submit" disabled={submitDisabled}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Yükleniyor...
</>
) : (
<>
<Upload className="size-4" />
Yükle
</>
)}
</Button>
)}
{canEdit && currentLogoUrl && (
<Button
type="button"
variant="outline"
onClick={handleRemove}
disabled={busy}
className="text-destructive hover:text-destructive"
>
{removing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
Kaldır
</Button>
)}
{!canEdit && (
<p className="text-muted-foreground text-xs">
Logo değiştirmek için yönetici yetkisi gerekli.
</p>
)}
</div>
</div>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,176 @@
"use client";
import { useActionState, useEffect } from "react";
import { Building2, Coins, Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { updateWorkspaceSettingsAction } from "@/lib/appwrite/workspace-actions";
import { initialWorkspaceSettingsState } from "@/lib/appwrite/workspace-types";
type Defaults = {
companyName: string;
companyTaxId: string;
companyAddress: string;
companyEmail: string;
companyPhone: string;
defaultCurrency: string;
kind: "lab" | "clinic" | null;
memberNumber: string;
};
export function WorkspaceSettingsForm({
canEdit,
defaults,
}: {
canEdit: boolean;
defaults: Defaults;
}) {
const [state, formAction, isPending] = useActionState(
updateWorkspaceSettingsAction,
initialWorkspaceSettingsState,
);
useEffect(() => {
if (state.ok) toast.success("Bilgiler güncellendi.");
else if (state.error) toast.error(state.error);
}, [state]);
const kindLabel = defaults.kind === "lab" ? "Laboratuvar" : defaults.kind === "clinic" ? "Klinik" : "—";
return (
<form action={formAction} className="space-y-6">
<fieldset disabled={!canEdit || isPending} className="space-y-6 disabled:opacity-90">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-4" />
Şirket
</CardTitle>
<CardDescription>Resmi şirket bilgileriniz ve bağlantı kodunuz.</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 md:grid-cols-2">
<div className="grid gap-2">
<Label>Hesap türü</Label>
<div className="bg-muted/50 flex h-9 items-center rounded-md border px-3 text-sm">
{kindLabel}
</div>
</div>
<div className="grid gap-2">
<Label>Bağlantı kodu</Label>
<div className="bg-muted/50 flex h-9 items-center rounded-md border px-3 font-mono text-sm tracking-widest">
{defaults.memberNumber || "—"}
</div>
</div>
<div className="md:col-span-2 grid gap-2">
<Label htmlFor="companyName">Şirket adı *</Label>
<Input
id="companyName"
name="companyName"
defaultValue={defaults.companyName}
required
placeholder="Örn. Atlas Diş Polikliniği"
/>
{state.fieldErrors?.companyName && (
<p className="text-destructive text-xs">{state.fieldErrors.companyName}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="companyTaxId">Vergi numarası</Label>
<Input
id="companyTaxId"
name="companyTaxId"
defaultValue={defaults.companyTaxId}
inputMode="numeric"
placeholder="1234567890"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="companyPhone">Telefon</Label>
<Input
id="companyPhone"
name="companyPhone"
type="tel"
defaultValue={defaults.companyPhone}
placeholder="+90 555 123 45 67"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="companyEmail">Email</Label>
<Input
id="companyEmail"
name="companyEmail"
type="email"
defaultValue={defaults.companyEmail}
placeholder="info@firma.com"
/>
{state.fieldErrors?.companyEmail && (
<p className="text-destructive text-xs">{state.fieldErrors.companyEmail}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="companyAddress">Adres</Label>
<Textarea
id="companyAddress"
name="companyAddress"
rows={2}
defaultValue={defaults.companyAddress}
placeholder="İl, ilçe, açık adres"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Coins className="size-4" />
Finans varsayılanları
</CardTitle>
<CardDescription>Yeni ve faturalarda kullanılan varsayılanlar.</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="defaultCurrency">Varsayılan para birimi</Label>
<Input
id="defaultCurrency"
name="defaultCurrency"
defaultValue={defaults.defaultCurrency}
maxLength={8}
placeholder="TRY"
style={{ textTransform: "uppercase" }}
/>
</div>
</CardContent>
</Card>
{canEdit && (
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
Kaydet
</>
)}
</Button>
</div>
)}
</fieldset>
</form>
);
}
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getLogoUrl } from "@/lib/appwrite/storage";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { LogoUploader } from "./components/logo-uploader";
import { WorkspaceSettingsForm } from "./components/workspace-form";
export const metadata: Metadata = {
title: "DLS — Şirket bilgileri",
};
export default async function WorkspaceSettingsPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const canEdit = ctx.role === "owner" || ctx.role === "admin";
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Şirket bilgileri</h1>
<p className="text-muted-foreground text-sm">
Faturalarda ve panel başlığında görünecek şirket bilgileri.
{!canEdit && " Düzenlemek için yönetici yetkisine ihtiyacınız var."}
</p>
</div>
<LogoUploader
canEdit={canEdit}
currentLogoUrl={getLogoUrl(ctx.settings?.logo)}
companyName={ctx.settings?.companyName ?? "Çalışma alanı"}
/>
<WorkspaceSettingsForm
canEdit={canEdit}
defaults={{
companyName: ctx.settings?.companyName ?? "",
companyTaxId: ctx.settings?.companyTaxId ?? "",
companyAddress: ctx.settings?.companyAddress ?? "",
companyEmail: ctx.settings?.companyEmail ?? "",
companyPhone: ctx.settings?.companyPhone ?? "",
defaultCurrency: ctx.settings?.defaultCurrency ?? "TRY",
kind: ctx.settings?.kind ?? null,
memberNumber: ctx.settings?.memberNumber ?? "",
}}
/>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { CheckCircle2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { acceptInviteAction } from "@/lib/appwrite/team-actions";
import { useState } from "react";
export function AcceptInviteButton({ code }: { code: string }) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const handleAccept = () => {
setError(null);
startTransition(async () => {
const result = await acceptInviteAction(code);
if (result.ok) {
router.push("/dashboard");
} else {
setError(result.error ?? "Beklenmeyen hata.");
}
});
};
return (
<>
<Button onClick={handleAccept} disabled={isPending} className="w-full">
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Katılınıyor...
</>
) : (
<>
<CheckCircle2 className="size-4" />
Daveti kabul et
</>
)}
</Button>
{error && (
<p className="text-destructive text-center text-sm" role="alert">
{error}
</p>
)}
</>
);
}
+141
View File
@@ -0,0 +1,141 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Logo } from "@/components/logo";
import { resolveInviteCode } from "@/lib/appwrite/team-actions";
import { createAdminClient, getCurrentUser } from "@/lib/appwrite/server";
import { DATABASE_ID, TABLES, type TenantSettings } from "@/lib/appwrite/schema";
import { AcceptInviteButton } from "./accept-invite-button";
import { Query } from "node-appwrite";
export const metadata: Metadata = {
title: "DLS — Davet",
};
async function getCompanyName(tenantId: string): Promise<string | null> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
});
return (result.rows[0] as unknown as TenantSettings | undefined)?.companyName ?? null;
} catch {
return null;
}
}
export default async function InvitePage({
params,
}: {
params: Promise<{ code: string }>;
}) {
const { code } = await params;
const invite = await resolveInviteCode(code);
const user = await getCurrentUser();
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="flex w-full max-w-md flex-col gap-6">
<Link href="/" className="flex items-center justify-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
<Logo size={22} />
</div>
<span className="text-xl font-semibold">DLS</span>
</Link>
{!invite || invite.status === "cancelled" ? (
<InvalidCard reason="Davet bulunamadı veya iptal edilmiş." />
) : invite.status === "accepted" ? (
<InvalidCard reason="Bu davet daha önce kabul edilmiş." action="dashboard" />
) : invite.expiresAt && new Date(invite.expiresAt).getTime() < Date.now() ? (
<InvalidCard reason="Bu davetin süresi dolmuş." />
) : (
<ValidInvite
code={code}
email={invite.email}
role={invite.role ?? "member"}
companyName={(await getCompanyName(invite.tenantId)) ?? "Bir çalışma alanı"}
currentUserEmail={user?.email ?? null}
/>
)}
</div>
</div>
);
}
function InvalidCard({ reason, action }: { reason: string; action?: "dashboard" }) {
return (
<Card>
<CardHeader className="text-center">
<CardTitle>Davet kullanılamıyor</CardTitle>
<CardDescription>{reason}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<Button asChild variant="outline">
<Link href={action === "dashboard" ? "/dashboard" : "/sign-in"}>
{action === "dashboard" ? "Panele git" : "Giriş yap"}
</Link>
</Button>
</CardContent>
</Card>
);
}
function ValidInvite({
code,
email,
role,
companyName,
currentUserEmail,
}: {
code: string;
email: string;
role: string;
companyName: string;
currentUserEmail: string | null;
}) {
const roleLabel = role === "admin" ? "Yönetici" : "Üye";
const emailMatches = currentUserEmail?.toLowerCase() === email.toLowerCase();
return (
<Card>
<CardHeader className="text-center">
<CardTitle className="text-2xl">{companyName}</CardTitle>
<CardDescription>
<strong>{email}</strong> olarak <strong>{roleLabel}</strong> rolüyle çalışma alanına davet
edildiniz.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{!currentUserEmail ? (
<>
<Button asChild className="w-full">
<Link href={`/sign-up?invite=${code}&email=${encodeURIComponent(email)}`}>
Hesap oluşturup katıl
</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href={`/sign-in?invite=${code}`}>Zaten hesabım var, giriş yap</Link>
</Button>
</>
) : emailMatches ? (
<AcceptInviteButton code={code} />
) : (
<>
<p className="text-destructive text-center text-sm">
Şu an <strong>{currentUserEmail}</strong> ile giriş yapmışsınız. Davet{" "}
<strong>{email}</strong> içindir. Doğru hesabı kullanın.
</p>
<Button asChild variant="outline" className="w-full">
<Link href={`/sign-in?invite=${code}`}>Farklı hesapla giriş yap</Link>
</Button>
</>
)}
</CardContent>
</Card>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+172
View File
@@ -0,0 +1,172 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: var(--font-inter);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--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.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--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.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--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;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* Ensure consistent font rendering across different DPI levels */
@media (-webkit-min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) {
body {
-webkit-font-smoothing: subpixel-antialiased;
}
}
/* Fix for sidebar "none" mode height issue */
.sidebar-none-mode [data-slot="sidebar"] {
height: 100vh !important;
min-height: 100vh !important;
}
/* Fix for right-side inset variant support */
@media (min-width: 768px) {
/* Right sidebar inset variant - margin adjustments */
[data-side="right"][data-variant="inset"] ~ [data-slot="sidebar-inset"] {
margin-right: 0 !important;
}
/* Right sidebar inset variant - collapsed state margin */
[data-side="right"][data-variant="inset"][data-state="collapsed"] ~ [data-slot="sidebar-inset"] {
margin-right: 0.5rem !important;
}
}
/* Smooth scrolling for the entire page */
html {
scroll-behavior: smooth;
}
/* Logo carousel animation */
@keyframes logo-scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-144rem); /* 12 items * 12rem = 144rem */
}
}
.animate-logo-scroll {
animation: logo-scroll 30s linear infinite;
}
.animate-logo-scroll:hover {
animation-play-state: paused;
}
@@ -0,0 +1,89 @@
"use client"
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { CardDecorator } from '@/components/ui/card-decorator'
import { Github, Code, Palette, Layout, Crown } from 'lucide-react'
const values = [
{
icon: Code,
title: 'Developer First',
description: 'Every component is built with the developer experience in mind, ensuring clean code and easy integration.'
},
{
icon: Palette,
title: 'Design Excellence',
description: 'We maintain the highest design standards, following shadcn/ui principles and modern UI patterns.'
},
{
icon: Layout,
title: 'Production Ready',
description: 'Battle-tested components used in real applications with proven performance and reliability across different environments.'
},
{
icon: Crown,
title: 'Premium Quality',
description: 'Hand-crafted with attention to detail and performance optimization, ensuring exceptional user experience and accessibility.'
}
]
export function AboutSection() {
return (
<section id="about" className="py-24 sm:py-32">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
{/* Section Header */}
<div className="mx-auto max-w-4xl text-center mb-16">
<Badge variant="outline" className="mb-4">
About ShadcnStore
</Badge>
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-6">
Built for developers, by developers
</h2>
<p className="text-lg text-muted-foreground mb-8">
We&apos;re passionate about creating the best marketplace for shadcn/ui components and templates.
Our mission is to accelerate development and help developers build beautiful admin interfaces faster.
</p>
</div>
{/* Modern Values Grid with Enhanced Design */}
<div className="grid grid-cols-1 gap-x-8 gap-y-12 sm:grid-cols-2 xl:grid-cols-4 mb-12">
{values.map((value, index) => (
<Card key={index} className='group shadow-xs py-2'>
<CardContent className='p-8'>
<div className='flex flex-col items-center text-center'>
<CardDecorator>
<value.icon className='h-6 w-6' aria-hidden />
</CardDecorator>
<h3 className='mt-6 font-medium text-balance'>{value.title}</h3>
<p className='text-muted-foreground mt-3 text-sm'>{value.description}</p>
</div>
</CardContent>
</Card>
))}
</div>
{/* Call to Action */}
<div className="mt-16 text-center">
<div className="flex items-center justify-center gap-2 mb-6">
<span className="text-muted-foreground"> Made with love for the developer community</span>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button size="lg" className="cursor-pointer" asChild>
<a href="https://github.com/silicondeck/shadcn-dashboard-landing-template" target="_blank" rel="noopener noreferrer">
<Github className="mr-2 h-4 w-4" />
Star on GitHub
</a>
</Button>
<Button size="lg" variant="outline" className="cursor-pointer" asChild>
<a href="https://discord.com/invite/XEQhPc9a6p" target="_blank" rel="noopener noreferrer">
Join Discord Community
</a>
</Button>
</div>
</div>
</div>
</section>
)
}
@@ -0,0 +1,93 @@
"use client"
import Image from 'next/image'
import { ArrowRight } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
const blogs = [
{
id: 1,
image: 'https://ui.shadcn.com/placeholder.svg',
category: 'Technology',
title: 'AI Development Catalysts',
description:
'Exploring how AI-driven tools are transforming software development workflows and accelerating innovation.',
},
{
id: 2,
image: 'https://ui.shadcn.com/placeholder.svg',
category: 'Lifestyle',
title: 'Minimalist Living Guide',
description:
'Minimalist living approaches that can help reduce stress and create more meaningful daily experiences.',
},
{
id: 3,
image: 'https://ui.shadcn.com/placeholder.svg',
category: 'Design',
title: 'Accessible UI Trends',
description:
'How modern UI trends are embracing accessibility while maintaining sleek, intuitive user experiences.',
},
]
export function BlogSection() {
return (
<section id="blog" className="py-24 sm:py-32 bg-muted/50">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
{/* Section Header */}
<div className="mx-auto max-w-2xl text-center mb-16">
<Badge variant="outline" className="mb-4">Latest Insights</Badge>
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
From our blog
</h2>
<p className="text-lg text-muted-foreground">
Stay updated with the latest trends, best practices, and insights from our team of experts.
</p>
</div>
{/* Blog Grid */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{blogs.map(blog => (
<Card key={blog.id} className="overflow-hidden py-0">
<CardContent className="px-0">
<div className="aspect-video">
<Image
src={blog.image}
alt={blog.title}
width={400}
height={225}
className="size-full object-cover dark:invert dark:brightness-[0.95]"
loading="lazy"
/>
</div>
<div className="space-y-3 p-6">
<p className="text-muted-foreground text-xs tracking-widest uppercase">
{blog.category}
</p>
<a
href="#"
onClick={e => e.preventDefault()}
className="cursor-pointer"
>
<h3 className="text-xl font-bold hover:text-primary transition-colors">{blog.title}</h3>
</a>
<p className="text-muted-foreground">{blog.description}</p>
<a
href="#"
onClick={e => e.preventDefault()}
className="inline-flex items-center gap-2 text-primary hover:underline cursor-pointer"
>
Learn More
<ArrowRight className="size-4" />
</a>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</section>
)
}
@@ -0,0 +1,228 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Mail, MessageCircle, Github, BookOpen } from 'lucide-react'
const contactFormSchema = z.object({
firstName: z.string().min(2, {
message: "First name must be at least 2 characters.",
}),
lastName: z.string().min(2, {
message: "Last name must be at least 2 characters.",
}),
email: z.string().email({
message: "Please enter a valid email address.",
}),
subject: z.string().min(5, {
message: "Subject must be at least 5 characters.",
}),
message: z.string().min(10, {
message: "Message must be at least 10 characters.",
}),
})
export function ContactSection() {
const form = useForm<z.infer<typeof contactFormSchema>>({
resolver: zodResolver(contactFormSchema),
defaultValues: {
firstName: "",
lastName: "",
email: "",
subject: "",
message: "",
},
})
function onSubmit(values: z.infer<typeof contactFormSchema>) {
// Here you would typically send the form data to your backend
console.log(values)
// You could also show a success message or redirect
form.reset()
}
return (
<section id="contact" className="py-24 sm:py-32">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="mx-auto max-w-2xl text-center mb-16">
<Badge variant="outline" className="mb-4">Get In Touch</Badge>
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
Need help or have questions?
</h2>
<p className="text-lg text-muted-foreground">
Our team is here to help you get the most out of ShadcnStore. Choose the best way to reach out to us.
</p>
</div>
<div className="grid gap-8 lg:grid-cols-3">
{/* Contact Options */}
<div className="space-y-6 order-2 lg:order-1">
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageCircle className="h-5 w-5 text-primary" />
Discord Community
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-3">
Join our active community for quick help and discussions with other developers.
</p>
<Button variant="outline" size="sm" className="cursor-pointer" asChild>
<a href="https://discord.com/invite/XEQhPc9a6p" target="_blank" rel="noopener noreferrer">
Join Discord
</a>
</Button>
</CardContent>
</Card>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Github className="h-5 w-5 text-primary" />
GitHub Issues
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-3">
Report bugs, request features, or contribute to our open source repository.
</p>
<Button variant="outline" size="sm" className="cursor-pointer" asChild>
<a href="https://github.com/silicondeck/shadcn-dashboard-landing-template/issues" target="_blank" rel="noopener noreferrer">
View on GitHub
</a>
</Button>
</CardContent>
</Card>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5 text-primary" />
Documentation
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-3">
Browse our comprehensive guides, tutorials, and component documentation.
</p>
<Button variant="outline" size="sm" className="cursor-pointer" asChild>
<a href="#">
View Docs
</a>
</Button>
</CardContent>
</Card>
</div>
{/* Contact Form */}
<div className="lg:col-span-2 order-1 lg:order-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Send us a message
</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First name</FormLabel>
<FormControl>
<Input placeholder="John" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last name</FormLabel>
<FormControl>
<Input placeholder="Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="john@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>Subject</FormLabel>
<FormControl>
<Input placeholder="Component request, bug report, general inquiry..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Message</FormLabel>
<FormControl>
<Textarea
placeholder="Tell us how we can help you with ShadcnStore components..."
rows={10}
className="min-h-50"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full cursor-pointer">
Send Message
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
)
}
@@ -0,0 +1,96 @@
"use client"
import { ArrowRight, TrendingUp, Package, Github } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
export function CTASection() {
return (
<section className='py-16 lg:py-24 bg-muted/80'>
<div className='container mx-auto px-4 lg:px-8'>
<div className='mx-auto max-w-4xl'>
<div className='text-center'>
<div className='space-y-8'>
{/* Badge and Stats */}
<div className='flex flex-col items-center gap-4'>
<Badge variant='outline' className='flex items-center gap-2'>
<TrendingUp className='size-3' />
Productivity Suite
</Badge>
<div className='text-muted-foreground flex items-center gap-4 text-sm'>
<span className='flex items-center gap-1'>
<div className='size-2 rounded-full bg-green-500' />
150+ Blocks
</span>
<Separator orientation='vertical' className='!h-4' />
<span>25K+ Downloads</span>
<Separator orientation='vertical' className='!h-4' />
<span>4.9 Rating</span>
</div>
</div>
{/* Main Content */}
<div className='space-y-6'>
<h1 className='text-4xl font-bold tracking-tight text-balance sm:text-5xl lg:text-6xl'>
Supercharge your team&apos;s
<span className='flex sm:inline-flex justify-center'>
<span className='relative mx-2'>
<span className='bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent'>
performance
</span>
<div className='absolute start-0 -bottom-2 h-1 w-full bg-gradient-to-r from-primary/30 to-secondary/30' />
</span>
today
</span>
</h1>
<p className='text-muted-foreground mx-auto max-w-2xl text-balance lg:text-xl'>
Stop building from scratch. Get production-ready components, templates and dashboards
that integrate seamlessly with your shadcn/ui projects.
</p>
</div>
{/* CTA Buttons */}
<div className='flex flex-col justify-center gap-4 sm:flex-row sm:gap-6'>
<Button size='lg' className='cursor-pointer px-8 py-6 text-lg font-medium' asChild>
<a href='https://shadcnstore.com/blocks' target='_blank' rel='noopener noreferrer'>
<Package className='me-2 size-5' />
Browse Components
</a>
</Button>
<Button variant='outline' size='lg' className='cursor-pointer px-8 py-6 text-lg font-medium group' asChild>
<a href='https://github.com/silicondeck/shadcn-dashboard-landing-template' target='_blank' rel='noopener noreferrer'>
<Github className='me-2 size-5' />
View on GitHub
<ArrowRight className='ms-2 size-4 transition-transform group-hover:translate-x-1' />
</a>
</Button>
</div>
{/* Trust Indicators */}
<div className='text-muted-foreground flex flex-wrap items-center justify-center gap-6 text-sm'>
<div className='flex items-center gap-2'>
<div className='size-2 rounded-full bg-green-600 dark:bg-green-400 me-1' />
<span>Free components available</span>
</div>
<div className='flex items-center gap-2'>
<div className='size-2 rounded-full bg-blue-600 dark:bg-blue-400 me-1' />
<span>Commercial license included</span>
</div>
<div className='flex items-center gap-2'>
<div className='size-2 rounded-full bg-purple-600 dark:bg-purple-400 me-1' />
<span>Regular updates & support</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
)
}
+107
View File
@@ -0,0 +1,107 @@
"use client"
import { CircleHelp } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
type FaqItem = {
value: string
question: string
answer: string
}
const faqItems: FaqItem[] = [
{
value: 'item-1',
question: 'How do I integrate ShadcnStore components into my project?',
answer:
'Integration is simple! All our components are built with shadcn/ui and work with React, Next.js, and Vite. Just copy the component code, install any required dependencies, and paste it into your project. Each component comes with detailed installation instructions and examples.',
},
{
value: 'item-2',
question: 'What\'s the difference between free and premium components?',
answer:
'Free components include essential UI elements like buttons, forms, and basic layouts. Premium components offer advanced features like complex data tables, analytics dashboards, authentication flows, and complete admin templates. Premium also includes Figma files, priority support, and commercial licenses.',
},
{
value: 'item-3',
question: 'Can I use these components in commercial projects?',
answer:
'Yes! Free components come with an MIT license for unlimited use. Premium components include a commercial license that allows usage in client projects, SaaS applications, and commercial products without attribution requirements.',
},
{
value: 'item-4',
question: 'Do you provide support and updates?',
answer:
'Absolutely! We provide community support for free components through our Discord server and GitHub issues. Premium subscribers get priority email support, regular component updates, and early access to new releases. We also maintain compatibility with the latest shadcn/ui versions.',
},
{
value: 'item-5',
question: 'What frameworks and tools do you support?',
answer:
'Our components work with React 18+, Next.js 13+, and Vite. We use TypeScript, Tailwind CSS, and follow shadcn/ui conventions. Components are tested with popular tools like React Hook Form, TanStack Query, and Zustand for state management.',
},
{
value: 'item-6',
question: 'How often do you release new components?',
answer:
'We release new components and templates weekly. Premium subscribers get early access to new releases, while free components are updated regularly based on community feedback. You can track our roadmap and request specific components through our GitHub repository.',
},
]
const FaqSection = () => {
return (
<section id="faq" className="py-24 sm:py-32">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
{/* Section Header */}
<div className="mx-auto max-w-2xl text-center mb-16">
<Badge variant="outline" className="mb-4">FAQ</Badge>
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
Frequently Asked Questions
</h2>
<p className="text-lg text-muted-foreground">
Everything you need to know about ShadcnStore components, licensing, and integration. Still have questions? We&apos;re here to help!
</p>
</div>
{/* FAQ Content */}
<div className="max-w-4xl mx-auto">
<div className='bg-transparent'>
<div className='p-0'>
<Accordion type='single' collapsible className='space-y-5'>
{faqItems.map(item => (
<AccordionItem key={item.value} value={item.value} className='rounded-md !border bg-transparent'>
<AccordionTrigger className='cursor-pointer items-center gap-4 rounded-none bg-transparent py-2 ps-3 pe-4 hover:no-underline data-[state=open]:border-b'>
<div className='flex items-center gap-4'>
<div className='bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-full'>
<CircleHelp className='size-5' />
</div>
<span className='text-start font-semibold'>{item.question}</span>
</div>
</AccordionTrigger>
<AccordionContent className='p-4 bg-transparent'>{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</div>
{/* Contact Support CTA */}
<div className="text-center mt-12">
<p className="text-muted-foreground mb-4">
Still have questions? We&apos;re here to help.
</p>
<Button className='cursor-pointer' asChild>
<a href="#contact">
Contact Support
</a>
</Button>
</div>
</div>
</div>
</section>
)
}
export { FaqSection }
@@ -0,0 +1,183 @@
"use client"
import {
BarChart3,
Zap,
Users,
ArrowRight,
Database,
Package,
Crown,
Layout,
Palette
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Image3D } from '@/components/image-3d'
const mainFeatures = [
{
icon: Package,
title: 'Curated Component Library',
description: 'Hand-picked blocks and templates for quality and reliability.'
},
{
icon: Crown,
title: 'Free & Premium Options',
description: 'Start free, upgrade to premium collections when you need more.'
},
{
icon: Layout,
title: 'Ready-to-Use Templates',
description: 'Copy-paste components that just work out of the box.'
},
{
icon: Zap,
title: 'Regular Updates',
description: 'New blocks and templates added weekly to keep you current.'
}
]
const secondaryFeatures = [
{
icon: BarChart3,
title: 'Multiple Frameworks',
description: 'React, Next.js, and Vite compatibility for flexible development.'
},
{
icon: Palette,
title: 'Modern Tech Stack',
description: 'Built with shadcn/ui, Tailwind CSS, and TypeScript.'
},
{
icon: Users,
title: 'Responsive Design',
description: 'Mobile-first components for all screen sizes and devices.'
},
{
icon: Database,
title: 'Developer-Friendly',
description: 'Clean code, well-documented, easy integration and customization.'
}
]
export function FeaturesSection() {
return (
<section id="features" className="py-24 sm:py-32 bg-muted/30">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
{/* Section Header */}
<div className="mx-auto max-w-2xl text-center mb-16">
<Badge variant="outline" className="mb-4">Marketplace Features</Badge>
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
Everything you need to build amazing web applications
</h2>
<p className="text-lg text-muted-foreground">
Our marketplace provides curated blocks, templates, landing pages, and admin dashboards to help you build professional applications faster than ever.
</p>
</div>
{/* First Feature Section */}
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-8 xl:gap-16 mb-24">
{/* Left Image */}
<Image3D
lightSrc="/feature-1-light.png"
darkSrc="/feature-1-dark.png"
alt="Analytics dashboard"
direction="left"
/>
{/* Right Content */}
<div className="space-y-6">
<div className="space-y-4">
<h3 className="text-2xl font-semibold tracking-tight text-balance sm:text-3xl">
Components that accelerate development
</h3>
<p className="text-muted-foreground text-base text-pretty">
Our curated marketplace offers premium blocks and templates designed to save time and ensure consistency across your admin projects.
</p>
</div>
<ul className="grid gap-4 sm:grid-cols-2">
{mainFeatures.map((feature, index) => (
<li key={index} className="group hover:bg-accent/5 flex items-start gap-3 p-2 rounded-lg transition-colors">
<div className="mt-0.5 flex shrink-0 items-center justify-center">
<feature.icon className="size-5 text-primary" aria-hidden="true" />
</div>
<div>
<h3 className="text-foreground font-medium">{feature.title}</h3>
<p className="text-muted-foreground mt-1 text-sm">{feature.description}</p>
</div>
</li>
))}
</ul>
<div className="flex flex-col sm:flex-row gap-4 pe-4 pt-2">
<Button size="lg" className="cursor-pointer">
<a href="https://shadcnstore.com/templates" className='flex items-center'>
Browse Templates
<ArrowRight className="ms-2 size-4" aria-hidden="true" />
</a>
</Button>
<Button size="lg" variant="outline" className="cursor-pointer">
<a href="https://shadcnstore.com/blocks">
View Components
</a>
</Button>
</div>
</div>
</div>
{/* Second Feature Section - Flipped Layout */}
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-8 xl:gap-16">
{/* Left Content */}
<div className="space-y-6 order-2 lg:order-1">
<div className="space-y-4">
<h3 className="text-2xl font-semibold tracking-tight text-balance sm:text-3xl">
Built for modern development workflows
</h3>
<p className="text-muted-foreground text-base text-pretty">
Every component follows best practices with TypeScript, responsive design, and clean code architecture that integrates seamlessly into your projects.
</p>
</div>
<ul className="grid gap-4 sm:grid-cols-2">
{secondaryFeatures.map((feature, index) => (
<li key={index} className="group hover:bg-accent/5 flex items-start gap-3 p-2 rounded-lg transition-colors">
<div className="mt-0.5 flex shrink-0 items-center justify-center">
<feature.icon className="size-5 text-primary" aria-hidden="true" />
</div>
<div>
<h3 className="text-foreground font-medium">{feature.title}</h3>
<p className="text-muted-foreground mt-1 text-sm">{feature.description}</p>
</div>
</li>
))}
</ul>
<div className="flex flex-col sm:flex-row gap-4 pe-4 pt-2">
<Button size="lg" className="cursor-pointer">
<a href="#" className='flex items-center'>
View Documentation
<ArrowRight className="ms-2 size-4" aria-hidden="true" />
</a>
</Button>
<Button size="lg" variant="outline" className="cursor-pointer">
<a href="https://github.com/silicondeck/shadcn-dashboard-landing-template" target="_blank" rel="noopener noreferrer">
GitHub Repository
</a>
</Button>
</div>
</div>
{/* Right Image */}
<Image3D
lightSrc="/feature-2-light.png"
darkSrc="/feature-2-dark.png"
alt="Performance dashboard"
direction="right"
className="order-1 lg:order-2"
/>
</div>
</div>
</section>
)
}
+234
View File
@@ -0,0 +1,234 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form"
import { Logo } from '@/components/logo'
import { Github, Twitter, Linkedin, Youtube, Heart } from 'lucide-react'
const newsletterSchema = z.object({
email: z.string().email({
message: "Please enter a valid email address.",
}),
})
const footerLinks = {
product: [
{ name: 'Features', href: '#features' },
{ name: 'Pricing', href: '#pricing' },
{ name: 'API', href: '#api' },
{ name: 'Documentation', href: '#docs' },
],
company: [
{ name: 'About', href: '#about' },
{ name: 'Blog', href: '#blog' },
{ name: 'Careers', href: '#careers' },
{ name: 'Press', href: '#press' },
],
resources: [
{ name: 'Help Center', href: '#help' },
{ name: 'Community', href: '#community' },
{ name: 'Guides', href: '#guides' },
{ name: 'Webinars', href: '#webinars' },
],
legal: [
{ name: 'Privacy', href: '#privacy' },
{ name: 'Terms', href: '#terms' },
{ name: 'Security', href: '#security' },
{ name: 'Status', href: '#status' },
],
}
const socialLinks = [
{ name: 'Twitter', href: '#', icon: Twitter },
{ name: 'GitHub', href: 'https://github.com/silicondeck/shadcn-dashboard-landing-template', icon: Github },
{ name: 'LinkedIn', href: '#', icon: Linkedin },
{ name: 'YouTube', href: '#', icon: Youtube },
]
export function LandingFooter() {
const form = useForm<z.infer<typeof newsletterSchema>>({
resolver: zodResolver(newsletterSchema),
defaultValues: {
email: "",
},
})
function onSubmit(values: z.infer<typeof newsletterSchema>) {
// Here you would typically send the email to your newsletter service
console.log(values)
// Show success message and reset form
form.reset()
}
return (
<footer className="border-t bg-background">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-16">
{/* Newsletter Section */}
<div className="mb-16">
<div className="mx-auto max-w-2xl text-center">
<h3 className="text-2xl font-bold mb-4">Stay updated</h3>
<p className="text-muted-foreground mb-6">
Get the latest updates, articles, and resources sent to your inbox weekly.
</p>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-2 max-w-md mx-auto sm:flex-row">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
type="email"
placeholder="Enter your email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="cursor-pointer">Subscribe</Button>
</form>
</Form>
</div>
</div>
{/* Main Footer Content */}
<div className="grid gap-8 grid-cols-4 lg:grid-cols-6">
{/* Brand Column */}
<div className="col-span-4 lg:col-span-2 max-w-2xl">
<div className="flex items-center space-x-2 mb-4 max-lg:justify-center">
<a href="https://shadcnstore.com" target='_blank' className="flex items-center space-x-2 cursor-pointer">
<Logo size={32} />
<span className="font-bold text-xl">ShadcnStore</span>
</a>
</div>
<p className="text-muted-foreground mb-6 max-lg:text-center max-lg:flex max-lg:justify-center">
Accelerating web development with curated blocks, templates, landing pages, and admin dashboards designed for modern developers.
</p>
<div className="flex space-x-4 max-lg:justify-center">
{socialLinks.map((social) => (
<Button key={social.name} variant="ghost" size="icon" asChild>
<a
href={social.href}
aria-label={social.name}
target="_blank"
rel="noopener noreferrer"
>
<social.icon className="h-4 w-4" />
</a>
</Button>
))}
</div>
</div>
{/* Links Columns */}
<div className='max-md:col-span-2 lg:col-span-1'>
<h4 className="font-semibold mb-4">Product</h4>
<ul className="space-y-3">
{footerLinks.product.map((link) => (
<li key={link.name}>
<a
href={link.href}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{link.name}
</a>
</li>
))}
</ul>
</div>
<div className='max-md:col-span-2 lg:col-span-1'>
<h4 className="font-semibold mb-4">Company</h4>
<ul className="space-y-3">
{footerLinks.company.map((link) => (
<li key={link.name}>
<a
href={link.href}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{link.name}
</a>
</li>
))}
</ul>
</div>
<div className='max-md:col-span-2 lg:col-span-1'>
<h4 className="font-semibold mb-4">Resources</h4>
<ul className="space-y-3">
{footerLinks.resources.map((link) => (
<li key={link.name}>
<a
href={link.href}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{link.name}
</a>
</li>
))}
</ul>
</div>
<div className='max-md:col-span-2 lg:col-span-1'>
<h4 className="font-semibold mb-4">Legal</h4>
<ul className="space-y-3">
{footerLinks.legal.map((link) => (
<li key={link.name}>
<a
href={link.href}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{link.name}
</a>
</li>
))}
</ul>
</div>
</div>
<Separator className="my-8" />
{/* Bottom Footer */}
<div className="flex flex-col lg:flex-row justify-between items-center gap-2">
<div className="flex flex-col sm:flex-row items-center gap-2 text-muted-foreground text-sm">
<div className="flex items-center gap-1">
<span>Made with</span>
<Heart className="h-4 w-4 text-red-500 fill-current" />
<span>by</span>
<a href="https://shadcnstore.com" target='_blank' className="font-semibold text-foreground hover:text-primary transition-colors cursor-pointer">
ShadcnStore
</a>
</div>
<span className="hidden sm:inline"></span>
<span>© {new Date().getFullYear()} for the developer community</span>
</div>
<div className="flex items-center space-x-4 text-sm text-muted-foreground mt-4 md:mt-0">
<a href="#privacy" className="hover:text-foreground transition-colors">
Privacy Policy
</a>
<a href="#terms" className="hover:text-foreground transition-colors">
Terms of Service
</a>
<a href="#cookies" className="hover:text-foreground transition-colors">
Cookie Policy
</a>
</div>
</div>
</div>
</footer>
)
}
+110
View File
@@ -0,0 +1,110 @@
"use client"
import Link from 'next/link'
import Image from 'next/image'
import { ArrowRight, Play, Star } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { DotPattern } from '@/components/dot-pattern'
export function HeroSection() {
return (
<section id="hero" className="relative overflow-hidden bg-gradient-to-b from-background to-background/80 pt-16 sm:pt-20 pb-16">
{/* Background Pattern */}
<div className="absolute inset-0">
{/* Dot pattern overlay using reusable component */}
<DotPattern className="opacity-100" size="md" fadeStyle="ellipse" />
</div>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative">
<div className="mx-auto max-w-4xl text-center">
{/* Announcement Badge */}
<div className="mb-8 flex justify-center">
<Badge variant="outline" className="px-4 py-2 border-foreground">
<Star className="w-3 h-3 mr-2 fill-current" />
New: Premium Template Collection
<ArrowRight className="w-3 h-3 ml-2" />
</Badge>
</div>
{/* Main Headline */}
<h1 className="mb-6 text-4xl font-bold tracking-tight sm:text-6xl lg:text-7xl">
Build Better
<span className="bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
{" "}Web Applications{" "}
</span>
with Ready-Made Components
</h1>
{/* Subheading */}
<p className="mx-auto mb-10 max-w-2xl text-lg text-muted-foreground sm:text-xl">
Accelerate your development with our curated collection of blocks, templates, landing pages,
and admin dashboards. From free components to complete solutions, built with shadcn/ui.
</p>
{/* CTA Buttons */}
<div className="flex flex-col gap-4 sm:flex-row sm:justify-center">
<Button size="lg" className="text-base cursor-pointer" asChild>
<Link href="/auth/sign-up">
Get Started Free
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button variant="outline" size="lg" className="text-base cursor-pointer" asChild>
<a href="#">
<Play className="mr-2 h-4 w-4" />
Watch Demo
</a>
</Button>
</div>
</div>
{/* Hero Image/Visual */}
<div className="mx-auto mt-20 max-w-6xl">
<div className="relative group">
{/* Top background glow effect - positioned above the image */}
<div className="absolute top-2 lg:-top-8 left-1/2 transform -translate-x-1/2 w-[90%] mx-auto h-24 lg:h-80 bg-primary/50 rounded-full blur-3xl"></div>
<div className="relative rounded-xl border bg-card shadow-2xl">
{/* Light mode dashboard image */}
<Image
src="/dashboard-light.png"
alt="Dashboard Preview - Light Mode"
width={1200}
height={800}
className="w-full rounded-xl object-cover block dark:hidden"
priority
/>
{/* Dark mode dashboard image */}
<Image
src="/dashboard-dark.png"
alt="Dashboard Preview - Dark Mode"
width={1200}
height={800}
className="w-full rounded-xl object-cover hidden dark:block"
priority
/>
{/* Bottom fade effect - gradient overlay that fades the image to background */}
<div className="absolute bottom-0 left-0 w-full h-32 md:h-40 lg:h-48 bg-gradient-to-b from-background/0 via-background/70 to-background rounded-b-xl"></div>
{/* Overlay play button for demo */}
<div className="absolute inset-0 flex items-center justify-center">
<Button
size="lg"
className="rounded-full h-16 w-16 p-0 cursor-pointer hover:scale-105 transition-transform"
asChild
>
<a href="#" aria-label="Watch demo video">
<Play className="h-6 w-6 fill-current" />
</a>
</Button>
</div>
</div>
</div>
</div>
</div>
</section>
)
}
@@ -0,0 +1,406 @@
"use client"
import React from 'react'
import { Palette, RotateCcw, Settings, X, Dices, Upload, ExternalLink, Sun, Moon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { useThemeManager } from '@/hooks/use-theme-manager'
import { useCircularTransition } from '@/hooks/use-circular-transition'
import { colorThemes, tweakcnThemes } from '@/config/theme-data'
import { radiusOptions, baseColors } from '@/config/theme-customizer-constants'
import { ColorPicker } from '@/components/color-picker'
import { ImportModal } from '@/components/theme-customizer/import-modal'
import { cn } from '@/lib/utils'
import type { ImportedTheme } from '@/types/theme-customizer'
import "@/components/theme-customizer/circular-transition.css"
interface LandingThemeCustomizerProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function LandingThemeCustomizer({ open, onOpenChange }: LandingThemeCustomizerProps) {
const {
applyImportedTheme,
isDarkMode,
resetTheme,
applyRadius,
setBrandColorsValues,
applyTheme,
applyTweakcnTheme,
brandColorsValues,
handleColorChange
} = useThemeManager()
const { toggleTheme } = useCircularTransition()
const [selectedTheme, setSelectedTheme] = React.useState("default")
const [selectedTweakcnTheme, setSelectedTweakcnTheme] = React.useState("")
const [selectedRadius, setSelectedRadius] = React.useState("0.5rem")
const [importModalOpen, setImportModalOpen] = React.useState(false)
const [importedTheme, setImportedTheme] = React.useState<ImportedTheme | null>(null)
const handleReset = () => {
// Reset all state variables to initial values
setSelectedTheme("")
setSelectedTweakcnTheme("")
setSelectedRadius("0.5rem")
setImportedTheme(null)
setBrandColorsValues({})
// Reset theme and radius to defaults
resetTheme()
applyRadius("0.5rem")
}
const handleImport = (themeData: ImportedTheme) => {
setImportedTheme(themeData)
// Clear other selections to indicate custom import is active
setSelectedTheme("")
setSelectedTweakcnTheme("")
// Apply the imported theme
applyImportedTheme(themeData, isDarkMode)
}
const handleImportClick = () => {
setImportModalOpen(true)
}
const handleRandomShadcn = () => {
// Apply a random shadcn theme
const randomTheme = colorThemes[Math.floor(Math.random() * colorThemes.length)]
setSelectedTheme(randomTheme.value)
setSelectedTweakcnTheme("")
setBrandColorsValues({})
setImportedTheme(null)
applyTheme(randomTheme.value, isDarkMode)
}
const handleRandomTweakcn = () => {
// Apply a random tweakcn theme
const randomTheme = tweakcnThemes[Math.floor(Math.random() * tweakcnThemes.length)]
setSelectedTweakcnTheme(randomTheme.value)
setSelectedTheme("")
setBrandColorsValues({})
setImportedTheme(null)
applyTweakcnTheme(randomTheme.preset, isDarkMode)
}
const handleRadiusSelect = (radius: string) => {
setSelectedRadius(radius)
applyRadius(radius)
}
const handleLightMode = (event: React.MouseEvent<HTMLButtonElement>) => {
if (isDarkMode === false) return
toggleTheme(event)
}
const handleDarkMode = (event: React.MouseEvent<HTMLButtonElement>) => {
if (isDarkMode === true) return
toggleTheme(event)
}
// Re-apply themes when theme mode changes
React.useEffect(() => {
if (importedTheme) {
applyImportedTheme(importedTheme, isDarkMode)
} else if (selectedTheme) {
applyTheme(selectedTheme, isDarkMode)
} else if (selectedTweakcnTheme) {
const selectedPreset = tweakcnThemes.find(t => t.value === selectedTweakcnTheme)?.preset
if (selectedPreset) {
applyTweakcnTheme(selectedPreset, isDarkMode)
}
}
}, [isDarkMode, importedTheme, selectedTheme, selectedTweakcnTheme, applyImportedTheme, applyTheme, applyTweakcnTheme])
return (
<>
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
<SheetContent
side="right"
className="w-[400px] p-0 gap-0 pointer-events-auto [&>button]:hidden overflow-hidden flex flex-col"
onInteractOutside={(e) => {
// Prevent the sheet from closing when dialog is open
if (importModalOpen) {
e.preventDefault()
}
}}
>
<SheetHeader className="space-y-0 p-4 pb-2">
<div className="flex items-center gap-2">
<div className="p-2 bg-primary/10 rounded-lg">
<Settings className="h-4 w-4" />
</div>
<SheetTitle className="text-lg font-semibold">Theme Customizer</SheetTitle>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="icon" onClick={handleReset} className="cursor-pointer h-8 w-8">
<RotateCcw className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => onOpenChange(false)} className="cursor-pointer h-8 w-8">
<X className="h-4 w-4" />
</Button>
</div>
</div>
<SheetDescription className="text-sm text-muted-foreground">
Customize the theme and colors of your landing page.
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Mode Section */}
<div className="space-y-3">
<Label className="text-sm font-medium">Mode</Label>
<div className="grid grid-cols-2 gap-2">
<Button
variant={!isDarkMode ? "secondary" : "outline"}
size="sm"
onClick={handleLightMode}
className="cursor-pointer mode-toggle-button relative overflow-hidden"
>
<Sun className="h-4 w-4 mr-1 transition-transform duration-300" />
Light
</Button>
<Button
variant={isDarkMode ? "secondary" : "outline"}
size="sm"
onClick={handleDarkMode}
className="cursor-pointer mode-toggle-button relative overflow-hidden"
>
<Moon className="h-4 w-4 mr-1 transition-transform duration-300" />
Dark
</Button>
</div>
</div>
<Separator />
{/* Shadcn UI Theme Presets */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Shadcn UI Theme Presets</Label>
<Button variant="outline" size="sm" onClick={handleRandomShadcn} className="cursor-pointer">
<Dices className="h-3.5 w-3.5 mr-1.5" />
Random
</Button>
</div>
<Select value={selectedTheme} onValueChange={(value) => {
setSelectedTheme(value)
setSelectedTweakcnTheme("")
setBrandColorsValues({})
setImportedTheme(null)
applyTheme(value, isDarkMode)
}}>
<SelectTrigger className="w-full cursor-pointer">
<SelectValue placeholder="Choose Shadcn Theme" />
</SelectTrigger>
<SelectContent className="max-h-60">
<div className="p-2">
{colorThemes.map((theme) => (
<SelectItem key={theme.value} value={theme.value} className="cursor-pointer">
<div className="flex items-center gap-2">
<div className="flex gap-1">
<div
className="w-3 h-3 rounded-full border border-border/20"
style={{ backgroundColor: theme.preset.styles.light.primary }}
/>
<div
className="w-3 h-3 rounded-full border border-border/20"
style={{ backgroundColor: theme.preset.styles.light.secondary }}
/>
<div
className="w-3 h-3 rounded-full border border-border/20"
style={{ backgroundColor: theme.preset.styles.light.accent }}
/>
<div
className="w-3 h-3 rounded-full border border-border/20"
style={{ backgroundColor: theme.preset.styles.light.muted }}
/>
</div>
<span>{theme.name}</span>
</div>
</SelectItem>
))}
</div>
</SelectContent>
</Select>
</div>
<Separator />
{/* Tweakcn Theme Presets */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Tweakcn Theme Presets</Label>
<Button variant="outline" size="sm" onClick={handleRandomTweakcn} className="cursor-pointer">
<Dices className="h-3.5 w-3.5 mr-1.5" />
Random
</Button>
</div>
<Select value={selectedTweakcnTheme} onValueChange={(value) => {
setSelectedTweakcnTheme(value)
setSelectedTheme("")
setBrandColorsValues({})
setImportedTheme(null)
const selectedPreset = tweakcnThemes.find(t => t.value === value)?.preset
if (selectedPreset) {
applyTweakcnTheme(selectedPreset, isDarkMode)
}
}}>
<SelectTrigger className="w-full cursor-pointer">
<SelectValue placeholder="Choose Tweakcn Theme" />
</SelectTrigger>
<SelectContent className="max-h-60">
<div className="p-2">
{tweakcnThemes.map((theme) => (
<SelectItem key={theme.value} value={theme.value} className="cursor-pointer">
<div className="flex items-center gap-2">
<div className="flex gap-1">
<div
className="w-3 h-3 rounded-full border border-border/20"
style={{ backgroundColor: theme.preset.styles.light.primary }}
/>
<div
className="w-3 h-3 rounded-full border border-border/20"
style={{ backgroundColor: theme.preset.styles.light.secondary }}
/>
<div
className="w-3 h-3 rounded-full border border-border/20"
style={{ backgroundColor: theme.preset.styles.light.accent }}
/>
<div
className="w-3 h-3 rounded-full border border-border/20"
style={{ backgroundColor: theme.preset.styles.light.muted }}
/>
</div>
<span>{theme.name}</span>
</div>
</SelectItem>
))}
</div>
</SelectContent>
</Select>
</div>
<Separator />
{/* Radius Selection */}
<div className="space-y-3">
<Label className="text-sm font-medium">Radius</Label>
<div className="grid grid-cols-5 gap-2">
{radiusOptions.map((option) => (
<div
key={option.value}
className={`relative cursor-pointer rounded-md p-3 border transition-colors ${
selectedRadius === option.value
? "border-primary"
: "border-border hover:border-border/60"
}`}
onClick={() => handleRadiusSelect(option.value)}
>
<div className="text-center">
<div className="text-xs font-medium">{option.name}</div>
</div>
</div>
))}
</div>
</div>
<Separator />
{/* Import Theme Button */}
<div className="space-y-3">
<Button
variant="outline"
size="lg"
onClick={handleImportClick}
className="w-full cursor-pointer"
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Import Theme
</Button>
</div>
{/* Brand Colors Section */}
<Accordion type="single" collapsible className="w-full border-b rounded-lg">
<AccordionItem value="brand-colors" className="border border-border rounded-lg overflow-hidden">
<AccordionTrigger className="px-4 py-3 hover:no-underline hover:bg-muted/50 transition-colors">
<Label className="text-sm font-medium cursor-pointer">Brand Colors</Label>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4 pt-2 space-y-3 border-t border-border bg-muted/20">
{baseColors.map((color) => (
<div key={color.cssVar} className="flex items-center justify-between">
<ColorPicker
label={color.name}
cssVar={color.cssVar}
value={brandColorsValues[color.cssVar] || ""}
onChange={handleColorChange}
/>
</div>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
{/* Tweakcn */}
<div className="p-4 bg-muted rounded-lg space-y-3">
<div className="flex items-center gap-2">
<Palette className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Advanced Customization</span>
</div>
<p className="text-xs text-muted-foreground">
For advanced theme customization with real-time preview, visual color picker, and hundreds of prebuilt themes, visit{" "}
<a
href="https://tweakcn.com/editor/theme"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline font-medium cursor-pointer"
>
tweakcn.com
</a>
</p>
<Button
variant="outline"
size="sm"
className="w-full cursor-pointer"
onClick={() => window.open('https://tweakcn.com/editor/theme', '_blank')}
>
<ExternalLink className="h-3.5 w-3.5 mr-1.5" />
Open Tweakcn
</Button>
</div>
</div>
</SheetContent>
</Sheet>
<ImportModal
open={importModalOpen}
onOpenChange={setImportModalOpen}
onImport={handleImport}
/>
</>
)
}
// Floating trigger button for landing page
export function LandingThemeCustomizerTrigger({ onClick }: { onClick: () => void }) {
return (
<Button
onClick={onClick}
size="icon"
className={cn(
"fixed top-1/2 -translate-y-1/2 h-12 w-12 rounded-full shadow-lg z-50 bg-primary hover:bg-primary/90 text-primary-foreground cursor-pointer right-4"
)}
>
<Settings className="h-5 w-5" />
</Button>
)
}
@@ -0,0 +1,117 @@
"use client"
import { Card } from '@/components/ui/card'
// Simple icon component for company logos
const SimpleIcon = ({ iconSlug, size = 24 }: { iconSlug: string; size?: number }) => {
const iconMap = {
shopify:
'M15.337 23.979l7.216-1.561s-2.604-17.613-2.625-17.73c-.018-.116-.114-.192-.211-.192s-1.929-.136-1.929-.136-1.275-1.274-1.439-1.411c-.045-.037-.075-.057-.121-.074l-.914 21.104h.023zM11.71 11.305s-.81-.424-1.774-.424c-1.447 0-1.504.906-1.504 1.141 0 1.232 3.24 1.715 3.24 4.629 0 2.295-1.44 3.76-3.406 3.76-2.354 0-3.54-1.465-3.54-1.465l.646-2.086s1.245 1.066 2.28 1.066c.675 0 .975-.545.975-.932 0-1.619-2.654-1.694-2.654-4.359-.034-2.237 1.571-4.416 4.827-4.416 1.257 0 1.875.361 1.875.361l-.945 2.715-.02.01zM11.17.83c.136 0 .271.038.405.135-.984.465-2.064 1.639-2.508 3.992-.656.213-1.293.405-1.889.578C7.697 3.75 8.951.84 11.17.84V.83zm1.235 2.949v.135c-.754.232-1.583.484-2.394.736.466-1.777 1.333-2.645 2.085-2.971.193.501.309 1.176.309 2.1zm.539-2.234c.694.074 1.141.867 1.429 1.755-.349.114-.735.231-1.158.366v-.252c0-.752-.096-1.371-.271-1.871v.002zm2.992 1.289c-.02 0-.06.021-.078.021s-.289.075-.714.21c-.423-1.233-1.176-2.37-2.508-2.37h-.115C12.135.209 11.669 0 11.265 0 8.159 0 6.675 3.877 6.21 5.846c-1.194.365-2.063.636-2.16.674-.675.213-.694.232-.772.87-.075.462-1.83 14.063-1.83 14.063L15.009 24l.927-21.166z',
netflix:
'm5.398 0 8.348 23.602c2.346.059 4.856.398 4.856.398L10.113 0H5.398zm8.489 0v9.172l4.715 13.33V0h-4.715zM5.398 1.5V24c1.873-.225 2.81-.312 4.715-.398V14.83L5.398 1.5z',
spotify:
'M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z',
airbnb:
'M12.001 18.275c-1.353-1.697-2.148-3.184-2.413-4.457-.263-1.027-.16-1.848.291-2.465.477-.71 1.188-1.056 2.121-1.056s1.643.345 2.12 1.063c.446.61.558 1.432.286 2.465-.291 1.298-1.085 2.785-2.412 4.458zm9.601 1.14c-.185 1.246-1.034 2.28-2.2 2.783-2.253.98-4.483-.583-6.392-2.704 3.157-3.951 3.74-7.028 2.385-9.018-.795-1.14-1.933-1.695-3.394-1.695-2.944 0-4.563 2.49-3.927 5.382.37 1.565 1.352 3.343 2.917 5.332-.98 1.085-1.91 1.856-2.732 2.333-.636.344-1.245.558-1.828.609-2.679.399-4.778-2.2-3.825-4.88.132-.345.395-.98.845-1.961l.025-.053c1.464-3.178 3.242-6.79 5.285-10.795l.053-.132.58-1.116c.45-.822.635-1.19 1.351-1.643.346-.21.77-.315 1.246-.315.954 0 1.698.558 2.016 1.007.158.239.345.557.582.953l.558 1.089.08.159c2.041 4.004 3.821 7.608 5.279 10.794l.026.025.533 1.22.318.764c.243.613.294 1.222.213 1.858zm1.22-2.39c-.186-.583-.505-1.271-.9-2.094v-.03c-1.889-4.006-3.642-7.608-5.307-10.844l-.111-.163C15.317 1.461 14.468 0 12.001 0c-2.44 0-3.476 1.695-4.535 3.898l-.081.16c-1.669 3.236-3.421 6.843-5.303 10.847v.053l-.559 1.22c-.21.504-.317.768-.345.847C-.172 20.74 2.611 24 5.98 24c.027 0 .132 0 .265-.027h.372c1.75-.213 3.554-1.325 5.384-3.317 1.829 1.989 3.635 3.104 5.382 3.317h.372c.133.027.239.027.265.027 3.37.003 6.152-3.261 4.802-6.975z',
dropbox:
'M6 1.807L0 5.629l6 3.822 6.001-3.822L6 1.807zM18 1.807l-6 3.822 6 3.822 6-3.822-6-3.822zM0 13.274l6 3.822 6.001-3.822L6 9.452l-6 3.822zM18 9.452l-6 3.822 6 3.822 6-3.822-6-3.822zM6 18.371l6.001 3.822 6-3.822-6-3.822L6 18.371z',
stripe:
'M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.594-7.305h.003z',
google:
'M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z',
apple:
'M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701',
meta: 'M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z',
tesla:
'M12 5.362l2.475-3.026s4.245.09 8.471 2.054c-1.082 1.636-3.231 2.438-3.231 2.438-.146-1.439-1.154-1.79-4.354-1.79L12 24 8.619 5.034c-3.18 0-4.188.354-4.335 1.792 0 0-2.146-.795-3.229-2.43C5.28 2.431 9.525 2.34 9.525 2.34L12 5.362l-.004.002H12v-.002zm0-3.899c3.415-.03 7.326.528 11.328 2.28.535-.968.672-1.395.672-1.395C19.625.612 15.528.015 12 0 8.472.015 4.375.61 0 2.349c0 0 .195.525.672 1.396C4.674 1.989 8.585 1.435 12 1.46v.003z',
salesforce:
'M10.006 5.415a4.195 4.195 0 013.045-1.306c1.56 0 2.954.9 3.69 2.205.63-.3 1.35-.45 2.1-.45 2.85 0 5.159 2.34 5.159 5.22s-2.31 5.22-5.176 5.22c-.345 0-.69-.044-1.02-.104a3.75 3.75 0 01-3.3 1.95c-.6 0-1.155-.15-1.65-.375A4.314 4.314 0 018.88 20.4a4.302 4.302 0 01-4.05-2.82c-.27.062-.54.076-.825.076-2.204 0-4.005-1.8-4.005-4.05 0-1.5.811-2.805 2.01-3.51-.255-.57-.39-1.2-.39-1.846 0-2.58 2.1-4.65 4.65-4.65 1.53 0 2.85.705 3.72 1.8',
github:
'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12',
} as const
const iconPath = iconMap[iconSlug as keyof typeof iconMap]
if (!iconPath) {
return <div className='bg-muted animate-pulse rounded-sm' style={{ width: size, height: size }} />
}
return (
<svg role='img' viewBox='0 0 24 24' className='fill-black dark:fill-white' style={{ width: size, height: size }}>
<path d={iconPath} />
</svg>
)
}
// Tech companies data
const techCompanies = [
{ name: 'Shopify', id: 'shopify' },
{ name: 'Netflix', id: 'netflix' },
{ name: 'Spotify', id: 'spotify' },
{ name: 'Airbnb', id: 'airbnb' },
{ name: 'Dropbox', id: 'dropbox' },
{ name: 'Stripe', id: 'stripe' },
{ name: 'Google', id: 'google' },
{ name: 'Apple', id: 'apple' },
{ name: 'Meta', id: 'meta' },
{ name: 'Tesla', id: 'tesla' },
{ name: 'Salesforce', id: 'salesforce' },
{ name: 'GitHub', id: 'github' },
] as const
export function LogoCarousel() {
return (
<section className="pb-12 sm:pb-16 lg:pb-20 pt-12">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
<p className="text-sm font-medium text-muted-foreground mb-8">
Trusted by leading companies worldwide
</p>
{/* Logo Carousel with Fade Effect */}
<div className="relative">
{/* Left Fade */}
<div className="absolute left-0 top-0 bottom-0 w-20 bg-gradient-to-r from-background to-transparent z-10 pointer-events-none" />
{/* Right Fade */}
<div className="absolute right-0 top-0 bottom-0 w-20 bg-gradient-to-l from-background to-transparent z-10 pointer-events-none" />
{/* Logo Container */}
<div className="overflow-hidden">
<div className="flex animate-logo-scroll space-x-8 sm:space-x-12">
{/* First set of logos */}
{techCompanies.map((company, index) => (
<Card
key={`first-${index}`}
className="flex-shrink-0 flex items-center justify-center h-16 w-40 opacity-60 hover:opacity-100 transition-opacity duration-300 border-0 shadow-none bg-transparent"
>
<div className="flex items-center gap-3">
<SimpleIcon iconSlug={company.id} size={28} />
<span className="text-foreground text-lg font-semibold whitespace-nowrap">
{company.name}
</span>
</div>
</Card>
))}
{/* Second set for seamless loop - identical to first */}
{techCompanies.map((company, index) => (
<Card
key={`second-${index}`}
className="flex-shrink-0 flex items-center justify-center h-16 w-40 opacity-60 hover:opacity-100 transition-opacity duration-300 border-0 shadow-none bg-transparent"
>
<div className="flex items-center gap-3">
<SimpleIcon iconSlug={company.id} size={28} />
<span className="text-foreground text-lg font-semibold whitespace-nowrap">
{company.name}
</span>
</div>
</Card>
))}
</div>
</div>
</div>
</div>
</div>
</section>
)
}
+274
View File
@@ -0,0 +1,274 @@
"use client"
import { useState } from 'react'
import Link from 'next/link'
import { Menu, Github, LayoutDashboard, ChevronDown, X, Moon, Sun } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from '@/components/ui/navigation-menu'
import {
Sheet,
SheetContent,
SheetTrigger,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Logo } from '@/components/logo'
import { MegaMenu } from '@/components/landing/mega-menu'
import { ModeToggle } from '@/components/mode-toggle'
import { useTheme } from '@/hooks/use-theme'
const navigationItems = [
{ name: 'Home', href: '#hero' },
{ name: 'Features', href: '#features' },
{ name: 'Solutions', href: '#features', hasMegaMenu: true },
{ name: 'Team', href: '#team' },
{ name: 'Pricing', href: '#pricing' },
{ name: 'FAQ', href: '#faq' },
{ name: 'Contact', href: '#contact' },
]
// Solutions menu items for mobile
const solutionsItems = [
{ title: 'Browse Products' },
{ name: 'Free Blocks', href: '#free-blocks' },
{ name: 'Premium Templates', href: '#premium-templates' },
{ name: 'Admin Dashboards', href: '#admin-dashboards' },
{ name: 'Landing Pages', href: '#landing-pages' },
{ title: 'Categories' },
{ name: 'E-commerce', href: '#ecommerce' },
{ name: 'SaaS Dashboards', href: '#saas-dashboards' },
{ name: 'Analytics', href: '#analytics' },
{ name: 'Authentication', href: '#authentication' },
{ title: 'Resources' },
{ name: 'Documentation', href: '#docs' },
{ name: 'Component Showcase', href: '#showcase' },
{ name: 'GitHub Repository', href: '#github' },
{ name: 'Design System', href: '#design-system' }
]
// Smooth scroll function
const smoothScrollTo = (targetId: string) => {
if (targetId.startsWith('#')) {
const element = document.querySelector(targetId)
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}
}
}
export function LandingNavbar() {
const [isOpen, setIsOpen] = useState(false)
const [solutionsOpen, setSolutionsOpen] = useState(false)
const { setTheme, theme } = useTheme()
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 flex h-16 items-center justify-between">
{/* Logo */}
<div className="flex items-center space-x-2">
<Link href="https://shadcnstore.com" className="flex items-center space-x-2 cursor-pointer" target='_blank' rel="noopener noreferrer">
<Logo size={32} />
<span className="font-bold">
ShadcnStore
</span>
</Link>
</div>
{/* Desktop Navigation */}
<NavigationMenu className="hidden xl:flex">
<NavigationMenuList>
{navigationItems.map((item) => (
<NavigationMenuItem key={item.name}>
{item.hasMegaMenu ? (
<>
<NavigationMenuTrigger className="bg-transparent hover:bg-transparent focus:bg-transparent data-[active]:bg-transparent data-[state=open]:bg-transparent px-4 py-2 text-sm font-medium transition-colors hover:text-primary focus:text-primary cursor-pointer">
{item.name}
</NavigationMenuTrigger>
<NavigationMenuContent>
<MegaMenu />
</NavigationMenuContent>
</>
) : (
<NavigationMenuLink
className="group inline-flex h-10 w-max items-center justify-center px-4 py-2 text-sm font-medium transition-colors hover:text-primary focus:text-primary focus:outline-none cursor-pointer"
onClick={(e: React.MouseEvent) => {
e.preventDefault()
if (item.href.startsWith('#')) {
smoothScrollTo(item.href)
} else {
window.location.href = item.href
}
}}
>
{item.name}
</NavigationMenuLink>
)}
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
{/* Desktop CTA */}
<div className="hidden xl:flex items-center space-x-2">
<ModeToggle variant="ghost" />
<Button variant="ghost" size="icon" asChild className="cursor-pointer">
<a href="https://github.com/silicondeck/shadcn-dashboard-landing-template" target="_blank" rel="noopener noreferrer" aria-label="GitHub Repository">
<Github className="h-5 w-5" />
</a>
</Button>
<Button variant="outline" asChild className="cursor-pointer">
<Link href="/dashboard" target="_blank" rel="noopener noreferrer">
<LayoutDashboard className="h-4 w-4 mr-2" />
Dashboard
</Link>
</Button>
<Button variant="ghost" asChild className="cursor-pointer">
<Link href="/auth/sign-in">Sign In</Link>
</Button>
<Button asChild className="cursor-pointer">
<Link href="/auth/sign-up">Get Started</Link>
</Button>
</div>
{/* Mobile Menu */}
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild className="xl:hidden">
<Button variant="ghost" size="icon" className="cursor-pointer">
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-full sm:w-[400px] p-0 gap-0 [&>button]:hidden overflow-hidden flex flex-col">
<div className="flex flex-col h-full">
{/* Header */}
<SheetHeader className="space-y-0 p-4 pb-2 border-b">
<div className="flex items-center gap-2">
<div className="p-2 bg-primary/10 rounded-lg">
<Logo size={16} />
</div>
<SheetTitle className="text-lg font-semibold">ShadcnStore</SheetTitle>
<div className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className="cursor-pointer h-8 w-8"
>
<Moon className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Sun className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
<Button variant="ghost" size="icon" asChild className="cursor-pointer h-8 w-8">
<a href="https://github.com/silicondeck/shadcn-dashboard-landing-template" target="_blank" rel="noopener noreferrer" aria-label="GitHub Repository">
<Github className="h-4 w-4" />
</a>
</Button>
<Button variant="ghost" size="icon" onClick={() => setIsOpen(false)} className="cursor-pointer h-8 w-8">
<X className="h-4 w-4" />
</Button>
</div>
</div>
</SheetHeader>
{/* Navigation Links */}
<div className="flex-1 overflow-y-auto">
<nav className="p-6 space-y-1">
{navigationItems.map((item) => (
<div key={item.name}>
{item.hasMegaMenu ? (
<Collapsible open={solutionsOpen} onOpenChange={setSolutionsOpen}>
<CollapsibleTrigger className="flex items-center justify-between w-full px-4 py-3 text-base font-medium rounded-lg transition-colors hover:bg-accent hover:text-accent-foreground cursor-pointer">
{item.name}
<ChevronDown className={`h-4 w-4 transition-transform ${solutionsOpen ? 'rotate-180' : ''}`} />
</CollapsibleTrigger>
<CollapsibleContent className="pl-4 space-y-1">
{solutionsItems.map((solution, index) => (
solution.title ? (
<div
key={`title-${index}`}
className="px-4 mt-5 py-2 text-xs font-semibold text-muted-foreground/50 uppercase tracking-wider"
>
{solution.title}
</div>
) : (
<a
key={solution.name}
href={solution.href}
className="flex items-center px-4 py-2 text-sm rounded-lg transition-colors hover:bg-accent hover:text-accent-foreground cursor-pointer"
onClick={(e) => {
setIsOpen(false)
if (solution.href?.startsWith('#')) {
e.preventDefault()
setTimeout(() => smoothScrollTo(solution.href), 100)
}
}}
>
{solution.name}
</a>
)
))}
</CollapsibleContent>
</Collapsible>
) : (
<a
href={item.href}
className="flex items-center px-4 py-3 text-base font-medium rounded-lg transition-colors hover:bg-accent hover:text-accent-foreground cursor-pointer"
onClick={(e) => {
setIsOpen(false)
if (item.href.startsWith('#')) {
e.preventDefault()
setTimeout(() => smoothScrollTo(item.href), 100)
}
}}
>
{item.name}
</a>
)}
</div>
))}
</nav>
</div>
{/* Footer Actions */}
<div className="border-t p-6 space-y-4">
{/* Primary Actions */}
<div className="space-y-3">
<Button variant="outline" size="lg" asChild className="w-full cursor-pointer">
<Link href="/dashboard">
<LayoutDashboard className="size-4" />
Dashboard
</Link>
</Button>
<div className="grid grid-cols-2 gap-3">
<Button variant="outline" size="lg" asChild className="cursor-pointer">
<Link href="/auth/sign-in">Sign In</Link>
</Button>
<Button asChild size="lg" className="cursor-pointer" >
<Link href="/auth/sign-up">Get Started</Link>
</Button>
</div>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</header>
)
}
@@ -0,0 +1,75 @@
"use client"
import {
Package,
Download,
Users,
Star
} from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { DotPattern } from '@/components/dot-pattern'
const stats = [
{
icon: Package,
value: '500+',
label: 'Components',
description: 'Ready-to-use blocks'
},
{
icon: Download,
value: '25K+',
label: 'Downloads',
description: 'Trusted worldwide'
},
{
icon: Users,
value: '10K+',
label: 'Developers',
description: 'Active community'
},
{
icon: Star,
value: '4.9',
label: 'Rating',
description: 'User satisfaction'
}
]
export function StatsSection() {
return (
<section className="py-12 sm:py-16 relative">
{/* Background with transparency */}
<div className="absolute inset-0 bg-gradient-to-r from-primary/8 via-transparent to-secondary/20" />
<DotPattern className="opacity-75" size="md" fadeStyle="circle" />
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative">
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
{stats.map((stat, index) => (
<Card
key={index}
className="text-center bg-background/60 backdrop-blur-sm border-border/50 py-0"
>
<CardContent className="p-6">
<div className="flex justify-center mb-4">
<div className="p-3 bg-primary/10 rounded-xl">
<stat.icon className="h-6 w-6 text-primary" />
</div>
</div>
<div className="space-y-1">
<h3 className="text-2xl sm:text-3xl font-bold text-foreground">
{stat.value}
</h3>
<p className="font-semibold text-foreground">{stat.label}</p>
<p className="text-sm text-muted-foreground">{stat.description}</p>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</section>
)
}

Some files were not shown because too many files have changed in this diff Show More