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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export default async function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
// overflow-x-hidden: Recharts SVG ve diğer absolute-positioned elementlerin
|
// 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.
|
// 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 */}
|
{/* Başlık */}
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
TABLES,
|
TABLES,
|
||||||
type Property,
|
type Property,
|
||||||
type PropertyMatch,
|
type PropertyMatch,
|
||||||
|
type CustomerSearch,
|
||||||
type Activity,
|
type Activity,
|
||||||
} from "@/lib/appwrite/schema";
|
} from "@/lib/appwrite/schema";
|
||||||
import { createAdminClient } from "@/lib/appwrite/server";
|
import { createAdminClient } from "@/lib/appwrite/server";
|
||||||
@@ -35,7 +36,7 @@ export default async function PropertyDetailPage({ params }: Props) {
|
|||||||
|
|
||||||
if (property.tenantId !== ctx.tenantId) notFound();
|
if (property.tenantId !== ctx.tenantId) notFound();
|
||||||
|
|
||||||
const [customers, matchesResult, activitiesResult] = await Promise.all([
|
const [customers, matchesResult, activitiesResult, searchesResult] = await Promise.all([
|
||||||
listCustomers(ctx.tenantId),
|
listCustomers(ctx.tenantId),
|
||||||
tablesDB.listRows({
|
tablesDB.listRows({
|
||||||
databaseId: DATABASE_ID,
|
databaseId: DATABASE_ID,
|
||||||
@@ -57,11 +58,19 @@ export default async function PropertyDetailPage({ params }: Props) {
|
|||||||
Query.limit(20),
|
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 matches = JSON.parse(JSON.stringify(matchesResult.rows)) as PropertyMatch[];
|
||||||
const activities = JSON.parse(JSON.stringify(activitiesResult.rows)) as Activity[];
|
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);
|
const imageIds = parseImageIds(property.imageIds);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,7 +79,8 @@ export default async function PropertyDetailPage({ params }: Props) {
|
|||||||
matches={matches}
|
matches={matches}
|
||||||
activities={activities}
|
activities={activities}
|
||||||
imageIds={imageIds}
|
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");
|
if (!user) redirect("/sign-in");
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-muted-foreground text-sm">Profil ayarları</p>
|
<p className="text-muted-foreground text-sm">Profil ayarları</p>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{user.name || user.email}</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{user.name || user.email}</h1>
|
||||||
|
|||||||
@@ -1,72 +1,107 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Crown, Lightning } from '@/lib/icons';
|
import { Crown, Lightning, Star } from "@/lib/icons";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { Progress } from "@/components/ui/progress";
|
||||||
import type { PlanUsage } from "@/lib/appwrite/plan-limits";
|
import type { PlanUsage } from "@/lib/appwrite/plan-limits";
|
||||||
import type { TenantPlan } from "@/lib/appwrite/schema";
|
import type { TenantPlan, PlanPeriod } from "@/lib/appwrite/schema";
|
||||||
import { RESOURCE_LABELS } from "@/lib/appwrite/plan-limits-shared";
|
import { PLAN_NAMES } from "@/lib/appwrite/plan-limits-shared";
|
||||||
|
|
||||||
const LIMIT_LABELS: Record<string, string> = {
|
const RESOURCE_LABELS: Record<string, string> = {
|
||||||
properties: "İlan",
|
properties: "Aktif İlan",
|
||||||
customers: "Müşteri",
|
customers: "Müşteri",
|
||||||
members: "Ekip üyesi",
|
members: "Ekip Üyesi",
|
||||||
presentations: "Sunum",
|
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({
|
export function CurrentPlanCard({
|
||||||
plan,
|
plan,
|
||||||
|
period,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
usage,
|
usage,
|
||||||
}: {
|
}: {
|
||||||
plan: TenantPlan;
|
plan: TenantPlan;
|
||||||
|
period?: PlanPeriod | null;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
usage: PlanUsage["usage"];
|
usage: PlanUsage["usage"];
|
||||||
}) {
|
}) {
|
||||||
const isPro = plan === "pro";
|
const isPaid = plan !== "free";
|
||||||
const expiryDate = expiresAt ? new Date(expiresAt).toLocaleDateString("tr-TR") : null;
|
const expiryDate = expiresAt ? new Date(expiresAt).toLocaleDateString("tr-TR") : null;
|
||||||
|
const periodLabel = period === "yearly" ? "Yıllık" : period === "monthly" ? "Aylık" : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardContent className="pt-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<CardTitle className="text-base font-semibold">Mevcut Plan</CardTitle>
|
{/* Plan bilgisi */}
|
||||||
<Badge
|
<div className="flex items-center gap-3">
|
||||||
variant={isPro ? "default" : "secondary"}
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary shrink-0">
|
||||||
className="gap-1"
|
<PlanIcon plan={plan} />
|
||||||
>
|
</div>
|
||||||
{isPro ? <Crown className="h-3 w-3" /> : <Lightning className="h-3 w-3" />}
|
<div>
|
||||||
{isPro ? "Pro" : "Ücretsiz"}
|
<div className="flex items-center gap-2">
|
||||||
</Badge>
|
<span className="font-semibold text-sm">{PLAN_NAMES[plan]} Plan</span>
|
||||||
</div>
|
<Badge variant={planBadgeVariant(plan)} className="gap-1 text-[11px]">
|
||||||
{isPro && expiryDate && (
|
<PlanIcon plan={plan} />
|
||||||
<p className="text-xs text-muted-foreground">{expiryDate} tarihine kadar geçerli</p>
|
{PLAN_NAMES[plan]}
|
||||||
)}
|
{periodLabel && <span className="opacity-70">· {periodLabel}</span>}
|
||||||
</CardHeader>
|
</Badge>
|
||||||
<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" : ""}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,96 +1,248 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { startCheckoutAction, downgradeToFreeAction } from "@/lib/appwrite/subscription-actions";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import type { TenantPlan } from "@/lib/appwrite/schema";
|
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 PLAN_ICONS: Record<string, React.ReactNode> = {
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
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;
|
export function UpgradeSection({
|
||||||
const isPro = currentPlan === "pro";
|
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);
|
||||||
try {
|
|
||||||
await startCheckoutAction(formData);
|
function handleCheckout(planId: string) {
|
||||||
} catch (e) {
|
if (paytrEnabled) {
|
||||||
if (e instanceof Error && e.message.includes("NEXT_REDIRECT")) throw e;
|
setLoadingPlan(planId);
|
||||||
toast.error("Ödeme başlatılamadı. Tekrar deneyin.");
|
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() {
|
async function handleDowngrade() {
|
||||||
const fd = new FormData();
|
|
||||||
try {
|
try {
|
||||||
await downgradeToFreeAction();
|
await downgradeToFreeAction();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && e.message.includes("NEXT_REDIRECT")) throw e;
|
if (e instanceof Error && e.message.includes("NEXT_REDIRECT")) throw e;
|
||||||
void fd;
|
toast.error("İşlem başarısız.");
|
||||||
toast.error("İşlem başarısız. Tekrar deneyin.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Card className="border-primary/30">
|
<>
|
||||||
<CardHeader>
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-2">
|
{/* Başlık + toggle */}
|
||||||
<CardTitle className="text-base">{pro.name} Plana Geç</CardTitle>
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
<Badge variant="secondary" className="text-xs">Önerilen</Badge>
|
<div>
|
||||||
</div>
|
<h2 className="text-xl font-bold tracking-tight">Planınızı seçin</h2>
|
||||||
<CardDescription>{pro.description}</CardDescription>
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
</CardHeader>
|
İhtiyacınıza göre istediğiniz zaman değiştirebilirsiniz.
|
||||||
<CardContent className="space-y-4">
|
</p>
|
||||||
<ul className="space-y-1.5">
|
</div>
|
||||||
{pro.features.map((f) => (
|
<div className="flex items-center gap-1 rounded-full border bg-muted/40 p-1 text-sm">
|
||||||
<li key={f} className="flex items-center gap-2 text-sm">
|
<button
|
||||||
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
|
onClick={() => setPeriod("monthly")}
|
||||||
{f}
|
className={`rounded-full px-5 py-1.5 font-medium transition-all ${
|
||||||
</li>
|
period === "monthly"
|
||||||
))}
|
? "bg-background shadow-sm text-foreground"
|
||||||
</ul>
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
<div className="flex items-baseline gap-1">
|
>
|
||||||
<span className="text-3xl font-bold">{pro.price.toLocaleString("tr-TR")}</span>
|
Aylık
|
||||||
<span className="text-muted-foreground text-sm">₺ / ay</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
<form ref={formRef} action={handleUpgrade}>
|
{/* 3 plan kartı */}
|
||||||
<input type="hidden" name="plan" value="pro" />
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<Button type="submit" className="w-full gap-2 cursor-pointer">
|
{plans.map((plan) => {
|
||||||
<Crown className="h-4 w-4" />
|
const isCurrent = currentPlan === plan.id;
|
||||||
Pro'ya Geç
|
const displayPrice = planPriceDisplay(plan, period);
|
||||||
</Button>
|
const isLoading = isPending && loadingPlan === plan.id;
|
||||||
</form>
|
|
||||||
</CardContent>
|
return (
|
||||||
</Card>
|
<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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { CheckCircle, XCircle } from '@/lib/icons';
|
|||||||
|
|
||||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
import { getEffectivePlan, getPlanUsage } from "@/lib/appwrite/plan-limits";
|
import { getEffectivePlan, getPlanUsage } from "@/lib/appwrite/plan-limits";
|
||||||
|
import { isPayTREnabled } from "@/lib/payments/paytr";
|
||||||
import { CurrentPlanCard } from "./components/current-plan-card";
|
import { CurrentPlanCard } from "./components/current-plan-card";
|
||||||
import { UpgradeSection } from "./components/upgrade-section";
|
import { UpgradeSection } from "./components/upgrade-section";
|
||||||
|
|
||||||
@@ -33,11 +34,12 @@ export default async function BillingPage({
|
|||||||
|
|
||||||
const plan = getEffectivePlan(ctx);
|
const plan = getEffectivePlan(ctx);
|
||||||
const { usage } = await getPlanUsage(ctx);
|
const { usage } = await getPlanUsage(ctx);
|
||||||
|
const paytrEnabled = isPayTREnabled();
|
||||||
|
|
||||||
const officeName = ctx.settings?.officeName ?? "Ofis";
|
const officeName = ctx.settings?.officeName ?? "Ofis";
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-muted-foreground text-sm">{officeName}</p>
|
<p className="text-muted-foreground text-sm">{officeName}</p>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Plan & Faturalama</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Plan & Faturalama</h1>
|
||||||
@@ -49,7 +51,7 @@ export default async function BillingPage({
|
|||||||
{upgraded && (
|
{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">
|
<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" />
|
<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'ya hoş geldiniz.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -60,14 +62,18 @@ export default async function BillingPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<CurrentPlanCard
|
||||||
<CurrentPlanCard
|
plan={plan}
|
||||||
plan={plan}
|
period={ctx.settings?.planPeriod ?? null}
|
||||||
expiresAt={ctx.settings?.planExpiresAt ?? null}
|
expiresAt={ctx.settings?.planExpiresAt ?? null}
|
||||||
usage={usage}
|
usage={usage}
|
||||||
/>
|
/>
|
||||||
<UpgradeSection currentPlan={plan} />
|
|
||||||
</div>
|
<UpgradeSection
|
||||||
|
currentPlan={plan}
|
||||||
|
currentPeriod={ctx.settings?.planPeriod ?? null}
|
||||||
|
paytrEnabled={paytrEnabled}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default async function MembersPage() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-muted-foreground text-sm">{ctx.settings?.officeName ?? "Çalışma alanı"}</p>
|
<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>
|
<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";
|
const officeName = ctx.settings?.officeName ?? "Ofis";
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-muted-foreground text-sm">{officeName}</p>
|
<p className="text-muted-foreground text-sm">{officeName}</p>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Ofis Bilgileri</h1>
|
<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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
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 propertyMap = Object.fromEntries(properties.map((p) => [p.$id, p]));
|
||||||
const searchMap = Object.fromEntries(searches.map((s) => [s.$id, s]));
|
const searchMap = Object.fromEntries(searches.map((s) => [s.$id, s]));
|
||||||
|
|
||||||
const pendingCount = items.filter((m) => !m.notified).length;
|
const pendingCount = items.filter((m) => m.notified !== true).length;
|
||||||
const visible = tab === "pending" ? items.filter((m) => !m.notified) : items;
|
const visible = tab === "pending" ? items.filter((m) => m.notified !== true) : items;
|
||||||
|
|
||||||
function openBreakdown(m: PropertyMatch) {
|
function openBreakdown(m: PropertyMatch) {
|
||||||
setSelectedMatch(m);
|
setSelectedMatch(m);
|
||||||
@@ -157,7 +158,7 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
|
|||||||
<tr
|
<tr
|
||||||
key={m.$id}
|
key={m.$id}
|
||||||
className={`border-b last:border-0 ${
|
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">
|
<td className="p-3">
|
||||||
@@ -165,21 +166,51 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3 cursor-pointer" onClick={() => openBreakdown(m)}>
|
<td className="p-3 cursor-pointer" onClick={() => openBreakdown(m)}>
|
||||||
<p className="font-medium">{customer?.name ?? m.customerId}</p>
|
<p className="font-medium">{customer?.name ?? m.customerId}</p>
|
||||||
{customer?.phone && (
|
<div className="flex flex-col gap-0.5 mt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||||
<p className="text-xs text-muted-foreground">{customer.phone}</p>
|
{customer?.phone && (
|
||||||
)}
|
<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>
|
||||||
<td className="p-3 cursor-pointer max-w-[180px]" onClick={() => openBreakdown(m)}>
|
<td className="p-3 max-w-[200px]">
|
||||||
<p className="truncate">{property?.title ?? m.propertyId}</p>
|
<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 && (
|
{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>
|
||||||
<td className="p-3 text-muted-foreground hidden md:table-cell">
|
<td className="p-3 text-muted-foreground hidden md:table-cell">
|
||||||
{new Date(m.$createdAt).toLocaleDateString("tr-TR")}
|
{new Date(m.$createdAt).toLocaleDateString("tr-TR")}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{m.notified ? (
|
{m.notified === true ? (
|
||||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
<Checks className="size-3.5 text-green-500" />
|
<Checks className="size-3.5 text-green-500" />
|
||||||
Bildirildi
|
Bildirildi
|
||||||
@@ -192,7 +223,7 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{!m.notified && (
|
{m.notified !== true && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
+52
-36
@@ -40,10 +40,23 @@ export function NavMain({
|
|||||||
}) {
|
}) {
|
||||||
const pathname = usePathname()
|
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]) => {
|
const shouldBeOpen = (item: typeof items[0]) => {
|
||||||
if (item.isActive) return true
|
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 (
|
return (
|
||||||
@@ -58,40 +71,43 @@ export function NavMain({
|
|||||||
className="group/collapsible"
|
className="group/collapsible"
|
||||||
>
|
>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
{item.items?.length ? (
|
{item.items?.length ? (() => {
|
||||||
<>
|
const activeSubUrl = bestSubMatch(item.items)
|
||||||
<CollapsibleTrigger asChild>
|
return (
|
||||||
<SidebarMenuButton tooltip={item.title} className="cursor-pointer">
|
<>
|
||||||
{item.icon && <item.icon />}
|
<CollapsibleTrigger asChild>
|
||||||
<span>{item.title}</span>
|
<SidebarMenuButton tooltip={item.title} className="cursor-pointer">
|
||||||
<CaretRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
{item.icon && <item.icon />}
|
||||||
</SidebarMenuButton>
|
<span>{item.title}</span>
|
||||||
</CollapsibleTrigger>
|
<CaretRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||||
<CollapsibleContent>
|
</SidebarMenuButton>
|
||||||
<SidebarMenuSub>
|
</CollapsibleTrigger>
|
||||||
{item.items?.map((subItem) => (
|
<CollapsibleContent>
|
||||||
<SidebarMenuSubItem key={subItem.title}>
|
<SidebarMenuSub>
|
||||||
<SidebarMenuSubButton asChild className="cursor-pointer" isActive={pathname === subItem.url}>
|
{item.items?.map((subItem) => (
|
||||||
<Link
|
<SidebarMenuSubItem key={subItem.title}>
|
||||||
href={subItem.url}
|
<SidebarMenuSubButton asChild className="cursor-pointer" isActive={activeSubUrl === subItem.url}>
|
||||||
target={(item.title === "Auth Pages" || item.title === "Errors") ? "_blank" : undefined}
|
<Link
|
||||||
rel={(item.title === "Auth Pages" || item.title === "Errors") ? "noopener noreferrer" : undefined}
|
href={subItem.url}
|
||||||
>
|
target={(item.title === "Auth Pages" || item.title === "Errors") ? "_blank" : undefined}
|
||||||
<span>{subItem.title}</span>
|
rel={(item.title === "Auth Pages" || item.title === "Errors") ? "noopener noreferrer" : undefined}
|
||||||
{subItem.badge != null && subItem.badge > 0 && (
|
>
|
||||||
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
|
<span>{subItem.title}</span>
|
||||||
{subItem.badge > 99 ? "99+" : subItem.badge}
|
{subItem.badge != null && subItem.badge > 0 && (
|
||||||
</span>
|
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
|
||||||
)}
|
{subItem.badge > 99 ? "99+" : subItem.badge}
|
||||||
</Link>
|
</span>
|
||||||
</SidebarMenuSubButton>
|
)}
|
||||||
</SidebarMenuSubItem>
|
</Link>
|
||||||
))}
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSub>
|
</SidebarMenuSubItem>
|
||||||
</CollapsibleContent>
|
))}
|
||||||
</>
|
</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}>
|
<Link href={item.url}>
|
||||||
{item.icon && <item.icon />}
|
{item.icon && <item.icon />}
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
|
|||||||
@@ -103,10 +103,15 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
|||||||
|
|
||||||
const mappedCount = filteredProperties.filter((p) => p.mapLat != null && p.mapLng != null).length;
|
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(() => ({
|
const { withImages, withoutImages } = useMemo(() => ({
|
||||||
withImages: filteredProperties.filter((p) => parseImageIds(p.imageIds).length > 0),
|
withImages: filteredProperties.filter((p) => (parsedImageIds[p.$id]?.length ?? 0) > 0),
|
||||||
withoutImages: filteredProperties.filter((p) => parseImageIds(p.imageIds).length === 0),
|
withoutImages: filteredProperties.filter((p) => (parsedImageIds[p.$id]?.length ?? 0) === 0),
|
||||||
}), [filteredProperties]);
|
}), [filteredProperties, parsedImageIds]);
|
||||||
|
|
||||||
const STATUS_TABS: Array<{ key: PropertyStatus | "all"; label: string }> = [
|
const STATUS_TABS: Array<{ key: PropertyStatus | "all"; label: string }> = [
|
||||||
{ key: "all", label: "Tümü" },
|
{ key: "all", label: "Tümü" },
|
||||||
@@ -220,13 +225,13 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
|||||||
{withoutImages.length > 0 && (
|
{withoutImages.length > 0 && (
|
||||||
<ImageGroupHeader hasImages count={withImages.length} />
|
<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 && (
|
{withImages.length > 0 && withoutImages.length > 0 && (
|
||||||
<ImageGroupHeader hasImages={false} count={withoutImages.length} />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Desktop table — hidden on mobile */}
|
{/* Desktop table — hidden on mobile */}
|
||||||
@@ -260,7 +265,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{withImages.map((p) => (
|
{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 && (
|
{withImages.length > 0 && withoutImages.length > 0 && (
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
@@ -270,7 +275,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{withoutImages.map((p) => (
|
{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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -289,7 +294,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
|||||||
{withoutImages.length > 0 && <ImageGroupHeader hasImages count={withImages.length} />}
|
{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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
|
||||||
{withImages.map((p) => (
|
{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>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -302,7 +307,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
|||||||
{withoutImages.length > 0 && (
|
{withoutImages.length > 0 && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
|
||||||
{withoutImages.map((p) => (
|
{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>
|
</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>
|
<p className="text-muted-foreground text-sm text-center py-10">Henüz ilan yok.</p>
|
||||||
)}
|
)}
|
||||||
{filteredProperties.map((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 hasCoords = p.mapLat != null && p.mapLng != null;
|
||||||
const isSelected = selectedId === p.$id;
|
const isSelected = selectedId === p.$id;
|
||||||
return (
|
return (
|
||||||
@@ -419,12 +424,12 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Galeri kartı ── */
|
/* ── Galeri kartı ── */
|
||||||
function GalleryCard({ p, openEdit, setDeleteTarget }: {
|
function GalleryCard({ p, imageIds, openEdit, setDeleteTarget }: {
|
||||||
p: Property;
|
p: Property;
|
||||||
|
imageIds: string[];
|
||||||
openEdit: (p: Property) => void;
|
openEdit: (p: Property) => void;
|
||||||
setDeleteTarget: (p: Property) => void;
|
setDeleteTarget: (p: Property) => void;
|
||||||
}) {
|
}) {
|
||||||
const imageIds = parseImageIds(p.imageIds);
|
|
||||||
const [idx, setIdx] = useState(0);
|
const [idx, setIdx] = useState(0);
|
||||||
const [lightbox, setLightbox] = useState(false);
|
const [lightbox, setLightbox] = useState(false);
|
||||||
const safeIdx = Math.min(idx, imageIds.length - 1);
|
const safeIdx = Math.min(idx, imageIds.length - 1);
|
||||||
@@ -550,12 +555,13 @@ function ImageGroupHeader({ hasImages, count }: { hasImages: boolean; count: num
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Mobil kart ── */
|
/* ── Mobil kart ── */
|
||||||
function MobilePropertyCard({ p, openEdit, setDeleteTarget }: {
|
function MobilePropertyCard({ p, imageIds, openEdit, setDeleteTarget }: {
|
||||||
p: Property;
|
p: Property;
|
||||||
|
imageIds: string[];
|
||||||
openEdit: (p: Property) => void;
|
openEdit: (p: Property) => void;
|
||||||
setDeleteTarget: (p: Property) => void;
|
setDeleteTarget: (p: Property) => void;
|
||||||
}) {
|
}) {
|
||||||
const coverImageId = parseImageIds(p.imageIds)[0];
|
const coverImageId = imageIds[0];
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-card overflow-hidden">
|
<div className="rounded-lg border bg-card overflow-hidden">
|
||||||
{coverImageId && (
|
{coverImageId && (
|
||||||
@@ -611,14 +617,14 @@ function MobilePropertyCard({ p, openEdit, setDeleteTarget }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tablo satırı ── */
|
/* ── Tablo satırı ── */
|
||||||
function PropertyTableRow({ p, rowRefs, openEdit, setDeleteTarget, router }: {
|
function PropertyTableRow({ p, imageIds, rowRefs, openEdit, setDeleteTarget, router }: {
|
||||||
p: Property;
|
p: Property;
|
||||||
|
imageIds: string[];
|
||||||
rowRefs: React.MutableRefObject<Record<string, HTMLTableRowElement>>;
|
rowRefs: React.MutableRefObject<Record<string, HTMLTableRowElement>>;
|
||||||
openEdit: (p: Property) => void;
|
openEdit: (p: Property) => void;
|
||||||
setDeleteTarget: (p: Property) => void;
|
setDeleteTarget: (p: Property) => void;
|
||||||
router: ReturnType<typeof useRouter>;
|
router: ReturnType<typeof useRouter>;
|
||||||
}) {
|
}) {
|
||||||
const imageIds = parseImageIds(p.imageIds);
|
|
||||||
const coverImageId = imageIds[0];
|
const coverImageId = imageIds[0];
|
||||||
const [lightbox, setLightbox] = useState(false);
|
const [lightbox, setLightbox] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
ACTIVITY_TYPE_LABELS,
|
ACTIVITY_TYPE_LABELS,
|
||||||
type Property,
|
type Property,
|
||||||
type PropertyMatch,
|
type PropertyMatch,
|
||||||
|
type CustomerSearch,
|
||||||
|
type Customer,
|
||||||
type Activity,
|
type Activity,
|
||||||
} from "@/lib/appwrite/schema";
|
} from "@/lib/appwrite/schema";
|
||||||
import { getPropertyImageUrl } from "@/lib/appwrite/storage-utils";
|
import { getPropertyImageUrl } from "@/lib/appwrite/storage-utils";
|
||||||
@@ -28,7 +30,8 @@ interface Props {
|
|||||||
matches: PropertyMatch[];
|
matches: PropertyMatch[];
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
imageIds: string[];
|
imageIds: string[];
|
||||||
customerMap: Record<string, string>;
|
customers: Customer[];
|
||||||
|
searches: CustomerSearch[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
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",
|
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 router = useRouter();
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [imageOpen, setImageOpen] = useState(false);
|
const [imageOpen, setImageOpen] = useState(false);
|
||||||
@@ -219,15 +224,19 @@ export function PropertyDetailClient({ property, matches, activities, imageIds,
|
|||||||
{matches.length === 0 ? (
|
{matches.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">Henüz eşleşme yok.</p>
|
<p className="text-sm text-muted-foreground">Henüz eşleşme yok.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{matches.map((m) => (
|
{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">
|
const customer = customerMap[m.customerId];
|
||||||
<span className="text-sm truncate">
|
const search = searchMap[m.searchId];
|
||||||
{customerMap[m.customerId] ?? m.customerId}
|
return (
|
||||||
</span>
|
<MatchCard
|
||||||
<ScoreBadge score={m.score} />
|
key={m.$id}
|
||||||
</div>
|
match={m}
|
||||||
))}
|
customer={customer}
|
||||||
|
search={search}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</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} m²` : null;
|
||||||
|
const max = search.maxM2 != null ? `${search.maxM2} m²` : 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 }) {
|
function ScoreBadge({ score }: { score?: number | null }) {
|
||||||
const s = score ?? 0;
|
const s = score ?? 0;
|
||||||
const color =
|
const color =
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ import {
|
|||||||
} from "@/components/ui/drawer";
|
} from "@/components/ui/drawer";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PropertyImageUploader } from "./property-image-uploader";
|
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";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
|
||||||
interface PropertyImageSheetProps {
|
interface PropertyImageSheetProps {
|
||||||
@@ -39,6 +44,20 @@ export function PropertyImageSheet({
|
|||||||
const [imageIds, setImageIds] = useState<string[]>(initialImageIds);
|
const [imageIds, setImageIds] = useState<string[]>(initialImageIds);
|
||||||
const [saving, setSaving] = useState(false);
|
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() {
|
async function handleSave() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const result = await updatePropertyImagesAction(propertyId, imageIds);
|
const result = await updatePropertyImagesAction(propertyId, imageIds);
|
||||||
@@ -56,12 +75,13 @@ export function PropertyImageSheet({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PropertyImageUploader
|
<PropertyImageUploader
|
||||||
name="imageIds"
|
name="imageIds"
|
||||||
initialImageIds={initialImageIds}
|
initialImageIds={imageIds}
|
||||||
onChangeIds={setImageIds}
|
onChangeIds={setImageIds}
|
||||||
|
onDeleteImage={handleDeleteImage}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2 pt-2 border-t">
|
<div className="flex justify-end gap-2 pt-2 border-t">
|
||||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||||
İptal
|
Kapat
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={handleSave} disabled={saving}>
|
<Button type="button" onClick={handleSave} disabled={saving}>
|
||||||
{saving ? "Kaydediliyor…" : "Kaydet"}
|
{saving ? "Kaydediliyor…" : "Kaydet"}
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ interface PropertyImageUploaderProps {
|
|||||||
name: string;
|
name: string;
|
||||||
initialImageIds?: string[];
|
initialImageIds?: string[];
|
||||||
onChangeIds?: (ids: string[]) => void;
|
onChangeIds?: (ids: string[]) => void;
|
||||||
|
onDeleteImage?: (fileId: string) => Promise<boolean>;
|
||||||
maxImages?: number;
|
maxImages?: number;
|
||||||
isOwner?: boolean;
|
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 [imageIds, setImageIds] = useState<string[]>(initialImageIds);
|
||||||
const [queue, setQueue] = useState<UploadingFile[]>([]);
|
const [queue, setQueue] = useState<UploadingFile[]>([]);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
@@ -119,6 +120,11 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds,
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(fileId: string) {
|
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);
|
const result = await deletePropertyImageAction(fileId);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
updateIds((prev) => prev.filter((id) => id !== fileId));
|
updateIds((prev) => prev.filter((id) => id !== fileId));
|
||||||
@@ -195,7 +201,7 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds,
|
|||||||
{imageIds.length > 0 && (
|
{imageIds.length > 0 && (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{imageIds.map((id) => (
|
{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 */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={getPropertyImagePreviewUrl(id, 400, 300)}
|
src={getPropertyImagePreviewUrl(id, 400, 300)}
|
||||||
@@ -206,7 +212,7 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds,
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDelete(id)}
|
onClick={() => handleDelete(id)}
|
||||||
disabled={busy}
|
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" />
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
|
import { cache } from "react";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { Query } from "node-appwrite";
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ async function setActiveTenantCookie(tenantId: string) {
|
|||||||
} catch { /* ignore in middleware context */ }
|
} 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();
|
const user = await getCurrentUser();
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
@@ -107,4 +108,4 @@ export async function getActiveContext(): Promise<ActiveContext | null> {
|
|||||||
role,
|
role,
|
||||||
memberCount,
|
memberCount,
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ async function setSessionCookie(secret: string, expire: string) {
|
|||||||
(await cookies()).set(APPWRITE_SESSION_COOKIE, secret, {
|
(await cookies()).set(APPWRITE_SESSION_COOKIE, secret, {
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "strict",
|
sameSite: "lax",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
expires: new Date(expire),
|
expires: new Date(expire),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
|
import { unstable_cache } from "next/cache";
|
||||||
import { Query } from "node-appwrite";
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
import { DATABASE_ID, TABLES, type Activity, type Customer, type Property } from "./schema";
|
import { DATABASE_ID, TABLES, type Activity, type Customer, type Property } from "./schema";
|
||||||
@@ -83,7 +84,7 @@ export type DashboardStats = {
|
|||||||
takipMusteri: Customer[];
|
takipMusteri: Customer[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getDashboardStats(tenantId: string): Promise<DashboardStats> {
|
async function _getDashboardStats(tenantId: string): Promise<DashboardStats> {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -106,14 +107,12 @@ export async function getDashboardStats(tenantId: string): Promise<DashboardStat
|
|||||||
Query.limit(500),
|
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 [
|
const [
|
||||||
aktifRes, satilikRes, kiralikRes,
|
aktifRes, satilikRes, kiralikRes,
|
||||||
musteriRes, aliciRes, kiraciRes, yatirimciRes,
|
musteriRes, aliciRes, kiraciRes, yatirimciRes,
|
||||||
eslesmelerRes, buAyRes,
|
eslesmelerRes, buAyRes,
|
||||||
aktivitelerRes, ilanlarRes,
|
|
||||||
ilanTrendRes, musteriTrendRes, aktiviteTrendRes,
|
|
||||||
aktifPropAllRes,
|
|
||||||
takipRes, rezerveRes,
|
|
||||||
] = await Promise.all([
|
] = 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")]) }),
|
||||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.equal("status", "aktif"), Query.equal("listingType", "satilik")]) }),
|
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.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.propertyMatches, queries: base([Query.equal("notified", false)]) }),
|
||||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.greaterThanEqual("$createdAt", thisMonthStart.toISOString())]) }),
|
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.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: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(5)] }),
|
||||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: trend(TABLES.properties) }),
|
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),
|
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 },
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 (score >= SCORE_THRESHOLD) {
|
||||||
if (existingId) {
|
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 {
|
} else {
|
||||||
await tablesDB.createRow(
|
await tablesDB.createRow(
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
|
|||||||
@@ -7,23 +7,22 @@ export const PLAN_LIMIT_EXCEEDED = "PLAN_LIMIT_EXCEEDED";
|
|||||||
const INF = Number.POSITIVE_INFINITY;
|
const INF = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
export const PLAN_LIMITS: Record<TenantPlan, Record<PlanResource, number>> = {
|
export const PLAN_LIMITS: Record<TenantPlan, Record<PlanResource, number>> = {
|
||||||
free: {
|
free: { properties: 15, customers: 25, members: 1, presentations: 5 },
|
||||||
properties: 5,
|
starter: { properties: 50, customers: 100, members: 3, presentations: 20 },
|
||||||
customers: 10,
|
pro: { properties: 200, customers: 500, members: 10, presentations: 50 },
|
||||||
members: 2,
|
enterprise: { properties: INF, customers: INF, members: INF, presentations: INF },
|
||||||
presentations: 3,
|
};
|
||||||
},
|
|
||||||
pro: {
|
export const PLAN_NAMES: Record<TenantPlan, string> = {
|
||||||
properties: INF,
|
free: "Ücretsiz",
|
||||||
customers: INF,
|
starter: "Başlangıç",
|
||||||
members: INF,
|
pro: "Pro",
|
||||||
presentations: INF,
|
enterprise: "Enterprise",
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RESOURCE_LABELS: Record<PlanResource, string> = {
|
export const RESOURCE_LABELS: Record<PlanResource, string> = {
|
||||||
properties: "ilan",
|
properties: "aktif ilan",
|
||||||
customers: "müşteri",
|
customers: "müşteri",
|
||||||
members: "ekip üyesi",
|
members: "ekip üyesi",
|
||||||
presentations: "sunum",
|
presentations: "sunum",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,18 +8,19 @@ import type { TenantContext } from "./tenant-guard";
|
|||||||
import type { TenantPlan } from "./schema";
|
import type { TenantPlan } from "./schema";
|
||||||
import {
|
import {
|
||||||
PLAN_LIMITS,
|
PLAN_LIMITS,
|
||||||
|
PLAN_NAMES,
|
||||||
RESOURCE_LABELS,
|
RESOURCE_LABELS,
|
||||||
type PlanResource,
|
type PlanResource,
|
||||||
} from "./plan-limits-shared";
|
} from "./plan-limits-shared";
|
||||||
|
|
||||||
export 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;
|
const INF = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
export function getEffectivePlan(ctx: TenantContext): TenantPlan {
|
export function getEffectivePlan(ctx: TenantContext): TenantPlan {
|
||||||
const plan = (ctx.settings?.plan as TenantPlan | undefined) ?? "free";
|
const plan = (ctx.settings?.plan as TenantPlan | undefined) ?? "free";
|
||||||
if (plan === "pro") {
|
if (plan !== "free") {
|
||||||
const expires = ctx.settings?.planExpiresAt;
|
const expires = ctx.settings?.planExpiresAt;
|
||||||
if (expires && new Date(expires).getTime() < Date.now()) return "free";
|
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,
|
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({
|
const result = await tablesDB.listRows({
|
||||||
databaseId: DATABASE_ID,
|
databaseId: DATABASE_ID,
|
||||||
tableId: tableMap[resource],
|
tableId: tableMap[resource],
|
||||||
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
|
queries,
|
||||||
});
|
});
|
||||||
return result.total;
|
return result.total;
|
||||||
}
|
}
|
||||||
@@ -85,6 +98,7 @@ export function isPlanLimitError(e: unknown): e is PlanLimitError {
|
|||||||
return e instanceof PlanLimitError;
|
return e instanceof PlanLimitError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function planLimitMessage(resource: PlanResource, limit: number): string {
|
export function planLimitMessage(resource: PlanResource, limit: number, plan?: TenantPlan): string {
|
||||||
return `Ücretsiz planda en fazla ${limit} ${RESOURCE_LABELS[resource]} ekleyebilirsiniz. Pro'ya geçerek sınırı kaldırın.`;
|
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.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export type SystemRow = {
|
|||||||
|
|
||||||
type Row = 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 TenantRole = "owner" | "admin" | "member";
|
||||||
export type InviteRole = "admin" | "member";
|
export type InviteRole = "admin" | "member";
|
||||||
|
|
||||||
@@ -205,6 +206,7 @@ export interface TenantSettings extends Row {
|
|||||||
address?: string;
|
address?: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
plan?: TenantPlan;
|
plan?: TenantPlan;
|
||||||
|
planPeriod?: PlanPeriod;
|
||||||
planExpiresAt?: string;
|
planExpiresAt?: string;
|
||||||
planProvider?: string;
|
planProvider?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
|
import { cache } from "react";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import {
|
import {
|
||||||
Account,
|
Account,
|
||||||
@@ -59,11 +60,11 @@ export async function createSessionClient() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentUser() {
|
export const getCurrentUser = cache(async function getCurrentUser() {
|
||||||
try {
|
try {
|
||||||
const { account } = await createSessionClient();
|
const { account } = await createSessionClient();
|
||||||
return await account.get();
|
return await account.get();
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { BUCKETS } from "./schema";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
import { BUCKETS, DATABASE_ID, TABLES } from "./schema";
|
||||||
import { createAdminClient } from "./server";
|
import { createAdminClient } from "./server";
|
||||||
import { requireTenant } from "./tenant-guard";
|
import { requireTenant } from "./tenant-guard";
|
||||||
|
|
||||||
@@ -17,3 +19,32 @@ export async function deletePropertyImageAction(fileId: string): Promise<DeleteR
|
|||||||
return { ok: false, error: "Fotoğraf silinemedi." };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Query } from "node-appwrite";
|
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 { createAdminClient } from "./server";
|
||||||
import { requireRole, requireTenant } from "./tenant-guard";
|
import { requireRole, requireTenant } from "./tenant-guard";
|
||||||
import { getEffectivePlan } from "./plan-limits";
|
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 { getShopierPlanUrl, isShopierEnabled } from "../payments/shopier";
|
||||||
import { createPolarCheckout, isPolarEnabled } from "../payments/polar";
|
import { createPolarCheckout, isPolarEnabled } from "../payments/polar";
|
||||||
|
import { getPayTRToken } from "../payments/paytr";
|
||||||
const PRO_VALIDITY_DAYS = 30;
|
|
||||||
|
|
||||||
function generateOrderId(): string {
|
function generateOrderId(): string {
|
||||||
const t = Date.now().toString(36);
|
const t = Date.now().toString(36);
|
||||||
@@ -19,15 +19,15 @@ function generateOrderId(): string {
|
|||||||
return `ord_${t}_${r}`;
|
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(
|
export async function activatePlanInDb(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
plan: TenantPlan,
|
plan: TenantPlan,
|
||||||
provider: string,
|
provider: string,
|
||||||
|
period: PlanPeriod = "monthly",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
|
|
||||||
// tenant_settings satırını bul
|
|
||||||
const result = await tablesDB.listRows({
|
const result = await tablesDB.listRows({
|
||||||
databaseId: DATABASE_ID,
|
databaseId: DATABASE_ID,
|
||||||
tableId: TABLES.tenantSettings,
|
tableId: TABLES.tenantSettings,
|
||||||
@@ -36,11 +36,13 @@ export async function activatePlanInDb(
|
|||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
if (!row) throw new Error(`tenant_settings bulunamadı: ${tenantId}`);
|
if (!row) throw new Error(`tenant_settings bulunamadı: ${tenantId}`);
|
||||||
|
|
||||||
|
const validityDays = period === "yearly" ? 365 : 30;
|
||||||
const now = new Date();
|
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, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, row.$id, {
|
||||||
plan,
|
plan,
|
||||||
|
planPeriod: period,
|
||||||
planExpiresAt: expires.toISOString(),
|
planExpiresAt: expires.toISOString(),
|
||||||
planProvider: provider,
|
planProvider: provider,
|
||||||
});
|
});
|
||||||
@@ -118,6 +120,63 @@ export async function startPolarCheckoutAction(formData: FormData): Promise<void
|
|||||||
redirect(checkout.url);
|
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 ────────────────────────────────────────────────────────
|
// ── Unified entry point ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function startCheckoutAction(formData: FormData): Promise<void> {
|
export async function startCheckoutAction(formData: FormData): Promise<void> {
|
||||||
|
|||||||
@@ -1,43 +1,75 @@
|
|||||||
import type { TenantPlan } from "./schema";
|
import type { TenantPlan, PlanPeriod } from "./schema";
|
||||||
|
|
||||||
export type PlanCatalogEntry = {
|
export type PlanCatalogEntry = {
|
||||||
id: TenantPlan;
|
id: TenantPlan;
|
||||||
name: string;
|
name: string;
|
||||||
price: number;
|
|
||||||
currency: string;
|
|
||||||
description: string;
|
description: string;
|
||||||
|
monthly: number;
|
||||||
|
yearly: number; // 10 ay ücreti (2 ay bedava)
|
||||||
|
currency: string;
|
||||||
features: string[];
|
features: string[];
|
||||||
|
highlight?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PLAN_CATALOG: Record<TenantPlan, PlanCatalogEntry> = {
|
export const PLAN_CATALOG: Record<Exclude<TenantPlan, "free">, PlanCatalogEntry> = {
|
||||||
free: {
|
starter: {
|
||||||
id: "free",
|
id: "starter",
|
||||||
name: "Ücretsiz",
|
name: "Başlangıç",
|
||||||
price: 0,
|
description: "Büyüyen emlak ofisleri için.",
|
||||||
|
monthly: 599,
|
||||||
|
yearly: 5990,
|
||||||
currency: "TRY",
|
currency: "TRY",
|
||||||
description: "Küçük ofisler ve deneme için.",
|
|
||||||
features: [
|
features: [
|
||||||
"5 ilan",
|
"50 aktif ilan",
|
||||||
"10 müşteri",
|
"100 müşteri",
|
||||||
"3 sunum",
|
"20 sunum",
|
||||||
"2 ekip üyesi",
|
"3 ekip üyesi",
|
||||||
"Temel destek",
|
"10 fotoğraf / ilan",
|
||||||
|
"Otomatik eşleştirme",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
pro: {
|
pro: {
|
||||||
id: "pro",
|
id: "pro",
|
||||||
name: "Pro",
|
name: "Pro",
|
||||||
price: 499,
|
description: "Kurumsal emlak ofisleri için.",
|
||||||
|
monthly: 999,
|
||||||
|
yearly: 9990,
|
||||||
currency: "TRY",
|
currency: "TRY",
|
||||||
description: "Büyüyen emlak ofisleri için sınırsız kullanım.",
|
highlight: true,
|
||||||
features: [
|
features: [
|
||||||
"Sınırsız ilan",
|
"200 aktif ilan",
|
||||||
"Sınırsız müşteri",
|
"500 müşteri",
|
||||||
"Sınırsız sunum",
|
"50 sunum",
|
||||||
"Sınırsız ekip üyesi",
|
"10 ekip üyesi",
|
||||||
"Otomatik eşleştirme",
|
"20 fotoğraf / ilan",
|
||||||
"Yatırımcı portalı",
|
"Yatırımcı portalı",
|
||||||
"Öncelikli destek",
|
"Ö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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
|
import { cache } from "react";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { ID, Permission, Query, Role } from "node-appwrite";
|
import { ID, Permission, Query, Role } from "node-appwrite";
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ async function setTenantCookie(tenantId: string) {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireTenant(): Promise<TenantContext> {
|
export const requireTenant = cache(async function requireTenant(): Promise<TenantContext> {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) throw new Error("UNAUTHENTICATED");
|
if (!user) throw new Error("UNAUTHENTICATED");
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ export async function requireTenant(): Promise<TenantContext> {
|
|||||||
settings,
|
settings,
|
||||||
memberCount,
|
memberCount,
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
|
|
||||||
export function requireRole(ctx: TenantContext, allowed: TenantRole[]): void {
|
export function requireRole(ctx: TenantContext, allowed: TenantRole[]): void {
|
||||||
if (!allowed.includes(ctx.role)) {
|
if (!allowed.includes(ctx.role)) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-27
@@ -4,22 +4,15 @@ import { Query } from "node-appwrite";
|
|||||||
|
|
||||||
import { createAdminClient } from "./appwrite/server";
|
import { createAdminClient } from "./appwrite/server";
|
||||||
import { DATABASE_ID, TABLES, type TenantPlan } from "./appwrite/schema";
|
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 = {
|
export const PLAN_LIMITS = {
|
||||||
free: {
|
free: { properties: 15, customers: 25, presentations: 5, teamMembers: 1, propertyImages: 5 },
|
||||||
properties: 15,
|
starter: { properties: 50, customers: 100, presentations: 20, teamMembers: 3, propertyImages: 10 },
|
||||||
customers: 25,
|
pro: { properties: 200, customers: 500, presentations: 50, teamMembers: 10, propertyImages: 20 },
|
||||||
presentations: 5,
|
enterprise: { properties: INF, customers: INF, presentations: INF, teamMembers: INF, propertyImages: INF },
|
||||||
teamMembers: 1,
|
|
||||||
propertyImages: 5,
|
|
||||||
},
|
|
||||||
pro: {
|
|
||||||
properties: Infinity,
|
|
||||||
customers: Infinity,
|
|
||||||
presentations: Infinity,
|
|
||||||
teamMembers: Infinity,
|
|
||||||
propertyImages: Infinity,
|
|
||||||
},
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type LimitKey = keyof typeof PLAN_LIMITS.free;
|
export type LimitKey = keyof typeof PLAN_LIMITS.free;
|
||||||
@@ -34,19 +27,20 @@ export async function checkLimit(
|
|||||||
const planKey: TenantPlan = plan ?? "free";
|
const planKey: TenantPlan = plan ?? "free";
|
||||||
const limit = PLAN_LIMITS[planKey][key];
|
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();
|
const { tablesDB, teams } = createAdminClient();
|
||||||
|
|
||||||
let current = 0;
|
let current = 0;
|
||||||
|
|
||||||
if (key === "properties") {
|
if (key === "properties") {
|
||||||
const r = await tablesDB.listRows({
|
// Satılan/kiralanan ilanlar limite dahil değil.
|
||||||
databaseId: DATABASE_ID,
|
// Query.or() bypasses compound indexes; 3 parallel count queries each use [tenantId, status].
|
||||||
tableId: TABLES.properties,
|
const [aktifRes, rezerveRes, pasifRes] = await Promise.all([
|
||||||
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
|
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)] }),
|
||||||
current = r.total;
|
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") {
|
} else if (key === "customers") {
|
||||||
const r = await tablesDB.listRows({
|
const r = await tablesDB.listRows({
|
||||||
databaseId: DATABASE_ID,
|
databaseId: DATABASE_ID,
|
||||||
@@ -71,18 +65,24 @@ export async function checkLimit(
|
|||||||
|
|
||||||
export function limitLabel(key: LimitKey): string {
|
export function limitLabel(key: LimitKey): string {
|
||||||
const labels: Record<LimitKey, string> = {
|
const labels: Record<LimitKey, string> = {
|
||||||
properties: "ilan",
|
properties: "aktif ilan",
|
||||||
customers: "müşteri",
|
customers: "müşteri",
|
||||||
presentations: "sunum",
|
presentations: "sunum",
|
||||||
teamMembers: "ekip üyesi",
|
teamMembers: "ekip üyesi",
|
||||||
propertyImages: "fotoğraf",
|
propertyImages: "fotoğraf",
|
||||||
};
|
};
|
||||||
return labels[key];
|
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") {
|
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.`;
|
return `Bu çalışma alanı ${limitLabel(key)} limitine ulaştı. Yöneticinizle iletişime geçin.`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user