From 668fb7108bb38ff9e5b619f3726ef7dbecf40355 Mon Sep 17 00:00:00 2001 From: egecankomur Date: Thu, 14 May 2026 19:09:11 +0300 Subject: [PATCH] feat: subscription upgrade-only flow, discount codes, proration, enterprise inquiry form, payment history invoices page, fix mobile sidebar close on navigate --- .../billing/components/payment-history.tsx | 87 +++++ .../billing/components/upgrade-section.tsx | 335 ++++++++++++++++-- .../settings/billing/invoices/page.tsx | 49 +++ src/app/(dashboard)/settings/billing/page.tsx | 35 +- src/app/api/payments/paytr/callback/route.ts | 5 +- src/components/app-sidebar.tsx | 10 +- src/components/nav-main.tsx | 6 +- src/components/nav-user.tsx | 9 +- src/lib/appwrite/schema.ts | 11 + src/lib/appwrite/subscription-actions.ts | 176 +++++++-- src/lib/appwrite/subscription-types.ts | 18 + 11 files changed, 666 insertions(+), 75 deletions(-) create mode 100644 src/app/(dashboard)/settings/billing/components/payment-history.tsx create mode 100644 src/app/(dashboard)/settings/billing/invoices/page.tsx diff --git a/src/app/(dashboard)/settings/billing/components/payment-history.tsx b/src/app/(dashboard)/settings/billing/components/payment-history.tsx new file mode 100644 index 0000000..0819b4e --- /dev/null +++ b/src/app/(dashboard)/settings/billing/components/payment-history.tsx @@ -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 = { + paytr: "PayTR", + polar: "Polar", + shopier: "Shopier", + mock: "Test", +}; + +export function PaymentHistory({ events }: { events: PaymentEvent[] }) { + if (events.length === 0) { + return ( + + + Ödeme Geçmişi + + +

Henüz ödeme kaydı bulunmuyor.

+
+
+ ); + } + + return ( + + + Ödeme Geçmişi + + +
+ {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 ( +
+
+
+ +
+
+
+ {planEntry?.name ?? event.plan} — {periodLabel} +
+
+ {date} + · + {PROVIDER_LABELS[event.provider] ?? event.provider} + {event.discountCode && ( + <> + · + + + {event.discountCode} + + + )} +
+
+
+
+ + {event.amount.toLocaleString("tr-TR")} ₺ + +
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/settings/billing/components/upgrade-section.tsx b/src/app/(dashboard)/settings/billing/components/upgrade-section.tsx index 7642cb6..77180b5 100644 --- a/src/app/(dashboard)/settings/billing/components/upgrade-section.tsx +++ b/src/app/(dashboard)/settings/billing/components/upgrade-section.tsx @@ -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 = { @@ -21,22 +21,79 @@ const PLAN_ICONS: Record = { enterprise: , }; +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]; + 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(currentPeriod ?? "monthly"); const [paytrToken, setPaytrToken] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); + const [enterpriseDialogOpen, setEnterpriseDialogOpen] = useState(false); const [loadingPlan, setLoadingPlan] = useState(null); + const [discountCode, setDiscountCode] = useState(""); + const [discountApplied, setDiscountApplied] = useState(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]; + 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({

Planınızı seçin

- İhtiyacınıza göre istediğiniz zaman değiştirebilirsiniz. + Dilediğiniz zaman daha üst bir plana geçebilirsiniz.

@@ -117,13 +192,48 @@ export function UpgradeSection({
+ + {/* İndirim kodu */} +
+
+ + { + 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" + /> +
+ + {discountApplied !== null && ( + + + %{Math.round(discountApplied * 100)} indirim + + )} +
{/* 3 plan kartı */}
{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 && (
@@ -158,6 +268,11 @@ export function UpgradeSection({ {displayPrice.toLocaleString("tr-TR")} ₺/ay + {discountApplied !== null && !isCurrent && !isLower && ( + + {planPriceDisplay(plan, period).toLocaleString("tr-TR")}₺ + + )}
{period === "yearly" ? (

@@ -179,39 +294,65 @@ export function UpgradeSection({ {isCurrent ? ( -

- - -
- ) : ( + + ) : isLower ? ( + + ) : isEnterprise ? ( + ) : ( +
+ + {prorationAmount !== null && ( +

+ Bugün sadece{" "} + + {prorationAmount.toLocaleString("tr-TR")} ₺ + {" "} + ödersiniz (kalan süre mahsup edilir) +

+ )} +
)} ); })}
+ +

+ Abonelik iptali için{" "} + + info@kovakyazilim.com + {" "} + ile iletişime geçin. +

+ {/* PayTR iframe dialog */} {paytrEnabled && ( )} + + {/* Enterprise inquiry dialog */} + + + + + + Enterprise Plan Teklifi + + +
+

+ 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. +

+ + {/* Ekip büyüklüğü */} +
+ +
+ {["1-3", "4-10", "11-25", "25+"].map((opt) => ( + + ))} +
+
+ + {/* Ilan sayısı */} +
+ +
+ {["1-50", "51-200", "201-500", "500+"].map((opt) => ( + + ))} +
+
+ + {/* Özel modül */} +
+ +
+ {[ + { value: "yes", label: "Evet" }, + { value: "no", label: "Hayır" }, + { value: "maybe", label: "Değerlendiriyorum" }, + ].map((opt) => ( + + ))} +
+
+ + {/* Notlar */} +
+ +