chore(payments): remove EFT/bank-transfer method, keep Stripe only
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Removes the EFT (havale) payment surface across API, web, i18n, and docs.
Card payment via Stripe is now the only checkout path.

API (apps/api/src):
- Delete payments/bank-accounts/ module (controller + service + module)
- payments.service: drop EFT methods (createEftPayment, uploadEftReceipt,
  approveEft, rejectEft, getActiveBankInfo, getPendingEftPayments) and the
  BankAccountsService dependency
- payments.controller: drop /payments/eft, /payments/eft/:id/{receipt,approve,reject},
  /payments/bank-info, /payments/pending; keep /payments/me
- payments.module: drop BankAccountsModule import
- admin.service: drop pending EFT counter from getDashboardStats; drop
  getPendingPayments (admin EFT approval list)
- admin.controller: drop /admin/payments/pending endpoint
- admin.service.spec: drop the getPendingPayments + pendingPayments assertions

Web (apps/web/src):
- Delete components/payment/bank-transfer-card.tsx
- Delete routes/dashboard/admin/payments.tsx (EFT approval page); regenerate
  routeTree.gen.ts
- subscription/index.tsx: drop EFT tab from PaymentMethodSection, drop the
  paymentMethod state + handleEftCompleted, simplify ConfirmationCard to the
  Stripe-only path, drop unused imports (Tabs, BankTransferCard, Building2)
- billing.tsx: drop "eft" from method filter chips; keep methodLabels.eft and
  the legacy receipt download so historical EFT records still display
- dashboard.tsx + admin/index.tsx: drop /dashboard/admin/payments from the
  sidebar + admin quick-link grid; trim unused icon imports; KEYS_6 → KEYS_5
  for the now-5-card admin stats skeleton
- messages/{tr,en}.json: strip every EFT-only key under payment.* (eftTransfer,
  eftConfirmationDescription, eftPaid, eftStatus, bank.*, uploadReceipt* etc.)

Schema (apps/api/src/database/schema):
- bankAccounts table + payments.bankAccountId column are kept as-is: legacy
  EFT payments remain visible on the billing page and the schema preserves
  historical records.

Docs:
- README.md: "Stripe (kart) + EFT" → "Stripe (kart)"
- CLAUDE.md: stack table + PaymentsModule row + payments table note + route
  list updated; admin EFT approval route removed

Verification:
- pnpm typecheck: green (api + web + shared + config + ui)
- pnpm lint: green
- pnpm test: 20 web tests pass, 173 api tests pass
This commit is contained in:
Fusion
2026-05-15 17:24:06 +03:00
committed by Semih Yesilyurt
parent 1278ef679d
commit 65daf99b2e
20 changed files with 555 additions and 2135 deletions

View File

