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:
kovakmedya
2026-04-30 21:36:01 +03:00
parent ab336b191f
commit 196036c0d8
46 changed files with 2607 additions and 85 deletions
@@ -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>
);
}
+8 -1
View File
@@ -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,
+1
View File
@@ -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"
+2
View File
@@ -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,
+284 -16
View File
@@ -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"
@@ -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>
);
}
@@ -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>
);
}
+365 -44
View File
@@ -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"