feat: subscription upgrade-only flow, discount codes, proration, enterprise inquiry form, payment history invoices page, fix mobile sidebar close on navigate
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle, Tag } from "@/lib/icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
|
||||
import type { PaymentEvent } from "@/lib/appwrite/schema";
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
paytr: "PayTR",
|
||||
polar: "Polar",
|
||||
shopier: "Shopier",
|
||||
mock: "Test",
|
||||
};
|
||||
|
||||
export function PaymentHistory({ events }: { events: PaymentEvent[] }) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Ödeme Geçmişi</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Henüz ödeme kaydı bulunmuyor.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Ödeme Geçmişi</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{events.map((event) => {
|
||||
const planEntry = PLAN_CATALOG[event.plan as keyof typeof PLAN_CATALOG];
|
||||
const periodLabel = event.period === "yearly" ? "Yıllık" : "Aylık";
|
||||
const date = new Date(event.$createdAt).toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.$id}
|
||||
className="flex items-center justify-between px-6 py-3.5 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-500/10 text-green-600 shrink-0">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{planEntry?.name ?? event.plan} — {periodLabel}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs flex items-center gap-1.5 mt-0.5">
|
||||
<span>{date}</span>
|
||||
<span>·</span>
|
||||
<span>{PROVIDER_LABELS[event.provider] ?? event.provider}</span>
|
||||
{event.discountCode && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Tag className="h-3 w-3" />
|
||||
{event.discountCode}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className="text-green-600 border-green-500/30 bg-green-500/5">
|
||||
{event.amount.toLocaleString("tr-TR")} ₺
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Crown, Check, CircleNotch, Star, Lightning, X } from "@/lib/icons";
|
||||
import { Crown, Check, CircleNotch, Star, Lightning, X, Envelope, Tag } from "@/lib/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
startCheckoutAction,
|
||||
downgradeToFreeAction,
|
||||
getPayTRTokenAction,
|
||||
requestEnterpriseInquiryAction,
|
||||
} from "@/lib/appwrite/subscription-actions";
|
||||
import { PLAN_CATALOG, planPriceDisplay, planPrice } from "@/lib/appwrite/subscription-types";
|
||||
import { PLAN_CATALOG, PLAN_RANK, planPriceDisplay, planPrice, validateDiscountCode } from "@/lib/appwrite/subscription-types";
|
||||
import type { TenantPlan, PlanPeriod } from "@/lib/appwrite/schema";
|
||||
|
||||
const PLAN_ICONS: Record<string, React.ReactNode> = {
|
||||
@@ -21,22 +21,79 @@ const PLAN_ICONS: Record<string, React.ReactNode> = {
|
||||
enterprise: <Crown className="h-5 w-5" />,
|
||||
};
|
||||
|
||||
function calcProration(
|
||||
currentPlan: TenantPlan,
|
||||
currentPeriod: PlanPeriod,
|
||||
planExpiresAt: string | null,
|
||||
newPlanPrice: number,
|
||||
): number {
|
||||
if (!planExpiresAt || currentPlan === "free") return newPlanPrice;
|
||||
const currentEntry = PLAN_CATALOG[currentPlan as Exclude<TenantPlan, "free">];
|
||||
if (!currentEntry) return newPlanPrice;
|
||||
const periodDays = currentPeriod === "yearly" ? 365 : 30;
|
||||
const msRemaining = new Date(planExpiresAt).getTime() - Date.now();
|
||||
const daysRemaining = Math.max(0, msRemaining / (1000 * 60 * 60 * 24));
|
||||
const unusedFraction = Math.min(1, daysRemaining / periodDays);
|
||||
const credit = planPrice(currentEntry, currentPeriod) * unusedFraction;
|
||||
return Math.max(1, Math.round(newPlanPrice - credit));
|
||||
}
|
||||
|
||||
export function UpgradeSection({
|
||||
currentPlan,
|
||||
currentPeriod,
|
||||
planExpiresAt,
|
||||
paytrEnabled,
|
||||
}: {
|
||||
currentPlan: TenantPlan;
|
||||
currentPeriod?: PlanPeriod | null;
|
||||
planExpiresAt?: string | null;
|
||||
paytrEnabled?: boolean;
|
||||
}) {
|
||||
const [period, setPeriod] = useState<PlanPeriod>(currentPeriod ?? "monthly");
|
||||
const [paytrToken, setPaytrToken] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [enterpriseDialogOpen, setEnterpriseDialogOpen] = useState(false);
|
||||
const [loadingPlan, setLoadingPlan] = useState<string | null>(null);
|
||||
const [discountCode, setDiscountCode] = useState("");
|
||||
const [discountApplied, setDiscountApplied] = useState<number | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [enterpriseForm, setEnterpriseForm] = useState({
|
||||
teamSize: "",
|
||||
listingCount: "",
|
||||
needsCustomDev: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const plans = Object.values(PLAN_CATALOG);
|
||||
const currentRank = PLAN_RANK[currentPlan];
|
||||
|
||||
function applyDiscount() {
|
||||
if (!discountCode.trim()) return;
|
||||
const fraction = validateDiscountCode(discountCode);
|
||||
if (fraction === null) {
|
||||
toast.error("Geçersiz indirim kodu.");
|
||||
setDiscountApplied(null);
|
||||
return;
|
||||
}
|
||||
setDiscountApplied(fraction);
|
||||
toast.success(`İndirim kodu uygulandı: %${Math.round(fraction * 100)} indirim`);
|
||||
}
|
||||
|
||||
function getDisplayPrice(plan: typeof plans[0]): number {
|
||||
let base = planPriceDisplay(plan, period);
|
||||
if (discountApplied !== null) base = Math.round(base * (1 - discountApplied));
|
||||
return base;
|
||||
}
|
||||
|
||||
function getProrationAmount(planId: string): number | null {
|
||||
if (currentPlan === "free" || !planExpiresAt) return null;
|
||||
const entry = PLAN_CATALOG[planId as Exclude<TenantPlan, "free">];
|
||||
if (!entry) return null;
|
||||
let base = planPrice(entry, period);
|
||||
if (discountApplied !== null) base = Math.max(1, Math.round(base * (1 - discountApplied)));
|
||||
const prorated = calcProration(currentPlan, currentPeriod ?? "monthly", planExpiresAt, base);
|
||||
return prorated < base ? prorated : null;
|
||||
}
|
||||
|
||||
function handleCheckout(planId: string) {
|
||||
if (paytrEnabled) {
|
||||
@@ -46,6 +103,7 @@ export function UpgradeSection({
|
||||
const fd = new FormData();
|
||||
fd.set("plan", planId);
|
||||
fd.set("period", period);
|
||||
if (discountApplied !== null) fd.set("discountCode", discountCode.trim().toUpperCase());
|
||||
const token = await getPayTRTokenAction(fd);
|
||||
setPaytrToken(token);
|
||||
setDialogOpen(true);
|
||||
@@ -72,13 +130,30 @@ export function UpgradeSection({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDowngrade() {
|
||||
try {
|
||||
await downgradeToFreeAction();
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes("NEXT_REDIRECT")) throw e;
|
||||
toast.error("İşlem başarısız.");
|
||||
function handleEnterpriseInquiry() {
|
||||
if (!enterpriseForm.teamSize || !enterpriseForm.listingCount || !enterpriseForm.needsCustomDev) {
|
||||
toast.error("Lütfen tüm zorunlu alanları doldurun.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("teamSize", enterpriseForm.teamSize);
|
||||
fd.set("listingCount", enterpriseForm.listingCount);
|
||||
fd.set("needsCustomDev", enterpriseForm.needsCustomDev);
|
||||
fd.set("notes", enterpriseForm.notes);
|
||||
const result = await requestEnterpriseInquiryAction(fd);
|
||||
if (result.ok) {
|
||||
setEnterpriseDialogOpen(false);
|
||||
setEnterpriseForm({ teamSize: "", listingCount: "", needsCustomDev: "", notes: "" });
|
||||
toast.success("Talebiniz alındı. Ekibimiz kısa sürede sizinle iletişime geçecek.");
|
||||
} else {
|
||||
toast.error(result.error ?? "Talep gönderilemedi.");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Talep gönderilemedi.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -89,7 +164,7 @@ export function UpgradeSection({
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">Planınızı seçin</h2>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
İhtiyacınıza göre istediğiniz zaman değiştirebilirsiniz.
|
||||
Dilediğiniz zaman daha üst bir plana geçebilirsiniz.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-full border bg-muted/40 p-1 text-sm">
|
||||
@@ -117,13 +192,48 @@ export function UpgradeSection({
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* İndirim kodu */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={discountCode}
|
||||
onChange={(e) => {
|
||||
setDiscountCode(e.target.value.toUpperCase());
|
||||
if (discountApplied !== null) setDiscountApplied(null);
|
||||
}}
|
||||
onKeyDown={(e) => e.key === "Enter" && applyDiscount()}
|
||||
placeholder="İndirim kodu"
|
||||
className="h-9 w-40 rounded-lg border bg-background pl-8 pr-3 text-sm outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={applyDiscount}
|
||||
disabled={!discountCode.trim()}
|
||||
>
|
||||
Uygula
|
||||
</Button>
|
||||
{discountApplied !== null && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Check className="h-3 w-3" />
|
||||
%{Math.round(discountApplied * 100)} indirim
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3 plan kartı */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{plans.map((plan) => {
|
||||
const isCurrent = currentPlan === plan.id;
|
||||
const displayPrice = planPriceDisplay(plan, period);
|
||||
const isLower = PLAN_RANK[plan.id as TenantPlan] < currentRank;
|
||||
const isEnterprise = plan.id === "enterprise";
|
||||
const displayPrice = getDisplayPrice(plan);
|
||||
const prorationAmount = !isCurrent && !isLower ? getProrationAmount(plan.id) : null;
|
||||
const isLoading = isPending && loadingPlan === plan.id;
|
||||
|
||||
return (
|
||||
@@ -133,7 +243,7 @@ export function UpgradeSection({
|
||||
plan.highlight
|
||||
? "border-primary shadow-md ring-1 ring-primary/20"
|
||||
: ""
|
||||
}`}
|
||||
} ${isLower ? "opacity-50" : ""}`}
|
||||
>
|
||||
{plan.highlight && (
|
||||
<div className="absolute -top-3.5 inset-x-0 flex justify-center">
|
||||
@@ -158,6 +268,11 @@ export function UpgradeSection({
|
||||
{displayPrice.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm mb-1">₺/ay</span>
|
||||
{discountApplied !== null && !isCurrent && !isLower && (
|
||||
<span className="text-muted-foreground text-xs line-through mb-1 ml-1">
|
||||
{planPriceDisplay(plan, period).toLocaleString("tr-TR")}₺
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{period === "yearly" ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -179,18 +294,25 @@ export function UpgradeSection({
|
||||
</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"
|
||||
) : isLower ? (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Plan düşürülemez
|
||||
</Button>
|
||||
) : isEnterprise ? (
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
variant="outline"
|
||||
onClick={() => setEnterpriseDialogOpen(true)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Ücretsiz plana geç
|
||||
</button>
|
||||
</div>
|
||||
<Envelope className="h-4 w-4" />
|
||||
Teklif Al
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
variant={plan.highlight ? "default" : "outline"}
|
||||
@@ -204,14 +326,33 @@ export function UpgradeSection({
|
||||
)}
|
||||
{isLoading ? "Yükleniyor..." : `${plan.name}'a Geç`}
|
||||
</Button>
|
||||
{prorationAmount !== null && (
|
||||
<p className="text-center text-[11px] text-muted-foreground">
|
||||
Bugün sadece{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{prorationAmount.toLocaleString("tr-TR")} ₺
|
||||
</span>{" "}
|
||||
ödersiniz (kalan süre mahsup edilir)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Abonelik iptali için{" "}
|
||||
<a href="mailto:info@kovakyazilim.com" className="underline hover:text-foreground transition-colors">
|
||||
info@kovakyazilim.com
|
||||
</a>{" "}
|
||||
ile iletişime geçin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* PayTR iframe dialog */}
|
||||
{paytrEnabled && (
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
@@ -243,6 +384,136 @@ export function UpgradeSection({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Enterprise inquiry dialog */}
|
||||
<Dialog open={enterpriseDialogOpen} onOpenChange={setEnterpriseDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Crown className="h-5 w-5 text-primary" />
|
||||
Enterprise Plan Teklifi
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ekibimiz sizin için özel fiyatlandırma ve kurulum planı hazırlasın. Birkaç soruyu
|
||||
yanıtlayın, en kısa sürede size ulaşalım.
|
||||
</p>
|
||||
|
||||
{/* Ekip büyüklüğü */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
Ekibinizdeki danışman sayısı <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{["1-3", "4-10", "11-25", "25+"].map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => setEnterpriseForm((f) => ({ ...f, teamSize: opt }))}
|
||||
className={`rounded-lg border py-2 text-sm font-medium transition-colors ${
|
||||
enterpriseForm.teamSize === opt
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ilan sayısı */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
Aktif ilan sayınız <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{["1-50", "51-200", "201-500", "500+"].map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => setEnterpriseForm((f) => ({ ...f, listingCount: opt }))}
|
||||
className={`rounded-lg border py-2 text-sm font-medium transition-colors ${
|
||||
enterpriseForm.listingCount === opt
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Özel modül */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
Özel modül / entegrasyon geliştirmesi istiyor musunuz?{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: "yes", label: "Evet" },
|
||||
{ value: "no", label: "Hayır" },
|
||||
{ value: "maybe", label: "Değerlendiriyorum" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setEnterpriseForm((f) => ({ ...f, needsCustomDev: opt.value }))}
|
||||
className={`rounded-lg border py-2 text-sm font-medium transition-colors ${
|
||||
enterpriseForm.needsCustomDev === opt.value
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notlar */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
Eklemek istediğiniz notlar{" "}
|
||||
<span className="text-muted-foreground font-normal">(isteğe bağlı)</span>
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={enterpriseForm.notes}
|
||||
onChange={(e) => setEnterpriseForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
placeholder="Özel ihtiyaçlarınızı, entegrasyon beklentilerinizi veya sorularınızı buraya yazabilirsiniz..."
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setEnterpriseDialogOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 gap-2"
|
||||
onClick={handleEnterpriseInquiry}
|
||||
disabled={isPending || !enterpriseForm.teamSize || !enterpriseForm.listingCount || !enterpriseForm.needsCustomDev}
|
||||
>
|
||||
{isPending ? (
|
||||
<CircleNotch className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Envelope className="h-4 w-4" />
|
||||
)}
|
||||
Teklif Gönder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "@/lib/icons";
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { getPaymentHistoryAction } from "@/lib/appwrite/subscription-actions";
|
||||
import { PaymentHistory } from "../components/payment-history";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "KovakEmlak — Faturalar",
|
||||
};
|
||||
|
||||
export default async function InvoicesPage() {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
if (ctx.role !== "owner") redirect("/dashboard");
|
||||
|
||||
const events = await getPaymentHistoryAction();
|
||||
const officeName = ctx.settings?.officeName ?? "Ofis";
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit mb-1"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Plan & Faturalama
|
||||
</Link>
|
||||
<p className="text-muted-foreground text-sm">{officeName}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Faturalar</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Abonelik ödemelerinizin geçmişi.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PaymentHistory events={events} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { CheckCircle, XCircle } from '@/lib/icons';
|
||||
import Link from "next/link";
|
||||
import { CheckCircle, Receipt } from '@/lib/icons';
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { getEffectivePlan, getPlanUsage } from "@/lib/appwrite/plan-limits";
|
||||
@@ -30,7 +31,6 @@ export default async function BillingPage({
|
||||
|
||||
const params = await searchParams;
|
||||
const upgraded = params.upgraded === "1";
|
||||
const downgraded = params.downgraded === "1";
|
||||
|
||||
const plan = getEffectivePlan(ctx);
|
||||
const { usage } = await getPlanUsage(ctx);
|
||||
@@ -40,6 +40,7 @@ export default async function BillingPage({
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">{officeName}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Plan & Faturalama</h1>
|
||||
@@ -47,18 +48,19 @@ export default async function BillingPage({
|
||||
Mevcut planınızı görüntüleyin ve yönetin.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/settings/billing/invoices"
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0 mt-1"
|
||||
>
|
||||
<Receipt className="h-4 w-4" />
|
||||
Faturalar
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{upgraded && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/10 px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
||||
<CheckCircle className="h-4 w-4 shrink-0" />
|
||||
Tebrikler! Planınız başarıyla yükseltildi. KovakEmlak Pro'ya hoş geldiniz.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{downgraded && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-muted bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
|
||||
<XCircle className="h-4 w-4 shrink-0" />
|
||||
Ücretsiz plana geçildi.
|
||||
Tebrikler! Planınız başarıyla yükseltildi.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -72,6 +74,7 @@ export default async function BillingPage({
|
||||
<UpgradeSection
|
||||
currentPlan={plan}
|
||||
currentPeriod={ctx.settings?.planPeriod ?? null}
|
||||
planExpiresAt={ctx.settings?.planExpiresAt ?? null}
|
||||
paytrEnabled={paytrEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,10 @@ export async function POST(req: Request): Promise<Response> {
|
||||
return new Response("FAILED", { status: 400 });
|
||||
}
|
||||
try {
|
||||
await activatePlanInDb(tenantId, plan, "paytr", period);
|
||||
// totalAmount kuruş cinsinden gelir → TRY'ye çevir
|
||||
const amountTRY = Math.round(Number(totalAmount) / 100);
|
||||
const orderId = merchantOid;
|
||||
await activatePlanInDb(tenantId, plan, "paytr", period, { amount: amountTRY, orderId });
|
||||
} catch (e) {
|
||||
console.error("[paytr-callback]", e);
|
||||
return new Response("FAILED", { status: 500 });
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
import type { Icon } from "@/lib/icons";
|
||||
@@ -145,6 +146,10 @@ const navGroups: NavGroup[] = [
|
||||
url: "/settings/billing",
|
||||
icon: CreditCard,
|
||||
minRole: "owner" as ShellRole,
|
||||
items: [
|
||||
{ title: "Plan & Yükseltme", url: "/settings/billing" },
|
||||
{ title: "Faturalar", url: "/settings/billing/invoices" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -162,6 +167,9 @@ export function AppSidebar({
|
||||
pendingMatchCount?: number;
|
||||
role?: ShellRole;
|
||||
}) {
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
const closeMobile = () => { if (isMobile) setOpenMobile(false) };
|
||||
|
||||
const groups = navGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
@@ -184,7 +192,7 @@ export function AppSidebar({
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link href="/dashboard">
|
||||
<Link href="/dashboard" onClick={closeMobile}>
|
||||
{company.logoUrl ? (
|
||||
<div className="bg-background flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg border">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export function NavMain({
|
||||
@@ -39,6 +40,8 @@ export function NavMain({
|
||||
}[]
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const { isMobile, setOpenMobile } = useSidebar()
|
||||
const closeMobile = () => { if (isMobile) setOpenMobile(false) }
|
||||
|
||||
// Returns the url of the best-matching subitem (longest prefix wins).
|
||||
// Handles detail pages: /customers/123 correctly activates the /customers subitem
|
||||
@@ -89,6 +92,7 @@ export function NavMain({
|
||||
<SidebarMenuSubButton asChild className="cursor-pointer" isActive={activeSubUrl === subItem.url}>
|
||||
<Link
|
||||
href={subItem.url}
|
||||
onClick={closeMobile}
|
||||
target={(item.title === "Auth Pages" || item.title === "Errors") ? "_blank" : undefined}
|
||||
rel={(item.title === "Auth Pages" || item.title === "Errors") ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
@@ -108,7 +112,7 @@ export function NavMain({
|
||||
)
|
||||
})() : (
|
||||
<SidebarMenuButton asChild tooltip={item.title} className="cursor-pointer" isActive={pathname === item.url || pathname.startsWith(item.url + "/")}>
|
||||
<Link href={item.url}>
|
||||
<Link href={item.url} onClick={closeMobile}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
|
||||
@@ -37,7 +37,8 @@ export function NavUser({
|
||||
}: {
|
||||
user: { name: string; email: string };
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
const closeMobile = () => { if (isMobile) setOpenMobile(false) };
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSignOut = () => {
|
||||
@@ -85,19 +86,19 @@ export function NavUser({
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link href="/settings/account">
|
||||
<Link href="/settings/account" onClick={closeMobile}>
|
||||
<UserCircle />
|
||||
Profil
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link href="/settings/billing">
|
||||
<Link href="/settings/billing" onClick={closeMobile}>
|
||||
<CreditCard />
|
||||
Plan & Faturalama
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link href="/settings/notifications">
|
||||
<Link href="/settings/notifications" onClick={closeMobile}>
|
||||
<BellSimple />
|
||||
Bildirimler
|
||||
</Link>
|
||||
|
||||
@@ -17,6 +17,7 @@ export const TABLES = {
|
||||
inviteLinks: "invite_links",
|
||||
deals: "deals",
|
||||
passwordResets: "password_resets",
|
||||
paymentEvents: "payment_events",
|
||||
} as const;
|
||||
|
||||
export type TableId = (typeof TABLES)[keyof typeof TABLES];
|
||||
@@ -211,6 +212,16 @@ export interface TenantSettings extends Row {
|
||||
planProvider?: string;
|
||||
}
|
||||
|
||||
export interface PaymentEvent extends Row {
|
||||
tenantId: string;
|
||||
plan: Exclude<TenantPlan, "free">;
|
||||
period: PlanPeriod;
|
||||
amount: number;
|
||||
provider: string;
|
||||
orderId?: string;
|
||||
discountCode?: string;
|
||||
}
|
||||
|
||||
export type PropertyFeature =
|
||||
| "balkon"
|
||||
| "otopark"
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Query } from "node-appwrite";
|
||||
import { ID, Query } from "node-appwrite";
|
||||
|
||||
import { DATABASE_ID, TABLES, type TenantPlan, type PlanPeriod } from "./schema";
|
||||
import { DATABASE_ID, TABLES, type TenantPlan, type PlanPeriod, type PaymentEvent } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant } from "./tenant-guard";
|
||||
import { getEffectivePlan } from "./plan-limits";
|
||||
import { PLAN_CATALOG, planPrice } from "./subscription-types";
|
||||
import { PLAN_CATALOG, PLAN_RANK, planPrice, validateDiscountCode } from "./subscription-types";
|
||||
import { getShopierPlanUrl, isShopierEnabled } from "../payments/shopier";
|
||||
import { createPolarCheckout, isPolarEnabled } from "../payments/polar";
|
||||
import { getPayTRToken } from "../payments/paytr";
|
||||
@@ -25,6 +25,7 @@ export async function activatePlanInDb(
|
||||
plan: TenantPlan,
|
||||
provider: string,
|
||||
period: PlanPeriod = "monthly",
|
||||
opts?: { amount?: number; orderId?: string; discountCode?: string },
|
||||
): Promise<void> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
@@ -46,6 +47,24 @@ export async function activatePlanInDb(
|
||||
planExpiresAt: expires.toISOString(),
|
||||
planProvider: provider,
|
||||
});
|
||||
|
||||
// Ödeme geçmişi kaydı
|
||||
if (plan !== "free" && opts?.amount != null) {
|
||||
try {
|
||||
const eventData: Record<string, unknown> = {
|
||||
tenantId,
|
||||
plan,
|
||||
period,
|
||||
amount: opts.amount,
|
||||
provider,
|
||||
};
|
||||
if (opts.orderId) eventData.orderId = opts.orderId;
|
||||
if (opts.discountCode) eventData.discountCode = opts.discountCode;
|
||||
await tablesDB.createRow(DATABASE_ID, TABLES.paymentEvents, ID.unique(), eventData);
|
||||
} catch {
|
||||
// best-effort — ödeme aktivasyonunu bloke etme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deactivatePlanInDb(tenantId: string): Promise<void> {
|
||||
@@ -64,17 +83,45 @@ export async function deactivatePlanInDb(tenantId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function assertUpgrade(currentPlan: TenantPlan, targetPlan: TenantPlan) {
|
||||
if (PLAN_RANK[targetPlan] <= PLAN_RANK[currentPlan]) {
|
||||
throw new Error("Yalnızca daha üst bir plana geçiş yapabilirsiniz.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns prorated price to charge when upgrading mid-cycle. Min 1 TRY. */
|
||||
function calculateProration(
|
||||
currentPlan: TenantPlan,
|
||||
currentPeriod: PlanPeriod,
|
||||
planExpiresAt: string | undefined | null,
|
||||
newPlanPrice: number,
|
||||
): number {
|
||||
if (!planExpiresAt || currentPlan === "free") return newPlanPrice;
|
||||
const currentEntry = PLAN_CATALOG[currentPlan as Exclude<TenantPlan, "free">];
|
||||
if (!currentEntry) return newPlanPrice;
|
||||
|
||||
const periodDays = currentPeriod === "yearly" ? 365 : 30;
|
||||
const msRemaining = new Date(planExpiresAt).getTime() - Date.now();
|
||||
const daysRemaining = Math.max(0, msRemaining / (1000 * 60 * 60 * 24));
|
||||
const unusedFraction = Math.min(1, daysRemaining / periodDays);
|
||||
const credit = planPrice(currentEntry, currentPeriod) * unusedFraction;
|
||||
return Math.max(1, Math.round(newPlanPrice - credit));
|
||||
}
|
||||
|
||||
// ── Mock checkout (geliştirme ortamı) ─────────────────────────────────────────
|
||||
|
||||
export async function startMockCheckoutAction(formData: FormData): Promise<void> {
|
||||
const plan = String(formData.get("plan") ?? "") as TenantPlan;
|
||||
if (plan !== "pro") throw new Error("Geçersiz plan.");
|
||||
if (!["starter", "pro", "enterprise"].includes(plan)) throw new Error("Geçersiz plan.");
|
||||
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
assertUpgrade(getEffectivePlan(ctx), plan as TenantPlan);
|
||||
|
||||
// Mock: direkt aktive et
|
||||
await activatePlanInDb(ctx.tenantId, plan, "mock");
|
||||
const catalog = PLAN_CATALOG[plan as Exclude<TenantPlan, "free">];
|
||||
const period = String(formData.get("period") ?? "monthly") as PlanPeriod;
|
||||
const amount = planPrice(catalog, period);
|
||||
await activatePlanInDb(ctx.tenantId, plan as TenantPlan, "mock", period, { amount });
|
||||
redirect("/settings/billing?upgraded=1");
|
||||
}
|
||||
|
||||
@@ -82,15 +129,15 @@ export async function startMockCheckoutAction(formData: FormData): Promise<void>
|
||||
|
||||
export async function startShopierCheckoutAction(formData: FormData): Promise<void> {
|
||||
const plan = String(formData.get("plan") ?? "") as TenantPlan;
|
||||
if (plan !== "pro") throw new Error("Geçersiz plan.");
|
||||
if (!["starter", "pro"].includes(plan)) throw new Error("Geçersiz plan.");
|
||||
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
assertUpgrade(getEffectivePlan(ctx), plan as TenantPlan);
|
||||
|
||||
const storeUrl = getShopierPlanUrl(plan);
|
||||
if (!storeUrl) throw new Error("Shopier mağaza URL'i ayarlanmamış.");
|
||||
|
||||
// Shopier mağaza sayfasına yönlendir — ödeme tamamlanınca webhook gelir
|
||||
redirect(storeUrl);
|
||||
}
|
||||
|
||||
@@ -98,12 +145,13 @@ export async function startShopierCheckoutAction(formData: FormData): Promise<vo
|
||||
|
||||
export async function startPolarCheckoutAction(formData: FormData): Promise<void> {
|
||||
const plan = String(formData.get("plan") ?? "") as TenantPlan;
|
||||
if (plan !== "pro") throw new Error("Geçersiz plan.");
|
||||
if (!["starter", "pro"].includes(plan)) throw new Error("Geçersiz plan.");
|
||||
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
assertUpgrade(getEffectivePlan(ctx), plan as TenantPlan);
|
||||
|
||||
const catalog = PLAN_CATALOG[plan];
|
||||
const catalog = PLAN_CATALOG[plan as Exclude<TenantPlan, "free">];
|
||||
const orderId = generateOrderId();
|
||||
const appUrl = process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3001";
|
||||
|
||||
@@ -114,9 +162,7 @@ export async function startPolarCheckoutAction(formData: FormData): Promise<void
|
||||
successUrl: `${appUrl}/settings/billing?upgraded=1`,
|
||||
});
|
||||
|
||||
// orderId'yi tenant_settings'e geçici olarak kaydedebiliriz ama
|
||||
// minimal yaklaşımda metadata.tenant_id yeterli — webhook okur
|
||||
void catalog; // fiyat bilgisi ileride log için kullanılabilir
|
||||
void catalog;
|
||||
redirect(checkout.url);
|
||||
}
|
||||
|
||||
@@ -125,6 +171,7 @@ export async function startPolarCheckoutAction(formData: FormData): Promise<void
|
||||
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;
|
||||
const discountCodeRaw = String(formData.get("discountCode") ?? "").trim();
|
||||
|
||||
if (!["starter", "pro", "enterprise"].includes(planId)) throw new Error("Geçersiz plan.");
|
||||
if (!["monthly", "yearly"].includes(period)) throw new Error("Geçersiz dönem.");
|
||||
@@ -132,8 +179,26 @@ export async function getPayTRTokenAction(formData: FormData): Promise<string> {
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
|
||||
const currentPlan = getEffectivePlan(ctx);
|
||||
assertUpgrade(currentPlan, planId as TenantPlan);
|
||||
|
||||
const catalog = PLAN_CATALOG[planId];
|
||||
const price = planPrice(catalog, period);
|
||||
let price = planPrice(catalog, period);
|
||||
|
||||
// Proration: credit for unused days on current paid plan
|
||||
price = calculateProration(
|
||||
currentPlan,
|
||||
ctx.settings?.planPeriod ?? "monthly",
|
||||
ctx.settings?.planExpiresAt,
|
||||
price,
|
||||
);
|
||||
|
||||
// Discount code
|
||||
if (discountCodeRaw) {
|
||||
const discountFraction = validateDiscountCode(discountCodeRaw);
|
||||
if (!discountFraction) throw new Error("Geçersiz indirim kodu.");
|
||||
price = Math.max(1, Math.round(price * (1 - discountFraction)));
|
||||
}
|
||||
|
||||
// APP_URL: server-to-server callback (HTTPS zorunlu, prod'da veya ngrok)
|
||||
// APP_BROWSER_URL: browser redirect (dev'de localhost, prod'da aynı domain)
|
||||
@@ -152,9 +217,10 @@ export async function getPayTRTokenAction(formData: FormData): Promise<string> {
|
||||
// Format: {tenantId}T{timestamp}{random}P{plan}X{period}
|
||||
const merchantOid = `${ctx.tenantId}T${timestamp}${random}P${planId}X${period}`;
|
||||
|
||||
const discountLabel = discountCodeRaw ? ` + ${discountCodeRaw.toUpperCase()} İndirimi` : "";
|
||||
const userBasket: Array<[string, string, number]> = [
|
||||
[
|
||||
`KovakEmlak ${catalog.name} (${period === "yearly" ? "Yıllık" : "Aylık"})`,
|
||||
`KovakEmlak ${catalog.name} (${period === "yearly" ? "Yıllık" : "Aylık"})${discountLabel}`,
|
||||
price.toFixed(2),
|
||||
1,
|
||||
],
|
||||
@@ -177,7 +243,7 @@ export async function getPayTRTokenAction(formData: FormData): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Unified entry point ────────────────────────────────────────────────────────
|
||||
// ── Unified entry point (Polar/Shopier only — PayTR uses getPayTRTokenAction) ──
|
||||
|
||||
export async function startCheckoutAction(formData: FormData): Promise<void> {
|
||||
if (isPolarEnabled()) return startPolarCheckoutAction(formData);
|
||||
@@ -185,13 +251,63 @@ export async function startCheckoutAction(formData: FormData): Promise<void> {
|
||||
return startMockCheckoutAction(formData);
|
||||
}
|
||||
|
||||
// ── Downgrade ──────────────────────────────────────────────────────────────────
|
||||
// ── Enterprise inquiry ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function downgradeToFreeAction(): Promise<void> {
|
||||
export async function requestEnterpriseInquiryAction(
|
||||
formData: FormData,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
await deactivatePlanInDb(ctx.tenantId);
|
||||
redirect("/settings/billing?downgraded=1");
|
||||
|
||||
const officeName = ctx.settings?.officeName ?? ctx.user.name ?? "Bilinmiyor";
|
||||
const userEmail = ctx.user.email;
|
||||
const tenantId = ctx.tenantId;
|
||||
|
||||
const teamSize = String(formData.get("teamSize") ?? "").trim();
|
||||
const listingCount = String(formData.get("listingCount") ?? "").trim();
|
||||
const needsCustomDev = String(formData.get("needsCustomDev") ?? "").trim();
|
||||
const notes = String(formData.get("notes") ?? "").trim();
|
||||
|
||||
if (!teamSize || !listingCount || !needsCustomDev) {
|
||||
return { ok: false, error: "Lütfen tüm zorunlu alanları doldurun." };
|
||||
}
|
||||
|
||||
const customDevLabel = needsCustomDev === "yes" ? "Evet" : needsCustomDev === "no" ? "Hayır" : "Belirsiz";
|
||||
|
||||
try {
|
||||
const { messaging } = createAdminClient();
|
||||
|
||||
// Confirmation email to the tenant owner
|
||||
await messaging.createEmail(
|
||||
`enterprise-inquiry-${tenantId}-${Date.now()}`,
|
||||
"Enterprise Plan Talebiniz Alındı — KovakEmlak",
|
||||
`<p>Merhaba ${officeName},</p>
|
||||
<p>Enterprise plan talebiniz alındı. Ekibimiz en kısa sürede <strong>${userEmail}</strong> adresine size ulaşacak.</p>
|
||||
<table style="border-collapse:collapse;margin:16px 0;font-size:14px;">
|
||||
<tr><td style="padding:6px 12px 6px 0;color:#888;">Ofis adı</td><td style="padding:6px 0"><strong>${officeName}</strong></td></tr>
|
||||
<tr><td style="padding:6px 12px 6px 0;color:#888;">Ekip büyüklüğü</td><td style="padding:6px 0"><strong>${teamSize} kişi</strong></td></tr>
|
||||
<tr><td style="padding:6px 12px 6px 0;color:#888;">Aktif ilan sayısı</td><td style="padding:6px 0"><strong>${listingCount}</strong></td></tr>
|
||||
<tr><td style="padding:6px 12px 6px 0;color:#888;">Özel modül geliştirme</td><td style="padding:6px 0"><strong>${customDevLabel}</strong></td></tr>
|
||||
${notes ? `<tr><td style="padding:6px 12px 6px 0;color:#888;vertical-align:top;">Notlar</td><td style="padding:6px 0">${notes}</td></tr>` : ""}
|
||||
</table>
|
||||
<p style="color:#888;font-size:12px;">Bu e-posta otomatik olarak gönderilmiştir.</p>`,
|
||||
[],
|
||||
[ctx.user.id],
|
||||
[],
|
||||
);
|
||||
} catch {
|
||||
// Email gönderimi başarısız olsa bile talebi kabul et
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Downgrade (engellendi) ─────────────────────────────────────────────────────
|
||||
|
||||
export async function downgradeToFreeAction(): Promise<void> {
|
||||
// Ücretli plandan ücretsiz plana geçiş devre dışı.
|
||||
// Abonelik iptali için destek hattıyla iletişime geçin.
|
||||
throw new Error("Ücretli plandan ücretsiz plana geçiş yapılamaz. Abonelik iptali için info@kovakyazilim.com ile iletişime geçin.");
|
||||
}
|
||||
|
||||
// ── Mevcut plan bilgisi ────────────────────────────────────────────────────────
|
||||
@@ -209,3 +325,23 @@ export async function getCurrentPlanAction(): Promise<{
|
||||
provider: ctx.settings?.planProvider ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Ödeme geçmişi (sadece owner) ──────────────────────────────────────────────
|
||||
|
||||
export async function getPaymentHistoryAction(): Promise<PaymentEvent[]> {
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.paymentEvents,
|
||||
queries: [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(50),
|
||||
],
|
||||
});
|
||||
|
||||
return result.rows as unknown as PaymentEvent[];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import type { TenantPlan, PlanPeriod } from "./schema";
|
||||
|
||||
export const PLAN_RANK: Record<TenantPlan, number> = {
|
||||
free: 0,
|
||||
starter: 1,
|
||||
pro: 2,
|
||||
enterprise: 3,
|
||||
};
|
||||
|
||||
// Validated server-side; values are discount fractions (0.5 = 50% off)
|
||||
export const DISCOUNT_CODES: Record<string, number> = {
|
||||
KOVAK50: 0.5,
|
||||
KOVAK10: 0.1,
|
||||
};
|
||||
|
||||
export function validateDiscountCode(code: string): number | null {
|
||||
const normalized = code.trim().toUpperCase();
|
||||
return DISCOUNT_CODES[normalized] ?? null;
|
||||
}
|
||||
|
||||
export type PlanCatalogEntry = {
|
||||
id: TenantPlan;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user