diff --git a/CHANGELOG.md b/CHANGELOG.md
index e659bfd..0c5055c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,15 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
## [Yayınlanmamış]
+### Eklendi — Faz 12a: mağazalar ve ürünler (marka/paket)
+
+- Mağazalar (`stores`, `/magazalar`): ad, tür (fiziksel market / online platform / pazar), şube, eve uzaklık (km), taşıma güçlüğü (1–5), online ise teslimat ücreti ve ücretsiz teslimat alt limiti, yemek kartı geçerliliği ve geçerli sağlayıcılar, not. Zahmet skoru (0–100; uzaklık 20 km'ye kadar ve taşıma güçlüğü eşit ağırlıkla, online mağazada uzaklık sayılmaz) ve düzeyi; "ada göre / en yakın / en uzak / en zahmetli" sıralama. Aynı ad + şube tekil; arşivleme denetim kaydına yazılır. Kenar menüde "Mağazalar".
+- Ürünler (`products`): ürün tipine bağlı marka, tanım, isteğe bağlı barkod (8–14 rakam, hanede tekil), paket miktarı ve birimi. Paket birimi ürün tipinin birimine kendiliğinden çevrilebiliyorsa (aynı birim, kg↔g, lt↔ml) dönüşüm otomatik; çevrilemiyorsa dönüşüm zorunlu ("5 kg = 33 yıkama", "1 paket = 50 tablet", "75 ml = 1 adet"). Ürün tipinin birimi sonradan değişirse "Dönüşüm gerekli" uyarısı.
+- Ürün görselleri: birden çok görsel, mevcut ek dosya altyapısıyla (WebP'ye dönüştürme, küçük resim, yetkili okuma; hane üyeleri görür). Liste küçük resmi ve görüntüleyici.
+- Ürün tipi detayında "Ürünler (marka / paket)" bölümü; `/stok/urunler` ürün kataloğu (ürün tipine göre gruplu, marka/tanım/barkodla arama, arşiv).
+- Migration `013-stores-products` (index'ler). Seed: Çakır Ailesi için 8 mağaza ve 22 ürün.
+- Testler: zahmet skoru, sıralama, uzaklık gösterimi, paket dönüşümü ve açıklaması (birim); mağaza şeması ve kuralları, ürün dönüşüm zorunluluğu, barkod tekilliği, görsel bağlama/sıralama/çıkarma ve okuma yetkisi, arşiv (entegrasyon).
+
## [0.11.0] - 2026-11-06
Faz 11: ev stoğu (11a ve 11b alt fazları).
diff --git a/src/app/(app)/magazalar/page.tsx b/src/app/(app)/magazalar/page.tsx
new file mode 100644
index 0000000..345be28
--- /dev/null
+++ b/src/app/(app)/magazalar/page.tsx
@@ -0,0 +1,25 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { PageHeader } from "@/components/page-header";
+import { StoresView } from "@/features/stores/components/stores-view";
+import { listStores } from "@/features/stores/service";
+import { tr } from "@/i18n/tr";
+import { requireActiveHousehold } from "@/lib/dal";
+
+export const metadata: Metadata = { title: tr.stores.title };
+
+/** /magazalar — marketler, online platformlar ve pazarlar; uzaklık, taşıma ve zahmet skoru. */
+export default async function StoresPage() {
+ const { user, household } = await requireActiveHousehold("viewer");
+ const data = await listStores(user.id, household.id);
+ if (!data) notFound();
+ return (
+ <>
+
+ {!data.canWrite ? (
+
{tr.stores.readOnly}
+ ) : null}
+
+ >
+ );
+}
diff --git a/src/app/(app)/stok/[id]/page.tsx b/src/app/(app)/stok/[id]/page.tsx
index dfcc8c5..2b6e2ae 100644
--- a/src/app/(app)/stok/[id]/page.tsx
+++ b/src/app/(app)/stok/[id]/page.tsx
@@ -1,5 +1,7 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
+import { ProductsSection } from "@/features/products/components/products-section";
+import { listProducts, listProductTypeOptions } from "@/features/products/service";
import { ProductDetailView } from "@/features/stock/components/product-detail-view";
import { getProductType, getStockOptions } from "@/features/stock/service";
import { tr } from "@/i18n/tr";
@@ -11,10 +13,25 @@ export const metadata: Metadata = { title: tr.stock.detail.movements };
export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) {
const { user, household } = await requireActiveHousehold("viewer");
const { id } = await params;
- const [data, options] = await Promise.all([
+ const [data, options, products, types] = await Promise.all([
getProductType(user.id, household.id, id),
getStockOptions(user.id, household.id),
+ listProducts(user.id, household.id, { productTypeId: id, includeArchived: true }),
+ listProductTypeOptions(user.id, household.id),
]);
- if (!data || !options) notFound();
- return ;
+ if (!data || !options || !products || !types) notFound();
+ return (
+
+ }
+ />
+ );
}
diff --git a/src/app/(app)/stok/urunler/page.tsx b/src/app/(app)/stok/urunler/page.tsx
new file mode 100644
index 0000000..799d382
--- /dev/null
+++ b/src/app/(app)/stok/urunler/page.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { ProductsCatalog } from "@/features/products/components/products-catalog";
+import { listProducts, listProductTypeOptions } from "@/features/products/service";
+import { tr } from "@/i18n/tr";
+import { requireActiveHousehold } from "@/lib/dal";
+
+export const metadata: Metadata = { title: tr.products.title };
+
+/** /stok/urunler — ürün kataloğu (marka/paket), ürün tipine göre gruplu. */
+export default async function ProductsPage() {
+ const { user, household } = await requireActiveHousehold("viewer");
+ const [data, types] = await Promise.all([
+ listProducts(user.id, household.id, { includeArchived: true }),
+ listProductTypeOptions(user.id, household.id),
+ ]);
+ if (!data || !types) notFound();
+ return ;
+}
diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts
index e6ee9d1..32810ed 100644
--- a/src/components/layout/nav-config.ts
+++ b/src/components/layout/nav-config.ts
@@ -8,6 +8,7 @@ import {
Package,
Settings,
ShoppingCart,
+ Store,
Tags,
Target,
UserCog,
@@ -27,7 +28,7 @@ export type NavItem = {
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
-export const CURRENT_PHASE = 11.2;
+export const CURRENT_PHASE = 12.1;
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
@@ -41,6 +42,7 @@ export const MAIN_NAV: NavItem[] = [
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 },
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 },
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
+ { href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 },
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
diff --git a/src/features/attachments/components/attachment-picker.tsx b/src/features/attachments/components/attachment-picker.tsx
index c3b0f7d..2687fad 100644
--- a/src/features/attachments/components/attachment-picker.tsx
+++ b/src/features/attachments/components/attachment-picker.tsx
@@ -38,11 +38,17 @@ export function AttachmentPicker({
onChange,
allowedKinds,
onBusyChange,
+ hint,
+ addLabel,
}: {
value: AttachmentInfo[];
onChange: (items: AttachmentInfo[]) => void;
allowedKinds: readonly AttachmentKind[];
onBusyChange?: (busy: boolean) => void;
+ /** Boşken gösterilen ipucu (varsayılan: fiş/fatura). */
+ hint?: string;
+ /** Düğme metni (varsayılan: "Ek ekle"). */
+ addLabel?: string;
}) {
const inputRef = useRef(null);
const [uploading, setUploading] = useState(0);
@@ -91,14 +97,16 @@ export function AttachmentPicker({
disabled={room <= 0 || uploading > 0}
>
{uploading > 0 ? : }
- {uploading > 0 ? t.uploading : t.add}
+ {uploading > 0 ? t.uploading : (addLabel ?? t.add)}
{value.length
? t.count(value.length)
- : allowedKinds.includes("pdf")
- ? t.addHint.imagePdf
- : t.addHint.image}
+ : hint
+ ? hint
+ : allowedKinds.includes("pdf")
+ ? t.addHint.imagePdf
+ : t.addHint.image}
;
+
+/** Yeni ürün / düzenle: marka, tanım, barkod, paket, gerekirse dönüşüm, görseller. */
+export function ProductFormDialog({
+ product,
+ types,
+ defaultTypeId,
+ onClose,
+}: {
+ product: ProductView | null;
+ types: ProductTypeOption[];
+ defaultTypeId?: string;
+ onClose: () => void;
+}) {
+ const initialType = product?.productTypeId ?? defaultTypeId ?? "";
+ const typeUnitOf = (id: string) => types.find((x) => x.id === id)?.unit ?? "";
+ const unitChoices = (typeUnit: string) => [
+ ...new Set([typeUnit, ...STOCK_UNITS].filter(Boolean)),
+ ];
+ const initialUnit = product?.packageUnit ?? typeUnitOf(initialType) ?? "adet";
+ const known = unitChoices(typeUnitOf(initialType)).includes(initialUnit);
+
+ const [v, setV] = useState({
+ productTypeId: initialType,
+ brand: product?.brand ?? "",
+ name: product?.name ?? "",
+ barcode: product?.barcode ?? "",
+ packageQuantity: product ? quantityInput(product.packageQuantity) : "1",
+ unitChoice: known ? initialUnit || "adet" : CUSTOM,
+ customUnit: known ? "" : initialUnit,
+ typeQuantity: product?.typeQuantity ? quantityInput(product.typeQuantity) : "",
+ note: product?.note ?? "",
+ });
+ const [images, setImages] = useState(product?.images ?? []);
+ const [uploading, setUploading] = useState(false);
+ const [errors, setErrors] = useState({});
+ const [busy, setBusy] = useState(false);
+ const set = (k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
+
+ const typeUnit = typeUnitOf(v.productTypeId);
+ const packageUnit = v.unitChoice === CUSTOM ? v.customUnit.trim() : v.unitChoice;
+ const needsConversion =
+ Boolean(typeUnit && packageUnit) && !autoConvertible(packageUnit, typeUnit);
+ const pkgQty = parseQuantity(v.packageQuantity);
+ const pkgText = pkgQty ? formatQuantity(pkgQty, packageUnit) : `1 ${packageUnit}`;
+ const autoValue =
+ pkgQty && typeUnit && !needsConversion
+ ? packageInTypeUnit({ packageQuantity: pkgQty, packageUnit, typeUnit, typeQuantity: null })
+ : null;
+ const f = t.fields;
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setBusy(true);
+ const r = await saveProductAction(product?.id ?? null, {
+ productTypeId: v.productTypeId,
+ brand: v.brand,
+ name: v.name,
+ barcode: v.barcode,
+ packageQuantity: v.packageQuantity,
+ packageUnit,
+ typeQuantity: needsConversion ? v.typeQuantity : "",
+ note: v.note,
+ imageIds: images.map((i) => i.id),
+ });
+ setBusy(false);
+ if (r.ok) {
+ toast.success(t.toast.saved);
+ onClose();
+ } else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
+ }
+
+ return (
+ !o && onClose()}
+ title={product ? t.editTitle : t.createTitle}
+ footer={
+ <>
+
+ {tr.common.cancel}
+
+
+ {tr.common.save}
+
+ >
+ }
+ >
+
+
+ );
+}
diff --git a/src/features/products/components/product-list.tsx b/src/features/products/components/product-list.tsx
new file mode 100644
index 0000000..87091f4
--- /dev/null
+++ b/src/features/products/components/product-list.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+import {
+ Archive,
+ ArchiveRestore,
+ AlertTriangle,
+ EllipsisVertical,
+ ImageIcon,
+ Pencil,
+} from "lucide-react";
+import Link from "next/link";
+import { 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 {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { AttachmentThumb } from "@/features/attachments/components/attachment-strip";
+import { AttachmentViewer } from "@/features/attachments/components/attachment-viewer";
+import { tr } from "@/i18n/tr";
+import { cn } from "@/lib/utils";
+import { setProductArchivedAction } from "../actions";
+import type { ProductTypeOption, ProductView } from "../service";
+import { ProductFormDialog } from "./product-dialog";
+
+const t = tr.products;
+
+/** Ürün satırları: görsel, marka/tanım, paket, barkod; düzenle/arşivle. */
+export function ProductList({
+ products,
+ types,
+ canWrite,
+ showType = false,
+}: {
+ products: ProductView[];
+ types: ProductTypeOption[];
+ canWrite: boolean;
+ /** Katalogda ürün tipi bağlantısı. */
+ showType?: boolean;
+}) {
+ const [editing, setEditing] = useState(null);
+ const [archiving, setArchiving] = useState(null);
+ const [viewer, setViewer] = useState(null);
+
+ async function setArchived(p: ProductView, archived: boolean) {
+ const r = await setProductArchivedAction(p.id, archived);
+ if (r.ok) toast.success(archived ? t.toast.archived : t.toast.unarchived);
+ else toast.error(r.error);
+ }
+
+ return (
+ <>
+
+ {products.map((p) => {
+ const cover = p.images[0];
+ return (
+
+ {cover ? (
+ setViewer(p)} className="size-14" />
+ ) : (
+
+
+
+ )}
+
+
{p.label}
+
+ {p.packageLabel}
+ {showType ? (
+ <>
+ {" · "}
+
+ {p.productTypeName}
+
+ >
+ ) : null}
+
+
+ {p.barcode ?
{t.barcode(p.barcode)} : null}
+ {p.images.length > 1 ?
{t.images(p.images.length)} : null}
+ {p.archived ?
{t.archived} : null}
+ {p.conversionMissing ? (
+
+
+ {t.conversionMissing}
+
+ ) : null}
+
+
+ {canWrite ? (
+
+
+
+
+
+
+
+ setEditing(p)}>
+
+ {t.actions.edit}
+
+
+ {p.archived ? (
+ setArchived(p, false)}>
+
+ {t.actions.unarchive}
+
+ ) : (
+ setArchiving(p)}>
+
+ {t.actions.archive}
+
+ )}
+
+
+ ) : null}
+
+ );
+ })}
+
+
+ {editing ? (
+ setEditing(null)} />
+ ) : null}
+ !o && setViewer(null)}
+ />
+ !o && setArchiving(null)}>
+
+
+ {t.archiveTitle}
+
+ {archiving ? t.archiveDescription(archiving.label) : null}
+
+
+
+ {tr.common.cancel}
+ {
+ const p = archiving;
+ setArchiving(null);
+ if (p) await setArchived(p, true);
+ }}
+ >
+ {t.actions.archive}
+
+
+
+
+ >
+ );
+}
diff --git a/src/features/products/components/products-catalog.tsx b/src/features/products/components/products-catalog.tsx
new file mode 100644
index 0000000..5c4a3a2
--- /dev/null
+++ b/src/features/products/components/products-catalog.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { ArrowLeft, Plus, Search } from "lucide-react";
+import Link from "next/link";
+import { useMemo, useState } from "react";
+import { PageHeader } from "@/components/page-header";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { tr } from "@/i18n/tr";
+import { foldTr } from "@/shared/search";
+import type { ProductTypeOption, ProductView } from "../service";
+import { ProductFormDialog } from "./product-dialog";
+import { ProductList } from "./product-list";
+
+const t = tr.products;
+
+/** /stok/urunler: tüm ürünler ürün tipine göre gruplu; marka, tanım veya barkodla arama. */
+export function ProductsCatalog({
+ products,
+ types,
+ canWrite,
+}: {
+ products: ProductView[];
+ types: ProductTypeOption[];
+ canWrite: boolean;
+}) {
+ const [query, setQuery] = useState("");
+ const [showArchived, setShowArchived] = useState(false);
+ const [creating, setCreating] = useState(false);
+
+ const groups = useMemo(() => {
+ const q = foldTr(query.trim());
+ const digits = query.replace(/\s/g, "");
+ const match = (p: ProductView) =>
+ (showArchived || !p.archived) &&
+ (!q ||
+ foldTr(`${p.label} ${p.productTypeName}`).includes(q) ||
+ (/^\d+$/.test(digits) && (p.barcode ?? "").includes(digits)));
+ const by = new Map();
+ for (const p of products.filter(match)) {
+ const g = by.get(p.productTypeId) ?? { name: p.productTypeName, items: [] };
+ g.items.push(p);
+ by.set(p.productTypeId, g);
+ }
+ return [...by.entries()];
+ }, [products, query, showArchived]);
+
+ return (
+
+
+
+
+ {t.back}
+
+
+
setCreating(true)}>
+
+ {t.new}
+
+ ) : null
+ }
+ />
+
+
+
+ setQuery(e.target.value)}
+ placeholder={t.search}
+ aria-label={t.search}
+ className="pl-8"
+ />
+
+ {products.some((p) => p.archived) ? (
+
+ setShowArchived(x === true)} />
+ {t.showArchived}
+
+ ) : null}
+
+ {products.length === 0 ? (
+
+ {t.empty}
+
+ ) : groups.length === 0 ? (
+ {t.noMatch}
+ ) : (
+ groups.map(([typeId, g]) => (
+
+ ))
+ )}
+ {creating ? (
+ setCreating(false)} />
+ ) : null}
+
+ );
+}
diff --git a/src/features/products/components/products-section.tsx b/src/features/products/components/products-section.tsx
new file mode 100644
index 0000000..59e5eb1
--- /dev/null
+++ b/src/features/products/components/products-section.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { Plus } from "lucide-react";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { tr } from "@/i18n/tr";
+import type { ProductTypeOption, ProductView } from "../service";
+import { ProductFormDialog } from "./product-dialog";
+import { ProductList } from "./product-list";
+
+const t = tr.products;
+
+/** /stok/:id içinde: bu ürün tipinin markaları ve paketleri. */
+export function ProductsSection({
+ productTypeId,
+ products,
+ types,
+ canWrite,
+}: {
+ productTypeId: string;
+ products: ProductView[];
+ types: ProductTypeOption[];
+ canWrite: boolean;
+}) {
+ const [creating, setCreating] = useState(false);
+ return (
+
+
+
+
+ {t.sectionTitle}
+
+
{t.sectionDescription}
+
+ {canWrite && types.some((x) => x.id === productTypeId) ? (
+
setCreating(true)}>
+
+ {t.new}
+
+ ) : null}
+
+ {products.length === 0 ? (
+
+ {t.emptyType}
+
+ ) : (
+
+ )}
+ {creating ? (
+ setCreating(false)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/src/features/stock/components/product-detail-view.tsx b/src/features/stock/components/product-detail-view.tsx
index a957398..3f3496f 100644
--- a/src/features/stock/components/product-detail-view.tsx
+++ b/src/features/stock/components/product-detail-view.tsx
@@ -78,9 +78,12 @@ function Stat({ label, children }: { label: string; children: React.ReactNode })
export function ProductDetailView({
data,
categories,
+ products,
}: {
data: ProductTypeDetail;
categories: StockOption[];
+ /** Marka/paket bölümü (Faz 12). */
+ products?: React.ReactNode;
}) {
const [pending, setPending] = useState(null);
const close = () => setPending(null);
@@ -281,6 +284,8 @@ export function ProductDetailView({
+ {products}
+
{t.detail.movements}
diff --git a/src/features/stock/components/stock-view.tsx b/src/features/stock/components/stock-view.tsx
index 915f4ff..db6a611 100644
--- a/src/features/stock/components/stock-view.tsx
+++ b/src/features/stock/components/stock-view.tsx
@@ -9,6 +9,7 @@ import {
FolderTree,
Plus,
Search,
+ Tags,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -164,6 +165,12 @@ export function StockView({ data, options }: { data: Data; options: StockOption[
return (
+
+
+
+ {tr.products.catalogLink}
+
+
diff --git a/src/features/stores/components/store-dialog.tsx b/src/features/stores/components/store-dialog.tsx
new file mode 100644
index 0000000..8514ebf
--- /dev/null
+++ b/src/features/stores/components/store-dialog.tsx
@@ -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;
+
+/** 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({});
+ const [busy, setBusy] = useState(false);
+ const set = (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 (
+ !o && onClose()}
+ title={store ? t.editTitle : t.createTitle}
+ footer={
+ <>
+
+ {tr.common.cancel}
+
+
+ {tr.common.save}
+
+ >
+ }
+ >
+
+
+ );
+}
diff --git a/src/features/stores/components/stores-view.tsx b/src/features/stores/components/stores-view.tsx
new file mode 100644
index 0000000..3691977
--- /dev/null
+++ b/src/features/stores/components/stores-view.tsx
@@ -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 = {
+ 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 (
+
+
{t.effort}
+
+
+ {store.effort} · {t.effortLevels[store.effortLevel]}
+
+
+ );
+}
+
+/** /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("name");
+ const [showArchived, setShowArchived] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [archiving, setArchiving] = useState(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 (
+
+
+
setSort(x as StoreSort)}>
+
+
+
+
+ {STORE_SORTS.map((s) => (
+
+ {t.sorts[s]}
+
+ ))}
+
+
+ {hasArchived ? (
+
+ setShowArchived(x === true)} />
+ {t.showArchived}
+
+ ) : null}
+ {canWrite ? (
+
setEditing("new")}>
+
+ {t.new}
+
+ ) : null}
+
+
+ {visible.length === 0 ? (
+
+ {t.empty}
+
+ ) : (
+
+ {visible.map((s) => {
+ const Icon = KIND_ICON[s.kind];
+ return (
+
+
+
+
+
+
+
{s.name}
+
+ {[t.kindShort[s.kind], s.branch].filter(Boolean).join(" · ")}
+
+
+ {canWrite ? (
+
+
+
+
+
+
+
+ setEditing(s)}>
+
+ {t.actions.edit}
+
+
+ {s.archived ? (
+ setArchived(s, false)}>
+
+ {t.actions.unarchive}
+
+ ) : (
+ setArchiving(s)}>
+
+ {t.actions.archive}
+
+ )}
+
+
+ ) : null}
+
+
+ {s.kind === "online" ? (
+
+ {s.deliveryFeeMinor
+ ? t.delivery(formatMoney(s.deliveryFeeMinor))
+ : t.freeDelivery}
+ {s.deliveryFeeMinor && s.freeDeliveryMinMinor
+ ? ` · ${t.freeDeliveryFrom(formatMoney(s.freeDeliveryMinMinor))}`
+ : ""}
+
+ ) : (
+
+
+ {s.distanceM !== null ? formatDistance(s.distanceM) : t.distanceUnknown}
+
+ )}
+
+ {t.fields.carry}: {s.carryDifficulty} · {t.carryLevels[s.carryDifficulty]}
+
+
+
+
+ {s.archived ? {t.archived} : null}
+ {s.mealCardAccepted ? (
+
+
+ {t.mealCardBadge}:{" "}
+ {s.mealCardProviders.map((p) => tr.accounts.providers[p]).join(", ")}
+
+ ) : null}
+
+ {s.note ? {s.note}
: null}
+
+ );
+ })}
+
+ )}
+
+ {editing ? (
+
setEditing(null)}
+ />
+ ) : null}
+ !o && setArchiving(null)}>
+
+
+ {t.archiveTitle}
+
+ {archiving ? t.archiveDescription(archiving.label) : null}
+
+
+
+ {tr.common.cancel}
+ {
+ const s = archiving;
+ setArchiving(null);
+ if (s) await setArchived(s, true);
+ }}
+ >
+ {t.actions.archive}
+
+
+
+
+
+ );
+}