From 3609c97e340ef44c76b737b06d1fdb42f971c147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Mon, 17 Aug 2026 17:24:27 +0330 Subject: [PATCH] feat: guest-home/C04 shell, location onboarding (L01/L04/L06), profile placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/(auth)/login/page.tsx | 29 +++-- .../{ => (shop)}/addresses/page.tsx | 0 src/app/(customer)/{ => (shop)}/cart/page.tsx | 0 .../(customer)/{ => (shop)}/checkout/page.tsx | 0 src/app/(customer)/{ => (shop)}/layout.tsx | 2 +- .../{ => (shop)}/order-groups/[uuid]/page.tsx | 0 .../(customer)/{ => (shop)}/orders/page.tsx | 0 .../{ => (shop)}/products/[uuid]/page.tsx | 0 src/app/(customer)/(shop)/profile/page.tsx | 21 +++ .../{ => (shop)}/stores/[uuid]/page.tsx | 0 src/app/(customer)/location/error/page.tsx | 56 ++++++++ .../(customer)/location/permission/page.tsx | 79 ++++++++++++ src/app/(customer)/location/search/page.tsx | 16 +++ src/app/(customer)/page.tsx | 120 +++++++++--------- src/components/auth/auth-header-banner.tsx | 6 +- src/components/auth/otp-input.tsx | 2 +- src/components/auth/role-card.tsx | 2 +- src/components/customer/address-list.tsx | 2 +- src/components/home/bottom-nav.tsx | 40 ++++++ src/components/home/category-chips.tsx | 49 +++++++ .../home/location-reminder-banner.tsx | 40 ++++++ src/components/home/search-bar.tsx | 33 +++++ src/components/home/seller-promo-card.tsx | 21 +++ src/components/home/site-header.tsx | 41 ++++++ src/components/home/store-card.tsx | 28 ++++ .../location/neighborhood-search.tsx | 111 ++++++++++++++++ src/lib/location/client.ts | 23 ++++ src/lib/location/server.ts | 14 ++ src/lib/location/types.ts | 5 + src/proxy.ts | 1 + src/types/api.ts | 4 +- 31 files changed, 666 insertions(+), 79 deletions(-) rename src/app/(customer)/{ => (shop)}/addresses/page.tsx (100%) rename src/app/(customer)/{ => (shop)}/cart/page.tsx (100%) rename src/app/(customer)/{ => (shop)}/checkout/page.tsx (100%) rename src/app/(customer)/{ => (shop)}/layout.tsx (94%) rename src/app/(customer)/{ => (shop)}/order-groups/[uuid]/page.tsx (100%) rename src/app/(customer)/{ => (shop)}/orders/page.tsx (100%) rename src/app/(customer)/{ => (shop)}/products/[uuid]/page.tsx (100%) create mode 100644 src/app/(customer)/(shop)/profile/page.tsx rename src/app/(customer)/{ => (shop)}/stores/[uuid]/page.tsx (100%) create mode 100644 src/app/(customer)/location/error/page.tsx create mode 100644 src/app/(customer)/location/permission/page.tsx create mode 100644 src/app/(customer)/location/search/page.tsx create mode 100644 src/components/home/bottom-nav.tsx create mode 100644 src/components/home/category-chips.tsx create mode 100644 src/components/home/location-reminder-banner.tsx create mode 100644 src/components/home/search-bar.tsx create mode 100644 src/components/home/seller-promo-card.tsx create mode 100644 src/components/home/site-header.tsx create mode 100644 src/components/home/store-card.tsx create mode 100644 src/components/location/neighborhood-search.tsx create mode 100644 src/lib/location/client.ts create mode 100644 src/lib/location/server.ts create mode 100644 src/lib/location/types.ts diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index e702a6e..9d31836 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -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() {
-
+

ورود به حساب

برای ادامه، شماره موبایل خود را وارد کنید

@@ -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 />

شماره ۱۱ رقمی با ۰۹ شروع می‌شود

@@ -175,7 +184,7 @@ export default function LoginPage() { -
+

کد تأیید

- کد ۶ رقمی ارسال شده به شماره {toPersianDigits(normalizeIranianPhone(phone).replace("+98", "0"))} را وارد کنید + کد ۵ رقمی ارسال شده به شماره {toPersianDigits(normalizeIranianPhone(phone).replace("+98", "0"))} را وارد کنید

@@ -208,8 +217,8 @@ export default function LoginPage() { diff --git a/src/app/(customer)/addresses/page.tsx b/src/app/(customer)/(shop)/addresses/page.tsx similarity index 100% rename from src/app/(customer)/addresses/page.tsx rename to src/app/(customer)/(shop)/addresses/page.tsx diff --git a/src/app/(customer)/cart/page.tsx b/src/app/(customer)/(shop)/cart/page.tsx similarity index 100% rename from src/app/(customer)/cart/page.tsx rename to src/app/(customer)/(shop)/cart/page.tsx diff --git a/src/app/(customer)/checkout/page.tsx b/src/app/(customer)/(shop)/checkout/page.tsx similarity index 100% rename from src/app/(customer)/checkout/page.tsx rename to src/app/(customer)/(shop)/checkout/page.tsx diff --git a/src/app/(customer)/layout.tsx b/src/app/(customer)/(shop)/layout.tsx similarity index 94% rename from src/app/(customer)/layout.tsx rename to src/app/(customer)/(shop)/layout.tsx index c392dc7..dc61bbe 100644 --- a/src/app/(customer)/layout.tsx +++ b/src/app/(customer)/(shop)/layout.tsx @@ -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 ( diff --git a/src/app/(customer)/order-groups/[uuid]/page.tsx b/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx similarity index 100% rename from src/app/(customer)/order-groups/[uuid]/page.tsx rename to src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx diff --git a/src/app/(customer)/orders/page.tsx b/src/app/(customer)/(shop)/orders/page.tsx similarity index 100% rename from src/app/(customer)/orders/page.tsx rename to src/app/(customer)/(shop)/orders/page.tsx diff --git a/src/app/(customer)/products/[uuid]/page.tsx b/src/app/(customer)/(shop)/products/[uuid]/page.tsx similarity index 100% rename from src/app/(customer)/products/[uuid]/page.tsx rename to src/app/(customer)/(shop)/products/[uuid]/page.tsx diff --git a/src/app/(customer)/(shop)/profile/page.tsx b/src/app/(customer)/(shop)/profile/page.tsx new file mode 100644 index 0000000..b96a46c --- /dev/null +++ b/src/app/(customer)/(shop)/profile/page.tsx @@ -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 ( +
+

پروفایل

+ + سفارش‌های من + + + آدرس‌های من + +
+ +
+
+ ); +} diff --git a/src/app/(customer)/stores/[uuid]/page.tsx b/src/app/(customer)/(shop)/stores/[uuid]/page.tsx similarity index 100% rename from src/app/(customer)/stores/[uuid]/page.tsx rename to src/app/(customer)/(shop)/stores/[uuid]/page.tsx diff --git a/src/app/(customer)/location/error/page.tsx b/src/app/(customer)/location/error/page.tsx new file mode 100644 index 0000000..8e55da5 --- /dev/null +++ b/src/app/(customer)/location/error/page.tsx @@ -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 ( +
+ + + + +
+

موقعیت مکانی شناسایی نشد

+

مشکلی در دریافت موقعیت شما پیش آمد. لطفاً محله خود را دستی انتخاب کنید.

+
+ +
+ + + پشتیبانی +
+
+ ); +} diff --git a/src/app/(customer)/location/permission/page.tsx b/src/app/(customer)/location/permission/page.tsx new file mode 100644 index 0000000..1503e50 --- /dev/null +++ b/src/app/(customer)/location/permission/page.tsx @@ -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 ( +
+ + + + +
+

بازارچه محلی شما

+

برای نمایش فروشگاه‌های نزدیک، به موقعیت مکانی شما نیاز داریم

+
+ +
+ {BENEFITS.map(({ icon: Icon, text }) => ( +
+ + {text} +
+ ))} +
+ +
+ + + پشتیبانی +
+
+ ); +} diff --git a/src/app/(customer)/location/search/page.tsx b/src/app/(customer)/location/search/page.tsx new file mode 100644 index 0000000..c339b5c --- /dev/null +++ b/src/app/(customer)/location/search/page.tsx @@ -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 | 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 ; +} diff --git a/src/app/(customer)/page.tsx b/src/app/(customer)/page.tsx index 4c63984..0c57d04 100644 --- a/src/app/(customer)/page.tsx +++ b/src/app/(customer)/page.tsx @@ -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 | 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>("/v1/stores/?limit=12", { auth: false }); +async function getStores(params: { category?: string; search?: string; neighborhood?: string; lat?: number; lng?: number }) { + const res = await winofyFetch>("/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 ( -
-
-

دسته‌بندی‌ها

-
- {categories.map((category) => ( -
- {category.icon && ( - // eslint-disable-next-line @next/next/no-img-element - - )} - {category.name} +
+ + + {!location && ( +
+ +
+ )} + +
+

فروشگاه‌های نزدیک

+ + +
+ {stores.map((store, index) => ( +
+ + {!isAuthenticated && index === 2 && }
))} - {categories.length === 0 &&

دسته‌بندی‌ای یافت نشد.

} + {stores.length === 0 &&

فروشگاهی یافت نشد.

}
-
-

فروشگاه‌های نزدیک شما

-
- {stores.map((store) => ( - -
- {store.logo ? ( - // eslint-disable-next-line @next/next/no-img-element - {store.name} - ) : ( -
- )} -
-

{store.name}

-

{store.category.name}

-
-
-
- ⭐ {Number(store.rating_avg).toFixed(1)} ({store.rating_count}) - {store.is_open ? "باز" : "بسته"} -
-
- حداقل سفارش: {store.min_order_amount.toLocaleString("fa-IR")} تومان - ارسال: {store.delivery_fee.toLocaleString("fa-IR")} تومان -
- - ))} - {stores.length === 0 &&

فروشگاهی یافت نشد.

} -
-
-
+ +
); } diff --git a/src/components/auth/auth-header-banner.tsx b/src/components/auth/auth-header-banner.tsx index 7ce28d1..64cf08a 100644 --- a/src/components/auth/auth-header-banner.tsx +++ b/src/components/auth/auth-header-banner.tsx @@ -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 (
- {/* eslint-disable-next-line @next/next/no-img-element */} - وینسو - وینسو + وینسو
); diff --git a/src/components/auth/otp-input.tsx b/src/components/auth/otp-input.tsx index 6e48656..1676de3 100644 --- a/src/components/auth/otp-input.tsx +++ b/src/components/auth/otp-input.tsx @@ -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, diff --git a/src/components/auth/role-card.tsx b/src/components/auth/role-card.tsx index 08fcdd6..f247116 100644 --- a/src/components/auth/role-card.tsx +++ b/src/components/auth/role-card.tsx @@ -19,7 +19,7 @@ export function RoleCard({ return (
- + {icon}

{title}

diff --git a/src/components/customer/address-list.tsx b/src/components/customer/address-list.tsx index 981df26..badbda8 100644 --- a/src/components/customer/address-list.tsx +++ b/src/components/customer/address-list.tsx @@ -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], ); diff --git a/src/components/home/bottom-nav.tsx b/src/components/home/bottom-nav.tsx new file mode 100644 index 0000000..3c30f93 --- /dev/null +++ b/src/components/home/bottom-nav.tsx @@ -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 ( + + ); +} diff --git a/src/components/home/category-chips.tsx b/src/components/home/category-chips.tsx new file mode 100644 index 0000000..cf8e6de --- /dev/null +++ b/src/components/home/category-chips.tsx @@ -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 ( +
+ + {categories.map((category) => ( + + ))} +
+ ); +} diff --git a/src/components/home/location-reminder-banner.tsx b/src/components/home/location-reminder-banner.tsx new file mode 100644 index 0000000..80d2351 --- /dev/null +++ b/src/components/home/location-reminder-banner.tsx @@ -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 ( +
+ + +
+

فروشگاه‌های نزدیکت رو پیدا کن

+

+ برای نمایش نزدیک‌ترین فروشگاه‌ها، موقعیت مکانی خود را فعال کنید یا محله خود را به صورت دستی انتخاب کنید. +

+
+ +
+ + فعال کردن موقعیت مکانی + + + انتخاب محله + +
+
+ ); +} diff --git a/src/components/home/search-bar.tsx b/src/components/home/search-bar.tsx new file mode 100644 index 0000000..0c22fe4 --- /dev/null +++ b/src/components/home/search-bar.tsx @@ -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 ( + +
+ + 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" + /> +
+ + ); +} diff --git a/src/components/home/seller-promo-card.tsx b/src/components/home/seller-promo-card.tsx new file mode 100644 index 0000000..f273100 --- /dev/null +++ b/src/components/home/seller-promo-card.tsx @@ -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 ( +
+
+

کسب‌وکار داری؟

+

+ فروشگاه خودت رو بساز، محصولاتت رو مدیریت کن و سفارش دریافت کن. +

+
+ + ورود به پنل فروشنده + +
+ ); +} diff --git a/src/components/home/site-header.tsx b/src/components/home/site-header.tsx new file mode 100644 index 0000000..9e9515e --- /dev/null +++ b/src/components/home/site-header.tsx @@ -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 ( +
+ {isAuthenticated ? ( + + + {cartCount > 0 && ( + + {cartCount} + + )} + + ) : ( + + ورود + + )} + + + + {locationLabel ?? "انتخاب محله"} + + +
+ ); +} diff --git a/src/components/home/store-card.tsx b/src/components/home/store-card.tsx new file mode 100644 index 0000000..eda3c66 --- /dev/null +++ b/src/components/home/store-card.tsx @@ -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 ( + +
+ {store.logo && ( + // eslint-disable-next-line @next/next/no-img-element + {store.name} + )} +
+
+

{store.name}

+ {store.category.name} +
+ ⭐ {Number(store.rating_avg).toFixed(1)} + {store.is_open ? "باز" : "بسته"} +
+
+ + ); +} diff --git a/src/components/location/neighborhood-search.tsx b/src/components/location/neighborhood-search.tsx new file mode 100644 index 0000000..6bb2dc4 --- /dev/null +++ b/src/components/location/neighborhood-search.tsx @@ -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 ( + + ); + } + + return ( +
+
+ +

انتخاب محله

+
+ +
+ + 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 + /> +
+ + {state === "empty" && ( +
+ +

محله‌ای با این نام پیدا نشد

+

نام محله را بررسی کنید یا محله دیگری را جستجو کنید

+
+ )} + + {state === "results" && ( +
+

نتایج جستجو

+ {results.map((n) => ( + + ))} +
+ )} + + {(state === "idle" || state === "empty") && ( +
+

محله‌های پیشنهادی

+ {suggestions.map((n) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/lib/location/client.ts b/src/lib/location/client.ts new file mode 100644 index 0000000..de96074 --- /dev/null +++ b/src/lib/location/client.ts @@ -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; + } +} diff --git a/src/lib/location/server.ts b/src/lib/location/server.ts new file mode 100644 index 0000000..3485a45 --- /dev/null +++ b/src/lib/location/server.ts @@ -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 { + 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; + } +} diff --git a/src/lib/location/types.ts b/src/lib/location/types.ts new file mode 100644 index 0000000..37999fa --- /dev/null +++ b/src/lib/location/types.ts @@ -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"; diff --git a/src/proxy.ts b/src/proxy.ts index 4f0837d..c834035 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -12,6 +12,7 @@ const AUTH_REQUIRED_PREFIXES = [ "/order-groups", "/addresses", "/account", + "/profile", "/notifications", ]; diff --git a/src/types/api.ts b/src/types/api.ts index 7cebc1d..bc92b32 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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 {