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 { useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
startCheckoutAction,
|
startCheckoutAction,
|
||||||
downgradeToFreeAction,
|
|
||||||
getPayTRTokenAction,
|
getPayTRTokenAction,
|
||||||
|
requestEnterpriseInquiryAction,
|
||||||
} from "@/lib/appwrite/subscription-actions";
|
} 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";
|
import type { TenantPlan, PlanPeriod } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
const PLAN_ICONS: Record<string, React.ReactNode> = {
|
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" />,
|
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({
|
export function UpgradeSection({
|
||||||
currentPlan,
|
currentPlan,
|
||||||
currentPeriod,
|
currentPeriod,
|
||||||
|
planExpiresAt,
|
||||||
paytrEnabled,
|
paytrEnabled,
|
||||||
}: {
|
}: {
|
||||||
currentPlan: TenantPlan;
|
currentPlan: TenantPlan;
|
||||||
currentPeriod?: PlanPeriod | null;
|
currentPeriod?: PlanPeriod | null;
|
||||||
|
planExpiresAt?: string | null;
|
||||||
paytrEnabled?: boolean;
|
paytrEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [period, setPeriod] = useState<PlanPeriod>(currentPeriod ?? "monthly");
|
const [period, setPeriod] = useState<PlanPeriod>(currentPeriod ?? "monthly");
|
||||||
const [paytrToken, setPaytrToken] = useState<string | null>(null);
|
const [paytrToken, setPaytrToken] = useState<string | null>(null);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [enterpriseDialogOpen, setEnterpriseDialogOpen] = useState(false);
|
||||||
const [loadingPlan, setLoadingPlan] = useState<string | null>(null);
|
const [loadingPlan, setLoadingPlan] = useState<string | null>(null);
|
||||||
|
const [discountCode, setDiscountCode] = useState("");
|
||||||
|
const [discountApplied, setDiscountApplied] = useState<number | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [enterpriseForm, setEnterpriseForm] = useState({
|
||||||
|
teamSize: "",
|
||||||
|
listingCount: "",
|
||||||
|
needsCustomDev: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
const plans = Object.values(PLAN_CATALOG);
|
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) {
|
function handleCheckout(planId: string) {
|
||||||
if (paytrEnabled) {
|
if (paytrEnabled) {
|
||||||
@@ -46,6 +103,7 @@ export function UpgradeSection({
|
|||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.set("plan", planId);
|
fd.set("plan", planId);
|
||||||
fd.set("period", period);
|
fd.set("period", period);
|
||||||
|
if (discountApplied !== null) fd.set("discountCode", discountCode.trim().toUpperCase());
|
||||||
const token = await getPayTRTokenAction(fd);
|
const token = await getPayTRTokenAction(fd);
|
||||||
setPaytrToken(token);
|
setPaytrToken(token);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
@@ -72,13 +130,30 @@ export function UpgradeSection({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDowngrade() {
|
function handleEnterpriseInquiry() {
|
||||||
try {
|
if (!enterpriseForm.teamSize || !enterpriseForm.listingCount || !enterpriseForm.needsCustomDev) {
|
||||||
await downgradeToFreeAction();
|
toast.error("Lütfen tüm zorunlu alanları doldurun.");
|
||||||
} catch (e) {
|
return;
|
||||||
if (e instanceof Error && e.message.includes("NEXT_REDIRECT")) throw e;
|
|
||||||
toast.error("İşlem başarısız.");
|
|
||||||
}
|
}
|
||||||
|
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 (
|
return (
|
||||||
@@ -89,7 +164,7 @@ export function UpgradeSection({
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold tracking-tight">Planınızı seçin</h2>
|
<h2 className="text-xl font-bold tracking-tight">Planınızı seçin</h2>
|
||||||
<p className="text-muted-foreground text-sm mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 rounded-full border bg-muted/40 p-1 text-sm">
|
<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>
|
</Badge>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 3 plan kartı */}
|
{/* 3 plan kartı */}
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
{plans.map((plan) => {
|
{plans.map((plan) => {
|
||||||
const isCurrent = currentPlan === plan.id;
|
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;
|
const isLoading = isPending && loadingPlan === plan.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,7 +243,7 @@ export function UpgradeSection({
|
|||||||
plan.highlight
|
plan.highlight
|
||||||
? "border-primary shadow-md ring-1 ring-primary/20"
|
? "border-primary shadow-md ring-1 ring-primary/20"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
} ${isLower ? "opacity-50" : ""}`}
|
||||||
>
|
>
|
||||||
{plan.highlight && (
|
{plan.highlight && (
|
||||||
<div className="absolute -top-3.5 inset-x-0 flex justify-center">
|
<div className="absolute -top-3.5 inset-x-0 flex justify-center">
|
||||||
@@ -158,6 +268,11 @@ export function UpgradeSection({
|
|||||||
{displayPrice.toLocaleString("tr-TR")}
|
{displayPrice.toLocaleString("tr-TR")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-sm mb-1">₺/ay</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>
|
</div>
|
||||||
{period === "yearly" ? (
|
{period === "yearly" ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -179,39 +294,65 @@ export function UpgradeSection({
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{isCurrent ? (
|
{isCurrent ? (
|
||||||
<div className="space-y-2">
|
<Button variant="outline" className="w-full" disabled>
|
||||||
<Button variant="outline" className="w-full" disabled>
|
Mevcut Planınız
|
||||||
Mevcut Planınız
|
</Button>
|
||||||
</Button>
|
) : isLower ? (
|
||||||
<button
|
<Button variant="outline" className="w-full" disabled>
|
||||||
onClick={handleDowngrade}
|
Plan düşürülemez
|
||||||
className="w-full text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
|
</Button>
|
||||||
>
|
) : isEnterprise ? (
|
||||||
Ücretsiz plana geç
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full gap-2"
|
className="w-full gap-2"
|
||||||
variant={plan.highlight ? "default" : "outline"}
|
variant="outline"
|
||||||
|
onClick={() => setEnterpriseDialogOpen(true)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => handleCheckout(plan.id)}
|
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
<Envelope className="h-4 w-4" />
|
||||||
<CircleNotch className="h-4 w-4 animate-spin" />
|
Teklif Al
|
||||||
) : (
|
|
||||||
PLAN_ICONS[plan.id]
|
|
||||||
)}
|
|
||||||
{isLoading ? "Yükleniyor..." : `${plan.name}'a Geç`}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<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>
|
||||||
|
{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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
{/* PayTR iframe dialog */}
|
||||||
{paytrEnabled && (
|
{paytrEnabled && (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
@@ -243,6 +384,136 @@ export function UpgradeSection({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
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 { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
import { getEffectivePlan, getPlanUsage } from "@/lib/appwrite/plan-limits";
|
import { getEffectivePlan, getPlanUsage } from "@/lib/appwrite/plan-limits";
|
||||||
@@ -30,7 +31,6 @@ export default async function BillingPage({
|
|||||||
|
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const upgraded = params.upgraded === "1";
|
const upgraded = params.upgraded === "1";
|
||||||
const downgraded = params.downgraded === "1";
|
|
||||||
|
|
||||||
const plan = getEffectivePlan(ctx);
|
const plan = getEffectivePlan(ctx);
|
||||||
const { usage } = await getPlanUsage(ctx);
|
const { usage } = await getPlanUsage(ctx);
|
||||||
@@ -40,25 +40,27 @@ export default async function BillingPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-6 px-6 pt-4">
|
<div className="flex-1 space-y-6 px-6 pt-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<p className="text-muted-foreground text-sm">{officeName}</p>
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Plan & Faturalama</h1>
|
<p className="text-muted-foreground text-sm">{officeName}</p>
|
||||||
<p className="text-muted-foreground text-sm">
|
<h1 className="text-2xl font-bold tracking-tight">Plan & Faturalama</h1>
|
||||||
Mevcut planınızı görüntüleyin ve yönetin.
|
<p className="text-muted-foreground text-sm">
|
||||||
</p>
|
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>
|
</div>
|
||||||
|
|
||||||
{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" />
|
||||||
Tebrikler! Planınız başarıyla yükseltildi. KovakEmlak Pro'ya hoş geldiniz.
|
Tebrikler! Planınız başarıyla yükseltildi.
|
||||||
</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.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -72,6 +74,7 @@ export default async function BillingPage({
|
|||||||
<UpgradeSection
|
<UpgradeSection
|
||||||
currentPlan={plan}
|
currentPlan={plan}
|
||||||
currentPeriod={ctx.settings?.planPeriod ?? null}
|
currentPeriod={ctx.settings?.planPeriod ?? null}
|
||||||
|
planExpiresAt={ctx.settings?.planExpiresAt ?? null}
|
||||||
paytrEnabled={paytrEnabled}
|
paytrEnabled={paytrEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
return new Response("FAILED", { status: 400 });
|
return new Response("FAILED", { status: 400 });
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.error("[paytr-callback]", e);
|
console.error("[paytr-callback]", e);
|
||||||
return new Response("FAILED", { status: 500 });
|
return new Response("FAILED", { status: 500 });
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
|
||||||
import type { Icon } from "@/lib/icons";
|
import type { Icon } from "@/lib/icons";
|
||||||
@@ -145,6 +146,10 @@ const navGroups: NavGroup[] = [
|
|||||||
url: "/settings/billing",
|
url: "/settings/billing",
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
minRole: "owner" as ShellRole,
|
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;
|
pendingMatchCount?: number;
|
||||||
role?: ShellRole;
|
role?: ShellRole;
|
||||||
}) {
|
}) {
|
||||||
|
const { isMobile, setOpenMobile } = useSidebar();
|
||||||
|
const closeMobile = () => { if (isMobile) setOpenMobile(false) };
|
||||||
|
|
||||||
const groups = navGroups
|
const groups = navGroups
|
||||||
.map((group) => ({
|
.map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
@@ -184,7 +192,7 @@ export function AppSidebar({
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton size="lg" asChild>
|
<SidebarMenuButton size="lg" asChild>
|
||||||
<Link href="/dashboard">
|
<Link href="/dashboard" onClick={closeMobile}>
|
||||||
{company.logoUrl ? (
|
{company.logoUrl ? (
|
||||||
<div className="bg-background flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg border">
|
<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 */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
SidebarMenuSub,
|
SidebarMenuSub,
|
||||||
SidebarMenuSubButton,
|
SidebarMenuSubButton,
|
||||||
SidebarMenuSubItem,
|
SidebarMenuSubItem,
|
||||||
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
export function NavMain({
|
export function NavMain({
|
||||||
@@ -39,6 +40,8 @@ export function NavMain({
|
|||||||
}[]
|
}[]
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const { isMobile, setOpenMobile } = useSidebar()
|
||||||
|
const closeMobile = () => { if (isMobile) setOpenMobile(false) }
|
||||||
|
|
||||||
// Returns the url of the best-matching subitem (longest prefix wins).
|
// Returns the url of the best-matching subitem (longest prefix wins).
|
||||||
// Handles detail pages: /customers/123 correctly activates the /customers subitem
|
// 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}>
|
<SidebarMenuSubButton asChild className="cursor-pointer" isActive={activeSubUrl === subItem.url}>
|
||||||
<Link
|
<Link
|
||||||
href={subItem.url}
|
href={subItem.url}
|
||||||
|
onClick={closeMobile}
|
||||||
target={(item.title === "Auth Pages" || item.title === "Errors") ? "_blank" : undefined}
|
target={(item.title === "Auth Pages" || item.title === "Errors") ? "_blank" : undefined}
|
||||||
rel={(item.title === "Auth Pages" || item.title === "Errors") ? "noopener noreferrer" : 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 + "/")}>
|
<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 />}
|
{item.icon && <item.icon />}
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ export function NavUser({
|
|||||||
}: {
|
}: {
|
||||||
user: { name: string; email: string };
|
user: { name: string; email: string };
|
||||||
}) {
|
}) {
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile, setOpenMobile } = useSidebar();
|
||||||
|
const closeMobile = () => { if (isMobile) setOpenMobile(false) };
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const handleSignOut = () => {
|
const handleSignOut = () => {
|
||||||
@@ -85,19 +86,19 @@ export function NavUser({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link href="/settings/account">
|
<Link href="/settings/account" onClick={closeMobile}>
|
||||||
<UserCircle />
|
<UserCircle />
|
||||||
Profil
|
Profil
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing" onClick={closeMobile}>
|
||||||
<CreditCard />
|
<CreditCard />
|
||||||
Plan & Faturalama
|
Plan & Faturalama
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link href="/settings/notifications">
|
<Link href="/settings/notifications" onClick={closeMobile}>
|
||||||
<BellSimple />
|
<BellSimple />
|
||||||
Bildirimler
|
Bildirimler
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const TABLES = {
|
|||||||
inviteLinks: "invite_links",
|
inviteLinks: "invite_links",
|
||||||
deals: "deals",
|
deals: "deals",
|
||||||
passwordResets: "password_resets",
|
passwordResets: "password_resets",
|
||||||
|
paymentEvents: "payment_events",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TableId = (typeof TABLES)[keyof typeof TABLES];
|
export type TableId = (typeof TABLES)[keyof typeof TABLES];
|
||||||
@@ -211,6 +212,16 @@ export interface TenantSettings extends Row {
|
|||||||
planProvider?: string;
|
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 =
|
export type PropertyFeature =
|
||||||
| "balkon"
|
| "balkon"
|
||||||
| "otopark"
|
| "otopark"
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
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 { 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, planPrice } from "./subscription-types";
|
import { PLAN_CATALOG, PLAN_RANK, planPrice, validateDiscountCode } 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";
|
import { getPayTRToken } from "../payments/paytr";
|
||||||
@@ -25,6 +25,7 @@ export async function activatePlanInDb(
|
|||||||
plan: TenantPlan,
|
plan: TenantPlan,
|
||||||
provider: string,
|
provider: string,
|
||||||
period: PlanPeriod = "monthly",
|
period: PlanPeriod = "monthly",
|
||||||
|
opts?: { amount?: number; orderId?: string; discountCode?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
|
|
||||||
@@ -46,6 +47,24 @@ export async function activatePlanInDb(
|
|||||||
planExpiresAt: expires.toISOString(),
|
planExpiresAt: expires.toISOString(),
|
||||||
planProvider: provider,
|
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> {
|
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ı) ─────────────────────────────────────────
|
// ── Mock checkout (geliştirme ortamı) ─────────────────────────────────────────
|
||||||
|
|
||||||
export async function startMockCheckoutAction(formData: FormData): Promise<void> {
|
export async function startMockCheckoutAction(formData: FormData): Promise<void> {
|
||||||
const plan = String(formData.get("plan") ?? "") as TenantPlan;
|
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();
|
const ctx = await requireTenant();
|
||||||
requireRole(ctx, ["owner"]);
|
requireRole(ctx, ["owner"]);
|
||||||
|
assertUpgrade(getEffectivePlan(ctx), plan as TenantPlan);
|
||||||
|
|
||||||
// Mock: direkt aktive et
|
const catalog = PLAN_CATALOG[plan as Exclude<TenantPlan, "free">];
|
||||||
await activatePlanInDb(ctx.tenantId, plan, "mock");
|
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");
|
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> {
|
export async function startShopierCheckoutAction(formData: FormData): Promise<void> {
|
||||||
const plan = String(formData.get("plan") ?? "") as TenantPlan;
|
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();
|
const ctx = await requireTenant();
|
||||||
requireRole(ctx, ["owner"]);
|
requireRole(ctx, ["owner"]);
|
||||||
|
assertUpgrade(getEffectivePlan(ctx), plan as TenantPlan);
|
||||||
|
|
||||||
const storeUrl = getShopierPlanUrl(plan);
|
const storeUrl = getShopierPlanUrl(plan);
|
||||||
if (!storeUrl) throw new Error("Shopier mağaza URL'i ayarlanmamış.");
|
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);
|
redirect(storeUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,12 +145,13 @@ export async function startShopierCheckoutAction(formData: FormData): Promise<vo
|
|||||||
|
|
||||||
export async function startPolarCheckoutAction(formData: FormData): Promise<void> {
|
export async function startPolarCheckoutAction(formData: FormData): Promise<void> {
|
||||||
const plan = String(formData.get("plan") ?? "") as TenantPlan;
|
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();
|
const ctx = await requireTenant();
|
||||||
requireRole(ctx, ["owner"]);
|
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 orderId = generateOrderId();
|
||||||
const appUrl = process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3001";
|
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`,
|
successUrl: `${appUrl}/settings/billing?upgraded=1`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// orderId'yi tenant_settings'e geçici olarak kaydedebiliriz ama
|
void catalog;
|
||||||
// minimal yaklaşımda metadata.tenant_id yeterli — webhook okur
|
|
||||||
void catalog; // fiyat bilgisi ileride log için kullanılabilir
|
|
||||||
redirect(checkout.url);
|
redirect(checkout.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +171,7 @@ export async function startPolarCheckoutAction(formData: FormData): Promise<void
|
|||||||
export async function getPayTRTokenAction(formData: FormData): Promise<string> {
|
export async function getPayTRTokenAction(formData: FormData): Promise<string> {
|
||||||
const planId = String(formData.get("plan") ?? "") as Exclude<TenantPlan, "free">;
|
const planId = String(formData.get("plan") ?? "") as Exclude<TenantPlan, "free">;
|
||||||
const period = String(formData.get("period") ?? "monthly") as PlanPeriod;
|
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 (!["starter", "pro", "enterprise"].includes(planId)) throw new Error("Geçersiz plan.");
|
||||||
if (!["monthly", "yearly"].includes(period)) throw new Error("Geçersiz dönem.");
|
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();
|
const ctx = await requireTenant();
|
||||||
requireRole(ctx, ["owner"]);
|
requireRole(ctx, ["owner"]);
|
||||||
|
|
||||||
|
const currentPlan = getEffectivePlan(ctx);
|
||||||
|
assertUpgrade(currentPlan, planId as TenantPlan);
|
||||||
|
|
||||||
const catalog = PLAN_CATALOG[planId];
|
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_URL: server-to-server callback (HTTPS zorunlu, prod'da veya ngrok)
|
||||||
// APP_BROWSER_URL: browser redirect (dev'de localhost, prod'da aynı domain)
|
// 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}
|
// Format: {tenantId}T{timestamp}{random}P{plan}X{period}
|
||||||
const merchantOid = `${ctx.tenantId}T${timestamp}${random}P${planId}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]> = [
|
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),
|
price.toFixed(2),
|
||||||
1,
|
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> {
|
export async function startCheckoutAction(formData: FormData): Promise<void> {
|
||||||
if (isPolarEnabled()) return startPolarCheckoutAction(formData);
|
if (isPolarEnabled()) return startPolarCheckoutAction(formData);
|
||||||
@@ -185,13 +251,63 @@ export async function startCheckoutAction(formData: FormData): Promise<void> {
|
|||||||
return startMockCheckoutAction(formData);
|
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();
|
const ctx = await requireTenant();
|
||||||
requireRole(ctx, ["owner"]);
|
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 ────────────────────────────────────────────────────────
|
// ── Mevcut plan bilgisi ────────────────────────────────────────────────────────
|
||||||
@@ -209,3 +325,23 @@ export async function getCurrentPlanAction(): Promise<{
|
|||||||
provider: ctx.settings?.planProvider ?? null,
|
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";
|
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 = {
|
export type PlanCatalogEntry = {
|
||||||
id: TenantPlan;
|
id: TenantPlan;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user