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:
parent
f18778c5f3
commit
3609c97e34
31 changed files with 666 additions and 79 deletions
|
|
@ -12,6 +12,7 @@ import { ResendPrompt } from "@/components/auth/resend-prompt";
|
||||||
import { requestOtp, exchangeOtpForToken, normalizeIranianPhone, GooyalClientError } from "@/lib/auth/gooyal-client";
|
import { requestOtp, exchangeOtpForToken, normalizeIranianPhone, GooyalClientError } from "@/lib/auth/gooyal-client";
|
||||||
import { isValidIranianMobile } from "@/lib/validation/phone";
|
import { isValidIranianMobile } from "@/lib/validation/phone";
|
||||||
import { toPersianDigits, toLatinDigits } from "@/lib/format/persian-digits";
|
import { toPersianDigits, toLatinDigits } from "@/lib/format/persian-digits";
|
||||||
|
import { readLocationClient } from "@/lib/location/client";
|
||||||
|
|
||||||
type Step = "role" | "phone" | "otp";
|
type Step = "role" | "phone" | "otp";
|
||||||
type Role = "buyer" | "seller";
|
type Role = "buyer" | "seller";
|
||||||
|
|
@ -85,7 +86,15 @@ export default function LoginPage() {
|
||||||
});
|
});
|
||||||
if (!sessionRes.ok) throw new Error("خطا در برقراری نشست ورود");
|
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();
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof GooyalClientError || err instanceof Error ? err.message : "کد تایید نامعتبر است");
|
setError(err instanceof GooyalClientError || err instanceof Error ? err.message : "کد تایید نامعتبر است");
|
||||||
|
|
@ -96,7 +105,7 @@ export default function LoginPage() {
|
||||||
|
|
||||||
function handleOtpSubmit(e: React.FormEvent) {
|
function handleOtpSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (otp.length === 6) verifyOtp(otp);
|
if (otp.length === 5) verifyOtp(otp);
|
||||||
}
|
}
|
||||||
|
|
||||||
function backToPhoneStep() {
|
function backToPhoneStep() {
|
||||||
|
|
@ -147,7 +156,7 @@ export default function LoginPage() {
|
||||||
<main className="flex min-h-full flex-1 flex-col bg-surface">
|
<main className="flex min-h-full flex-1 flex-col bg-surface">
|
||||||
<AuthHeaderBanner />
|
<AuthHeaderBanner />
|
||||||
<form onSubmit={handlePhoneSubmit} className="flex flex-col gap-8 px-6 pt-10">
|
<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>
|
<h1 className="text-headline-medium font-extrabold text-foreground">ورود به حساب</h1>
|
||||||
<p className="text-body-large text-gray-700">برای ادامه، شماره موبایل خود را وارد کنید</p>
|
<p className="text-body-large text-gray-700">برای ادامه، شماره موبایل خود را وارد کنید</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -164,7 +173,7 @@ export default function LoginPage() {
|
||||||
placeholder={toPersianDigits("09123456789")}
|
placeholder={toPersianDigits("09123456789")}
|
||||||
value={phone ? toPersianDigits(phone) : ""}
|
value={phone ? toPersianDigits(phone) : ""}
|
||||||
onChange={(e) => setPhone(toLatinDigits(e.target.value).replace(/\D/g, "").slice(0, 11))}
|
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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<p className="text-label text-gray-600">شماره ۱۱ رقمی با ۰۹ شروع میشود</p>
|
<p className="text-label text-gray-600">شماره ۱۱ رقمی با ۰۹ شروع میشود</p>
|
||||||
|
|
@ -175,7 +184,7 @@ export default function LoginPage() {
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
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)}
|
disabled={loading || !isValidIranianMobile(phone)}
|
||||||
>
|
>
|
||||||
{loading ? "در حال ارسال..." : "دریافت کد تأیید"}
|
{loading ? "در حال ارسال..." : "دریافت کد تأیید"}
|
||||||
|
|
@ -187,15 +196,15 @@ export default function LoginPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-full flex-1 flex-col bg-surface px-6 py-6">
|
<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" />
|
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<form onSubmit={handleOtpSubmit} className="flex flex-col gap-8">
|
<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>
|
<h1 className="text-headline-medium font-extrabold text-foreground">کد تأیید</h1>
|
||||||
<p className="text-body-large text-gray-700">
|
<p className="text-body-large text-gray-700">
|
||||||
کد ۶ رقمی ارسال شده به شماره {toPersianDigits(normalizeIranianPhone(phone).replace("+98", "0"))} را وارد کنید
|
کد ۵ رقمی ارسال شده به شماره {toPersianDigits(normalizeIranianPhone(phone).replace("+98", "0"))} را وارد کنید
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -208,8 +217,8 @@ export default function LoginPage() {
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
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 || otp.length !== 6}
|
disabled={loading || otp.length !== 5}
|
||||||
>
|
>
|
||||||
{loading ? "در حال بررسی..." : "تأیید"}
|
{loading ? "در حال بررسی..." : "تأیید"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import Link from "next/link";
|
||||||
import { getSession } from "@/lib/auth/session";
|
import { getSession } from "@/lib/auth/session";
|
||||||
import { LogoutButton } from "@/components/customer/logout-button";
|
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();
|
const session = await getSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
21
src/app/(customer)/(shop)/profile/page.tsx
Normal file
21
src/app/(customer)/(shop)/profile/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/app/(customer)/location/error/page.tsx
Normal file
56
src/app/(customer)/location/error/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/app/(customer)/location/permission/page.tsx
Normal file
79
src/app/(customer)/location/permission/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/(customer)/location/search/page.tsx
Normal file
16
src/app/(customer)/location/search/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
|
|
@ -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 { 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 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() {
|
async function getStoreCategories() {
|
||||||
const res = await winofyFetch<PaginatedResponse<StoreCategory> | StoreCategory[]>("/v1/store-categories/", {
|
const res = await winofyFetch<PaginatedResponse<StoreCategory> | StoreCategory[]>("/v1/store-categories/", {
|
||||||
|
|
@ -10,73 +18,65 @@ async function getStoreCategories() {
|
||||||
return Array.isArray(res) ? res : res.results;
|
return Array.isArray(res) ? res : res.results;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getStores() {
|
async function getStores(params: { category?: string; search?: string; neighborhood?: string; lat?: number; lng?: number }) {
|
||||||
const res = await winofyFetch<PaginatedResponse<StoreListItem>>("/v1/stores/?limit=12", { auth: false });
|
const res = await winofyFetch<PaginatedResponse<StoreListItem>>("/v1/stores/", {
|
||||||
|
auth: false,
|
||||||
|
params: { limit: 20, ...params },
|
||||||
|
});
|
||||||
return res.results;
|
return res.results;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage({ searchParams }: PageProps<"/">) {
|
||||||
const [categories, stores] = await Promise.all([getStoreCategories(), getStores()]);
|
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 (
|
return (
|
||||||
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-8 px-4 py-6">
|
<div className="flex min-h-full flex-1 flex-col pb-24">
|
||||||
<section className="flex flex-col gap-3">
|
<SiteHeader isAuthenticated={isAuthenticated} locationLabel={locationLabel} cartCount={cartCount} />
|
||||||
<h2 className="text-sm font-semibold text-muted-foreground">دستهبندیها</h2>
|
<SearchBar placeholder={`جستجو در محصولات و فروشگاههای ${cityName}...`} defaultValue={search} />
|
||||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
{!location && (
|
||||||
{categories.map((category) => (
|
<div className="pb-2 pt-2">
|
||||||
<div
|
<LocationReminderBanner />
|
||||||
key={category.uuid}
|
</div>
|
||||||
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" />
|
|
||||||
)}
|
)}
|
||||||
<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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-3">
|
<BottomNav active="home" />
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
/** Dark-navy full-bleed header used on C01/C02 — winofyfrontenddoc.md §2.2/§2.3. */
|
/** 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex w-full shrink-0 items-center justify-center bg-primary-action"
|
className="flex w-full shrink-0 items-center justify-center bg-primary-action"
|
||||||
style={{ height }}
|
style={{ height }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<img src="/logotype-winsoo.svg" alt="وینسو" className="h-auto w-auto" />
|
||||||
<img src="/logotype-winsoo.svg" alt="وینسو" className="h-10 w-10" />
|
|
||||||
<span className="text-title-large font-extrabold text-white">وینسو</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { toLatinDigits, toPersianDigits } from "@/lib/format/persian-digits";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { cn } from "@/lib/utils/cn";
|
||||||
|
|
||||||
export function OtpInput({
|
export function OtpInput({
|
||||||
length = 6,
|
length = 5,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onComplete,
|
onComplete,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export function RoleCard({
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 rounded-xl border border-gray-300 bg-gray-50 p-4">
|
<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">
|
<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}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-title-large font-extrabold text-foreground">{title}</h3>
|
<h3 className="text-title-large font-extrabold text-foreground">{title}</h3>
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export function AddressList({
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const filteredNeighborhoods = useMemo(
|
const filteredNeighborhoods = useMemo(
|
||||||
() => neighborhoods.filter((n) => n.city === form.city_uuid),
|
() => neighborhoods.filter((n) => n.city.uuid === form.city_uuid),
|
||||||
[neighborhoods, form.city_uuid],
|
[neighborhoods, form.city_uuid],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
40
src/components/home/bottom-nav.tsx
Normal file
40
src/components/home/bottom-nav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/home/category-chips.tsx
Normal file
49
src/components/home/category-chips.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/home/location-reminder-banner.tsx
Normal file
40
src/components/home/location-reminder-banner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/home/search-bar.tsx
Normal file
33
src/components/home/search-bar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/home/seller-promo-card.tsx
Normal file
21
src/components/home/seller-promo-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/home/site-header.tsx
Normal file
41
src/components/home/site-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/home/store-card.tsx
Normal file
28
src/components/home/store-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/location/neighborhood-search.tsx
Normal file
111
src/components/location/neighborhood-search.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/lib/location/client.ts
Normal file
23
src/lib/location/client.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/lib/location/server.ts
Normal file
14
src/lib/location/server.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/lib/location/types.ts
Normal file
5
src/lib/location/types.ts
Normal 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";
|
||||||
|
|
@ -12,6 +12,7 @@ const AUTH_REQUIRED_PREFIXES = [
|
||||||
"/order-groups",
|
"/order-groups",
|
||||||
"/addresses",
|
"/addresses",
|
||||||
"/account",
|
"/account",
|
||||||
|
"/profile",
|
||||||
"/notifications",
|
"/notifications",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@
|
||||||
export interface City {
|
export interface City {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
slug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Neighborhood {
|
export interface Neighborhood {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
name: string;
|
name: string;
|
||||||
city: string;
|
/** Nested object, confirmed against staging — not a bare uuid string. */
|
||||||
|
city: City;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoreCategory {
|
export interface StoreCategory {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue