init: kovakemlak-crm project scaffold
- Next.js 16 + Appwrite multi-tenant emlak CRM - Database: kovakemlak-db (properties, customers, customer_searches, property_matches, presentations, investors, activities, tenant_settings) - Same stack as isletmem-kovakcrm (shadcn/ui template base) - Modules: portföy, müşteri takibi, arama kriterleri, otomatik eşleştirme, sunum linki, yatırımcı portalı
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CheckCircle2,
|
||||
CreditCard,
|
||||
Crown,
|
||||
Lock,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Star,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
removeCardAction,
|
||||
setDefaultCardAction,
|
||||
} from "@/lib/appwrite/saved-card-actions";
|
||||
import { listSavedCards } from "@/lib/appwrite/saved-card-queries";
|
||||
import type { CardBrand } from "@/lib/appwrite/schema";
|
||||
import { listPaymentsForTenant } from "@/lib/appwrite/subscription-queries";
|
||||
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
|
||||
import { getEffectivePlan } from "@/lib/appwrite/plan-limits";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
|
||||
const trFmt = new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency",
|
||||
currency: "TRY",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: "Bekliyor",
|
||||
success: "Başarılı",
|
||||
failed: "İptal",
|
||||
refunded: "İade",
|
||||
};
|
||||
|
||||
const STATUS_VARIANT: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
|
||||
pending: "secondary",
|
||||
success: "default",
|
||||
failed: "outline",
|
||||
refunded: "destructive",
|
||||
};
|
||||
|
||||
const PROVIDER_LABEL: Record<string, string> = {
|
||||
mock: "Mock (Test)",
|
||||
shopier: "Shopier",
|
||||
};
|
||||
|
||||
const BRAND_LABEL: Record<CardBrand, string> = {
|
||||
visa: "Visa",
|
||||
mastercard: "Mastercard",
|
||||
amex: "Amex",
|
||||
troy: "troy",
|
||||
unknown: "Kart",
|
||||
};
|
||||
|
||||
export default async function BillingSettings({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ upgraded?: string; cancelled?: string; downgraded?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const ctx = await requireTenant();
|
||||
const plan = getEffectivePlan(ctx);
|
||||
const isPro = plan === "pro";
|
||||
const canManage = ctx.role === "owner";
|
||||
|
||||
const [payments, savedCards] = await Promise.all([
|
||||
listPaymentsForTenant(ctx.tenantId, 10),
|
||||
listSavedCards(ctx.tenantId),
|
||||
]);
|
||||
|
||||
const expiresAt = ctx.settings?.planExpiresAt;
|
||||
const catalog = isPro ? PLAN_CATALOG.pro : PLAN_CATALOG.free;
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{ctx.settings?.companyName ?? "Çalışma alanı"}
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Faturalandırma</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Kayıtlı ödeme yöntemlerini, faturalarını ve veri saklama bilgilerini buradan yönet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sp.upgraded && (
|
||||
<Card className="border-emerald-500/40 bg-emerald-500/5">
|
||||
<CardContent className="flex items-center gap-3 py-4 text-sm">
|
||||
<CheckCircle2 className="size-5 text-emerald-600" />
|
||||
<span>Pro plan aktif. Sınırsız kullanım açıldı.</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{sp.cancelled && (
|
||||
<Card className="border-amber-500/40 bg-amber-500/5">
|
||||
<CardContent className="py-4 text-sm">
|
||||
Ödeme iptal edildi. Plan değişmedi.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{sp.downgraded && (
|
||||
<Card className="border-amber-500/40 bg-amber-500/5">
|
||||
<CardContent className="py-4 text-sm">Ücretsiz plana döndünüz.</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{isPro ? (
|
||||
<Crown className="size-5 text-amber-500" />
|
||||
) : (
|
||||
<Sparkles className="size-5 text-muted-foreground" />
|
||||
)}
|
||||
{catalog.name} plan
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isPro && expiresAt
|
||||
? `Yenileme tarihi: ${dateFmt.format(new Date(expiresAt))}`
|
||||
: isPro
|
||||
? "Sınırsız kullanım."
|
||||
: "Ücretsiz plan — sınırlar dolduğunda Pro'ya geç."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/pricing">
|
||||
Planları gör
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{trFmt.format(catalog.price)}
|
||||
<span className="text-muted-foreground text-sm font-normal"> /ay</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Kayıtlı kartlar</CardTitle>
|
||||
<CardDescription>
|
||||
Sonraki ödemelerde kullanılacak ödeme yöntemleri.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{canManage && !isPro && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/pricing">
|
||||
Pro'ya geç
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{savedCards.length === 0 ? (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 py-8 text-center text-sm">
|
||||
<CreditCard className="size-8 opacity-40" />
|
||||
<p>Henüz kayıtlı kart yok.</p>
|
||||
<p className="text-xs">
|
||||
Bir ödeme yaparken "Bu kartı kaydet" seçeneğini işaretle, kart bilgileri buraya
|
||||
eklenir.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{savedCards.map((c) => (
|
||||
<li
|
||||
key={c.$id}
|
||||
className={cn(
|
||||
"flex items-center gap-4 py-3",
|
||||
c.isDefault && "bg-primary/5 -mx-2 rounded-md px-2",
|
||||
)}
|
||||
>
|
||||
<div className="bg-muted flex size-10 shrink-0 items-center justify-center rounded-md">
|
||||
<CreditCard className="size-5" />
|
||||
</div>
|
||||
<div className="flex-1 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{BRAND_LABEL[c.brand ?? "unknown"]} •••• {c.last4}
|
||||
{c.isDefault && (
|
||||
<Badge variant="secondary" className="gap-1 text-[10px]">
|
||||
<Star className="!size-3" />
|
||||
Varsayılan
|
||||
</Badge>
|
||||
)}
|
||||
{c.provider === "mock" && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Mock
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{c.holderName ?? "İsimsiz"} · Son kullanma{" "}
|
||||
{String(c.expiryMonth).padStart(2, "0")}/{String(c.expiryYear).slice(2)}
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex gap-1">
|
||||
{!c.isDefault && (
|
||||
<form action={setDefaultCardAction}>
|
||||
<input type="hidden" name="id" value={c.$id} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
Varsayılan yap
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<form action={removeCardAction}>
|
||||
<input type="hidden" name="id" value={c.$id} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Sil
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-muted/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="size-4 text-emerald-600" />
|
||||
Kart bilgileriniz nasıl saklanır?
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lock className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">Ham kart numarası saklanmaz</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
İşletmem sunucularında kart numaranızın yalnızca son 4 hanesi, markası, son
|
||||
kullanma tarihi ve kart sahibi adı tutulur. Tam kart numarası ve CVC asla
|
||||
kaydedilmez.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldCheck className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">Mock test modu</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Şu an Pro plan mock ödeme akışıyla çalışır — gerçek tahsilat yapılmaz. Test
|
||||
amaçlı girilen kart numaralarına ait yalnızca son 4 hane görüntü amacıyla
|
||||
kaydedilir.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CreditCard className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">Shopier entegrasyonu sonrası</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Shopier (Türkiye'de PCI-DSS uyumlu, BDDK lisanslı ödeme hizmeti sağlayıcısı)
|
||||
devreye girdiğinde kart bilgileri Shopier'in altyapısında tokenize edilir.
|
||||
İşletmem yalnızca tokeni saklar; bir sonraki ödemede tokenle Shopier'e tekrar
|
||||
başvurulur. Token yalnızca o aboneliğe özeldir.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground border-t pt-3 text-xs">
|
||||
KVKK Aydınlatma Metni ve Mesafeli Satış Sözleşmesi yakında bu sayfaya eklenecek.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ödeme geçmişi</CardTitle>
|
||||
<CardDescription>Son 10 işlem.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{payments.length === 0 ? (
|
||||
<p className="text-muted-foreground py-8 text-center text-sm">
|
||||
Henüz ödeme kaydı yok.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tarih</TableHead>
|
||||
<TableHead>Sipariş No</TableHead>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead>Sağlayıcı</TableHead>
|
||||
<TableHead>Durum</TableHead>
|
||||
<TableHead className="text-right">Tutar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{payments.map((p) => (
|
||||
<TableRow key={p.$id}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{dateFmt.format(new Date(p.$createdAt))}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.orderId}</TableCell>
|
||||
<TableCell className="capitalize">{p.plan}</TableCell>
|
||||
<TableCell>{PROVIDER_LABEL[p.provider ?? "mock"]}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={STATUS_VARIANT[p.status ?? "pending"]}>
|
||||
{STATUS_LABEL[p.status ?? "pending"]}
|
||||
</Badge>
|
||||
{p.status === "pending" && (
|
||||
<Link
|
||||
href={`/settings/billing/checkout/${p.orderId}`}
|
||||
className="text-primary ml-2 inline-flex items-center gap-0.5 text-xs hover:underline"
|
||||
>
|
||||
Devam <ArrowUpRight className="size-3" />
|
||||
</Link>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{trFmt.format(p.amount)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user