Compare commits
39 Commits
3d044c5d5b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cbd67523fd | |||
| 2d67d49255 | |||
| 12397d409d | |||
| 7660901eb0 | |||
| 1618db57db | |||
| 2b48422b68 | |||
| 856e577f4b | |||
| 0d6e773197 | |||
| 5622b0ef07 | |||
| 668fb7108b | |||
| 37b0928da6 | |||
| 7c677dfa4b | |||
| 933cb17107 | |||
| 7c23a2b4ae | |||
| 5ac6a1f8b0 | |||
| fe86bfe6b2 | |||
| a3bcb464ea | |||
| 95e30a74c7 | |||
| b71edd880b | |||
| 84be9ec5e3 | |||
| 04e11c3fed | |||
| 8dd970d2ad | |||
| 3554b39800 | |||
| 3cce632eb3 | |||
| 95a7cbaf0d | |||
| 9f462c2f1f | |||
| 54f6112e7e | |||
| b1d3ee3681 | |||
| 92428cb4fd | |||
| fd5c6c645f | |||
| 63392bab7b | |||
| 237ec92691 | |||
| 9497cc72ce | |||
| 1fdeefc472 | |||
| 3caddff515 | |||
| 1d5ad5f62f | |||
| f043f4acd7 | |||
| d9aff26376 | |||
| a40e68254b |
@@ -3,6 +3,10 @@ import type { NextConfig } from "next";
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {},
|
||||
|
||||
experimental: {
|
||||
optimizePackageImports: ["@phosphor-icons/react"],
|
||||
},
|
||||
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
|
||||
images: {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@polar-sh/sdk": "^0.47.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
@@ -40,15 +41,20 @@
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"appwrite": "^24.2.0",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"driver.js": "^1.4.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"next": "16.1.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-appwrite": "^23.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
@@ -64,6 +70,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@types/jszip": "^3.4.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
Generated
+806
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ export function ForbiddenError() {
|
||||
<h2 className="mb-3 text-2xl font-semibold">Forbidden</h2>
|
||||
<p>Access to this resource is forbidden. You don'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 className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back House</Button>
|
||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function InternalServerError() {
|
||||
<h2 className="mb-3 text-2xl font-semibold">Internal Server Error</h2>
|
||||
<p>Something went wrong on our end. We'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 className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back House</Button>
|
||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function NotFoundError() {
|
||||
<h2 className="mb-3 text-2xl font-semibold">Page Not Found</h2>
|
||||
<p>The page you are looking for doesn'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 className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back House</Button>
|
||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function UnauthorizedError() {
|
||||
<h2 className="mb-3 text-2xl font-semibold">Unauthorized</h2>
|
||||
<p>You don'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 className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back House</Button>
|
||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function UnderMaintenanceError() {
|
||||
<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 className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back House</Button>
|
||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState } from "react";
|
||||
import { ArrowLeft, Loader2, MailCheck } from "lucide-react";
|
||||
import { ArrowLeft, CircleNotch, EnvelopeOpen } from '@/lib/icons';
|
||||
|
||||
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 { forgotPasswordAction } from "@/lib/appwrite/auth-actions";
|
||||
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(forgotPasswordAction, initialAuthState);
|
||||
const [state, formAction, isPending] = useActionState(requestPasswordResetAction, initialAuthState);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
@@ -28,7 +28,7 @@ export function ForgotPasswordForm1({ className, ...props }: React.ComponentProp
|
||||
{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" />
|
||||
<EnvelopeOpen className="size-6" />
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
Bağlantı emailinize gönderildi. Gelen kutusunu kontrol edin.
|
||||
@@ -64,7 +64,7 @@ export function ForgotPasswordForm1({ className, ...props }: React.ComponentProp
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Gönderiliyor...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function ForgotPasswordPage() {
|
||||
<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">İşletmem</span>
|
||||
<span className="text-lg font-semibold">Emlak CRM</span>
|
||||
</Link>
|
||||
<ForgotPasswordForm1 />
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "İşletmem — Giriş",
|
||||
description: "İşletmem KovakCRM hesabınıza giriş yapın veya yeni hesap oluşturun.",
|
||||
title: "Emlak CRM — Giriş",
|
||||
description: "Emlak CRM hesabınıza giriş yapın.",
|
||||
};
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState } from "react";
|
||||
import { ArrowLeft, CircleNotch, ShieldCheck } from "@/lib/icons";
|
||||
|
||||
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 ? (
|
||||
<>
|
||||
<CircleNotch 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import { XCircle } from "@/lib/icons";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export function LoginForm3({
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Login with Apple</span>
|
||||
<span className="sr-only">Login with AppleLogo</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">
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { CircleNotch, Buildings, Users, Presentation, Lightning } from '@/lib/icons';
|
||||
|
||||
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";
|
||||
@@ -16,40 +14,132 @@ import { initialAuthState } from "@/lib/appwrite/auth-types";
|
||||
export function LoginForm1({
|
||||
className,
|
||||
inviteCode,
|
||||
passwordReset,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { inviteCode?: string }) {
|
||||
}: React.ComponentProps<"div"> & { inviteCode?: string; passwordReset?: boolean }) {
|
||||
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 className={cn("flex min-h-svh w-full", className)} {...props}>
|
||||
{/* ── Sol: Marka paneli ── */}
|
||||
<div className="relative hidden w-1/2 flex-col justify-between overflow-hidden bg-slate-900 p-12 text-white lg:flex">
|
||||
{/* Arka plan dekorasyon */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 15% 15%, rgba(59,130,246,0.25) 0%, transparent 45%), radial-gradient(circle at 85% 80%, rgba(99,102,241,0.2) 0%, transparent 50%)",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute -top-32 -right-32 size-96 rounded-full bg-blue-500/10 blur-3xl"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute -bottom-40 -left-24 size-[28rem] rounded-full bg-indigo-600/10 blur-3xl"
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* Logo + Ürün adı */}
|
||||
<div className="relative z-10 flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-blue-500/20 ring-1 ring-blue-400/30 backdrop-blur">
|
||||
<Buildings className="size-5 text-blue-300" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold tracking-tight leading-none">Emlak CRM</p>
|
||||
<p className="text-xs text-slate-400 leading-none mt-0.5">Kovak Yazılım</p>
|
||||
</div>
|
||||
<span className="text-xl font-semibold">İşletmem</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Orta içerik */}
|
||||
<div className="relative z-10 space-y-8">
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-3xl font-bold leading-snug tracking-tight">
|
||||
Gayrimenkul süreçlerinizi tek platformdan yönetin
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">
|
||||
İlanlar, müşteriler, akıllı eşleşme ve sunumlar — ekibinizle birlikte, her yerden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-4">
|
||||
{[
|
||||
{
|
||||
icon: Buildings,
|
||||
title: "Portföy yönetimi",
|
||||
desc: "Tüm ilanlarınızı fotoğraflarıyla ekleyin, takip edin",
|
||||
},
|
||||
{
|
||||
icon: Lightning,
|
||||
title: "Akıllı eşleşme",
|
||||
desc: "Ağırlıklı puanlama ile müşteri × ilan eşleştirmesi",
|
||||
},
|
||||
{
|
||||
icon: Presentation,
|
||||
title: "Sunum paylaşımı",
|
||||
desc: "Müşteriye özel sunum linkleri oluşturun ve gönderin",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: "Müşteri & arama",
|
||||
desc: "Alıcı ve kiracıların kriterlerini saklayın",
|
||||
},
|
||||
].map(({ icon: Icon, title, desc }) => (
|
||||
<li key={title} className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-blue-500/15 ring-1 ring-blue-400/20">
|
||||
<Icon className="size-3.5 text-blue-300" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-none">{title}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">{desc}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Alt: Kovak Yazılım */}
|
||||
<div className="relative z-10 flex flex-col gap-0.5">
|
||||
<p className="text-xs font-semibold text-slate-300">Emlak CRM</p>
|
||||
<p className="text-xs text-slate-500">Kovak Yazılım · kovaksoft.com</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Sağ: Giriş formu ── */}
|
||||
<div className="flex w-full flex-col items-center justify-center bg-white px-6 py-10 lg:w-1/2 dark:bg-slate-950">
|
||||
<div className="w-full max-w-sm space-y-8">
|
||||
{/* Mobilde logo */}
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-blue-600 text-white">
|
||||
<Buildings className="size-4" />
|
||||
</div>
|
||||
<span className="font-bold">Emlak CRM</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Tekrar hoş geldiniz</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Hesabınıza giriş yaparak devam edin
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{passwordReset && (
|
||||
<p className="rounded-md border bg-green-50 px-3 py-2 text-center text-xs text-green-700 dark:bg-green-950 dark:text-green-300">
|
||||
Şifreniz güncellendi. Yeni şifrenizle giriş yapabilirsiniz.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{inviteCode && (
|
||||
<p className="text-muted-foreground rounded-md border bg-muted/50 px-3 py-2 text-center text-xs">
|
||||
<p className="rounded-md border bg-blue-50 px-3 py-2 text-center text-xs text-blue-700 dark:bg-blue-950 dark:text-blue-300">
|
||||
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>
|
||||
<form action={formAction} className="space-y-5">
|
||||
{inviteCode && <input type="hidden" name="inviteCode" value={inviteCode} />}
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
@@ -61,12 +151,12 @@ export function LoginForm1({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<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"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-4 hover:underline"
|
||||
>
|
||||
Şifremi unuttum
|
||||
</Link>
|
||||
@@ -81,7 +171,7 @@ export function LoginForm1({
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<p className="text-destructive text-sm text-center" role="alert">
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{state.error}
|
||||
</p>
|
||||
)}
|
||||
@@ -89,15 +179,16 @@ export function LoginForm1({
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Giriş yapılıyor...
|
||||
</>
|
||||
) : (
|
||||
"Giriş yap"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
Hesabınız yok mu?{" "}
|
||||
<Link
|
||||
href="/sign-up"
|
||||
@@ -105,64 +196,8 @@ export function LoginForm1({
|
||||
>
|
||||
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>
|
||||
'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">İşletmem</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">KovakSoft tarafından</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,17 +6,11 @@ import { getCurrentUser } from "@/lib/appwrite/server";
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ invite?: string }>;
|
||||
searchParams: Promise<{ invite?: string; reset?: string }>;
|
||||
}) {
|
||||
const { invite } = await searchParams;
|
||||
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">
|
||||
<LoginForm1 inviteCode={invite} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoginForm1 inviteCode={invite} passwordReset={reset === "success"} />;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function SignupForm3({
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Sign up with Apple</span>
|
||||
<span className="sr-only">Sign up with AppleLogo</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">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { CircleNotch } from '@/lib/icons';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -35,7 +35,7 @@ export function SignupForm1({
|
||||
<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">İşletmem</span>
|
||||
<span className="text-xl font-semibold">Emlak CRM</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,7 @@ export function SignupForm1({
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Hesap oluşturuluyor...
|
||||
</>
|
||||
) : (
|
||||
@@ -159,22 +159,22 @@ function BrandPanel() {
|
||||
<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">İşletmem</span>
|
||||
<span className="text-lg font-medium">Emlak CRM</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ç.
|
||||
Emlak ofisinizi 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>
|
||||
<li>• Portföy ve müşteri yönetimi</li>
|
||||
<li>• Otomatik ilan-müşteri eşleştirme</li>
|
||||
<li>• Komisyon ve finans takibi</li>
|
||||
</ul>
|
||||
<div className="text-primary-foreground/70 mt-4 text-xs">KovakSoft tarafından</div>
|
||||
<div className="text-primary-foreground/70 mt-4 text-xs">Kovak Yazılım ve Medya LTD. ŞTİ.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { GraduationCap } from '@/lib/icons';
|
||||
import { AcademyClient } from "@/components/academy/academy-client";
|
||||
|
||||
export const metadata = { title: "Akademi | KovakEmlak" };
|
||||
|
||||
export default function AcademyPage() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-5xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 text-primary p-2 rounded-lg">
|
||||
<GraduationCap className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Akademi</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Her modülün turunu başlatarak sistemi adım adım öğrenin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AcademyClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,9 +11,9 @@ import { ActivitiesClient } from "@/components/activities/activities-client";
|
||||
|
||||
export default async function ActivitiesPage() {
|
||||
const ctx = await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
const { tablesDB, teams, users } = createAdminClient();
|
||||
|
||||
const [customers, properties, activitiesResult] = await Promise.all([
|
||||
const [customers, properties, activitiesResult, membershipsResult] = await Promise.all([
|
||||
listCustomers(ctx.tenantId),
|
||||
listProperties(ctx.tenantId),
|
||||
tablesDB.listRows({
|
||||
@@ -25,9 +25,21 @@ export default async function ActivitiesPage() {
|
||||
Query.limit(300),
|
||||
],
|
||||
}),
|
||||
teams.listMemberships(ctx.tenantId),
|
||||
]);
|
||||
|
||||
const activities = activitiesResult.rows as unknown as Activity[];
|
||||
const activities = JSON.parse(JSON.stringify(activitiesResult.rows)) as Activity[];
|
||||
|
||||
const members = (
|
||||
await Promise.all(
|
||||
membershipsResult.memberships
|
||||
.filter((m) => m.userId && m.confirm)
|
||||
.map(async (m) => {
|
||||
const u = await users.get(m.userId).catch(() => null);
|
||||
return u ? { id: m.userId, name: u.name } : null;
|
||||
}),
|
||||
)
|
||||
).filter((m): m is { id: string; name: string } => m !== null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||
@@ -35,6 +47,9 @@ export default async function ActivitiesPage() {
|
||||
initialActivities={activities}
|
||||
customers={customers}
|
||||
properties={properties}
|
||||
role={ctx.role}
|
||||
members={members}
|
||||
currentUserId={ctx.user.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Phone, Envelope, CalendarCheck, Tag } from '@/lib/icons';
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { getCustomer } from "@/lib/appwrite/customer-queries";
|
||||
import {
|
||||
DATABASE_ID, TABLES,
|
||||
CUSTOMER_TYPE_LABELS, CUSTOMER_STAGE_LABELS, CUSTOMER_SOURCE_LABELS,
|
||||
ACTIVITY_TYPE_LABELS, PROPERTY_TYPE_LABELS, LISTING_TYPE_LABELS,
|
||||
type Activity, type CustomerSearch, type PropertyMatch, type Property,
|
||||
} from "@/lib/appwrite/schema";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const STAGE_COLORS: Record<string, string> = {
|
||||
ilk_temas: "bg-slate-100 text-slate-700",
|
||||
aktif_arama: "bg-blue-100 text-blue-700",
|
||||
teklif: "bg-amber-100 text-amber-700",
|
||||
sozlesme: "bg-purple-100 text-purple-700",
|
||||
kapandi: "bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
|
||||
export default async function CustomerDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
const ctx = await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const customer = await getCustomer(id, ctx.tenantId);
|
||||
if (!customer) notFound();
|
||||
|
||||
const [activitiesRes, searchesRes, matchesRes] = await Promise.all([
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.activities,
|
||||
queries: [
|
||||
Query.equal("customerId", id),
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(20),
|
||||
],
|
||||
}),
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.customerSearches,
|
||||
queries: [
|
||||
Query.equal("customerId", id),
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.limit(20),
|
||||
],
|
||||
}),
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.propertyMatches,
|
||||
queries: [
|
||||
Query.equal("customerId", id),
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.orderDesc("score"),
|
||||
Query.limit(20),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const activities = JSON.parse(JSON.stringify(activitiesRes.rows)) as Activity[];
|
||||
const searches = JSON.parse(JSON.stringify(searchesRes.rows)) as CustomerSearch[];
|
||||
const matches = JSON.parse(JSON.stringify(matchesRes.rows)) as PropertyMatch[];
|
||||
|
||||
// Fetch matched properties for display
|
||||
const matchedPropertyIds = [...new Set(matches.map((m) => m.propertyId))];
|
||||
const propertiesMap: Record<string, Property> = {};
|
||||
if (matchedPropertyIds.length > 0) {
|
||||
const propsRes = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.properties,
|
||||
queries: [
|
||||
Query.equal("$id", matchedPropertyIds.slice(0, 25)),
|
||||
Query.limit(25),
|
||||
],
|
||||
});
|
||||
for (const row of propsRes.rows) {
|
||||
const p = row as unknown as Property;
|
||||
propertiesMap[p.$id] = p;
|
||||
}
|
||||
}
|
||||
|
||||
const stageKey = customer.stage ?? "ilk_temas";
|
||||
|
||||
function parseJsonList(json?: string | null): string[] {
|
||||
if (!json) return [];
|
||||
try { return JSON.parse(json) as string[]; } catch { return [json]; }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-5xl">
|
||||
{/* Back */}
|
||||
<div>
|
||||
<Link href="/customers" className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-sm">
|
||||
<ArrowLeft className="size-4" />
|
||||
Müşteriler
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold">{customer.name}</h1>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline">{CUSTOMER_TYPE_LABELS[customer.type] ?? customer.type}</Badge>
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${STAGE_COLORS[stageKey] ?? STAGE_COLORS.ilk_temas}`}>
|
||||
{CUSTOMER_STAGE_LABELS[stageKey as keyof typeof CUSTOMER_STAGE_LABELS] ?? stageKey}
|
||||
</span>
|
||||
{customer.source && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Tag className="size-3" />
|
||||
{CUSTOMER_SOURCE_LABELS[customer.source] ?? customer.source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/customers"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Düzenle →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{/* Left: info + searches + activities */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{/* Contact card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">İletişim</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{customer.phone && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Phone className="size-4 text-muted-foreground shrink-0" />
|
||||
<a href={`tel:${customer.phone}`} className="hover:underline">{customer.phone}</a>
|
||||
</div>
|
||||
)}
|
||||
{customer.email && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Envelope className="size-4 text-muted-foreground shrink-0" />
|
||||
<a href={`mailto:${customer.email}`} className="hover:underline">{customer.email}</a>
|
||||
</div>
|
||||
)}
|
||||
{customer.nextFollowUpDate && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CalendarCheck className="size-4 text-muted-foreground shrink-0" />
|
||||
<span>Takip: {new Date(customer.nextFollowUpDate).toLocaleDateString("tr-TR", { day: "numeric", month: "long", year: "numeric" })}</span>
|
||||
</div>
|
||||
)}
|
||||
{!customer.phone && !customer.email && !customer.nextFollowUpDate && (
|
||||
<p className="text-sm text-muted-foreground">İletişim bilgisi yok.</p>
|
||||
)}
|
||||
{customer.notes && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-muted-foreground whitespace-pre-wrap">{customer.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* MagnifyingGlass criteria */}
|
||||
{searches.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Arama Kriterleri ({searches.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{searches.map((s) => (
|
||||
<div key={s.$id} className="rounded-lg border p-3 text-sm space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">
|
||||
{s.listingType ? (s.listingType === "satilik" ? "Satılık" : "Kiralık") : "Tümü"}
|
||||
{parseJsonList(s.propertyTypes).length > 0 && ` · ${parseJsonList(s.propertyTypes).join(", ")}`}
|
||||
</span>
|
||||
<Badge variant={s.isActive ? "default" : "secondary"} className="text-xs">
|
||||
{s.isActive ? "Aktif" : "Pasif"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{parseJsonList(s.roomCounts).length > 0 && (
|
||||
<span>Oda: {parseJsonList(s.roomCounts).join(", ")}</span>
|
||||
)}
|
||||
{(s.minPrice || s.maxPrice) && (
|
||||
<span>Fiyat: {s.minPrice ? s.minPrice.toLocaleString("tr-TR") : "–"} – {s.maxPrice ? s.maxPrice.toLocaleString("tr-TR") : "–"} ₺</span>
|
||||
)}
|
||||
{(s.minM2 || s.maxM2) && (
|
||||
<span>m²: {s.minM2 ?? "–"}–{s.maxM2 ?? "–"}</span>
|
||||
)}
|
||||
{parseJsonList(s.cities).length > 0 && (
|
||||
<span>Şehir: {parseJsonList(s.cities).join(", ")}</span>
|
||||
)}
|
||||
{parseJsonList(s.districts).length > 0 && (
|
||||
<span>İlçe: {parseJsonList(s.districts).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
{s.notes && <p className="text-xs text-muted-foreground italic">{s.notes}</p>}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Activities */}
|
||||
{activities.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Aktiviteler ({activities.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y px-0">
|
||||
{activities.map((a) => (
|
||||
<div key={a.$id} className="flex items-start gap-3 px-6 py-3 text-sm">
|
||||
<div className="mt-0.5">
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
||||
{ACTIVITY_TYPE_LABELS[a.type] ?? a.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{a.title}</p>
|
||||
{a.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{a.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{new Date(a.$createdAt).toLocaleDateString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: matches */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">
|
||||
Eşleşen İlanlar
|
||||
{matches.length > 0 && (
|
||||
<span className="ml-1.5 text-muted-foreground font-normal text-xs">({matches.length})</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{matches.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Henüz eşleşme yok.</p>
|
||||
) : (
|
||||
matches.map((m) => {
|
||||
const p = propertiesMap[m.propertyId];
|
||||
const score = m.score ?? 0;
|
||||
const scoreColor =
|
||||
score >= 80 ? "text-green-600" : score >= 60 ? "text-blue-600" : score >= 40 ? "text-amber-600" : "text-gray-400";
|
||||
return (
|
||||
<div key={m.$id} className="rounded-lg border p-2.5 text-sm space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium truncate text-xs leading-snug">
|
||||
{p?.title ?? m.propertyId}
|
||||
</p>
|
||||
<span className={`text-xs font-bold ${scoreColor}`}>{score}</span>
|
||||
</div>
|
||||
{p && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}
|
||||
{p.city ? ` · ${p.city}` : ""}
|
||||
{p.price ? ` · ${p.price.toLocaleString("tr-TR")} ₺` : ""}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-[10px] ${m.notified ? "text-muted-foreground" : "text-amber-600 font-medium"}`}>
|
||||
{m.notified ? "Bildirildi" : "Bekliyor"}
|
||||
</span>
|
||||
{p && (
|
||||
<Link href={`/properties/${p.$id}`} className="text-[10px] text-primary hover:underline">
|
||||
Detay →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function CustomersLoading() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-7 w-28" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32 rounded-md" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-48 rounded-md" />
|
||||
<Skeleton className="h-9 w-32 rounded-md" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border overflow-hidden">
|
||||
<div className="bg-muted/30 p-3 flex gap-4">
|
||||
{[140, 100, 80, 60].map((w, i) => (
|
||||
<Skeleton key={i} className="h-3" style={{ width: w }} />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="border-t p-3 flex gap-4 items-center">
|
||||
<Skeleton className="size-8 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-20 hidden sm:block" />
|
||||
<Skeleton className="h-5 w-16 rounded-full hidden md:block" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function MatchesLoading() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-7 w-32" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-44 rounded-md" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b pb-0">
|
||||
<Skeleton className="h-9 w-24 rounded-none" />
|
||||
<Skeleton className="h-9 w-20 rounded-none" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<div className="bg-muted/30 p-3 flex gap-4">
|
||||
{[40, 120, 160, 80, 70].map((w, i) => (
|
||||
<Skeleton key={i} className="h-3" style={{ width: w }} />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="border-t p-3 flex gap-4 items-center">
|
||||
<Skeleton className="h-5 w-8 rounded-full shrink-0" />
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-16 hidden md:block" />
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,20 @@ import { Query } from "node-appwrite";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||
import { listProperties } from "@/lib/appwrite/property-queries";
|
||||
import { DATABASE_ID, TABLES, type PropertyMatch } from "@/lib/appwrite/schema";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type PropertyMatch,
|
||||
type CustomerSearch,
|
||||
} from "@/lib/appwrite/schema";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { MatchesClient } from "@/components/matches/matches-client";
|
||||
|
||||
export default async function MatchesPage() {
|
||||
const ctx = await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const [customers, properties, matchesResult] = await Promise.all([
|
||||
const [customers, properties, matchesResult, searchesResult] = await Promise.all([
|
||||
listCustomers(ctx.tenantId),
|
||||
listProperties(ctx.tenantId),
|
||||
tablesDB.listRows({
|
||||
@@ -24,58 +30,30 @@ export default async function MatchesPage() {
|
||||
Query.limit(500),
|
||||
],
|
||||
}),
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.customerSearches,
|
||||
queries: [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.limit(200),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const matches = matchesResult.rows as unknown as PropertyMatch[];
|
||||
const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c.name]));
|
||||
const propertyMap = Object.fromEntries(properties.map((p) => [p.$id, p.title]));
|
||||
const matches = (
|
||||
JSON.parse(JSON.stringify(matchesResult.rows)) as PropertyMatch[]
|
||||
).sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
|
||||
const searches = JSON.parse(JSON.stringify(searchesResult.rows)) as CustomerSearch[];
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Eşleşmeler</h1>
|
||||
<span className="text-muted-foreground text-sm">{matches.length} eşleşme</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-3 font-medium">Müşteri</th>
|
||||
<th className="text-left p-3 font-medium">İlan</th>
|
||||
<th className="text-left p-3 font-medium">Tarih</th>
|
||||
<th className="text-left p-3 font-medium">Görüntülendi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matches.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-muted-foreground text-center py-10">
|
||||
Henüz eşleşme yok.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{matches.map((m) => (
|
||||
<tr key={m.$id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="p-3">{customerMap[m.customerId] ?? m.customerId}</td>
|
||||
<td className="p-3">{propertyMap[m.propertyId] ?? m.propertyId}</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{new Date(m.$createdAt).toLocaleDateString("tr-TR")}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{m.viewedAt ? (
|
||||
<span className="text-green-600 text-xs">
|
||||
{new Date(m.viewedAt).toLocaleDateString("tr-TR")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">Hayır</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<MatchesClient
|
||||
matches={matches}
|
||||
customers={customers}
|
||||
properties={properties}
|
||||
searches={searches}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,25 @@ export const dynamic = "force-dynamic";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||
import { CustomersClient } from "@/components/customers/customers-client";
|
||||
import { checkLimit, PLAN_LIMITS } from "@/lib/plans";
|
||||
|
||||
export default async function CustomersPage() {
|
||||
const ctx = await requireTenant();
|
||||
const customers = await listCustomers(ctx.tenantId);
|
||||
const [customers, limitResult] = await Promise.all([
|
||||
listCustomers(ctx.tenantId),
|
||||
checkLimit(ctx.tenantId, ctx.settings?.plan, "customers"),
|
||||
]);
|
||||
|
||||
const plan = ctx.settings?.plan ?? "free";
|
||||
const limit = PLAN_LIMITS[plan].customers;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||
<CustomersClient initialCustomers={customers} />
|
||||
<CustomersClient
|
||||
initialCustomers={customers}
|
||||
isOwner={ctx.role === "owner"}
|
||||
usageLimit={limit !== Infinity ? { current: limitResult.current, limit } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function SearchesLoading() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-7 w-40" />
|
||||
<Skeleton className="h-4 w-52" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-36 rounded-md" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border overflow-hidden">
|
||||
<div className="bg-muted/30 p-3 flex gap-4">
|
||||
{[140, 100, 120, 80, 60].map((w, i) => (
|
||||
<Skeleton key={i} className="h-3" style={{ width: w }} />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<div key={i} className="border-t p-3 flex gap-4 items-center">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-28 hidden sm:block" />
|
||||
<Skeleton className="h-4 w-32 hidden md:block" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-7 w-7 rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export default async function SearchesPage() {
|
||||
}),
|
||||
]);
|
||||
|
||||
const searches = searchesResult.rows as unknown as CustomerSearch[];
|
||||
const searches = JSON.parse(JSON.stringify(searchesResult.rows)) as CustomerSearch[];
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { IconContext } from "@phosphor-icons/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 { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions";
|
||||
|
||||
export type ShellUser = {
|
||||
id: string;
|
||||
@@ -21,20 +24,33 @@ export type ShellCompany = {
|
||||
logoUrl?: string | null;
|
||||
};
|
||||
|
||||
export type ShellRole = "owner" | "admin" | "member";
|
||||
|
||||
export function DashboardShell({
|
||||
user,
|
||||
company,
|
||||
children,
|
||||
initialPrefs,
|
||||
pendingMatchCount = 0,
|
||||
role = "member",
|
||||
serverIsMobile = false,
|
||||
}: {
|
||||
user: ShellUser;
|
||||
company: ShellCompany;
|
||||
children: React.ReactNode;
|
||||
initialPrefs: ThemePrefs;
|
||||
pendingMatchCount?: number;
|
||||
role?: ShellRole;
|
||||
serverIsMobile?: boolean;
|
||||
}) {
|
||||
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
|
||||
const { config } = useSidebarConfig();
|
||||
|
||||
return (
|
||||
<IconContext.Provider value={{ weight: "bold" }}>
|
||||
<SidebarProvider
|
||||
defaultOpen={!serverIsMobile}
|
||||
defaultIsMobile={serverIsMobile}
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "16rem",
|
||||
@@ -44,6 +60,8 @@ export function DashboardShell({
|
||||
}
|
||||
className={config.collapsible === "none" ? "sidebar-none-mode" : ""}
|
||||
>
|
||||
<PrefsInitializer prefs={initialPrefs} />
|
||||
|
||||
{config.side === "left" ? (
|
||||
<>
|
||||
<AppSidebar
|
||||
@@ -52,14 +70,12 @@ export function DashboardShell({
|
||||
variant={config.variant}
|
||||
collapsible={config.collapsible}
|
||||
side={config.side}
|
||||
pendingMatchCount={pendingMatchCount}
|
||||
role={role}
|
||||
/>
|
||||
<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>
|
||||
<div className="flex flex-1 flex-col min-h-0">{children}</div>
|
||||
<SiteFooter />
|
||||
</SidebarInset>
|
||||
</>
|
||||
@@ -67,11 +83,7 @@ export function DashboardShell({
|
||||
<>
|
||||
<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>
|
||||
<div className="flex flex-1 flex-col min-h-0">{children}</div>
|
||||
<SiteFooter />
|
||||
</SidebarInset>
|
||||
<AppSidebar
|
||||
@@ -80,6 +92,8 @@ export function DashboardShell({
|
||||
variant={config.variant}
|
||||
collapsible={config.collapsible}
|
||||
side={config.side}
|
||||
pendingMatchCount={pendingMatchCount}
|
||||
role={role}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -88,7 +102,9 @@ export function DashboardShell({
|
||||
<ThemeCustomizer
|
||||
open={themeCustomizerOpen}
|
||||
onOpenChange={setThemeCustomizerOpen}
|
||||
initialPrefs={initialPrefs}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</IconContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,291 +1,232 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Line, LineChart, CartesianGrid, XAxis, YAxis, Pie, PieChart, Cell,
|
||||
} from "recharts";
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
Card, CardContent, CardDescription, CardHeader, CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/components/ui/toggle-group"
|
||||
ChartContainer, ChartTooltip, type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
|
||||
export const description = "An interactive area chart"
|
||||
type View = "trend" | "dagilim";
|
||||
|
||||
const chartData = [
|
||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
||||
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
||||
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
||||
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
||||
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
||||
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
||||
{ date: "2024-04-07", desktop: 245, mobile: 180 },
|
||||
{ date: "2024-04-08", desktop: 409, mobile: 320 },
|
||||
{ date: "2024-04-09", desktop: 59, mobile: 110 },
|
||||
{ date: "2024-04-10", desktop: 261, mobile: 190 },
|
||||
{ date: "2024-04-11", desktop: 327, mobile: 350 },
|
||||
{ date: "2024-04-12", desktop: 292, mobile: 210 },
|
||||
{ date: "2024-04-13", desktop: 342, mobile: 380 },
|
||||
{ date: "2024-04-14", desktop: 137, mobile: 220 },
|
||||
{ date: "2024-04-15", desktop: 120, mobile: 170 },
|
||||
{ date: "2024-04-16", desktop: 138, mobile: 190 },
|
||||
{ date: "2024-04-17", desktop: 446, mobile: 360 },
|
||||
{ date: "2024-04-18", desktop: 364, mobile: 410 },
|
||||
{ date: "2024-04-19", desktop: 243, mobile: 180 },
|
||||
{ date: "2024-04-20", desktop: 89, mobile: 150 },
|
||||
{ date: "2024-04-21", desktop: 137, mobile: 200 },
|
||||
{ date: "2024-04-22", desktop: 224, mobile: 170 },
|
||||
{ date: "2024-04-23", desktop: 138, mobile: 230 },
|
||||
{ date: "2024-04-24", desktop: 387, mobile: 290 },
|
||||
{ date: "2024-04-25", desktop: 215, mobile: 250 },
|
||||
{ date: "2024-04-26", desktop: 75, mobile: 130 },
|
||||
{ date: "2024-04-27", desktop: 383, mobile: 420 },
|
||||
{ date: "2024-04-28", desktop: 122, mobile: 180 },
|
||||
{ date: "2024-04-29", desktop: 315, mobile: 240 },
|
||||
{ date: "2024-04-30", desktop: 454, mobile: 380 },
|
||||
{ date: "2024-05-01", desktop: 165, mobile: 220 },
|
||||
{ date: "2024-05-02", desktop: 293, mobile: 310 },
|
||||
{ date: "2024-05-03", desktop: 247, mobile: 190 },
|
||||
{ date: "2024-05-04", desktop: 385, mobile: 420 },
|
||||
{ date: "2024-05-05", desktop: 481, mobile: 390 },
|
||||
{ date: "2024-05-06", desktop: 498, mobile: 520 },
|
||||
{ date: "2024-05-07", desktop: 388, mobile: 300 },
|
||||
{ date: "2024-05-08", desktop: 149, mobile: 210 },
|
||||
{ date: "2024-05-09", desktop: 227, mobile: 180 },
|
||||
{ date: "2024-05-10", desktop: 293, mobile: 330 },
|
||||
{ date: "2024-05-11", desktop: 335, mobile: 270 },
|
||||
{ date: "2024-05-12", desktop: 197, mobile: 240 },
|
||||
{ date: "2024-05-13", desktop: 197, mobile: 160 },
|
||||
{ date: "2024-05-14", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-05-15", desktop: 473, mobile: 380 },
|
||||
{ date: "2024-05-16", desktop: 338, mobile: 400 },
|
||||
{ date: "2024-05-17", desktop: 499, mobile: 420 },
|
||||
{ date: "2024-05-18", desktop: 315, mobile: 350 },
|
||||
{ date: "2024-05-19", desktop: 235, mobile: 180 },
|
||||
{ date: "2024-05-20", desktop: 177, mobile: 230 },
|
||||
{ date: "2024-05-21", desktop: 82, mobile: 140 },
|
||||
{ date: "2024-05-22", desktop: 81, mobile: 120 },
|
||||
{ date: "2024-05-23", desktop: 252, mobile: 290 },
|
||||
{ date: "2024-05-24", desktop: 294, mobile: 220 },
|
||||
{ date: "2024-05-25", desktop: 201, mobile: 250 },
|
||||
{ date: "2024-05-26", desktop: 213, mobile: 170 },
|
||||
{ date: "2024-05-27", desktop: 420, mobile: 460 },
|
||||
{ date: "2024-05-28", desktop: 233, mobile: 190 },
|
||||
{ date: "2024-05-29", desktop: 78, mobile: 130 },
|
||||
{ date: "2024-05-30", desktop: 340, mobile: 280 },
|
||||
{ date: "2024-05-31", desktop: 178, mobile: 230 },
|
||||
{ date: "2024-06-01", desktop: 178, mobile: 200 },
|
||||
{ date: "2024-06-02", desktop: 470, mobile: 410 },
|
||||
{ date: "2024-06-03", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-04", desktop: 439, mobile: 380 },
|
||||
{ date: "2024-06-05", desktop: 88, mobile: 140 },
|
||||
{ date: "2024-06-06", desktop: 294, mobile: 250 },
|
||||
{ date: "2024-06-07", desktop: 323, mobile: 370 },
|
||||
{ date: "2024-06-08", desktop: 385, mobile: 320 },
|
||||
{ date: "2024-06-09", desktop: 438, mobile: 480 },
|
||||
{ date: "2024-06-10", desktop: 155, mobile: 200 },
|
||||
{ date: "2024-06-11", desktop: 92, mobile: 150 },
|
||||
{ date: "2024-06-12", desktop: 492, mobile: 420 },
|
||||
{ date: "2024-06-13", desktop: 81, mobile: 130 },
|
||||
{ date: "2024-06-14", desktop: 426, mobile: 380 },
|
||||
{ date: "2024-06-15", desktop: 307, mobile: 350 },
|
||||
{ date: "2024-06-16", desktop: 371, mobile: 310 },
|
||||
{ date: "2024-06-17", desktop: 475, mobile: 520 },
|
||||
{ date: "2024-06-18", desktop: 107, mobile: 170 },
|
||||
{ date: "2024-06-19", desktop: 341, mobile: 290 },
|
||||
{ date: "2024-06-20", desktop: 408, mobile: 450 },
|
||||
{ date: "2024-06-21", desktop: 169, mobile: 210 },
|
||||
{ date: "2024-06-22", desktop: 317, mobile: 270 },
|
||||
{ date: "2024-06-23", desktop: 480, mobile: 530 },
|
||||
{ date: "2024-06-24", desktop: 132, mobile: 180 },
|
||||
{ date: "2024-06-25", desktop: 141, mobile: 190 },
|
||||
{ date: "2024-06-26", desktop: 434, mobile: 380 },
|
||||
{ date: "2024-06-27", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-06-28", desktop: 149, mobile: 200 },
|
||||
{ date: "2024-06-29", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-30", desktop: 446, mobile: 400 },
|
||||
]
|
||||
const PIE_COLORS = [
|
||||
"hsl(221, 83%, 53%)",
|
||||
"hsl(142, 71%, 45%)",
|
||||
"hsl(38, 92%, 50%)",
|
||||
"hsl(280, 68%, 58%)",
|
||||
"hsl(10, 80%, 55%)",
|
||||
"hsl(200, 65%, 50%)",
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
visitors: {
|
||||
label: "Visitors",
|
||||
},
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
ilanSayisi: { label: "İlan", color: "hsl(221, 83%, 53%)" },
|
||||
musteriSayisi: { label: "Müşteri", color: "hsl(142, 71%, 45%)" },
|
||||
aktiviteSayisi: { label: "Aktivite", color: "hsl(38, 92%, 50%)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function ChartAreaInteractive() {
|
||||
const isMobile = useIsMobile()
|
||||
const [timeRange, setTimeRange] = React.useState("90d")
|
||||
const SERIES = [
|
||||
{ dataKey: "ilanSayisi", label: "İlan", color: "hsl(221, 83%, 53%)" },
|
||||
{ dataKey: "musteriSayisi", label: "Müşteri", color: "hsl(142, 71%, 45%)" },
|
||||
{ dataKey: "aktiviteSayisi", label: "Aktivite", color: "hsl(38, 92%, 50%)" },
|
||||
] as const;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
setTimeRange("7d")
|
||||
}
|
||||
}, [isMobile])
|
||||
export function ChartAreaInteractive({
|
||||
ilanTrend, musteriTrend, aktiviteTrend, portfoyDagilim,
|
||||
}: {
|
||||
ilanTrend: { ay: string; ilanSayisi: number }[];
|
||||
musteriTrend: { ay: string; musteriSayisi: number }[];
|
||||
aktiviteTrend: { ay: string; aktiviteSayisi: number }[];
|
||||
portfoyDagilim: { tip: string; label: string; sayi: number }[];
|
||||
}) {
|
||||
const [view, setView] = useState<View>("trend");
|
||||
|
||||
const filteredData = chartData.filter((item) => {
|
||||
const date = new Date(item.date)
|
||||
const referenceDate = new Date("2024-06-30")
|
||||
let daysToSubtract = 90
|
||||
if (timeRange === "30d") {
|
||||
daysToSubtract = 30
|
||||
} else if (timeRange === "7d") {
|
||||
daysToSubtract = 7
|
||||
}
|
||||
const startDate = new Date(referenceDate)
|
||||
startDate.setDate(startDate.getDate() - daysToSubtract)
|
||||
return date >= startDate
|
||||
})
|
||||
const trendData = useMemo(() =>
|
||||
ilanTrend.map((item, i) => ({
|
||||
ay: item.ay,
|
||||
ilanSayisi: item.ilanSayisi,
|
||||
musteriSayisi: musteriTrend[i]?.musteriSayisi ?? 0,
|
||||
aktiviteSayisi: aktiviteTrend[i]?.aktiviteSayisi ?? 0,
|
||||
})),
|
||||
[ilanTrend, musteriTrend, aktiviteTrend]
|
||||
);
|
||||
|
||||
const dagilimTotal = portfoyDagilim.reduce((s, d) => s + d.sayi, 0);
|
||||
|
||||
return (
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardTitle>Total Visitors</CardTitle>
|
||||
/*
|
||||
* Mobil: kart doğal yüksekliğinde (overflow: hidden her iki eksen).
|
||||
* Desktop: lg:h-full ile grid item'ı tamamen doldurur (items-stretch ile eşit yükseklik).
|
||||
*/
|
||||
<Card className="overflow-hidden flex flex-col lg:h-full">
|
||||
<CardHeader className="pb-2 shrink-0">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<CardTitle>Portföy Analitiği</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="hidden @[540px]/card:block">
|
||||
Total for the last 3 months
|
||||
</span>
|
||||
<span className="@[540px]/card:hidden">Last 3 months</span>
|
||||
{view === "trend"
|
||||
? "Son 6 ay — ilan, müşteri ve aktivite"
|
||||
: "Aktif portföy dağılımı"}
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={setTimeRange}
|
||||
variant="outline"
|
||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
|
||||
</div>
|
||||
<div className="flex rounded-lg border text-xs overflow-hidden shrink-0">
|
||||
{(["trend", "dagilim"] as View[]).map((v, i) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setView(v)}
|
||||
className={`px-3 py-1.5 font-medium transition-colors ${i > 0 ? "border-l" : ""} ${
|
||||
view === v
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
|
||||
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger
|
||||
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
|
||||
size="sm"
|
||||
aria-label="Select a value"
|
||||
>
|
||||
<SelectValue placeholder="Last 3 months" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="90d" className="rounded-lg">
|
||||
Last 3 months
|
||||
</SelectItem>
|
||||
<SelectItem value="30d" className="rounded-lg">
|
||||
Last 30 days
|
||||
</SelectItem>
|
||||
<SelectItem value="7d" className="rounded-lg">
|
||||
Last 7 days
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
{v === "trend" ? "Trend" : "Dağılım"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{view === "trend" && (
|
||||
<div className="flex gap-3 mt-1">
|
||||
{SERIES.map((s) => (
|
||||
<span key={s.dataKey} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-block size-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={filteredData}>
|
||||
<defs>
|
||||
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={1.0}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
|
||||
{/*
|
||||
* CardContent:
|
||||
* - Mobil: padding normal, yükseklik = içerik (chart 180px sabit).
|
||||
* - Desktop: lg:flex-1 lg:min-h-0 ile kalan alanı doldurur.
|
||||
* min-h-0 zorunlu: flex-1 tek başına overflow'u engellemez.
|
||||
*/}
|
||||
<CardContent className="px-2 sm:px-4 pb-4 lg:flex-1 lg:min-h-0">
|
||||
{view === "trend" ? (
|
||||
/*
|
||||
* h-[180px]: mobilde sabit yükseklik → Recharts collapse etmez.
|
||||
* lg:h-full: desktop'ta CardContent'in tamamını doldurur.
|
||||
*/
|
||||
<ChartContainer config={chartConfig} className="h-[180px] lg:h-full w-full">
|
||||
<LineChart data={trendData} margin={{ top: 8, right: 8, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
dataKey="ay"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value)
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value as string | number | Date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background px-3 py-2 text-sm shadow-md space-y-1">
|
||||
<p className="font-medium text-xs text-muted-foreground mb-1.5">{label}</p>
|
||||
{payload.map((p) => {
|
||||
const s = SERIES.find((x) => x.dataKey === p.dataKey);
|
||||
return (
|
||||
<div key={p.dataKey} className="flex items-center gap-2">
|
||||
<span className="size-2 rounded-full shrink-0" style={{ backgroundColor: s?.color }} />
|
||||
<span className="flex-1">{s?.label}</span>
|
||||
<span className="font-semibold tabular-nums">{p.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
indicator="dot"
|
||||
/>
|
||||
}
|
||||
{SERIES.map((s) => (
|
||||
<Line
|
||||
key={s.dataKey}
|
||||
type="monotone"
|
||||
dataKey={s.dataKey}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: s.color, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5, fill: s.color, strokeWidth: 0 }}
|
||||
/>
|
||||
<Area
|
||||
dataKey="mobile"
|
||||
type="natural"
|
||||
fill="url(#fillMobile)"
|
||||
stroke="var(--color-mobile)"
|
||||
stackId="a"
|
||||
/>
|
||||
<Area
|
||||
dataKey="desktop"
|
||||
type="natural"
|
||||
fill="url(#fillDesktop)"
|
||||
stroke="var(--color-desktop)"
|
||||
stackId="a"
|
||||
/>
|
||||
</AreaChart>
|
||||
))}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-6 items-center h-[180px] lg:h-full">
|
||||
{portfoyDagilim.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground w-full text-center py-8">
|
||||
Henüz aktif ilan yok
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="shrink-0">
|
||||
<PieChart width={150} height={150}>
|
||||
<Pie
|
||||
data={portfoyDagilim}
|
||||
dataKey="sayi"
|
||||
nameKey="label"
|
||||
cx={75} cy={75}
|
||||
innerRadius={46}
|
||||
outerRadius={68}
|
||||
paddingAngle={2}
|
||||
strokeWidth={0}
|
||||
>
|
||||
{portfoyDagilim.map((_, i) => (
|
||||
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.[0]) return null;
|
||||
const d = payload[0].payload as { label: string; sayi: number };
|
||||
const pct = dagilimTotal > 0 ? Math.round((d.sayi / dagilimTotal) * 100) : 0;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background px-3 py-2 text-sm shadow-sm">
|
||||
<p className="font-medium">{d.label}</p>
|
||||
<p className="text-muted-foreground">{d.sayi} ilan · %{pct}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 w-full space-y-2.5 overflow-y-auto">
|
||||
{portfoyDagilim.map((d, i) => {
|
||||
const pct = dagilimTotal > 0 ? Math.round((d.sayi / dagilimTotal) * 100) : 0;
|
||||
return (
|
||||
<div key={d.tip}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="size-2.5 rounded-full shrink-0" style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }} />
|
||||
<span className="text-sm flex-1 min-w-0 truncate">{d.label}</span>
|
||||
<span className="text-sm font-semibold tabular-nums">{d.sayi}</span>
|
||||
<span className="text-xs text-muted-foreground w-8 text-right">%{pct}</span>
|
||||
</div>
|
||||
<div className="ml-4 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${pct}%`, backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
|
||||
export function DashboardCarousel({
|
||||
children,
|
||||
className,
|
||||
count,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className: string;
|
||||
count: number;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
const onScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const total = el.scrollWidth - el.clientWidth;
|
||||
if (total <= 0) return;
|
||||
setActive(Math.round((el.scrollLeft / total) * (count - 1)));
|
||||
}, [count]);
|
||||
|
||||
const scrollTo = (i: number) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const total = el.scrollWidth - el.clientWidth;
|
||||
el.scrollTo({ left: (total / (count - 1)) * i, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={scrollRef} onScroll={onScroll} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex justify-center gap-1.5 mt-2 lg:hidden">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
aria-label={`Bölüm ${i + 1}`}
|
||||
onClick={() => scrollTo(i)}
|
||||
className={`h-1.5 rounded-full transition-all duration-200 ${
|
||||
i === active
|
||||
? "w-4 bg-primary"
|
||||
: "w-1.5 bg-muted-foreground/30 hover:bg-muted-foreground/50"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,19 +21,19 @@ import {
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
CircleCheckBig,
|
||||
EllipsisVertical,
|
||||
GripVertical,
|
||||
Columns2,
|
||||
Loader,
|
||||
CaretDown,
|
||||
CaretLeft,
|
||||
CaretRight,
|
||||
CaretDoubleLeft,
|
||||
CaretDoubleRight,
|
||||
CheckCircle,
|
||||
DotsThreeVertical,
|
||||
DotsSixVertical,
|
||||
Columns,
|
||||
CircleNotch,
|
||||
Plus,
|
||||
TrendingUp,
|
||||
} from "lucide-react"
|
||||
TrendUp,
|
||||
} from '@/lib/icons'
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
@@ -121,7 +121,7 @@ function DragHandle({ id }: { id: number }) {
|
||||
size="icon"
|
||||
className="text-muted-foreground size-7 hover:bg-transparent cursor-move"
|
||||
>
|
||||
<GripVertical className="text-muted-foreground size-3" />
|
||||
<DotsSixVertical className="text-muted-foreground size-3" />
|
||||
<span className="sr-only">Drag to reorder</span>
|
||||
</Button>
|
||||
)
|
||||
@@ -184,9 +184,9 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
||||
{row.original.status === "Done" ? (
|
||||
<CircleCheckBig className="text-green-500 dark:text-green-400" />
|
||||
<CheckCircle className="text-green-500 dark:text-green-400" />
|
||||
) : (
|
||||
<Loader />
|
||||
<CircleNotch />
|
||||
)}
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
@@ -286,7 +286,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8 cursor-pointer"
|
||||
size="icon"
|
||||
>
|
||||
<EllipsisVertical />
|
||||
<DotsThreeVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -629,7 +629,7 @@ export function DataTable({
|
||||
disabled={!currentTable.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<ChevronsLeft />
|
||||
<CaretDoubleLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -639,7 +639,7 @@ export function DataTable({
|
||||
disabled={!currentTable.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeft />
|
||||
<CaretLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -649,7 +649,7 @@ export function DataTable({
|
||||
disabled={!currentTable.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRight />
|
||||
<CaretRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -659,7 +659,7 @@ export function DataTable({
|
||||
disabled={!currentTable.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<ChevronsRight />
|
||||
<CaretDoubleRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -705,10 +705,10 @@ export function DataTable({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||
<Columns2 />
|
||||
<Columns />
|
||||
<span className="hidden lg:inline">Customize Columns</span>
|
||||
<span className="lg:hidden">Columns</span>
|
||||
<ChevronDown />
|
||||
<CaretDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
@@ -838,7 +838,7 @@ export function DataTable({
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<ChevronsLeft />
|
||||
<CaretDoubleLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -848,7 +848,7 @@ export function DataTable({
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeft />
|
||||
<CaretLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -858,7 +858,7 @@ export function DataTable({
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRight />
|
||||
<CaretRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -868,7 +868,7 @@ export function DataTable({
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<ChevronsRight />
|
||||
<CaretDoubleRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -992,7 +992,7 @@ function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
||||
<div className="grid gap-2">
|
||||
<div className="flex gap-2 leading-none font-medium">
|
||||
Trending up by 5.2% this month{" "}
|
||||
<TrendingUp className="size-4" />
|
||||
<TrendUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Showing total visitors for the last 6 months. This is just
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Phone, Envelope, CalendarCheck } from '@/lib/icons';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CUSTOMER_STAGE_LABELS, CUSTOMER_TYPE_LABELS, type Customer } from "@/lib/appwrite/schema";
|
||||
|
||||
function isOverdue(date: string) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return new Date(date) < today;
|
||||
}
|
||||
|
||||
export function FollowUpWidget({ customers }: { customers: Customer[] }) {
|
||||
const empty = customers.length === 0;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden flex flex-col lg:h-full">
|
||||
<CardHeader className="shrink-0">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<CalendarCheck className={`size-4 ${empty ? "text-muted-foreground" : "text-amber-500"}`} />
|
||||
Bugünkü Takipler
|
||||
{!empty && (
|
||||
<Badge variant="secondary" className="ml-auto text-xs">{customers.length}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pb-3 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
|
||||
{empty ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6 px-4">
|
||||
Bekleyen takip yok
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{customers.map((c) => {
|
||||
const overdue = c.nextFollowUpDate ? isOverdue(c.nextFollowUpDate) : false;
|
||||
return (
|
||||
<li key={c.$id} className="flex items-center gap-3 px-6 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{c.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{CUSTOMER_TYPE_LABELS[c.type] ?? c.type}
|
||||
{c.stage ? ` · ${CUSTOMER_STAGE_LABELS[c.stage] ?? c.stage}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{overdue && (
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">
|
||||
Gecikti
|
||||
</span>
|
||||
)}
|
||||
{c.phone && (
|
||||
<a
|
||||
href={`tel:${c.phone}`}
|
||||
title={c.phone}
|
||||
className="size-7 rounded-md bg-muted flex items-center justify-center hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
>
|
||||
<Phone className="size-3.5" />
|
||||
</a>
|
||||
)}
|
||||
{c.email && (
|
||||
<a
|
||||
href={`mailto:${c.email}`}
|
||||
title={c.email}
|
||||
className="size-7 rounded-md bg-muted flex items-center justify-center hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
>
|
||||
<Envelope className="size-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { Calendar, FilePlus, Receipt, UserPlus } from "lucide-react";
|
||||
import { Calendar, FilePlus, Receipt, UserPlus } from '@/lib/icons';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { tr } from "date-fns/locale";
|
||||
import { ChatCircle, FileText, Eye, Phone, Note } from '@/lib/icons';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Activity } from "@/lib/appwrite/schema";
|
||||
|
||||
const typeConfig: Record<string, { label: string; icon: React.ElementType; variant: "default" | "secondary" | "outline" }> = {
|
||||
gorusme: { label: "Görüşme", icon: ChatCircle, variant: "default" },
|
||||
teklif: { label: "Teklif", icon: FileText, variant: "secondary" },
|
||||
ziyaret: { label: "Ziyaret", icon: Eye, variant: "outline" },
|
||||
arama: { label: "Arama", icon: Phone, variant: "outline" },
|
||||
not: { label: "Not", icon: Note, variant: "outline" },
|
||||
};
|
||||
|
||||
export function RecentActivities({ activities }: { activities: Activity[] }) {
|
||||
return (
|
||||
<Card className="overflow-hidden flex flex-col lg:h-full">
|
||||
<CardHeader className="shrink-0">
|
||||
<CardTitle className="text-base">Son Aktiviteler</CardTitle>
|
||||
</CardHeader>
|
||||
{/* Mobil: içerik sayfayı scroll ettirir. Desktop: kart içinde kalır. */}
|
||||
<CardContent className="px-4 pb-4 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
|
||||
{activities.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||
Henüz aktivite yok
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{activities.map((a) => {
|
||||
const cfg = typeConfig[a.type] ?? typeConfig.not;
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<li key={a.$id} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<span className="mt-0.5 rounded-md bg-muted p-1.5 shrink-0">
|
||||
<Icon className="size-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium truncate">{a.title}</p>
|
||||
<Badge variant={cfg.variant} className="text-xs px-1.5 py-0 shrink-0">
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
{a.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
|
||||
{a.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatDistanceToNow(new Date(a.$createdAt), { addSuffix: true, locale: tr })}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Link from "next/link";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { tr } from "date-fns/locale";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Property } from "@/lib/appwrite/schema";
|
||||
|
||||
const statusDot: Record<string, { color: string; label: string }> = {
|
||||
aktif: { color: "bg-emerald-500", label: "Aktif" },
|
||||
pasif: { color: "bg-zinc-400", label: "Pasif" },
|
||||
satildi: { color: "bg-blue-500", label: "Satıldı" },
|
||||
kiralandit: { color: "bg-orange-400", label: "Kiralandı" },
|
||||
};
|
||||
|
||||
const listingLabel: Record<string, string> = { satilik: "Satılık", kiralik: "Kiralık" };
|
||||
const typeLabel: Record<string, string> = {
|
||||
daire: "Daire", villa: "Villa", arsa: "Arsa",
|
||||
dukkan: "Dükkan", ofis: "Ofis", depo: "Depo",
|
||||
};
|
||||
|
||||
function formatPrice(price: number, currency = "TRY") {
|
||||
return new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency", currency, maximumFractionDigits: 0,
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
export function RecentProperties({ properties }: { properties: Property[] }) {
|
||||
return (
|
||||
<Card className="overflow-hidden flex flex-col lg:h-full">
|
||||
<CardHeader className="shrink-0">
|
||||
<CardTitle className="text-base">Son Eklenen İlanlar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pb-4 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
|
||||
{properties.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center px-4">
|
||||
Henüz ilan yok
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{properties.map((p) => {
|
||||
const dot = statusDot[p.status] ?? { color: "bg-zinc-400", label: p.status };
|
||||
return (
|
||||
<li key={p.$id}>
|
||||
<Link
|
||||
href={`/properties/${p.$id}`}
|
||||
className="flex items-center gap-3 px-6 py-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<span
|
||||
title={dot.label}
|
||||
className={`size-2 rounded-full shrink-0 ${dot.color}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{p.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{typeLabel[p.propertyType] ?? p.propertyType} · {listingLabel[p.listingType] ?? p.listingType}
|
||||
{p.city ? ` · ${p.city}` : ""}
|
||||
{p.roomCount ? ` · ${p.roomCount}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5 shrink-0">
|
||||
<p className="text-sm font-semibold tabular-nums">
|
||||
{formatPrice(p.price, p.currency)}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(p.$createdAt), { addSuffix: true, locale: tr })}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +1,58 @@
|
||||
import { TrendingDown, TrendingUp } from "lucide-react"
|
||||
import { Buildings, Users, Lightning, TrendUp } from '@/lib/icons';
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import type { DashboardStats } from "@/lib/appwrite/dashboard-queries";
|
||||
|
||||
export function SectionCards({ stats }: { stats: DashboardStats }) {
|
||||
const items = [
|
||||
{
|
||||
label: "Aktif İlan",
|
||||
value: stats.aktifIlanlar,
|
||||
icon: Buildings,
|
||||
sub: [
|
||||
`${stats.satilikAktif} sat`,
|
||||
`${stats.kiralikAktif} kir`,
|
||||
...(stats.rezerveIlanlar > 0 ? [`${stats.rezerveIlanlar} rsv`] : []),
|
||||
].join(" · "),
|
||||
},
|
||||
{
|
||||
label: "Müşteri",
|
||||
value: stats.toplamMusteri,
|
||||
icon: Users,
|
||||
sub: `${stats.aliciMusteri} alıcı · ${stats.kiraciMusteri} kiracı · ${stats.yatirimciMusteri} yat`,
|
||||
},
|
||||
{
|
||||
label: "Bekleyen Eşleşme",
|
||||
value: stats.bekleyenEslesmeler,
|
||||
icon: Lightning,
|
||||
sub: "iletilmemiş bildirim",
|
||||
accent: stats.bekleyenEslesmeler > 0,
|
||||
},
|
||||
{
|
||||
label: "Bu Ay Eklenen",
|
||||
value: stats.buAyIlanlar,
|
||||
icon: TrendUp,
|
||||
sub: "yeni ilan",
|
||||
},
|
||||
];
|
||||
|
||||
export function SectionCards() {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Total Revenue</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
$1,250.00
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Trending up this month <TrendingUp className="size-4" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-px rounded-xl border bg-border overflow-hidden">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="bg-card px-4 py-3.5 flex items-center gap-3">
|
||||
<div className={`size-9 rounded-lg flex items-center justify-center shrink-0 ${
|
||||
item.accent
|
||||
? "bg-amber-100 dark:bg-amber-950/40"
|
||||
: "bg-muted"
|
||||
}`}>
|
||||
<item.icon className={`size-4 ${item.accent ? "text-amber-600 dark:text-amber-400" : "text-foreground/60"}`} />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Visitors for the last 6 months
|
||||
<div className="min-w-0">
|
||||
<p className="text-2xl font-bold tabular-nums leading-none">{item.value}</p>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1 leading-none">{item.label}</p>
|
||||
<p className="text-[10px] text-muted-foreground/60 mt-0.5 truncate">{item.sub}</p>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>New Customers</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
1,234
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingDown />
|
||||
-20%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Down 20% this period <TrendingDown className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Acquisition needs attention
|
||||
))}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Active Accounts</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
45,678
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Strong user retention <TrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">Engagement exceed targets</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Growth Rate</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
4.5%
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingUp />
|
||||
+4.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Steady performance increase <TrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">Meets growth projections</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="flex-1 space-y-4 px-4 sm:px-6 pt-4 overflow-x-hidden">
|
||||
{/* Başlık */}
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-8 w-64" />
|
||||
</div>
|
||||
|
||||
{/* Stat kartları */}
|
||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border bg-card p-4 space-y-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-7 w-12" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grafik + liste */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 rounded-xl border bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-[200px] w-full" />
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-28" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded-md shrink-0" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-14 shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alt satır */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{[0, 1].map((i) => (
|
||||
<div key={i} className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
{Array.from({ length: 4 }).map((_, j) => (
|
||||
<div key={j} className="flex items-center gap-2">
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-3 w-12 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,100 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Buildings, UserPlus, GitMerge, Plus } from '@/lib/icons';
|
||||
|
||||
import { getActiveContext } from "@/lib/appwrite/active-context";
|
||||
import { getDashboardStats } from "@/lib/appwrite/dashboard-queries";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SectionCards } from "./components/section-cards";
|
||||
import { ChartAreaInteractive } from "./components/chart-area-interactive";
|
||||
import { RecentActivities } from "./components/recent-activities";
|
||||
import { RecentProperties } from "./components/recent-properties";
|
||||
import { FollowUpWidget } from "./components/follow-up-widget";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const ctx = await getActiveContext();
|
||||
if (!ctx) redirect("/onboarding");
|
||||
|
||||
const stats = await getDashboardStats(ctx.tenantId);
|
||||
|
||||
const firstName = ctx.user.name?.split(" ")[0] ?? "";
|
||||
const officeName = ctx.settings?.officeName ?? "Ç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">{officeName}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
// overflow-x-hidden: Recharts SVG ve diğer absolute-positioned elementlerin
|
||||
// yatay taşmasını keser; tooltip'ler kart içinde render olduğu için etkilenmez.
|
||||
<div className="flex-1 space-y-4 px-4 sm:px-6 pt-4 overflow-x-hidden">
|
||||
|
||||
{/* Başlık */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground truncate">{officeName}</p>
|
||||
<h1 className="text-lg font-semibold tracking-tight truncate">
|
||||
{firstName ? `Hoş geldiniz, ${firstName}` : "Genel Bakış"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Portföyünüzü ve müşteri aktivitelerini buradan takip edin.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
<Button asChild size="sm" variant="outline" className="px-2 sm:px-3">
|
||||
<Link href="/properties">
|
||||
<Buildings className="size-3.5" />
|
||||
<span className="hidden sm:inline">Yeni İlan</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline" className="px-2 sm:px-3">
|
||||
<Link href="/customers">
|
||||
<UserPlus className="size-3.5" />
|
||||
<span className="hidden sm:inline">Müşteri</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline" className="px-2 sm:px-3">
|
||||
<Link href="/presentations">
|
||||
<Plus className="size-3.5" />
|
||||
<span className="hidden sm:inline">Sunum</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline" className="px-2 sm:px-3">
|
||||
<Link href="/customers/matches">
|
||||
<GitMerge className="size-3.5" />
|
||||
<span className="hidden sm:inline">Eşleşmeler</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<p className="text-muted-foreground text-sm">Aktif İlanlar</p>
|
||||
<p className="mt-2 text-3xl font-bold">—</p>
|
||||
{/* İstatistik şeridi */}
|
||||
<div data-tour="dashboard-stats">
|
||||
<SectionCards stats={stats} />
|
||||
</div>
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<p className="text-muted-foreground text-sm">Müşteriler</p>
|
||||
<p className="mt-2 text-3xl font-bold">—</p>
|
||||
|
||||
{/* Grafik + Son Aktiviteler
|
||||
Mobil: tek kolon, alt alta.
|
||||
Desktop (lg): 3+2 kolon, eşit yükseklik (items-stretch). */}
|
||||
<div className="grid gap-4 lg:grid-cols-5 lg:items-stretch">
|
||||
<div className="min-w-0 lg:col-span-3">
|
||||
<ChartAreaInteractive
|
||||
ilanTrend={stats.aylikTrend}
|
||||
musteriTrend={stats.aylikMusteriTrend}
|
||||
aktiviteTrend={stats.aylikAktiviteTrend}
|
||||
portfoyDagilim={stats.portfoyDagilim}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<p className="text-muted-foreground text-sm">Bekleyen Eşleşmeler</p>
|
||||
<p className="mt-2 text-3xl font-bold">—</p>
|
||||
<div data-tour="dashboard-activity" className="min-w-0 lg:col-span-2">
|
||||
<RecentActivities activities={stats.sonAktiviteler} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Son İlanlar + Bugünkü Takipler */}
|
||||
<div className="grid gap-4 lg:grid-cols-5 lg:items-stretch">
|
||||
<div className="min-w-0 lg:col-span-3">
|
||||
<RecentProperties properties={stats.sonIlanlar} />
|
||||
</div>
|
||||
<div data-tour="dashboard-matches" className="min-w-0 lg:col-span-2">
|
||||
<FollowUpWidget customers={stats.takipMusteri} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema";
|
||||
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||
import { Query } from "node-appwrite";
|
||||
import { FinanceClient } from "@/components/finance/finance-client";
|
||||
import type { Deal } from "@/lib/appwrite/schema";
|
||||
|
||||
export default async function FinancePage() {
|
||||
const ctx = await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const dealQueries = [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(200),
|
||||
];
|
||||
|
||||
if (ctx.role === "member") {
|
||||
dealQueries.push(Query.equal("agentId", ctx.user.id));
|
||||
}
|
||||
|
||||
const [dealsResult, customers] = await Promise.all([
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.deals,
|
||||
queries: dealQueries,
|
||||
}),
|
||||
listCustomers(ctx.tenantId),
|
||||
]);
|
||||
|
||||
const deals = JSON.parse(JSON.stringify(dealsResult.rows)) as Deal[];
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-6 lg:px-8">
|
||||
<FinanceClient
|
||||
initialDeals={deals}
|
||||
customers={customers}
|
||||
role={ctx.role}
|
||||
userId={ctx.user.id}
|
||||
userName={ctx.user.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
@@ -9,6 +10,7 @@ import { InvestorsClient } from "@/components/investors/investors-client";
|
||||
|
||||
export default async function InvestorsPage() {
|
||||
const ctx = await requireTenant();
|
||||
if (ctx.role === "member") redirect("/dashboard");
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const result = await tablesDB.listRows({
|
||||
@@ -21,7 +23,7 @@ export default async function InvestorsPage() {
|
||||
],
|
||||
});
|
||||
|
||||
const investors = result.rows as unknown as Investor[];
|
||||
const investors = JSON.parse(JSON.stringify(result.rows)) as Investor[];
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||
|
||||
@@ -1,17 +1,63 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { getActiveContext } from "@/lib/appwrite/active-context";
|
||||
import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||
import { createAdminClient, createSessionClient, getCurrentUser } from "@/lib/appwrite/server";
|
||||
import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema";
|
||||
import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions";
|
||||
import { PLAN_LIMITS } from "@/lib/plans";
|
||||
import { DashboardShell } from "./dashboard-shell";
|
||||
|
||||
function detectMobileUA(ua: string | null): boolean {
|
||||
if (!ua) return false;
|
||||
return /Android|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i.test(ua);
|
||||
}
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const hdrs = await headers();
|
||||
const serverIsMobile = detectMobileUA(hdrs.get("user-agent"));
|
||||
|
||||
const sessionUser = await getCurrentUser();
|
||||
if (!sessionUser) redirect("/sign-in");
|
||||
|
||||
const ctx = await getActiveContext();
|
||||
if (!ctx) redirect("/onboarding");
|
||||
|
||||
// Hard plan enforcement: non-owner members on free plan with more than 1 confirmed member
|
||||
const plan = ctx.settings?.plan ?? "free";
|
||||
const memberLimit = PLAN_LIMITS[plan].teamMembers;
|
||||
if (ctx.role !== "owner" && memberLimit !== Infinity && ctx.memberCount > memberLimit) {
|
||||
redirect("/plan-limit");
|
||||
}
|
||||
|
||||
let pendingMatchCount = 0;
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const res = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.propertyMatches,
|
||||
queries: [Query.equal("tenantId", ctx.tenantId), Query.equal("notified", false), Query.limit(1)],
|
||||
});
|
||||
pendingMatchCount = res.total;
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
let themePrefs: ThemePrefs = {};
|
||||
try {
|
||||
const { account } = await createSessionClient();
|
||||
const raw = await account.getPrefs<ThemePrefs>();
|
||||
// getPrefs returns an Appwrite prototype object — serialize to plain object
|
||||
// so Next.js can pass it from Server → Client Component
|
||||
themePrefs = JSON.parse(JSON.stringify(raw)) as ThemePrefs;
|
||||
} catch {
|
||||
// use defaults if prefs unavailable
|
||||
}
|
||||
|
||||
const company = {
|
||||
id: ctx.tenantId,
|
||||
name: ctx.settings?.officeName ?? "Çalışma alanı",
|
||||
@@ -24,7 +70,7 @@ export default async function DashboardLayout({
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell user={user} company={company}>
|
||||
<DashboardShell user={user} company={company} initialPrefs={themePrefs} pendingMatchCount={pendingMatchCount} role={ctx.role} serverIsMobile={serverIsMobile}>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
);
|
||||
|
||||
@@ -8,12 +8,13 @@ import { listProperties } from "@/lib/appwrite/property-queries";
|
||||
import { DATABASE_ID, TABLES, type Presentation } from "@/lib/appwrite/schema";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { PresentationsClient } from "@/components/presentations/presentations-client";
|
||||
import { checkLimit, PLAN_LIMITS } from "@/lib/plans";
|
||||
|
||||
export default async function PresentationsPage() {
|
||||
const ctx = await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const [customers, properties, presResult] = await Promise.all([
|
||||
const [customers, properties, presResult, limitResult] = await Promise.all([
|
||||
listCustomers(ctx.tenantId),
|
||||
listProperties(ctx.tenantId),
|
||||
tablesDB.listRows({
|
||||
@@ -25,9 +26,15 @@ export default async function PresentationsPage() {
|
||||
Query.limit(200),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
checkLimit(ctx.tenantId, ctx.settings?.plan, "presentations"),
|
||||
]).catch((e) => {
|
||||
console.error("[PresentationsPage] data fetch failed:", e);
|
||||
throw e;
|
||||
});
|
||||
|
||||
const presentations = presResult.rows as unknown as Presentation[];
|
||||
const presentations = JSON.parse(JSON.stringify(presResult.rows)) as Presentation[];
|
||||
const plan = ctx.settings?.plan ?? "free";
|
||||
const presLimit = PLAN_LIMITS[plan].presentations;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||
@@ -35,6 +42,8 @@ export default async function PresentationsPage() {
|
||||
initialPresentations={presentations}
|
||||
customers={customers}
|
||||
properties={properties}
|
||||
isOwner={ctx.role === "owner"}
|
||||
usageLimit={presLimit !== Infinity ? { current: limitResult.current, limit: presLimit } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type Property,
|
||||
type PropertyMatch,
|
||||
type CustomerSearch,
|
||||
type Activity,
|
||||
} from "@/lib/appwrite/schema";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { parseImageIds } from "@/lib/appwrite/storage-utils";
|
||||
import { PropertyDetailClient } from "@/components/properties/property-detail-client";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PropertyDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
const ctx = await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
let property: Property;
|
||||
try {
|
||||
const row = await tablesDB.getRow(DATABASE_ID, TABLES.properties, id);
|
||||
property = JSON.parse(JSON.stringify(row)) as Property;
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (property.tenantId !== ctx.tenantId) notFound();
|
||||
|
||||
const [customers, matchesResult, activitiesResult, searchesResult] = await Promise.all([
|
||||
listCustomers(ctx.tenantId),
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.propertyMatches,
|
||||
queries: [
|
||||
Query.equal("propertyId", id),
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.orderDesc("score"),
|
||||
Query.limit(50),
|
||||
],
|
||||
}),
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.activities,
|
||||
queries: [
|
||||
Query.equal("propertyId", id),
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(20),
|
||||
],
|
||||
}),
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.customerSearches,
|
||||
queries: [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.limit(200),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const matches = JSON.parse(JSON.stringify(matchesResult.rows)) as PropertyMatch[];
|
||||
const activities = JSON.parse(JSON.stringify(activitiesResult.rows)) as Activity[];
|
||||
const searches = JSON.parse(JSON.stringify(searchesResult.rows)) as CustomerSearch[];
|
||||
const imageIds = parseImageIds(property.imageIds);
|
||||
|
||||
return (
|
||||
<PropertyDetailClient
|
||||
property={property}
|
||||
matches={matches}
|
||||
activities={activities}
|
||||
imageIds={imageIds}
|
||||
customers={customers}
|
||||
searches={searches}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function PropertiesLoading() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||
{/* Başlık + buton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-7 w-24" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-28 rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Filtre bar */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Skeleton className="h-9 w-48 rounded-md" />
|
||||
<Skeleton className="h-9 w-32 rounded-md" />
|
||||
<Skeleton className="h-9 w-32 rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Tablo */}
|
||||
<div className="rounded-xl border overflow-hidden">
|
||||
<div className="bg-muted/30 p-3 flex gap-4">
|
||||
{[120, 80, 80, 70, 60].map((w, i) => (
|
||||
<Skeleton key={i} className="h-3" style={{ width: w }} />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="border-t p-3 flex gap-4 items-center">
|
||||
<Skeleton className="h-10 w-10 rounded-md shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-20 hidden sm:block" />
|
||||
<Skeleton className="h-4 w-16 hidden md:block" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,28 @@ export const dynamic = "force-dynamic";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { listProperties } from "@/lib/appwrite/property-queries";
|
||||
import { PropertiesClient } from "@/components/properties/properties-client";
|
||||
import { checkLimit, PLAN_LIMITS } from "@/lib/plans";
|
||||
|
||||
export default async function PropertiesPage() {
|
||||
const ctx = await requireTenant();
|
||||
const properties = await listProperties(ctx.tenantId);
|
||||
const [properties, limitResult] = await Promise.all([
|
||||
listProperties(ctx.tenantId),
|
||||
checkLimit(ctx.tenantId, ctx.settings?.plan, "properties"),
|
||||
]);
|
||||
|
||||
const plan = ctx.settings?.plan ?? "free";
|
||||
const limit = PLAN_LIMITS[plan].properties;
|
||||
|
||||
const imgLimit = PLAN_LIMITS[plan].propertyImages;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||
<PropertiesClient initialProperties={properties} />
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6 overflow-hidden">
|
||||
<PropertiesClient
|
||||
initialProperties={properties}
|
||||
isOwner={ctx.role === "owner"}
|
||||
usageLimit={limit !== Infinity ? { current: limitResult.current, limit } : undefined}
|
||||
maxImagesPerProperty={imgLimit !== Infinity ? imgLimit : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { CircleNotch, FloppyDisk } from '@/lib/icons';
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -65,12 +65,12 @@ export function EmailForm({ currentEmail }: { currentEmail: string }) {
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Güncelleniyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-4" />
|
||||
<FloppyDisk className="size-4" />
|
||||
Email'i güncelle
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { CircleNotch, FloppyDisk } from '@/lib/icons';
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -39,12 +39,12 @@ export function NameForm({ currentName }: { currentName: string }) {
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Kaydediliyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-4" />
|
||||
<FloppyDisk className="size-4" />
|
||||
Kaydet
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef } from "react";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { Key, CircleNotch } from '@/lib/icons';
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -82,12 +82,12 @@ export function PasswordForm() {
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Güncelleniyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KeyRound className="size-4" />
|
||||
<Key className="size-4" />
|
||||
Şifreyi değiştir
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default async function AccountSettingsPage() {
|
||||
if (!user) redirect("/sign-in");
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||
<div className="flex-1 space-y-6 px-6 pt-4">
|
||||
<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>
|
||||
|
||||
@@ -218,7 +218,7 @@ export default function AppearanceSettings() {
|
||||
|
||||
<div className="flex space-x-2 mt-12">
|
||||
<Button type="submit" className="cursor-pointer">
|
||||
Save Preferences
|
||||
FloppyDisk Preferences
|
||||
</Button>
|
||||
<Button variant="outline" type="button" className="cursor-pointer">Cancel</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { Crown, Lightning, Star } from "@/lib/icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import type { PlanUsage } from "@/lib/appwrite/plan-limits";
|
||||
import type { TenantPlan, PlanPeriod } from "@/lib/appwrite/schema";
|
||||
import { PLAN_NAMES } from "@/lib/appwrite/plan-limits-shared";
|
||||
|
||||
const RESOURCE_LABELS: Record<string, string> = {
|
||||
properties: "Aktif İlan",
|
||||
customers: "Müşteri",
|
||||
members: "Ekip Üyesi",
|
||||
presentations: "Sunum",
|
||||
};
|
||||
|
||||
function PlanIcon({ plan }: { plan: TenantPlan }) {
|
||||
if (plan === "enterprise") return <Crown className="h-4 w-4" />;
|
||||
if (plan === "pro") return <Star className="h-4 w-4" />;
|
||||
if (plan === "starter") return <Lightning className="h-4 w-4" />;
|
||||
return <Lightning className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
function planBadgeVariant(plan: TenantPlan): "default" | "secondary" | "outline" {
|
||||
if (plan === "enterprise" || plan === "pro") return "default";
|
||||
if (plan === "starter") return "outline";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
export function CurrentPlanCard({
|
||||
plan,
|
||||
period,
|
||||
expiresAt,
|
||||
usage,
|
||||
}: {
|
||||
plan: TenantPlan;
|
||||
period?: PlanPeriod | null;
|
||||
expiresAt: string | null;
|
||||
usage: PlanUsage["usage"];
|
||||
}) {
|
||||
const isPaid = plan !== "free";
|
||||
const expiryDate = expiresAt ? new Date(expiresAt).toLocaleDateString("tr-TR") : null;
|
||||
const periodLabel = period === "yearly" ? "Yıllık" : period === "monthly" ? "Aylık" : null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* Plan bilgisi */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary shrink-0">
|
||||
<PlanIcon plan={plan} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{PLAN_NAMES[plan]} Plan</span>
|
||||
<Badge variant={planBadgeVariant(plan)} className="gap-1 text-[11px]">
|
||||
<PlanIcon plan={plan} />
|
||||
{PLAN_NAMES[plan]}
|
||||
{periodLabel && <span className="opacity-70">· {periodLabel}</span>}
|
||||
</Badge>
|
||||
</div>
|
||||
{isPaid && expiryDate && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{expiryDate} tarihine kadar geçerli
|
||||
</p>
|
||||
)}
|
||||
{!isPaid && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Ücretsiz kullanımdaki özellikler
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kullanım çubukları */}
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-2 sm:grid-cols-4">
|
||||
{(Object.entries(usage) as [keyof typeof usage, PlanUsage["usage"][keyof PlanUsage["usage"]]][]).map(
|
||||
([resource, { used, limit, reached }]) => {
|
||||
const isUnlimited = limit === Number.POSITIVE_INFINITY;
|
||||
const pct = isUnlimited ? 0 : Math.min(100, (used / limit) * 100);
|
||||
return (
|
||||
<div key={resource} className="min-w-[80px]">
|
||||
<div className="flex justify-between text-[11px] text-muted-foreground mb-1">
|
||||
<span>{RESOURCE_LABELS[resource] ?? resource}</span>
|
||||
<span className={reached && !isUnlimited ? "text-destructive font-medium" : ""}>
|
||||
{isUnlimited ? `${used}/∞` : `${used}/${limit}`}
|
||||
</span>
|
||||
</div>
|
||||
{isUnlimited ? (
|
||||
<div className="h-1 rounded-full bg-primary/20" />
|
||||
) : (
|
||||
<Progress
|
||||
value={pct}
|
||||
className={`h-1 ${reached ? "[&>div]:bg-destructive" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle, Tag } from "@/lib/icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
|
||||
import type { PaymentEvent } from "@/lib/appwrite/schema";
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
paytr: "PayTR",
|
||||
polar: "Polar",
|
||||
shopier: "Shopier",
|
||||
mock: "Test",
|
||||
};
|
||||
|
||||
export function PaymentHistory({ events }: { events: PaymentEvent[] }) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Ödeme Geçmişi</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Henüz ödeme kaydı bulunmuyor.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Ödeme Geçmişi</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{events.map((event) => {
|
||||
const planEntry = PLAN_CATALOG[event.plan as keyof typeof PLAN_CATALOG];
|
||||
const periodLabel = event.period === "yearly" ? "Yıllık" : "Aylık";
|
||||
const date = new Date(event.$createdAt).toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.$id}
|
||||
className="flex items-center justify-between px-6 py-3.5 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-500/10 text-green-600 shrink-0">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{planEntry?.name ?? event.plan} — {periodLabel}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs flex items-center gap-1.5 mt-0.5">
|
||||
<span>{date}</span>
|
||||
<span>·</span>
|
||||
<span>{PROVIDER_LABELS[event.provider] ?? event.provider}</span>
|
||||
{event.discountCode && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Tag className="h-3 w-3" />
|
||||
{event.discountCode}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className="text-green-600 border-green-500/30 bg-green-500/5">
|
||||
{event.amount.toLocaleString("tr-TR")} ₺
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Crown, Check, CircleNotch, Star, Lightning, X, Envelope, Tag } from "@/lib/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
startCheckoutAction,
|
||||
getPayTRTokenAction,
|
||||
requestEnterpriseInquiryAction,
|
||||
validateDiscountCodeAction,
|
||||
} from "@/lib/appwrite/subscription-actions";
|
||||
import { PLAN_CATALOG, PLAN_RANK, planPriceDisplay, planPrice } from "@/lib/appwrite/subscription-types";
|
||||
import type { TenantPlan, PlanPeriod } from "@/lib/appwrite/schema";
|
||||
|
||||
const PLAN_ICONS: Record<string, React.ReactNode> = {
|
||||
starter: <Lightning className="h-5 w-5" />,
|
||||
pro: <Star className="h-5 w-5" />,
|
||||
enterprise: <Crown className="h-5 w-5" />,
|
||||
};
|
||||
|
||||
function calcProration(
|
||||
currentPlan: TenantPlan,
|
||||
currentPeriod: PlanPeriod,
|
||||
planExpiresAt: string | null,
|
||||
newPlanPrice: number,
|
||||
): number {
|
||||
if (!planExpiresAt || currentPlan === "free") return newPlanPrice;
|
||||
const currentEntry = PLAN_CATALOG[currentPlan as Exclude<TenantPlan, "free">];
|
||||
if (!currentEntry) return newPlanPrice;
|
||||
const periodDays = currentPeriod === "yearly" ? 365 : 30;
|
||||
const msRemaining = new Date(planExpiresAt).getTime() - Date.now();
|
||||
const daysRemaining = Math.max(0, msRemaining / (1000 * 60 * 60 * 24));
|
||||
const unusedFraction = Math.min(1, daysRemaining / periodDays);
|
||||
const credit = planPrice(currentEntry, currentPeriod) * unusedFraction;
|
||||
return Math.max(1, Math.round(newPlanPrice - credit));
|
||||
}
|
||||
|
||||
export function UpgradeSection({
|
||||
currentPlan,
|
||||
currentPeriod,
|
||||
planExpiresAt,
|
||||
paytrEnabled,
|
||||
}: {
|
||||
currentPlan: TenantPlan;
|
||||
currentPeriod?: PlanPeriod | null;
|
||||
planExpiresAt?: string | null;
|
||||
paytrEnabled?: boolean;
|
||||
}) {
|
||||
const [period, setPeriod] = useState<PlanPeriod>(currentPeriod ?? "monthly");
|
||||
const [paytrToken, setPaytrToken] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [enterpriseDialogOpen, setEnterpriseDialogOpen] = useState(false);
|
||||
const [loadingPlan, setLoadingPlan] = useState<string | null>(null);
|
||||
const [discountCode, setDiscountCode] = useState("");
|
||||
const [discountApplied, setDiscountApplied] = useState<number | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [enterpriseForm, setEnterpriseForm] = useState({
|
||||
teamSize: "",
|
||||
listingCount: "",
|
||||
needsCustomDev: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const plans = Object.values(PLAN_CATALOG);
|
||||
const currentRank = PLAN_RANK[currentPlan];
|
||||
|
||||
function applyDiscount() {
|
||||
if (!discountCode.trim()) return;
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const fraction = await validateDiscountCodeAction(discountCode.trim());
|
||||
setDiscountApplied(fraction);
|
||||
toast.success(`İndirim kodu uygulandı: %${Math.round(fraction * 100)} indirim`);
|
||||
} catch {
|
||||
toast.error("Geçersiz indirim kodu.");
|
||||
setDiscountApplied(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getDisplayPrice(plan: typeof plans[0]): number {
|
||||
let base = planPriceDisplay(plan, period);
|
||||
if (discountApplied !== null) base = Math.round(base * (1 - discountApplied));
|
||||
return base;
|
||||
}
|
||||
|
||||
function getProrationAmount(planId: string): number | null {
|
||||
if (currentPlan === "free" || !planExpiresAt) return null;
|
||||
const entry = PLAN_CATALOG[planId as Exclude<TenantPlan, "free">];
|
||||
if (!entry) return null;
|
||||
let base = planPrice(entry, period);
|
||||
if (discountApplied !== null) base = Math.max(1, Math.round(base * (1 - discountApplied)));
|
||||
const prorated = calcProration(currentPlan, currentPeriod ?? "monthly", planExpiresAt, base);
|
||||
return prorated < base ? prorated : null;
|
||||
}
|
||||
|
||||
function handleCheckout(planId: string) {
|
||||
if (paytrEnabled) {
|
||||
setLoadingPlan(planId);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("plan", planId);
|
||||
fd.set("period", period);
|
||||
if (discountApplied !== null) fd.set("discountCode", discountCode.trim().toUpperCase());
|
||||
const token = await getPayTRTokenAction(fd);
|
||||
setPaytrToken(token);
|
||||
setDialogOpen(true);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ödeme başlatılamadı.");
|
||||
} finally {
|
||||
setLoadingPlan(null);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setLoadingPlan(planId);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("plan", planId);
|
||||
await startCheckoutAction(fd);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes("NEXT_REDIRECT")) throw e;
|
||||
toast.error("Ödeme başlatılamadı.");
|
||||
} finally {
|
||||
setLoadingPlan(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnterpriseInquiry() {
|
||||
if (!enterpriseForm.teamSize || !enterpriseForm.listingCount || !enterpriseForm.needsCustomDev) {
|
||||
toast.error("Lütfen tüm zorunlu alanları doldurun.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("teamSize", enterpriseForm.teamSize);
|
||||
fd.set("listingCount", enterpriseForm.listingCount);
|
||||
fd.set("needsCustomDev", enterpriseForm.needsCustomDev);
|
||||
fd.set("notes", enterpriseForm.notes);
|
||||
const result = await requestEnterpriseInquiryAction(fd);
|
||||
if (result.ok) {
|
||||
setEnterpriseDialogOpen(false);
|
||||
setEnterpriseForm({ teamSize: "", listingCount: "", needsCustomDev: "", notes: "" });
|
||||
toast.success("Talebiniz alındı. Ekibimiz kısa sürede sizinle iletişime geçecek.");
|
||||
} else {
|
||||
toast.error(result.error ?? "Talep gönderilemedi.");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Talep gönderilemedi.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{/* Başlık + toggle */}
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">Planınızı seçin</h2>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Dilediğiniz zaman daha üst bir plana geçebilirsiniz.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-full border bg-muted/40 p-1 text-sm">
|
||||
<button
|
||||
onClick={() => setPeriod("monthly")}
|
||||
className={`rounded-full px-5 py-1.5 font-medium transition-all ${
|
||||
period === "monthly"
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Aylık
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPeriod("yearly")}
|
||||
className={`flex items-center gap-2 rounded-full px-5 py-1.5 font-medium transition-all ${
|
||||
period === "yearly"
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Yıllık
|
||||
<Badge variant="secondary" className="text-[11px] px-1.5 py-0 rounded-full">
|
||||
2 ay bedava
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* İndirim kodu */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={discountCode}
|
||||
onChange={(e) => {
|
||||
setDiscountCode(e.target.value.toUpperCase());
|
||||
if (discountApplied !== null) setDiscountApplied(null);
|
||||
}}
|
||||
onKeyDown={(e) => e.key === "Enter" && applyDiscount()}
|
||||
placeholder="İndirim kodu"
|
||||
className="h-9 w-40 rounded-lg border bg-background pl-8 pr-3 text-sm outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={applyDiscount}
|
||||
disabled={!discountCode.trim()}
|
||||
>
|
||||
Uygula
|
||||
</Button>
|
||||
{discountApplied !== null && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Check className="h-3 w-3" />
|
||||
%{Math.round(discountApplied * 100)} indirim
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3 plan kartı */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{plans.map((plan) => {
|
||||
const isCurrent = currentPlan === plan.id;
|
||||
const isLower = PLAN_RANK[plan.id as TenantPlan] < currentRank;
|
||||
const isEnterprise = plan.id === "enterprise";
|
||||
const displayPrice = getDisplayPrice(plan);
|
||||
const prorationAmount = !isCurrent && !isLower ? getProrationAmount(plan.id) : null;
|
||||
const isLoading = isPending && loadingPlan === plan.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className={`relative flex flex-col transition-shadow ${
|
||||
plan.highlight
|
||||
? "border-primary shadow-md ring-1 ring-primary/20"
|
||||
: ""
|
||||
} ${isLower ? "opacity-50" : ""}`}
|
||||
>
|
||||
{plan.highlight && (
|
||||
<div className="absolute -top-3.5 inset-x-0 flex justify-center">
|
||||
<Badge className="rounded-full px-3 text-xs shadow-sm">
|
||||
Popüler
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="pb-4 pt-6">
|
||||
<div className={`flex items-center gap-2 mb-3 ${plan.highlight ? "text-primary" : ""}`}>
|
||||
{PLAN_ICONS[plan.id]}
|
||||
<span className="font-semibold text-base">{plan.name}</span>
|
||||
{isCurrent && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 ml-auto">
|
||||
Mevcut
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-end gap-1">
|
||||
<span className="text-4xl font-bold tracking-tight">
|
||||
{displayPrice.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm mb-1">₺/ay</span>
|
||||
{discountApplied !== null && !isCurrent && !isLower && (
|
||||
<span className="text-muted-foreground text-xs line-through mb-1 ml-1">
|
||||
{planPriceDisplay(plan, period).toLocaleString("tr-TR")}₺
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{period === "yearly" ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{plan.yearly.toLocaleString("tr-TR")} ₺ yıllık faturalandırılır
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{plan.description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col flex-1 gap-5">
|
||||
<ul className="space-y-2.5 flex-1">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm">
|
||||
<Check className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{isCurrent ? (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Mevcut Planınız
|
||||
</Button>
|
||||
) : isLower ? (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Plan düşürülemez
|
||||
</Button>
|
||||
) : isEnterprise ? (
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
variant="outline"
|
||||
onClick={() => setEnterpriseDialogOpen(true)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Envelope className="h-4 w-4" />
|
||||
Teklif Al
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
variant={plan.highlight ? "default" : "outline"}
|
||||
disabled={isPending}
|
||||
onClick={() => handleCheckout(plan.id)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CircleNotch className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
PLAN_ICONS[plan.id]
|
||||
)}
|
||||
{isLoading ? "Yükleniyor..." : `${plan.name}'a Geç`}
|
||||
</Button>
|
||||
{prorationAmount !== null && (
|
||||
<p className="text-center text-[11px] text-muted-foreground">
|
||||
Bugün sadece{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{prorationAmount.toLocaleString("tr-TR")} ₺
|
||||
</span>{" "}
|
||||
ödersiniz (kalan süre mahsup edilir)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Abonelik iptali için{" "}
|
||||
<a href="mailto:info@kovakyazilim.com" className="underline hover:text-foreground transition-colors">
|
||||
info@kovakyazilim.com
|
||||
</a>{" "}
|
||||
ile iletişime geçin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* PayTR iframe dialog */}
|
||||
{paytrEnabled && (
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) setPaytrToken(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg p-0 overflow-hidden" showCloseButton={false}>
|
||||
<DialogHeader className="flex flex-row items-center justify-between px-4 py-3 border-b">
|
||||
<DialogTitle className="text-base">Güvenli Ödeme</DialogTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
{paytrToken && (
|
||||
<iframe
|
||||
src={`https://www.paytr.com/odeme/guvenli/${paytrToken}`}
|
||||
className="w-full border-none"
|
||||
style={{ height: 560 }}
|
||||
allowFullScreen
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Enterprise inquiry dialog */}
|
||||
<Dialog open={enterpriseDialogOpen} onOpenChange={setEnterpriseDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Crown className="h-5 w-5 text-primary" />
|
||||
Enterprise Plan Teklifi
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ekibimiz sizin için özel fiyatlandırma ve kurulum planı hazırlasın. Birkaç soruyu
|
||||
yanıtlayın, en kısa sürede size ulaşalım.
|
||||
</p>
|
||||
|
||||
{/* Ekip büyüklüğü */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
Ekibinizdeki danışman sayısı <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{["1-3", "4-10", "11-25", "25+"].map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => setEnterpriseForm((f) => ({ ...f, teamSize: opt }))}
|
||||
className={`rounded-lg border py-2 text-sm font-medium transition-colors ${
|
||||
enterpriseForm.teamSize === opt
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ilan sayısı */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
Aktif ilan sayınız <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{["1-50", "51-200", "201-500", "500+"].map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => setEnterpriseForm((f) => ({ ...f, listingCount: opt }))}
|
||||
className={`rounded-lg border py-2 text-sm font-medium transition-colors ${
|
||||
enterpriseForm.listingCount === opt
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Özel modül */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
Özel modül / entegrasyon geliştirmesi istiyor musunuz?{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: "yes", label: "Evet" },
|
||||
{ value: "no", label: "Hayır" },
|
||||
{ value: "maybe", label: "Değerlendiriyorum" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setEnterpriseForm((f) => ({ ...f, needsCustomDev: opt.value }))}
|
||||
className={`rounded-lg border py-2 text-sm font-medium transition-colors ${
|
||||
enterpriseForm.needsCustomDev === opt.value
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notlar */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
Eklemek istediğiniz notlar{" "}
|
||||
<span className="text-muted-foreground font-normal">(isteğe bağlı)</span>
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={enterpriseForm.notes}
|
||||
onChange={(e) => setEnterpriseForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
placeholder="Özel ihtiyaçlarınızı, entegrasyon beklentilerinizi veya sorularınızı buraya yazabilirsiniz..."
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setEnterpriseDialogOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 gap-2"
|
||||
onClick={handleEnterpriseInquiry}
|
||||
disabled={isPending || !enterpriseForm.teamSize || !enterpriseForm.listingCount || !enterpriseForm.needsCustomDev}
|
||||
>
|
||||
{isPending ? (
|
||||
<CircleNotch className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Envelope className="h-4 w-4" />
|
||||
)}
|
||||
Teklif Gönder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "@/lib/icons";
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { getPaymentHistoryAction } from "@/lib/appwrite/subscription-actions";
|
||||
import { PaymentHistory } from "../components/payment-history";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "KovakEmlak — Faturalar",
|
||||
};
|
||||
|
||||
export default async function InvoicesPage() {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
if (ctx.role !== "owner") redirect("/dashboard");
|
||||
|
||||
const rawEvents = await getPaymentHistoryAction();
|
||||
const events = JSON.parse(JSON.stringify(rawEvents));
|
||||
const officeName = ctx.settings?.officeName ?? "Ofis";
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit mb-1"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Plan & Faturalama
|
||||
</Link>
|
||||
<p className="text-muted-foreground text-sm">{officeName}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Faturalar</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Abonelik ödemelerinizin geçmişi.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PaymentHistory events={events} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { CheckCircle, Receipt } from '@/lib/icons';
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { getEffectivePlan, getPlanUsage } from "@/lib/appwrite/plan-limits";
|
||||
import { isPayTREnabled } from "@/lib/payments/paytr";
|
||||
import { CurrentPlanCard } from "./components/current-plan-card";
|
||||
import { UpgradeSection } from "./components/upgrade-section";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "KovakEmlak — Plan & Faturalama",
|
||||
};
|
||||
|
||||
export default async function BillingPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
if (ctx.role !== "owner") redirect("/dashboard");
|
||||
|
||||
const params = await searchParams;
|
||||
const upgraded = params.upgraded === "1";
|
||||
|
||||
const plan = getEffectivePlan(ctx);
|
||||
const { usage } = await getPlanUsage(ctx);
|
||||
const paytrEnabled = isPayTREnabled();
|
||||
|
||||
const officeName = ctx.settings?.officeName ?? "Ofis";
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">{officeName}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Plan & Faturalama</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Mevcut planınızı görüntüleyin ve yönetin.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/settings/billing/invoices"
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0 mt-1"
|
||||
>
|
||||
<Receipt className="h-4 w-4" />
|
||||
Faturalar
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{upgraded && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/10 px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
||||
<CheckCircle className="h-4 w-4 shrink-0" />
|
||||
Tebrikler! Planınız başarıyla yükseltildi.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CurrentPlanCard
|
||||
plan={plan}
|
||||
period={ctx.settings?.planPeriod ?? null}
|
||||
expiresAt={ctx.settings?.planExpiresAt ?? null}
|
||||
usage={usage}
|
||||
/>
|
||||
|
||||
<UpgradeSection
|
||||
currentPlan={plan}
|
||||
currentPeriod={ctx.settings?.planPeriod ?? null}
|
||||
planExpiresAt={ctx.settings?.planExpiresAt ?? null}
|
||||
paytrEnabled={paytrEnabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ 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 { GithubLogo, SlackLogo, TwitterLogo, Lightning, Globe, Database, AppleLogo, Browser, FacebookLogo, InstagramLogo, DribbbleLogo } from '@/lib/icons'
|
||||
import { useState } from "react"
|
||||
export default function ConnectionSettings() {
|
||||
// Controlled state for switches
|
||||
@@ -37,9 +37,9 @@ export default function ConnectionSettings() {
|
||||
<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" />
|
||||
<AppleLogo className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">Apple</div>
|
||||
<div className="font-medium">AppleLogo</div>
|
||||
<div className="text-sm text-muted-foreground">Calendar and contacts</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,7 +52,7 @@ export default function ConnectionSettings() {
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Chrome className="h-8 w-8" />
|
||||
<Browser className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">Google</div>
|
||||
<div className="text-sm text-muted-foreground">Calendar and contacts</div>
|
||||
@@ -67,9 +67,9 @@ export default function ConnectionSettings() {
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Github className="h-8 w-8" />
|
||||
<GithubLogo className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">Github</div>
|
||||
<div className="font-medium">GithubLogo</div>
|
||||
<div className="text-sm text-muted-foreground">Manage your Git repositories</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,9 +82,9 @@ export default function ConnectionSettings() {
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Slack className="h-8 w-8" />
|
||||
<SlackLogo className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">Slack</div>
|
||||
<div className="font-medium">SlackLogo</div>
|
||||
<div className="text-sm text-muted-foreground">Communication</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,13 +109,13 @@ export default function ConnectionSettings() {
|
||||
<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" />
|
||||
<FacebookLogo className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
Facebook
|
||||
FacebookLogo
|
||||
<Badge variant="outline" className="ml-2">Not Connected</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Share updates on Facebook</div>
|
||||
<div className="text-sm text-muted-foreground">Share updates on FacebookLogo</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="cursor-pointer">
|
||||
@@ -125,13 +125,13 @@ export default function ConnectionSettings() {
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Twitter className="h-8 w-8" />
|
||||
<TwitterLogo className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
Twitter
|
||||
TwitterLogo
|
||||
<Badge variant="secondary" className="ml-2">connected</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Share updates on Twitter</div>
|
||||
<div className="text-sm text-muted-foreground">Share updates on TwitterLogo</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="cursor-pointer text-destructive">
|
||||
@@ -141,13 +141,13 @@ export default function ConnectionSettings() {
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Instagram className="h-8 w-8" />
|
||||
<InstagramLogo className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
Instagram
|
||||
InstagramLogo
|
||||
<Badge variant="secondary" className="ml-2">connected</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Stay connected at Instagram</div>
|
||||
<div className="text-sm text-muted-foreground">Stay connected at InstagramLogo</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="cursor-pointer text-destructive">
|
||||
@@ -157,13 +157,13 @@ export default function ConnectionSettings() {
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Dribbble className="h-8 w-8" />
|
||||
<DribbbleLogo className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
Dribbble
|
||||
DribbbleLogo
|
||||
<Badge variant="outline" className="ml-2">Not Connected</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Stay connected at Dribbble</div>
|
||||
<div className="text-sm text-muted-foreground">Stay connected at DribbbleLogo</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="cursor-pointer">
|
||||
@@ -186,7 +186,7 @@ export default function ConnectionSettings() {
|
||||
<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" />
|
||||
<Lightning className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">Zapier</div>
|
||||
<div className="text-sm text-muted-foreground">Automate workflows with Zapier</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef, useState } from "react";
|
||||
import { Check, Copy, Loader2, UserPlus } from "lucide-react";
|
||||
import { Check, Copy, CircleNotch, UserPlus } from '@/lib/icons';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -80,7 +80,7 @@ export function InviteForm() {
|
||||
<Button type="submit" disabled={isPending} className="w-full md:w-auto">
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Gönderiliyor...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { DoorOpen, Loader2, Trash2 } from "lucide-react";
|
||||
import { DoorOpen, CircleNotch, Trash } from '@/lib/icons';
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -179,7 +179,7 @@ export function MembersTable({
|
||||
onClick={() => setLeaving(true)}
|
||||
>
|
||||
{busy === "leave" ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<CircleNotch className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<DoorOpen className="size-3.5" />
|
||||
)}
|
||||
@@ -195,9 +195,9 @@ export function MembersTable({
|
||||
onClick={() => setRemoving(m)}
|
||||
>
|
||||
{busy === m.id ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<CircleNotch className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
<Trash className="size-3.5" />
|
||||
)}
|
||||
Çıkar
|
||||
</Button>
|
||||
@@ -240,7 +240,7 @@ export function MembersTable({
|
||||
onClick={handleRemove}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||
{busy ? <CircleNotch className="size-4 animate-spin" /> : <Trash className="size-4" />}
|
||||
Çıkar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -269,7 +269,7 @@ export function MembersTable({
|
||||
onClick={handleLeave}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <DoorOpen className="size-4" />}
|
||||
{busy ? <CircleNotch className="size-4 animate-spin" /> : <DoorOpen className="size-4" />}
|
||||
Ayrıl
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition, useState } from "react";
|
||||
import { Check, Copy, Loader2, X } from "lucide-react";
|
||||
import { Check, Copy, CircleNotch, X } from '@/lib/icons';
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -119,7 +119,7 @@ export function PendingInvitesTable({
|
||||
onClick={() => cancel(inv.id)}
|
||||
>
|
||||
{busy === inv.id ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<CircleNotch className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<X className="size-3.5" />
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Query } from "node-appwrite";
|
||||
@@ -21,6 +23,8 @@ export default async function MembersPage() {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
if (ctx.role === "member") redirect("/dashboard");
|
||||
|
||||
const canManage = ctx.role === "owner" || ctx.role === "admin";
|
||||
const isOwner = ctx.role === "owner";
|
||||
|
||||
@@ -63,7 +67,7 @@ export default async function MembersPage() {
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||
<div className="flex-1 space-y-6 px-6 pt-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">{ctx.settings?.officeName ?? "Çalışma alanı"}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Ekip üyeleri</h1>
|
||||
|
||||
@@ -17,7 +17,7 @@ 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"
|
||||
import { Bell, Envelope, ChatCircle } from '@/lib/icons'
|
||||
|
||||
const notificationsFormSchema = z.object({
|
||||
emailSecurity: z.boolean(),
|
||||
@@ -595,7 +595,7 @@ export default function NotificationSettings() {
|
||||
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" />
|
||||
<Envelope 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>
|
||||
@@ -639,7 +639,7 @@ export default function NotificationSettings() {
|
||||
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" />
|
||||
<ChatCircle 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>
|
||||
@@ -659,7 +659,7 @@ export default function NotificationSettings() {
|
||||
</Card>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" className="cursor-pointer">Save Preferences</Button>
|
||||
<Button type="submit" className="cursor-pointer">FloppyDisk Preferences</Button>
|
||||
<Button variant="outline" type="reset" className="cursor-pointer">Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
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 { Upload } from '@/lib/icons'
|
||||
import { useRef, useState } from "react"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Logo } from "@/components/logo"
|
||||
@@ -95,7 +95,7 @@ export default function UserSettingsPage() {
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Settings</CardTitle>
|
||||
<CardTitle>Profile GearSix</CardTitle>
|
||||
<CardDescription>Update your personal information and preferences</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
@@ -346,7 +346,7 @@ export default function UserSettingsPage() {
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-start gap-3">
|
||||
<Button type="submit" className="cursor-pointer">
|
||||
Save Changes
|
||||
FloppyDisk Changes
|
||||
</Button>
|
||||
<Button variant="outline" type="button" className="cursor-pointer">
|
||||
Cancel
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Building2, ImagePlus, Loader2, Trash2, Upload } from "lucide-react";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Buildings, ImageSquare, CircleNotch, Trash } from '@/lib/icons';
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
removeLogoAction,
|
||||
uploadLogoAction,
|
||||
} from "@/lib/appwrite/logo-actions";
|
||||
import { initialLogoState } from "@/lib/appwrite/logo-types";
|
||||
|
||||
type Props = {
|
||||
canEdit: boolean;
|
||||
@@ -29,32 +28,40 @@ 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 [uploading, startUpload] = useTransition();
|
||||
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 [progress, setProgress] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const progressInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewUrl(currentLogoUrl);
|
||||
}, [currentLogoUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Logo güncellendi.");
|
||||
setSelectedName(null);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
function startProgressAnimation() {
|
||||
setProgress(5);
|
||||
if (progressInterval.current) clearInterval(progressInterval.current);
|
||||
progressInterval.current = setInterval(() => {
|
||||
setProgress((p) => {
|
||||
if (p >= 85) return p;
|
||||
return p + Math.random() * 12 + 3;
|
||||
});
|
||||
}, 250);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
const handleFile = (file: File | null) => {
|
||||
if (!file) return;
|
||||
function stopProgress(success: boolean) {
|
||||
if (progressInterval.current) clearInterval(progressInterval.current);
|
||||
if (success) {
|
||||
setProgress(100);
|
||||
setTimeout(() => setProgress(0), 1200);
|
||||
} else {
|
||||
setProgress(0);
|
||||
}
|
||||
}
|
||||
|
||||
function uploadFile(file: File) {
|
||||
if (!ALLOWED_MIME.includes(file.type)) {
|
||||
toast.error("Sadece PNG, JPG, WebP veya SVG yükleyebilirsiniz.");
|
||||
return;
|
||||
@@ -63,48 +70,65 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
||||
toast.error("Dosya 2MB'dan büyük olamaz.");
|
||||
return;
|
||||
}
|
||||
setSelectedName(file.name);
|
||||
|
||||
// Show local preview immediately
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setPreviewUrl(typeof e.target?.result === "string" ? e.target.result : null);
|
||||
if (typeof e.target?.result === "string") setPreviewUrl(e.target.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
// Auto-upload
|
||||
const formData = new FormData();
|
||||
formData.append("logo", file);
|
||||
startProgressAnimation();
|
||||
|
||||
startUpload(async () => {
|
||||
const result = await uploadLogoAction(null, formData);
|
||||
stopProgress(result.ok);
|
||||
if (result.ok) {
|
||||
toast.success("Logo güncellendi.");
|
||||
} else {
|
||||
toast.error(result.error ?? "Logo yüklenemedi.");
|
||||
setPreviewUrl(currentLogoUrl); // revert preview
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) uploadFile(file);
|
||||
// reset so same file can be re-selected
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
function 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);
|
||||
if (file) uploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
function 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;
|
||||
const busy = uploading || removing;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="size-4" />
|
||||
<Buildings className="size-4" />
|
||||
Logo
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -113,8 +137,8 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form ref={formRef} action={formAction} className="space-y-4">
|
||||
<div className="grid items-start gap-6 md:grid-cols-[200px_1fr]">
|
||||
{/* Preview */}
|
||||
<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
|
||||
@@ -125,77 +149,77 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 p-4 text-center text-xs">
|
||||
<Building2 className="size-8 opacity-40" />
|
||||
<Buildings className="size-8 opacity-40" />
|
||||
<span>Henüz logo yok</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Drop zone — auto-uploads on select */}
|
||||
<label
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (canEdit) setDragOver(true);
|
||||
}}
|
||||
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",
|
||||
"flex min-h-[120px] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors",
|
||||
canEdit && !busy ? "cursor-pointer hover:bg-muted/30" : "cursor-not-allowed opacity-60",
|
||||
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)}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<ImagePlus className="text-muted-foreground size-6" />
|
||||
{uploading ? (
|
||||
<CircleNotch className="size-6 text-muted-foreground animate-spin" />
|
||||
) : (
|
||||
<ImageSquare className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
<div className="text-sm font-medium">
|
||||
{selectedName ?? "Logo yüklemek için tıkla veya sürükle bırak"}
|
||||
{uploading ? "Yükleniyor…" : "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
|
||||
</>
|
||||
{/* Progress bar */}
|
||||
{progress > 0 && (
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
progress === 100 ? "bg-green-500 duration-300" : "bg-primary duration-200",
|
||||
)}
|
||||
</Button>
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canEdit && currentLogoUrl && (
|
||||
|
||||
{/* Remove button */}
|
||||
{canEdit && previewUrl && !uploading && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRemove}
|
||||
disabled={busy}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
{removing ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
<Trash className="size-4" />
|
||||
)}
|
||||
Kaldır
|
||||
Logoyu Kaldır
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!canEdit && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Logo değiştirmek için yönetici yetkisi gerekli.
|
||||
@@ -203,8 +227,6 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { Building2, Loader2, Save } from "lucide-react";
|
||||
import { Buildings, CircleNotch, FloppyDisk } from '@/lib/icons';
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -43,7 +43,7 @@ export function WorkspaceSettingsForm({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="size-4" />
|
||||
<Buildings className="size-4" />
|
||||
Ofis Bilgileri
|
||||
</CardTitle>
|
||||
<CardDescription>Müşterilere ve sunumlarda gösterilecek ofis bilgileri.</CardDescription>
|
||||
@@ -117,12 +117,12 @@ export function WorkspaceSettingsForm({
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Kaydediliyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-4" />
|
||||
<FloppyDisk className="size-4" />
|
||||
Kaydet
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
@@ -18,11 +20,13 @@ export default async function WorkspaceSettingsPage() {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
const canEdit = ctx.role === "owner" || ctx.role === "admin";
|
||||
if (ctx.role === "member") redirect("/dashboard");
|
||||
|
||||
const canEdit = ctx.role === "owner";
|
||||
const officeName = ctx.settings?.officeName ?? "Ofis";
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||
<div className="flex-1 space-y-6 px-6 pt-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">{officeName}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Ofis Bilgileri</h1>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
|
||||
export function SidebarOverlay() {
|
||||
const { open, isMobile, toggleSidebar } = useSidebar();
|
||||
if (isMobile || !open) return null;
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[9] bg-black/20"
|
||||
onClick={toggleSidebar}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function ToolsLoading() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-4 md:p-6">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-6 w-28" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div className="flex gap-6 flex-col lg:flex-row">
|
||||
<div className="w-full lg:w-60 space-y-4">
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-4">
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||
import { WatermarkClient } from "./watermark-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Fotoğraf Damgala — KovakEmlak CRM",
|
||||
};
|
||||
|
||||
async function fetchLogoAsDataUrl(logoUrl: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(logoUrl, { next: { revalidate: 3600 } });
|
||||
if (!res.ok) return null;
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const mime = res.headers.get("content-type") ?? "image/png";
|
||||
return `data:${mime};base64,${buffer.toString("base64")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function WatermarkPage() {
|
||||
const ctx = await requireTenant();
|
||||
const logoUrl = getLogoUrl(ctx.settings?.logo ?? null);
|
||||
const logoDataUrl = logoUrl ? await fetchLogoAsDataUrl(logoUrl) : null;
|
||||
return <WatermarkClient logoDataUrl={logoDataUrl} />;
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Upload, Download, CircleNotch, CheckCircle, XCircle, Repeat, WarningCircle,
|
||||
} from "@/lib/icons";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Position =
|
||||
| "top-left" | "top-center" | "top-right"
|
||||
| "middle-left" | "center" | "middle-right"
|
||||
| "bottom-left" | "bottom-center" | "bottom-right"
|
||||
| "tiled";
|
||||
|
||||
const GRID: Position[][] = [
|
||||
["top-left", "top-center", "top-right"],
|
||||
["middle-left", "center", "middle-right"],
|
||||
["bottom-left", "bottom-center", "bottom-right"],
|
||||
];
|
||||
|
||||
const POS_LABELS: Record<Position, string> = {
|
||||
"top-left": "Sol Üst", "top-center": "Üst Orta", "top-right": "Sağ Üst",
|
||||
"middle-left": "Sol Orta", "center": "Merkez", "middle-right": "Sağ Orta",
|
||||
"bottom-left": "Sol Alt", "bottom-center": "Alt Orta", "bottom-right": "Sağ Alt",
|
||||
"tiled": "Tekrar",
|
||||
};
|
||||
|
||||
interface WatermarkPrefs {
|
||||
position: Position;
|
||||
logoSizePct: number; // 5–40
|
||||
logoOpacity: number; // 10–100
|
||||
bgEnabled: boolean;
|
||||
bgOpacity: number; // 10–60
|
||||
bgColor: "white" | "dark";
|
||||
}
|
||||
|
||||
const DEFAULT_PREFS: WatermarkPrefs = {
|
||||
position: "bottom-right",
|
||||
logoSizePct: 20,
|
||||
logoOpacity: 100,
|
||||
bgEnabled: true,
|
||||
bgOpacity: 25,
|
||||
bgColor: "white",
|
||||
};
|
||||
|
||||
const PREFS_KEY = "kovak-wm-prefs-v1";
|
||||
|
||||
interface WFile {
|
||||
id: string;
|
||||
file: File;
|
||||
originalUrl: string;
|
||||
blob?: Blob;
|
||||
status: "idle" | "processing" | "done" | "error";
|
||||
}
|
||||
|
||||
// ── Canvas helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function loadImg(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
type FixedPos = Exclude<Position, "tiled">;
|
||||
|
||||
function calcXY(
|
||||
pos: FixedPos,
|
||||
iW: number, iH: number,
|
||||
lW: number, lH: number,
|
||||
pad: number,
|
||||
): [number, number] {
|
||||
const map: Record<FixedPos, [number, number]> = {
|
||||
"top-left": [pad, pad],
|
||||
"top-center": [(iW - lW) / 2, pad],
|
||||
"top-right": [iW - lW - pad, pad],
|
||||
"middle-left": [pad, (iH - lH) / 2],
|
||||
"center": [(iW - lW) / 2, (iH - lH) / 2],
|
||||
"middle-right": [iW - lW - pad, (iH - lH) / 2],
|
||||
"bottom-left": [pad, iH - lH - pad],
|
||||
"bottom-center": [(iW - lW) / 2, iH - lH - pad],
|
||||
"bottom-right": [iW - lW - pad, iH - lH - pad],
|
||||
};
|
||||
return map[pos];
|
||||
}
|
||||
|
||||
async function applyWatermark(
|
||||
sourceUrl: string,
|
||||
logo: HTMLImageElement,
|
||||
prefs: WatermarkPrefs,
|
||||
): Promise<Blob> {
|
||||
const src = await loadImg(sourceUrl);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = src.naturalWidth;
|
||||
canvas.height = src.naturalHeight;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
// Fill white so JPEG output has no black artifacts on transparent PNGs
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(src, 0, 0);
|
||||
|
||||
const lW = Math.round(src.naturalWidth * (prefs.logoSizePct / 100));
|
||||
const lH = Math.round((logo.naturalHeight / logo.naturalWidth) * lW);
|
||||
const pad = Math.round(src.naturalWidth * 0.025);
|
||||
|
||||
const logoAlpha = prefs.logoOpacity / 100;
|
||||
|
||||
if (prefs.position === "tiled") {
|
||||
const stepX = lW * 2.8;
|
||||
const stepY = lH * 2.8;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = logoAlpha;
|
||||
for (let y = -lH; y < src.naturalHeight + lH; y += stepY) {
|
||||
for (let x = -lW; x < src.naturalWidth + lW; x += stepX) {
|
||||
ctx.drawImage(logo, x, y, lW, lH);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
} else {
|
||||
const [x, y] = calcXY(prefs.position as FixedPos, src.naturalWidth, src.naturalHeight, lW, lH, pad);
|
||||
|
||||
if (prefs.bgEnabled) {
|
||||
const bp = Math.round(lW * 0.2);
|
||||
const bx = x - bp, by = y - bp;
|
||||
const bw = lW + bp * 2, bh = lH + bp * 2;
|
||||
const r = Math.round(bp * 0.6);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx + r, by);
|
||||
ctx.lineTo(bx + bw - r, by);
|
||||
ctx.arcTo(bx + bw, by, bx + bw, by + r, r);
|
||||
ctx.lineTo(bx + bw, by + bh - r);
|
||||
ctx.arcTo(bx + bw, by + bh, bx + bw - r, by + bh, r);
|
||||
ctx.lineTo(bx + r, by + bh);
|
||||
ctx.arcTo(bx, by + bh, bx, by + bh - r, r);
|
||||
ctx.lineTo(bx, by + r);
|
||||
ctx.arcTo(bx, by, bx + r, by, r);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = prefs.bgColor === "white"
|
||||
? `rgba(255,255,255,${prefs.bgOpacity / 100})`
|
||||
: `rgba(0,0,0,${prefs.bgOpacity / 100})`;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = logoAlpha;
|
||||
ctx.drawImage(logo, x, y, lW, lH);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) =>
|
||||
canvas.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob")), "image/jpeg", 0.92),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function WatermarkClient({ logoDataUrl }: { logoDataUrl: string | null }) {
|
||||
const [prefs, setPrefs] = useState<WatermarkPrefs>(DEFAULT_PREFS);
|
||||
const [files, setFiles] = useState<WFile[]>([]);
|
||||
const [previewId, setPreviewId] = useState<string | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [logoLoaded, setLogoLoaded] = useState(false);
|
||||
|
||||
const logoRef = useRef<HTMLImageElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevPreviewRef = useRef<string | null>(null);
|
||||
|
||||
// ── Load prefs from localStorage on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFS_KEY);
|
||||
if (raw) setPrefs({ ...DEFAULT_PREFS, ...JSON.parse(raw) });
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
// ── Logo: data URL is pre-fetched server-side, just decode into HTMLImageElement
|
||||
useEffect(() => {
|
||||
if (!logoDataUrl) return;
|
||||
const img = new Image();
|
||||
img.onload = () => { logoRef.current = img; setLogoLoaded(true); };
|
||||
img.src = logoDataUrl;
|
||||
}, [logoDataUrl]);
|
||||
|
||||
// ── Live preview (debounced 300 ms)
|
||||
// logoLoaded is in deps so the preview fires once the logo Image decodes
|
||||
useEffect(() => {
|
||||
if (!previewId || !logoRef.current || !logoLoaded) return;
|
||||
const wf = files.find((f) => f.id === previewId);
|
||||
if (!wf) return;
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
if (!logoRef.current) return;
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const blob = await applyWatermark(wf.originalUrl, logoRef.current, prefs);
|
||||
const url = URL.createObjectURL(blob);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev && prev !== prevPreviewRef.current) URL.revokeObjectURL(prev);
|
||||
prevPreviewRef.current = url;
|
||||
return url;
|
||||
});
|
||||
} catch { /* ignore */ } finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [previewId, prefs, logoLoaded]);
|
||||
|
||||
function updatePrefs(patch: Partial<WatermarkPrefs>) {
|
||||
setPrefs((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
try { localStorage.setItem(PREFS_KEY, JSON.stringify(next)); } catch { /* ignore */ }
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function addFiles(incoming: FileList | File[]) {
|
||||
const images = Array.from(incoming).filter((f) => f.type.startsWith("image/"));
|
||||
if (!images.length) return;
|
||||
setFiles((prev) => {
|
||||
const added: WFile[] = images.map((f) => ({
|
||||
id: `${f.name}-${f.size}-${Date.now()}-${Math.random()}`,
|
||||
file: f,
|
||||
originalUrl: URL.createObjectURL(f),
|
||||
status: "idle",
|
||||
}));
|
||||
const next = [...prev, ...added];
|
||||
if (!previewId && added.length > 0) setPreviewId(added[0].id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function processAll() {
|
||||
if (!logoRef.current || !files.length) return;
|
||||
setProcessing(true);
|
||||
|
||||
// Snapshot logo + prefs so concurrent tasks all use the same values
|
||||
const logo = logoRef.current;
|
||||
const currentPrefs = prefs;
|
||||
const toProcess = files.filter((f) => f.status !== "done");
|
||||
|
||||
// Mark all pending as "processing" in one batch
|
||||
setFiles((p) => p.map((x) =>
|
||||
toProcess.some((f) => f.id === x.id) ? { ...x, status: "processing" as const } : x,
|
||||
));
|
||||
|
||||
// Process all files in parallel — each gets its own canvas
|
||||
await Promise.all(
|
||||
toProcess.map(async (wf) => {
|
||||
try {
|
||||
const blob = await applyWatermark(wf.originalUrl, logo, currentPrefs);
|
||||
setFiles((p) => p.map((x) => x.id === wf.id ? { ...x, status: "done" as const, blob } : x));
|
||||
} catch {
|
||||
setFiles((p) => p.map((x) => x.id === wf.id ? { ...x, status: "error" as const } : x));
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setProcessing(false);
|
||||
toast.success("Tüm görseller işlendi!");
|
||||
}
|
||||
|
||||
async function downloadZip() {
|
||||
const done = files.filter((f) => f.blob);
|
||||
if (!done.length) { toast.error("Önce görselleri işleyin."); return; }
|
||||
try {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = new JSZip();
|
||||
for (const wf of done) {
|
||||
if (wf.blob) zip.file(wf.file.name.replace(/\.[^.]+$/, "") + "_wm.jpg", wf.blob);
|
||||
}
|
||||
const zipBlob = await zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 3 } });
|
||||
triggerDownload(zipBlob, "watermarked.zip");
|
||||
} catch {
|
||||
toast.error("ZIP oluşturulamadı.");
|
||||
}
|
||||
}
|
||||
|
||||
function downloadSingle(wf: WFile) {
|
||||
if (!wf.blob) return;
|
||||
triggerDownload(wf.blob, wf.file.name.replace(/\.[^.]+$/, "") + "_wm.jpg");
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
files.forEach((wf) => {
|
||||
URL.revokeObjectURL(wf.originalUrl);
|
||||
});
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
setFiles([]); setPreviewId(null); setPreviewUrl(null);
|
||||
}
|
||||
|
||||
const doneCount = files.filter((f) => f.status === "done").length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-4 md:p-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Fotoğraf Damgala</h1>
|
||||
<p className="text-muted-foreground text-sm mt-0.5">Görsellere otomatik logo ekle ve ZIP olarak indir</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 flex-col lg:flex-row items-start">
|
||||
|
||||
{/* ── Settings panel ──────────────────────────────────── */}
|
||||
<div className="w-full lg:w-60 shrink-0 space-y-5">
|
||||
|
||||
{/* Logo */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Logo</p>
|
||||
{logoDataUrl && logoLoaded ? (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border bg-muted/30">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={logoDataUrl} alt="Logo" className="h-10 w-auto max-w-[110px] object-contain" />
|
||||
<span className="text-xs text-muted-foreground">Ofis logosu</span>
|
||||
</div>
|
||||
) : logoDataUrl ? (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg border text-muted-foreground text-sm">
|
||||
<CircleNotch className="size-4 animate-spin shrink-0" />
|
||||
<span>Yükleniyor…</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 rounded-lg border border-dashed text-sm text-muted-foreground flex items-start gap-2">
|
||||
<WarningCircle className="size-4 mt-0.5 shrink-0 text-amber-500" />
|
||||
<span>
|
||||
Logo bulunamadı.{" "}
|
||||
<a href="/settings/workspace" className="text-primary underline">Ayarlardan</a> yükleyin.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Position grid */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Pozisyon</p>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{GRID.map((row, ri) =>
|
||||
row.map((pos, ci) => {
|
||||
const active = prefs.position === pos;
|
||||
return (
|
||||
<button
|
||||
key={`${ri}-${ci}`}
|
||||
type="button"
|
||||
title={POS_LABELS[pos]}
|
||||
onClick={() => updatePrefs({ position: pos })}
|
||||
className={cn(
|
||||
"h-9 rounded-md border transition-colors flex items-center justify-center",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-muted/40 hover:bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"size-2 rounded-sm",
|
||||
active ? "bg-primary-foreground" : "bg-current opacity-50",
|
||||
)} />
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updatePrefs({ position: "tiled" })}
|
||||
className={cn(
|
||||
"w-full h-8 rounded-md border text-xs font-medium transition-colors flex items-center justify-center gap-1.5",
|
||||
prefs.position === "tiled"
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-muted/40 hover:bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Repeat className="size-3.5" /> Tekrar (Tiled)
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground">{POS_LABELS[prefs.position]}</p>
|
||||
</div>
|
||||
|
||||
{/* Logo size */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Boyut</Label>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">%{prefs.logoSizePct}</span>
|
||||
</div>
|
||||
<input type="range" min={5} max={40} step={1} value={prefs.logoSizePct}
|
||||
onChange={(e) => updatePrefs({ logoSizePct: Number(e.target.value) })}
|
||||
className="w-full accent-primary" />
|
||||
</div>
|
||||
|
||||
{/* Logo opacity — always visible */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Logo Opaklığı</Label>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">%{prefs.logoOpacity}</span>
|
||||
</div>
|
||||
<input type="range" min={10} max={100} step={5} value={prefs.logoOpacity}
|
||||
onChange={(e) => updatePrefs({ logoOpacity: Number(e.target.value) })}
|
||||
className="w-full accent-primary" />
|
||||
</div>
|
||||
|
||||
{/* Background — only for fixed positions */}
|
||||
{prefs.position !== "tiled" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Arkaplan</Label>
|
||||
<Switch
|
||||
checked={prefs.bgEnabled}
|
||||
onCheckedChange={(v) => updatePrefs({ bgEnabled: v })}
|
||||
className="scale-90"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{prefs.bgEnabled && (
|
||||
<>
|
||||
<div className="flex gap-1.5">
|
||||
{(["white", "dark"] as const).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => updatePrefs({ bgColor: c })}
|
||||
className={cn(
|
||||
"flex-1 h-7 rounded-md border text-xs transition-colors",
|
||||
prefs.bgColor === c
|
||||
? "border-primary bg-primary/10 text-primary font-medium"
|
||||
: "border-border text-muted-foreground hover:border-foreground/30",
|
||||
)}
|
||||
>
|
||||
{c === "white" ? "Beyaz" : "Koyu"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs text-muted-foreground">Arkaplan Opaklığı</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">%{prefs.bgOpacity}</span>
|
||||
</div>
|
||||
<input type="range" min={10} max={60} step={5} value={prefs.bgOpacity}
|
||||
onChange={(e) => updatePrefs({ bgOpacity: Number(e.target.value) })}
|
||||
className="w-full accent-primary" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={processAll}
|
||||
disabled={!files.length || !logoLoaded || processing}
|
||||
>
|
||||
{processing
|
||||
? <><CircleNotch className="size-4 mr-2 animate-spin" />İşleniyor…</>
|
||||
: files.length
|
||||
? `${files.length} Görseli İşle`
|
||||
: "Görsel Yükle"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Upload + preview ────────────────────────────────── */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => { e.preventDefault(); addFiles(e.dataTransfer.files); }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onKeyDown={(e) => e.key === "Enter" && fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer hover:border-primary/50 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<Upload className="size-8 mx-auto text-muted-foreground/40 mb-2" />
|
||||
<p className="text-sm font-medium">Görselleri sürükle veya tıkla</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">JPG, PNG, WebP — çoklu seçim desteklenir</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && addFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File grid */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{files.length} görsel
|
||||
{doneCount > 0 && (
|
||||
<span className="text-muted-foreground font-normal ml-1">({doneCount} işlendi)</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{doneCount > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={downloadZip}>
|
||||
<Download className="size-3.5 mr-1.5" />ZIP İndir
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={clearAll} className="text-muted-foreground">
|
||||
Temizle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 xl:grid-cols-6 gap-2">
|
||||
{files.map((wf) => (
|
||||
<div
|
||||
key={wf.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setPreviewId(wf.id)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setPreviewId(wf.id)}
|
||||
className={cn(
|
||||
"relative aspect-[4/3] rounded-lg overflow-hidden cursor-pointer border-2 transition-all select-none",
|
||||
previewId === wf.id ? "border-primary shadow-md" : "border-transparent hover:border-border",
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={wf.originalUrl}
|
||||
alt={wf.file.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{wf.status === "processing" && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<CircleNotch className="size-5 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{wf.status === "done" && (
|
||||
<CheckCircle weight="fill" className="absolute top-1 right-1 size-4 text-green-400 drop-shadow" />
|
||||
)}
|
||||
{wf.status === "error" && (
|
||||
<XCircle weight="fill" className="absolute top-1 right-1 size-4 text-red-400 drop-shadow" />
|
||||
)}
|
||||
{wf.status === "done" && wf.blob && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); downloadSingle(wf); }}
|
||||
className="absolute bottom-1 right-1 size-6 rounded bg-black/60 hover:bg-black/80 text-white flex items-center justify-center"
|
||||
>
|
||||
<Download className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live preview */}
|
||||
{previewId && (
|
||||
<div className="rounded-xl border overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Önizleme {previewLoading && <CircleNotch className="size-3 inline animate-spin ml-1" />}
|
||||
</span>
|
||||
{(() => {
|
||||
const wf = files.find((f) => f.id === previewId);
|
||||
return wf?.status === "done" && wf.blob ? (
|
||||
<Button size="sm" variant="ghost" className="h-6 text-xs gap-1" onClick={() => downloadSingle(wf)}>
|
||||
<Download className="size-3" /> İndir
|
||||
</Button>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="p-3 flex justify-center bg-muted/10 min-h-32">
|
||||
{previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Önizleme"
|
||||
className="max-w-full h-auto rounded max-h-[55vh] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center text-muted-foreground text-sm">
|
||||
{logoLoaded ? "Ayarları değiştirince önizleme yüklenir…" : "Logo yükleniyor…"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { verifyPayTRCallback } from "@/lib/payments/paytr";
|
||||
import { activatePlanInDb } from "@/lib/appwrite/subscription-actions";
|
||||
import type { TenantPlan, PlanPeriod } from "@/lib/appwrite/schema";
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const rawBody = await req.text();
|
||||
const params = new URLSearchParams(rawBody);
|
||||
|
||||
const merchantOid = params.get("merchant_oid") ?? "";
|
||||
const status = params.get("status") ?? "";
|
||||
const totalAmount = params.get("total_amount") ?? "";
|
||||
const hash = params.get("hash") ?? "";
|
||||
|
||||
if (!verifyPayTRCallback({ merchantOid, status, totalAmount, hash })) {
|
||||
return new Response("FAILED", { status: 400 });
|
||||
}
|
||||
|
||||
if (status === "success") {
|
||||
// merchant_oid: {encodedTenantId}T{timestamp}{random}P{plan}X{period}
|
||||
// Hyphens were encoded as Z — decode back
|
||||
const tenantId = (merchantOid.split("T")[0] ?? "").replace(/Z/g, "-");
|
||||
const planPart = merchantOid.split("P")[1]; // "{plan}X{period}"
|
||||
const plan = (planPart?.split("X")[0] ?? "pro") as TenantPlan;
|
||||
const period = (planPart?.split("X")[1] ?? "monthly") as PlanPeriod;
|
||||
|
||||
if (!tenantId) {
|
||||
return new Response("FAILED", { status: 400 });
|
||||
}
|
||||
try {
|
||||
// totalAmount kuruş cinsinden gelir → TRY'ye çevir
|
||||
const amountTRY = Math.round(Number(totalAmount) / 100);
|
||||
const orderId = merchantOid;
|
||||
await activatePlanInDb(tenantId, plan, "paytr", period, { amount: amountTRY, orderId });
|
||||
} catch (e) {
|
||||
console.error("[paytr-callback]", e);
|
||||
return new Response("FAILED", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PayTR düz metin "OK" bekliyor — BOM veya whitespace olmayacak
|
||||
return new Response("OK", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST() {
|
||||
import { verifyAndParsePolarWebhook } from "@/lib/payments/polar";
|
||||
import { activatePlanInDb, deactivatePlanInDb } from "@/lib/appwrite/subscription-actions";
|
||||
import type { TenantPlan } from "@/lib/appwrite/schema";
|
||||
|
||||
export async function POST(req: Request): Promise<NextResponse> {
|
||||
const rawBody = await req.text();
|
||||
const headers: Record<string, string> = {};
|
||||
req.headers.forEach((v, k) => { headers[k] = v; });
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = verifyAndParsePolarWebhook(headers, rawBody);
|
||||
} catch {
|
||||
return new NextResponse("Webhook imzası geçersiz.", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (event.type === "order.created" || event.type === "subscription.active") {
|
||||
const tenantId = (event.data as { metadata?: Record<string, string> }).metadata?.tenant_id;
|
||||
if (!tenantId) {
|
||||
return new NextResponse("tenant_id metadata eksik.", { status: 400 });
|
||||
}
|
||||
await activatePlanInDb(tenantId, "pro" as TenantPlan, "polar");
|
||||
}
|
||||
|
||||
if (event.type === "subscription.canceled" || event.type === "subscription.revoked") {
|
||||
const tenantId = (event.data as { metadata?: Record<string, string> }).metadata?.tenant_id;
|
||||
if (tenantId) {
|
||||
await deactivatePlanInDb(tenantId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[polar-webhook]", e);
|
||||
return new NextResponse("İşlem hatası.", { status: 500 });
|
||||
}
|
||||
|
||||
return new NextResponse("OK", { status: 200 });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST() {
|
||||
import { verifyShopierWebhookSignature, type ShopierWebhookOrder } from "@/lib/payments/shopier";
|
||||
import { activatePlanInDb } from "@/lib/appwrite/subscription-actions";
|
||||
import type { TenantPlan } from "@/lib/appwrite/schema";
|
||||
|
||||
export async function POST(req: Request): Promise<NextResponse> {
|
||||
const rawBody = await req.text();
|
||||
const signature = req.headers.get("x-shopier-signature") ?? "";
|
||||
|
||||
if (!verifyShopierWebhookSignature(signature, rawBody)) {
|
||||
return new NextResponse("Webhook imzası geçersiz.", { status: 400 });
|
||||
}
|
||||
|
||||
let order: ShopierWebhookOrder;
|
||||
try {
|
||||
order = JSON.parse(rawBody) as ShopierWebhookOrder;
|
||||
} catch {
|
||||
return new NextResponse("JSON parse hatası.", { status: 400 });
|
||||
}
|
||||
|
||||
// Sadece fulfilled + paid siparişlerde planı aktive et
|
||||
if (order.status === "fulfilled" && order.paymentStatus === "paid") {
|
||||
// Shopier'da tenant eşleştirme: buyer email → Appwrite kullanıcısı → tenantId
|
||||
// Shopier'ın metadata alanı yok, bu yüzden email üzerinden bağlıyoruz.
|
||||
// Not: Shopier entegrasyonu tamamlanınca burada email → tenantId çözümlemesi yapılacak.
|
||||
const buyerEmail = order.shippingInfo.email;
|
||||
if (!buyerEmail) {
|
||||
return new NextResponse("Alıcı emaili eksik.", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: email ile Appwrite Users'dan userId bul → tenant_settings'ten tenantId al
|
||||
// Şimdilik log bırakıyoruz, tam implementasyon Shopier'ın buyer_note veya
|
||||
// custom field desteğine göre tamamlanacak.
|
||||
console.log("[shopier-webhook] ödeme alındı:", buyerEmail, order.id);
|
||||
|
||||
// Eğer Shopier custom metadata destekliyorsa:
|
||||
// const tenantId = order.customField?.tenant_id;
|
||||
// await activatePlanInDb(tenantId, "pro", "shopier");
|
||||
void activatePlanInDb; // import kullanılıyor
|
||||
void ("pro" as TenantPlan);
|
||||
} catch (e) {
|
||||
console.error("[shopier-webhook]", e);
|
||||
return new NextResponse("İşlem hatası.", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return new NextResponse("OK", { status: 200 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { ID, Permission, Role } from "node-appwrite";
|
||||
import { InputFile } from "node-appwrite/file";
|
||||
|
||||
import { BUCKETS } from "@/lib/appwrite/schema";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
|
||||
const MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "Oturum geçersiz." }, { status: 401 });
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "Geçersiz istek." }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
return NextResponse.json({ ok: false, error: "Dosya seçin." }, { status: 400 });
|
||||
}
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return NextResponse.json({ ok: false, error: "Sadece görsel dosyaları desteklenir." }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
return NextResponse.json({ ok: false, error: "Dosya 20 MB'dan büyük olamaz." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { storage } = createAdminClient();
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const extMap: Record<string, string> = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/avif": ".avif",
|
||||
};
|
||||
const ext = extMap[file.type] ?? ".jpg";
|
||||
const baseName = file.name.replace(/\.[^/.]+$/, "");
|
||||
const safeName = baseName + ext;
|
||||
const inputFile = InputFile.fromBuffer(buffer, safeName);
|
||||
|
||||
const created = await storage.createFile(
|
||||
BUCKETS.propertyImages,
|
||||
ID.unique(),
|
||||
inputFile,
|
||||
[
|
||||
Permission.read(Role.any()),
|
||||
Permission.update(Role.team(ctx.tenantId)),
|
||||
Permission.delete(Role.team(ctx.tenantId, "owner")),
|
||||
Permission.delete(Role.team(ctx.tenantId, "admin")),
|
||||
],
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, fileId: created.$id });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("Property image upload error:", e);
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: process.env.NODE_ENV === "development" ? msg : "Fotoğraf yüklenemedi." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CheckCircle2, Loader2 } from "lucide-react";
|
||||
import { CheckCircle, CircleNotch } from '@/lib/icons';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { acceptInviteAction } from "@/lib/appwrite/team-actions";
|
||||
@@ -30,12 +30,12 @@ export function AcceptInviteButton({ code }: { code: string }) {
|
||||
<Button onClick={handleAccept} disabled={isPending} className="w-full">
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Katılınıyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="size-4" />
|
||||
<CheckCircle className="size-4" />
|
||||
Daveti kabul et
|
||||
</>
|
||||
)}
|
||||
|
||||
+14
-4
@@ -115,6 +115,9 @@
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -129,10 +132,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Fix for sidebar "none" mode height issue */
|
||||
.sidebar-none-mode [data-slot="sidebar"] {
|
||||
height: 100vh !important;
|
||||
min-height: 100vh !important;
|
||||
/* sidebar-none-mode: sticky layout — sidebar stays visible while page scrolls */
|
||||
.sidebar-none-mode {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Fix for right-side inset variant support */
|
||||
@@ -170,3 +172,11 @@ html {
|
||||
.animate-logo-scroll:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Logo } from "@/components/logo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kullanım Şartları — Emlak CRM",
|
||||
description:
|
||||
"Kovak Yazılım ve Medya LTD. ŞTİ. tarafından sunulan Emlak CRM hizmetine ait kullanım şartları ve koşulları.",
|
||||
};
|
||||
|
||||
export default function KullanimSartlariPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b">
|
||||
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||
<Logo size={18} />
|
||||
</div>
|
||||
<span className="font-semibold">Emlak CRM</span>
|
||||
</Link>
|
||||
<Link href="/kvkk" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
KVKK Gizlilik Politikası →
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="max-w-4xl mx-auto px-6 py-12">
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<h1 className="text-3xl font-bold mb-2">Kullanım Şartları</h1>
|
||||
<p className="text-muted-foreground text-sm mb-8">Son güncelleme: 11 Mayıs 2026</p>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">1. Taraflar ve Kapsam</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Bu Kullanım Şartları (“Şartlar”), <strong>Kovak Yazılım ve Medya LTD. ŞTİ.</strong>
|
||||
(“Şirket”, “Biz”) ile <strong>emlak.kovakcrm.com</strong> üzerinden
|
||||
sunulan <strong>Emlak CRM</strong> hizmetini (“Hizmet”) kullanan gerçek veya
|
||||
tüzel kişi (“Kullanıcı”, “Siz”) arasındaki hukuki ilişkiyi
|
||||
düzenlemektedir.
|
||||
</p>
|
||||
<p className="text-muted-foreground leading-relaxed mt-3">
|
||||
Hizmetimize kaydolarak veya herhangi bir şekilde kullanarak bu Şartlar'ı okuduğunuzu,
|
||||
anladığınızı ve kabul ettiğinizi beyan etmiş olursunuz. Şartları kabul etmiyorsanız
|
||||
Hizmet'i kullanmaktan vazgeçiniz.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">2. Hizmetin Tanımı</h2>
|
||||
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||
Emlak CRM, emlak ofislerine ve gayrimenkul danışmanlarına yönelik bulut tabanlı
|
||||
(“SaaS”) bir yazılım hizmetidir. Hizmet kapsamında sunulan başlıca özellikler:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||
<li>Gayrimenkul portföy yönetimi (ilan ekleme, düzenleme, durum takibi)</li>
|
||||
<li>Müşteri ilişkileri yönetimi (alıcı/kiracı/yatırımcı takibi)</li>
|
||||
<li>Otomatik ilan-müşteri eşleştirme motoru</li>
|
||||
<li>Paylaşılabilir sunum linkleri</li>
|
||||
<li>Yatırımcı portalı</li>
|
||||
<li>Finans ve komisyon takibi</li>
|
||||
<li>Çok kullanıcılı ve çok ofisli (multi-tenant) yapı</li>
|
||||
<li>Aktivite ve görev yönetimi</li>
|
||||
</ul>
|
||||
<p className="text-muted-foreground leading-relaxed mt-3">
|
||||
Şirket, Hizmet kapsamını önceden bildirmek koşuluyla genişletme, değiştirme veya
|
||||
belirli özellikleri kaldırma hakkını saklı tutar.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">3. Hesap Oluşturma ve Güvenlik</h2>
|
||||
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||
Hizmet'ten yararlanmak için geçerli bir e-posta adresi ile hesap oluşturmanız
|
||||
gerekmektedir. Hesabınıza ilişkin şunları kabul edersiniz:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||
<li>Kayıt sırasında sağlanan bilgilerin doğru, güncel ve eksiksiz olması</li>
|
||||
<li>Şifrenizin gizli tutulması ve yetkisiz erişimlere karşı korunması</li>
|
||||
<li>Hesabınız aracılığıyla gerçekleşen tüm işlemlerin sorumluluğunun size ait olması</li>
|
||||
<li>Hesabınızın üçüncü kişilere devredilmemesi veya paylaşılmaması</li>
|
||||
<li>Şüpheli bir erişim fark ettiğinizde derhal destek@kovakcrm.com adresine bildirim yapılması</li>
|
||||
</ul>
|
||||
<p className="text-muted-foreground leading-relaxed mt-3">
|
||||
18 yaşından küçük kişiler Hizmet'i kullanamaz. Tüzel kişilik adına hesap açan gerçek
|
||||
kişi, söz konusu tüzel kişiyi temsil etmeye yetkili olduğunu beyan eder.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">4. Abonelik ve Ödeme Koşulları</h2>
|
||||
|
||||
<h3 className="font-medium mb-2">4.1 Abonelik Planları</h3>
|
||||
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||
Emlak CRM ücretsiz deneme ve ücretli abonelik planları sunmaktadır. Güncel plan detayları
|
||||
ve fiyatlandırma emlak.kovakcrm.com üzerinde yayımlanmaktadır.
|
||||
</p>
|
||||
|
||||
<h3 className="font-medium mb-2">4.2 Ödeme</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||
<li>Abonelik ücretleri belirtilen dönem başında (aylık veya yıllık) peşin tahsil edilir.</li>
|
||||
<li>Tüm fiyatlar KDV hariç olup Türk Lirası (TRY) cinsinden belirtilir.</li>
|
||||
<li>Ödeme başarısızlığında Şirket, hizmet erişimini geçici olarak askıya alabilir.</li>
|
||||
<li>Fatura bilgileriniz 213 sayılı Vergi Usul Kanunu kapsamında 10 yıl saklanır.</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="font-medium mt-4 mb-2">4.3 İptal ve İade</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||
<li>Aboneliğinizi dilediğiniz zaman iptal edebilirsiniz; iptal, mevcut dönemin sonunda geçerli olur.</li>
|
||||
<li>Kıst iade yapılmaz; dönem başında ödenmiş ücret iade edilmez.</li>
|
||||
<li>
|
||||
6502 sayılı Tüketicinin Korunması Hakkında Kanun kapsamındaki cayma hakkı, ilk
|
||||
abonelik başlangıcından itibaren 14 gün içinde kullanılabilir. Bu süre içinde
|
||||
Hizmet henüz kullanılmadıysa tam iade yapılır.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">5. Kullanım Kuralları ve Yasaklar</h2>
|
||||
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||
Hizmet'i aşağıdaki amaçlarla kullanmak kesinlikle yasaktır:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||
<li>Türk hukuku veya uluslararası mevzuata aykırı herhangi bir faaliyette bulunmak</li>
|
||||
<li>Sahte veya yanıltıcı gayrimenkul ilanları yayımlamak</li>
|
||||
<li>Başkasının kişisel verilerini rızası olmaksızın işlemek</li>
|
||||
<li>Sistemin güvenliğini tehdit eden yazılım veya kod çalıştırmak</li>
|
||||
<li>API veya otomatik araçlarla aşırı yük oluşturmak (DDoS benzeri eylemler)</li>
|
||||
<li>Rakip bir ürün veya hizmet geliştirmek amacıyla Hizmet'i tersine mühendislik yapmak</li>
|
||||
<li>Hizmet altyapısına yetkisiz erişim sağlamaya çalışmak</li>
|
||||
<li>Fikri mülkiyet haklarını ihlal eden içerik yüklemek</li>
|
||||
<li>Kullanıcıları taciz etmek veya kötüye kullanım niteliğinde eylemlerde bulunmak</li>
|
||||
</ul>
|
||||
<p className="text-muted-foreground leading-relaxed mt-3">
|
||||
Bu kurallara aykırı kullanım tespit edildiğinde Şirket, önceden bildirim yapmaksızın
|
||||
hesabı askıya alma veya kalıcı olarak kapatma hakkına sahiptir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">6. Veriler, Gizlilik ve Veri Sahipliği</h2>
|
||||
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||
Hizmet'e yüklediğiniz tüm veriler (müşteri bilgileri, ilan içerikleri, belgeler vb.)
|
||||
size ait olmaya devam eder. Şirket bu verileri yalnızca Hizmet'in sunulması amacıyla
|
||||
işler; reklam veya üçüncü taraflara satış amacıyla kullanmaz.
|
||||
</p>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Kişisel verilerin işlenmesine ilişkin ayrıntılı bilgi için{" "}
|
||||
<Link href="/kvkk" className="text-primary hover:underline">
|
||||
KVKK Aydınlatma Metni
|
||||
</Link>
|
||||
'ni incelemenizi rica ederiz.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">7. Fikri Mülkiyet Hakları</h2>
|
||||
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||
Emlak CRM markası, logosu, yazılım kodları, tasarım unsurları, kullanıcı arayüzü
|
||||
ve dokümantasyonu dahil Hizmet'e ait tüm fikri mülkiyet hakları Kovak Yazılım ve
|
||||
Medya LTD. ŞTİ.'ye aittir ve 5846 sayılı Fikir ve Sanat Eserleri Kanunu ile
|
||||
ilgili uluslararası anlaşmalar kapsamında koruma altındadır.
|
||||
</p>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Bu Şartlar, Kullanıcı'ya yalnızca Hizmet'i kendi iş faaliyetleri kapsamında
|
||||
kullanmaya yönelik sınırlı, devredilemez ve münhasır olmayan bir lisans vermektedir.
|
||||
Yazılımın kopyalanması, dağıtılması, alt lisans verilmesi veya türev çalışma
|
||||
oluşturulması yasaktır.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">8. Hizmet Sürekliliği ve Garanti Reddi</h2>
|
||||
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||
Şirket, makul teknik imkânlar dahilinde Hizmet'in kesintisiz ve hatasız çalışmasını
|
||||
hedefler. Bununla birlikte:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||
<li>Planlı bakım çalışmaları önceden duyurularak gerçekleştirilir.</li>
|
||||
<li>
|
||||
Üçüncü taraf altyapı sağlayıcılarından kaynaklanan kesintiler dahil teknik arızalar
|
||||
için Şirket, tazminat sorumluluğunu kabul etmez.
|
||||
</li>
|
||||
<li>
|
||||
Hizmet, Türk Borçlar Kanunu'nun 219-231. maddeleri kapsamında “olduğu gibi”
|
||||
sunulmaktadır; belirli bir amaca uygunluk garantisi verilmemektedir.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">9. Sorumluluk Sınırlaması</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Yürürlükteki hukukun izin verdiği azami ölçüde, Şirket; dolaylı, arızi, özel,
|
||||
sonuçsal veya cezai zararlardan (kâr kaybı, veri kaybı, itibar kaybı dahil) sorumlu
|
||||
değildir. Şirket'in doğrudan sorumluluğu, zararın doğduğu tarihi takip eden son
|
||||
12 ay içinde Kullanıcı'nın ödediği toplam abonelik ücretiyle sınırlıdır.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">10. Hesap Silme ve Hizmet Sonlandırma</h2>
|
||||
|
||||
<h3 className="font-medium mb-2">10.1 Kullanıcı Tarafından</h3>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Hesabınızı dilediğiniz zaman Ayarlar > Hesap bölümünden kapatabilir ya da
|
||||
destek@kovakcrm.com adresine e-posta göndererek silme talebinde bulunabilirsiniz.
|
||||
Silme işlemi 30 gün içinde tamamlanır; bu süre, faturalama ve yasal saklama
|
||||
yükümlülükleri saklı kalmak kaydıyla verilerinizin de silinmesini kapsar.
|
||||
</p>
|
||||
|
||||
<h3 className="font-medium mt-4 mb-2">10.2 Şirket Tarafından</h3>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Şirket; bu Şartlar'ın ihlali, ödeme yapılmaması veya yasal zorunluluk hallerinde
|
||||
önceden bildirim yaparak ya da ağır ihlallerde derhal hesabı kapatma hakkını saklı tutar.
|
||||
Haksız fesih durumunda kalan dönem ücreti iade edilir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">11. Değişiklikler</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Şirket, bu Şartlar'ı gerektiğinde güncelleme hakkını saklı tutar. Önemli değişiklikler
|
||||
yürürlük tarihinden en az 15 gün önce kayıtlı e-posta adresinize bildirilir ve
|
||||
güncel metin her zaman <strong>emlak.kovakcrm.com/kullanim-sartlari</strong> adresinde
|
||||
yayımlanır. Bildirimin ardından Hizmet'i kullanmaya devam etmeniz, değişiklikleri
|
||||
kabul ettiğiniz anlamına gelir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">12. Uygulanacak Hukuk ve Yetki</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Bu Şartlar Türk hukukuna tabi olup Türk Borçlar Kanunu, Türk Ticaret Kanunu ve
|
||||
6698 sayılı KVKK başta olmak üzere ilgili tüm Türk mevzuatı uygulanır.
|
||||
Bu Şartlar'dan doğan her türlü uyuşmazlıkta <strong>İstanbul (Çağlayan) Mahkemeleri
|
||||
ve İcra Daireleri</strong> yetkilidir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">13. İletişim</h2>
|
||||
<div className="p-4 rounded-lg bg-muted text-sm space-y-1">
|
||||
<p><strong>Kovak Yazılım ve Medya LTD. ŞTİ.</strong></p>
|
||||
<p>Teknik Destek: <a href="mailto:destek@kovakcrm.com" className="text-primary hover:underline">destek@kovakcrm.com</a></p>
|
||||
<p>KVKK Başvuruları: <a href="mailto:kvkk@kovakcrm.com" className="text-primary hover:underline">kvkk@kovakcrm.com</a></p>
|
||||
<p>Hukuki Bildirimler: <a href="mailto:hukuk@kovakcrm.com" className="text-primary hover:underline">hukuk@kovakcrm.com</a></p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t mt-12">
|
||||
<div className="max-w-4xl mx-auto px-6 py-6 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<p>© {new Date().getFullYear()} Kovak Yazılım ve Medya LTD. ŞTİ. — Emlak CRM</p>
|
||||
<div className="flex gap-4">
|
||||
<Link href="/kvkk" className="hover:text-foreground transition-colors">KVKK</Link>
|
||||
<Link href="/kullanim-sartlari" className="hover:text-foreground transition-colors">Kullanım Şartları</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Logo } from "@/components/logo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "KVKK Gizlilik Politikası — Emlak CRM",
|
||||
description:
|
||||
"Kovak Yazılım ve Medya LTD. ŞTİ. tarafından işletilen Emlak CRM hizmetine ilişkin Kişisel Verilerin Korunması Kanunu (KVKK) kapsamında aydınlatma metni.",
|
||||
};
|
||||
|
||||
export default function KvkkPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b">
|
||||
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||
<Logo size={18} />
|
||||
</div>
|
||||
<span className="font-semibold">Emlak CRM</span>
|
||||
</Link>
|
||||
<Link href="/kullanim-sartlari" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
Kullanım Şartları →
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="max-w-4xl mx-auto px-6 py-12">
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<h1 className="text-3xl font-bold mb-2">Kişisel Verilerin Korunması Kanunu (KVKK) Aydınlatma Metni</h1>
|
||||
<p className="text-muted-foreground text-sm mb-8">Son güncelleme: 11 Mayıs 2026</p>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">1. Veri Sorumlusu</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
6698 sayılı Kişisel Verilerin Korunması Kanunu (“KVKK”) uyarınca kişisel verileriniz;
|
||||
veri sorumlusu sıfatıyla <strong>Kovak Yazılım ve Medya LTD. ŞTİ.</strong> (“Şirket”,
|
||||
“Biz”) tarafından aşağıda açıklanan amaç ve kapsamda işlenmektedir.
|
||||
</p>
|
||||
<div className="mt-3 p-4 rounded-lg bg-muted text-sm space-y-1">
|
||||
<p><strong>Ünvan:</strong> Kovak Yazılım ve Medya LTD. ŞTİ.</p>
|
||||
<p><strong>Hizmet:</strong> Emlak CRM (emlak.kovakcrm.com)</p>
|
||||
<p><strong>E-posta:</strong> kvkk@kovakcrm.com</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">2. İşlenen Kişisel Veriler</h2>
|
||||
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||
Emlak CRM hizmetinin kullanımı kapsamında aşağıdaki kişisel veriler işlenebilmektedir:
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">a) Hesap ve Kimlik Bilgileri</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1 ml-4">
|
||||
<li>Ad, soyad</li>
|
||||
<li>E-posta adresi</li>
|
||||
<li>Şifreli hesap bilgileri (şifreniz yalnızca tek yönlü şifrelenerek saklanır)</li>
|
||||
<li>Profil fotoğrafı (isteğe bağlı)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">b) İşletme Bilgileri</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1 ml-4">
|
||||
<li>Emlak ofisi/şirket adı ve iletişim bilgileri</li>
|
||||
<li>Ofis adresi, telefon numarası, web sitesi</li>
|
||||
<li>Logo ve marka görselleri</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">c) Müşteri ve Portföy Verileri</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1 ml-4">
|
||||
<li>Sisteme eklenen müşteri adı, telefon, e-posta ve notları</li>
|
||||
<li>Gayrimenkul ilan bilgileri (konum, fiyat, özellikler, görseller)</li>
|
||||
<li>Satış ve kiralama işlem kayıtları</li>
|
||||
<li>Müşteri arama kriterleri ve eşleşme kayıtları</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">d) Kullanım ve Teknik Veriler</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1 ml-4">
|
||||
<li>Oturum açma tarihleri ve IP adresi</li>
|
||||
<li>Kullanılan tarayıcı ve işletim sistemi bilgisi</li>
|
||||
<li>Uygulama içi eylem logları (denetim izleri)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">e) Ödeme ve Fatura Bilgileri</h3>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1 ml-4">
|
||||
<li>Fatura adı/unvanı, adresi ve vergi numarası</li>
|
||||
<li>Ödeme onay referans numarası</li>
|
||||
<li>Kart bilgileri tarafımızca saklanmaz; ödeme altyapı sağlayıcımız tarafından PCI-DSS uyumlu biçimde işlenir</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">3. Kişisel Verilerin İşlenme Amaçları</h2>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||
<li>Emlak CRM hesabının oluşturulması, doğrulanması ve yönetilmesi</li>
|
||||
<li>Çok kiracılı (multi-tenant) yapıda veri izolasyonunun sağlanması</li>
|
||||
<li>Portföy, müşteri, aktivite ve finans modüllerinin çalıştırılması</li>
|
||||
<li>Otomatik eşleştirme motorunun ilan-müşteri kriterleri üzerinden çalıştırılması</li>
|
||||
<li>Paylaşılabilir sunum linkleri ve yatırımcı portalı hizmetinin sunulması</li>
|
||||
<li>Abonelik ve ödeme işlemlerinin yürütülmesi</li>
|
||||
<li>Teknik destek ve kullanıcı bildirimleri</li>
|
||||
<li>Bilgi güvenliği, dolandırıcılık önleme ve yasal yükümlülüklerin yerine getirilmesi</li>
|
||||
<li>Hizmet kalitesinin ölçülmesi ve iyileştirilmesi</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">4. Hukuki Dayanak</h2>
|
||||
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||
Kişisel verileriniz KVKK'nın 5. ve 6. maddeleri uyarınca aşağıdaki hukuki dayanaklara istinaden işlenmektedir:
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 pr-4 font-medium">İşleme Amacı</th>
|
||||
<th className="text-left py-2 font-medium">Hukuki Dayanak</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4">Hesap oluşturma ve hizmet sunumu</td>
|
||||
<td className="py-2">Sözleşmenin kurulması veya ifası (KVKK m.5/2-c)</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4">Ödeme ve fatura işlemleri</td>
|
||||
<td className="py-2">Sözleşmenin ifası; hukuki yükümlülük (KVKK m.5/2-ç)</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4">Güvenlik ve denetim logları</td>
|
||||
<td className="py-2">Meşru menfaat (KVKK m.5/2-f)</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4">Pazarlama ve ürün bildirimleri</td>
|
||||
<td className="py-2">Açık rıza (KVKK m.5/1)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 pr-4">Yasal bildirim yükümlülükleri</td>
|
||||
<td className="py-2">Kanunda öngörülme (KVKK m.5/2-a)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">5. Kişisel Verilerin Aktarımı</h2>
|
||||
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||
Kişisel verileriniz, hizmetin sunulabilmesi için aşağıdaki üçüncü taraflarla paylaşılabilir:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||
<li>
|
||||
<strong>Appwrite (Altyapı Sağlayıcı):</strong> Kimlik doğrulama, veritabanı ve depolama
|
||||
hizmetleri için kullanılmaktadır. Veriler Appwrite'ın sağladığı sunucularda şifreli
|
||||
biçimde saklanmaktadır.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ödeme Altyapı Sağlayıcıları:</strong> Abonelik ödemelerinin işlenmesi amacıyla
|
||||
yalnızca gerekli bilgiler aktarılır; kart bilgileri Şirket tarafından saklanmaz.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Yetkili Kamu Kurumları:</strong> Yasal zorunluluk veya mahkeme kararı durumunda
|
||||
ilgili mevzuat çerçevesinde paylaşım yapılabilir.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-muted-foreground leading-relaxed mt-3">
|
||||
Kişisel verileriniz, yukarıda sayılanlar dışında üçüncü kişilerle reklam amaçlı paylaşılmaz
|
||||
ve satılmaz.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">6. Kişisel Verilerin Saklanma Süresi</h2>
|
||||
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||
Kişisel verileriniz, işleme amacının gerektirdiği süre boyunca ve yasal saklama
|
||||
yükümlülüklerine uygun olarak tutulur:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||
<li>Hesap ve profil bilgileri: Hesabın aktif olduğu süre + silinme talebinden itibaren 30 gün</li>
|
||||
<li>Fatura ve ödeme kayıtları: Vergi mevzuatı gereği 10 yıl</li>
|
||||
<li>Güvenlik logları: 2 yıl</li>
|
||||
<li>Pazarlama izni: Geri alınana kadar</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">7. Çerezler (Cookie) Politikası</h2>
|
||||
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||
Emlak CRM yalnızca aşağıdaki amaçlarla zorunlu çerezler kullanmaktadır:
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 pr-4 font-medium">Çerez Adı</th>
|
||||
<th className="text-left py-2 pr-4 font-medium">Amaç</th>
|
||||
<th className="text-left py-2 font-medium">Süre</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4 font-mono text-xs">isletmem-session</td>
|
||||
<td className="py-2 pr-4">Kullanıcı oturum doğrulaması</td>
|
||||
<td className="py-2">Oturum süresi</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4 font-mono text-xs">isletmem-tenant</td>
|
||||
<td className="py-2 pr-4">Aktif çalışma alanı seçimi</td>
|
||||
<td className="py-2">1 yıl</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 pr-4 font-mono text-xs">isletmem-ui-theme</td>
|
||||
<td className="py-2 pr-4">Tema tercihi (açık/koyu)</td>
|
||||
<td className="py-2">Kalıcı (localStorage)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed mt-3">
|
||||
Hizmetimiz üçüncü taraf reklam veya izleme çerezleri kullanmamaktadır.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">8. Veri Sahibinin Hakları (KVKK Madde 11)</h2>
|
||||
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||
KVKK'nın 11. maddesi uyarınca veri sahibi olarak aşağıdaki haklara sahipsiniz:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||
<li>Kişisel verilerinizin işlenip işlenmediğini öğrenme</li>
|
||||
<li>İşlenmişse buna ilişkin bilgi talep etme</li>
|
||||
<li>İşlenme amacını ve amacına uygun kullanılıp kullanılmadığını öğrenme</li>
|
||||
<li>Yurt içinde veya yurt dışında aktarıldığı üçüncü kişileri öğrenme</li>
|
||||
<li>Eksik veya yanlış işlenmişse düzeltilmesini isteme</li>
|
||||
<li>İşleme amacının ortadan kalkması halinde silinmesini veya yok edilmesini isteme</li>
|
||||
<li>Düzeltme, silme ve yok etme işlemlerinin aktarılan üçüncü kişilere bildirilmesini isteme</li>
|
||||
<li>Münhasıran otomatik sistemler vasıtasıyla oluşan sonuca itiraz etme</li>
|
||||
<li>Kanuna aykırı işleme nedeniyle zararın giderilmesini talep etme</li>
|
||||
</ul>
|
||||
<div className="mt-4 p-4 rounded-lg bg-muted text-sm">
|
||||
<p className="font-medium mb-1">Başvuru Yöntemi</p>
|
||||
<p className="text-muted-foreground">
|
||||
Haklarınızı kullanmak için <strong>kvkk@kovakcrm.com</strong> adresine kimliğinizi
|
||||
doğrular nitelikte bilgiler içeren yazılı başvurunuzu iletebilirsiniz. Başvurular
|
||||
KVKK'nın 13. maddesi ve ilgili tebliğ gereğince en geç 30 gün içinde yanıtlanır.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">9. Veri Güvenliği</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Kovak Yazılım ve Medya LTD. ŞTİ., kişisel verilerinizin güvenliğini sağlamak amacıyla
|
||||
teknik ve idari tedbirler almaktadır: TLS/HTTPS şifrelemesi, güvenli parola saklama
|
||||
(bcrypt hash), erişim denetimi ve denetim logları bunların başında gelmektedir.
|
||||
Güvenlik ihlali durumunda KVKK'nın 12. maddesi uyarınca Kurul'a bildirim yapılır
|
||||
ve etkilenen kullanıcılar ivedilikle bilgilendirilir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">10. Politika Değişiklikleri</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Bu metin, yasal düzenlemeler veya hizmet değişiklikleri doğrultusunda güncellenebilir.
|
||||
Önemli değişiklikler e-posta yoluyla bildirilir; güncel metin her zaman
|
||||
<strong> emlak.kovakcrm.com/kvkk</strong> adresinde yayımlanır.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">11. İletişim</h2>
|
||||
<div className="p-4 rounded-lg bg-muted text-sm space-y-1">
|
||||
<p><strong>Kovak Yazılım ve Medya LTD. ŞTİ.</strong></p>
|
||||
<p>KVKK Başvuruları: <a href="mailto:kvkk@kovakcrm.com" className="text-primary hover:underline">kvkk@kovakcrm.com</a></p>
|
||||
<p>Genel İletişim: <a href="mailto:destek@kovakcrm.com" className="text-primary hover:underline">destek@kovakcrm.com</a></p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t mt-12">
|
||||
<div className="max-w-4xl mx-auto px-6 py-6 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<p>© {new Date().getFullYear()} Kovak Yazılım ve Medya LTD. ŞTİ. — Emlak CRM</p>
|
||||
<div className="flex gap-4">
|
||||
<Link href="/kvkk" className="hover:text-foreground transition-colors">KVKK</Link>
|
||||
<Link href="/kullanim-sartlari" className="hover:text-foreground transition-colors">Kullanım Şartları</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ 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'
|
||||
import { GithubLogo, Code, Palette, Layout, Crown } from '@/lib/icons'
|
||||
|
||||
const values = [
|
||||
{
|
||||
@@ -72,7 +72,7 @@ export function AboutSection() {
|
||||
<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" />
|
||||
<GithubLogo className="mr-2 h-4 w-4" />
|
||||
Star on GitHub
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import Image from 'next/image'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { ArrowRight } from '@/lib/icons'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Mail, MessageCircle, Github, BookOpen } from 'lucide-react'
|
||||
import { Envelope, ChatCircle, GithubLogo, BookOpen } from '@/lib/icons'
|
||||
|
||||
const contactFormSchema = z.object({
|
||||
firstName: z.string().min(2, {
|
||||
@@ -74,7 +74,7 @@ export function ContactSection() {
|
||||
<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" />
|
||||
<ChatCircle className="h-5 w-5 text-primary" />
|
||||
Discord Community
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -93,7 +93,7 @@ export function ContactSection() {
|
||||
<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" />
|
||||
<GithubLogo className="h-5 w-5 text-primary" />
|
||||
GitHub Issues
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -134,7 +134,7 @@ export function ContactSection() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
<Envelope className="h-5 w-5" />
|
||||
Send us a message
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { ArrowRight, TrendingUp, Package, Github } from 'lucide-react'
|
||||
import { ArrowRight, TrendUp, Package, GithubLogo } from '@/lib/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
@@ -15,14 +15,14 @@ export function CTASection() {
|
||||
{/* 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' />
|
||||
<TrendUp 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
|
||||
150+ SquaresFour
|
||||
</span>
|
||||
<Separator orientation='vertical' className='!h-4' />
|
||||
<span>25K+ Downloads</span>
|
||||
@@ -62,7 +62,7 @@ export function CTASection() {
|
||||
</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' />
|
||||
<GithubLogo className='me-2 size-5' />
|
||||
View on GitHub
|
||||
<ArrowRight className='ms-2 size-4 transition-transform group-hover:translate-x-1' />
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { CircleHelp } from 'lucide-react'
|
||||
import { Question } from '@/lib/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -75,7 +75,7 @@ const FaqSection = () => {
|
||||
<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' />
|
||||
<Question className='size-5' />
|
||||
</div>
|
||||
<span className='text-start font-semibold'>{item.question}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
BarChart3,
|
||||
Zap,
|
||||
ChartBar,
|
||||
Lightning,
|
||||
Users,
|
||||
ArrowRight,
|
||||
Database,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Crown,
|
||||
Layout,
|
||||
Palette
|
||||
} from 'lucide-react'
|
||||
} from '@/lib/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Image3D } from '@/components/image-3d'
|
||||
@@ -32,7 +32,7 @@ const mainFeatures = [
|
||||
description: 'Copy-paste components that just work out of the box.'
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
icon: Lightning,
|
||||
title: 'Regular Updates',
|
||||
description: 'New blocks and templates added weekly to keep you current.'
|
||||
}
|
||||
@@ -40,7 +40,7 @@ const mainFeatures = [
|
||||
|
||||
const secondaryFeatures = [
|
||||
{
|
||||
icon: BarChart3,
|
||||
icon: ChartBar,
|
||||
title: 'Multiple Frameworks',
|
||||
description: 'React, Next.js, and Vite compatibility for flexible development.'
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Logo } from '@/components/logo'
|
||||
import { Github, Twitter, Linkedin, Youtube, Heart } from 'lucide-react'
|
||||
import { GithubLogo, TwitterLogo, LinkedinLogo, YoutubeLogo, Heart } from '@/lib/icons'
|
||||
|
||||
const newsletterSchema = z.object({
|
||||
email: z.string().email({
|
||||
@@ -50,10 +50,10 @@ const footerLinks = {
|
||||
}
|
||||
|
||||
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 },
|
||||
{ name: 'TwitterLogo', href: '#', icon: TwitterLogo },
|
||||
{ name: 'GitHub', href: 'https://github.com/silicondeck/shadcn-dashboard-landing-template', icon: GithubLogo },
|
||||
{ name: 'LinkedIn', href: '#', icon: LinkedinLogo },
|
||||
{ name: 'YouTube', href: '#', icon: YoutubeLogo },
|
||||
]
|
||||
|
||||
export function LandingFooter() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { ArrowRight, Play, Star } from 'lucide-react'
|
||||
import { ArrowRight, Play, Star } from '@/lib/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DotPattern } from '@/components/dot-pattern'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import React from 'react'
|
||||
import { Palette, RotateCcw, Settings, X, Dices, Upload, ExternalLink, Sun, Moon } from 'lucide-react'
|
||||
import { Palette, ArrowCounterClockwise, GearSix, X, Shuffle, Upload, ArrowSquareOut, Sun, Moon } from '@/lib/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -136,12 +136,12 @@ export function LandingThemeCustomizer({ open, onOpenChange }: LandingThemeCusto
|
||||
<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" />
|
||||
<GearSix 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" />
|
||||
<ArrowCounterClockwise 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" />
|
||||
@@ -186,7 +186,7 @@ export function LandingThemeCustomizer({ open, onOpenChange }: LandingThemeCusto
|
||||
<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" />
|
||||
<Shuffle className="h-3.5 w-3.5 mr-1.5" />
|
||||
Random
|
||||
</Button>
|
||||
</div>
|
||||
@@ -240,7 +240,7 @@ export function LandingThemeCustomizer({ open, onOpenChange }: LandingThemeCusto
|
||||
<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" />
|
||||
<Shuffle className="h-3.5 w-3.5 mr-1.5" />
|
||||
Random
|
||||
</Button>
|
||||
</div>
|
||||
@@ -373,7 +373,7 @@ export function LandingThemeCustomizer({ open, onOpenChange }: LandingThemeCusto
|
||||
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" />
|
||||
<ArrowSquareOut className="h-3.5 w-3.5 mr-1.5" />
|
||||
Open Tweakcn
|
||||
</Button>
|
||||
</div>
|
||||
@@ -400,7 +400,7 @@ export function LandingThemeCustomizerTrigger({ onClick }: { onClick: () => void
|
||||
"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" />
|
||||
<GearSix className="h-5 w-5" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ const techCompanies = [
|
||||
{ name: 'Dropbox', id: 'dropbox' },
|
||||
{ name: 'Stripe', id: 'stripe' },
|
||||
{ name: 'Google', id: 'google' },
|
||||
{ name: 'Apple', id: 'apple' },
|
||||
{ name: 'AppleLogo', id: 'apple' },
|
||||
{ name: 'Meta', id: 'meta' },
|
||||
{ name: 'Tesla', id: 'tesla' },
|
||||
{ name: 'Salesforce', id: 'salesforce' },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Menu, Github, LayoutDashboard, ChevronDown, X, Moon, Sun } from 'lucide-react'
|
||||
import { List, GithubLogo, SquaresFour, CaretDown, X, Moon, Sun } from '@/lib/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
NavigationMenu,
|
||||
@@ -30,7 +30,7 @@ import { ModeToggle } from '@/components/mode-toggle'
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
|
||||
const navigationItems = [
|
||||
{ name: 'Home', href: '#hero' },
|
||||
{ name: 'House', href: '#hero' },
|
||||
{ name: 'Features', href: '#features' },
|
||||
{ name: 'Solutions', href: '#features', hasMegaMenu: true },
|
||||
{ name: 'Team', href: '#team' },
|
||||
@@ -42,7 +42,7 @@ const navigationItems = [
|
||||
// Solutions menu items for mobile
|
||||
const solutionsItems = [
|
||||
{ title: 'Browse Products' },
|
||||
{ name: 'Free Blocks', href: '#free-blocks' },
|
||||
{ name: 'Free SquaresFour', href: '#free-blocks' },
|
||||
{ name: 'Premium Templates', href: '#premium-templates' },
|
||||
{ name: 'Admin Dashboards', href: '#admin-dashboards' },
|
||||
{ name: 'Landing Pages', href: '#landing-pages' },
|
||||
@@ -128,12 +128,12 @@ export function LandingNavbar() {
|
||||
<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" />
|
||||
<GithubLogo 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" />
|
||||
<SquaresFour className="h-4 w-4 mr-2" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -145,11 +145,11 @@ export function LandingNavbar() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{/* Mobile List */}
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<SheetTrigger asChild className="xl:hidden">
|
||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||
<Menu className="h-5 w-5" />
|
||||
<List className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
@@ -174,7 +174,7 @@ export function LandingNavbar() {
|
||||
</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" />
|
||||
<GithubLogo className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsOpen(false)} className="cursor-pointer h-8 w-8">
|
||||
@@ -193,7 +193,7 @@ export function LandingNavbar() {
|
||||
<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' : ''}`} />
|
||||
<CaretDown className={`h-4 w-4 transition-transform ${solutionsOpen ? 'rotate-180' : ''}`} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pl-4 space-y-1">
|
||||
{solutionsItems.map((solution, index) => (
|
||||
@@ -250,7 +250,7 @@ export function LandingNavbar() {
|
||||
<div className="space-y-3">
|
||||
<Button variant="outline" size="lg" asChild className="w-full cursor-pointer">
|
||||
<Link href="/dashboard">
|
||||
<LayoutDashboard className="size-4" />
|
||||
<SquaresFour className="size-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { Check } from 'lucide-react'
|
||||
import { Check } from '@/lib/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
@@ -101,7 +101,7 @@ export function PricingSection() {
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="text-primary font-semibold">Save 20%</span> On Annual Billing
|
||||
<span className="text-primary font-semibold">FloppyDisk 20%</span> On Annual Billing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Download,
|
||||
Users,
|
||||
Star
|
||||
} from 'lucide-react'
|
||||
} from '@/lib/icons'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DotPattern } from '@/components/dot-pattern'
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ 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, Linkedin, Globe } from 'lucide-react'
|
||||
import { GithubLogo, LinkedinLogo, Globe } from '@/lib/icons'
|
||||
|
||||
|
||||
const team = [
|
||||
@@ -91,7 +91,7 @@ const team = [
|
||||
id: 7,
|
||||
name: 'James Anderson',
|
||||
role: 'UX Researcher',
|
||||
description: 'Lead user research for Slack. Contractor for Netflix and Udacity.',
|
||||
description: 'Lead user research for SlackLogo. Contractor for Netflix and Udacity.',
|
||||
image: 'https://images.unsplash.com/photo-1566492031773-4f4e44671d66?q=60&w=150&auto=format&fit=crop',
|
||||
fallback: 'JA',
|
||||
social: {
|
||||
@@ -181,7 +181,7 @@ export function TeamSection() {
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${member.name} LinkedIn`}
|
||||
>
|
||||
<Linkedin className="h-4 w-4" />
|
||||
<LinkedinLogo className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -196,7 +196,7 @@ export function TeamSection() {
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${member.name} GitHub`}
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
<GithubLogo className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
+8
-3
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
@@ -6,9 +6,14 @@ import { Toaster } from "@/components/ui/sonner";
|
||||
import { SidebarConfigProvider } from "@/contexts/sidebar-context";
|
||||
import { inter } from "@/lib/fonts";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "İşletmem KovakCRM",
|
||||
description: "Multi-tenant CRM — KovakSoft",
|
||||
title: "Emlak CRM — Kovak Yazılım ve Medya",
|
||||
description: "Emlak ofisleri için çok kiracılı CRM — portföy, müşteri, finans ve eşleştirme.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Building2, Loader2, ShieldCheck } from "lucide-react";
|
||||
import { Buildings, CircleNotch, ShieldCheck, ArrowRight } from '@/lib/icons';
|
||||
|
||||
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 { Logo } from "@/components/logo";
|
||||
import { createWorkspaceAction } from "@/lib/appwrite/tenant-actions";
|
||||
import { createWorkspaceAction, importWorkspaceAction } from "@/lib/appwrite/tenant-actions";
|
||||
import { initialWorkspaceState } from "@/lib/appwrite/tenant-types";
|
||||
|
||||
export function CreateWorkspaceForm({ userName }: { userName?: string }) {
|
||||
interface Props {
|
||||
userName?: string;
|
||||
crossAppTeams?: Array<{ $id: string; name: string }>;
|
||||
}
|
||||
|
||||
export function CreateWorkspaceForm({ userName, crossAppTeams = [] }: Props) {
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
createWorkspaceAction,
|
||||
initialWorkspaceState,
|
||||
);
|
||||
const [, importFormAction, isImporting] = useActionState(
|
||||
importWorkspaceAction,
|
||||
initialWorkspaceState,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -23,49 +32,81 @@ export function CreateWorkspaceForm({ userName }: { userName?: string }) {
|
||||
<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">İşletmem</span>
|
||||
<span className="text-xl font-semibold">Kovak Emlak CRM</span>
|
||||
</div>
|
||||
|
||||
{/* Cross-app import section */}
|
||||
{crossAppTeams.length > 0 && (
|
||||
<Card className="border-primary/20 bg-primary/5">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Mevcut şirketinizi ekleyin</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Diğer bir KovakSoft uygulamasında kayıtlı şirketinizi tek tıkla buraya ekleyebilirsiniz.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{crossAppTeams.map((team) => (
|
||||
<form key={team.$id} action={importFormAction}>
|
||||
<input type="hidden" name="teamId" value={team.$id} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isImporting}
|
||||
className="flex w-full items-center justify-between rounded-lg border bg-background px-4 py-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-60"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Buildings className="size-4 text-muted-foreground" />
|
||||
<span>{team.name}</span>
|
||||
</div>
|
||||
{isImporting ? (
|
||||
<CircleNotch className="size-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ArrowRight className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{crossAppTeams.length > 0 && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-muted px-2 text-muted-foreground">veya yeni oluştur</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<Building2 className="size-6" />
|
||||
<Buildings className="size-6" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">
|
||||
{userName ? `Hoş geldiniz, ${userName}` : "Çalışma alanı oluştur"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Şirketinizin bilgilerini girin — birkaç saniyede çalışma alanınız hazır.
|
||||
Ofis bilgilerini girin — birkaç saniyede çalışma alanınız hazır.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={formAction} className="flex flex-col gap-5">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="companyName">Şirket adı *</Label>
|
||||
<Label htmlFor="companyName">Ofis / şirket adı *</Label>
|
||||
<Input
|
||||
id="companyName"
|
||||
name="companyName"
|
||||
type="text"
|
||||
placeholder="KovakSoft Yazılım Ltd."
|
||||
placeholder="Kovak Emlak Ofisi"
|
||||
autoComplete="organization"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="companyTaxId">
|
||||
Vergi numarası <span className="text-muted-foreground text-xs">(opsiyonel)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="companyTaxId"
|
||||
name="companyTaxId"
|
||||
type="text"
|
||||
placeholder="1234567890"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="companyPhone">
|
||||
Telefon <span className="text-muted-foreground text-xs">(opsiyonel)</span>
|
||||
@@ -88,7 +129,7 @@ export function CreateWorkspaceForm({ userName }: { userName?: string }) {
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Hazırlanıyor...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,28 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { getCurrentUser } from "@/lib/appwrite/server";
|
||||
import { getUserTeams } from "@/lib/appwrite/tenant";
|
||||
import { getCurrentUser, createAdminClient } from "@/lib/appwrite/server";
|
||||
import { getCrossAppTeams } from "@/lib/appwrite/tenant";
|
||||
import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema";
|
||||
import { CreateWorkspaceForm } from "./components/create-workspace-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "İşletmem — Çalışma alanı oluştur",
|
||||
description: "İşletmem için ilk çalışma alanınızı kurun.",
|
||||
title: "Kovak Emlak CRM — Çalışma alanı oluştur",
|
||||
description: "Kovak Emlak CRM için ilk çalışma alanınızı kurun.",
|
||||
};
|
||||
|
||||
export default async function OnboardingPage() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect("/sign-in");
|
||||
|
||||
const teams = await getUserTeams();
|
||||
if (teams && teams.total > 0) {
|
||||
redirect("/dashboard");
|
||||
// Use admin client — never fails due to expired session tokens.
|
||||
// If user already has a team with CRM settings, send them to dashboard.
|
||||
try {
|
||||
const { users, tablesDB } = createAdminClient();
|
||||
const memberships = await users.listMemberships(user.$id);
|
||||
if (memberships.total > 0) {
|
||||
const teamIds = memberships.memberships.map((m) => m.teamId);
|
||||
const settings = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.tenantSettings,
|
||||
queries: [Query.equal("tenantId", teamIds), Query.limit(1)],
|
||||
});
|
||||
if (settings.rows.length > 0) redirect("/dashboard");
|
||||
}
|
||||
} catch {
|
||||
// Admin client uses an API key (not a session), so this is extremely rare.
|
||||
// If it does fail, show a 500 rather than silently letting the user create
|
||||
// a duplicate workspace.
|
||||
throw new Error("Onboarding guard check failed — server configuration issue.");
|
||||
}
|
||||
|
||||
const crossAppTeams = await getCrossAppTeams();
|
||||
|
||||
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-md">
|
||||
<CreateWorkspaceForm userName={user.name?.split(" ")[0]} />
|
||||
<CreateWorkspaceForm
|
||||
userName={user.name?.split(" ")[0]}
|
||||
crossAppTeams={crossAppTeams}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getCurrentUser, createAdminClient } from "@/lib/appwrite/server";
|
||||
import { getActiveTenantId } from "@/lib/appwrite/tenant";
|
||||
import { DATABASE_ID, TABLES, type TenantSettings } from "@/lib/appwrite/schema";
|
||||
import { PLAN_LIMITS } from "@/lib/plans";
|
||||
import { Query } from "node-appwrite";
|
||||
import { SignOutForm } from "./sign-out-form";
|
||||
|
||||
export default async function PlanLimitPage() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect("/sign-in");
|
||||
|
||||
const tenantId = await getActiveTenantId();
|
||||
if (!tenantId) redirect("/onboarding");
|
||||
|
||||
const { tablesDB, teams } = createAdminClient();
|
||||
|
||||
const [settingsResult, membershipsResult] = await Promise.all([
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.tenantSettings,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
|
||||
}),
|
||||
teams.listMemberships(tenantId),
|
||||
]);
|
||||
|
||||
const settings = settingsResult.rows[0] as unknown as TenantSettings | undefined;
|
||||
const plan = settings?.plan ?? "free";
|
||||
const memberLimit = PLAN_LIMITS[plan].teamMembers;
|
||||
|
||||
// If user is owner or plan allows them, redirect back to dashboard
|
||||
const myMembership = membershipsResult.memberships.find((m) => m.userId === user.$id);
|
||||
const isOwner = myMembership?.roles?.includes("owner");
|
||||
const confirmedCount = membershipsResult.memberships.filter((m) => m.confirm).length;
|
||||
|
||||
if (isOwner || memberLimit === Infinity || confirmedCount <= memberLimit) {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
// Find the owner to show their contact
|
||||
const ownerMembership = membershipsResult.memberships.find((m) => m.roles?.includes("owner"));
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center bg-muted/30 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border bg-card shadow-sm overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 px-8 py-8 text-center">
|
||||
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-white/10">
|
||||
<svg className="size-8 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2zm0 15a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm1-4a1 1 0 0 1-2 0V7a1 1 0 0 1 2 0v6z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-white">Erişim Kısıtlandı</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
{settings?.officeName ?? "Bu çalışma alanı"} ücretsiz plana geçti
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-8 py-6 space-y-4">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Ücretsiz planda yalnızca <strong>1 kullanıcı</strong> sisteme erişebilir.
|
||||
Bu çalışma alanındaki erişiminiz geçici olarak askıya alınmıştır.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 px-4 py-3 text-sm text-amber-800 dark:text-amber-300">
|
||||
Yöneticinizden çalışma alanını Pro plana yükseltmesini isteyin ya da
|
||||
hesabınızı çalışma alanından çıkarmasını talep edin.
|
||||
</div>
|
||||
|
||||
{ownerMembership?.userEmail && (
|
||||
<div className="rounded-lg border px-4 py-3 text-sm">
|
||||
<p className="text-muted-foreground text-xs mb-1">Çalışma alanı yöneticisi</p>
|
||||
<p className="font-medium">{ownerMembership.userName ?? ownerMembership.userEmail}</p>
|
||||
<a
|
||||
href={`mailto:${ownerMembership.userEmail}`}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{ownerMembership.userEmail}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t px-8 py-4 flex items-center justify-between gap-3 bg-muted/20">
|
||||
<SignOutForm />
|
||||
<Link
|
||||
href="https://kovakcrm.com/fiyatlar"
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
Plan karşılaştır
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { signOutAction } from "@/lib/appwrite/auth-actions";
|
||||
|
||||
export function SignOutForm() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleSignOut() {
|
||||
startTransition(async () => {
|
||||
await signOutAction();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
disabled={isPending}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Çıkış yapılıyor..." : "Çıkış yap"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Query } from "node-appwrite";
|
||||
import { Buildings, House } from '@/lib/icons';
|
||||
|
||||
import { DATABASE_ID, TABLES, type Presentation, type Property } from "@/lib/appwrite/schema";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { incrementPresentationViewCount } from "@/lib/appwrite/presentation-actions";
|
||||
import {
|
||||
PROPERTY_TYPE_LABELS,
|
||||
LISTING_TYPE_LABELS,
|
||||
PROPERTY_STATUS_LABELS,
|
||||
} from "@/lib/appwrite/schema";
|
||||
import { SunumClient } from "./sunum-client";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ token: string }>;
|
||||
@@ -16,7 +13,7 @@ interface Props {
|
||||
|
||||
export default async function SunumPage({ params }: Props) {
|
||||
const { token } = await params;
|
||||
const { tablesDB } = createAdminClient();
|
||||
const { tablesDB, teams, users } = createAdminClient();
|
||||
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
@@ -30,18 +27,27 @@ export default async function SunumPage({ params }: Props) {
|
||||
|
||||
if (presentation.expiresAt && new Date(presentation.expiresAt) < new Date()) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">Sunum Süresi Doldu</h1>
|
||||
<p className="text-gray-500">Bu sunum artık geçerli değil.</p>
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="text-center px-6">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-orange-100 mb-4">
|
||||
<Buildings className="size-8 text-orange-500" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Sunum Süresi Doldu</h1>
|
||||
<p className="text-gray-500">Bu sunum bağlantısı artık geçerli değil.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Increment view count (fire-and-forget)
|
||||
void incrementPresentationViewCount(presentation.$id, presentation.viewCount ?? 0);
|
||||
|
||||
const [creatorResult, teamResult] = await Promise.allSettled([
|
||||
users.get(presentation.createdBy),
|
||||
teams.get(presentation.tenantId),
|
||||
]);
|
||||
const creatorName = creatorResult.status === "fulfilled" ? creatorResult.value.name : null;
|
||||
const companyName = teamResult.status === "fulfilled" ? teamResult.value.name : null;
|
||||
|
||||
let propertyIds: string[] = [];
|
||||
try {
|
||||
propertyIds = JSON.parse(presentation.propertyIds) as string[];
|
||||
@@ -53,86 +59,69 @@ export default async function SunumPage({ params }: Props) {
|
||||
for (const pid of propertyIds) {
|
||||
try {
|
||||
const row = await tablesDB.getRow(DATABASE_ID, TABLES.properties, pid);
|
||||
properties.push(row as unknown as Property);
|
||||
properties.push(JSON.parse(JSON.stringify(row)) as Property);
|
||||
} catch {
|
||||
// Property may have been deleted
|
||||
// deleted
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white border-b px-6 py-4">
|
||||
<h1 className="text-xl font-semibold text-gray-800">{presentation.title}</h1>
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Hero header */}
|
||||
<header className="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
|
||||
<div className="max-w-5xl mx-auto px-6 py-10">
|
||||
<div className="flex items-center gap-2 text-slate-400 text-sm mb-5">
|
||||
<Buildings className="size-4" />
|
||||
<span>Emlak Sunumu</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{presentation.title}</h1>
|
||||
{presentation.notes && (
|
||||
<p className="mt-1 text-sm text-gray-500">{presentation.notes}</p>
|
||||
<p className="mt-3 text-slate-300 text-sm max-w-2xl leading-relaxed">
|
||||
{presentation.notes}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400">{properties.length} ilan</p>
|
||||
<div className="mt-5 flex items-center gap-3 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1.5 bg-white/10 rounded-full px-3 py-1.5 text-sm">
|
||||
<span className="font-semibold text-white">{properties.length}</span>
|
||||
<span className="text-slate-300">ilan</span>
|
||||
</span>
|
||||
{(creatorName || companyName) && (
|
||||
<span className="text-slate-400 text-sm">
|
||||
{creatorName && <span className="text-slate-200 font-medium">{creatorName}</span>}
|
||||
{creatorName && companyName && <span className="mx-1.5">·</span>}
|
||||
{companyName && <span>{companyName}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 py-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{properties.map((p) => (
|
||||
<PropertyCard key={p.$id} property={p} />
|
||||
))}
|
||||
{properties.length === 0 && (
|
||||
<p className="col-span-full text-center text-gray-400 py-16">
|
||||
Bu sunumda ilan bulunmuyor.
|
||||
</p>
|
||||
{/* Grid */}
|
||||
<main className="max-w-5xl mx-auto px-4 py-10">
|
||||
{properties.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<House className="size-10 text-gray-200 mx-auto mb-3" />
|
||||
<p className="text-gray-400">Bu sunumda ilan bulunmuyor.</p>
|
||||
</div>
|
||||
) : (
|
||||
<SunumClient properties={properties} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyCard({ property: p }: { property: Property }) {
|
||||
const isExpired = p.status === "satildi" || p.status === "kiralandit";
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border shadow-sm overflow-hidden ${isExpired ? "opacity-60" : ""}`}>
|
||||
<div className="bg-gray-100 h-40 flex items-center justify-center text-4xl text-gray-300">
|
||||
🏠
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="font-semibold text-gray-800 text-sm leading-tight">{p.title}</h2>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded whitespace-nowrap font-medium ${
|
||||
p.status === "aktif" ? "bg-green-100 text-green-700" :
|
||||
p.status === "pasif" ? "bg-gray-100 text-gray-600" :
|
||||
"bg-orange-100 text-orange-700"
|
||||
}`}>
|
||||
{PROPERTY_STATUS_LABELS[p.status] ?? p.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 text-xs text-gray-500">
|
||||
<span>{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}</span>
|
||||
<span>·</span>
|
||||
<span>{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}</span>
|
||||
{p.roomCount && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{p.roomCount}</span>
|
||||
</>
|
||||
)}
|
||||
{p.netM2 && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{p.netM2} m²</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
{[p.neighborhood, p.district, p.city].filter(Boolean).join(", ")}
|
||||
</p>
|
||||
|
||||
<p className="text-lg font-bold text-gray-900">
|
||||
{p.price.toLocaleString("tr-TR")} {p.currency ?? "TRY"}
|
||||
</p>
|
||||
|
||||
{p.description && (
|
||||
<p className="text-xs text-gray-500 line-clamp-3">{p.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t bg-white mt-8 py-6 text-center text-xs text-gray-400">
|
||||
{creatorName || companyName ? (
|
||||
<>
|
||||
Bu sunum{" "}
|
||||
{companyName && <span className="font-medium text-gray-600">{companyName}</span>}
|
||||
{creatorName && companyName && " · "}
|
||||
{creatorName && <span className="font-medium text-gray-600">{creatorName}</span>}
|
||||
{" "}tarafından hazırlanmıştır.
|
||||
</>
|
||||
) : (
|
||||
"Bu sunum Emlak CRM tarafından hazırlanmıştır."
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, CaretLeft, CaretRight, MapPin, House, ImageSquare } from "@/lib/icons";
|
||||
import {
|
||||
getPropertyImageUrl,
|
||||
getPropertyImagePreviewUrl,
|
||||
parseImageIds,
|
||||
} from "@/lib/appwrite/storage-utils";
|
||||
import {
|
||||
PROPERTY_TYPE_LABELS,
|
||||
LISTING_TYPE_LABELS,
|
||||
PROPERTY_STATUS_LABELS,
|
||||
type Property,
|
||||
} from "@/lib/appwrite/schema";
|
||||
|
||||
/* ── Status style map ── */
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
aktif: "bg-green-500/90 text-white",
|
||||
pasif: "bg-gray-500/80 text-white",
|
||||
satildi: "bg-orange-500/90 text-white",
|
||||
kiralandit: "bg-blue-500/90 text-white",
|
||||
};
|
||||
|
||||
/* ── Mini gallery (card içi) ── */
|
||||
function CardGallery({ imageIds, title, onOpenLightbox, onOpenDetail }: {
|
||||
imageIds: string[];
|
||||
title: string;
|
||||
onOpenLightbox: (idx: number) => void;
|
||||
onOpenDetail: () => void;
|
||||
}) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const safeIdx = Math.min(idx, imageIds.length - 1);
|
||||
|
||||
if (imageIds.length === 0) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenDetail}
|
||||
className="flex h-full w-full items-center justify-center flex-col gap-2"
|
||||
>
|
||||
<ImageSquare className="size-12 text-slate-200" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Ana görsel */}
|
||||
<div className="relative h-full group/img">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPropertyImagePreviewUrl(imageIds[safeIdx], 640, 420)}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover cursor-zoom-in transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
onClick={() => onOpenLightbox(safeIdx)}
|
||||
/>
|
||||
{/* Prev / Next */}
|
||||
{imageIds.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setIdx((i) => (i - 1 + imageIds.length) % imageIds.length); }}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 size-7 rounded-full bg-black/40 hover:bg-black/65 text-white flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity"
|
||||
>
|
||||
<CaretLeft className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setIdx((i) => (i + 1) % imageIds.length); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 size-7 rounded-full bg-black/40 hover:bg-black/65 text-white flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity"
|
||||
>
|
||||
<CaretRight className="size-3.5" />
|
||||
</button>
|
||||
<div className="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-1.5 py-0.5 rounded-full tabular-nums opacity-0 group-hover/img:opacity-100 transition-opacity">
|
||||
{safeIdx + 1}/{imageIds.length}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail şeridi */}
|
||||
{imageIds.length > 1 && (
|
||||
<div className="flex gap-1 px-2 pb-2 overflow-x-auto scrollbar-hide shrink-0">
|
||||
{imageIds.map((id, i) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setIdx(i)}
|
||||
className={`shrink-0 w-12 h-9 rounded overflow-hidden border-2 transition-all ${
|
||||
i === safeIdx ? "border-white" : "border-white/20 hover:border-white/50"
|
||||
}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPropertyImagePreviewUrl(id, 100, 75)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Lightbox ── */
|
||||
function Lightbox({ imageIds, title, initialIndex, onClose }: {
|
||||
imageIds: string[];
|
||||
title: string;
|
||||
initialIndex: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [idx, setIdx] = useState(initialIndex);
|
||||
const prev = useCallback(() => setIdx((i) => (i - 1 + imageIds.length) % imageIds.length), [imageIds.length]);
|
||||
const next = useCallback(() => setIdx((i) => (i + 1) % imageIds.length), [imageIds.length]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
|
||||
onClick={onClose}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 size-9 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 text-white/70 text-sm tabular-nums select-none">
|
||||
{idx + 1} / {imageIds.length}
|
||||
</div>
|
||||
{imageIds.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); prev(); }}
|
||||
className="absolute left-3 sm:left-6 size-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white"
|
||||
>
|
||||
<CaretLeft className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPropertyImageUrl(imageIds[idx])}
|
||||
alt={`${title} ${idx + 1}`}
|
||||
className="max-h-[90dvh] max-w-[90vw] object-contain rounded-lg select-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
/>
|
||||
{imageIds.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); next(); }}
|
||||
className="absolute right-3 sm:right-6 size-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white"
|
||||
>
|
||||
<CaretRight className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
{imageIds.length > 1 && (
|
||||
<div
|
||||
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-1.5 overflow-x-auto max-w-[90vw] px-2 scrollbar-hide"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{imageIds.map((id, i) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setIdx(i)}
|
||||
className={`shrink-0 w-14 h-10 rounded overflow-hidden border-2 transition-all ${
|
||||
i === idx ? "border-white" : "border-white/20 hover:border-white/50"
|
||||
}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={getPropertyImagePreviewUrl(id, 160, 120)} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Detay Modal ── */
|
||||
function DetailModal({ p, onClose }: { p: Property; onClose: () => void }) {
|
||||
const imageIds = parseImageIds(p.imageIds);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-end sm:items-center justify-center bg-black/60 p-0 sm:p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white w-full max-w-2xl max-h-[95dvh] rounded-t-2xl sm:rounded-2xl overflow-hidden flex flex-col shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Galeri */}
|
||||
<div className="relative shrink-0 bg-black">
|
||||
{imageIds.length > 0 ? (
|
||||
<>
|
||||
<div className="relative aspect-[16/9] group">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPropertyImagePreviewUrl(imageIds[activeIdx], 900, 506)}
|
||||
alt={p.title}
|
||||
className="h-full w-full object-cover cursor-zoom-in"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
/>
|
||||
{imageIds.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveIdx((i) => (i - 1 + imageIds.length) % imageIds.length)}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 size-9 rounded-full bg-black/40 hover:bg-black/65 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<CaretLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveIdx((i) => (i + 1) % imageIds.length)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 size-9 rounded-full bg-black/40 hover:bg-black/65 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<CaretRight className="size-4" />
|
||||
</button>
|
||||
<div className="absolute bottom-3 right-3 bg-black/50 text-white text-xs px-2 py-1 rounded-full tabular-nums">
|
||||
{activeIdx + 1} / {imageIds.length}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Thumbnail şeridi */}
|
||||
{imageIds.length > 1 && (
|
||||
<div className="flex gap-1.5 px-3 py-2 overflow-x-auto scrollbar-hide bg-black/80">
|
||||
{imageIds.map((id, i) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setActiveIdx(i)}
|
||||
className={`shrink-0 w-14 h-10 rounded overflow-hidden border-2 transition-all ${
|
||||
i === activeIdx ? "border-white" : "border-white/20 hover:border-white/50"
|
||||
}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={getPropertyImagePreviewUrl(id, 140, 100)} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="aspect-[16/9] flex items-center justify-center">
|
||||
<House className="size-16 text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kapat */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 size-8 rounded-full bg-black/50 hover:bg-black/70 text-white flex items-center justify-center transition-colors"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* İçerik */}
|
||||
<div className="overflow-y-auto flex-1 p-5 space-y-4">
|
||||
{/* Başlık + durum */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="text-xl font-bold text-gray-900 leading-snug">{p.title}</h2>
|
||||
<span className={`shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full ${STATUS_STYLE[p.status] ?? "bg-gray-200 text-gray-700"}`}>
|
||||
{PROPERTY_STATUS_LABELS[p.status] ?? p.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Fiyat */}
|
||||
<p className="text-3xl font-bold text-slate-900 tracking-tight">
|
||||
{p.price.toLocaleString("tr-TR")}
|
||||
<span className="text-base font-normal text-gray-400 ml-2">{p.currency ?? "TRY"}</span>
|
||||
</p>
|
||||
|
||||
{/* Konum */}
|
||||
{(p.city || p.district || p.neighborhood) && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-500">
|
||||
<MapPin className="size-3.5 shrink-0 text-gray-400" />
|
||||
<span>{[p.neighborhood, p.district, p.city].filter(Boolean).join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Özellik pilleri */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">
|
||||
{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}
|
||||
</span>
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">
|
||||
{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}
|
||||
</span>
|
||||
{p.roomCount && (
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">{p.roomCount}</span>
|
||||
)}
|
||||
{p.netM2 && (
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">{p.netM2} m²</span>
|
||||
)}
|
||||
{p.grossM2 && (
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">{p.grossM2} m² brüt</span>
|
||||
)}
|
||||
{p.floor != null && (
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">{p.floor}. kat</span>
|
||||
)}
|
||||
{p.totalFloors != null && (
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">{p.totalFloors} katlı</span>
|
||||
)}
|
||||
{p.buildingAge != null && (
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">{p.buildingAge === 0 ? "Sıfır bina" : `${p.buildingAge} yaşında`}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Açıklama */}
|
||||
{p.description && (
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm font-semibold text-gray-700 mb-1.5">Açıklama</p>
|
||||
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-line">{p.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Harita linki */}
|
||||
{p.mapLat != null && p.mapLng != null && (
|
||||
<div className="border-t pt-4">
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${p.mapLat},${p.mapLng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-700 transition-colors"
|
||||
>
|
||||
<MapPin className="size-4" />
|
||||
Google Maps'te Göster
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lightboxOpen && (
|
||||
<Lightbox
|
||||
imageIds={imageIds}
|
||||
title={p.title}
|
||||
initialIndex={activeIdx}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Ana kart ── */
|
||||
function PropertyCard({ p }: { p: Property }) {
|
||||
const imageIds = parseImageIds(p.imageIds);
|
||||
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const isSold = p.status === "satildi" || p.status === "kiralandit";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`group bg-white rounded-2xl shadow-sm hover:shadow-xl transition-shadow duration-300 overflow-hidden border border-gray-100 flex flex-col ${isSold ? "opacity-70" : ""}`}
|
||||
>
|
||||
{/* Görsel alanı */}
|
||||
<div className="relative bg-slate-100 overflow-hidden shrink-0 flex flex-col" style={{ height: imageIds.length > 1 ? "calc(13rem + 2.75rem)" : "13rem" }}>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<CardGallery
|
||||
imageIds={imageIds}
|
||||
title={p.title}
|
||||
onOpenLightbox={(idx) => setLightboxIdx(idx)}
|
||||
onOpenDetail={() => setDetailOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Statü */}
|
||||
<span
|
||||
className={`absolute top-3 left-3 text-xs font-semibold px-2.5 py-1 rounded-full backdrop-blur-sm z-10 ${STATUS_STYLE[p.status] ?? "bg-gray-500/80 text-white"}`}
|
||||
>
|
||||
{PROPERTY_STATUS_LABELS[p.status] ?? p.status}
|
||||
</span>
|
||||
|
||||
{/* Listing type */}
|
||||
<span className="absolute top-3 right-3 text-xs font-semibold px-2.5 py-1 rounded-full bg-slate-900/70 text-white backdrop-blur-sm z-10">
|
||||
{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 flex flex-col gap-2 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
className="font-semibold text-gray-900 leading-snug line-clamp-2 text-left hover:text-blue-700 transition-colors"
|
||||
>
|
||||
{p.title}
|
||||
</button>
|
||||
|
||||
{(p.city || p.district || p.neighborhood) && (
|
||||
<div className="flex items-start gap-1 text-xs text-gray-500">
|
||||
<MapPin className="size-3 shrink-0 mt-0.5 text-gray-400" />
|
||||
<span className="line-clamp-1">
|
||||
{[p.neighborhood, p.district, p.city].filter(Boolean).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full font-medium">
|
||||
{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}
|
||||
</span>
|
||||
{p.roomCount && (
|
||||
<span className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full font-medium">{p.roomCount}</span>
|
||||
)}
|
||||
{p.netM2 && (
|
||||
<span className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full font-medium">{p.netM2} m²</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-2 flex items-end justify-between gap-2">
|
||||
<p className="text-2xl font-bold text-slate-900 tracking-tight">
|
||||
{p.price.toLocaleString("tr-TR")}
|
||||
<span className="text-sm font-normal text-gray-400 ml-1.5">{p.currency ?? "TRY"}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
className="shrink-0 text-xs font-medium text-blue-600 hover:text-blue-700 hover:underline transition-colors"
|
||||
>
|
||||
Detayları gör →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lightboxIdx !== null && (
|
||||
<Lightbox
|
||||
imageIds={imageIds}
|
||||
title={p.title}
|
||||
initialIndex={lightboxIdx}
|
||||
onClose={() => setLightboxIdx(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailOpen && (
|
||||
<DetailModal p={p} onClose={() => setDetailOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Export ── */
|
||||
export function SunumClient({ properties }: { properties: Property[] }) {
|
||||
return (
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{properties.map((p) => (
|
||||
<PropertyCard key={p.$id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { CheckCircle, Circle } from '@/lib/icons';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ACADEMY_MODULES } from "@/lib/academy/tours";
|
||||
import { getCompletedModules, resetProgress } from "@/lib/academy/progress";
|
||||
import { AcademyTourButton } from "./academy-tour-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function AcademyClient() {
|
||||
const [completed, setCompleted] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setCompleted(getCompletedModules());
|
||||
}, []);
|
||||
|
||||
function handleComplete() {
|
||||
setCompleted(getCompletedModules());
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
resetProgress();
|
||||
setCompleted([]);
|
||||
}
|
||||
|
||||
const percent = Math.round((completed.length / ACADEMY_MODULES.length) * 100);
|
||||
const allDone = completed.length === ACADEMY_MODULES.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress header */}
|
||||
<div className="bg-card border rounded-xl p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-sm">Genel İlerleme</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
{completed.length} / {ACADEMY_MODULES.length} modül tamamlandı
|
||||
</p>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-2xl font-bold",
|
||||
allDone ? "text-green-600" : "text-primary"
|
||||
)}>
|
||||
%{percent}
|
||||
</span>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-500",
|
||||
allDone ? "bg-green-500" : "bg-primary"
|
||||
)}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{allDone && (
|
||||
<p className="text-green-600 text-sm font-medium">
|
||||
🎉 Tüm modülleri tamamladınız! Artık KovakEmlak CRM'i tam verimle kullanabilirsiniz.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Module grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{ACADEMY_MODULES.map((mod) => {
|
||||
const isDone = completed.includes(mod.id);
|
||||
return (
|
||||
<div
|
||||
key={mod.id}
|
||||
className={cn(
|
||||
"bg-card border rounded-xl p-5 flex flex-col gap-3 transition-colors",
|
||||
isDone && "border-green-200 bg-green-50/40 dark:bg-green-950/20 dark:border-green-900"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-2xl">{mod.icon}</span>
|
||||
<div>
|
||||
<p className="font-semibold text-sm leading-tight">{mod.title}</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
{mod.steps.length} adım
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isDone ? (
|
||||
<CheckCircle className="size-5 text-green-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Circle className="size-5 text-muted-foreground/40 shrink-0 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs leading-relaxed flex-1">
|
||||
{mod.description}
|
||||
</p>
|
||||
|
||||
{/* Step previews */}
|
||||
<div className="space-y-1">
|
||||
{mod.steps.slice(0, 3).map((step, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className={cn(
|
||||
"size-4 rounded-full flex items-center justify-center text-[10px] font-medium shrink-0",
|
||||
isDone
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="truncate">{step.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AcademyTourButton
|
||||
module={mod}
|
||||
onComplete={handleComplete}
|
||||
variant={isDone ? "ghost" : "outline"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{completed.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={handleReset} className="text-muted-foreground text-xs">
|
||||
İlerlemeyi sıfırla
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { GraduationCap } from '@/lib/icons';
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ACADEMY_MODULES } from "@/lib/academy/tours";
|
||||
import { getCompletedModules } from "@/lib/academy/progress";
|
||||
|
||||
export function AcademySidebarBadge() {
|
||||
const [percent, setPercent] = useState(0);
|
||||
const pathname = usePathname();
|
||||
const isActive = pathname === "/academy";
|
||||
|
||||
useEffect(() => {
|
||||
function update() {
|
||||
const completed = getCompletedModules();
|
||||
setPercent(Math.round((completed.length / ACADEMY_MODULES.length) * 100));
|
||||
}
|
||||
update();
|
||||
window.addEventListener("focus", update);
|
||||
return () => window.removeEventListener("focus", update);
|
||||
}, []);
|
||||
|
||||
if (percent === 100) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/academy"
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors mx-2",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<GraduationCap className="size-4 shrink-0" />
|
||||
<span className="flex-1 text-xs font-medium">Akademi</span>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full",
|
||||
percent === 0
|
||||
? "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300"
|
||||
: "bg-primary/15 text-primary"
|
||||
)}>
|
||||
%{percent}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user