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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user