From 6c5d02ddd057f1740af92f43fa20ff30ee71bfe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Sat, 5 Sep 2026 11:37:15 +0330 Subject: [PATCH] feat: address pages added --- package-lock.json | 115 +++++++-- package.json | 3 + .../(customer)/(shop)/addresses/map/page.tsx | 30 +++ .../(customer)/(shop)/addresses/new/page.tsx | 42 +++ src/app/(customer)/(shop)/addresses/page.tsx | 22 +- .../(shop)/checkout/[store]/confirm/page.tsx | 4 +- .../customer/address-details-form.tsx | 240 ++++++++++++++++++ src/components/customer/address-list.tsx | 198 --------------- src/components/customer/address-selector.tsx | 106 ++++++++ src/components/customer/checkout-address.tsx | 47 +++- .../location/address-map-leaflet.tsx | 98 +++++++ .../location/address-map-picker.tsx | 73 ++++++ 12 files changed, 742 insertions(+), 236 deletions(-) create mode 100644 src/app/(customer)/(shop)/addresses/map/page.tsx create mode 100644 src/app/(customer)/(shop)/addresses/new/page.tsx create mode 100644 src/components/customer/address-details-form.tsx delete mode 100644 src/components/customer/address-list.tsx create mode 100644 src/components/customer/address-selector.tsx create mode 100644 src/components/location/address-map-leaflet.tsx create mode 100644 src/components/location/address-map-picker.tsx diff --git a/package-lock.json b/package-lock.json index 003039d..adb2fa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,15 +19,18 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jose": "^6.2.9", + "leaflet": "^1.9.4", "lucide-react": "^1.31.0", "next": "16.3.1", "react": "19.2.8", "react-dom": "19.2.8", + "react-leaflet": "^5.0.0", "tailwind-merge": "^3.6.0", "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/leaflet": "^1.9.22", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -81,7 +84,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -291,10 +293,32 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, @@ -2052,6 +2076,17 @@ "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2383,6 +2418,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2397,6 +2439,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -2413,7 +2465,6 @@ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2424,7 +2475,6 @@ "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2474,7 +2524,6 @@ "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", @@ -2996,6 +3045,18 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -3007,6 +3068,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", @@ -3055,7 +3127,6 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3411,7 +3482,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -4028,7 +4098,6 @@ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4214,7 +4283,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5545,6 +5613,12 @@ "node": ">=0.10" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6448,7 +6522,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6458,7 +6531,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6473,6 +6545,20 @@ "dev": true, "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -7235,7 +7321,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7398,7 +7483,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7719,7 +7803,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 92d2a3b..b5560eb 100644 --- a/package.json +++ b/package.json @@ -20,15 +20,18 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jose": "^6.2.9", + "leaflet": "^1.9.4", "lucide-react": "^1.31.0", "next": "16.3.1", "react": "19.2.8", "react-dom": "19.2.8", + "react-leaflet": "^5.0.0", "tailwind-merge": "^3.6.0", "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/leaflet": "^1.9.22", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/app/(customer)/(shop)/addresses/map/page.tsx b/src/app/(customer)/(shop)/addresses/map/page.tsx new file mode 100644 index 0000000..1c9493a --- /dev/null +++ b/src/app/(customer)/(shop)/addresses/map/page.tsx @@ -0,0 +1,30 @@ +import { Suspense } from "react"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import { requireSession } from "@/lib/auth/session"; +import { getLocationServer } from "@/lib/location/server"; +import { AddressMapPicker } from "@/components/location/address-map-picker"; + +export default async function AddressMapPage() { + await requireSession("/addresses/map"); + const location = await getLocationServer(); + const initialCenter: [number, number] | null = location?.mode === "geo" ? [location.lat, location.lng] : null; + + return ( +
+ {/* Same header pattern as /cart and /checkout/[store]. */} +
+ + + +

انتخاب موقعیت روی نقشه

+
+ + {/* AddressMapPicker reads useSearchParams() (the `next` return-path handoff) — + needs Suspense or `next build` fails, even though `next dev` tolerates it. */} + + + +
+ ); +} diff --git a/src/app/(customer)/(shop)/addresses/new/page.tsx b/src/app/(customer)/(shop)/addresses/new/page.tsx new file mode 100644 index 0000000..3e0325e --- /dev/null +++ b/src/app/(customer)/(shop)/addresses/new/page.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import { requireSession } from "@/lib/auth/session"; +import { getLocationServer } from "@/lib/location/server"; +import { getCities, getNeighborhoods } from "@/lib/api/locations"; +import { AddressDetailsForm } from "@/components/customer/address-details-form"; + +export default async function NewAddressPage({ searchParams }: PageProps<"/addresses/new">) { + await requireSession("/addresses/new"); + const { lat, lng, address, next } = await searchParams; + + const location = await getLocationServer(); + const cityUuid = location?.mode === "manual" ? location.cityUuid : null; + const neighborhoodUuid = location?.mode === "manual" ? location.neighborhoodUuid : null; + + // Only needed for the fallback city/neighborhood picker (no location cookie set yet) + // — cheap enough to always fetch rather than branch server-side on cityUuid. + const [cities, neighborhoods] = await Promise.all([getCities(), getNeighborhoods()]); + + return ( +
+ {/* Same header pattern as /cart and /checkout/[store]. */} +
+ + + +

جزئیات آدرس

+
+ + +
+ ); +} diff --git a/src/app/(customer)/(shop)/addresses/page.tsx b/src/app/(customer)/(shop)/addresses/page.tsx index ae25141..614b84a 100644 --- a/src/app/(customer)/(shop)/addresses/page.tsx +++ b/src/app/(customer)/(shop)/addresses/page.tsx @@ -1,16 +1,24 @@ +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; import { requireSession } from "@/lib/auth/session"; import { getAddresses } from "@/lib/api/addresses"; -import { getCities, getNeighborhoods } from "@/lib/api/locations"; -import { AddressList } from "@/components/customer/address-list"; +import { AddressSelector } from "@/components/customer/address-selector"; -export default async function AddressesPage() { +export default async function AddressesPage({ searchParams }: PageProps<"/addresses">) { await requireSession("/addresses"); - const [addresses, cities, neighborhoods] = await Promise.all([getAddresses(), getCities(), getNeighborhoods()]); + const [addresses, { next }] = await Promise.all([getAddresses(), searchParams]); return ( -
-

آدرس‌های من

- +
+ {/* Same header pattern as /cart and /checkout/[store]. */} +
+ + + +

آدرس‌های من

+
+ +
); } diff --git a/src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx b/src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx index 9a6b8d2..588a082 100644 --- a/src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx +++ b/src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx @@ -37,7 +37,9 @@ export default async function ConfirmPaymentPage({

تأیید و پرداخت

- {isDelivery && } + {isDelivery && ( + + )}
diff --git a/src/components/customer/address-details-form.tsx b/src/components/customer/address-details-form.tsx new file mode 100644 index 0000000..c745c81 --- /dev/null +++ b/src/components/customer/address-details-form.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { createAddress, type AddressInput } from "@/lib/api/addresses"; +import type { AddressLabel, City, Neighborhood } from "@/types/api"; + +const LABEL_PRESETS: { value: AddressLabel; text: string }[] = [ + { value: "other", text: "سایر" }, + { value: "work", text: "محل کار" }, + { value: "home", text: "خانه جدید" }, +]; + +export function AddressDetailsForm({ + fullAddress, + latitude, + longitude, + cityUuid, + neighborhoodUuid, + cities, + neighborhoods, + next, +}: { + fullAddress: string; + latitude: number; + longitude: number; + /** Inferred from the customer's current location cookie, if they'd set one manually. */ + cityUuid: string | null; + neighborhoodUuid: string | null; + cities: City[]; + neighborhoods: Neighborhood[]; + /** Set when this add-address detour started from checkout — see handleSubmit. */ + next: string | null; +}) { + const router = useRouter(); + const [address, setAddress] = useState(fullAddress); + const [label, setLabel] = useState("home"); + const [title, setTitle] = useState("خانه جدید"); + const [plaque, setPlaque] = useState(""); + const [floor, setFloor] = useState(""); + const [unit, setUnit] = useState(""); + const [recipientName, setRecipientName] = useState(""); + const [recipientPhone, setRecipientPhone] = useState(""); + const [manualCityUuid, setManualCityUuid] = useState(cityUuid ?? ""); + const [manualNeighborhoodUuid, setManualNeighborhoodUuid] = useState(neighborhoodUuid ?? ""); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + // city_uuid is a hard requirement on the backend (OpenAPI: Address.city_uuid is + // required for writes) but isn't part of this screen's design — it's normally + // inferred from the customer's current location cookie. Only show a picker for + // it when that inference failed (e.g. they're in "geo" mode, or never set one). + const needsCityPicker = !cityUuid; + const filteredNeighborhoods = useMemo( + () => neighborhoods.filter((n) => n.city.uuid === manualCityUuid), + [neighborhoods, manualCityUuid], + ); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + const finalCityUuid = cityUuid ?? manualCityUuid; + if (!finalCityUuid) { + setError("شهر مشخص نیست — لطفاً محله خود را از صفحه اصلی انتخاب کنید یا شهر را زیر مشخص کنید."); + return; + } + + startTransition(async () => { + const input: AddressInput = { + label, + title, + full_address: address, + plaque, + floor, + unit, + recipient_name: recipientName, + recipient_phone: recipientPhone, + city_uuid: finalCityUuid, + neighborhood_uuid: (neighborhoodUuid ?? manualNeighborhoodUuid) || undefined, + latitude, + longitude, + // Arriving here from checkout means they had no address at all yet — make + // this one the default immediately so it's actually used for the order, + // rather than sending them on to /addresses to select it a second time. + // A plain profile-driven "add another address" leaves the existing default alone. + is_default: next !== null, + }; + const result = await createAddress(input); + if (result.ok) { + router.push(next ?? "/addresses"); + } else { + setError(result.message); + } + }); + } + + function selectPreset(preset: (typeof LABEL_PRESETS)[number]) { + setLabel(preset.value); + setTitle(preset.text); + } + + return ( +
+
+ آدرس کامل + setAddress(e.target.value)} + required + className="h-12 rounded-xl border border-gray-300 bg-white px-4 text-[14px] text-foreground outline-none focus:border-primary-action" + /> +
+ +
+ عنوان آدرس +
+ {LABEL_PRESETS.map((preset) => ( + + ))} +
+
+ +
+
+ پلاک + setPlaque(e.target.value)} + className="h-12 rounded-xl border border-gray-300 bg-white px-3 text-center text-[14px] text-foreground outline-none focus:border-primary-action" + /> +
+
+ طبقه + setFloor(e.target.value)} + className="h-12 rounded-xl border border-gray-300 bg-white px-3 text-center text-[14px] text-foreground outline-none focus:border-primary-action" + /> +
+
+ واحد + setUnit(e.target.value)} + className="h-12 rounded-xl border border-gray-300 bg-white px-3 text-center text-[14px] text-foreground outline-none focus:border-primary-action" + /> +
+
+ +
+ نام گیرنده + setRecipientName(e.target.value)} + required + className="h-12 rounded-xl border border-gray-300 bg-white px-4 text-[14px] text-foreground outline-none focus:border-primary-action" + /> +
+ +
+ شماره تماس + setRecipientPhone(e.target.value)} + placeholder="09123456789" + required + className="h-12 rounded-xl border border-gray-300 bg-white px-4 text-[14px] text-foreground outline-none placeholder:text-gray-400 focus:border-primary-action" + /> +
+ + {needsCityPicker && ( +
+

+ شهر شما مشخص نیست — این فقط لازم است چون هنوز محله‌ای از صفحه اصلی انتخاب نکرده‌اید. +

+
+ + +
+
+ )} + + {error &&

{error}

} + + +
+ ); +} diff --git a/src/components/customer/address-list.tsx b/src/components/customer/address-list.tsx deleted file mode 100644 index c80af60..0000000 --- a/src/components/customer/address-list.tsx +++ /dev/null @@ -1,198 +0,0 @@ -"use client"; - -import { useMemo, useState, useTransition } from "react"; -import { useRouter } from "next/navigation"; -import { createAddress, deleteAddress, type AddressInput } from "@/lib/api/addresses"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import type { Address, AddressLabel, City, Neighborhood } from "@/types/api"; - -const LABEL_OPTIONS: { value: AddressLabel; label: string }[] = [ - { value: "home", label: "خانه" }, - { value: "work", label: "محل کار" }, - { value: "other", label: "سایر" }, -]; - -const emptyForm = { - label: "home" as AddressLabel, - title: "", - full_address: "", - plaque: "", - floor: "", - unit: "", - recipient_name: "", - recipient_phone: "", - city_uuid: "", - neighborhood_uuid: "", - is_default: false, -}; - -export function AddressList({ - addresses, - cities, - neighborhoods, -}: { - addresses: Address[]; - cities: City[]; - neighborhoods: Neighborhood[]; -}) { - const router = useRouter(); - const [isAdding, setIsAdding] = useState(false); - const [form, setForm] = useState(emptyForm); - const [error, setError] = useState(null); - const [isPending, startTransition] = useTransition(); - - const filteredNeighborhoods = useMemo( - () => neighborhoods.filter((n) => n.city.uuid === form.city_uuid), - [neighborhoods, form.city_uuid], - ); - - function handleDelete(uuid: string) { - startTransition(async () => { - await deleteAddress(uuid); - router.refresh(); - }); - } - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(null); - startTransition(async () => { - const input: AddressInput = { - ...form, - neighborhood_uuid: form.neighborhood_uuid || undefined, - latitude: 0, - longitude: 0, - }; - const result = await createAddress(input); - if (result.ok) { - setForm(emptyForm); - setIsAdding(false); - router.refresh(); - } else { - setError(result.message); - } - }); - } - - return ( -
-
- {addresses.map((address) => ( -
-
- - {LABEL_OPTIONS.find((o) => o.value === address.label)?.label} {address.is_default && "· پیش‌فرض"} - - {address.full_address} - - {address.recipient_name} — {address.recipient_phone} - -
- -
- ))} - {addresses.length === 0 && !isAdding &&

آدرسی ثبت نشده است.

} -
- - {isAdding ? ( -
-
- {LABEL_OPTIONS.map((opt) => ( - - ))} -
- - setForm((f) => ({ ...f, full_address: e.target.value }))} - required - /> -
- setForm((f) => ({ ...f, plaque: e.target.value }))} /> - setForm((f) => ({ ...f, floor: e.target.value }))} /> - setForm((f) => ({ ...f, unit: e.target.value }))} /> -
- -
- - -
- - setForm((f) => ({ ...f, recipient_name: e.target.value }))} - required - /> - setForm((f) => ({ ...f, recipient_phone: e.target.value }))} - required - /> - - - - {error &&

{error}

} - -
- - -
-
- ) : ( - - )} -
- ); -} diff --git a/src/components/customer/address-selector.tsx b/src/components/customer/address-selector.tsx new file mode 100644 index 0000000..b718482 --- /dev/null +++ b/src/components/customer/address-selector.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { updateAddress } from "@/lib/api/addresses"; +import type { Address, AddressLabel } from "@/types/api"; + +const ADDRESS_LABEL_FA: Record = { + home: "خانه", + work: "محل کار", + other: "سایر", +}; + +/** + * Doubles as both a plain address-management screen (reached from the profile menu) + * and a picker (reached from checkout's "تغییر آدرس" with a `next` return path) — + * the radio always reflects/sets which address is the default, "تأیید" just persists + * that choice and, if there's somewhere to return to, goes back there. + */ +export function AddressSelector({ addresses, next }: { addresses: Address[]; next: string | null }) { + const router = useRouter(); + const [selectedUuid, setSelectedUuid] = useState( + () => addresses.find((a) => a.is_default)?.uuid ?? addresses[0]?.uuid ?? null, + ); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + function handleConfirm() { + setError(null); + const currentDefaultUuid = addresses.find((a) => a.is_default)?.uuid; + if (!selectedUuid || selectedUuid === currentDefaultUuid) { + if (next) router.push(next); + return; + } + startTransition(async () => { + const result = await updateAddress(selectedUuid, { is_default: true }); + if (result.ok) { + if (next) router.push(next); + else router.refresh(); + } else { + setError(result.message); + } + }); + } + + return ( +
+
+ {addresses.map((address) => { + const isSelected = address.uuid === selectedUuid; + return ( + + ); + })} + + {addresses.length === 0 && ( +

هنوز آدرسی ثبت نکرده‌اید.

+ )} + + +
+ + {error &&

{error}

} + + {addresses.length > 0 && ( + + )} +
+ ); +} diff --git a/src/components/customer/checkout-address.tsx b/src/components/customer/checkout-address.tsx index 5210fc9..5c64ed9 100644 --- a/src/components/customer/checkout-address.tsx +++ b/src/components/customer/checkout-address.tsx @@ -9,30 +9,45 @@ const ADDRESS_LABEL_FA: Record = { }; /** - * "افزودن آدرس" / "تغییر آدرس" both just go to /addresses for now — the picker step - * in between (per the design) is a later task. + * Both actions carry `returnTo` (the checkout confirm page's own URL) so the whole + * address-management chain — map → details form → address list — comes back here + * instead of stranding the customer on /addresses mid-checkout. */ -export function CheckoutAddress({ address }: { address: Address | null }) { +export function CheckoutAddress({ + address, + returnTo, +}: { + address: Address | null; + returnTo: string; +}) { + const next = encodeURIComponent(returnTo); return (
-

آدرس تحویل

+

+ آدرس تحویل +

{address ? ( -
+
{/* DOM order intentionally mirrors the screenshot's left→right layout — see the inherited dir="rtl" note on the /cart page. */} -
- - تغییر آدرس - +
+ {address.title || ADDRESS_LABEL_FA[address.label]} -
+

+ {address.full_address} +

-

{address.full_address}

+ + تغییر آدرس +
) : (
@@ -40,10 +55,14 @@ export function CheckoutAddress({ address }: { address: Address | null }) {

- هنوز آدرسی ثبت نکرده‌اید. برای ادامه سفارش، لطفاً یک آدرس اضافه کنید. + هنوز آدرسی ثبت نکرده‌اید. برای ادامه سفارش، لطفاً یک آدرس اضافه + کنید.

- - diff --git a/src/components/location/address-map-leaflet.tsx b/src/components/location/address-map-leaflet.tsx new file mode 100644 index 0000000..d36e365 --- /dev/null +++ b/src/components/location/address-map-leaflet.tsx @@ -0,0 +1,98 @@ +"use client"; + +import "leaflet/dist/leaflet.css"; +import { useState } from "react"; +import { MapContainer, TileLayer, ZoomControl, useMap, useMapEvents } from "react-leaflet"; +import { LocateFixed } from "lucide-react"; + +const ADDWIN_TILE_URL = "https://maps.addwin.ir/styles/basic/{z}/{x}/{y}.png"; + +/** + * Reports the map's center whenever panning/zooming settles — this drives the + * "pin fixed at screen-center, map moves underneath" UX (see the CSS pin in + * AddressMapLeaflet below), not a draggable react-leaflet . + */ +function CenterTracker({ onMove }: { onMove: (lat: number, lng: number) => void }) { + useMapEvents({ + moveend: (e) => { + const c = e.target.getCenter(); + onMove(c.lat, c.lng); + }, + }); + return null; +} + +/** Recenters the map on the browser's reported GPS position — same navigator.geolocation + * pattern as the location-onboarding flow (permission/error screens). */ +function RecenterButton() { + const map = useMap(); + const [locating, setLocating] = useState(false); + + function handleClick() { + setLocating(true); + navigator.geolocation.getCurrentPosition( + (position) => { + map.flyTo([position.coords.latitude, position.coords.longitude], 16); + setLocating(false); + }, + () => setLocating(false), + { timeout: 10000 }, + ); + } + + return ( + + ); +} + +export function AddressMapLeaflet({ + initialCenter, + onCenterChange, +}: { + initialCenter: [number, number]; + onCenterChange: (lat: number, lng: number) => void; +}) { + return ( +
+ + + + + + + + {/* Fixed screen-center pin — its tip (not its center) marks the selected point, + so it's offset up by its own height rather than centered on both axes. */} +
+
+ +
+
+
+ ); +} diff --git a/src/components/location/address-map-picker.tsx b/src/components/location/address-map-picker.tsx new file mode 100644 index 0000000..f32f0bc --- /dev/null +++ b/src/components/location/address-map-picker.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; +import dynamic from "next/dynamic"; +import { useRouter, useSearchParams } from "next/navigation"; + +// Leaflet touches `window` at import time — this component can only ever run in +// the browser, so it's dynamically imported with ssr:false from this client +// component (Server Components/pages can't pass ssr:false themselves in the +// App Router, hence this wrapper layer). +const AddressMapLeaflet = dynamic( + () => import("./address-map-leaflet").then((m) => m.AddressMapLeaflet), + { + ssr: false, + loading: () =>
, + }, +); + +// Central Tehran — used only when there's no prior location to center on. +const DEFAULT_CENTER: [number, number] = [35.7219, 51.339]; + +export function AddressMapPicker({ initialCenter }: { initialCenter: [number, number] | null }) { + const router = useRouter(); + const searchParams = useSearchParams(); + const next = searchParams.get("next"); + const [center, setCenter] = useState(initialCenter ?? DEFAULT_CENTER); + const [address, setAddress] = useState(""); + + function handleConfirm() { + const params = new URLSearchParams({ + lat: String(center[0]), + lng: String(center[1]), + address, + }); + if (next) params.set("next", next); + router.push(`/addresses/new?${params.toString()}`); + } + + return ( +
+
+ setCenter([lat, lng])} /> +
+ +
+
+ موقعیت انتخابی + {/* No reverse-geocoding endpoint is documented for Addwin or Winofy (checked + the OpenAPI spec — Address only ever takes latitude/longitude and a + separate free-text full_address, never derives one from the other), so + this is a real editable field styled to match the design, not an + auto-filled label. */} + setAddress(e.target.value)} + placeholder="آدرس را وارد کنید — مثلاً خیابان، کوچه، پلاک" + className="border-0 bg-transparent p-0 text-[15px] font-semibold leading-normal text-foreground outline-none placeholder:font-normal placeholder:text-gray-400" + /> +
+ + +
+
+ ); +}