From 7c677dfa4b556795b74b92b63eb4f8ddb7439801 Mon Sep 17 00:00:00 2001 From: egecankomur Date: Wed, 13 May 2026 13:08:05 +0300 Subject: [PATCH] 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 --- src/app/(dashboard)/customers/loading.tsx | 39 +++ .../(dashboard)/customers/matches/loading.tsx | 43 +++ .../customers/searches/loading.tsx | 35 +++ src/app/(dashboard)/dashboard/loading.tsx | 60 ++++ src/app/(dashboard)/dashboard/page.tsx | 2 +- src/app/(dashboard)/properties/[id]/page.tsx | 16 +- src/app/(dashboard)/properties/loading.tsx | 44 +++ src/app/(dashboard)/settings/account/page.tsx | 2 +- .../billing/components/current-plan-card.tsx | 127 +++++--- .../billing/components/upgrade-section.tsx | 296 +++++++++++++----- src/app/(dashboard)/settings/billing/page.tsx | 26 +- src/app/(dashboard)/settings/members/page.tsx | 2 +- .../(dashboard)/settings/workspace/page.tsx | 2 +- src/app/api/payments/paytr/callback/route.ts | 41 +++ src/components/matches/matches-client.tsx | 55 +++- src/components/nav-main.tsx | 88 +++--- .../properties/properties-client.tsx | 38 ++- .../properties/property-detail-client.tsx | 136 +++++++- .../properties/property-image-sheet.tsx | 26 +- .../properties/property-image-uploader.tsx | 12 +- src/lib/appwrite/active-context.ts | 5 +- src/lib/appwrite/auth-actions.ts | 2 +- src/lib/appwrite/dashboard-queries.ts | 25 +- src/lib/appwrite/matching.ts | 12 +- src/lib/appwrite/plan-limits-shared.ts | 29 +- src/lib/appwrite/plan-limits.ts | 24 +- src/lib/appwrite/schema.ts | 4 +- src/lib/appwrite/server.ts | 5 +- src/lib/appwrite/storage-actions.ts | 33 +- src/lib/appwrite/subscription-actions.ts | 73 ++++- src/lib/appwrite/subscription-types.ts | 74 +++-- src/lib/appwrite/tenant-guard.ts | 5 +- src/lib/payments/paytr.ts | 130 ++++++++ src/lib/plans.ts | 54 ++-- 34 files changed, 1257 insertions(+), 308 deletions(-) create mode 100644 src/app/(dashboard)/customers/loading.tsx create mode 100644 src/app/(dashboard)/customers/matches/loading.tsx create mode 100644 src/app/(dashboard)/customers/searches/loading.tsx create mode 100644 src/app/(dashboard)/dashboard/loading.tsx create mode 100644 src/app/(dashboard)/properties/loading.tsx create mode 100644 src/app/api/payments/paytr/callback/route.ts create mode 100644 src/lib/payments/paytr.ts diff --git a/src/app/(dashboard)/customers/loading.tsx b/src/app/(dashboard)/customers/loading.tsx new file mode 100644 index 0000000..328c8ba --- /dev/null +++ b/src/app/(dashboard)/customers/loading.tsx @@ -0,0 +1,39 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function CustomersLoading() { + return ( +
+
+
+ + +
+ +
+ +
+ + +
+ +
+
+ {[140, 100, 80, 60].map((w, i) => ( + + ))} +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ +
+ + +
+ + +
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/customers/matches/loading.tsx b/src/app/(dashboard)/customers/matches/loading.tsx new file mode 100644 index 0000000..c5936ae --- /dev/null +++ b/src/app/(dashboard)/customers/matches/loading.tsx @@ -0,0 +1,43 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function MatchesLoading() { + return ( +
+
+
+ + +
+ +
+ +
+ + +
+ +
+
+ {[40, 120, 160, 80, 70].map((w, i) => ( + + ))} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ +
+ + +
+
+ + +
+ + +
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/customers/searches/loading.tsx b/src/app/(dashboard)/customers/searches/loading.tsx new file mode 100644 index 0000000..890da58 --- /dev/null +++ b/src/app/(dashboard)/customers/searches/loading.tsx @@ -0,0 +1,35 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function SearchesLoading() { + return ( +
+
+
+ + +
+ +
+ +
+
+ {[140, 100, 120, 80, 60].map((w, i) => ( + + ))} +
+ {Array.from({ length: 7 }).map((_, i) => ( +
+
+ + +
+ + + + +
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/loading.tsx b/src/app/(dashboard)/dashboard/loading.tsx new file mode 100644 index 0000000..32668b6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/loading.tsx @@ -0,0 +1,60 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function DashboardLoading() { + return ( +
+ {/* Başlık */} +
+ + +
+ + {/* Stat kartları */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+ + {/* Grafik + liste */} +
+
+ + +
+
+ + {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+
+ + {/* Alt satır */} +
+ {[0, 1].map((i) => ( +
+ + {Array.from({ length: 4 }).map((_, j) => ( +
+ + +
+ ))} +
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index a3bdd60..19e95ac 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -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. -
+
{/* Başlık */}
diff --git a/src/app/(dashboard)/properties/[id]/page.tsx b/src/app/(dashboard)/properties/[id]/page.tsx index 87f6f62..21076cd 100644 --- a/src/app/(dashboard)/properties/[id]/page.tsx +++ b/src/app/(dashboard)/properties/[id]/page.tsx @@ -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} /> ); } diff --git a/src/app/(dashboard)/properties/loading.tsx b/src/app/(dashboard)/properties/loading.tsx new file mode 100644 index 0000000..58deeac --- /dev/null +++ b/src/app/(dashboard)/properties/loading.tsx @@ -0,0 +1,44 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function PropertiesLoading() { + return ( +
+ {/* Başlık + buton */} +
+
+ + +
+ +
+ + {/* Filtre bar */} +
+ + + +
+ + {/* Tablo */} +
+
+ {[120, 80, 80, 70, 60].map((w, i) => ( + + ))} +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ +
+ + +
+ + + +
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/settings/account/page.tsx b/src/app/(dashboard)/settings/account/page.tsx index 652f7d0..5bb0b62 100644 --- a/src/app/(dashboard)/settings/account/page.tsx +++ b/src/app/(dashboard)/settings/account/page.tsx @@ -18,7 +18,7 @@ export default async function AccountSettingsPage() { if (!user) redirect("/sign-in"); return ( -
+

Profil ayarları

{user.name || user.email}

diff --git a/src/app/(dashboard)/settings/billing/components/current-plan-card.tsx b/src/app/(dashboard)/settings/billing/components/current-plan-card.tsx index 9784826..a4d6b65 100644 --- a/src/app/(dashboard)/settings/billing/components/current-plan-card.tsx +++ b/src/app/(dashboard)/settings/billing/components/current-plan-card.tsx @@ -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 = { - properties: "İlan", - customers: "Müşteri", - members: "Ekip üyesi", +const RESOURCE_LABELS: Record = { + properties: "Aktif İlan", + customers: "Müşteri", + members: "Ekip Üyesi", presentations: "Sunum", }; +function PlanIcon({ plan }: { plan: TenantPlan }) { + if (plan === "enterprise") return ; + if (plan === "pro") return ; + if (plan === "starter") return ; + return ; +} + +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 ( - -
- Mevcut Plan - - {isPro ? : } - {isPro ? "Pro" : "Ücretsiz"} - -
- {isPro && expiryDate && ( -

{expiryDate} tarihine kadar geçerli

- )} -
- - {(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 ( -
-
- {LIMIT_LABELS[resource] ?? RESOURCE_LABELS[resource as keyof typeof RESOURCE_LABELS]} - - {isUnlimited ? `${used} / ∞` : `${used} / ${limit}`} - -
- {!isUnlimited && ( - div]:bg-destructive" : ""}`} - /> - )} + +
+ {/* Plan bilgisi */} +
+
+ +
+
+
+ {PLAN_NAMES[plan]} Plan + + + {PLAN_NAMES[plan]} + {periodLabel && · {periodLabel}} +
- ); - } - )} + {isPaid && expiryDate && ( +

+ {expiryDate} tarihine kadar geçerli +

+ )} + {!isPaid && ( +

+ Ücretsiz kullanımdaki özellikler +

+ )} +
+
+ + {/* Kullanım çubukları */} +
+ {(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 ( +
+
+ {RESOURCE_LABELS[resource] ?? resource} + + {isUnlimited ? `${used}/∞` : `${used}/${limit}`} + +
+ {isUnlimited ? ( +
+ ) : ( + div]:bg-destructive" : ""}`} + /> + )} +
+ ); + } + )} +
+
); diff --git a/src/app/(dashboard)/settings/billing/components/upgrade-section.tsx b/src/app/(dashboard)/settings/billing/components/upgrade-section.tsx index 1e1d0a6..7642cb6 100644 --- a/src/app/(dashboard)/settings/billing/components/upgrade-section.tsx +++ b/src/app/(dashboard)/settings/billing/components/upgrade-section.tsx @@ -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(null); +const PLAN_ICONS: Record = { + starter: , + pro: , + enterprise: , +}; - 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(currentPeriod ?? "monthly"); + const [paytrToken, setPaytrToken] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [loadingPlan, setLoadingPlan] = useState(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 ( - - -
- - Pro Plan Aktif -
- - Tüm özelliklere sınırsız erişiminiz var. - -
- -
- -
-
-
- ); - } - return ( - - -
- {pro.name} Plana Geç - Önerilen -
- {pro.description} -
- -
    - {pro.features.map((f) => ( -
  • - - {f} -
  • - ))} -
- -
- {pro.price.toLocaleString("tr-TR")} - ₺ / ay + <> +
+ {/* Başlık + toggle */} +
+
+

Planınızı seçin

+

+ İhtiyacınıza göre istediğiniz zaman değiştirebilirsiniz. +

+
+
+ + +
-
- - -
- - + {/* 3 plan kartı */} +
+ {plans.map((plan) => { + const isCurrent = currentPlan === plan.id; + const displayPrice = planPriceDisplay(plan, period); + const isLoading = isPending && loadingPlan === plan.id; + + return ( + + {plan.highlight && ( +
+ + Popüler + +
+ )} + + +
+ {PLAN_ICONS[plan.id]} + {plan.name} + {isCurrent && ( + + Mevcut + + )} +
+
+ + {displayPrice.toLocaleString("tr-TR")} + + ₺/ay +
+ {period === "yearly" ? ( +

+ {plan.yearly.toLocaleString("tr-TR")} ₺ yıllık faturalandırılır +

+ ) : ( +

{plan.description}

+ )} +
+ + +
    + {plan.features.map((f) => ( +
  • + + {f} +
  • + ))} +
+ + {isCurrent ? ( +
+ + +
+ ) : ( + + )} +
+
+ ); + })} +
+
+ + {paytrEnabled && ( + { + setDialogOpen(open); + if (!open) setPaytrToken(null); + }} + > + + + Güvenli Ödeme + + + {paytrToken && ( +