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:
2026-11-06 11:48:04 +03:00
parent f0b570284e
commit d52f41ab0c
14 changed files with 1228 additions and 8 deletions
@@ -0,0 +1,275 @@
"use client";
import {
Archive,
ArchiveRestore,
EllipsisVertical,
Globe,
MapPin,
Pencil,
Plus,
ShoppingBasket,
Store,
Utensils,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format";
import {
formatDistance,
sortStores,
STORE_SORTS,
type StoreKind,
type StoreSort,
} from "@/shared/stores";
import { setStoreArchivedAction } from "../actions";
import type { StoreView } from "../service";
import { StoreFormDialog } from "./store-dialog";
const t = tr.stores;
const KIND_ICON: Record<StoreKind, typeof Store> = {
market: Store,
online: Globe,
bazaar: ShoppingBasket,
};
/** Zahmet çubuğu: 0–100, düzey etiketiyle (renk tek başına anlam taşımaz). */
function EffortMeter({ store }: { store: StoreView }) {
return (
<div className="flex items-center gap-2" title={t.effortHint}>
<span className="w-14 shrink-0 text-xs text-muted-foreground">{t.effort}</span>
<div
className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-muted"
role="meter"
aria-label={t.effortValue(store.effort)}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={store.effort}
>
<div
className={cn(
"h-full rounded-full",
store.effortLevel === "high"
? "bg-expense"
: store.effortLevel === "medium"
? "bg-amber-500"
: "bg-income",
)}
style={{ width: `${Math.max(store.effort, 2)}%` }}
/>
</div>
<span className="w-20 shrink-0 text-right text-xs tabular-nums">
{store.effort} · {t.effortLevels[store.effortLevel]}
</span>
</div>
);
}
/** /magazalar: sıralama (ad, en yakın, en uzak, en zahmetli), kartlar, ekle/düzenle/arşivle. */
export function StoresView({ stores, canWrite }: { stores: StoreView[]; canWrite: boolean }) {
const [sort, setSort] = useState<StoreSort>("name");
const [showArchived, setShowArchived] = useState(false);
const [editing, setEditing] = useState<StoreView | "new" | null>(null);
const [archiving, setArchiving] = useState<StoreView | null>(null);
const visible = useMemo(
() =>
sortStores(
stores.filter((s) => showArchived || !s.archived),
sort,
),
[stores, sort, showArchived],
);
const hasArchived = stores.some((s) => s.archived);
async function setArchived(store: StoreView, archived: boolean) {
const r = await setStoreArchivedAction(store.id, archived);
if (r.ok) toast.success(archived ? t.toast.archived : t.toast.unarchived);
else toast.error(r.error);
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<Select value={sort} onValueChange={(x) => setSort(x as StoreSort)}>
<SelectTrigger className="w-44" aria-label={t.sort}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STORE_SORTS.map((s) => (
<SelectItem key={s} value={s}>
{t.sorts[s]}
</SelectItem>
))}
</SelectContent>
</Select>
{hasArchived ? (
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={showArchived} onCheckedChange={(x) => setShowArchived(x === true)} />
{t.showArchived}
</label>
) : null}
{canWrite ? (
<Button size="sm" className="ml-auto" onClick={() => setEditing("new")}>
<Plus />
{t.new}
</Button>
) : null}
</div>
{visible.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.empty}
</p>
) : (
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{visible.map((s) => {
const Icon = KIND_ICON[s.kind];
return (
<li
key={s.id}
className={cn(
"flex flex-col gap-3 rounded-xl border bg-card p-4",
s.archived && "opacity-60",
)}
>
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<Icon className="size-4.5 text-muted-foreground" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{s.name}</p>
<p className="truncate text-xs text-muted-foreground">
{[t.kindShort[s.kind], s.branch].filter(Boolean).join(" · ")}
</p>
</div>
{canWrite ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="icon-sm" variant="ghost" aria-label={t.actions.more}>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={s.archived} onSelect={() => setEditing(s)}>
<Pencil />
{t.actions.edit}
</DropdownMenuItem>
<DropdownMenuSeparator />
{s.archived ? (
<DropdownMenuItem onSelect={() => setArchived(s, false)}>
<ArchiveRestore />
{t.actions.unarchive}
</DropdownMenuItem>
) : (
<DropdownMenuItem variant="destructive" onSelect={() => setArchiving(s)}>
<Archive />
{t.actions.archive}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{s.kind === "online" ? (
<span className="text-muted-foreground">
{s.deliveryFeeMinor
? t.delivery(formatMoney(s.deliveryFeeMinor))
: t.freeDelivery}
{s.deliveryFeeMinor && s.freeDeliveryMinMinor
? ` · ${t.freeDeliveryFrom(formatMoney(s.freeDeliveryMinMinor))}`
: ""}
</span>
) : (
<span className="flex items-center gap-1 tabular-nums">
<MapPin className="size-3.5 text-muted-foreground" aria-hidden />
{s.distanceM !== null ? formatDistance(s.distanceM) : t.distanceUnknown}
</span>
)}
<span className="text-muted-foreground">
{t.fields.carry}: {s.carryDifficulty} · {t.carryLevels[s.carryDifficulty]}
</span>
</div>
<EffortMeter store={s} />
<div className="flex flex-wrap gap-1.5">
{s.archived ? <Badge variant="secondary">{t.archived}</Badge> : null}
{s.mealCardAccepted ? (
<Badge
variant="outline"
className="h-auto max-w-full gap-1 text-left whitespace-normal"
>
<Utensils className="size-3" aria-hidden />
{t.mealCardBadge}:{" "}
{s.mealCardProviders.map((p) => tr.accounts.providers[p]).join(", ")}
</Badge>
) : null}
</div>
{s.note ? <p className="text-xs text-muted-foreground">{s.note}</p> : null}
</li>
);
})}
</ul>
)}
{editing ? (
<StoreFormDialog
store={editing === "new" ? null : editing}
onClose={() => setEditing(null)}
/>
) : null}
<AlertDialog open={archiving !== null} onOpenChange={(o) => !o && setArchiving(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
<AlertDialogDescription>
{archiving ? t.archiveDescription(archiving.label) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button
variant="destructive"
onClick={async () => {
const s = archiving;
setArchiving(null);
if (s) await setArchived(s, true);
}}
>
{t.actions.archive}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}