feat(ui): alışveriş listeleri, akıllı öneri paneli ve alışveriş modu
- /alisveris, /alisveris/:id ve /alisveris/:id/mod sayfaları; odak ve 30 sn yenileme - stok önerileri paneli, kalem ekle/düzenle/taşı, mağazaya gruplu kontrol listesi - dashboard Alışveriş kartı, kenar menü, ürün tipi formunda hacimli işareti Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Check, Globe } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { startTransition, useOptimistic } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StoreDot } from "@/features/prices/components/offers-list";
|
||||
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import { formatQuantity } from "@/shared/quantity";
|
||||
import { SHOPPING_REFRESH_MS } from "@/shared/shopping";
|
||||
import type { PlanLine } from "@/shared/shopping-plan";
|
||||
import { setShoppingItemCheckedAction } from "../actions";
|
||||
import type { ShoppingItemView, ShoppingListDetail } from "../service";
|
||||
import { planHeadline, type PlanChoice } from "./plan-panel";
|
||||
|
||||
const t = tr.shopping;
|
||||
const m = t.mode;
|
||||
|
||||
type Group = {
|
||||
key: string;
|
||||
title: string;
|
||||
slot: number | null;
|
||||
online: boolean;
|
||||
hint?: string;
|
||||
rows: { item: ShoppingItemView; line: PlanLine | null }[];
|
||||
};
|
||||
|
||||
function Row({
|
||||
item,
|
||||
line,
|
||||
product,
|
||||
canWrite,
|
||||
onToggle,
|
||||
}: {
|
||||
item: ShoppingItemView;
|
||||
line: PlanLine | null;
|
||||
product: { label: string; packageLabel: string } | undefined;
|
||||
canWrite: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const sub = [
|
||||
line
|
||||
? line.packages > 1
|
||||
? t.plan.line(formatQuantity(line.packages * 1000), formatMoney(line.priceMinor))
|
||||
: formatMoney(line.priceMinor)
|
||||
: null,
|
||||
line && product && item.kind === "type" ? `${product.label} · ${product.packageLabel}` : null,
|
||||
item.note || null,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={item.checked}
|
||||
aria-label={m.check(item.label)}
|
||||
disabled={!canWrite}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"flex min-h-16 w-full items-center gap-4 px-4 py-3 text-left transition-colors hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none disabled:cursor-default",
|
||||
item.checked && "bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-md border-2",
|
||||
item.checked ? "border-income bg-income text-white" : "border-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{item.checked ? <Check className="size-4.5" strokeWidth={3} /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block text-base font-medium",
|
||||
item.checked && "text-muted-foreground line-through",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{sub.length ? (
|
||||
<span className="line-clamp-2 text-sm text-muted-foreground">{sub.join(" · ")}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0 text-base tabular-nums">{item.quantityLabel}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** /alisveris/:id/mod — mağazaya göre gruplanmış, büyük dokunma alanlı kontrol listesi. */
|
||||
export function ShoppingModeView({
|
||||
data,
|
||||
choice,
|
||||
}: {
|
||||
data: ShoppingListDetail;
|
||||
choice: PlanChoice;
|
||||
}) {
|
||||
useAutoRefresh(SHOPPING_REFRESH_MS);
|
||||
const { list, plan, canWrite } = data;
|
||||
const [items, setOptimistic] = useOptimistic(
|
||||
data.items,
|
||||
(state, change: { id: string; checked: boolean }) =>
|
||||
state.map((i) => (i.id === change.id ? { ...i, checked: change.checked } : i)),
|
||||
);
|
||||
const option = (choice === "single" ? plan.single : null) ?? plan.recommended;
|
||||
const byId = new Map(items.map((i) => [i.id, i]));
|
||||
|
||||
const groups: Group[] = [];
|
||||
const placed = new Set<string>();
|
||||
for (const g of option?.stores ?? []) {
|
||||
const store = plan.stores[g.storeId];
|
||||
const rows = g.lines.flatMap((line) => {
|
||||
const item = byId.get(line.itemId);
|
||||
if (!item) return [];
|
||||
placed.add(item.id);
|
||||
return [{ item, line }];
|
||||
});
|
||||
groups.push({
|
||||
key: g.storeId,
|
||||
title: store?.label ?? "—",
|
||||
slot: store?.slot ?? null,
|
||||
online: store?.kind === "online",
|
||||
rows,
|
||||
});
|
||||
}
|
||||
const rest = items.filter((i) => !placed.has(i.id));
|
||||
if (rest.length)
|
||||
groups.push({
|
||||
key: "other",
|
||||
title: m.other,
|
||||
slot: null,
|
||||
online: false,
|
||||
hint: m.otherHint,
|
||||
rows: rest.map((item) => ({ item, line: null })),
|
||||
});
|
||||
|
||||
const done = items.filter((i) => i.checked).length;
|
||||
const pct = items.length ? Math.round((done / items.length) * 100) : 0;
|
||||
|
||||
function toggle(item: ShoppingItemView) {
|
||||
const checked = !item.checked;
|
||||
startTransition(async () => {
|
||||
setOptimistic({ id: item.id, checked });
|
||||
const r = await setShoppingItemCheckedAction(list.id, item.id, checked);
|
||||
if (!r.ok) toast.error(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
const headline = planHeadline(plan);
|
||||
const differs =
|
||||
plan.single &&
|
||||
plan.single.stores.map((s) => s.storeId).join() !==
|
||||
plan.recommended?.stores.map((s) => s.storeId).join();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href={`/alisveris/${list.id}`}>
|
||||
<ArrowLeft />
|
||||
{t.actions.backToList}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{m.title}: {list.name}
|
||||
</h1>
|
||||
{headline ? <p className="text-sm text-muted-foreground">{headline}</p> : null}
|
||||
{differs ? (
|
||||
<nav aria-label={m.planChoice} className="flex gap-2">
|
||||
{(["recommended", "single"] as const).map((c) => (
|
||||
<Button
|
||||
key={c}
|
||||
size="sm"
|
||||
variant={c === choice ? "secondary" : "ghost"}
|
||||
asChild
|
||||
aria-current={c === choice ? "page" : undefined}
|
||||
>
|
||||
<Link href={c === "single" ? "?plan=single" : "?"} replace scroll={false}>
|
||||
{c === "single" ? t.plan.single : t.plan.recommended}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="sticky top-14 z-10 -mx-4 bg-background/95 px-4 py-2 backdrop-blur">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium tabular-nums">{m.progress(done, items.length)}</span>
|
||||
{done === items.length && items.length ? (
|
||||
<span className="text-income">{m.allDone}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="mt-1.5 h-2 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={items.length}
|
||||
aria-valuenow={done}
|
||||
aria-label={m.progress(done, items.length)}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-income transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groups.map((g) => {
|
||||
const groupDone = g.rows.filter((r) => r.item.checked).length;
|
||||
return (
|
||||
<section key={g.key} className="overflow-hidden rounded-xl border bg-card">
|
||||
<header className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2.5">
|
||||
<h2 className="flex min-w-0 items-center font-medium">
|
||||
{g.slot ? <StoreDot slot={g.slot} /> : null}
|
||||
<span className="truncate">{g.title}</span>
|
||||
{g.online ? (
|
||||
<Globe className="ml-1.5 size-3.5 text-muted-foreground" aria-hidden />
|
||||
) : null}
|
||||
</h2>
|
||||
<span className="ml-auto text-sm text-muted-foreground tabular-nums">
|
||||
{groupDone}/{g.rows.length}
|
||||
</span>
|
||||
</header>
|
||||
{g.hint ? <p className="px-4 pt-2 text-xs text-muted-foreground">{g.hint}</p> : null}
|
||||
<ul className="divide-y">
|
||||
{g.rows.map(({ item, line }) => (
|
||||
<Row
|
||||
key={item.id}
|
||||
item={item}
|
||||
line={line}
|
||||
product={line ? plan.products[line.productId] : undefined}
|
||||
canWrite={canWrite}
|
||||
onToggle={() => toggle(item)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user