Files
isletmem-kovakcrm/src/app/(dashboard)/services/components/service-form-sheet.tsx
T
kovakmedya 196036c0d8 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
2026-04-30 21:36:01 +03:00

205 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useActionState, useEffect } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
createServiceAction,
updateServiceAction,
} from "@/lib/appwrite/service-actions";
import { initialServiceState } from "@/lib/appwrite/service-types";
import type { CustomerOption, ServiceRow } from "./types";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
service?: ServiceRow | null;
customers: CustomerOption[];
defaultCustomerId?: string;
};
export function ServiceFormSheet({
open,
onOpenChange,
service,
customers,
defaultCustomerId,
}: Props) {
const isEdit = Boolean(service);
const action = isEdit ? updateServiceAction : createServiceAction;
const [state, formAction, isPending] = useActionState(action, initialServiceState);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Hizmet güncellendi." : "Hizmet eklendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Hizmeti düzenle" : "Yeni hizmet"}</SheetTitle>
<SheetDescription>
{customers.length === 0
? "Hizmet eklemek için önce en az bir müşteri tanımlamalısınız."
: "Müşteriye sunduğunuz hizmeti tanımlayın."}
</SheetDescription>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
{isEdit && service && <input type="hidden" name="id" value={service.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="customerId">Müşteri *</Label>
<Select
name="customerId"
defaultValue={service?.customerId ?? defaultCustomerId ?? ""}
disabled={customers.length === 0}
>
<SelectTrigger id="customerId">
<SelectValue placeholder="Müşteri seçin" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{state.fieldErrors?.customerId && (
<p className="text-destructive text-xs">{state.fieldErrors.customerId}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="name">Hizmet adı *</Label>
<Input
id="name"
name="name"
defaultValue={service?.name ?? ""}
placeholder="Örn. Web hosting bakımı"
required
/>
{state.fieldErrors?.name && (
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="description">Açıklama</Label>
<Textarea
id="description"
name="description"
rows={3}
defaultValue={service?.description ?? ""}
placeholder="Hizmetin kapsamı, sınırları, vb."
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="unitPrice">Birim fiyat () *</Label>
<Input
id="unitPrice"
name="unitPrice"
type="number"
step="0.01"
min="0"
defaultValue={service?.unitPrice ?? ""}
placeholder="0.00"
required
/>
{state.fieldErrors?.unitPrice && (
<p className="text-destructive text-xs">{state.fieldErrors.unitPrice}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="billingPeriod">Faturalama dönemi</Label>
<Select
name="billingPeriod"
defaultValue={service?.billingPeriod ?? "onetime"}
>
<SelectTrigger id="billingPeriod">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="onetime">Tek seferlik</SelectItem>
<SelectItem value="monthly">Aylık</SelectItem>
<SelectItem value="yearly">Yıllık</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="grid gap-0.5">
<Label htmlFor="recurring" className="cursor-pointer">
Tekrarlayan hizmet
</Label>
<p className="text-muted-foreground text-xs">
Bu hizmet düzenli olarak fatura kesilecek mi?
</p>
</div>
<Switch id="recurring" name="recurring" defaultChecked={service?.recurring} />
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending || customers.length === 0}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}