feat(ui): mağazalar sayfası, ürün kataloğu ve ürün tipi detayında ürünler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { MEAL_CARD_PROVIDERS, type MealCardProvider } from "@/shared/accounts";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { quantityInput } from "@/shared/stock";
|
||||
import { effortScore, STORE_KINDS, type StoreKind } from "@/shared/stores";
|
||||
import { saveStoreAction } from "../actions";
|
||||
import type { StoreView } from "../service";
|
||||
|
||||
const t = tr.stores;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
|
||||
/** Yeni mağaza / düzenle. Online mağazada uzaklık yerine teslimat ücretleri. */
|
||||
export function StoreFormDialog({
|
||||
store,
|
||||
onClose,
|
||||
}: {
|
||||
store: StoreView | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({
|
||||
name: store?.name ?? "",
|
||||
kind: (store?.kind ?? "market") as StoreKind,
|
||||
branch: store?.branch ?? "",
|
||||
distanceKm: store?.distanceM != null ? quantityInput(store.distanceM) : "",
|
||||
carryDifficulty: String(store?.carryDifficulty ?? 2),
|
||||
deliveryFee: store?.deliveryFeeMinor != null ? formatMoneyInput(store.deliveryFeeMinor) : "",
|
||||
freeDeliveryMin:
|
||||
store?.freeDeliveryMinMinor != null ? formatMoneyInput(store.freeDeliveryMinMinor) : "",
|
||||
mealCardAccepted: store?.mealCardAccepted ?? false,
|
||||
mealCardProviders: (store?.mealCardProviders ?? []) as MealCardProvider[],
|
||||
note: store?.note ?? "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
const online = v.kind === "online";
|
||||
const f = t.fields;
|
||||
|
||||
// Önizleme: girilen uzaklık ve taşıma puanıyla zahmet skoru.
|
||||
const meters = Number(v.distanceKm.replace(",", ".")) * 1000;
|
||||
const preview = effortScore({
|
||||
kind: v.kind,
|
||||
distanceM: Number.isFinite(meters) && v.distanceKm.trim() ? meters : null,
|
||||
carryDifficulty: Number(v.carryDifficulty),
|
||||
});
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveStoreAction(store?.id ?? null, {
|
||||
...v,
|
||||
carryDifficulty: v.carryDifficulty,
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
const toggleProvider = (p: MealCardProvider, on: boolean) =>
|
||||
set(
|
||||
"mealCardProviders",
|
||||
on ? [...v.mealCardProviders, p] : v.mealCardProviders.filter((x) => x !== p),
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={store ? t.editTitle : t.createTitle}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="store-form" disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="store-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.name)} className="col-span-2 sm:col-span-1">
|
||||
<FieldLabel htmlFor="st-name">{f.name}</FieldLabel>
|
||||
<Input
|
||||
id="st-name"
|
||||
value={v.name}
|
||||
placeholder={f.namePlaceholder}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
autoFocus={!store}
|
||||
/>
|
||||
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||
</Field>
|
||||
<Field className="col-span-2 sm:col-span-1">
|
||||
<FieldLabel htmlFor="st-branch">{f.branch}</FieldLabel>
|
||||
<Input
|
||||
id="st-branch"
|
||||
value={v.branch}
|
||||
placeholder={f.branchPlaceholder}
|
||||
onChange={(e) => set("branch", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="st-kind">{f.kind}</FieldLabel>
|
||||
<Select value={v.kind} onValueChange={(x) => set("kind", x as StoreKind)}>
|
||||
<SelectTrigger id="st-kind" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STORE_KINDS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{t.kinds[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{online ? (
|
||||
<>
|
||||
<Field data-invalid={Boolean(errors.deliveryFee)}>
|
||||
<FieldLabel htmlFor="st-fee">{f.deliveryFee} (₺)</FieldLabel>
|
||||
<Input
|
||||
id="st-fee"
|
||||
inputMode="decimal"
|
||||
value={v.deliveryFee}
|
||||
onChange={(e) => set("deliveryFee", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.deliveryFee)}
|
||||
/>
|
||||
{errors.deliveryFee ? <FieldError>{errors.deliveryFee}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.freeDeliveryMin)}>
|
||||
<FieldLabel htmlFor="st-free">{f.freeDeliveryMin} (₺)</FieldLabel>
|
||||
<Input
|
||||
id="st-free"
|
||||
inputMode="decimal"
|
||||
value={v.freeDeliveryMin}
|
||||
onChange={(e) => set("freeDeliveryMin", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.freeDeliveryMin)}
|
||||
/>
|
||||
{errors.freeDeliveryMin ? (
|
||||
<FieldError>{errors.freeDeliveryMin}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field data-invalid={Boolean(errors.distanceKm)}>
|
||||
<FieldLabel htmlFor="st-distance">{f.distance}</FieldLabel>
|
||||
<Input
|
||||
id="st-distance"
|
||||
inputMode="decimal"
|
||||
value={v.distanceKm}
|
||||
onChange={(e) => set("distanceKm", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.distanceKm)}
|
||||
/>
|
||||
{errors.distanceKm ? <FieldError>{errors.distanceKm}</FieldError> : null}
|
||||
</Field>
|
||||
)}
|
||||
<Field className={online ? "col-span-2" : undefined}>
|
||||
<FieldLabel htmlFor="st-carry">{f.carry}</FieldLabel>
|
||||
<Select value={v.carryDifficulty} onValueChange={(x) => set("carryDifficulty", x)}>
|
||||
<SelectTrigger id="st-carry" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n} · {t.carryLevels[n]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">
|
||||
{online ? null : `${f.distanceHint} `}
|
||||
{f.carryHint} <span className="font-medium">{t.effortValue(preview)}</span>
|
||||
</FieldDescription>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="st-meal"
|
||||
checked={v.mealCardAccepted}
|
||||
onCheckedChange={(x) => set("mealCardAccepted", x === true)}
|
||||
/>
|
||||
<FieldLabel htmlFor="st-meal">{f.mealCard}</FieldLabel>
|
||||
</Field>
|
||||
{v.mealCardAccepted ? (
|
||||
<Field data-invalid={Boolean(errors.mealCardProviders)}>
|
||||
<FieldLabel>{f.mealCardProviders}</FieldLabel>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{MEAL_CARD_PROVIDERS.map((p) => (
|
||||
<label key={p} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={v.mealCardProviders.includes(p)}
|
||||
onCheckedChange={(x) => toggleProvider(p, x === true)}
|
||||
/>
|
||||
{tr.accounts.providers[p]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{errors.mealCardProviders ? (
|
||||
<FieldError>{errors.mealCardProviders}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="st-note">{f.note}</FieldLabel>
|
||||
<Input id="st-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user