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 { 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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
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 { 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" />
|
||||
)}
|
||||
<span className="text-xs">{category.name}</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</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>
|
||||
<BottomNav active="home" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
||||
|
|
|
|||
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",
|
||||
"/addresses",
|
||||
"/account",
|
||||
"/profile",
|
||||
"/notifications",
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue