feat: plan tier system, mock checkout, saved cards, tenant logo upload + mobile sheet fix
Plan & billing layer:
- New tables: subscription_payments, saved_cards (via Appwrite MCP)
- tenant_settings: plan/planStartedAt/planExpiresAt/lastPaymentId columns
- Free tier limits (50 customers / 100 finance entries / 5 software / 1 member)
enforced via requirePlanCapacity gate in create actions
- PlanLimitDialog opens when limit hit; UsageBanner at 80% threshold
- /pricing rebuilt with Free + Pro tiers and Klinik/Ajans ecosystem teasers
- /settings/billing redesigned: compact plan summary, saved cards list,
KVKK transparency block, payment history
- Usage stats moved to /pricing where they are decision-relevant
Mock checkout flow:
- 3D animated credit card with sync inputs and CVC flip
- Brand auto-detection (Visa / Mastercard / Amex / troy)
- Saved-card mode when previous cards exist; first card defaults to default
- 'Bu kartı kaydet' checkbox with explicit storage scope disclosure
- /settings/billing/checkout/[orderId] route
Saved cards:
- saved_cards bucket stores last4 + brand + expiry + holder only
- Default toggle, remove action, owner-only management
- Architecture ready for Shopier provider token swap-in
Tenant logo upload (first file upload feature):
- New Appwrite bucket: tenant-logos (max 2MB, image only, public read)
- uploadLogoAction with orphan cleanup, removeLogoAction
- LogoUploader UI: drag-drop, client-side preview, validation
- Sidebar shows logo when set, falls back to default icon
Mobile sheet fix:
- SheetContent uses h-dvh instead of h-full (dynamic viewport)
- SheetFooter pads pb-[max(1rem,env(safe-area-inset-bottom))]
- 13 form sheets switched py-4 → pt-4 to let safe-area apply
db: subscription_payments, saved_cards tables; tenant_settings plan columns;
tenant-logos storage bucket
This commit is contained in:
@@ -225,7 +225,7 @@ export function EventFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div>
|
||||
{isEdit && event && onRequestDelete && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
|
||||
import {
|
||||
createCustomerAction,
|
||||
updateCustomerAction,
|
||||
@@ -40,11 +41,14 @@ export function CustomerFormSheet({ open, onOpenChange, customer }: Props) {
|
||||
const isEdit = Boolean(customer);
|
||||
const action = isEdit ? updateCustomerAction : createCustomerAction;
|
||||
const [state, formAction, isPending] = useActionState(action, initialCustomerState);
|
||||
const [planLimitOpen, setPlanLimitOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success(isEdit ? "Müşteri güncellendi." : "Müşteri eklendi.");
|
||||
onOpenChange(false);
|
||||
} else if (state.code === "PLAN_LIMIT_EXCEEDED") {
|
||||
setPlanLimitOpen(true);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
@@ -155,7 +159,7 @@ export function CustomerFormSheet({ open, onOpenChange, customer }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -182,6 +186,11 @@ export function CustomerFormSheet({ open, onOpenChange, customer }: Props) {
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
<PlanLimitDialog
|
||||
open={planLimitOpen}
|
||||
onOpenChange={setPlanLimitOpen}
|
||||
message={state.error}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { UsageBanner } from "@/components/billing/usage-banner";
|
||||
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||
import { getPlanUsage } from "@/lib/appwrite/plan-limits";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { CustomersClient } from "./components/customers-client";
|
||||
|
||||
@@ -17,7 +19,10 @@ export default async function CustomersPage() {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
const customers = await listCustomers(ctx.tenantId);
|
||||
const [customers, usage] = await Promise.all([
|
||||
listCustomers(ctx.tenantId),
|
||||
getPlanUsage(ctx),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||
@@ -29,6 +34,8 @@ export default async function CustomersPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UsageBanner usage={usage} resource="customers" />
|
||||
|
||||
<CustomersClient
|
||||
customers={customers.map((c) => ({
|
||||
id: c.$id,
|
||||
|
||||
@@ -18,6 +18,7 @@ export type ShellUser = {
|
||||
export type ShellCompany = {
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string | null;
|
||||
};
|
||||
|
||||
export function DashboardShell({
|
||||
|
||||
@@ -130,7 +130,7 @@ export function BankFormSheet({ open, onOpenChange, account }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -201,7 +201,7 @@ export function CardFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
Vazgeç
|
||||
|
||||
@@ -170,7 +170,7 @@ export function StatementFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
Vazgeç
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { Loader2, Save, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -61,11 +63,14 @@ export function FinanceFormSheet({
|
||||
const isEdit = Boolean(entry);
|
||||
const action = isEdit ? updateFinanceEntryAction : createFinanceEntryAction;
|
||||
const [state, formAction, isPending] = useActionState(action, initialFinanceState);
|
||||
const [planLimitOpen, setPlanLimitOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success(isEdit ? "Kayıt güncellendi." : "Kayıt eklendi.");
|
||||
onOpenChange(false);
|
||||
} else if (state.code === "PLAN_LIMIT_EXCEEDED") {
|
||||
setPlanLimitOpen(true);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
@@ -216,7 +221,7 @@ export function FinanceFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div>
|
||||
{isEdit && entry && onRequestDelete && (
|
||||
@@ -260,6 +265,11 @@ export function FinanceFormSheet({
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
<PlanLimitDialog
|
||||
open={planLimitOpen}
|
||||
onOpenChange={setPlanLimitOpen}
|
||||
message={state.error}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ export function LoanFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
Vazgeç
|
||||
|
||||
@@ -271,7 +271,7 @@ function ItemFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -162,7 +162,7 @@ export function InvoiceFormSheet({ open, onOpenChange, invoice, customers }: Pro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getActiveContext } from "@/lib/appwrite/active-context";
|
||||
import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||
import { DashboardShell } from "./dashboard-shell";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
@@ -14,6 +15,7 @@ export default async function DashboardLayout({
|
||||
const company = {
|
||||
id: ctx.tenantId,
|
||||
name: ctx.settings?.companyName ?? "Çalışma alanı",
|
||||
logoUrl: getLogoUrl(ctx.settings?.logo) ?? null,
|
||||
};
|
||||
const user = {
|
||||
id: ctx.user.id,
|
||||
|
||||
@@ -1,24 +1,292 @@
|
||||
import { PricingPlans } from "@/components/pricing-plans"
|
||||
import { FeaturesGrid } from "./components/features-grid"
|
||||
import { FAQSection } from "./components/faq-section"
|
||||
import { redirect } from "next/navigation";
|
||||
import { Building2, Check, Clock, Crown, Sparkles, Stethoscope } from "lucide-react";
|
||||
|
||||
// Import data
|
||||
import featuresData from "./data/features.json"
|
||||
import faqsData from "./data/faqs.json"
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RESOURCE_LABELS,
|
||||
getEffectivePlan,
|
||||
getPlanUsage,
|
||||
type PlanResource,
|
||||
} from "@/lib/appwrite/plan-limits";
|
||||
import {
|
||||
downgradeToFreeAction,
|
||||
startMockCheckoutAction,
|
||||
} from "@/lib/appwrite/subscription-actions";
|
||||
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
|
||||
const trFmt = new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency",
|
||||
currency: "TRY",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
type EcosystemTier = {
|
||||
id: "klinik" | "ajans";
|
||||
name: string;
|
||||
description: string;
|
||||
Icon: typeof Stethoscope;
|
||||
features: string[];
|
||||
};
|
||||
|
||||
const ECOSYSTEM_TIERS: EcosystemTier[] = [
|
||||
{
|
||||
id: "klinik",
|
||||
name: "Kliniğim",
|
||||
description: "Hekim, klinik ve sağlık merkezleri için.",
|
||||
Icon: Stethoscope,
|
||||
features: [
|
||||
"Hasta kaydı + KVKK uyumlu dosyalama",
|
||||
"Randevu + hatırlatma",
|
||||
"Reçete ve tetkik takibi",
|
||||
"Klinik finans paneli",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ajans",
|
||||
name: "Ajansım",
|
||||
description: "Yaratıcı ajanslar ve danışmanlıklar için.",
|
||||
Icon: Building2,
|
||||
features: [
|
||||
"Proje + saat takibi",
|
||||
"Müşteri portalı",
|
||||
"Brief + onay akışı",
|
||||
"Ajans bazlı raporlama",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default async function PricingPage() {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
const currentPlan = getEffectivePlan(ctx);
|
||||
const isPro = currentPlan === "pro";
|
||||
const canManage = ctx.role === "owner";
|
||||
const usage = await getPlanUsage(ctx);
|
||||
|
||||
const resources: PlanResource[] = ["customers", "financeEntries", "software", "members"];
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
...PLAN_CATALOG.free,
|
||||
isCurrent: !isPro,
|
||||
isPopular: false,
|
||||
},
|
||||
{
|
||||
...PLAN_CATALOG.pro,
|
||||
isCurrent: isPro,
|
||||
isPopular: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<div className="px-4 lg:px-6">
|
||||
{/* Pricing Cards */}
|
||||
<section className='pb-12' id='pricing'>
|
||||
<PricingPlans mode="pricing" />
|
||||
<div className="flex-1 space-y-8 px-6 pt-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Plan</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
İşletmem'i ölçeğine göre kullan. Sektörel paketler (Kliniğim, Ajansım) yakında.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Bu ayki kullanımın</CardTitle>
|
||||
<CardDescription>
|
||||
Mevcut planın sınırlarına ne kadar yaklaştığını gör.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
{resources.map((r) => {
|
||||
const u = usage.usage[r];
|
||||
const pct =
|
||||
u.limit === Number.POSITIVE_INFINITY
|
||||
? 0
|
||||
: Math.min(100, Math.round((u.used / Math.max(1, u.limit)) * 100));
|
||||
const limitLabel =
|
||||
u.limit === Number.POSITIVE_INFINITY ? "∞" : String(u.limit);
|
||||
return (
|
||||
<div key={r} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="capitalize">{RESOURCE_LABELS[r]}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-xs",
|
||||
u.reached
|
||||
? "text-destructive font-semibold"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{u.used} / {limitLabel}
|
||||
</span>
|
||||
</div>
|
||||
{u.limit !== Number.POSITIVE_INFINITY && (
|
||||
<Progress value={pct} className="h-1.5" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold tracking-tight">İşletmem planları</h2>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Tek para birimi: ₺ (TRY)
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{tiers.map((tier) => (
|
||||
<Card
|
||||
key={tier.id}
|
||||
className={cn("flex flex-col pt-0", {
|
||||
"border-primary relative shadow-lg": tier.isPopular,
|
||||
"border-primary": tier.isCurrent,
|
||||
})}
|
||||
>
|
||||
{tier.isCurrent && (
|
||||
<div className="absolute start-0 -top-3 w-full">
|
||||
<Badge className="mx-auto flex w-fit gap-1.5 rounded-full font-medium">
|
||||
<Sparkles className="!size-4" />
|
||||
Mevcut plan
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{tier.isPopular && !tier.isCurrent && (
|
||||
<div className="absolute start-0 -top-3 w-full">
|
||||
<Badge variant="secondary" className="mx-auto flex w-fit gap-1.5 rounded-full font-medium">
|
||||
<Crown className="!size-4" />
|
||||
Önerilen
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader className="space-y-2 pt-8 text-center">
|
||||
<CardTitle className="text-2xl">{tier.name}</CardTitle>
|
||||
<p className="text-muted-foreground text-sm text-balance">{tier.description}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col space-y-6">
|
||||
<div className="flex items-baseline justify-center">
|
||||
<span className="text-4xl font-bold">{trFmt.format(tier.price)}</span>
|
||||
<span className="text-muted-foreground text-sm">/ay</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{tier.features.map((feature) => (
|
||||
<div key={feature} className="flex items-center gap-2">
|
||||
<div className="bg-muted rounded-full p-1">
|
||||
<Check className="size-3.5" />
|
||||
</div>
|
||||
<span className="text-sm">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{tier.isCurrent ? (
|
||||
<Button className="w-full" size="lg" variant="outline" disabled>
|
||||
Mevcut plan
|
||||
</Button>
|
||||
) : !canManage ? (
|
||||
<Button className="w-full" size="lg" variant="outline" disabled>
|
||||
Sahip yetkisi gerekli
|
||||
</Button>
|
||||
) : tier.id === "pro" ? (
|
||||
<form action={startMockCheckoutAction} className="w-full">
|
||||
<input type="hidden" name="plan" value="pro" />
|
||||
<Button type="submit" className="w-full" size="lg">
|
||||
<Crown className="size-4" />
|
||||
Pro'ya geç (Test)
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form action={downgradeToFreeAction} className="w-full">
|
||||
<Button type="submit" className="w-full" size="lg" variant="outline">
|
||||
Ücretsiz'e dön
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<FeaturesGrid features={featuresData} />
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Ekosistem paketleri</h2>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Clock className="size-3" />
|
||||
Yakında
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Sektörel modüller İşletmem'in üzerine eklenecek. Aynı hesabınla farklı şirketleri tek
|
||||
panelden yöneteceksin.
|
||||
</p>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<FAQSection faqs={faqsData} />
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{ECOSYSTEM_TIERS.map((t) => (
|
||||
<Card key={t.id} className="flex flex-col bg-muted/30">
|
||||
<CardHeader>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="bg-background flex size-10 items-center justify-center rounded-md border">
|
||||
<t.Icon className="size-5" />
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Yakında
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-xl">{t.name}</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">{t.description}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col">
|
||||
<div className="space-y-2">
|
||||
{t.features.map((feature) => (
|
||||
<div key={feature} className="flex items-center gap-2">
|
||||
<div className="bg-background rounded-full p-1">
|
||||
<Check className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full" size="lg" variant="outline" disabled>
|
||||
Geliştirme aşamasında
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card className="bg-muted/20">
|
||||
<CardContent className="text-muted-foreground py-4 text-xs">
|
||||
<p>
|
||||
<span className="text-foreground font-medium">Test modu:</span> Pro plan şu anda mock
|
||||
ödeme akışıyla çalışır. Shopier entegrasyonu yakında — gerçek tahsilat ancak entegrasyon
|
||||
tamamlandıktan sonra başlayacak.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export function ServiceFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { Cpu, Wifi } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Brand = "visa" | "mastercard" | "amex" | "troy" | "unknown";
|
||||
|
||||
function detectBrand(num: string): Brand {
|
||||
const n = num.replace(/\s/g, "");
|
||||
if (/^4/.test(n)) return "visa";
|
||||
if (/^(5[1-5]|2[2-7])/.test(n)) return "mastercard";
|
||||
if (/^3[47]/.test(n)) return "amex";
|
||||
if (/^(9792|65)/.test(n)) return "troy";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function BrandLogo({ brand }: { brand: Brand }) {
|
||||
const base = "text-white/95 font-black tracking-tight";
|
||||
if (brand === "visa") {
|
||||
return <span className={cn(base, "text-2xl italic font-serif")}>VISA</span>;
|
||||
}
|
||||
if (brand === "mastercard") {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="size-7 rounded-full bg-red-500" />
|
||||
<div className="-ml-3 size-7 rounded-full bg-amber-400 mix-blend-screen" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (brand === "amex") {
|
||||
return <span className={cn(base, "text-base")}>AMEX</span>;
|
||||
}
|
||||
if (brand === "troy") {
|
||||
return <span className={cn(base, "text-xl tracking-widest")}>troy</span>;
|
||||
}
|
||||
return (
|
||||
<div className="size-8 rounded-full border-2 border-white/40 border-dashed" />
|
||||
);
|
||||
}
|
||||
|
||||
function maskedNumber(num: string): string {
|
||||
const digits = num.replace(/\D/g, "").slice(0, 16);
|
||||
const padded = digits.padEnd(16, "•");
|
||||
return padded.match(/.{1,4}/g)?.join(" ") ?? "";
|
||||
}
|
||||
|
||||
type Props = {
|
||||
number: string;
|
||||
name: string;
|
||||
expiry: string;
|
||||
cvc: string;
|
||||
flipped: boolean;
|
||||
};
|
||||
|
||||
export function CreditCardVisual({ number, name, expiry, cvc, flipped }: Props) {
|
||||
const brand = detectBrand(number);
|
||||
const display = maskedNumber(number);
|
||||
const cvcDisplay = cvc.padEnd(3, "•").slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="w-full" style={{ perspective: "1200px" }}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative aspect-[1.586/1] w-full transition-transform duration-700 will-change-transform",
|
||||
)}
|
||||
style={{
|
||||
transformStyle: "preserve-3d",
|
||||
transform: flipped ? "rotateY(180deg)" : "rotateY(0deg)",
|
||||
}}
|
||||
>
|
||||
{/* FRONT */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-2xl p-5 sm:p-6 shadow-xl overflow-hidden"
|
||||
style={{
|
||||
backfaceVisibility: "hidden",
|
||||
background:
|
||||
"linear-gradient(135deg, oklch(0.32 0.04 260) 0%, oklch(0.20 0.04 260) 60%, oklch(0.12 0.03 260) 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 opacity-30 mix-blend-overlay"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at 20% 0%, rgba(255,255,255,0.4), transparent 40%), radial-gradient(circle at 80% 100%, rgba(255,255,255,0.2), transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative flex h-full flex-col justify-between text-white">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Cpu className="size-9 text-amber-300/80" strokeWidth={1.25} />
|
||||
<Wifi className="size-5 rotate-90 text-white/60" />
|
||||
</div>
|
||||
<BrandLogo brand={brand} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="font-mono text-lg tracking-[0.2em] sm:text-xl">
|
||||
{display}
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-4 text-xs sm:text-sm">
|
||||
<div>
|
||||
<div className="text-white/50 text-[10px] uppercase tracking-wider">
|
||||
Kart sahibi
|
||||
</div>
|
||||
<div className="font-medium uppercase truncate">
|
||||
{name || "AD SOYAD"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-white/50 text-[10px] uppercase tracking-wider">
|
||||
Son kullanma
|
||||
</div>
|
||||
<div className="font-mono">{expiry || "AA/YY"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BACK */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-2xl shadow-xl overflow-hidden"
|
||||
style={{
|
||||
backfaceVisibility: "hidden",
|
||||
transform: "rotateY(180deg)",
|
||||
background:
|
||||
"linear-gradient(135deg, oklch(0.28 0.04 260) 0%, oklch(0.18 0.04 260) 60%, oklch(0.10 0.03 260) 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="mt-6 h-12 w-full bg-black/80" />
|
||||
<div className="px-5 pt-5 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-white/90 flex h-9 flex-1 items-center justify-end rounded-sm px-3 font-mono text-sm text-slate-800">
|
||||
{cvcDisplay}
|
||||
</div>
|
||||
<div className="text-white/70 text-[10px] uppercase tracking-wider">
|
||||
CVC
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-white/40 mt-6 text-[10px] leading-relaxed">
|
||||
Bu kart yalnızca İşletmem mock test akışı içindir. Gerçek bir banka kartı
|
||||
değildir, hiçbir tahsilat yapılmaz.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, CreditCard, Loader2, Lock, ShieldCheck, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
cancelMockPaymentAction,
|
||||
confirmMockPaymentAction,
|
||||
} from "@/lib/appwrite/subscription-actions";
|
||||
import type { CardBrand, SavedCard } from "@/lib/appwrite/schema";
|
||||
|
||||
import { CreditCardVisual } from "./credit-card-visual";
|
||||
|
||||
const trFmt = new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency",
|
||||
currency: "TRY",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const BRAND_LABEL: Record<CardBrand, string> = {
|
||||
visa: "Visa",
|
||||
mastercard: "Mastercard",
|
||||
amex: "Amex",
|
||||
troy: "troy",
|
||||
unknown: "Kart",
|
||||
};
|
||||
|
||||
function formatNumber(v: string): string {
|
||||
return v
|
||||
.replace(/\D/g, "")
|
||||
.slice(0, 16)
|
||||
.replace(/(.{4})/g, "$1 ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function formatExpiry(v: string): string {
|
||||
const digits = v.replace(/\D/g, "").slice(0, 4);
|
||||
if (digits.length < 3) return digits;
|
||||
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
|
||||
}
|
||||
|
||||
function formatCvc(v: string): string {
|
||||
return v.replace(/\D/g, "").slice(0, 4);
|
||||
}
|
||||
|
||||
function detectBrand(num: string): CardBrand {
|
||||
const n = num.replace(/\s/g, "");
|
||||
if (/^4/.test(n)) return "visa";
|
||||
if (/^(5[1-5]|2[2-7])/.test(n)) return "mastercard";
|
||||
if (/^3[47]/.test(n)) return "amex";
|
||||
if (/^(9792|65)/.test(n)) return "troy";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
type Props = {
|
||||
orderId: string;
|
||||
amount: number;
|
||||
planName: string;
|
||||
planPeriod: string;
|
||||
savedCards: SavedCard[];
|
||||
};
|
||||
|
||||
export function MockPaymentForm({
|
||||
orderId,
|
||||
amount,
|
||||
planName,
|
||||
planPeriod,
|
||||
savedCards,
|
||||
}: Props) {
|
||||
const defaultSaved = savedCards.find((c) => c.isDefault) ?? savedCards[0] ?? null;
|
||||
const [mode, setMode] = useState<"saved" | "new">(defaultSaved ? "saved" : "new");
|
||||
const [selectedCardId, setSelectedCardId] = useState<string>(defaultSaved?.$id ?? "");
|
||||
|
||||
const [number, setNumber] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [expiry, setExpiry] = useState("");
|
||||
const [cvc, setCvc] = useState("");
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [saveCard, setSaveCard] = useState(true);
|
||||
|
||||
const [confirming, startConfirm] = useTransition();
|
||||
const [cancelling, startCancel] = useTransition();
|
||||
|
||||
const numberDigits = number.replace(/\s/g, "");
|
||||
const expiryDigits = expiry.replace(/\D/g, "");
|
||||
|
||||
const newCardFilled =
|
||||
numberDigits.length === 16 &&
|
||||
name.trim().length >= 3 &&
|
||||
expiryDigits.length === 4 &&
|
||||
cvc.length >= 3;
|
||||
|
||||
const filled = mode === "saved" ? Boolean(selectedCardId) : newCardFilled;
|
||||
|
||||
const handleConfirm = () => {
|
||||
const fd = new FormData();
|
||||
fd.set("orderId", orderId);
|
||||
|
||||
if (mode === "saved" && selectedCardId) {
|
||||
fd.set("savedCardId", selectedCardId);
|
||||
} else {
|
||||
const month = expiryDigits.slice(0, 2);
|
||||
const year = expiryDigits.slice(2, 4);
|
||||
fd.set("cardLast4", numberDigits.slice(-4));
|
||||
fd.set("cardExpiryMonth", month);
|
||||
fd.set("cardExpiryYear", `20${year}`);
|
||||
fd.set("cardBrand", detectBrand(numberDigits));
|
||||
fd.set("cardHolder", name.trim());
|
||||
fd.set("saveCard", saveCard ? "true" : "false");
|
||||
}
|
||||
|
||||
startConfirm(() => confirmMockPaymentAction(fd));
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
const fd = new FormData();
|
||||
fd.set("orderId", orderId);
|
||||
startCancel(() => cancelMockPaymentAction(fd));
|
||||
};
|
||||
|
||||
const busy = confirming || cancelling;
|
||||
|
||||
// Visual sync — saved card preview when in saved mode
|
||||
const selected = savedCards.find((c) => c.$id === selectedCardId);
|
||||
const visualNumber =
|
||||
mode === "saved" && selected
|
||||
? `${"•".repeat(12)} ${selected.last4}`
|
||||
: number;
|
||||
const visualName =
|
||||
mode === "saved" && selected ? selected.holderName ?? "" : name;
|
||||
const visualExpiry =
|
||||
mode === "saved" && selected
|
||||
? `${String(selected.expiryMonth).padStart(2, "0")}/${String(selected.expiryYear).slice(2)}`
|
||||
: expiry;
|
||||
const visualCvc = mode === "saved" ? "" : cvc;
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,420px)_1fr]">
|
||||
<div className="space-y-4">
|
||||
<CreditCardVisual
|
||||
number={visualNumber}
|
||||
name={visualName}
|
||||
expiry={visualExpiry}
|
||||
cvc={visualCvc}
|
||||
flipped={flipped}
|
||||
/>
|
||||
|
||||
<div className="bg-emerald-500/5 flex items-start gap-2 rounded-md border p-3 text-xs">
|
||||
<ShieldCheck className="size-4 text-emerald-600 shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
Test modu — gerçek kart bilgisi gerekmez. Onayladığında plan{" "}
|
||||
<span className="text-foreground font-medium">{planPeriod}</span> boyunca aktif
|
||||
olur, tahsilat yapılmaz.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Ödenecek tutar
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{trFmt.format(amount)}</span>
|
||||
<span className="text-muted-foreground text-sm">{planName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{savedCards.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 rounded-md border p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded px-3 py-2 text-sm font-medium transition-colors",
|
||||
mode === "saved"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={() => setMode("saved")}
|
||||
disabled={busy}
|
||||
>
|
||||
Kayıtlı kart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded px-3 py-2 text-sm font-medium transition-colors",
|
||||
mode === "new"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={() => setMode("new")}
|
||||
disabled={busy}
|
||||
>
|
||||
Yeni kart
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "saved" && savedCards.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{savedCards.map((c) => (
|
||||
<label
|
||||
key={c.$id}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-md border p-3 transition-colors",
|
||||
selectedCardId === c.$id
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="savedCard"
|
||||
className="size-4"
|
||||
checked={selectedCardId === c.$id}
|
||||
onChange={() => setSelectedCardId(c.$id)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<CreditCard className="text-muted-foreground size-5 shrink-0" />
|
||||
<div className="flex-1 text-sm">
|
||||
<div className="font-medium">
|
||||
{BRAND_LABEL[c.brand ?? "unknown"]} •••• {c.last4}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{c.holderName ?? "İsimsiz"} · Son kullanma{" "}
|
||||
{String(c.expiryMonth).padStart(2, "0")}/{String(c.expiryYear).slice(2)}
|
||||
</div>
|
||||
</div>
|
||||
{c.isDefault && (
|
||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">
|
||||
Varsayılan
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cc-number">Kart numarası</Label>
|
||||
<Input
|
||||
id="cc-number"
|
||||
inputMode="numeric"
|
||||
autoComplete="cc-number"
|
||||
placeholder="1234 5678 9012 3456"
|
||||
value={number}
|
||||
onFocus={() => setFlipped(false)}
|
||||
onChange={(e) => setNumber(formatNumber(e.target.value))}
|
||||
className="font-mono tracking-wider"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cc-name">Kart üzerindeki ad</Label>
|
||||
<Input
|
||||
id="cc-name"
|
||||
autoComplete="cc-name"
|
||||
placeholder="AD SOYAD"
|
||||
value={name}
|
||||
onFocus={() => setFlipped(false)}
|
||||
onChange={(e) => setName(e.target.value.toUpperCase())}
|
||||
className="uppercase"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cc-expiry">Son kullanma</Label>
|
||||
<Input
|
||||
id="cc-expiry"
|
||||
inputMode="numeric"
|
||||
autoComplete="cc-exp"
|
||||
placeholder="AA/YY"
|
||||
value={expiry}
|
||||
onFocus={() => setFlipped(false)}
|
||||
onChange={(e) => setExpiry(formatExpiry(e.target.value))}
|
||||
className="font-mono"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cc-cvc">CVC</Label>
|
||||
<Input
|
||||
id="cc-cvc"
|
||||
inputMode="numeric"
|
||||
autoComplete="cc-csc"
|
||||
placeholder="123"
|
||||
value={cvc}
|
||||
onFocus={() => setFlipped(true)}
|
||||
onBlur={() => setFlipped(false)}
|
||||
onChange={(e) => setCvc(formatCvc(e.target.value))}
|
||||
className="font-mono"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 rounded-md border bg-muted/30 p-3 text-sm">
|
||||
<Checkbox
|
||||
id="save-card"
|
||||
checked={saveCard}
|
||||
onCheckedChange={(v) => setSaveCard(Boolean(v))}
|
||||
disabled={busy}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Bu kartı kaydet</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
Sonraki ödemelerde tek tıkla kullan. Kart numarasının yalnızca son 4 hanesi,
|
||||
markası ve son kullanma tarihi saklanır — ham numara hiçbir yerde tutulmaz.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-2 sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="flex-1"
|
||||
onClick={handleConfirm}
|
||||
disabled={busy || !filled}
|
||||
>
|
||||
{confirming ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Onaylanıyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="size-4" />
|
||||
Güvenli ödeme — {trFmt.format(amount)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={busy}
|
||||
>
|
||||
{cancelling ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<X className="size-4" />
|
||||
)}
|
||||
Vazgeç
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!filled && mode === "new" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Onay butonu etkin olması için tüm kart alanlarını doldurman gerekir. Test modu —
|
||||
herhangi bir 16 haneli numara çalışır.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
Plan & Faturalandırma'ya dön
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { listSavedCards } from "@/lib/appwrite/saved-card-queries";
|
||||
import { getPaymentByOrderId } from "@/lib/appwrite/subscription-queries";
|
||||
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
|
||||
import { MockPaymentForm } from "./components/mock-payment-form";
|
||||
|
||||
export default async function MockCheckoutPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ orderId: string }>;
|
||||
}) {
|
||||
const { orderId } = await params;
|
||||
const ctx = await requireTenant();
|
||||
if (ctx.role !== "owner") redirect("/settings/billing");
|
||||
|
||||
const payment = await getPaymentByOrderId(ctx.tenantId, orderId);
|
||||
if (!payment) redirect("/settings/billing");
|
||||
|
||||
if (payment.status === "success") redirect("/settings/billing?upgraded=1");
|
||||
if (payment.status === "failed") redirect("/settings/billing?cancelled=1");
|
||||
|
||||
const plan = PLAN_CATALOG[payment.plan];
|
||||
const savedCards = await listSavedCards(ctx.tenantId);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-sm"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Plan & Faturalandırma
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Ödemeyi tamamla</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
Mock Test
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Sipariş No: <span className="font-mono">{payment.orderId}</span> · Aşağıdaki kart
|
||||
formunu doldur — alanlar gerçek zamanlı olarak karta yansır.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<MockPaymentForm
|
||||
orderId={payment.orderId}
|
||||
amount={payment.amount}
|
||||
planName={plan.name}
|
||||
planPeriod="30 gün"
|
||||
savedCards={savedCards}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +1,372 @@
|
||||
"use client"
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CheckCircle2,
|
||||
CreditCard,
|
||||
Crown,
|
||||
Lock,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Star,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { PricingPlans } from "@/components/pricing-plans"
|
||||
import { CurrentPlanCard } from "./components/current-plan-card"
|
||||
import { BillingHistoryCard } from "./components/billing-history-card"
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
removeCardAction,
|
||||
setDefaultCardAction,
|
||||
} from "@/lib/appwrite/saved-card-actions";
|
||||
import { listSavedCards } from "@/lib/appwrite/saved-card-queries";
|
||||
import type { CardBrand } from "@/lib/appwrite/schema";
|
||||
import { listPaymentsForTenant } from "@/lib/appwrite/subscription-queries";
|
||||
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
|
||||
import { getEffectivePlan } from "@/lib/appwrite/plan-limits";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
|
||||
// Import data
|
||||
import currentPlanData from "./data/current-plan.json"
|
||||
import billingHistoryData from "./data/billing-history.json"
|
||||
const trFmt = new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency",
|
||||
currency: "TRY",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
export default function BillingSettings() {
|
||||
const handlePlanSelect = (planId: string) => {
|
||||
console.log('Plan selected:', planId)
|
||||
// Handle plan selection logic here
|
||||
}
|
||||
const dateFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: "Bekliyor",
|
||||
success: "Başarılı",
|
||||
failed: "İptal",
|
||||
refunded: "İade",
|
||||
};
|
||||
|
||||
const STATUS_VARIANT: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
|
||||
pending: "secondary",
|
||||
success: "default",
|
||||
failed: "outline",
|
||||
refunded: "destructive",
|
||||
};
|
||||
|
||||
const PROVIDER_LABEL: Record<string, string> = {
|
||||
mock: "Mock (Test)",
|
||||
shopier: "Shopier",
|
||||
};
|
||||
|
||||
const BRAND_LABEL: Record<CardBrand, string> = {
|
||||
visa: "Visa",
|
||||
mastercard: "Mastercard",
|
||||
amex: "Amex",
|
||||
troy: "troy",
|
||||
unknown: "Kart",
|
||||
};
|
||||
|
||||
export default async function BillingSettings({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ upgraded?: string; cancelled?: string; downgraded?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const ctx = await requireTenant();
|
||||
const plan = getEffectivePlan(ctx);
|
||||
const isPro = plan === "pro";
|
||||
const canManage = ctx.role === "owner";
|
||||
|
||||
const [payments, savedCards] = await Promise.all([
|
||||
listPaymentsForTenant(ctx.tenantId, 10),
|
||||
listSavedCards(ctx.tenantId),
|
||||
]);
|
||||
|
||||
const expiresAt = ctx.settings?.planExpiresAt;
|
||||
const catalog = isPro ? PLAN_CATALOG.pro : PLAN_CATALOG.free;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Plans & Billing</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your subscription and billing information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
|
||||
<CurrentPlanCard plan={currentPlanData} />
|
||||
<BillingHistoryCard history={billingHistoryData} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Available Plans</CardTitle>
|
||||
<CardDescription>
|
||||
Choose a plan that works best for you.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PricingPlans
|
||||
mode="billing"
|
||||
currentPlanId="professional"
|
||||
onPlanSelect={handlePlanSelect}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{ctx.settings?.companyName ?? "Çalışma alanı"}
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Faturalandırma</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Kayıtlı ödeme yöntemlerini, faturalarını ve veri saklama bilgilerini buradan yönet.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
{sp.upgraded && (
|
||||
<Card className="border-emerald-500/40 bg-emerald-500/5">
|
||||
<CardContent className="flex items-center gap-3 py-4 text-sm">
|
||||
<CheckCircle2 className="size-5 text-emerald-600" />
|
||||
<span>Pro plan aktif. Sınırsız kullanım açıldı.</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{sp.cancelled && (
|
||||
<Card className="border-amber-500/40 bg-amber-500/5">
|
||||
<CardContent className="py-4 text-sm">
|
||||
Ödeme iptal edildi. Plan değişmedi.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{sp.downgraded && (
|
||||
<Card className="border-amber-500/40 bg-amber-500/5">
|
||||
<CardContent className="py-4 text-sm">Ücretsiz plana döndünüz.</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{isPro ? (
|
||||
<Crown className="size-5 text-amber-500" />
|
||||
) : (
|
||||
<Sparkles className="size-5 text-muted-foreground" />
|
||||
)}
|
||||
{catalog.name} plan
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isPro && expiresAt
|
||||
? `Yenileme tarihi: ${dateFmt.format(new Date(expiresAt))}`
|
||||
: isPro
|
||||
? "Sınırsız kullanım."
|
||||
: "Ücretsiz plan — sınırlar dolduğunda Pro'ya geç."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/pricing">
|
||||
Planları gör
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{trFmt.format(catalog.price)}
|
||||
<span className="text-muted-foreground text-sm font-normal"> /ay</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Kayıtlı kartlar</CardTitle>
|
||||
<CardDescription>
|
||||
Sonraki ödemelerde kullanılacak ödeme yöntemleri.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{canManage && !isPro && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/pricing">
|
||||
Pro'ya geç
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{savedCards.length === 0 ? (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 py-8 text-center text-sm">
|
||||
<CreditCard className="size-8 opacity-40" />
|
||||
<p>Henüz kayıtlı kart yok.</p>
|
||||
<p className="text-xs">
|
||||
Bir ödeme yaparken "Bu kartı kaydet" seçeneğini işaretle, kart bilgileri buraya
|
||||
eklenir.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{savedCards.map((c) => (
|
||||
<li
|
||||
key={c.$id}
|
||||
className={cn(
|
||||
"flex items-center gap-4 py-3",
|
||||
c.isDefault && "bg-primary/5 -mx-2 rounded-md px-2",
|
||||
)}
|
||||
>
|
||||
<div className="bg-muted flex size-10 shrink-0 items-center justify-center rounded-md">
|
||||
<CreditCard className="size-5" />
|
||||
</div>
|
||||
<div className="flex-1 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{BRAND_LABEL[c.brand ?? "unknown"]} •••• {c.last4}
|
||||
{c.isDefault && (
|
||||
<Badge variant="secondary" className="gap-1 text-[10px]">
|
||||
<Star className="!size-3" />
|
||||
Varsayılan
|
||||
</Badge>
|
||||
)}
|
||||
{c.provider === "mock" && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Mock
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{c.holderName ?? "İsimsiz"} · Son kullanma{" "}
|
||||
{String(c.expiryMonth).padStart(2, "0")}/{String(c.expiryYear).slice(2)}
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex gap-1">
|
||||
{!c.isDefault && (
|
||||
<form action={setDefaultCardAction}>
|
||||
<input type="hidden" name="id" value={c.$id} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
Varsayılan yap
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<form action={removeCardAction}>
|
||||
<input type="hidden" name="id" value={c.$id} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Sil
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-muted/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="size-4 text-emerald-600" />
|
||||
Kart bilgileriniz nasıl saklanır?
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lock className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">Ham kart numarası saklanmaz</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
İşletmem sunucularında kart numaranızın yalnızca son 4 hanesi, markası, son
|
||||
kullanma tarihi ve kart sahibi adı tutulur. Tam kart numarası ve CVC asla
|
||||
kaydedilmez.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldCheck className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">Mock test modu</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Şu an Pro plan mock ödeme akışıyla çalışır — gerçek tahsilat yapılmaz. Test
|
||||
amaçlı girilen kart numaralarına ait yalnızca son 4 hane görüntü amacıyla
|
||||
kaydedilir.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CreditCard className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">Shopier entegrasyonu sonrası</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Shopier (Türkiye'de PCI-DSS uyumlu, BDDK lisanslı ödeme hizmeti sağlayıcısı)
|
||||
devreye girdiğinde kart bilgileri Shopier'in altyapısında tokenize edilir.
|
||||
İşletmem yalnızca tokeni saklar; bir sonraki ödemede tokenle Shopier'e tekrar
|
||||
başvurulur. Token yalnızca o aboneliğe özeldir.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground border-t pt-3 text-xs">
|
||||
KVKK Aydınlatma Metni ve Mesafeli Satış Sözleşmesi yakında bu sayfaya eklenecek.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ödeme geçmişi</CardTitle>
|
||||
<CardDescription>Son 10 işlem.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{payments.length === 0 ? (
|
||||
<p className="text-muted-foreground py-8 text-center text-sm">
|
||||
Henüz ödeme kaydı yok.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tarih</TableHead>
|
||||
<TableHead>Sipariş No</TableHead>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead>Sağlayıcı</TableHead>
|
||||
<TableHead>Durum</TableHead>
|
||||
<TableHead className="text-right">Tutar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{payments.map((p) => (
|
||||
<TableRow key={p.$id}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{dateFmt.format(new Date(p.$createdAt))}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.orderId}</TableCell>
|
||||
<TableCell className="capitalize">{p.plan}</TableCell>
|
||||
<TableCell>{PROVIDER_LABEL[p.provider ?? "mock"]}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={STATUS_VARIANT[p.status ?? "pending"]}>
|
||||
{STATUS_LABEL[p.status ?? "pending"]}
|
||||
</Badge>
|
||||
{p.status === "pending" && (
|
||||
<Link
|
||||
href={`/settings/billing/checkout/${p.orderId}`}
|
||||
className="text-primary ml-2 inline-flex items-center gap-0.5 text-xs hover:underline"
|
||||
>
|
||||
Devam <ArrowUpRight className="size-3" />
|
||||
</Link>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{trFmt.format(p.amount)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,19 +14,24 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
|
||||
import { inviteMemberAction } from "@/lib/appwrite/team-actions";
|
||||
import { initialInviteState } from "@/lib/appwrite/team-types";
|
||||
|
||||
export function InviteForm() {
|
||||
const [state, formAction, isPending] = useActionState(inviteMemberAction, initialInviteState);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [planLimitOpen, setPlanLimitOpen] = useState(false);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok && formRef.current) {
|
||||
formRef.current.reset();
|
||||
}
|
||||
}, [state.ok, state.shortUrl]);
|
||||
if (state.code === "PLAN_LIMIT_EXCEEDED") {
|
||||
setPlanLimitOpen(true);
|
||||
}
|
||||
}, [state.ok, state.shortUrl, state.code]);
|
||||
|
||||
const copy = async () => {
|
||||
if (!state.shortUrl) return;
|
||||
@@ -88,7 +93,7 @@ export function InviteForm() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{state.error && (
|
||||
{state.error && state.code !== "PLAN_LIMIT_EXCEEDED" && (
|
||||
<p className="text-destructive mt-3 text-sm" role="alert">
|
||||
{state.error}
|
||||
</p>
|
||||
@@ -109,6 +114,11 @@ export function InviteForm() {
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<PlanLimitDialog
|
||||
open={planLimitOpen}
|
||||
onOpenChange={setPlanLimitOpen}
|
||||
message={state.error}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Building2, ImagePlus, Loader2, Trash2, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
initialLogoState,
|
||||
removeLogoAction,
|
||||
uploadLogoAction,
|
||||
} from "@/lib/appwrite/logo-actions";
|
||||
|
||||
type Props = {
|
||||
canEdit: boolean;
|
||||
currentLogoUrl: string | null;
|
||||
companyName: string;
|
||||
};
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const ALLOWED_MIME = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"];
|
||||
|
||||
export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
uploadLogoAction,
|
||||
initialLogoState,
|
||||
);
|
||||
const [removing, startRemove] = useTransition();
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(currentLogoUrl);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewUrl(currentLogoUrl);
|
||||
}, [currentLogoUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Logo güncellendi.");
|
||||
setSelectedName(null);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
const handleFile = (file: File | null) => {
|
||||
if (!file) return;
|
||||
if (!ALLOWED_MIME.includes(file.type)) {
|
||||
toast.error("Sadece PNG, JPG, WebP veya SVG yükleyebilirsiniz.");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
toast.error("Dosya 2MB'dan büyük olamaz.");
|
||||
return;
|
||||
}
|
||||
setSelectedName(file.name);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setPreviewUrl(typeof e.target?.result === "string" ? e.target.result : null);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && inputRef.current) {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
inputRef.current.files = dt.files;
|
||||
handleFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
startRemove(async () => {
|
||||
const result = await removeLogoAction();
|
||||
if (result.ok) {
|
||||
toast.success("Logo kaldırıldı.");
|
||||
setPreviewUrl(null);
|
||||
setSelectedName(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
} else {
|
||||
toast.error(result.error ?? "Logo kaldırılamadı.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const submitDisabled = isPending || removing || !selectedName;
|
||||
const busy = isPending || removing;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="size-4" />
|
||||
Logo
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Faturalarda, panel başlığında ve dış paylaşımlarda görünür. PNG, JPG, WebP veya SVG —
|
||||
en fazla 2 MB.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form ref={formRef} action={formAction} className="space-y-4">
|
||||
<div className="grid items-start gap-6 md:grid-cols-[200px_1fr]">
|
||||
<div className="bg-muted flex aspect-square w-full items-center justify-center overflow-hidden rounded-lg border">
|
||||
{previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={`${companyName} logo`}
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 p-4 text-center text-xs">
|
||||
<Building2 className="size-8 opacity-40" />
|
||||
<span>Henüz logo yok</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (canEdit) setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={canEdit ? handleDrop : undefined}
|
||||
className={cn(
|
||||
"flex min-h-[120px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors",
|
||||
dragOver && "border-primary bg-primary/5",
|
||||
!canEdit && "cursor-not-allowed opacity-60",
|
||||
!dragOver && "hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
name="logo"
|
||||
accept={ALLOWED_MIME.join(",")}
|
||||
className="sr-only"
|
||||
disabled={!canEdit || busy}
|
||||
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<ImagePlus className="text-muted-foreground size-6" />
|
||||
<div className="text-sm font-medium">
|
||||
{selectedName ?? "Logo yüklemek için tıkla veya sürükle bırak"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Önerilen: kare, en az 256×256 px, şeffaf arka plan (PNG/SVG)
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canEdit && (
|
||||
<Button type="submit" disabled={submitDisabled}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Yükleniyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="size-4" />
|
||||
Yükle
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && currentLogoUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRemove}
|
||||
disabled={busy}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
{removing ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
Kaldır
|
||||
</Button>
|
||||
)}
|
||||
{!canEdit && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Logo değiştirmek için yönetici yetkisi gerekli.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { LogoUploader } from "./components/logo-uploader";
|
||||
import { WorkspaceSettingsForm } from "./components/workspace-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -29,6 +31,12 @@ export default async function WorkspaceSettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LogoUploader
|
||||
canEdit={canEdit}
|
||||
currentLogoUrl={getLogoUrl(ctx.settings?.logo)}
|
||||
companyName={ctx.settings?.companyName ?? "Çalışma alanı"}
|
||||
/>
|
||||
|
||||
<WorkspaceSettingsForm
|
||||
canEdit={canEdit}
|
||||
defaults={{
|
||||
|
||||
@@ -204,7 +204,7 @@ export function AssignmentFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
|
||||
import {
|
||||
createSoftwareAction,
|
||||
updateSoftwareAction,
|
||||
@@ -33,11 +34,14 @@ export function SoftwareFormSheet({ open, onOpenChange, software }: Props) {
|
||||
const isEdit = Boolean(software);
|
||||
const action = isEdit ? updateSoftwareAction : createSoftwareAction;
|
||||
const [state, formAction, isPending] = useActionState(action, initialSoftwareState);
|
||||
const [planLimitOpen, setPlanLimitOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success(isEdit ? "Yazılım güncellendi." : "Yazılım eklendi.");
|
||||
onOpenChange(false);
|
||||
} else if (state.code === "PLAN_LIMIT_EXCEEDED") {
|
||||
setPlanLimitOpen(true);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
@@ -108,7 +112,7 @@ export function SoftwareFormSheet({ open, onOpenChange, software }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -135,6 +139,11 @@ export function SoftwareFormSheet({ open, onOpenChange, software }: Props) {
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
<PlanLimitDialog
|
||||
open={planLimitOpen}
|
||||
onOpenChange={setPlanLimitOpen}
|
||||
message={state.error}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export function TaskFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user