@@ -1,460 +0,0 @@
import { api } from "@/lib/api-client";
import { startAction } from "@/lib/faro";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { formatTRY } from "@sase/shared";
import { Badge, Button, Skeleton } from "@sase/ui";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Building2,
CheckCircle2,
Clock,
Copy,
FileText,
Hash,
Landmark,
QrCode,
Upload,
UploadCloud,
User,
} from "lucide-react";
import { useCallback, useRef, useState } from "react";
interface BankInfo {
id: string;
bankName: string;
accountHolder: string;
iban: string;
kolayAdres: string | null;
kolayAdresType: "email" | "phone" | "tckn" | null;
qrImageUrl: string | null;
}
interface EftCreateResponse {
paymentId: string;
bankInfo: BankInfo & { description: string };
}
interface BankTransferCardProps {
planKey: string;
period: "monthly" | "yearly";
brandIds: string[];
totalAmount: number;
onCompleted: () => void;
}
function formatIban(iban: string): string {
// Group into chunks of 4 for readability: TR33 0001 0000 ...
const compact = iban.replace(/\s+/g, "").toUpperCase();
return compact.match(/.{1,4}/g)?.join(" ") ?? compact;
}
function maskIban(iban: string): string {
const compact = iban.replace(/\s+/g, "");
if (compact.length < 10) return iban;
return `${compact.slice(0, 6)} •••• •••• •••• ${compact.slice(-4)}`;
}
export function BankTransferCard({
planKey,
period,
brandIds,
totalAmount,
onCompleted,
}: BankTransferCardProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [eftPayment, setEftPayment] = useState<EftCreateResponse | null>(null);
const [revealIban, setRevealIban] = useState(false);
const { data: bankInfo, isLoading } = useQuery({
queryKey: ["payments", "bank-info"],
queryFn: () => api.get<BankInfo | null>("/payments/bank-info"),
});
const eftMutation = useMutation({
mutationFn: () =>
api.post<EftCreateResponse>("/payments/eft", {
planKey,
billingPeriod: period,
brandIds,
}),
onSuccess: (data) => {
setEftPayment(data);
capture("eft_initiated", {
plan: planKey,
period,
amount: totalAmount,
bank_account_id: data.bankInfo.id,
});
},
onError: () => toast.error(t("errors.generic")),
});
const uploadMutation = useMutation({
mutationFn: (file: File) => {
const formData = new FormData();
formData.append("file", file);
return api.upload<{ receiptUrl: string }>(
`/payments/eft/${eftPayment?.paymentId}/receipt`,
formData,
);
},
onSuccess: () => {
capture("payment_success", {
method: "eft",
plan: planKey,
period,
amount: totalAmount,
payment_id: eftPayment?.paymentId,
});
toast.success(t("payment.receiptUploaded"));
onCompleted();
},
onError: () => toast.error(t("payment.uploadFailed")),
});
const validateAndSetFile = useCallback(
(file: File) => {
const valid = ["image/png", "image/jpeg", "application/pdf"];
if (!valid.includes(file.type)) {
toast.error(t("errors.invalidFileType"));
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error(t("errors.fileTooBig"));
return;
}
setUploadedFile(file);
},
[t],
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) validateAndSetFile(file);
},
[validateAndSetFile],
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) validateAndSetFile(file);
},
[validateAndSetFile],
);
function copy(text: string, eventName: string) {
navigator.clipboard.writeText(text);
toast.success(t("common.copied"));
capture(eventName, { bank_account_id: bankInfo?.id });
}
function handleEftProceed() {
startAction("payment-eft", { plan: planKey, period, amount: String(totalAmount) });
capture("payment_initiated", { method: "eft", plan: planKey, period, amount: totalAmount });
eftMutation.mutate();
}
function handleUploadReceipt() {
if (!uploadedFile) return;
startAction("receipt-upload", { paymentId: eftPayment?.paymentId ?? "" });
capture("receipt_uploaded", { payment_id: eftPayment?.paymentId });
uploadMutation.mutate(uploadedFile);
}
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-7 w-56" />
<Skeleton className="h-44 w-full rounded-2xl" />
<Skeleton className="h-12 w-full rounded-lg" />
</div>
);
}
if (!bankInfo) {
return (
<div className="rounded-2xl border border-dashed border-amber-300/60 bg-amber-50/60 p-6 text-sm text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/30 dark:text-amber-200">
<p className="mb-1 font-semibold">{t("payment.bank.unavailableTitle")}</p>
<p>{t("payment.bank.unavailableDescription")}</p>
</div>
);
}
const description = eftPayment?.bankInfo.description ?? t("payment.bank.previewDescription");
const displayIban = revealIban ? formatIban(bankInfo.iban) : maskIban(bankInfo.iban);
const compactIban = bankInfo.iban.replace(/\s+/g, "");
return (
<div className="space-y-6">
{/* Bank info hero card */}
<div className="relative overflow-hidden rounded-2xl border border-border bg-gradient-to-br from-background via-background to-primary/[0.04] p-6 shadow-sm">
<div className="pointer-events-none absolute -right-12 -top-12 h-44 w-44 rounded-full bg-primary/[0.08] blur-2xl" />
<div className="relative grid gap-6 sm:grid-cols-[1fr_auto]">
<div className="space-y-5">
<div>
<p className="text-xs uppercase tracking-wider text-muted-foreground">
{t("payment.bank.bankLabel")}
</p>
<p className="mt-1 flex items-center gap-2 text-lg font-semibold">
<Landmark className="h-5 w-5 text-primary" aria-hidden="true" />
{bankInfo.bankName}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wider text-muted-foreground">
{t("payment.bank.accountHolderLabel")}
</p>
<p className="mt-1 flex items-center gap-2 font-medium">
<User className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{bankInfo.accountHolder}
</p>
</div>
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs uppercase tracking-wider text-muted-foreground">
{t("payment.bank.ibanLabel")}
</p>
<button
type="button"
className="text-xs font-medium text-primary hover:underline"
onClick={() => setRevealIban((v) => !v)}
>
{revealIban ? t("payment.bank.hide") : t("payment.bank.reveal")}
</button>
</div>
<div className="mt-1 flex items-center gap-2">
<code className="flex-1 truncate font-mono text-sm tracking-wide">
{displayIban}
</code>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => copy(compactIban, "iban_copied")}
aria-label={t("payment.bank.copyIban")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{bankInfo.kolayAdres && (
<div>
<p className="text-xs uppercase tracking-wider text-muted-foreground">
{t("payment.bank.kolayAdresLabel")}
{bankInfo.kolayAdresType && (
<Badge variant="outline" className="ml-2 text-[10px] uppercase">
{t(`payment.bank.kolayAdresType.${bankInfo.kolayAdresType}`)}
</Badge>
)}
</p>
<div className="mt-1 flex items-center gap-2">
<code className="flex-1 truncate font-mono text-sm">{bankInfo.kolayAdres}</code>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => copy(bankInfo.kolayAdres ?? "", "kolay_adres_copied")}
aria-label={t("payment.bank.copyKolayAdres")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4 border-t border-border/60 pt-4">
<div>
<p className="text-xs uppercase tracking-wider text-muted-foreground">
{t("payment.bank.descriptionLabel")}
</p>
<div className="mt-1 flex items-center gap-2">
<code className="flex-1 truncate font-mono text-sm">{description}</code>
{eftPayment && (
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => copy(description, "description_copied")}
aria-label={t("payment.bank.copyDescription")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
<div>
<p className="text-xs uppercase tracking-wider text-muted-foreground">
{t("payment.bank.amountLabel")}
</p>
<p className="mt-1 text-lg font-bold text-foreground">{formatTRY(totalAmount)}</p>
</div>
</div>
</div>
{/* QR image */}
{bankInfo.qrImageUrl ? (
<button
type="button"
onClick={() => capture("qr_viewed", { bank_account_id: bankInfo.id })}
className="group relative flex h-44 w-44 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-background p-2 transition-transform hover:scale-[1.02]"
aria-label={t("payment.bank.qrAlt")}
>
<img
src={bankInfo.qrImageUrl}
alt={t("payment.bank.qrAlt")}
className="h-full w-full object-contain"
loading="lazy"
/>
<span className="pointer-events-none absolute bottom-1.5 left-1.5 right-1.5 rounded-md bg-background/85 px-1.5 py-0.5 text-center text-[10px] uppercase tracking-wider text-muted-foreground backdrop-blur">
{t("payment.bank.qrHint")}
</span>
</button>
) : (
<div className="hidden h-44 w-44 shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-muted/30 p-3 text-center text-xs text-muted-foreground sm:flex">
<QrCode className="mb-2 h-7 w-7 opacity-40" />
{t("payment.bank.qrUnavailable")}
</div>
)}
</div>
</div>
{!eftPayment ? (
<Button
className="w-full"
size="lg"
onClick={handleEftProceed}
disabled={eftMutation.isPending}
>
{eftMutation.isPending ? (
<>
<Clock className="mr-2 h-4 w-4 animate-spin" />
{t("payment.processingPayment")}
</>
) : (
<>
<Building2 className="mr-2 h-4 w-4" />
{t("payment.bank.proceedToUpload")}
</>
)}
</Button>
) : (
<div className="space-y-4 rounded-2xl border border-border bg-background p-5">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<UploadCloud className="h-5 w-5" />
</div>
<div>
<p className="font-semibold">{t("payment.uploadReceipt")}</p>
<p className="text-sm text-muted-foreground">
{t("payment.uploadReceiptDescription")}
</p>
</div>
</div>
<button
type="button"
className={`flex w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors ${
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-primary/50"
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept="image/png,image/jpeg,application/pdf"
onChange={handleFileSelect}
/>
{uploadedFile ? (
<div className="flex items-center gap-3">
<FileText className="h-8 w-8 text-primary" />
<div className="text-left">
<p className="text-sm font-medium">{uploadedFile.name}</p>
<p className="text-xs text-muted-foreground">
{(uploadedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
) : (
<>
<Upload className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t("payment.dragDrop")}</p>
<p className="text-xs text-muted-foreground">{t("payment.supportedFormats")}</p>
</>
)}
</button>
{uploadedFile && (
<Button
className="w-full"
onClick={handleUploadReceipt}
disabled={uploadMutation.isPending}
>
{uploadMutation.isPending ? (
<>
<Clock className="mr-2 h-4 w-4 animate-spin" />
{t("payment.uploading")}
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
{t("payment.uploadReceipt")}
</>
)}
</Button>
)}
<div className="rounded-lg border border-border/60 bg-muted/30 p-4">
<p className="mb-2 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<Hash className="h-3 w-3" />
{t("payment.paymentStatus")}
</p>
<ol className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
{t("payment.eftStatus.created")}
</li>
<li className="flex items-center gap-2">
{uploadedFile ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : (
<Clock className="h-4 w-4 text-muted-foreground" />
)}
{t("payment.eftStatus.receiptUploaded")}
</li>
<li className="flex items-center gap-2 text-muted-foreground">
<Clock className="h-4 w-4" />
{t("payment.waitingApproval")}
</li>
</ol>
</div>
</div>
)}
</div>
);
}

View File

@@ -273,7 +273,6 @@
"totalAmount": "Total Amount",
"paymentMethod": "Payment Method",
"creditCard": "Credit Card",
"eftTransfer": "EFT/Wire Transfer",
"payWithCard": "Pay with Card",
"paying": "Processing payment...",
"bankDetails": "Bank Details",
@@ -282,30 +281,15 @@
"iban": "IBAN",
"description": "Description",
"paymentDescription": "Sase.tr Subscription Payment",
"uploadReceipt": "Upload Receipt",
"uploadReceiptDescription": "Upload your receipt after EFT/Wire transfer.",
"dragDrop": "Drag and drop a file or click to browse",
"supportedFormats": "PNG, JPG, or PDF (max 5MB)",
"uploading": "Uploading...",
"receiptUploaded": "Receipt uploaded successfully. Waiting for approval.",
"uploadFailed": "Receipt upload failed.",
"paymentStatus": "Payment Status",
"waitingApproval": "Waiting for Approval",
"approved": "Approved",
"step1": "Plan & Brands",
"step2": "Payment",
"step3": "Confirmation",
"confirmation": "Payment received!",
"confirmationDescription": "Your subscription has been activated successfully.",
"eftConfirmationDescription": "Your EFT/Wire receipt has been received. Your subscription will be activated after approval.",
"goToDashboard": "Go to Dashboard",
"initializeFailed": "Payment initialization failed. Please try again.",
"processingPayment": "Processing payment...",
"eftPaid": "I Made EFT/Wire Transfer",
"eftStatus": {
"created": "EFT/Wire transfer record created",
"receiptUploaded": "Receipt uploaded"
},
"paymentFailed": "Payment failed. Please try again.",
"stripe": {
"secureCheckoutTitle": "Secure payment with 3D Secure",
@@ -315,31 +299,6 @@
"verifyingTitle": "Verifying your payment",
"verifyingDescription": "Your subscription will activate as soon as Stripe confirms. Stay on this page for a few seconds.",
"trustProvider": "Stripe payment infrastructure"
},
"bank": {
"bankLabel": "Bank",
"accountHolderLabel": "Account holder",
"ibanLabel": "IBAN",
"kolayAdresLabel": "Easy Address",
"kolayAdresType": {
"email": "Email",
"phone": "Phone",
"tckn": "TR ID"
},
"descriptionLabel": "Reference",
"amountLabel": "Amount",
"copyIban": "Copy IBAN",
"copyKolayAdres": "Copy easy address",
"copyDescription": "Copy reference",
"qrAlt": "Mobile banking QR code",
"qrHint": "Scan in your bank app",
"qrUnavailable": "QR coming soon",
"reveal": "Show",
"hide": "Hide",
"previewDescription": "Fills in once your payment is created",
"proceedToUpload": "I made the transfer — upload receipt",
"unavailableTitle": "Bank transfer unavailable",
"unavailableDescription": "Please use card payment or try again shortly."
}
},
"billing": {

View File

@@ -273,7 +273,6 @@
"totalAmount": "Toplam Tutar",
"paymentMethod": "Ödeme Yöntemi",
"creditCard": "Kredi Kartı",
"eftTransfer": "EFT/Havale",
"payWithCard": "Kartla Öde",
"paying": "Ödeme yapılıyor...",
"bankDetails": "Banka Bilgileri",
@@ -282,30 +281,15 @@
"iban": "IBAN",
"description": "Açıklama",
"paymentDescription": "Sase.tr Abonelik Ödemesi",
"uploadReceipt": "Dekont Yükle",
"uploadReceiptDescription": "EFT/Havale sonrası dekontunuzu yükleyin.",
"dragDrop": "Dosyayı sürükleyip bırakın veya tıklayın",
"supportedFormats": "PNG, JPG veya PDF (maks. 5MB)",
"uploading": "Yükleniyor...",
"receiptUploaded": "Dekont başarıyla yüklendi. Onay bekleniyor.",
"uploadFailed": "Dekont yükleme başarısız.",
"paymentStatus": "Ödeme Durumu",
"waitingApproval": "Onay Bekleniyor",
"approved": "Onaylandı",
"step1": "Plan ve Markalar",
"step2": "Ödeme",
"step3": "Onay",
"confirmation": "Ödemeniz alındı!",
"confirmationDescription": "Aboneliğiniz başarıyla aktifleştirildi.",
"eftConfirmationDescription": "EFT/Havale dekontunuz alındı. Onay sonrası aboneliğiniz aktifleştirilecektir.",
"goToDashboard": "Panele Git",
"initializeFailed": "Ödeme başlatılamadı. Lütfen tekrar deneyin.",
"processingPayment": "Ödeme işleniyor...",
"eftPaid": "EFT/Havale Yaptım",
"eftStatus": {
"created": "EFT/Havale kaydı oluşturuldu",
"receiptUploaded": "Dekont yüklendi"
},
"paymentFailed": "Ödeme başarısız oldu. Lütfen tekrar deneyin.",
"stripe": {
"secureCheckoutTitle": "3D Secure ile güvenli ödeme",
@@ -315,31 +299,6 @@
"verifyingTitle": "Ödemen doğrulanıyor",
"verifyingDescription": "Stripe onayı geldiğinde aboneliğin otomatik aktifleşecek. Bu sayfayı kapatmadan birkaç saniye bekle.",
"trustProvider": "Stripe ödeme altyapısı"
},
"bank": {
"bankLabel": "Banka",
"accountHolderLabel": "Hesap Sahibi",
"ibanLabel": "IBAN",
"kolayAdresLabel": "Kolay Adres",
"kolayAdresType": {
"email": "E-posta",
"phone": "Telefon",
"tckn": "TCKN"
},
"descriptionLabel": "Açıklama",
"amountLabel": "Tutar",
"copyIban": "IBAN'ı kopyala",
"copyKolayAdres": "Kolay Adres'i kopyala",
"copyDescription": "Açıklamayı kopyala",
"qrAlt": "Mobil bankacılık QR kodu",
"qrHint": "Bankan ile tara",
"qrUnavailable": "QR yakında",
"reveal": "Göster",
"hide": "Gizle",
"previewDescription": "Ödeme oluştuğunda burası dolar",
"proceedToUpload": "Havaleyi yaptım, dekont yükle",
"unavailableTitle": "Havale şu an aktif değil",
"unavailableDescription": "Kart ile ödemeyi tercih edebilir veya birazdan tekrar denersin."
}
},
"billing": {

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,6 @@ import {
BookOpen,
Copy,
CreditCard,
DollarSign,
FlaskConical,
History,
LayoutDashboard,
@@ -67,7 +66,6 @@ const supportItems = [
const adminItems = [
{ to: "/dashboard/admin", label: "Yönetim Paneli", icon: Shield },
{ to: "/dashboard/admin/users", label: "Kullanıcılar", icon: Users },
{ to: "/dashboard/admin/payments", label: "Ödemeler", icon: DollarSign },
{ to: "/dashboard/admin/analytics", label: "Analitik", icon: BarChart3 },
{ to: "/dashboard/admin/copy-logs", label: "OEM Kopyalama", icon: Copy },
{ to: "/dashboard/admin/referrals", label: "Referanslar", icon: Share2 },

View File

@@ -1,7 +1,7 @@
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { KEYS_6 } from "@/lib/keys";
import { KEYS_5 } from "@/lib/keys";
import { formatTRY } from "@sase/shared";
import { Card, CardContent } from "@sase/ui";
import { Skeleton } from "@sase/ui";
@@ -10,10 +10,8 @@ import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import {
Activity,
Clock,
Copy,
CreditCard,
Receipt,
Search,
TrendingUp,
UserCog,
@@ -39,7 +37,6 @@ interface DashboardStats {
totalRevenue: number;
totalQueries: number;
newUsersThisMonth: number;
pendingPayments: number;
}
interface DailyStat {
@@ -77,7 +74,7 @@ function AdminDashboardPage() {
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{KEYS_6.map((__k) => (
{KEYS_5.map((__k) => (
<Skeleton key={__k} className="h-32" />
))}
</div>
@@ -123,13 +120,6 @@ function AdminDashboardPage() {
color: "text-orange-600",
bg: "bg-orange-50",
},
{
label: "Bekleyen Odeme",
value: stats?.pendingPayments ?? 0,
icon: Clock,
color: "text-red-600",
bg: "bg-red-50",
},
];
const quickLinks = [
@@ -138,11 +128,6 @@ function AdminDashboardPage() {
label: "Kullanici Yonetimi",
icon: UserCog,
},
{
to: "/dashboard/admin/payments",
label: "Odeme Onaylari",
icon: Receipt,
},
{
to: "/dashboard/admin/analytics",
label: "Sorgu Analizi",
@@ -182,7 +167,7 @@ function AdminDashboardPage() {
{/* Stat Cards */}
{statsLoading ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{KEYS_6.map((__k) => (
{KEYS_5.map((__k) => (
<Skeleton key={__k} className="h-32" />
))}
</div>

View File

@@ -1,234 +0,0 @@
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { formatTRY } from "@sase/shared";
import { Card, CardContent } from "@sase/ui";
import { Button } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, CheckCircle, ExternalLink, Receipt, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { KEYS_5 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/admin/payments")({
component: AdminPaymentsPage,
});
interface PendingPayment {
id: string;
userId: string;
userName: string;
userEmail: string;
subscriptionId: string;
amount: number;
currency: string;
method: string;
status: string;
eftReceiptUrl: string | null;
createdAt: string;
}
function AdminPaymentsPage() {
const { t } = useTranslation();
const { user, isLoading: authLoading } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [confirmAction, setConfirmAction] = useState<{
id: string;
type: "approve" | "reject";
} | null>(null);
useEffect(() => {
if (!authLoading && user?.role !== "admin") {
navigate({ to: "/dashboard/search" });
}
}, [authLoading, user, navigate]);
const { data: payments, isLoading } = useQuery({
queryKey: ["admin", "payments", "pending"],
queryFn: () => api.get<PendingPayment[]>("/admin/payments/pending"),
enabled: user?.role === "admin",
});
const approveMutation = useMutation({
mutationFn: (paymentId: string) => api.patch(`/payments/eft/${paymentId}/approve`, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
queryClient.invalidateQueries({ queryKey: ["admin", "dashboard"] });
setConfirmAction(null);
},
});
const rejectMutation = useMutation({
mutationFn: (paymentId: string) => api.patch(`/payments/eft/${paymentId}/reject`, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
queryClient.invalidateQueries({ queryKey: ["admin", "dashboard"] });
setConfirmAction(null);
},
});
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;
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
return (
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">EFT Odeme Onaylari</h2>
<Badge variant="secondary">{payments?.length ?? 0} bekleyen</Badge>
</div>
{isLoading ? (
<div className="space-y-4">
{KEYS_5.map((__k) => (
<Skeleton key={__k} className="h-32 w-full" />
))}
</div>
) : !payments || payments.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<CheckCircle className="h-12 w-12 text-green-500" />
<p className="text-lg font-medium">Bekleyen odeme bulunmuyor</p>
<p className="text-sm text-muted-foreground">Tum EFT odemeleri islenmis durumda</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{payments.map((payment) => (
<Card key={payment.id}>
<CardContent className="p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{/* User Info */}
<div className="space-y-1">
<p className="font-medium">{payment.userName}</p>
<p className="text-sm text-muted-foreground">{payment.userEmail}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDate(payment.createdAt)}</span>
<span>|</span>
<span>ID: {payment.id.substring(0, 8)}...</span>
</div>
</div>
{/* Amount & Receipt */}
<div className="flex flex-col items-end gap-2">
<p className="text-xl font-bold">{formatTRY(payment.amount)}</p>
{payment.eftReceiptUrl ? (
<a
href={payment.eftReceiptUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" />
Dekont Goruntule
</a>
) : (
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
<Receipt className="h-3 w-3" />
Dekont yuklenmemis
</span>
)}
</div>
</div>
{/* Confirm Dialog */}
{confirmAction && confirmAction.id === payment.id ? (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 text-amber-600" />
<div className="flex-1">
<p className="font-medium">
{confirmAction.type === "approve"
? "Odemeyi onaylamak istediginize emin misiniz?"
: "Odemeyi reddetmek istediginize emin misiniz?"}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{confirmAction.type === "approve"
? "Bu islem aboneligi aktif hale getirecektir."
: "Bu islem odemeyi basarisiz olarak isaretleyecektir."}
</p>
<div className="mt-3 flex items-center gap-2">
<Button
size="sm"
variant={confirmAction.type === "approve" ? "default" : "destructive"}
disabled={approveMutation.isPending || rejectMutation.isPending}
onClick={() => {
if (confirmAction.type === "approve") {
approveMutation.mutate(payment.id);
} else {
rejectMutation.mutate(payment.id);
}
}}
>
{approveMutation.isPending || rejectMutation.isPending
? "Isleniyor..."
: "Evet, onayla"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setConfirmAction(null)}
>
Iptal
</Button>
</div>
</div>
</div>
</div>
) : (
<div className="mt-4 flex items-center gap-2 border-t pt-4">
<Button
size="sm"
onClick={() =>
setConfirmAction({
id: payment.id,
type: "approve",
})
}
>
<CheckCircle className="mr-1 h-4 w-4" />
Onayla
</Button>
<Button
size="sm"
variant="destructive"
onClick={() =>
setConfirmAction({
id: payment.id,
type: "reject",
})
}
>
<XCircle className="mr-1 h-4 w-4" />
Reddet
</Button>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -77,7 +77,7 @@ function BillingPage() {
{/* Method Filter */}
<div className="flex gap-1">
{["all", "stripe", "eft"].map((method) => (
{["all", "stripe"].map((method) => (
<Button
key={method}
variant={methodFilter === method ? "default" : "outline"}

View File

@@ -1,4 +1,3 @@
import { BankTransferCard } from "@/components/payment/bank-transfer-card";
import { StripeCheckoutButton } from "@/components/payment/stripe-checkout-button";
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { api } from "@/lib/api-client";
@@ -25,17 +24,12 @@ import {
DialogTitle,
Separator,
Skeleton,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@sase/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
AlertTriangle,
ArrowRight,
Building2,
Check,
CheckCircle2,
Clock,
@@ -189,7 +183,6 @@ export function SubscriptionPage() {
const [selectedPlanKey, setSelectedPlanKey] = useState<string | null>(null);
const [selectedBrandIds, setSelectedBrandIds] = useState<string[]>([]);
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "yearly">("monthly");
const [paymentMethod, setPaymentMethod] = useState<"stripe" | "eft">("stripe");
// ─── Page-level state ──────────────────────────────────────────────────────
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
@@ -343,7 +336,6 @@ export function SubscriptionPage() {
if (search.stripe === "success" && !stripeResultRef.current) {
stripeResultRef.current = true;
setStep("confirmation");
setPaymentMethod("stripe");
capture("stripe_redirect_returned", { result: "success", session_id: search.session_id });
} else if (search.stripe === "cancelled" && !stripeResultRef.current) {
stripeResultRef.current = true;
@@ -459,11 +451,6 @@ export function SubscriptionPage() {
}
}
function handleEftCompleted() {
setStep("confirmation");
queryClient.invalidateQueries({ queryKey: ["subscription"] });
}
function handleCancelClick() {
if (downgradePlan) {
const currentBrandIds = (subscription?.brands ?? []).map((b) => b.brandId);
@@ -675,9 +662,7 @@ export function SubscriptionPage() {
title={t("subscription.steps.payment.title")}
isActive={step === "payment"}
isComplete={step === "confirmation"}
summary={
paymentMethod === "stripe" ? t("payment.creditCard") : t("payment.eftTransfer")
}
summary={t("payment.creditCard")}
onEdit={() => setStep("payment")}
>
<PaymentMethodSection
@@ -685,18 +670,14 @@ export function SubscriptionPage() {
period={billingPeriod}
brandIds={selectedBrandIds}
totalAmount={totalAmount}
method={paymentMethod}
onMethodChange={setPaymentMethod}
onEftCompleted={handleEftCompleted}
/>
</StepCard>
)}
{step === "confirmation" && (
<ConfirmationCard
method={paymentMethod}
subscription={subscription ?? null}
isStripeVerifying={paymentMethod === "stripe" && subscription?.status !== "active"}
isStripeVerifying={subscription?.status !== "active"}
navigate={navigate}
/>
)}
@@ -1100,50 +1081,21 @@ function PaymentMethodSection({
period,
brandIds,
totalAmount,
method,
onMethodChange,
onEftCompleted,
}: {
planKey: string;
period: "monthly" | "yearly";
brandIds: string[];
totalAmount: number;
method: "stripe" | "eft";
onMethodChange: (m: "stripe" | "eft") => void;
onEftCompleted: () => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-4">
<Tabs value={method} onValueChange={(v) => onMethodChange(v as "stripe" | "eft")}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="stripe" data-faro-user-action-name="payment-tab-card">
<CreditCard className="mr-2 h-4 w-4" />
{t("payment.creditCard")}
</TabsTrigger>
<TabsTrigger value="eft" data-faro-user-action-name="payment-tab-eft">
<Building2 className="mr-2 h-4 w-4" />
{t("payment.eftTransfer")}
</TabsTrigger>
</TabsList>
<TabsContent value="stripe" className="pt-4">
<StripeCheckoutButton
planKey={planKey}
period={period}
brandIds={brandIds}
totalAmount={totalAmount}
/>
</TabsContent>
<TabsContent value="eft" className="pt-4">
<BankTransferCard
planKey={planKey}
period={period}
brandIds={brandIds}
totalAmount={totalAmount}
onCompleted={onEftCompleted}
/>
</TabsContent>
</Tabs>
<StripeCheckoutButton
planKey={planKey}
period={period}
brandIds={brandIds}
totalAmount={totalAmount}
/>
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-xs text-foreground/70">
<span aria-hidden="true">🔒</span>
@@ -1160,18 +1112,16 @@ function PaymentMethodSection({
}
function ConfirmationCard({
method,
subscription,
isStripeVerifying,
navigate,
}: {
method: "stripe" | "eft";
subscription: Subscription | null;
isStripeVerifying: boolean;
navigate: ReturnType<typeof useNavigate>;
}) {
const { t } = useTranslation();
if (method === "stripe" && isStripeVerifying) {
if (isStripeVerifying) {
return (
<Card className="border-primary/30 ring-1 ring-primary/10">
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
@@ -1192,13 +1142,9 @@ function ConfirmationCard({
<CheckCircle2 className="h-16 w-16 text-emerald-500" />
<div>
<h3 className="text-2xl font-bold">{t("payment.confirmation")}</h3>
<p className="mt-2 text-muted-foreground">
{method === "stripe"
? t("payment.confirmationDescription")
: t("payment.eftConfirmationDescription")}
</p>
<p className="mt-2 text-muted-foreground">{t("payment.confirmationDescription")}</p>
</div>
{subscription?.endDate && method === "stripe" && (
{subscription?.endDate && (
<div className="rounded-lg border border-border bg-background/60 px-4 py-2 text-sm">
<span className="text-muted-foreground">{t("subscription.endDate")}: </span>
<span className="font-medium">