feat(billing): indirim kodu sistemi (admin panel + Stripe promo + ödeme)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Admin paneli (admin@sase.tr) kod üretir/takip eder, ödeme sayfası kodu uygular. Kodlar Stripe Coupon + Promotion Code olarak yaşar (limit/süre/ kullanım Stripe redemption'da enforce edilir); yerel discount_codes aynası panel listeleme + checkout-anı preview içindir. - migration 0022_discount_codes + schema (discount_codes) - DiscountCodesModule: admin CRUD (@Roles admin) + /discount-codes/validate (login) - StripeService: createPromotion / deactivatePromotion / getPromotionRedemptions / previewDiscount + resolvePlanAmount; createCheckoutSession opsiyonel discountCode -> discounts[{promotion_code}] + metadata.discount_code; webhook gerçek tahsilatı (session.amount_total) yazar + ayna times_redeemed++ - web: /dashboard/admin/discount-codes paneli + ödeme adımı indirim alanı (validate preview -> indirimli özet -> kod checkout'a); tr/en çeviriler - 7 birim test (previewDiscount: % / sabit / süre / geçersiz/pasif/dolu/limit) Not: 2026-04-22 Stripe API promotionCodes.create `promotion:{type:'coupon'}` kullanıyor (üst-seviye `coupon` değil). dev'de Stripe key yok -> CREATE dev'de 503; E2E için dev'e Stripe TEST key gerekir. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,7 @@ import {
|
||||
Shield,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Ticket,
|
||||
Trophy,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@@ -93,6 +94,7 @@ const adminItems: readonly NavItem[] = [
|
||||
{ to: "/dashboard/admin/analytics", labelKey: "nav.analytics", icon: BarChart3 },
|
||||
{ to: "/dashboard/admin/copy-logs", labelKey: "nav.copyLogs", icon: Copy },
|
||||
{ to: "/dashboard/admin/referrals", labelKey: "nav.referrals", icon: Share2 },
|
||||
{ to: "/dashboard/admin/discount-codes", labelKey: "nav.discountCodes", icon: Ticket },
|
||||
{ to: "/dashboard/service-test", labelKey: "nav.serviceTest", icon: FlaskConical },
|
||||
];
|
||||
|
||||
|
||||
432
apps/web/src/routes/dashboard/admin/discount-codes.tsx
Normal file
432
apps/web/src/routes/dashboard/admin/discount-codes.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ApiError, api } from "@/lib/api-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { formatTRY } from "@sase/shared";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
Skeleton,
|
||||
} from "@sase/ui";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Copy, Plus, RefreshCw, Ticket } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/admin/discount-codes")({
|
||||
component: AdminDiscountCodesPage,
|
||||
});
|
||||
|
||||
type DiscountStatus = "active" | "inactive" | "expired" | "exhausted";
|
||||
|
||||
interface DiscountCode {
|
||||
id: string;
|
||||
code: string;
|
||||
discountType: "percentage" | "fixed";
|
||||
percentOff: number | null;
|
||||
amountOff: number | null;
|
||||
duration: "once" | "repeating" | "forever";
|
||||
durationInMonths: number | null;
|
||||
maxRedemptions: number | null;
|
||||
timesRedeemed: number;
|
||||
expiresAt: string | null;
|
||||
isActive: boolean;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
status: DiscountStatus;
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<DiscountStatus, "default" | "secondary" | "destructive" | "outline"> =
|
||||
{
|
||||
active: "default",
|
||||
inactive: "secondary",
|
||||
expired: "outline",
|
||||
exhausted: "destructive",
|
||||
};
|
||||
const STATUS_LABEL: Record<DiscountStatus, string> = {
|
||||
active: "Aktif",
|
||||
inactive: "Pasif",
|
||||
expired: "Süresi doldu",
|
||||
exhausted: "Limit doldu",
|
||||
};
|
||||
const SKELETON_KEYS = ["s1", "s2", "s3", "s4", "s5"];
|
||||
|
||||
const emptyForm = {
|
||||
code: "",
|
||||
discountType: "percentage" as "percentage" | "fixed",
|
||||
percentOff: "20",
|
||||
amountOffTL: "",
|
||||
duration: "once" as "once" | "repeating" | "forever",
|
||||
durationInMonths: "3",
|
||||
maxRedemptions: "",
|
||||
expiresAt: "",
|
||||
note: "",
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function discountLabel(c: DiscountCode) {
|
||||
return c.discountType === "percentage" ? `%${c.percentOff}` : formatTRY(c.amountOff ?? 0);
|
||||
}
|
||||
|
||||
function durationLabel(c: DiscountCode) {
|
||||
if (c.duration === "forever") return "Her ödemede";
|
||||
if (c.duration === "repeating") return `İlk ${c.durationInMonths} ay`;
|
||||
return "İlk ödeme";
|
||||
}
|
||||
|
||||
function AdminDiscountCodesPage() {
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && user?.role !== "admin") {
|
||||
navigate({ to: "/dashboard/search" });
|
||||
}
|
||||
}, [authLoading, user, navigate]);
|
||||
|
||||
const { data: codes, isLoading } = useQuery({
|
||||
queryKey: ["admin", "discount-codes"],
|
||||
queryFn: () => api.get<DiscountCode[]>("/discount-codes"),
|
||||
enabled: user?.role === "admin",
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: Record<string, unknown>) => api.post("/discount-codes", payload),
|
||||
onSuccess: () => {
|
||||
toast.success("İndirim kodu oluşturuldu");
|
||||
setDialogOpen(false);
|
||||
setForm(emptyForm);
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "discount-codes"] });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof ApiError ? err.message : "Kod oluşturulamadı");
|
||||
},
|
||||
});
|
||||
|
||||
const deactivateMutation = useMutation({
|
||||
mutationFn: (id: string) => api.patch(`/discount-codes/${id}/deactivate`),
|
||||
onSuccess: () => {
|
||||
toast.success("Kod devre dışı bırakıldı");
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "discount-codes"] });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof ApiError ? err.message : "İşlem başarısız");
|
||||
},
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => api.post<{ synced: number; updated: number }>("/discount-codes/sync"),
|
||||
onSuccess: (res) => {
|
||||
toast.success(`Stripe ile eşitlendi (${res.updated} güncellendi)`);
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "discount-codes"] });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof ApiError ? err.message : "Eşitleme başarısız");
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const payload: Record<string, unknown> = {
|
||||
discountType: form.discountType,
|
||||
duration: form.duration,
|
||||
};
|
||||
if (form.code.trim()) payload.code = form.code.trim();
|
||||
if (form.discountType === "percentage") payload.percentOff = Number(form.percentOff);
|
||||
else payload.amountOff = Math.round(Number(form.amountOffTL) * 100);
|
||||
if (form.duration === "repeating") payload.durationInMonths = Number(form.durationInMonths);
|
||||
if (form.maxRedemptions.trim()) payload.maxRedemptions = Number(form.maxRedemptions);
|
||||
if (form.expiresAt) payload.expiresAt = new Date(`${form.expiresAt}T23:59:59`).toISOString();
|
||||
if (form.note.trim()) payload.note = form.note.trim();
|
||||
createMutation.mutate(payload);
|
||||
}
|
||||
|
||||
function copyCode(code: string) {
|
||||
navigator.clipboard?.writeText(code).then(
|
||||
() => toast.success(`${code} kopyalandı`),
|
||||
() => toast.error("Kopyalanamadı"),
|
||||
);
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (user?.role !== "admin") return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ticket className="h-6 w-6 text-brand" />
|
||||
<h2 className="text-2xl font-bold">İndirim Kodları</h2>
|
||||
<Badge variant="outline">{codes?.length ?? 0} kod</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
>
|
||||
<RefreshCw className={`mr-1 h-4 w-4 ${syncMutation.isPending ? "animate-spin" : ""}`} />
|
||||
Stripe ile eşitle
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Yeni Kod
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Kodlar Stripe'da kupon + promosyon kodu olarak oluşturulur; süre ve kullanım limiti ödeme
|
||||
sırasında Stripe tarafından uygulanır. Kullanım sayıları ödeme tamamlandıkça güncellenir
|
||||
(anlık değilse "Stripe ile eşitle").
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{SKELETON_KEYS.map((k) => (
|
||||
<Skeleton key={k} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !codes || codes.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
Henüz indirim kodu yok. "Yeni Kod" ile oluşturun.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="hidden items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground lg:grid lg:grid-cols-7">
|
||||
<span className="col-span-2">Kod</span>
|
||||
<span>İndirim</span>
|
||||
<span>Süre</span>
|
||||
<span>Kullanım</span>
|
||||
<span>Bitiş</span>
|
||||
<span className="text-right">İşlem</span>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{codes.map((c) => (
|
||||
<div key={c.id} className="grid items-center gap-4 px-6 py-4 lg:grid-cols-7">
|
||||
<div className="col-span-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyCode(c.code)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 font-mono text-sm font-semibold transition-colors hover:bg-muted/70"
|
||||
title="Kopyala"
|
||||
>
|
||||
{c.code}
|
||||
<Copy className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
|
||||
</div>
|
||||
<div className="font-medium">{discountLabel(c)}</div>
|
||||
<div className="text-sm text-muted-foreground">{durationLabel(c)}</div>
|
||||
<div className="text-sm">
|
||||
{c.timesRedeemed} / {c.maxRedemptions ?? "∞"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{c.expiresAt ? formatDate(c.expiresAt) : "—"}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{c.isActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (window.confirm(`${c.code} kodunu devre dışı bırak?`)) {
|
||||
deactivateMutation.mutate(c.id);
|
||||
}
|
||||
}}
|
||||
disabled={deactivateMutation.isPending}
|
||||
>
|
||||
Devre dışı bırak
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{c.note && (
|
||||
<p className="col-span-full text-xs text-muted-foreground lg:col-span-7">
|
||||
{c.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Yeni İndirim Kodu</DialogTitle>
|
||||
<DialogDescription>
|
||||
Boş bırakırsanız rastgele bir kod üretilir. Tutarı TL olarak girin.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">Kod (opsiyonel)</Label>
|
||||
<Input
|
||||
id="code"
|
||||
placeholder="Örn. YILBASI25 — boş = otomatik"
|
||||
value={form.code}
|
||||
onChange={(e) => setForm((p) => ({ ...p, code: e.target.value.toUpperCase() }))}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="discountType">İndirim türü</Label>
|
||||
<select
|
||||
id="discountType"
|
||||
value={form.discountType}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
discountType: e.target.value as "percentage" | "fixed",
|
||||
}))
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="percentage">Yüzde (%)</option>
|
||||
<option value="fixed">Sabit tutar (₺)</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.discountType === "percentage" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="percentOff">Yüzde (1-100)</Label>
|
||||
<Input
|
||||
id="percentOff"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.percentOff}
|
||||
onChange={(e) => setForm((p) => ({ ...p, percentOff: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amountOffTL">Tutar (₺)</Label>
|
||||
<Input
|
||||
id="amountOffTL"
|
||||
type="number"
|
||||
min={1}
|
||||
step="0.01"
|
||||
placeholder="100"
|
||||
value={form.amountOffTL}
|
||||
onChange={(e) => setForm((p) => ({ ...p, amountOffTL: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duration">Abonelikte süre</Label>
|
||||
<select
|
||||
id="duration"
|
||||
value={form.duration}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
duration: e.target.value as "once" | "repeating" | "forever",
|
||||
}))
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="once">Sadece ilk ödeme</option>
|
||||
<option value="repeating">Belirli ay sayısı</option>
|
||||
<option value="forever">Her yenilemede</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.duration === "repeating" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="durationInMonths">Ay sayısı (1-36)</Label>
|
||||
<Input
|
||||
id="durationInMonths"
|
||||
type="number"
|
||||
min={1}
|
||||
max={36}
|
||||
value={form.durationInMonths}
|
||||
onChange={(e) => setForm((p) => ({ ...p, durationInMonths: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxRedemptions">Kullanım limiti</Label>
|
||||
<Input
|
||||
id="maxRedemptions"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Boş = sınırsız"
|
||||
value={form.maxRedemptions}
|
||||
onChange={(e) => setForm((p) => ({ ...p, maxRedemptions: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expiresAt">Son kullanma</Label>
|
||||
<Input
|
||||
id="expiresAt"
|
||||
type="date"
|
||||
value={form.expiresAt}
|
||||
onChange={(e) => setForm((p) => ({ ...p, expiresAt: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">Not (opsiyonel)</Label>
|
||||
<Input
|
||||
id="note"
|
||||
placeholder="Kampanya adı / kime verildiği"
|
||||
value={form.note}
|
||||
onChange={(e) => setForm((p) => ({ ...p, note: e.target.value }))}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Oluşturuluyor..." : "Oluştur"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Separator,
|
||||
Skeleton,
|
||||
} from "@sase/ui";
|
||||
@@ -1245,6 +1246,20 @@ export function OrderSummary({
|
||||
);
|
||||
}
|
||||
|
||||
type ValidDiscount = {
|
||||
valid: true;
|
||||
code: string;
|
||||
discountType: "percentage" | "fixed";
|
||||
percentOff: number | null;
|
||||
amountOff: number | null;
|
||||
duration: string;
|
||||
durationInMonths: number | null;
|
||||
originalAmount: number;
|
||||
discountAmount: number;
|
||||
finalAmount: number;
|
||||
};
|
||||
type DiscountPreview = ValidDiscount | { valid: false; reason: string; message: string };
|
||||
|
||||
function PaymentMethodSection({
|
||||
planKey,
|
||||
period,
|
||||
@@ -1292,20 +1307,133 @@ function PaymentMethodSection({
|
||||
? selectedBrandNames.join(", ")
|
||||
: `${brandIds.length} ${t("subscription.brandsSelected")}`;
|
||||
|
||||
// İndirim kodu: kullanıcı girer → /discount-codes/validate fiyatı doğrular.
|
||||
// Uygulanınca özet + ödeme butonu indirimli tutarı gösterir; kod checkout'a
|
||||
// geçirilir ve Stripe redemption'da yeniden doğrular.
|
||||
const [discountInput, setDiscountInput] = useState("");
|
||||
const [appliedDiscount, setAppliedDiscount] = useState<ValidDiscount | null>(null);
|
||||
|
||||
// Plan/dönem değişince fiyat değişir → uygulanmış indirimi sıfırla (yeniden
|
||||
// doğrulanması gerekir).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: planKey/period are intentional reset triggers
|
||||
useEffect(() => {
|
||||
setAppliedDiscount(null);
|
||||
setDiscountInput("");
|
||||
}, [planKey, period]);
|
||||
|
||||
const validateDiscount = useMutation({
|
||||
mutationFn: (code: string) =>
|
||||
api.post<DiscountPreview>("/discount-codes/validate", {
|
||||
code,
|
||||
planKey,
|
||||
billingPeriod: period,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
if (res.valid) {
|
||||
setAppliedDiscount(res);
|
||||
toast.success(t("subscription.discount.applied"));
|
||||
} else {
|
||||
setAppliedDiscount(null);
|
||||
toast.error(res.message || t("subscription.discount.invalid"));
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setAppliedDiscount(null);
|
||||
toast.error(t("subscription.discount.invalid"));
|
||||
},
|
||||
});
|
||||
|
||||
const effectiveTotal = appliedDiscount ? appliedDiscount.finalAmount : totalAmount;
|
||||
const durationNote = !appliedDiscount
|
||||
? ""
|
||||
: appliedDiscount.duration === "forever"
|
||||
? t("subscription.discount.durationForever")
|
||||
: appliedDiscount.duration === "repeating" && appliedDiscount.durationInMonths
|
||||
? t("subscription.discount.durationRepeating", { months: appliedDiscount.durationInMonths })
|
||||
: t("subscription.discount.durationOnce");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<OrderSummary
|
||||
planKey={planKey}
|
||||
period={period}
|
||||
totalAmount={totalAmount}
|
||||
totalAmount={effectiveTotal}
|
||||
brandsValue={brandsValue}
|
||||
/>
|
||||
|
||||
{/* İndirim kodu */}
|
||||
<div className="rounded-2xl border border-border bg-background p-4">
|
||||
<p className="mb-2 text-sm font-medium">{t("subscription.discount.label")}</p>
|
||||
{appliedDiscount ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-emerald-300/40 bg-emerald-50/40 px-3 py-2 dark:border-emerald-900/40 dark:bg-emerald-950/10">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
|
||||
<Check className="h-4 w-4 shrink-0 text-emerald-500" />
|
||||
<span className="font-semibold">{appliedDiscount.code}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{appliedDiscount.discountType === "percentage"
|
||||
? `%${appliedDiscount.percentOff} ${t("subscription.discount.off")}`
|
||||
: `−${formatTRY(appliedDiscount.amountOff ?? 0)}`}
|
||||
{" · "}
|
||||
{durationNote}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAppliedDiscount(null);
|
||||
setDiscountInput("");
|
||||
}}
|
||||
>
|
||||
{t("subscription.discount.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("subscription.discount.newTotal")}{" "}
|
||||
<span className="font-semibold text-foreground">{formatTRY(effectiveTotal)}</span>{" "}
|
||||
<span className="text-muted-foreground line-through">{formatTRY(totalAmount)}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="discount-code"
|
||||
value={discountInput}
|
||||
onChange={(e) => setDiscountInput(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && discountInput.trim()) {
|
||||
e.preventDefault();
|
||||
validateDiscount.mutate(discountInput.trim());
|
||||
}
|
||||
}}
|
||||
placeholder={t("subscription.discount.placeholder")}
|
||||
maxLength={50}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => validateDiscount.mutate(discountInput.trim())}
|
||||
disabled={!discountInput.trim() || validateDiscount.isPending}
|
||||
>
|
||||
{validateDiscount.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
t("subscription.discount.apply")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<StripeCheckoutButton
|
||||
planKey={planKey}
|
||||
period={period}
|
||||
brandIds={brandIds}
|
||||
totalAmount={totalAmount}
|
||||
totalAmount={effectiveTotal}
|
||||
discountCode={appliedDiscount?.code}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-xs text-foreground/70">
|
||||
|
||||
Reference in New Issue
Block a user