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",
const RESOURCE_LABELS: Record<string, string> = {
properties: "Aktif İlan",
customers: "Müşteri",
members: "Ekip üyesi",
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"}
<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>
{isPro && expiryDate && (
<p className="text-xs text-muted-foreground">{expiryDate} tarihine kadar geçerli</p>
{isPaid && expiryDate && (
<p className="text-xs text-muted-foreground mt-0.5">
{expiryDate} tarihine kadar geçerli
</p>
)}
</CardHeader>
<CardContent className="space-y-3">
{!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="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>
<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 && (
{isUnlimited ? (
<div className="h-1 rounded-full bg-primary/20" />
) : (
<Progress
value={pct}
className={`h-1.5 ${reached ? "[&>div]:bg-destructive" : ""}`}
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) {
const plans = Object.values(PLAN_CATALOG);
function handleCheckout(planId: string) {
if (paytrEnabled) {
setLoadingPlan(planId);
startTransition(async () => {
try {
await startCheckoutAction(formData);
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ı. Tekrar deneyin.");
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 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>
<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>
);
}
<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>
{/* 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 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>
<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>
<CardDescription>{pro.description}</CardDescription>
)}
<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="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}
<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>
<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>
<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ç
{isCurrent ? (
<div className="space-y-2">
<Button variant="outline" className="w-full" disabled>
Mevcut Planınız
</Button>
</form>
<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>
)}
</>
);
}
+11 -5
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}
period={ctx.settings?.planPeriod ?? null}
expiresAt={ctx.settings?.planExpiresAt ?? null}
usage={usage}
/>
<UpgradeSection currentPlan={plan} />
</div>
<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>
@@ -0,0 +1,41 @@
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: {tenantId}T{timestamp}{random}P{plan}X{period}
const tenantId = merchantOid.split("T")[0];
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 {
await activatePlanInDb(tenantId, plan, "paytr", period);
} 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" },
});
}
+41 -10
View File
@@ -2,7 +2,8 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { BellRinging, Checks } from '@/lib/icons';
import Link from "next/link";
import { BellRinging, Checks, Phone, Envelope } from '@/lib/icons';
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -46,8 +47,8 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
const propertyMap = Object.fromEntries(properties.map((p) => [p.$id, p]));
const searchMap = Object.fromEntries(searches.map((s) => [s.$id, s]));
const pendingCount = items.filter((m) => !m.notified).length;
const visible = tab === "pending" ? items.filter((m) => !m.notified) : items;
const pendingCount = items.filter((m) => m.notified !== true).length;
const visible = tab === "pending" ? items.filter((m) => m.notified !== true) : items;
function openBreakdown(m: PropertyMatch) {
setSelectedMatch(m);
@@ -157,7 +158,7 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
<tr
key={m.$id}
className={`border-b last:border-0 ${
!m.notified ? "bg-amber-50/40 dark:bg-amber-950/10" : "hover:bg-muted/30"
m.notified !== true ? "bg-amber-50/40 dark:bg-amber-950/10" : "hover:bg-muted/30"
}`}
>
<td className="p-3">
@@ -165,21 +166,51 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
</td>
<td className="p-3 cursor-pointer" onClick={() => openBreakdown(m)}>
<p className="font-medium">{customer?.name ?? m.customerId}</p>
<div className="flex flex-col gap-0.5 mt-0.5" onClick={(e) => e.stopPropagation()}>
{customer?.phone && (
<p className="text-xs text-muted-foreground">{customer.phone}</p>
<a
href={`tel:${customer.phone}`}
className="flex items-center gap-1 text-xs text-foreground hover:text-primary transition-colors w-fit"
>
<Phone className="size-3 shrink-0" />
{customer.phone}
</a>
)}
{customer?.email && (
<a
href={`mailto:${customer.email}`}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors w-fit"
>
<Envelope className="size-3 shrink-0" />
{customer.email}
</a>
)}
</div>
</td>
<td className="p-3 cursor-pointer max-w-[180px]" onClick={() => openBreakdown(m)}>
<p className="truncate">{property?.title ?? m.propertyId}</p>
<td className="p-3 max-w-[200px]">
<Link
href={`/properties/${m.propertyId}`}
className="font-medium hover:text-primary hover:underline transition-colors truncate block"
onClick={(e) => e.stopPropagation()}
>
{property?.title ?? m.propertyId}
</Link>
{property?.city && (
<p className="text-xs text-muted-foreground">{property.city}</p>
<p className="text-xs text-muted-foreground">
{property.city}{property.district ? `, ${property.district}` : ""}
</p>
)}
{property?.price != null && (
<p className="text-xs font-medium text-foreground/80">
{property.price.toLocaleString("tr-TR")} {property.currency ?? "₺"}
</p>
)}
</td>
<td className="p-3 text-muted-foreground hidden md:table-cell">
{new Date(m.$createdAt).toLocaleDateString("tr-TR")}
</td>
<td className="p-3">
{m.notified ? (
{m.notified === true ? (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Checks className="size-3.5 text-green-500" />
Bildirildi
@@ -192,7 +223,7 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
)}
</td>
<td className="p-3">
{!m.notified && (
{m.notified !== true && (
<Button
size="sm"
variant="ghost"
+22 -6
View File
@@ -40,10 +40,23 @@ export function NavMain({
}) {
const pathname = usePathname()
// Check if any subitem is active to determine if parent should be open
// Returns the url of the best-matching subitem (longest prefix wins).
// Handles detail pages: /customers/123 correctly activates the /customers subitem
// without accidentally activating /customers/searches (a sibling).
function bestSubMatch(subitems: { url: string }[]): string | null {
let best: string | null = null
for (const sub of subitems) {
if (pathname === sub.url || pathname.startsWith(sub.url + "/")) {
if (!best || sub.url.length > best.length) best = sub.url
}
}
return best
}
const shouldBeOpen = (item: typeof items[0]) => {
if (item.isActive) return true
return item.items?.some(subItem => pathname === subItem.url) || false
if (item.items) return bestSubMatch(item.items) !== null
return false
}
return (
@@ -58,7 +71,9 @@ export function NavMain({
className="group/collapsible"
>
<SidebarMenuItem>
{item.items?.length ? (
{item.items?.length ? (() => {
const activeSubUrl = bestSubMatch(item.items)
return (
<>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} className="cursor-pointer">
@@ -71,7 +86,7 @@ export function NavMain({
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild className="cursor-pointer" isActive={pathname === subItem.url}>
<SidebarMenuSubButton asChild className="cursor-pointer" isActive={activeSubUrl === subItem.url}>
<Link
href={subItem.url}
target={(item.title === "Auth Pages" || item.title === "Errors") ? "_blank" : undefined}
@@ -90,8 +105,9 @@ export function NavMain({
</SidebarMenuSub>
</CollapsibleContent>
</>
) : (
<SidebarMenuButton asChild tooltip={item.title} className="cursor-pointer" isActive={pathname === item.url}>
)
})() : (
<SidebarMenuButton asChild tooltip={item.title} className="cursor-pointer" isActive={pathname === item.url || pathname.startsWith(item.url + "/")}>
<Link href={item.url}>
{item.icon && <item.icon />}
<span>{item.title}</span>
+22 -16
View File
@@ -103,10 +103,15 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
const mappedCount = filteredProperties.filter((p) => p.mapLat != null && p.mapLng != null).length;
const parsedImageIds = useMemo(() =>
Object.fromEntries(properties.map((p) => [p.$id, parseImageIds(p.imageIds)])),
[properties],
);
const { withImages, withoutImages } = useMemo(() => ({
withImages: filteredProperties.filter((p) => parseImageIds(p.imageIds).length > 0),
withoutImages: filteredProperties.filter((p) => parseImageIds(p.imageIds).length === 0),
}), [filteredProperties]);
withImages: filteredProperties.filter((p) => (parsedImageIds[p.$id]?.length ?? 0) > 0),
withoutImages: filteredProperties.filter((p) => (parsedImageIds[p.$id]?.length ?? 0) === 0),
}), [filteredProperties, parsedImageIds]);
const STATUS_TABS: Array<{ key: PropertyStatus | "all"; label: string }> = [
{ key: "all", label: "Tümü" },
@@ -220,13 +225,13 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
{withoutImages.length > 0 && (
<ImageGroupHeader hasImages count={withImages.length} />
)}
{withImages.map((p) => <MobilePropertyCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />)}
{withImages.map((p) => <MobilePropertyCard key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />)}
</>
)}
{withImages.length > 0 && withoutImages.length > 0 && (
<ImageGroupHeader hasImages={false} count={withoutImages.length} />
)}
{withoutImages.map((p) => <MobilePropertyCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />)}
{withoutImages.map((p) => <MobilePropertyCard key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />)}
</div>
{/* Desktop table — hidden on mobile */}
@@ -260,7 +265,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
</TableRow>
)}
{withImages.map((p) => (
<PropertyTableRow key={p.$id} p={p} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
<PropertyTableRow key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
))}
{withImages.length > 0 && withoutImages.length > 0 && (
<TableRow className="hover:bg-transparent">
@@ -270,7 +275,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
</TableRow>
)}
{withoutImages.map((p) => (
<PropertyTableRow key={p.$id} p={p} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
<PropertyTableRow key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
))}
</TableBody>
</Table>
@@ -289,7 +294,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
{withoutImages.length > 0 && <ImageGroupHeader hasImages count={withImages.length} />}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
{withImages.map((p) => (
<GalleryCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />
<GalleryCard key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />
))}
</div>
</>
@@ -302,7 +307,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
{withoutImages.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
{withoutImages.map((p) => (
<GalleryCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />
<GalleryCard key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />
))}
</div>
)}
@@ -321,7 +326,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
<p className="text-muted-foreground text-sm text-center py-10">Henüz ilan yok.</p>
)}
{filteredProperties.map((p) => {
const coverImageId = parseImageIds(p.imageIds)[0];
const coverImageId = (parsedImageIds[p.$id] ?? [])[0];
const hasCoords = p.mapLat != null && p.mapLng != null;
const isSelected = selectedId === p.$id;
return (
@@ -419,12 +424,12 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
}
/* ── Galeri kartı ── */
function GalleryCard({ p, openEdit, setDeleteTarget }: {
function GalleryCard({ p, imageIds, openEdit, setDeleteTarget }: {
p: Property;
imageIds: string[];
openEdit: (p: Property) => void;
setDeleteTarget: (p: Property) => void;
}) {
const imageIds = parseImageIds(p.imageIds);
const [idx, setIdx] = useState(0);
const [lightbox, setLightbox] = useState(false);
const safeIdx = Math.min(idx, imageIds.length - 1);
@@ -550,12 +555,13 @@ function ImageGroupHeader({ hasImages, count }: { hasImages: boolean; count: num
}
/* ── Mobil kart ── */
function MobilePropertyCard({ p, openEdit, setDeleteTarget }: {
function MobilePropertyCard({ p, imageIds, openEdit, setDeleteTarget }: {
p: Property;
imageIds: string[];
openEdit: (p: Property) => void;
setDeleteTarget: (p: Property) => void;
}) {
const coverImageId = parseImageIds(p.imageIds)[0];
const coverImageId = imageIds[0];
return (
<div className="rounded-lg border bg-card overflow-hidden">
{coverImageId && (
@@ -611,14 +617,14 @@ function MobilePropertyCard({ p, openEdit, setDeleteTarget }: {
}
/* ── Tablo satırı ── */
function PropertyTableRow({ p, rowRefs, openEdit, setDeleteTarget, router }: {
function PropertyTableRow({ p, imageIds, rowRefs, openEdit, setDeleteTarget, router }: {
p: Property;
imageIds: string[];
rowRefs: React.MutableRefObject<Record<string, HTMLTableRowElement>>;
openEdit: (p: Property) => void;
setDeleteTarget: (p: Property) => void;
router: ReturnType<typeof useRouter>;
}) {
const imageIds = parseImageIds(p.imageIds);
const coverImageId = imageIds[0];
const [lightbox, setLightbox] = useState(false);
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, PencilSimple, MapPin, ImageSquare, Plus, FileText, ClipboardText, Users, CaretLeft, CaretRight, X } from '@/lib/icons';
import { ArrowLeft, PencilSimple, MapPin, ImageSquare, Plus, FileText, ClipboardText, Users, CaretLeft, CaretRight, X, Phone, Envelope, BellRinging, Checks } from '@/lib/icons';
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -19,6 +19,8 @@ import {
ACTIVITY_TYPE_LABELS,
type Property,
type PropertyMatch,
type CustomerSearch,
type Customer,
type Activity,
} from "@/lib/appwrite/schema";
import { getPropertyImageUrl } from "@/lib/appwrite/storage-utils";
@@ -28,7 +30,8 @@ interface Props {
matches: PropertyMatch[];
activities: Activity[];
imageIds: string[];
customerMap: Record<string, string>;
customers: Customer[];
searches: CustomerSearch[];
}
const STATUS_COLOR: Record<string, string> = {
@@ -39,7 +42,9 @@ const STATUS_COLOR: Record<string, string> = {
rezerve: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
};
export function PropertyDetailClient({ property, matches, activities, imageIds, customerMap }: Props) {
export function PropertyDetailClient({ property, matches, activities, imageIds, customers, searches }: Props) {
const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c]));
const searchMap = Object.fromEntries(searches.map((s) => [s.$id, s]));
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [imageOpen, setImageOpen] = useState(false);
@@ -219,15 +224,19 @@ export function PropertyDetailClient({ property, matches, activities, imageIds,
{matches.length === 0 ? (
<p className="text-sm text-muted-foreground">Henüz eşleşme yok.</p>
) : (
<div className="space-y-2">
{matches.map((m) => (
<div key={m.$id} className="flex items-center justify-between gap-2 border-b pb-2 last:border-0 last:pb-0">
<span className="text-sm truncate">
{customerMap[m.customerId] ?? m.customerId}
</span>
<ScoreBadge score={m.score} />
</div>
))}
<div className="space-y-3">
{matches.map((m) => {
const customer = customerMap[m.customerId];
const search = searchMap[m.searchId];
return (
<MatchCard
key={m.$id}
match={m}
customer={customer}
search={search}
/>
);
})}
</div>
)}
</TabsContent>
@@ -375,6 +384,109 @@ function Spec({ label, value }: { label: string; value: string }) {
}
function MatchCard({ match, customer, search }: {
match: PropertyMatch;
customer?: Customer;
search?: CustomerSearch;
}) {
const fmt = (n: number) => n.toLocaleString("tr-TR");
const criteria: string[] = [];
if (search?.listingType) criteria.push(LISTING_TYPE_LABELS[search.listingType] ?? search.listingType);
if (search?.propertyTypes) {
try {
const types = JSON.parse(search.propertyTypes) as string[];
types.forEach((t) => criteria.push(PROPERTY_TYPE_LABELS[t as keyof typeof PROPERTY_TYPE_LABELS] ?? t));
} catch { /* ignore */ }
}
if (search?.roomCounts) {
try {
const rooms = JSON.parse(search.roomCounts) as string[];
if (rooms.length) criteria.push(`${rooms.join(", ")} oda`);
} catch { /* ignore */ }
}
if (search?.minPrice != null || search?.maxPrice != null) {
const min = search.minPrice != null ? `${fmt(search.minPrice)}` : null;
const max = search.maxPrice != null ? `${fmt(search.maxPrice)}` : null;
criteria.push(min && max ? `${min} ${max}` : min ? `min ${min}` : `max ${max!}`);
}
if (search?.minM2 != null || search?.maxM2 != null) {
const min = search.minM2 != null ? `${search.minM2}` : null;
const max = search.maxM2 != null ? `${search.maxM2}` : null;
criteria.push(min && max ? `${min} ${max}` : min ? `min ${min}` : `max ${max!}`);
}
if (search?.cities) {
try {
const cities = JSON.parse(search.cities) as string[];
if (cities.length) criteria.push(cities.join(", "));
} catch { /* ignore */ }
}
if (search?.districts) {
try {
const districts = JSON.parse(search.districts) as string[];
if (districts.length) criteria.push(districts.join(", "));
} catch { /* ignore */ }
}
return (
<div className={`rounded-lg border p-3 space-y-2.5 ${match.notified !== true ? "bg-amber-50/30 dark:bg-amber-950/10 border-amber-200/50 dark:border-amber-800/30" : ""}`}>
{/* Üst satır: isim + puan + bildirim durumu */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Link
href={`/customers/${match.customerId}`}
className="font-medium text-sm hover:text-primary hover:underline transition-colors"
>
{customer?.name ?? match.customerId}
</Link>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
{customer?.phone && (
<a href={`tel:${customer.phone}`} className="flex items-center gap-1 text-xs text-foreground/80 hover:text-primary transition-colors">
<Phone className="size-3 shrink-0" />
{customer.phone}
</a>
)}
{customer?.email && (
<a href={`mailto:${customer.email}`} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors">
<Envelope className="size-3 shrink-0" />
{customer.email}
</a>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{match.notified === true ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Checks className="size-3.5 text-green-500" />
Bildirildi
</span>
) : (
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
<BellRinging className="size-3.5" />
Bekliyor
</span>
)}
<ScoreBadge score={match.score} />
</div>
</div>
{/* Arama kriterleri */}
{criteria.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1.5 border-t border-dashed">
{criteria.map((c, i) => (
<span key={i} className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{c}
</span>
))}
</div>
)}
{search?.notes && (
<p className="text-xs text-muted-foreground italic border-t pt-1.5">{search.notes}</p>
)}
</div>
);
}
function ScoreBadge({ score }: { score?: number | null }) {
const s = score ?? 0;
const color =
@@ -17,7 +17,12 @@ import {
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import { PropertyImageUploader } from "./property-image-uploader";
import { updatePropertyImagesAction } from "@/lib/appwrite/property-actions";
import {
updatePropertyImagesAction,
} from "@/lib/appwrite/property-actions";
import {
deletePropertyImageAndSyncAction,
} from "@/lib/appwrite/storage-actions";
import { useIsMobile } from "@/hooks/use-mobile";
interface PropertyImageSheetProps {
@@ -39,6 +44,20 @@ export function PropertyImageSheet({
const [imageIds, setImageIds] = useState<string[]>(initialImageIds);
const [saving, setSaving] = useState(false);
// Silme: storage + DB'yi tek seferde güncelle, "Kaydet" gerektirmez
async function handleDeleteImage(fileId: string): Promise<boolean> {
const remaining = imageIds.filter((id) => id !== fileId);
const result = await deletePropertyImageAndSyncAction(propertyId, fileId, remaining);
if (result.ok) {
setImageIds(remaining);
onSuccess?.();
return true;
}
toast.error(result.error ?? "Fotoğraf silinemedi.");
return false;
}
// Yeni yüklenen fotoğrafları kaydet
async function handleSave() {
setSaving(true);
const result = await updatePropertyImagesAction(propertyId, imageIds);
@@ -56,12 +75,13 @@ export function PropertyImageSheet({
<div className="space-y-4">
<PropertyImageUploader
name="imageIds"
initialImageIds={initialImageIds}
initialImageIds={imageIds}
onChangeIds={setImageIds}
onDeleteImage={handleDeleteImage}
/>
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
İptal
Kapat
</Button>
<Button type="button" onClick={handleSave} disabled={saving}>
{saving ? "Kaydediliyor…" : "Kaydet"}
@@ -15,11 +15,12 @@ interface PropertyImageUploaderProps {
name: string;
initialImageIds?: string[];
onChangeIds?: (ids: string[]) => void;
onDeleteImage?: (fileId: string) => Promise<boolean>;
maxImages?: number;
isOwner?: boolean;
}
export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds, maxImages, isOwner }: PropertyImageUploaderProps) {
export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds, onDeleteImage, maxImages, isOwner }: PropertyImageUploaderProps) {
const [imageIds, setImageIds] = useState<string[]>(initialImageIds);
const [queue, setQueue] = useState<UploadingFile[]>([]);
const [dragging, setDragging] = useState(false);
@@ -119,6 +120,11 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds,
}
async function handleDelete(fileId: string) {
if (onDeleteImage) {
const ok = await onDeleteImage(fileId);
if (ok) updateIds((prev) => prev.filter((id) => id !== fileId));
return;
}
const result = await deletePropertyImageAction(fileId);
if (result.ok) {
updateIds((prev) => prev.filter((id) => id !== fileId));
@@ -195,7 +201,7 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds,
{imageIds.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{imageIds.map((id) => (
<div key={id} className="group relative aspect-video overflow-hidden rounded-md border bg-muted">
<div key={id} className="relative aspect-video overflow-hidden rounded-md border bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getPropertyImagePreviewUrl(id, 400, 300)}
@@ -206,7 +212,7 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds,
type="button"
onClick={() => handleDelete(id)}
disabled={busy}
className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded-full bg-black/60 text-white transition-colors hover:bg-red-500 group-hover:flex"
className="absolute right-1 top-1 flex size-6 items-center justify-center rounded-full bg-black/50 text-white transition-colors hover:bg-red-500"
>
<X className="size-3" />
</button>
+3 -2
View File
@@ -1,5 +1,6 @@
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { Query } from "node-appwrite";
@@ -34,7 +35,7 @@ async function setActiveTenantCookie(tenantId: string) {
} catch { /* ignore in middleware context */ }
}
export async function getActiveContext(): Promise<ActiveContext | null> {
export const getActiveContext = cache(async function getActiveContext(): Promise<ActiveContext | null> {
const user = await getCurrentUser();
if (!user) return null;
@@ -107,4 +108,4 @@ export async function getActiveContext(): Promise<ActiveContext | null> {
role,
memberCount,
};
}
});
+1 -1
View File
@@ -32,7 +32,7 @@ async function setSessionCookie(secret: string, expire: string) {
(await cookies()).set(APPWRITE_SESSION_COOKIE, secret, {
path: "/",
httpOnly: true,
sameSite: "strict",
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
expires: new Date(expire),
});
+20 -5
View File
@@ -1,5 +1,6 @@
import "server-only";
import { unstable_cache } from "next/cache";
import { Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type Activity, type Customer, type Property } from "./schema";
@@ -83,7 +84,7 @@ export type DashboardStats = {
takipMusteri: Customer[];
};
export async function getDashboardStats(tenantId: string): Promise<DashboardStats> {
async function _getDashboardStats(tenantId: string): Promise<DashboardStats> {
const { tablesDB } = createAdminClient();
const today = new Date();
@@ -106,14 +107,12 @@ export async function getDashboardStats(tenantId: string): Promise<DashboardStat
Query.limit(500),
];
// Split into two sequential batches to avoid overwhelming the connection pool
// with too many simultaneous requests to the same Appwrite host.
const [
aktifRes, satilikRes, kiralikRes,
musteriRes, aliciRes, kiraciRes, yatirimciRes,
eslesmelerRes, buAyRes,
aktivitelerRes, ilanlarRes,
ilanTrendRes, musteriTrendRes, aktiviteTrendRes,
aktifPropAllRes,
takipRes, rezerveRes,
] = await Promise.all([
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.equal("status", "aktif")]) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.equal("status", "aktif"), Query.equal("listingType", "satilik")]) }),
@@ -124,6 +123,14 @@ export async function getDashboardStats(tenantId: string): Promise<DashboardStat
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: base([Query.equal("type", "yatirimci")]) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.propertyMatches, queries: base([Query.equal("notified", false)]) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.greaterThanEqual("$createdAt", thisMonthStart.toISOString())]) }),
]);
const [
aktivitelerRes, ilanlarRes,
ilanTrendRes, musteriTrendRes, aktiviteTrendRes,
aktifPropAllRes,
takipRes, rezerveRes,
] = await Promise.all([
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.activities, queries: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(6)] }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(5)] }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: trend(TABLES.properties) }),
@@ -156,3 +163,11 @@ export async function getDashboardStats(tenantId: string): Promise<DashboardStat
takipMusteri: parse<Customer>(takipRes),
};
}
// Cache per-tenant for 30 seconds. Reduces Appwrite round-trips on slow
// connections; revalidated automatically when the tag is purged on mutations.
export const getDashboardStats = unstable_cache(
_getDashboardStats,
["dashboard-stats"],
{ revalidate: 30 },
);
+10 -2
View File
@@ -46,11 +46,19 @@ export async function matchPropertyToSearches(
],
});
const existingId = existing.rows.length > 0 ? existing.rows[0].$id : null;
const existingRow = existing.rows.length > 0 ? existing.rows[0] : null;
const existingId = existingRow?.$id ?? null;
if (score >= SCORE_THRESHOLD) {
if (existingId) {
await tablesDB.updateRow(DATABASE_ID, TABLES.propertyMatches, existingId, { score });
// Preserve the existing notified status — only refresh score.
// Without this, some Appwrite backends reset omitted fields to their
// schema default (false), which would un-notify already-actioned matches.
const existingNotified = (existingRow as { notified?: boolean }).notified ?? false;
await tablesDB.updateRow(DATABASE_ID, TABLES.propertyMatches, existingId, {
score,
notified: existingNotified,
});
} else {
await tablesDB.createRow(
DATABASE_ID,
+12 -13
View File
@@ -7,22 +7,21 @@ export const PLAN_LIMIT_EXCEEDED = "PLAN_LIMIT_EXCEEDED";
const INF = Number.POSITIVE_INFINITY;
export const PLAN_LIMITS: Record<TenantPlan, Record<PlanResource, number>> = {
free: {
properties: 5,
customers: 10,
members: 2,
presentations: 3,
},
pro: {
properties: INF,
customers: INF,
members: INF,
presentations: INF,
},
free: { properties: 15, customers: 25, members: 1, presentations: 5 },
starter: { properties: 50, customers: 100, members: 3, presentations: 20 },
pro: { properties: 200, customers: 500, members: 10, presentations: 50 },
enterprise: { properties: INF, customers: INF, members: INF, presentations: INF },
};
export const PLAN_NAMES: Record<TenantPlan, string> = {
free: "Ücretsiz",
starter: "Başlangıç",
pro: "Pro",
enterprise: "Enterprise",
};
export const RESOURCE_LABELS: Record<PlanResource, string> = {
properties: "ilan",
properties: "aktif ilan",
customers: "müşteri",
members: "ekip üyesi",
presentations: "sunum",
+19 -5
View File
@@ -8,18 +8,19 @@ import type { TenantContext } from "./tenant-guard";
import type { TenantPlan } from "./schema";
import {
PLAN_LIMITS,
PLAN_NAMES,
RESOURCE_LABELS,
type PlanResource,
} from "./plan-limits-shared";
export type { PlanResource } from "./plan-limits-shared";
export { PLAN_LIMIT_EXCEEDED, PLAN_LIMITS, RESOURCE_LABELS } from "./plan-limits-shared";
export { PLAN_LIMIT_EXCEEDED, PLAN_LIMITS, PLAN_NAMES, RESOURCE_LABELS } from "./plan-limits-shared";
const INF = Number.POSITIVE_INFINITY;
export function getEffectivePlan(ctx: TenantContext): TenantPlan {
const plan = (ctx.settings?.plan as TenantPlan | undefined) ?? "free";
if (plan === "pro") {
if (plan !== "free") {
const expires = ctx.settings?.planExpiresAt;
if (expires && new Date(expires).getTime() < Date.now()) return "free";
}
@@ -40,10 +41,22 @@ async function countResource(tenantId: string, resource: PlanResource): Promise<
presentations: TABLES.presentations,
};
const queries = [Query.equal("tenantId", tenantId), Query.limit(1)];
// Satılan/kiralanan ilanlar limite dahil değil — sadece aktif portföy sayılır
if (resource === "properties") {
queries.push(
Query.or([
Query.equal("status", "aktif"),
Query.equal("status", "rezerve"),
Query.equal("status", "pasif"),
]) as unknown as ReturnType<typeof Query.equal>,
);
}
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: tableMap[resource],
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
queries,
});
return result.total;
}
@@ -85,6 +98,7 @@ export function isPlanLimitError(e: unknown): e is PlanLimitError {
return e instanceof PlanLimitError;
}
export function planLimitMessage(resource: PlanResource, limit: number): string {
return `Ücretsiz planda en fazla ${limit} ${RESOURCE_LABELS[resource]} ekleyebilirsiniz. Pro'ya geçerek sınırı kaldırın.`;
export function planLimitMessage(resource: PlanResource, limit: number, plan?: TenantPlan): string {
const planName = plan ? (PLAN_NAMES[plan] ?? "Mevcut") : "Mevcut";
return `${planName} planda en fazla ${limit} ${RESOURCE_LABELS[resource]} ekleyebilirsiniz. Planınızı yükselterek devam edin.`;
}
+3 -1
View File
@@ -33,7 +33,8 @@ export type SystemRow = {
type Row = SystemRow;
export type TenantPlan = "free" | "pro";
export type TenantPlan = "free" | "starter" | "pro" | "enterprise";
export type PlanPeriod = "monthly" | "yearly";
export type TenantRole = "owner" | "admin" | "member";
export type InviteRole = "admin" | "member";
@@ -205,6 +206,7 @@ export interface TenantSettings extends Row {
address?: string;
createdBy: string;
plan?: TenantPlan;
planPeriod?: PlanPeriod;
planExpiresAt?: string;
planProvider?: string;
}
+3 -2
View File
@@ -1,5 +1,6 @@
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import {
Account,
@@ -59,11 +60,11 @@ export async function createSessionClient() {
};
}
export async function getCurrentUser() {
export const getCurrentUser = cache(async function getCurrentUser() {
try {
const { account } = await createSessionClient();
return await account.get();
} catch {
return null;
}
}
});
+32 -1
View File
@@ -1,6 +1,8 @@
"use server";
import { BUCKETS } from "./schema";
import { revalidatePath } from "next/cache";
import { BUCKETS, DATABASE_ID, TABLES } from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
@@ -17,3 +19,32 @@ export async function deletePropertyImageAction(fileId: string): Promise<DeleteR
return { ok: false, error: "Fotoğraf silinemedi." };
}
}
// Storage'dan siler VE property kaydındaki imageIds listesini günceller.
// PropertyImageSheet'ten çağrılır — ayrıca "Kaydet"e basma gerekmez.
export async function deletePropertyImageAndSyncAction(
propertyId: string,
fileId: string,
remainingIds: string[],
): Promise<DeleteResult> {
await requireTenant();
const { storage, tablesDB } = createAdminClient();
try {
await storage.deleteFile(BUCKETS.propertyImages, fileId);
} catch {
return { ok: false, error: "Fotoğraf silinemedi." };
}
try {
await tablesDB.updateRow(DATABASE_ID, TABLES.properties, propertyId, {
imageIds: JSON.stringify(remainingIds),
});
} catch {
return { ok: false, error: "Fotoğraf silindi ancak ilan kaydı güncellenemedi." };
}
revalidatePath(`/properties/${propertyId}`);
revalidatePath("/properties");
return { ok: true };
}
+66 -7
View File
@@ -1,17 +1,17 @@
"use server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type TenantPlan } from "./schema";
import { DATABASE_ID, TABLES, type TenantPlan, type PlanPeriod } from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard";
import { getEffectivePlan } from "./plan-limits";
import { PLAN_CATALOG } from "./subscription-types";
import { PLAN_CATALOG, planPrice } from "./subscription-types";
import { getShopierPlanUrl, isShopierEnabled } from "../payments/shopier";
import { createPolarCheckout, isPolarEnabled } from "../payments/polar";
const PRO_VALIDITY_DAYS = 30;
import { getPayTRToken } from "../payments/paytr";
function generateOrderId(): string {
const t = Date.now().toString(36);
@@ -19,15 +19,15 @@ function generateOrderId(): string {
return `ord_${t}_${r}`;
}
// Webhook handler'larından da çağrılabilir — provider "polar" | "shopier" | "mock"
// Webhook handler'larından da çağrılabilir — provider "polar" | "shopier" | "paytr" | "mock"
export async function activatePlanInDb(
tenantId: string,
plan: TenantPlan,
provider: string,
period: PlanPeriod = "monthly",
): Promise<void> {
const { tablesDB } = createAdminClient();
// tenant_settings satırını bul
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
@@ -36,11 +36,13 @@ export async function activatePlanInDb(
const row = result.rows[0];
if (!row) throw new Error(`tenant_settings bulunamadı: ${tenantId}`);
const validityDays = period === "yearly" ? 365 : 30;
const now = new Date();
const expires = new Date(now.getTime() + PRO_VALIDITY_DAYS * 24 * 60 * 60 * 1000);
const expires = new Date(now.getTime() + validityDays * 24 * 60 * 60 * 1000);
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, row.$id, {
plan,
planPeriod: period,
planExpiresAt: expires.toISOString(),
planProvider: provider,
});
@@ -118,6 +120,63 @@ export async function startPolarCheckoutAction(formData: FormData): Promise<void
redirect(checkout.url);
}
// ── PayTR checkout ─────────────────────────────────────────────────────────────
export async function getPayTRTokenAction(formData: FormData): Promise<string> {
const planId = String(formData.get("plan") ?? "") as Exclude<TenantPlan, "free">;
const period = String(formData.get("period") ?? "monthly") as PlanPeriod;
if (!["starter", "pro", "enterprise"].includes(planId)) throw new Error("Geçersiz plan.");
if (!["monthly", "yearly"].includes(period)) throw new Error("Geçersiz dönem.");
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
const catalog = PLAN_CATALOG[planId];
const price = planPrice(catalog, period);
// APP_URL: server-to-server callback (HTTPS zorunlu, prod'da veya ngrok)
// APP_BROWSER_URL: browser redirect (dev'de localhost, prod'da aynı domain)
const appUrl = process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3000";
const browserUrl = (process.env.APP_BROWSER_URL ?? appUrl).replace(/\/$/, "");
const requestHeaders = await headers();
const userIp =
requestHeaders.get("x-forwarded-for")?.split(",")[0]?.trim() ??
requestHeaders.get("x-real-ip") ??
"1.2.3.4";
const timestamp = Date.now().toString();
const random = Math.random().toString(36).slice(2, 8);
// Uppercase harfler separator — tenantId (lowercase a-z0-9) hiçbir zaman içermez
// Format: {tenantId}T{timestamp}{random}P{plan}X{period}
const merchantOid = `${ctx.tenantId}T${timestamp}${random}P${planId}X${period}`;
const userBasket: Array<[string, string, number]> = [
[
`KovakEmlak ${catalog.name} (${period === "yearly" ? "Yıllık" : "Aylık"})`,
price.toFixed(2),
1,
],
];
const officeName = ctx.settings?.officeName ?? ctx.user.name ?? "Müşteri";
return getPayTRToken({
merchantOid,
email: ctx.user.email,
userName: officeName,
userAddress: ctx.settings?.address ?? "Türkiye",
userPhone: ctx.settings?.phone ?? "05000000000",
paymentAmountKurus: price * 100,
userBasket,
userIp,
notifyUrl: `${appUrl}/api/payments/paytr/callback`,
okUrl: `${browserUrl}/settings/billing?upgraded=1`,
failUrl: `${browserUrl}/settings/billing?failed=1`,
});
}
// ── Unified entry point ────────────────────────────────────────────────────────
export async function startCheckoutAction(formData: FormData): Promise<void> {
+53 -21
View File
@@ -1,43 +1,75 @@
import type { TenantPlan } from "./schema";
import type { TenantPlan, PlanPeriod } from "./schema";
export type PlanCatalogEntry = {
id: TenantPlan;
name: string;
price: number;
currency: string;
description: string;
monthly: number;
yearly: number; // 10 ay ücreti (2 ay bedava)
currency: string;
features: string[];
highlight?: boolean;
};
export const PLAN_CATALOG: Record<TenantPlan, PlanCatalogEntry> = {
free: {
id: "free",
name: "Ücretsiz",
price: 0,
export const PLAN_CATALOG: Record<Exclude<TenantPlan, "free">, PlanCatalogEntry> = {
starter: {
id: "starter",
name: "Başlangıç",
description: "Büyüyen emlak ofisleri için.",
monthly: 599,
yearly: 5990,
currency: "TRY",
description: "Küçük ofisler ve deneme için.",
features: [
"5 ilan",
"10 müşteri",
"3 sunum",
"2 ekip üyesi",
"Temel destek",
"50 aktif ilan",
"100 müşteri",
"20 sunum",
"3 ekip üyesi",
"10 fotoğraf / ilan",
"Otomatik eşleştirme",
],
},
pro: {
id: "pro",
name: "Pro",
price: 499,
description: "Kurumsal emlak ofisleri için.",
monthly: 999,
yearly: 9990,
currency: "TRY",
description: "Büyüyen emlak ofisleri için sınırsız kullanım.",
highlight: true,
features: [
"Sınırsız ilan",
"Sınırsız müşteri",
"Sınırsız sunum",
"Sınırsız ekip üyesi",
"Otomatik eşleştirme",
"200 aktif ilan",
"500 müşteri",
"50 sunum",
"10 ekip üyesi",
"20 fotoğraf / ilan",
"Yatırımcı portalı",
"Öncelikli destek",
],
},
enterprise: {
id: "enterprise",
name: "Enterprise",
description: "Büyük zincir ofisler için sınırsız kullanım.",
monthly: 1799,
yearly: 17990,
currency: "TRY",
features: [
"Sınırsız aktif ilan",
"Sınırsız müşteri",
"Sınırsız sunum",
"Sınırsız ekip üyesi",
"Sınırsız fotoğraf",
"Yatırımcı portalı",
"Öncelikli & özel destek",
],
},
};
export function planPrice(entry: PlanCatalogEntry, period: PlanPeriod): number {
return period === "yearly" ? entry.yearly : entry.monthly;
}
export function planPriceDisplay(entry: PlanCatalogEntry, period: PlanPeriod): number {
// Yıllık seçildiğinde aylık karşılığını göster
return period === "yearly" ? Math.round(entry.yearly / 12) : entry.monthly;
}
+3 -2
View File
@@ -1,5 +1,6 @@
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { ID, Permission, Query, Role } from "node-appwrite";
@@ -86,7 +87,7 @@ async function setTenantCookie(tenantId: string) {
} catch { /* ignore */ }
}
export async function requireTenant(): Promise<TenantContext> {
export const requireTenant = cache(async function requireTenant(): Promise<TenantContext> {
const user = await getCurrentUser();
if (!user) throw new Error("UNAUTHENTICATED");
@@ -141,7 +142,7 @@ export async function requireTenant(): Promise<TenantContext> {
settings,
memberCount,
};
}
});
export function requireRole(ctx: TenantContext, allowed: TenantRole[]): void {
if (!allowed.includes(ctx.role)) {
+130
View File
@@ -0,0 +1,130 @@
import "server-only";
import { createHmac } from "crypto";
const MERCHANT_ID = process.env.PAYTR_MERCHANT_ID ?? "";
const MERCHANT_KEY = process.env.PAYTR_MERCHANT_KEY ?? "";
const MERCHANT_SALT = process.env.PAYTR_MERCHANT_SALT ?? "";
export function isPayTREnabled(): boolean {
return (
process.env.PAYMENT_PROVIDER === "paytr" &&
Boolean(MERCHANT_ID) &&
Boolean(MERCHANT_KEY) &&
Boolean(MERCHANT_SALT)
);
}
type GetPayTRTokenParams = {
merchantOid: string;
email: string;
userName: string;
userAddress: string;
userPhone: string;
paymentAmountKurus: number;
userBasket: Array<[string, string, number]>; // [name, price_tl_string, qty]
userIp: string;
notifyUrl: string;
okUrl: string;
failUrl: string;
};
export async function getPayTRToken(params: GetPayTRTokenParams): Promise<string> {
const {
merchantOid,
email,
userName,
userAddress,
userPhone,
paymentAmountKurus,
userBasket,
userIp,
notifyUrl,
okUrl,
failUrl,
} = params;
const noInstallment = "1";
const maxInstallment = "0";
const currency = "TL";
const testMode = process.env.PAYTR_TEST_MODE === "1" ? "1" : "0";
const userBasketEncoded = Buffer.from(JSON.stringify(userBasket)).toString("base64");
// Hash sırası — token üretirken: salt EN SONDA
const hashStr =
MERCHANT_ID +
userIp +
merchantOid +
email +
String(paymentAmountKurus) +
userBasketEncoded +
noInstallment +
maxInstallment +
currency +
testMode +
MERCHANT_SALT;
const paytrToken = createHmac("sha256", MERCHANT_KEY).update(hashStr).digest("base64");
const body = new URLSearchParams({
merchant_id: MERCHANT_ID,
user_ip: userIp,
merchant_oid: merchantOid,
email,
payment_amount: String(paymentAmountKurus),
paytr_token: paytrToken,
user_basket: userBasketEncoded,
debug_on: process.env.PAYTR_TEST_MODE === "1" ? "1" : "0",
no_installment: noInstallment,
max_installment: maxInstallment,
currency,
test_mode: testMode,
notify_url: notifyUrl,
merchant_ok_url: okUrl,
merchant_fail_url: failUrl,
user_name: userName,
user_address: userAddress,
user_phone: userPhone,
});
const res = await fetch("https://www.paytr.com/odeme/api/get-token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!res.ok) throw new Error(`PayTR token isteği başarısız: HTTP ${res.status}`);
const json = (await res.json()) as { status: string; token?: string; reason?: string };
if (json.status !== "success" || !json.token) {
throw new Error(`PayTR hatası: ${json.reason ?? "Bilinmeyen hata"}`);
}
return json.token;
}
export function verifyPayTRCallback(params: {
merchantOid: string;
status: string;
totalAmount: string;
hash: string;
}): boolean {
if (!MERCHANT_KEY || !MERCHANT_SALT) return false;
// Hash sırası — callback'te: salt 2. sırada (token üretmedekinden farklı!)
const hashStr = params.merchantOid + MERCHANT_SALT + params.status + params.totalAmount;
const expected = createHmac("sha256", MERCHANT_KEY).update(hashStr).digest("base64");
try {
const a = Buffer.from(expected);
const b = Buffer.from(params.hash);
if (a.length !== b.length) return false;
// timing-safe comparison
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
return diff === 0;
} catch {
return false;
}
}
+25 -25
View File
@@ -4,22 +4,15 @@ import { Query } from "node-appwrite";
import { createAdminClient } from "./appwrite/server";
import { DATABASE_ID, TABLES, type TenantPlan } from "./appwrite/schema";
import { PLAN_NAMES } from "./appwrite/plan-limits-shared";
const INF = Number.POSITIVE_INFINITY;
export const PLAN_LIMITS = {
free: {
properties: 15,
customers: 25,
presentations: 5,
teamMembers: 1,
propertyImages: 5,
},
pro: {
properties: Infinity,
customers: Infinity,
presentations: Infinity,
teamMembers: Infinity,
propertyImages: Infinity,
},
free: { properties: 15, customers: 25, presentations: 5, teamMembers: 1, propertyImages: 5 },
starter: { properties: 50, customers: 100, presentations: 20, teamMembers: 3, propertyImages: 10 },
pro: { properties: 200, customers: 500, presentations: 50, teamMembers: 10, propertyImages: 20 },
enterprise: { properties: INF, customers: INF, presentations: INF, teamMembers: INF, propertyImages: INF },
} as const;
export type LimitKey = keyof typeof PLAN_LIMITS.free;
@@ -34,19 +27,20 @@ export async function checkLimit(
const planKey: TenantPlan = plan ?? "free";
const limit = PLAN_LIMITS[planKey][key];
if (limit === Infinity) return { allowed: true, current: 0, limit: Infinity };
if (limit === INF) return { allowed: true, current: 0, limit: INF };
const { tablesDB, teams } = createAdminClient();
let current = 0;
if (key === "properties") {
const r = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.properties,
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
});
current = r.total;
// Satılan/kiralanan ilanlar limite dahil değil.
// Query.or() bypasses compound indexes; 3 parallel count queries each use [tenantId, status].
const [aktifRes, rezerveRes, pasifRes] = await Promise.all([
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: [Query.equal("tenantId", tenantId), Query.equal("status", "aktif"), Query.limit(1)] }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: [Query.equal("tenantId", tenantId), Query.equal("status", "rezerve"), Query.limit(1)] }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: [Query.equal("tenantId", tenantId), Query.equal("status", "pasif"), Query.limit(1)] }),
]);
current = aktifRes.total + rezerveRes.total + pasifRes.total;
} else if (key === "customers") {
const r = await tablesDB.listRows({
databaseId: DATABASE_ID,
@@ -71,7 +65,7 @@ export async function checkLimit(
export function limitLabel(key: LimitKey): string {
const labels: Record<LimitKey, string> = {
properties: "ilan",
properties: "aktif ilan",
customers: "müşteri",
presentations: "sunum",
teamMembers: "ekip üyesi",
@@ -80,9 +74,15 @@ export function limitLabel(key: LimitKey): string {
return labels[key];
}
export function limitErrorMessage(key: LimitKey, limit: number, role: "owner" | "admin" | "member"): string {
export function limitErrorMessage(
key: LimitKey,
limit: number,
role: "owner" | "admin" | "member",
plan?: TenantPlan | null,
): string {
if (role === "owner") {
return `Ücretsiz planda en fazla ${limit} ${limitLabel(key)} ekleyebilirsiniz. Pro'ya geçerek limitsiz kullanın.`;
const planName = plan ? (PLAN_NAMES[plan] ?? "Mevcut") : "Mevcut";
return `${planName} planda en fazla ${limit} ${limitLabel(key)} ekleyebilirsiniz. Planınızı yükselterek devam edin.`;
}
return `Bu çalışma alanı ${limitLabel(key)} limitine ulaştı. Yöneticinizle iletişime geçin.`;
}