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>
|
||||||
</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 className="flex w-full items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
{isEdit && event && onRequestDelete && (
|
{isEdit && event && onRequestDelete && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect } from "react";
|
import { useActionState, useEffect, useState } from "react";
|
||||||
import { Loader2, Save } from "lucide-react";
|
import { Loader2, Save } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
|
||||||
import {
|
import {
|
||||||
createCustomerAction,
|
createCustomerAction,
|
||||||
updateCustomerAction,
|
updateCustomerAction,
|
||||||
@@ -40,11 +41,14 @@ export function CustomerFormSheet({ open, onOpenChange, customer }: Props) {
|
|||||||
const isEdit = Boolean(customer);
|
const isEdit = Boolean(customer);
|
||||||
const action = isEdit ? updateCustomerAction : createCustomerAction;
|
const action = isEdit ? updateCustomerAction : createCustomerAction;
|
||||||
const [state, formAction, isPending] = useActionState(action, initialCustomerState);
|
const [state, formAction, isPending] = useActionState(action, initialCustomerState);
|
||||||
|
const [planLimitOpen, setPlanLimitOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.ok) {
|
if (state.ok) {
|
||||||
toast.success(isEdit ? "Müşteri güncellendi." : "Müşteri eklendi.");
|
toast.success(isEdit ? "Müşteri güncellendi." : "Müşteri eklendi.");
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
} else if (state.code === "PLAN_LIMIT_EXCEEDED") {
|
||||||
|
setPlanLimitOpen(true);
|
||||||
} else if (state.error) {
|
} else if (state.error) {
|
||||||
toast.error(state.error);
|
toast.error(state.error);
|
||||||
}
|
}
|
||||||
@@ -155,7 +159,7 @@ export function CustomerFormSheet({ open, onOpenChange, customer }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex w-full justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -182,6 +186,11 @@ export function CustomerFormSheet({ open, onOpenChange, customer }: Props) {
|
|||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</form>
|
</form>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
<PlanLimitDialog
|
||||||
|
open={planLimitOpen}
|
||||||
|
onOpenChange={setPlanLimitOpen}
|
||||||
|
message={state.error}
|
||||||
|
/>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { UsageBanner } from "@/components/billing/usage-banner";
|
||||||
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||||
|
import { getPlanUsage } from "@/lib/appwrite/plan-limits";
|
||||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
import { CustomersClient } from "./components/customers-client";
|
import { CustomersClient } from "./components/customers-client";
|
||||||
|
|
||||||
@@ -17,7 +19,10 @@ export default async function CustomersPage() {
|
|||||||
redirect("/onboarding");
|
redirect("/onboarding");
|
||||||
}
|
}
|
||||||
|
|
||||||
const customers = await listCustomers(ctx.tenantId);
|
const [customers, usage] = await Promise.all([
|
||||||
|
listCustomers(ctx.tenantId),
|
||||||
|
getPlanUsage(ctx),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||||
@@ -29,6 +34,8 @@ export default async function CustomersPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UsageBanner usage={usage} resource="customers" />
|
||||||
|
|
||||||
<CustomersClient
|
<CustomersClient
|
||||||
customers={customers.map((c) => ({
|
customers={customers.map((c) => ({
|
||||||
id: c.$id,
|
id: c.$id,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type ShellUser = {
|
|||||||
export type ShellCompany = {
|
export type ShellCompany = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
logoUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DashboardShell({
|
export function DashboardShell({
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function BankFormSheet({ open, onOpenChange, account }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex w-full justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export function CardFormSheet({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex w-full justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||||
Vazgeç
|
Vazgeç
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export function StatementFormSheet({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex w-full justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||||
Vazgeç
|
Vazgeç
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect } from "react";
|
import { useActionState, useEffect, useState } from "react";
|
||||||
import { Loader2, Save, Trash2 } from "lucide-react";
|
import { Loader2, Save, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -61,11 +63,14 @@ export function FinanceFormSheet({
|
|||||||
const isEdit = Boolean(entry);
|
const isEdit = Boolean(entry);
|
||||||
const action = isEdit ? updateFinanceEntryAction : createFinanceEntryAction;
|
const action = isEdit ? updateFinanceEntryAction : createFinanceEntryAction;
|
||||||
const [state, formAction, isPending] = useActionState(action, initialFinanceState);
|
const [state, formAction, isPending] = useActionState(action, initialFinanceState);
|
||||||
|
const [planLimitOpen, setPlanLimitOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.ok) {
|
if (state.ok) {
|
||||||
toast.success(isEdit ? "Kayıt güncellendi." : "Kayıt eklendi.");
|
toast.success(isEdit ? "Kayıt güncellendi." : "Kayıt eklendi.");
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
} else if (state.code === "PLAN_LIMIT_EXCEEDED") {
|
||||||
|
setPlanLimitOpen(true);
|
||||||
} else if (state.error) {
|
} else if (state.error) {
|
||||||
toast.error(state.error);
|
toast.error(state.error);
|
||||||
}
|
}
|
||||||
@@ -216,7 +221,7 @@ export function FinanceFormSheet({
|
|||||||
</div>
|
</div>
|
||||||
</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 className="flex w-full items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
{isEdit && entry && onRequestDelete && (
|
{isEdit && entry && onRequestDelete && (
|
||||||
@@ -260,6 +265,11 @@ export function FinanceFormSheet({
|
|||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</form>
|
</form>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
<PlanLimitDialog
|
||||||
|
open={planLimitOpen}
|
||||||
|
onOpenChange={setPlanLimitOpen}
|
||||||
|
message={state.error}
|
||||||
|
/>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export function LoanFormSheet({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex w-full justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||||
Vazgeç
|
Vazgeç
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ function ItemFormSheet({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex w-full justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export function InvoiceFormSheet({ open, onOpenChange, invoice, customers }: Pro
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex w-full justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { getActiveContext } from "@/lib/appwrite/active-context";
|
import { getActiveContext } from "@/lib/appwrite/active-context";
|
||||||
|
import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||||
import { DashboardShell } from "./dashboard-shell";
|
import { DashboardShell } from "./dashboard-shell";
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
@@ -14,6 +15,7 @@ export default async function DashboardLayout({
|
|||||||
const company = {
|
const company = {
|
||||||
id: ctx.tenantId,
|
id: ctx.tenantId,
|
||||||
name: ctx.settings?.companyName ?? "Çalışma alanı",
|
name: ctx.settings?.companyName ?? "Çalışma alanı",
|
||||||
|
logoUrl: getLogoUrl(ctx.settings?.logo) ?? null,
|
||||||
};
|
};
|
||||||
const user = {
|
const user = {
|
||||||
id: ctx.user.id,
|
id: ctx.user.id,
|
||||||
|
|||||||
@@ -1,24 +1,292 @@
|
|||||||
import { PricingPlans } from "@/components/pricing-plans"
|
import { redirect } from "next/navigation";
|
||||||
import { FeaturesGrid } from "./components/features-grid"
|
import { Building2, Check, Clock, Crown, Sparkles, Stethoscope } from "lucide-react";
|
||||||
import { FAQSection } from "./components/faq-section"
|
|
||||||
|
|
||||||
// Import data
|
import { Badge } from "@/components/ui/badge";
|
||||||
import featuresData from "./data/features.json"
|
import { Button } from "@/components/ui/button";
|
||||||
import faqsData from "./data/faqs.json"
|
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 (
|
return (
|
||||||
<div className="px-4 lg:px-6">
|
<div className="flex-1 space-y-8 px-6 pt-0">
|
||||||
{/* Pricing Cards */}
|
<div className="flex flex-col gap-1">
|
||||||
<section className='pb-12' id='pricing'>
|
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
|
||||||
<PricingPlans mode="pricing" />
|
<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>
|
</section>
|
||||||
|
|
||||||
{/* Features Section */}
|
<section className="space-y-3">
|
||||||
<FeaturesGrid features={featuresData} />
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">Ekosistem paketleri</h2>
|
||||||
{/* FAQ Section */}
|
<Badge variant="outline" className="text-xs">
|
||||||
<FAQSection faqs={faqsData} />
|
<Clock className="size-3" />
|
||||||
|
Yakında
|
||||||
|
</Badge>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
</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">
|
<div className="flex w-full justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="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 { Badge } from "@/components/ui/badge";
|
||||||
import { PricingPlans } from "@/components/pricing-plans"
|
import { Button } from "@/components/ui/button";
|
||||||
import { CurrentPlanCard } from "./components/current-plan-card"
|
import {
|
||||||
import { BillingHistoryCard } from "./components/billing-history-card"
|
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
|
const trFmt = new Intl.NumberFormat("tr-TR", {
|
||||||
import currentPlanData from "./data/current-plan.json"
|
style: "currency",
|
||||||
import billingHistoryData from "./data/billing-history.json"
|
currency: "TRY",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
export default function BillingSettings() {
|
const dateFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
const handlePlanSelect = (planId: string) => {
|
day: "2-digit",
|
||||||
console.log('Plan selected:', planId)
|
month: "short",
|
||||||
// Handle plan selection logic here
|
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 (
|
return (
|
||||||
<div className="space-y-6 px-4 lg:px-6">
|
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||||
<div>
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-3xl font-bold">Plans & Billing</h1>
|
<p className="text-muted-foreground text-sm">
|
||||||
<p className="text-muted-foreground">
|
{ctx.settings?.companyName ?? "Çalışma alanı"}
|
||||||
Manage your subscription and billing information.
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
|
{sp.upgraded && (
|
||||||
<CurrentPlanCard plan={currentPlanData} />
|
<Card className="border-emerald-500/40 bg-emerald-500/5">
|
||||||
<BillingHistoryCard history={billingHistoryData} />
|
<CardContent className="flex items-center gap-3 py-4 text-sm">
|
||||||
</div>
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-6">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Available Plans</CardTitle>
|
<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>
|
<CardDescription>
|
||||||
Choose a plan that works best for you.
|
{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>
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href="/pricing">
|
||||||
|
Planları gör
|
||||||
|
<ArrowUpRight className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<PricingPlans
|
<div className="text-2xl font-bold">
|
||||||
mode="billing"
|
{trFmt.format(catalog.price)}
|
||||||
currentPlanId="professional"
|
<span className="text-muted-foreground text-sm font-normal"> /ay</span>
|
||||||
onPlanSelect={handlePlanSelect}
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,19 +14,24 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
|
||||||
import { inviteMemberAction } from "@/lib/appwrite/team-actions";
|
import { inviteMemberAction } from "@/lib/appwrite/team-actions";
|
||||||
import { initialInviteState } from "@/lib/appwrite/team-types";
|
import { initialInviteState } from "@/lib/appwrite/team-types";
|
||||||
|
|
||||||
export function InviteForm() {
|
export function InviteForm() {
|
||||||
const [state, formAction, isPending] = useActionState(inviteMemberAction, initialInviteState);
|
const [state, formAction, isPending] = useActionState(inviteMemberAction, initialInviteState);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [planLimitOpen, setPlanLimitOpen] = useState(false);
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.ok && formRef.current) {
|
if (state.ok && formRef.current) {
|
||||||
formRef.current.reset();
|
formRef.current.reset();
|
||||||
}
|
}
|
||||||
}, [state.ok, state.shortUrl]);
|
if (state.code === "PLAN_LIMIT_EXCEEDED") {
|
||||||
|
setPlanLimitOpen(true);
|
||||||
|
}
|
||||||
|
}, [state.ok, state.shortUrl, state.code]);
|
||||||
|
|
||||||
const copy = async () => {
|
const copy = async () => {
|
||||||
if (!state.shortUrl) return;
|
if (!state.shortUrl) return;
|
||||||
@@ -88,7 +93,7 @@ export function InviteForm() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{state.error && (
|
{state.error && state.code !== "PLAN_LIMIT_EXCEEDED" && (
|
||||||
<p className="text-destructive mt-3 text-sm" role="alert">
|
<p className="text-destructive mt-3 text-sm" role="alert">
|
||||||
{state.error}
|
{state.error}
|
||||||
</p>
|
</p>
|
||||||
@@ -109,6 +114,11 @@ export function InviteForm() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<PlanLimitDialog
|
||||||
|
open={planLimitOpen}
|
||||||
|
onOpenChange={setPlanLimitOpen}
|
||||||
|
message={state.error}
|
||||||
|
/>
|
||||||
</Card>
|
</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 type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
|
import { LogoUploader } from "./components/logo-uploader";
|
||||||
import { WorkspaceSettingsForm } from "./components/workspace-form";
|
import { WorkspaceSettingsForm } from "./components/workspace-form";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -29,6 +31,12 @@ export default async function WorkspaceSettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LogoUploader
|
||||||
|
canEdit={canEdit}
|
||||||
|
currentLogoUrl={getLogoUrl(ctx.settings?.logo)}
|
||||||
|
companyName={ctx.settings?.companyName ?? "Çalışma alanı"}
|
||||||
|
/>
|
||||||
|
|
||||||
<WorkspaceSettingsForm
|
<WorkspaceSettingsForm
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
defaults={{
|
defaults={{
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export function AssignmentFormSheet({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex w-full justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect } from "react";
|
import { useActionState, useEffect, useState } from "react";
|
||||||
import { Loader2, Save } from "lucide-react";
|
import { Loader2, Save } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
|
||||||
import {
|
import {
|
||||||
createSoftwareAction,
|
createSoftwareAction,
|
||||||
updateSoftwareAction,
|
updateSoftwareAction,
|
||||||
@@ -33,11 +34,14 @@ export function SoftwareFormSheet({ open, onOpenChange, software }: Props) {
|
|||||||
const isEdit = Boolean(software);
|
const isEdit = Boolean(software);
|
||||||
const action = isEdit ? updateSoftwareAction : createSoftwareAction;
|
const action = isEdit ? updateSoftwareAction : createSoftwareAction;
|
||||||
const [state, formAction, isPending] = useActionState(action, initialSoftwareState);
|
const [state, formAction, isPending] = useActionState(action, initialSoftwareState);
|
||||||
|
const [planLimitOpen, setPlanLimitOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.ok) {
|
if (state.ok) {
|
||||||
toast.success(isEdit ? "Yazılım güncellendi." : "Yazılım eklendi.");
|
toast.success(isEdit ? "Yazılım güncellendi." : "Yazılım eklendi.");
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
} else if (state.code === "PLAN_LIMIT_EXCEEDED") {
|
||||||
|
setPlanLimitOpen(true);
|
||||||
} else if (state.error) {
|
} else if (state.error) {
|
||||||
toast.error(state.error);
|
toast.error(state.error);
|
||||||
}
|
}
|
||||||
@@ -108,7 +112,7 @@ export function SoftwareFormSheet({ open, onOpenChange, software }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex w-full justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -135,6 +139,11 @@ export function SoftwareFormSheet({ open, onOpenChange, software }: Props) {
|
|||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</form>
|
</form>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
<PlanLimitDialog
|
||||||
|
open={planLimitOpen}
|
||||||
|
onOpenChange={setPlanLimitOpen}
|
||||||
|
message={state.error}
|
||||||
|
/>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export function TaskFormSheet({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex w-full justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -149,9 +149,20 @@ export function AppSidebar({
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton size="lg" asChild>
|
<SidebarMenuButton size="lg" asChild>
|
||||||
<Link href="/dashboard">
|
<Link href="/dashboard">
|
||||||
|
{company.logoUrl ? (
|
||||||
|
<div className="bg-background flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg border">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={company.logoUrl}
|
||||||
|
alt={`${company.name} logo`}
|
||||||
|
className="size-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
|
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
|
||||||
<Logo size={20} className="text-current" />
|
<Logo size={20} className="text-current" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">İşletmem</span>
|
<span className="truncate font-medium">İşletmem</span>
|
||||||
<span className="text-muted-foreground truncate text-xs">{company.name}</span>
|
<span className="text-muted-foreground truncate text-xs">{company.name}</span>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Crown } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlanLimitDialog({ open, onOpenChange, message }: Props) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Crown className="size-5 text-amber-500" />
|
||||||
|
Ücretsiz plan sınırına ulaştınız
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{message ?? "Yeni kayıt eklemek için Pro plana geçmeniz gerekiyor."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="rounded-md border bg-muted/30 p-3 text-sm space-y-1">
|
||||||
|
<div className="font-medium text-foreground">Pro plan ile gelen avantajlar</div>
|
||||||
|
<ul className="text-muted-foreground space-y-0.5 list-disc list-inside">
|
||||||
|
<li>Sınırsız müşteri, finans kaydı, yazılım</li>
|
||||||
|
<li>Sınırsız ekip üyesi</li>
|
||||||
|
<li>Audit log + öncelikli destek</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Kapat
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/settings/billing">
|
||||||
|
<Crown className="size-4" />
|
||||||
|
Pro'ya geç
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { AlertTriangle, Crown } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
RESOURCE_LABELS,
|
||||||
|
type PlanResource,
|
||||||
|
type PlanUsage,
|
||||||
|
} from "@/lib/appwrite/plan-limits";
|
||||||
|
|
||||||
|
const SOFT_THRESHOLD = 0.8;
|
||||||
|
|
||||||
|
export function UsageBanner({
|
||||||
|
usage,
|
||||||
|
resource,
|
||||||
|
}: {
|
||||||
|
usage: PlanUsage;
|
||||||
|
resource: PlanResource;
|
||||||
|
}) {
|
||||||
|
if (usage.plan === "pro") return null;
|
||||||
|
|
||||||
|
const u = usage.usage[resource];
|
||||||
|
if (u.limit === Number.POSITIVE_INFINITY) return null;
|
||||||
|
|
||||||
|
const ratio = u.used / u.limit;
|
||||||
|
if (ratio < SOFT_THRESHOLD) return null;
|
||||||
|
|
||||||
|
const label = RESOURCE_LABELS[resource];
|
||||||
|
const reached = u.reached;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={
|
||||||
|
reached
|
||||||
|
? "border-destructive/40 bg-destructive/5"
|
||||||
|
: "border-amber-500/40 bg-amber-500/5"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CardContent className="flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-start gap-2 text-sm">
|
||||||
|
<AlertTriangle
|
||||||
|
className={`size-4 mt-0.5 shrink-0 ${
|
||||||
|
reached ? "text-destructive" : "text-amber-600"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">
|
||||||
|
{reached ? "Sınıra ulaşıldı" : "Sınıra yaklaşıyorsun"}:
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{u.used} / {u.limit} {label}.
|
||||||
|
</span>
|
||||||
|
{reached && (
|
||||||
|
<span className="text-muted-foreground"> Yeni {label} eklemek için Pro'ya geç.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button asChild size="sm" variant={reached ? "default" : "outline"}>
|
||||||
|
<Link href="/settings/billing">
|
||||||
|
<Crown className="size-3.5" />
|
||||||
|
Pro'ya geç
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,9 +58,9 @@ function SheetContent({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
side === "right" &&
|
side === "right" &&
|
||||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-dvh w-3/4 border-l sm:max-w-sm",
|
||||||
side === "left" &&
|
side === "left" &&
|
||||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-dvh w-3/4 border-r sm:max-w-sm",
|
||||||
side === "top" &&
|
side === "top" &&
|
||||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
side === "bottom" &&
|
side === "bottom" &&
|
||||||
@@ -93,7 +93,10 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sheet-footer"
|
data-slot="sheet-footer"
|
||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
className={cn(
|
||||||
|
"mt-auto flex flex-col gap-2 p-4 pb-[max(1rem,env(safe-area-inset-bottom))]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { AppwriteException, ID, Permission, Role } from "node-appwrite";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { logAudit } from "./audit";
|
import { logAudit } from "./audit";
|
||||||
|
import {
|
||||||
|
isPlanLimitError,
|
||||||
|
planLimitMessage,
|
||||||
|
requirePlanCapacity,
|
||||||
|
} from "./plan-limits";
|
||||||
import { DATABASE_ID, TABLES, type Customer } from "./schema";
|
import { DATABASE_ID, TABLES, type Customer } from "./schema";
|
||||||
import { createAdminClient } from "./server";
|
import { createAdminClient } from "./server";
|
||||||
import { requireTenant } from "./tenant-guard";
|
import { requireTenant } from "./tenant-guard";
|
||||||
@@ -64,6 +69,19 @@ export async function createCustomerAction(
|
|||||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requirePlanCapacity(ctx, "customers");
|
||||||
|
} catch (e) {
|
||||||
|
if (isPlanLimitError(e)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: planLimitMessage(e.resource, e.limit),
|
||||||
|
code: "PLAN_LIMIT_EXCEEDED",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
const row = await tablesDB.createRow(
|
const row = await tablesDB.createRow(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type CustomerActionState = {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
fieldErrors?: Record<string, string>;
|
fieldErrors?: Record<string, string>;
|
||||||
|
code?: "PLAN_LIMIT_EXCEEDED";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialCustomerState: CustomerActionState = { ok: false };
|
export const initialCustomerState: CustomerActionState = { ok: false };
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { AppwriteException, ID } from "node-appwrite";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { logAudit } from "./audit";
|
import { logAudit } from "./audit";
|
||||||
|
import {
|
||||||
|
isPlanLimitError,
|
||||||
|
planLimitMessage,
|
||||||
|
requirePlanCapacity,
|
||||||
|
} from "./plan-limits";
|
||||||
import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema";
|
import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema";
|
||||||
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
|
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
|
||||||
import { createAdminClient } from "./server";
|
import { createAdminClient } from "./server";
|
||||||
@@ -68,6 +73,19 @@ export async function createFinanceEntryAction(
|
|||||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requirePlanCapacity(ctx, "financeEntries");
|
||||||
|
} catch (e) {
|
||||||
|
if (isPlanLimitError(e)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: planLimitMessage(e.resource, e.limit),
|
||||||
|
code: "PLAN_LIMIT_EXCEEDED",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
const data = { ...parsed.data, date: toIso(parsed.data.date) };
|
const data = { ...parsed.data, date: toIso(parsed.data.date) };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type FinanceActionState = {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
fieldErrors?: Record<string, string>;
|
fieldErrors?: Record<string, string>;
|
||||||
|
code?: "PLAN_LIMIT_EXCEEDED";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialFinanceState: FinanceActionState = { ok: false };
|
export const initialFinanceState: FinanceActionState = { ok: false };
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { ID, Permission, Role } from "node-appwrite";
|
||||||
|
import { InputFile } from "node-appwrite/file";
|
||||||
|
|
||||||
|
import { logAudit } from "./audit";
|
||||||
|
import { BUCKETS, DATABASE_ID, TABLES } from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
import { requireRole, requireTenant } from "./tenant-guard";
|
||||||
|
|
||||||
|
const MAX_BYTES = 2 * 1024 * 1024;
|
||||||
|
const ALLOWED_TYPES = new Set([
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/webp",
|
||||||
|
"image/svg+xml",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type LogoActionState = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialLogoState: LogoActionState = { ok: false };
|
||||||
|
|
||||||
|
function teamLogoPermissions(tenantId: string) {
|
||||||
|
return [
|
||||||
|
Permission.read(Role.any()),
|
||||||
|
Permission.update(Role.team(tenantId, "owner")),
|
||||||
|
Permission.update(Role.team(tenantId, "admin")),
|
||||||
|
Permission.delete(Role.team(tenantId, "owner")),
|
||||||
|
Permission.delete(Role.team(tenantId, "admin")),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadLogoAction(
|
||||||
|
_prev: LogoActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<LogoActionState> {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner", "admin"]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Logo yüklemek için yönetici yetkisi gerekli." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = formData.get("logo");
|
||||||
|
if (!(file instanceof File) || file.size === 0) {
|
||||||
|
return { ok: false, error: "Dosya seçin." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
return { ok: false, error: "Dosya 2MB'dan büyük olamaz." };
|
||||||
|
}
|
||||||
|
if (!ALLOWED_TYPES.has(file.type)) {
|
||||||
|
return { ok: false, error: "Sadece PNG, JPG, WebP veya SVG yükleyebilirsiniz." };
|
||||||
|
}
|
||||||
|
if (!ctx.settings) {
|
||||||
|
return { ok: false, error: "Çalışma alanı ayarları bulunamadı." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storage, tablesDB } = createAdminClient();
|
||||||
|
const previousLogoId = ctx.settings.logo;
|
||||||
|
|
||||||
|
let newFileId: string | null = null;
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const inputFile = InputFile.fromBuffer(buffer, file.name);
|
||||||
|
|
||||||
|
const created = await storage.createFile({
|
||||||
|
bucketId: BUCKETS.tenantLogos,
|
||||||
|
fileId: ID.unique(),
|
||||||
|
file: inputFile,
|
||||||
|
permissions: teamLogoPermissions(ctx.tenantId),
|
||||||
|
});
|
||||||
|
newFileId = created.$id;
|
||||||
|
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
|
||||||
|
logo: newFileId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (previousLogoId && previousLogoId !== newFileId) {
|
||||||
|
try {
|
||||||
|
await storage.deleteFile({
|
||||||
|
bucketId: BUCKETS.tenantLogos,
|
||||||
|
fileId: previousLogoId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort — orphaned file is acceptable, won't block the new logo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "tenant_logo",
|
||||||
|
entityId: newFileId,
|
||||||
|
changes: { previous: previousLogoId ?? null },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (newFileId) {
|
||||||
|
try {
|
||||||
|
await storage.deleteFile({
|
||||||
|
bucketId: BUCKETS.tenantLogos,
|
||||||
|
fileId: newFileId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore cleanup error */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : "Logo yüklenemedi.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/settings/workspace");
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeLogoAction(): Promise<LogoActionState> {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner", "admin"]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Logo silmek için yönetici yetkisi gerekli." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.settings) {
|
||||||
|
return { ok: false, error: "Çalışma alanı ayarları bulunamadı." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousLogoId = ctx.settings.logo;
|
||||||
|
if (!previousLogoId) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { storage, tablesDB } = createAdminClient();
|
||||||
|
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
|
||||||
|
logo: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await storage.deleteFile({
|
||||||
|
bucketId: BUCKETS.tenantLogos,
|
||||||
|
fileId: previousLogoId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* file already gone, fine */
|
||||||
|
}
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "delete",
|
||||||
|
entityType: "tenant_logo",
|
||||||
|
entityId: previousLogoId,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : "Logo silinemedi.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/settings/workspace");
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
import { DATABASE_ID, TABLES, type TenantPlan } from "./schema";
|
||||||
|
import type { TenantContext } from "./tenant-guard";
|
||||||
|
|
||||||
|
export type PlanResource = "customers" | "financeEntries" | "software" | "members";
|
||||||
|
|
||||||
|
export const PLAN_LIMIT_EXCEEDED = "PLAN_LIMIT_EXCEEDED";
|
||||||
|
|
||||||
|
const INF = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
export const PLAN_LIMITS: Record<TenantPlan, Record<PlanResource, number>> = {
|
||||||
|
free: {
|
||||||
|
customers: 50,
|
||||||
|
financeEntries: 100,
|
||||||
|
software: 5,
|
||||||
|
members: 1,
|
||||||
|
},
|
||||||
|
pro: {
|
||||||
|
customers: INF,
|
||||||
|
financeEntries: INF,
|
||||||
|
software: INF,
|
||||||
|
members: INF,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RESOURCE_LABELS: Record<PlanResource, string> = {
|
||||||
|
customers: "müşteri",
|
||||||
|
financeEntries: "finans kaydı",
|
||||||
|
software: "yazılım",
|
||||||
|
members: "ekip üyesi",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getEffectivePlan(ctx: TenantContext): TenantPlan {
|
||||||
|
const plan = ctx.settings?.plan ?? "free";
|
||||||
|
if (plan === "pro") {
|
||||||
|
const expires = ctx.settings?.planExpiresAt;
|
||||||
|
if (expires && new Date(expires).getTime() < Date.now()) {
|
||||||
|
return "free";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countResource(
|
||||||
|
tenantId: string,
|
||||||
|
resource: PlanResource,
|
||||||
|
): Promise<number> {
|
||||||
|
const { tablesDB, teams } = createAdminClient();
|
||||||
|
|
||||||
|
if (resource === "members") {
|
||||||
|
const result = await teams.listMemberships(tenantId);
|
||||||
|
return result.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableMap: Record<Exclude<PlanResource, "members">, string> = {
|
||||||
|
customers: TABLES.customers,
|
||||||
|
financeEntries: TABLES.financeEntries,
|
||||||
|
software: TABLES.software,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: tableMap[resource],
|
||||||
|
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
|
||||||
|
});
|
||||||
|
return result.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlanUsage = {
|
||||||
|
plan: TenantPlan;
|
||||||
|
usage: Record<PlanResource, { used: number; limit: number; reached: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getPlanUsage(ctx: TenantContext): Promise<PlanUsage> {
|
||||||
|
const plan = getEffectivePlan(ctx);
|
||||||
|
const limits = PLAN_LIMITS[plan];
|
||||||
|
|
||||||
|
const resources: PlanResource[] = ["customers", "financeEntries", "software", "members"];
|
||||||
|
const counts = await Promise.all(resources.map((r) => countResource(ctx.tenantId, r)));
|
||||||
|
|
||||||
|
const usage = {} as PlanUsage["usage"];
|
||||||
|
resources.forEach((r, i) => {
|
||||||
|
const used = counts[i];
|
||||||
|
const limit = limits[r];
|
||||||
|
usage[r] = { used, limit, reached: used >= limit };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { plan, usage };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlanLimitError extends Error {
|
||||||
|
code = PLAN_LIMIT_EXCEEDED;
|
||||||
|
constructor(public resource: PlanResource, public limit: number) {
|
||||||
|
super(`Plan limit reached for ${resource} (${limit})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requirePlanCapacity(
|
||||||
|
ctx: TenantContext,
|
||||||
|
resource: PlanResource,
|
||||||
|
): Promise<void> {
|
||||||
|
const plan = getEffectivePlan(ctx);
|
||||||
|
const limit = PLAN_LIMITS[plan][resource];
|
||||||
|
if (limit === INF) return;
|
||||||
|
|
||||||
|
const used = await countResource(ctx.tenantId, resource);
|
||||||
|
if (used >= limit) {
|
||||||
|
throw new PlanLimitError(resource, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPlanLimitError(e: unknown): e is PlanLimitError {
|
||||||
|
return e instanceof PlanLimitError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planLimitMessage(resource: PlanResource, limit: number): string {
|
||||||
|
const label = RESOURCE_LABELS[resource];
|
||||||
|
return `Ücretsiz planda en fazla ${limit} ${label} ekleyebilirsiniz. Pro'ya geçerek sınırı kaldırın.`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { ID, Permission, Query, Role } from "node-appwrite";
|
||||||
|
|
||||||
|
import { logAudit } from "./audit";
|
||||||
|
import { DATABASE_ID, TABLES, type CardBrand, type SavedCard } from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
import { requireRole, requireTenant } from "./tenant-guard";
|
||||||
|
|
||||||
|
const VALID_BRANDS: CardBrand[] = ["visa", "mastercard", "amex", "troy", "unknown"];
|
||||||
|
|
||||||
|
function teamCardPermissions(tenantId: string) {
|
||||||
|
return [
|
||||||
|
Permission.read(Role.team(tenantId, "owner")),
|
||||||
|
Permission.read(Role.team(tenantId, "admin")),
|
||||||
|
Permission.update(Role.team(tenantId, "owner")),
|
||||||
|
Permission.delete(Role.team(tenantId, "owner")),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearOtherDefaults(tenantId: string, exceptId?: string): Promise<void> {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.savedCards,
|
||||||
|
queries: [
|
||||||
|
Query.equal("tenantId", tenantId),
|
||||||
|
Query.equal("isDefault", true),
|
||||||
|
Query.limit(20),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
for (const row of result.rows) {
|
||||||
|
if (exceptId && row.$id === exceptId) continue;
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.savedCards, row.$id, {
|
||||||
|
isDefault: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveCardInput = {
|
||||||
|
brand: string;
|
||||||
|
last4: string;
|
||||||
|
expiryMonth: number;
|
||||||
|
expiryYear: number;
|
||||||
|
holderName: string;
|
||||||
|
makeDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function persistCardFromMockCheckout(input: SaveCardInput): Promise<void> {
|
||||||
|
const ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner"]);
|
||||||
|
|
||||||
|
const brand = (VALID_BRANDS as string[]).includes(input.brand)
|
||||||
|
? (input.brand as CardBrand)
|
||||||
|
: "unknown";
|
||||||
|
const last4 = input.last4.replace(/\D/g, "").slice(-4).padStart(4, "0");
|
||||||
|
|
||||||
|
if (last4.length !== 4) throw new Error("Geçersiz kart son 4 hanesi.");
|
||||||
|
if (input.expiryMonth < 1 || input.expiryMonth > 12) throw new Error("Geçersiz ay.");
|
||||||
|
if (input.expiryYear < 2026 || input.expiryYear > 2099) throw new Error("Geçersiz yıl.");
|
||||||
|
|
||||||
|
if (input.makeDefault) {
|
||||||
|
await clearOtherDefaults(ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const row = await tablesDB.createRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.savedCards,
|
||||||
|
ID.unique(),
|
||||||
|
{
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
createdBy: ctx.user.id,
|
||||||
|
brand,
|
||||||
|
last4,
|
||||||
|
expiryMonth: input.expiryMonth,
|
||||||
|
expiryYear: input.expiryYear,
|
||||||
|
holderName: input.holderName.trim().slice(0, 128) || undefined,
|
||||||
|
provider: "mock",
|
||||||
|
isDefault: input.makeDefault,
|
||||||
|
},
|
||||||
|
teamCardPermissions(ctx.tenantId),
|
||||||
|
);
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "create",
|
||||||
|
entityType: "saved_card",
|
||||||
|
entityId: row.$id,
|
||||||
|
changes: { brand, last4, isDefault: input.makeDefault },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/settings/billing");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDefaultCardAction(formData: FormData): Promise<void> {
|
||||||
|
const id = String(formData.get("id") ?? "");
|
||||||
|
if (!id) throw new Error("ID eksik.");
|
||||||
|
|
||||||
|
const ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner"]);
|
||||||
|
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const existing = (await tablesDB.getRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.savedCards,
|
||||||
|
id,
|
||||||
|
)) as unknown as SavedCard;
|
||||||
|
if (existing.tenantId !== ctx.tenantId) throw new Error("Erişim engellendi.");
|
||||||
|
|
||||||
|
await clearOtherDefaults(ctx.tenantId, id);
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.savedCards, id, { isDefault: true });
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "saved_card",
|
||||||
|
entityId: id,
|
||||||
|
changes: { isDefault: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/settings/billing");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeCardAction(formData: FormData): Promise<void> {
|
||||||
|
const id = String(formData.get("id") ?? "");
|
||||||
|
if (!id) throw new Error("ID eksik.");
|
||||||
|
|
||||||
|
const ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner"]);
|
||||||
|
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const existing = (await tablesDB.getRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.savedCards,
|
||||||
|
id,
|
||||||
|
)) as unknown as SavedCard;
|
||||||
|
if (existing.tenantId !== ctx.tenantId) throw new Error("Erişim engellendi.");
|
||||||
|
|
||||||
|
await tablesDB.deleteRow(DATABASE_ID, TABLES.savedCards, id);
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "delete",
|
||||||
|
entityType: "saved_card",
|
||||||
|
entityId: id,
|
||||||
|
changes: { last4: existing.last4 },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/settings/billing");
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import { DATABASE_ID, TABLES, type SavedCard } from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
|
||||||
|
export async function listSavedCards(tenantId: string): Promise<SavedCard[]> {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.savedCards,
|
||||||
|
queries: [
|
||||||
|
Query.equal("tenantId", tenantId),
|
||||||
|
Query.orderDesc("isDefault"),
|
||||||
|
Query.orderDesc("$createdAt"),
|
||||||
|
Query.limit(20),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return result.rows as unknown as SavedCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDefaultCard(tenantId: string): Promise<SavedCard | null> {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.savedCards,
|
||||||
|
queries: [
|
||||||
|
Query.equal("tenantId", tenantId),
|
||||||
|
Query.equal("isDefault", true),
|
||||||
|
Query.limit(1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return (result.rows[0] as unknown as SavedCard) ?? null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
export const DATABASE_ID = "isletmem";
|
export const DATABASE_ID = "isletmem";
|
||||||
|
|
||||||
|
export const BUCKETS = {
|
||||||
|
tenantLogos: "tenant-logos",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const TABLES = {
|
export const TABLES = {
|
||||||
tenantSettings: "tenant_settings",
|
tenantSettings: "tenant_settings",
|
||||||
customers: "customers",
|
customers: "customers",
|
||||||
@@ -18,6 +22,8 @@ export const TABLES = {
|
|||||||
loanInstallments: "loan_installments",
|
loanInstallments: "loan_installments",
|
||||||
creditCards: "credit_cards",
|
creditCards: "credit_cards",
|
||||||
creditCardStatements: "credit_card_statements",
|
creditCardStatements: "credit_card_statements",
|
||||||
|
subscriptionPayments: "subscription_payments",
|
||||||
|
savedCards: "saved_cards",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TableId = (typeof TABLES)[keyof typeof TABLES];
|
export type TableId = (typeof TABLES)[keyof typeof TABLES];
|
||||||
@@ -36,6 +42,8 @@ type Row = SystemRow;
|
|||||||
|
|
||||||
export type TenantRole = "owner" | "admin" | "member";
|
export type TenantRole = "owner" | "admin" | "member";
|
||||||
|
|
||||||
|
export type TenantPlan = "free" | "pro";
|
||||||
|
|
||||||
export interface TenantSettings extends Row {
|
export interface TenantSettings extends Row {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
@@ -47,6 +55,10 @@ export interface TenantSettings extends Row {
|
|||||||
defaultVatRate?: number;
|
defaultVatRate?: number;
|
||||||
invoicePrefix?: string;
|
invoicePrefix?: string;
|
||||||
invoiceCounter?: number;
|
invoiceCounter?: number;
|
||||||
|
plan?: TenantPlan;
|
||||||
|
planStartedAt?: string;
|
||||||
|
planExpiresAt?: string;
|
||||||
|
lastPaymentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CustomerStatus = "active" | "passive";
|
export type CustomerStatus = "active" | "passive";
|
||||||
@@ -263,6 +275,37 @@ export interface CreditCardStatement extends Row {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SubscriptionStatus = "pending" | "success" | "failed" | "refunded";
|
||||||
|
export type SubscriptionProvider = "mock" | "shopier";
|
||||||
|
|
||||||
|
export type CardBrand = "visa" | "mastercard" | "amex" | "troy" | "unknown";
|
||||||
|
|
||||||
|
export interface SavedCard extends Row {
|
||||||
|
tenantId: string;
|
||||||
|
createdBy: string;
|
||||||
|
brand?: CardBrand;
|
||||||
|
last4: string;
|
||||||
|
expiryMonth: number;
|
||||||
|
expiryYear: number;
|
||||||
|
holderName?: string;
|
||||||
|
providerToken?: string;
|
||||||
|
provider?: SubscriptionProvider;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionPayment extends Row {
|
||||||
|
tenantId: string;
|
||||||
|
createdBy: string;
|
||||||
|
orderId: string;
|
||||||
|
plan: TenantPlan;
|
||||||
|
amount: number;
|
||||||
|
currency?: string;
|
||||||
|
status?: SubscriptionStatus;
|
||||||
|
provider?: SubscriptionProvider;
|
||||||
|
providerPayload?: string;
|
||||||
|
processedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type InviteRole = "admin" | "member";
|
export type InviteRole = "admin" | "member";
|
||||||
export type InviteStatus = "pending" | "accepted" | "cancelled" | "expired";
|
export type InviteStatus = "pending" | "accepted" | "cancelled" | "expired";
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { logAudit } from "./audit";
|
import { logAudit } from "./audit";
|
||||||
|
import {
|
||||||
|
isPlanLimitError,
|
||||||
|
planLimitMessage,
|
||||||
|
requirePlanCapacity,
|
||||||
|
} from "./plan-limits";
|
||||||
import {
|
import {
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
TABLES,
|
TABLES,
|
||||||
@@ -68,6 +73,19 @@ export async function createSoftwareAction(
|
|||||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requirePlanCapacity(ctx, "software");
|
||||||
|
} catch (e) {
|
||||||
|
if (isPlanLimitError(e)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: planLimitMessage(e.resource, e.limit),
|
||||||
|
code: "PLAN_LIMIT_EXCEEDED",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
const row = await tablesDB.createRow(
|
const row = await tablesDB.createRow(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type SoftwareActionState = {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
fieldErrors?: Record<string, string>;
|
fieldErrors?: Record<string, string>;
|
||||||
|
code?: "PLAN_LIMIT_EXCEEDED";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialSoftwareState: SoftwareActionState = { ok: false };
|
export const initialSoftwareState: SoftwareActionState = { ok: false };
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { BUCKETS } from "./schema";
|
||||||
|
|
||||||
|
const ENDPOINT = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT?.replace(/\/$/, "") ?? "";
|
||||||
|
const PROJECT_ID = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID ?? "";
|
||||||
|
|
||||||
|
export function getFileViewUrl(bucketId: string, fileId: string): string {
|
||||||
|
if (!ENDPOINT || !PROJECT_ID || !fileId) return "";
|
||||||
|
return `${ENDPOINT}/storage/buckets/${bucketId}/files/${fileId}/view?project=${PROJECT_ID}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogoUrl(fileId?: string | null): string | null {
|
||||||
|
if (!fileId) return null;
|
||||||
|
return getFileViewUrl(BUCKETS.tenantLogos, fileId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ID, Permission, Query, Role } from "node-appwrite";
|
||||||
|
|
||||||
|
import { logAudit } from "./audit";
|
||||||
|
import { persistCardFromMockCheckout } from "./saved-card-actions";
|
||||||
|
import { getDefaultCard } from "./saved-card-queries";
|
||||||
|
import { DATABASE_ID, TABLES, type SubscriptionPayment, type TenantPlan } from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
import { requireRole, requireTenant } from "./tenant-guard";
|
||||||
|
import { PLAN_CATALOG } from "./subscription-types";
|
||||||
|
|
||||||
|
const PRO_VALIDITY_DAYS = 30;
|
||||||
|
|
||||||
|
function teamRowPermissions(tenantId: string) {
|
||||||
|
return [
|
||||||
|
Permission.read(Role.team(tenantId, "owner")),
|
||||||
|
Permission.read(Role.team(tenantId, "admin")),
|
||||||
|
Permission.update(Role.team(tenantId, "owner")),
|
||||||
|
Permission.delete(Role.team(tenantId, "owner")),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateOrderId(): string {
|
||||||
|
const t = Date.now().toString(36);
|
||||||
|
const r = Math.random().toString(36).slice(2, 10);
|
||||||
|
return `ord_${t}_${r}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startMockCheckoutAction(formData: FormData): Promise<void> {
|
||||||
|
const plan = String(formData.get("plan") ?? "") as TenantPlan;
|
||||||
|
if (plan !== "pro") throw new Error("Geçersiz plan.");
|
||||||
|
|
||||||
|
const ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner"]);
|
||||||
|
|
||||||
|
const catalog = PLAN_CATALOG[plan];
|
||||||
|
const orderId = generateOrderId();
|
||||||
|
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
await tablesDB.createRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.subscriptionPayments,
|
||||||
|
ID.unique(),
|
||||||
|
{
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
createdBy: ctx.user.id,
|
||||||
|
orderId,
|
||||||
|
plan,
|
||||||
|
amount: catalog.price,
|
||||||
|
currency: catalog.currency,
|
||||||
|
status: "pending",
|
||||||
|
provider: "mock",
|
||||||
|
},
|
||||||
|
teamRowPermissions(ctx.tenantId),
|
||||||
|
);
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "create",
|
||||||
|
entityType: "subscription_payment",
|
||||||
|
entityId: orderId,
|
||||||
|
changes: { plan, amount: catalog.price, provider: "mock" },
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(`/settings/billing/checkout/${orderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findPendingPaymentByOrderId(
|
||||||
|
tenantId: string,
|
||||||
|
orderId: string,
|
||||||
|
): Promise<SubscriptionPayment | null> {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.subscriptionPayments,
|
||||||
|
queries: [Query.equal("orderId", orderId), Query.equal("tenantId", tenantId), Query.limit(1)],
|
||||||
|
});
|
||||||
|
return (result.rows[0] as unknown as SubscriptionPayment) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmMockPaymentAction(formData: FormData): Promise<void> {
|
||||||
|
const orderId = String(formData.get("orderId") ?? "");
|
||||||
|
if (!orderId) throw new Error("orderId eksik.");
|
||||||
|
|
||||||
|
const ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner"]);
|
||||||
|
|
||||||
|
const payment = await findPendingPaymentByOrderId(ctx.tenantId, orderId);
|
||||||
|
if (!payment) throw new Error("Ödeme bulunamadı.");
|
||||||
|
if (payment.status === "success") {
|
||||||
|
redirect(`/settings/billing?upgraded=1`);
|
||||||
|
}
|
||||||
|
if (payment.provider !== "mock") {
|
||||||
|
throw new Error("Bu ödeme mock olarak onaylanamaz.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCard = String(formData.get("saveCard") ?? "") === "true";
|
||||||
|
const useSavedCardId = String(formData.get("savedCardId") ?? "");
|
||||||
|
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const now = new Date();
|
||||||
|
const expires = new Date(now.getTime() + PRO_VALIDITY_DAYS * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.subscriptionPayments, payment.$id, {
|
||||||
|
status: "success",
|
||||||
|
processedAt: now.toISOString(),
|
||||||
|
providerPayload: JSON.stringify({
|
||||||
|
mock: true,
|
||||||
|
confirmedBy: ctx.user.id,
|
||||||
|
usedSavedCardId: useSavedCardId || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ctx.settings) {
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
|
||||||
|
plan: payment.plan,
|
||||||
|
planStartedAt: now.toISOString(),
|
||||||
|
planExpiresAt: expires.toISOString(),
|
||||||
|
lastPaymentId: payment.$id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveCard && !useSavedCardId) {
|
||||||
|
const last4 = String(formData.get("cardLast4") ?? "").replace(/\D/g, "").slice(-4);
|
||||||
|
const month = parseInt(String(formData.get("cardExpiryMonth") ?? "0"), 10);
|
||||||
|
const year = parseInt(String(formData.get("cardExpiryYear") ?? "0"), 10);
|
||||||
|
const brand = String(formData.get("cardBrand") ?? "unknown");
|
||||||
|
const holder = String(formData.get("cardHolder") ?? "").trim();
|
||||||
|
|
||||||
|
if (last4.length === 4 && month > 0 && year >= 2026) {
|
||||||
|
const existingDefault = await getDefaultCard(ctx.tenantId);
|
||||||
|
try {
|
||||||
|
await persistCardFromMockCheckout({
|
||||||
|
brand,
|
||||||
|
last4,
|
||||||
|
expiryMonth: month,
|
||||||
|
expiryYear: year,
|
||||||
|
holderName: holder,
|
||||||
|
makeDefault: !existingDefault,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort — payment already succeeded, don't fail upgrade if card save errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "subscription_payment",
|
||||||
|
entityId: payment.$id,
|
||||||
|
changes: { status: "success", plan: payment.plan, expires: expires.toISOString() },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/settings/billing");
|
||||||
|
redirect(`/settings/billing?upgraded=1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelMockPaymentAction(formData: FormData): Promise<void> {
|
||||||
|
const orderId = String(formData.get("orderId") ?? "");
|
||||||
|
if (!orderId) throw new Error("orderId eksik.");
|
||||||
|
|
||||||
|
const ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner"]);
|
||||||
|
|
||||||
|
const payment = await findPendingPaymentByOrderId(ctx.tenantId, orderId);
|
||||||
|
if (!payment) {
|
||||||
|
redirect(`/settings/billing`);
|
||||||
|
}
|
||||||
|
if (payment && payment.status === "pending") {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.subscriptionPayments, payment.$id, {
|
||||||
|
status: "failed",
|
||||||
|
processedAt: new Date().toISOString(),
|
||||||
|
providerPayload: JSON.stringify({ mock: true, cancelledBy: ctx.user.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "subscription_payment",
|
||||||
|
entityId: payment.$id,
|
||||||
|
changes: { status: "failed" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/settings/billing");
|
||||||
|
redirect(`/settings/billing?cancelled=1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downgradeToFreeAction(): Promise<void> {
|
||||||
|
const ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner"]);
|
||||||
|
|
||||||
|
if (!ctx.settings) throw new Error("Ayar yok.");
|
||||||
|
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
|
||||||
|
plan: "free",
|
||||||
|
planExpiresAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "tenant_settings",
|
||||||
|
entityId: ctx.settings.$id,
|
||||||
|
changes: { plan: "free" },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/settings/billing");
|
||||||
|
redirect(`/settings/billing?downgraded=1`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import { DATABASE_ID, TABLES, type SubscriptionPayment } from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
|
||||||
|
export async function getPaymentByOrderId(
|
||||||
|
tenantId: string,
|
||||||
|
orderId: string,
|
||||||
|
): Promise<SubscriptionPayment | null> {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.subscriptionPayments,
|
||||||
|
queries: [
|
||||||
|
Query.equal("orderId", orderId),
|
||||||
|
Query.equal("tenantId", tenantId),
|
||||||
|
Query.limit(1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return (result.rows[0] as unknown as SubscriptionPayment) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPaymentsForTenant(
|
||||||
|
tenantId: string,
|
||||||
|
limit = 20,
|
||||||
|
): Promise<SubscriptionPayment[]> {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.subscriptionPayments,
|
||||||
|
queries: [
|
||||||
|
Query.equal("tenantId", tenantId),
|
||||||
|
Query.orderDesc("$createdAt"),
|
||||||
|
Query.limit(limit),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return result.rows as unknown as SubscriptionPayment[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { TenantPlan } from "./schema";
|
||||||
|
|
||||||
|
export type PlanCatalogEntry = {
|
||||||
|
id: TenantPlan;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
description: string;
|
||||||
|
features: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PLAN_CATALOG: Record<TenantPlan, PlanCatalogEntry> = {
|
||||||
|
free: {
|
||||||
|
id: "free",
|
||||||
|
name: "Ücretsiz",
|
||||||
|
price: 0,
|
||||||
|
currency: "TRY",
|
||||||
|
description: "Tek kullanıcı, denemek için.",
|
||||||
|
features: [
|
||||||
|
"50 müşteri",
|
||||||
|
"100 finans kaydı",
|
||||||
|
"5 yazılım",
|
||||||
|
"Tek kullanıcı",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pro: {
|
||||||
|
id: "pro",
|
||||||
|
name: "Pro",
|
||||||
|
price: 299,
|
||||||
|
currency: "TRY",
|
||||||
|
description: "Sınırsız büyüyen ekipler için.",
|
||||||
|
features: [
|
||||||
|
"Sınırsız müşteri",
|
||||||
|
"Sınırsız finans kaydı",
|
||||||
|
"Sınırsız yazılım",
|
||||||
|
"Sınırsız ekip üyesi",
|
||||||
|
"Audit log + öncelikli destek",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -5,6 +5,11 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
||||||
|
|
||||||
import { logAudit } from "./audit";
|
import { logAudit } from "./audit";
|
||||||
|
import {
|
||||||
|
isPlanLimitError,
|
||||||
|
planLimitMessage,
|
||||||
|
requirePlanCapacity,
|
||||||
|
} from "./plan-limits";
|
||||||
import { DATABASE_ID, TABLES, type InviteLink, type InviteRole } from "./schema";
|
import { DATABASE_ID, TABLES, type InviteLink, type InviteRole } from "./schema";
|
||||||
import { createAdminClient, createSessionClient } from "./server";
|
import { createAdminClient, createSessionClient } from "./server";
|
||||||
import { requireRole, requireTenant } from "./tenant-guard";
|
import { requireRole, requireTenant } from "./tenant-guard";
|
||||||
@@ -66,6 +71,19 @@ export async function inviteMemberAction(
|
|||||||
return { ok: false, error: "Kendinizi davet edemezsiniz." };
|
return { ok: false, error: "Kendinizi davet edemezsiniz." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requirePlanCapacity(ctx, "members");
|
||||||
|
} catch (e) {
|
||||||
|
if (isPlanLimitError(e)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: planLimitMessage(e.resource, e.limit),
|
||||||
|
code: "PLAN_LIMIT_EXCEEDED",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
const admin = createAdminClient();
|
const admin = createAdminClient();
|
||||||
|
|
||||||
// 1. Kullanıcı zaten Appwrite'ta var mı?
|
// 1. Kullanıcı zaten Appwrite'ta var mı?
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type InviteState = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
shortUrl?: string;
|
shortUrl?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
code?: "PLAN_LIMIT_EXCEEDED";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialInviteState: InviteState = { ok: false };
|
export const initialInviteState: InviteState = { ok: false };
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export async function createWorkspaceAction(
|
|||||||
companyName,
|
companyName,
|
||||||
companyTaxId,
|
companyTaxId,
|
||||||
companyPhone,
|
companyPhone,
|
||||||
|
plan: "free",
|
||||||
|
planStartedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
Permission.read(Role.team(teamId)),
|
Permission.read(Role.team(teamId)),
|
||||||
|
|||||||
Reference in New Issue
Block a user