perf: memoize parseImageIds, fix checkLimit OR query, loading skeletons, dashboard cache, compound indexes, sidebar active state, matches notified fix, padding fixes, match criteria in property detail

This commit is contained in:
egecankomur
2026-05-13 13:08:05 +03:00
parent 933cb17107
commit 7c677dfa4b
34 changed files with 1257 additions and 308 deletions
+39
View File
@@ -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>
);
}
@@ -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>
);
}
+60
View File
@@ -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 -1
View File
@@ -25,7 +25,7 @@ export default async function DashboardPage() {
return (
// 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-0 overflow-x-hidden">
<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">
+13 -3
View File
@@ -10,6 +10,7 @@ import {
TABLES,
type Property,
type PropertyMatch,
type CustomerSearch,
type Activity,
} from "@/lib/appwrite/schema";
import { createAdminClient } from "@/lib/appwrite/server";
@@ -35,7 +36,7 @@ export default async function PropertyDetailPage({ params }: Props) {
if (property.tenantId !== ctx.tenantId) notFound();
const [customers, matchesResult, activitiesResult] = await Promise.all([
const [customers, matchesResult, activitiesResult, searchesResult] = await Promise.all([
listCustomers(ctx.tenantId),
tablesDB.listRows({
databaseId: DATABASE_ID,
@@ -57,11 +58,19 @@ export default async function PropertyDetailPage({ params }: Props) {
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 customerMap = Object.fromEntries(customers.map((c) => [c.$id, c.name]));
const searches = JSON.parse(JSON.stringify(searchesResult.rows)) as CustomerSearch[];
const imageIds = parseImageIds(property.imageIds);
return (
@@ -70,7 +79,8 @@ export default async function PropertyDetailPage({ params }: Props) {
matches={matches}
activities={activities}
imageIds={imageIds}
customerMap={customerMap}
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>
);
}
@@ -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>
@@ -1,72 +1,107 @@
"use client";
import { Crown, Lightning } from '@/lib/icons';
import { Crown, Lightning, Star } from "@/lib/icons";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import type { PlanUsage } from "@/lib/appwrite/plan-limits";
import type { TenantPlan } from "@/lib/appwrite/schema";
import { RESOURCE_LABELS } from "@/lib/appwrite/plan-limits-shared";
import type { TenantPlan, PlanPeriod } from "@/lib/appwrite/schema";
import { PLAN_NAMES } from "@/lib/appwrite/plan-limits-shared";
const LIMIT_LABELS: Record<string, string> = {
properties: "İlan",
customers: "Müşteri",
members: "Ekip üyesi",
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 isPro = plan === "pro";
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>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">Mevcut Plan</CardTitle>
<Badge
variant={isPro ? "default" : "secondary"}
className="gap-1"
>
{isPro ? <Crown className="h-3 w-3" /> : <Lightning className="h-3 w-3" />}
{isPro ? "Pro" : "Ücretsiz"}
</Badge>
</div>
{isPro && expiryDate && (
<p className="text-xs text-muted-foreground">{expiryDate} tarihine kadar geçerli</p>
)}
</CardHeader>
<CardContent className="space-y-3">
{(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="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{LIMIT_LABELS[resource] ?? RESOURCE_LABELS[resource as keyof typeof RESOURCE_LABELS]}</span>
<span className={reached && !isUnlimited ? "text-destructive font-medium" : ""}>
{isUnlimited ? `${used} / ∞` : `${used} / ${limit}`}
</span>
</div>
{!isUnlimited && (
<Progress
value={pct}
className={`h-1.5 ${reached ? "[&>div]:bg-destructive" : ""}`}
/>
)}
<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>
);
@@ -1,96 +1,248 @@
"use client";
import { useRef } from "react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { Crown, Check, CircleNotch } from '@/lib/icons';
import { Crown, Check, CircleNotch, Star, Lightning, X } from "@/lib/icons";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { startCheckoutAction, downgradeToFreeAction } from "@/lib/appwrite/subscription-actions";
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
import type { TenantPlan } from "@/lib/appwrite/schema";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
startCheckoutAction,
downgradeToFreeAction,
getPayTRTokenAction,
} from "@/lib/appwrite/subscription-actions";
import { PLAN_CATALOG, planPriceDisplay, planPrice } from "@/lib/appwrite/subscription-types";
import type { TenantPlan, PlanPeriod } from "@/lib/appwrite/schema";
export function UpgradeSection({ currentPlan }: { currentPlan: TenantPlan }) {
const formRef = useRef<HTMLFormElement>(null);
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" />,
};
const pro = PLAN_CATALOG.pro;
const isPro = currentPlan === "pro";
export function UpgradeSection({
currentPlan,
currentPeriod,
paytrEnabled,
}: {
currentPlan: TenantPlan;
currentPeriod?: PlanPeriod | null;
paytrEnabled?: boolean;
}) {
const [period, setPeriod] = useState<PlanPeriod>(currentPeriod ?? "monthly");
const [paytrToken, setPaytrToken] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [loadingPlan, setLoadingPlan] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
async function handleUpgrade(formData: FormData) {
try {
await startCheckoutAction(formData);
} catch (e) {
if (e instanceof Error && e.message.includes("NEXT_REDIRECT")) throw e;
toast.error("Ödeme başlatılamadı. Tekrar deneyin.");
const plans = Object.values(PLAN_CATALOG);
function handleCheckout(planId: string) {
if (paytrEnabled) {
setLoadingPlan(planId);
startTransition(async () => {
try {
const fd = new FormData();
fd.set("plan", planId);
fd.set("period", period);
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);
}
});
}
}
async function handleDowngrade() {
const fd = new FormData();
try {
await downgradeToFreeAction();
} catch (e) {
if (e instanceof Error && e.message.includes("NEXT_REDIRECT")) throw e;
void fd;
toast.error("İşlem başarısız. Tekrar deneyin.");
toast.error("İşlem başarısız.");
}
}
if (isPro) {
return (
<Card className="border-primary/30 bg-primary/5">
<CardHeader>
<div className="flex items-center gap-2">
<Crown className="h-5 w-5 text-primary" />
<CardTitle className="text-base">Pro Plan Aktif</CardTitle>
</div>
<CardDescription>
Tüm özelliklere sınırsız erişiminiz var.
</CardDescription>
</CardHeader>
<CardContent>
<form action={handleDowngrade}>
<Button type="submit" variant="outline" size="sm" className="text-muted-foreground">
Ücretsiz plana geç
</Button>
</form>
</CardContent>
</Card>
);
}
return (
<Card className="border-primary/30">
<CardHeader>
<div className="flex items-center gap-2">
<CardTitle className="text-base">{pro.name} Plana Geç</CardTitle>
<Badge variant="secondary" className="text-xs">Önerilen</Badge>
</div>
<CardDescription>{pro.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ul className="space-y-1.5">
{pro.features.map((f) => (
<li key={f} className="flex items-center gap-2 text-sm">
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
{f}
</li>
))}
</ul>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold">{pro.price.toLocaleString("tr-TR")}</span>
<span className="text-muted-foreground text-sm"> / ay</span>
<>
<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">
İhtiyacınıza göre istediğiniz zaman değiştirebilirsiniz.
</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>
</div>
<form ref={formRef} action={handleUpgrade}>
<input type="hidden" name="plan" value="pro" />
<Button type="submit" className="w-full gap-2 cursor-pointer">
<Crown className="h-4 w-4" />
Pro&apos;ya Geç
</Button>
</form>
</CardContent>
</Card>
{/* 3 plan kartı */}
<div className="grid gap-4 sm:grid-cols-3">
{plans.map((plan) => {
const isCurrent = currentPlan === plan.id;
const displayPrice = planPriceDisplay(plan, period);
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"
: ""
}`}
>
{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>
</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 ? (
<div className="space-y-2">
<Button variant="outline" className="w-full" disabled>
Mevcut Planınız
</Button>
<button
onClick={handleDowngrade}
className="w-full text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
>
Ücretsiz plana geç
</button>
</div>
) : (
<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>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
{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>
)}
</>
);
}
+16 -10
View File
@@ -6,6 +6,7 @@ import { CheckCircle, XCircle } 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";
@@ -33,11 +34,12 @@ export default async function BillingPage({
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-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">Plan & Faturalama</h1>
@@ -49,7 +51,7 @@ export default async function BillingPage({
{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" />
Pro plana başarıyla geçtiniz. İyi kullanımlar!
Tebrikler! Planınız başarıyla yükseltildi. KovakEmlak Pro&apos;ya hoş geldiniz.
</div>
)}
@@ -60,14 +62,18 @@ export default async function BillingPage({
</div>
)}
<div className="grid gap-6 md:grid-cols-2">
<CurrentPlanCard
plan={plan}
expiresAt={ctx.settings?.planExpiresAt ?? null}
usage={usage}
/>
<UpgradeSection currentPlan={plan} />
</div>
<CurrentPlanCard
plan={plan}
period={ctx.settings?.planPeriod ?? null}
expiresAt={ctx.settings?.planExpiresAt ?? null}
usage={usage}
/>
<UpgradeSection
currentPlan={plan}
currentPeriod={ctx.settings?.planPeriod ?? null}
paytrEnabled={paytrEnabled}
/>
</div>
);
}
@@ -67,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>
@@ -26,7 +26,7 @@ export default async function WorkspaceSettingsPage() {
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>