feat: guest-home/C04 shell, location onboarding (L01/L04/L06), profile placeholder

Implements winofyfrontenddoc (1).md §3-4: unified guest-home/authenticated-home
screen (header with login chip vs cart badge, pill search bar, dismissible
location-reminder banner, category chips, store cards, interleaved seller
promo card, sticky bottom nav), plus the full post-OTP location-onboarding
sub-flow (geolocation permission -> error/retry -> manual neighborhood
search, unified idle/results/empty states per the doc's own recommendation).

Location state persists in a plain (non-httpOnly) cookie readable both
client- and server-side, since it drives SSR store queries (neighborhood
or lat/lng) as well as client interactions.

Moved cart/checkout/store/product/orders/addresses under a new (shop) route
group so the new home-shell header doesn't double up with the existing
simple nav header on inner pages.

Real bug caught via live staging data: Neighborhood.city is a nested
{uuid, name, slug} object, not a bare uuid string as typed -- broke both
the address form's city filter and the new neighborhood search. Fixed and
verified against real API responses, not just assumed from the guide.
This commit is contained in:
Afra ‌ 2026-08-17 17:24:27 +03:30
parent f18778c5f3
commit 3609c97e34
31 changed files with 666 additions and 79 deletions

View file

@ -12,6 +12,7 @@ import { ResendPrompt } from "@/components/auth/resend-prompt";
import { requestOtp, exchangeOtpForToken, normalizeIranianPhone, GooyalClientError } from "@/lib/auth/gooyal-client";
import { isValidIranianMobile } from "@/lib/validation/phone";
import { toPersianDigits, toLatinDigits } from "@/lib/format/persian-digits";
import { readLocationClient } from "@/lib/location/client";
type Step = "role" | "phone" | "otp";
type Role = "buyer" | "seller";
@ -85,7 +86,15 @@ export default function LoginPage() {
});
if (!sessionRes.ok) throw new Error("خطا در برقراری نشست ورود");
router.push(role === "seller" ? "/seller" : next);
if (role === "seller") {
router.push("/seller");
} else if (readLocationClient()) {
// Location already resolved from a prior session — skip L01 straight to the destination (C04).
router.push(next);
} else {
// First time with no location set — hand off into the L01 onboarding flow per doc §4.
router.push(`/location/permission?next=${encodeURIComponent(next)}`);
}
router.refresh();
} catch (err) {
setError(err instanceof GooyalClientError || err instanceof Error ? err.message : "کد تایید نامعتبر است");
@ -96,7 +105,7 @@ export default function LoginPage() {
function handleOtpSubmit(e: React.FormEvent) {
e.preventDefault();
if (otp.length === 6) verifyOtp(otp);
if (otp.length === 5) verifyOtp(otp);
}
function backToPhoneStep() {
@ -147,7 +156,7 @@ export default function LoginPage() {
<main className="flex min-h-full flex-1 flex-col bg-surface">
<AuthHeaderBanner />
<form onSubmit={handlePhoneSubmit} className="flex flex-col gap-8 px-6 pt-10">
<div className="flex flex-col gap-2 text-right">
<div className="flex flex-col gap-4 text-right">
<h1 className="text-headline-medium font-extrabold text-foreground">ورود به حساب</h1>
<p className="text-body-large text-gray-700">برای ادامه، شماره موبایل خود را وارد کنید</p>
</div>
@ -164,7 +173,7 @@ export default function LoginPage() {
placeholder={toPersianDigits("09123456789")}
value={phone ? toPersianDigits(phone) : ""}
onChange={(e) => setPhone(toLatinDigits(e.target.value).replace(/\D/g, "").slice(0, 11))}
className="text-center tracking-[0.2em]"
className="text-end tracking-[0.2em]"
autoFocus
/>
<p className="text-label text-gray-600">شماره ۱۱ رقمی با ۰۹ شروع می‌شود</p>
@ -175,7 +184,7 @@ export default function LoginPage() {
<Button
type="submit"
size="lg"
className="bg-primary-action hover:bg-primary-action/90"
className="bg-primary-action hover:bg-primary-action/90 text-primary-foreground"
disabled={loading || !isValidIranianMobile(phone)}
>
{loading ? "در حال ارسال..." : "دریافت کد تأیید"}
@ -187,15 +196,15 @@ export default function LoginPage() {
return (
<main className="flex min-h-full flex-1 flex-col bg-surface px-6 py-6">
<button type="button" onClick={backToPhoneStep} className="mb-6 flex h-8 w-8 items-center justify-center" aria-label="بازگشت">
<button type="button" onClick={backToPhoneStep} className="mb-12 flex h-8 w-8 items-center justify-center" aria-label="بازگشت">
<ArrowRight className="h-5 w-5 text-foreground" />
</button>
<form onSubmit={handleOtpSubmit} className="flex flex-col gap-8">
<div className="flex flex-col gap-2 text-right">
<div className="flex flex-col gap-4 text-right">
<h1 className="text-headline-medium font-extrabold text-foreground">کد تأیید</h1>
<p className="text-body-large text-gray-700">
کد ۶ رقمی ارسال شده به شماره {toPersianDigits(normalizeIranianPhone(phone).replace("+98", "0"))} را وارد کنید
کد ۵ رقمی ارسال شده به شماره {toPersianDigits(normalizeIranianPhone(phone).replace("+98", "0"))} را وارد کنید
</p>
</div>
@ -208,8 +217,8 @@ export default function LoginPage() {
<Button
type="submit"
size="lg"
className="bg-primary-action hover:bg-primary-action/90"
disabled={loading || otp.length !== 6}
className="bg-primary-action hover:bg-primary-action/90 text-primary-foreground"
disabled={loading || otp.length !== 5}
>
{loading ? "در حال بررسی..." : "تأیید"}
</Button>

View file

@ -2,7 +2,7 @@ import Link from "next/link";
import { getSession } from "@/lib/auth/session";
import { LogoutButton } from "@/components/customer/logout-button";
export default async function CustomerLayout({ children }: LayoutProps<"/">) {
export default async function ShopLayout({ children }: LayoutProps<"/">) {
const session = await getSession();
return (

View file

@ -0,0 +1,21 @@
import Link from "next/link";
import { LogoutButton } from "@/components/customer/logout-button";
// Minimal placeholder — winofyfrontenddoc.md's bottom-nav references a "پروفایل"
// tab, but no profile screen is documented yet in either doc.
export default function ProfilePage() {
return (
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-3 px-4 py-6">
<h1 className="text-title-large font-extrabold text-foreground">پروفایل</h1>
<Link href="/orders" className="rounded-xl border border-border p-4 text-body-large">
سفارش‌های من
</Link>
<Link href="/addresses" className="rounded-xl border border-border p-4 text-body-large">
آدرس‌های من
</Link>
<div className="rounded-xl border border-border p-4">
<LogoutButton />
</div>
</main>
);
}

View file

@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { MapPinOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { setLocation } from "@/lib/location/client";
// L04 — winofyfrontenddoc (1).md §4.2. CTA hierarchy deliberately flips from L01:
// manual selection is primary here (most likely to succeed), retry is secondary.
export default function LocationErrorPage() {
const router = useRouter();
const searchParams = useSearchParams();
const next = searchParams.get("next") ?? "/";
const [retrying, setRetrying] = useState(false);
function retry() {
setRetrying(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({ mode: "geo", lat: position.coords.latitude, lng: position.coords.longitude });
router.push(next);
router.refresh();
},
() => setRetrying(false),
{ timeout: 10000 },
);
}
return (
<main className="flex min-h-full flex-1 flex-col items-center gap-8 bg-surface px-6 py-10 text-center">
<span className="flex h-28 w-28 items-center justify-center rounded-full bg-error/10 text-error">
<MapPinOff className="h-14 w-14" />
</span>
<div className="flex flex-col gap-2">
<h1 className="text-headline-medium font-extrabold text-foreground">موقعیت مکانی شناسایی نشد</h1>
<p className="text-body-large text-gray-700">مشکلی در دریافت موقعیت شما پیش آمد. لطفاً محله خود را دستی انتخاب کنید.</p>
</div>
<div className="mt-auto flex w-full flex-col gap-4">
<Button
size="lg"
className="w-full bg-primary-action hover:bg-primary-action/90 text-primary-foreground"
onClick={() => router.push(`/location/search?next=${encodeURIComponent(next)}`)}
>
انتخاب دستی محله
</Button>
<button type="button" onClick={retry} disabled={retrying} className="text-body-medium font-semibold text-primary-action">
{retrying ? "در حال تلاش..." : "تلاش مجدد"}
</button>
<span className="text-body-medium text-gray-500">پشتیبانی</span>
</div>
</main>
);
}

View file

@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { MapPin, Zap, Store } from "lucide-react";
import { Button } from "@/components/ui/button";
import { setLocation } from "@/lib/location/client";
const BENEFITS = [
{ icon: MapPin, text: "نمایش فروشگاه‌های محله شما" },
{ icon: Zap, text: "تحویل سریع‌تر" },
{ icon: Store, text: "کشف بازارچه‌های اطراف" },
];
// L01 — winofyfrontenddoc (1).md §4.1. No illustration asset was provided;
// approximated with a simple icon badge rather than the isometric map graphic.
export default function LocationPermissionPage() {
const router = useRouter();
const searchParams = useSearchParams();
const next = searchParams.get("next") ?? "/";
const [loading, setLoading] = useState(false);
function allowLocation() {
setLoading(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({ mode: "geo", lat: position.coords.latitude, lng: position.coords.longitude });
router.push(next);
router.refresh();
},
() => {
setLoading(false);
router.push(`/location/error?next=${encodeURIComponent(next)}`);
},
{ timeout: 10000 },
);
}
return (
<main className="flex min-h-full flex-1 flex-col items-center gap-8 bg-surface px-6 py-10 text-center">
<span className="flex h-28 w-28 items-center justify-center rounded-full bg-primary-action/10 text-primary-action">
<MapPin className="h-14 w-14" />
</span>
<div className="flex flex-col gap-2">
<h1 className="text-headline-medium font-extrabold text-foreground">بازارچه محلی شما</h1>
<p className="text-body-large text-gray-700">برای نمایش فروشگاه‌های نزدیک، به موقعیت مکانی شما نیاز داریم</p>
</div>
<div className="flex w-full flex-col gap-3 text-right">
{BENEFITS.map(({ icon: Icon, text }) => (
<div key={text} className="flex items-center gap-3 rounded-xl border border-gray-300 bg-white p-3">
<Icon className="h-5 w-5 shrink-0 text-primary-action" />
<span className="text-body-medium text-foreground">{text}</span>
</div>
))}
</div>
<div className="mt-auto flex w-full flex-col gap-4">
<Button
size="lg"
className="w-full bg-primary-action hover:bg-primary-action/90 text-primary-foreground"
disabled={loading}
onClick={allowLocation}
>
{loading ? "در حال دریافت موقعیت..." : "اجازه دسترسی به موقعیت مکانی"}
</Button>
<button
type="button"
onClick={() => router.push(`/location/search?next=${encodeURIComponent(next)}`)}
className="text-body-medium font-semibold text-primary-action"
>
انتخاب دستی محله
</button>
<span className="text-body-medium text-gray-500">پشتیبانی</span>
</div>
</main>
);
}

View file

@ -0,0 +1,16 @@
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
import type { Neighborhood } from "@/types/api";
import { NeighborhoodSearch } from "@/components/location/neighborhood-search";
async function getNeighborhoods() {
const res = await winofyFetch<PaginatedResponse<Neighborhood> | Neighborhood[]>("/v1/neighborhoods/", {
auth: false,
params: { limit: 200 },
});
return Array.isArray(res) ? res : res.results;
}
export default async function NeighborhoodSearchPage() {
const neighborhoods = await getNeighborhoods();
return <NeighborhoodSearch neighborhoods={neighborhoods} />;
}

View file

@ -1,7 +1,15 @@
import Link from "next/link";
import { getSession } from "@/lib/auth/session";
import { getLocationServer } from "@/lib/location/server";
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
import { resolveMediaUrl } from "@/lib/api/media";
import { getCart } from "@/lib/actions/cart";
import type { StoreCategory, StoreListItem } from "@/types/api";
import { SiteHeader } from "@/components/home/site-header";
import { SearchBar } from "@/components/home/search-bar";
import { LocationReminderBanner } from "@/components/home/location-reminder-banner";
import { CategoryChips } from "@/components/home/category-chips";
import { StoreCard } from "@/components/home/store-card";
import { SellerPromoCard } from "@/components/home/seller-promo-card";
import { BottomNav } from "@/components/home/bottom-nav";
async function getStoreCategories() {
const res = await winofyFetch<PaginatedResponse<StoreCategory> | StoreCategory[]>("/v1/store-categories/", {
@ -10,73 +18,65 @@ async function getStoreCategories() {
return Array.isArray(res) ? res : res.results;
}
async function getStores() {
const res = await winofyFetch<PaginatedResponse<StoreListItem>>("/v1/stores/?limit=12", { auth: false });
async function getStores(params: { category?: string; search?: string; neighborhood?: string; lat?: number; lng?: number }) {
const res = await winofyFetch<PaginatedResponse<StoreListItem>>("/v1/stores/", {
auth: false,
params: { limit: 20, ...params },
});
return res.results;
}
export default async function HomePage() {
const [categories, stores] = await Promise.all([getStoreCategories(), getStores()]);
export default async function HomePage({ searchParams }: PageProps<"/">) {
const params = await searchParams;
const category = typeof params.category === "string" ? params.category : undefined;
const search = typeof params.search === "string" ? params.search : undefined;
const [session, location] = await Promise.all([getSession(), getLocationServer()]);
const isAuthenticated = !!session;
const [categories, stores, cart] = await Promise.all([
getStoreCategories(),
getStores({
category,
search,
neighborhood: location?.mode === "manual" ? location.neighborhoodUuid : undefined,
lat: location?.mode === "geo" ? location.lat : undefined,
lng: location?.mode === "geo" ? location.lng : undefined,
}),
isAuthenticated ? getCart() : null,
]);
const cartCount = cart ? cart.groups.reduce((sum, g) => sum + g.items.reduce((s, i) => s + i.quantity, 0), 0) : 0;
const cityName = location?.mode === "manual" ? location.cityName : "تهران";
const locationLabel =
location?.mode === "manual" ? `محله: ${location.neighborhoodName}` : location?.mode === "geo" ? "موقعیت فعلی" : null;
return (
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-8 px-4 py-6">
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-muted-foreground">دسته‌بندی‌ها</h2>
<div className="flex gap-3 overflow-x-auto pb-2">
{categories.map((category) => (
<div
key={category.uuid}
className="flex shrink-0 flex-col items-center gap-2 rounded-2xl border border-border px-4 py-3"
>
{category.icon && (
// eslint-disable-next-line @next/next/no-img-element
<img src={resolveMediaUrl(category.icon) ?? undefined} alt="" className="h-8 w-8" />
<div className="flex min-h-full flex-1 flex-col pb-24">
<SiteHeader isAuthenticated={isAuthenticated} locationLabel={locationLabel} cartCount={cartCount} />
<SearchBar placeholder={`جستجو در محصولات و فروشگاه‌های ${cityName}...`} defaultValue={search} />
{!location && (
<div className="pb-2 pt-2">
<LocationReminderBanner />
</div>
)}
<span className="text-xs">{category.name}</span>
<section className="flex flex-col gap-3 pt-4">
<h2 className="px-4 text-title-large font-bold text-foreground">فروشگاه‌های نزدیک</h2>
<CategoryChips categories={categories} />
<div className="flex flex-col gap-3 px-4">
{stores.map((store, index) => (
<div key={store.uuid} className="flex flex-col gap-3">
<StoreCard store={store} />
{!isAuthenticated && index === 2 && <SellerPromoCard />}
</div>
))}
{categories.length === 0 && <p className="text-sm text-muted-foreground">دسته‌بندی‌ای یافت نشد.</p>}
{stores.length === 0 && <p className="text-body-medium text-gray-600">فروشگاهی یافت نشد.</p>}
</div>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-muted-foreground">فروشگاه‌های نزدیک شما</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{stores.map((store) => (
<Link
href={`/stores/${store.uuid}`}
key={store.uuid}
className="flex flex-col gap-2 rounded-2xl border border-border p-4"
>
<div className="flex items-center gap-3">
{store.logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveMediaUrl(store.logo) ?? undefined}
alt={store.name}
className="h-12 w-12 rounded-full object-cover"
/>
) : (
<div className="h-12 w-12 rounded-full bg-muted" />
)}
<div>
<h3 className="font-medium">{store.name}</h3>
<p className="text-xs text-muted-foreground">{store.category.name}</p>
<BottomNav active="home" />
</div>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>⭐ {Number(store.rating_avg).toFixed(1)} ({store.rating_count})</span>
<span>{store.is_open ? "باز" : "بسته"}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span>حداقل سفارش: {store.min_order_amount.toLocaleString("fa-IR")} تومان</span>
<span>ارسال: {store.delivery_fee.toLocaleString("fa-IR")} تومان</span>
</div>
</Link>
))}
{stores.length === 0 && <p className="text-sm text-muted-foreground">فروشگاهی یافت نشد.</p>}
</div>
</section>
</main>
);
}

View file

@ -1,14 +1,12 @@
/** Dark-navy full-bleed header used on C01/C02 — winofyfrontenddoc.md §2.2/§2.3. */
export function AuthHeaderBanner({ height = 236 }: { height?: number }) {
export function AuthHeaderBanner({ height = 165 }: { height?: number }) {
return (
<div
className="flex w-full shrink-0 items-center justify-center bg-primary-action"
style={{ height }}
>
<div className="flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/logotype-winsoo.svg" alt="وینسو" className="h-10 w-10" />
<span className="text-title-large font-extrabold text-white">وینسو</span>
<img src="/logotype-winsoo.svg" alt="وینسو" className="h-auto w-auto" />
</div>
</div>
);

View file

@ -5,7 +5,7 @@ import { toLatinDigits, toPersianDigits } from "@/lib/format/persian-digits";
import { cn } from "@/lib/utils/cn";
export function OtpInput({
length = 6,
length = 5,
value,
onChange,
onComplete,

View file

@ -19,7 +19,7 @@ export function RoleCard({
return (
<div className="flex flex-col gap-5 rounded-xl border border-gray-300 bg-gray-50 p-4">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-primary-action/10 text-primary-action">
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-action/10 text-primary-action">
{icon}
</span>
<h3 className="text-title-large font-extrabold text-foreground">{title}</h3>

View file

@ -43,7 +43,7 @@ export function AddressList({
const [isPending, startTransition] = useTransition();
const filteredNeighborhoods = useMemo(
() => neighborhoods.filter((n) => n.city === form.city_uuid),
() => neighborhoods.filter((n) => n.city.uuid === form.city_uuid),
[neighborhoods, form.city_uuid],
);

View file

@ -0,0 +1,40 @@
import Link from "next/link";
import { Home, Search, ShoppingBag, User } from "lucide-react";
import { cn } from "@/lib/utils/cn";
type Tab = "profile" | "orders" | "search" | "home";
const TABS: { key: Tab; href: string; label: string; icon: typeof Home }[] = [
{ key: "profile", href: "/profile", label: "پروفایل", icon: User },
{ key: "orders", href: "/orders", label: "سفارش‌ها", icon: ShoppingBag },
{ key: "search", href: "/", label: "جستجو", icon: Search },
{ key: "home", href: "/", label: "خانه", icon: Home },
];
/** winofyfrontenddoc (1).md §3.6 — sticky bottom nav, 4 equal-width tabs. */
export function BottomNav({ active }: { active: Tab }) {
return (
<nav
className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-[390px] items-stretch border-t border-gray-300 bg-white"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
{TABS.map((tab) => {
const Icon = tab.icon;
const isActive = tab.key === active;
return (
<Link
key={tab.key}
href={tab.href}
className={cn(
"flex flex-1 flex-col items-center gap-1 py-3 text-label",
isActive ? "text-primary-action" : "text-gray-500",
)}
>
<Icon className="h-5 w-5" />
<span>{tab.label}</span>
</Link>
);
})}
</nav>
);
}

View file

@ -0,0 +1,49 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { cn } from "@/lib/utils/cn";
import type { StoreCategory } from "@/types/api";
/** winofyfrontenddoc (1).md §3.5 — horizontal-scroll category filter chips. */
export function CategoryChips({ categories }: { categories: StoreCategory[] }) {
const router = useRouter();
const searchParams = useSearchParams();
const activeUuid = searchParams.get("category");
function select(uuid: string | null) {
const params = new URLSearchParams(searchParams.toString());
if (uuid) params.set("category", uuid);
else params.delete("category");
router.push(params.toString() ? `/?${params.toString()}` : "/");
}
return (
<div className="flex gap-2 overflow-x-auto px-4 pb-1">
<button
type="button"
onClick={() => select(null)}
className={cn(
"shrink-0 rounded-full px-3 py-2 text-body-medium",
!activeUuid ? "bg-primary-action text-white" : "border border-gray-300 bg-white text-foreground",
)}
>
همه
</button>
{categories.map((category) => (
<button
key={category.uuid}
type="button"
onClick={() => select(category.uuid)}
className={cn(
"shrink-0 rounded-full px-3 py-2 text-body-medium",
activeUuid === category.uuid
? "bg-primary-action text-white"
: "border border-gray-300 bg-white text-foreground",
)}
>
{category.name}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { X } from "lucide-react";
/** winofyfrontenddoc (1).md §3.4 — shown only while no neighborhood/location is set. */
export function LocationReminderBanner() {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
return (
<div className="relative mx-4 flex flex-col gap-4 rounded-2xl border border-[#F0642D] bg-[#FFFBF7] p-4">
<button
type="button"
onClick={() => setDismissed(true)}
className="absolute left-3 top-3 text-gray-500"
aria-label="بستن"
>
<X className="h-4 w-4" />
</button>
<div className="flex flex-col gap-1 pl-6">
<h3 className="text-title-medium font-semibold text-foreground">فروشگاه‌های نزدیکت رو پیدا کن</h3>
<p className="text-body-medium text-gray-700">
برای نمایش نزدیک‌ترین فروشگاه‌ها، موقعیت مکانی خود را فعال کنید یا محله خود را به صورت دستی انتخاب کنید.
</p>
</div>
<div className="flex items-center gap-3">
<Link href="/location/permission" className="rounded-lg bg-[#F0642D] px-3 py-2 text-label font-semibold text-white">
فعال کردن موقعیت مکانی
</Link>
<Link href="/location/search" className="px-3 py-2 text-label font-semibold text-foreground">
انتخاب محله
</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,33 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Search } from "lucide-react";
/** winofyfrontenddoc (1).md §3.3 — pill search bar, gray-50 fill, icon docked at the visual-right (RTL leading) edge. */
export function SearchBar({ placeholder, defaultValue = "" }: { placeholder: string; defaultValue?: string }) {
const router = useRouter();
const [value, setValue] = useState(defaultValue);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (value.trim()) params.set("search", value.trim());
router.push(params.toString() ? `/?${params.toString()}` : "/");
}
return (
<form onSubmit={handleSubmit} className="px-4 py-2">
<div className="flex h-12 items-center gap-2 rounded-full bg-gray-50 px-4">
<Search className="h-5 w-5 shrink-0 text-gray-500" />
<input
type="search"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
className="h-full flex-1 bg-transparent text-body-medium text-foreground placeholder:text-gray-500 focus:outline-none"
/>
</div>
</form>
);
}

View file

@ -0,0 +1,21 @@
import Link from "next/link";
/** winofyfrontenddoc (1).md §3.5 — "become a seller" card interleaved in the store list. */
export function SellerPromoCard() {
return (
<div className="flex h-[170px] flex-col justify-between gap-4 rounded-2xl border border-gray-300 bg-gray-50 p-5">
<div className="flex flex-col gap-1">
<h3 className="text-title-medium font-semibold text-foreground">کسب‌وکار داری؟</h3>
<p className="text-body-medium text-gray-700">
فروشگاه خودت رو بساز، محصولاتت رو مدیریت کن و سفارش دریافت کن.
</p>
</div>
<Link
href="/login"
className="flex h-12 w-fit items-center justify-center rounded-full bg-primary-action px-5 text-body-large font-semibold text-white"
>
ورود به پنل فروشنده
</Link>
</div>
);
}

View file

@ -0,0 +1,41 @@
import Link from "next/link";
import { ChevronDown, MapPin, ShoppingBag } from "lucide-react";
/** guest-home / C04 header — winofyfrontenddoc (1).md §3.2 / §4.5. */
export function SiteHeader({
isAuthenticated,
locationLabel,
cartCount,
}: {
isAuthenticated: boolean;
locationLabel: string | null;
cartCount: number;
}) {
return (
<header className="flex w-full items-center justify-between px-4 py-3">
{isAuthenticated ? (
<Link href="/cart" className="relative flex h-8 w-8 items-center justify-center text-primary-action">
<ShoppingBag className="h-6 w-6" />
{cartCount > 0 && (
<span className="absolute -top-1 -left-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-error px-1 text-[10px] font-semibold text-white">
{cartCount}
</span>
)}
</Link>
) : (
<Link
href="/login"
className="flex h-8 items-center justify-center rounded-full border-[1.5px] border-primary-action bg-white px-4 text-body-medium font-semibold text-primary-action"
>
ورود
</Link>
)}
<Link href="/location/search" className="flex items-center gap-1 text-body-medium text-foreground">
<ChevronDown className="h-4 w-4 text-gray-600" />
<span>{locationLabel ?? "انتخاب محله"}</span>
<MapPin className="h-4 w-4 text-primary-action" />
</Link>
</header>
);
}

View file

@ -0,0 +1,28 @@
import Link from "next/link";
import { resolveMediaUrl } from "@/lib/api/media";
import type { StoreListItem } from "@/types/api";
/** winofyfrontenddoc (1).md §3.5 store-card. */
export function StoreCard({ store }: { store: StoreListItem }) {
return (
<Link
href={`/stores/${store.uuid}`}
className="flex items-center gap-3 rounded-xl border border-gray-300 bg-white p-3 shadow-sm"
>
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded-[10px] border border-gray-300 bg-muted">
{store.logo && (
// eslint-disable-next-line @next/next/no-img-element
<img src={resolveMediaUrl(store.logo) ?? undefined} alt={store.name} className="h-full w-full object-cover" />
)}
</div>
<div className="flex flex-1 flex-col gap-1">
<h3 className="text-[15px] leading-[22px] font-bold text-foreground">{store.name}</h3>
<span className="w-fit rounded-full bg-muted px-2 py-0.5 text-label text-gray-600">{store.category.name}</span>
<div className="flex items-center justify-between text-label text-gray-600">
<span>⭐ {Number(store.rating_avg).toFixed(1)}</span>
<span>{store.is_open ? "باز" : "بسته"}</span>
</div>
</div>
</Link>
);
}

View file

@ -0,0 +1,111 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowRight, MapPin, Search, SearchX } from "lucide-react";
import { setLocation } from "@/lib/location/client";
import type { Neighborhood } from "@/types/api";
// L06 (+ neighborhood-search-results) unified — winofyfrontenddoc (1).md §4.3.
// idle / results / empty share ~90% of layout, per the doc's own recommendation.
export function NeighborhoodSearch({ neighborhoods }: { neighborhoods: Neighborhood[] }) {
const router = useRouter();
const searchParams = useSearchParams();
const next = searchParams.get("next") ?? "/";
const [query, setQuery] = useState("");
const [focused, setFocused] = useState(false);
const results = useMemo(() => {
if (!query.trim()) return [];
const q = query.trim();
return neighborhoods.filter((n) => n.name.includes(q));
}, [neighborhoods, query]);
const state: "idle" | "results" | "empty" = !query.trim() ? "idle" : results.length > 0 ? "results" : "empty";
const suggestions = neighborhoods.slice(0, 8);
function selectNeighborhood(n: Neighborhood) {
setLocation({
mode: "manual",
cityUuid: n.city.uuid,
cityName: n.city.name,
neighborhoodUuid: n.uuid,
neighborhoodName: n.name,
});
router.push(next);
router.refresh();
}
function NeighborhoodRow({ n }: { n: Neighborhood }) {
return (
<button
type="button"
onClick={() => selectNeighborhood(n)}
className="flex w-full items-center gap-3 border-b border-gray-300 py-4 text-right"
>
<MapPin className="h-5 w-5 shrink-0 text-gray-500" />
<span className="text-body-large text-foreground">
{n.city.name}، {n.name}
</span>
</button>
);
}
return (
<main className="flex min-h-full flex-1 flex-col bg-surface px-4 py-4">
<div className="mb-4 flex items-center gap-3">
<button type="button" onClick={() => router.back()} aria-label="بازگشت">
<ArrowRight className="h-5 w-5 text-foreground" />
</button>
<h1 className="text-title-large font-extrabold text-foreground">انتخاب محله</h1>
</div>
<div
className={
focused
? "flex h-12 items-center gap-2 rounded-3xl border border-primary-action bg-[#F5F5F5] px-4"
: "flex h-12 items-center gap-2 rounded-3xl border border-gray-300 bg-white px-4"
}
>
<Search className="h-5 w-5 shrink-0 text-gray-500" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder="با نام محله خود را جستجو کنید"
className="h-full flex-1 bg-transparent text-body-medium text-foreground placeholder:text-gray-500 focus:outline-none"
autoFocus
/>
</div>
{state === "empty" && (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<SearchX className="h-12 w-12 text-gray-400" />
<h2 className="text-title-medium font-semibold text-foreground">محله‌ای با این نام پیدا نشد</h2>
<p className="text-body-medium text-gray-600">نام محله را بررسی کنید یا محله دیگری را جستجو کنید</p>
</div>
)}
{state === "results" && (
<div className="mt-4 flex flex-col">
<h2 className="pb-2 text-label text-gray-600">نتایج جستجو</h2>
{results.map((n) => (
<NeighborhoodRow key={n.uuid} n={n} />
))}
</div>
)}
{(state === "idle" || state === "empty") && (
<div className="mt-4 flex flex-col">
<h2 className="pb-2 text-label text-gray-600">محله‌های پیشنهادی</h2>
{suggestions.map((n) => (
<NeighborhoodRow key={n.uuid} n={n} />
))}
</div>
)}
</main>
);
}

View file

@ -0,0 +1,23 @@
"use client";
import { LOCATION_COOKIE_NAME, type LocationState } from "./types";
const MAX_AGE_SECONDS = 60 * 60 * 24 * 180; // 180 days — doc §4.6 gap #9: persist so L01 doesn't refire every session.
export function setLocation(state: LocationState) {
document.cookie = `${LOCATION_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(state))}; path=/; max-age=${MAX_AGE_SECONDS}; samesite=lax`;
}
export function clearLocation() {
document.cookie = `${LOCATION_COOKIE_NAME}=; path=/; max-age=0`;
}
export function readLocationClient(): LocationState | null {
const match = document.cookie.match(new RegExp(`(?:^|; )${LOCATION_COOKIE_NAME}=([^;]*)`));
if (!match) return null;
try {
return JSON.parse(decodeURIComponent(match[1])) as LocationState;
} catch {
return null;
}
}

View file

@ -0,0 +1,14 @@
import "server-only";
import { cookies } from "next/headers";
import { LOCATION_COOKIE_NAME, type LocationState } from "./types";
export async function getLocationServer(): Promise<LocationState | null> {
const store = await cookies();
const value = store.get(LOCATION_COOKIE_NAME)?.value;
if (!value) return null;
try {
return JSON.parse(value) as LocationState;
} catch {
return null;
}
}

View file

@ -0,0 +1,5 @@
export type LocationState =
| { mode: "geo"; lat: number; lng: number }
| { mode: "manual"; cityUuid: string; cityName: string; neighborhoodUuid: string; neighborhoodName: string };
export const LOCATION_COOKIE_NAME = "winofy_location";

View file

@ -12,6 +12,7 @@ const AUTH_REQUIRED_PREFIXES = [
"/order-groups",
"/addresses",
"/account",
"/profile",
"/notifications",
];

View file

@ -3,12 +3,14 @@
export interface City {
uuid: string;
name: string;
slug?: string;
}
export interface Neighborhood {
uuid: string;
name: string;
city: string;
/** Nested object, confirmed against staging — not a bare uuid string. */
city: City;
}
export interface StoreCategory {