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)/cart/page.tsx b/src/app/(customer)/(shop)/cart/page.tsx index 56ee906..a33af69 100644 --- a/src/app/(customer)/(shop)/cart/page.tsx +++ b/src/app/(customer)/(shop)/cart/page.tsx @@ -26,10 +26,7 @@ export default async function CartPage() { ); return ( -
- {/* DOM order intentionally mirrors the screenshot's left→right layout, not reading - order — under this app's inherited dir="rtl", the first child renders at the - right edge and the last at the left. */} +
@@ -49,7 +46,11 @@ export default async function CartPage() { )} @@ -65,19 +66,23 @@ export default async function CartPage() { ))}
-
-
- - جمع کل سبد ({cart.groups.length.toLocaleString("fa-IR")} فروشگاه،{" "} - {totalItemLines.toLocaleString("fa-IR")} کالا) +
+
+
+ + جمع کل سبد ({cart.groups.length.toLocaleString("fa-IR")} فروشگاه،{" "} + {totalItemLines.toLocaleString("fa-IR")} کالا) + + +

+ هر فروشگاه جداگانه پرداخت و ارسال می‌شود +

+
+ + + {cart.grand_total.toLocaleString("fa-IR")} تومان -

- هر فروشگاه جداگانه پرداخت و ارسال می‌شود -

- - {cart.grand_total.toLocaleString("fa-IR")} تومان -
); diff --git a/src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx b/src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx index 9411ca7..588a082 100644 --- a/src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx +++ b/src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx @@ -4,10 +4,16 @@ import { ArrowRight } from "lucide-react"; import { getCart } from "@/lib/api/cart"; import { getAddresses } from "@/lib/api/addresses"; import { requireSession } from "@/lib/auth/session"; +import { CheckoutAddress } from "@/components/customer/checkout-address"; import { CheckoutPayment } from "@/components/customer/checkout-payment"; -export default async function ConfirmPaymentPage({ params }: PageProps<"/checkout/[store]/confirm">) { +export default async function ConfirmPaymentPage({ + params, + searchParams, +}: PageProps<"/checkout/[store]/confirm">) { const { store } = await params; + const { fulfillment } = await searchParams; + const isDelivery = fulfillment === "delivery"; await requireSession(`/checkout/${store}/confirm`); const [cart, addresses] = await Promise.all([ @@ -19,6 +25,8 @@ export default async function ConfirmPaymentPage({ params }: PageProps<"/checkou const group = cart.groups.find((g) => g.store.uuid === store); if (!group) notFound(); + const address = addresses.find((a) => a.is_default) ?? addresses[0] ?? null; + return (
{/* Same header pattern as /cart and /checkout/[store]. */} @@ -29,7 +37,11 @@ export default async function ConfirmPaymentPage({ params }: PageProps<"/checkou

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

- + {isDelivery && ( + + )} + +
); } diff --git a/src/app/(customer)/(shop)/checkout/page.tsx b/src/app/(customer)/(shop)/checkout/page.tsx deleted file mode 100644 index 2182a1b..0000000 --- a/src/app/(customer)/(shop)/checkout/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { redirect } from "next/navigation"; -import { getCart } from "@/lib/api/cart"; -import { requireSession } from "@/lib/auth/session"; -import { getAddresses } from "@/lib/api/addresses"; -import { CheckoutForm } from "@/components/customer/checkout-form"; - -export default async function CheckoutPage() { - await requireSession("/checkout"); - const [cart, addresses] = await Promise.all([getCart(), getAddresses()]); - - if (cart.groups.length === 0) redirect("/cart"); - - return ( -
-

تکمیل سفارش

- -
- ); -} 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 new file mode 100644 index 0000000..5c64ed9 --- /dev/null +++ b/src/components/customer/checkout-address.tsx @@ -0,0 +1,73 @@ +import Link from "next/link"; +import { MapPin } from "lucide-react"; +import type { Address, AddressLabel } from "@/types/api"; + +const ADDRESS_LABEL_FA: Record = { + home: "خانه", + work: "محل کار", + other: "سایر", +}; + +/** + * 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, + 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} +

+
+ + تغییر آدرس + +
+ ) : ( +
+ + + +

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

+ + + +
+ )} +
+ ); +} diff --git a/src/components/customer/checkout-form.tsx b/src/components/customer/checkout-form.tsx deleted file mode 100644 index a8af62d..0000000 --- a/src/components/customer/checkout-form.tsx +++ /dev/null @@ -1,158 +0,0 @@ -"use client"; - -import { useState, useTransition } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { submitCheckout } from "@/lib/api/checkout"; -import { Button } from "@/components/ui/button"; -import type { Address, Cart, DeliveryType, PaymentMethod } from "@/types/api"; - -const PAYMENT_LABELS_FA: Record = { - wallet: "کیف پول وینوفای", - online: "درگاه بانکی", - cash_on_delivery: "پرداخت در محل", -}; - -export function CheckoutForm({ cart, addresses }: { cart: Cart; addresses: Address[] }) { - const router = useRouter(); - const [addressUuid, setAddressUuid] = useState(addresses.find((a) => a.is_default)?.uuid ?? addresses[0]?.uuid ?? ""); - const [deliveryType, setDeliveryType] = useState("express"); - const [scheduledAt, setScheduledAt] = useState(""); - const [paymentMethod, setPaymentMethod] = useState("cash_on_delivery"); - const [notes, setNotes] = useState(""); - const [error, setError] = useState(null); - const [isPending, startTransition] = useTransition(); - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(null); - - if (!addressUuid) { - setError("لطفاً یک آدرس انتخاب کنید."); - return; - } - if (deliveryType === "scheduled" && !scheduledAt) { - setError("لطفاً زمان ارسال را مشخص کنید."); - return; - } - - startTransition(async () => { - const result = await submitCheckout({ - address_uuid: addressUuid, - delivery_type: deliveryType, - scheduled_at: deliveryType === "scheduled" ? new Date(scheduledAt).toISOString() : null, - payment_method: paymentMethod, - notes, - }); - if (result.ok) { - router.push(`/order-groups/${result.data.uuid}`); - } else { - setError(result.message); - } - }); - } - - if (addresses.length === 0) { - return ( -
-

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

- - - -
- ); - } - - return ( -
-
-

آدرس تحویل

- {addresses.map((address) => ( - - ))} - - مدیریت آدرس‌ها - -
- -
-

زمان ارسال

-
- - -
- {deliveryType === "scheduled" && ( - setScheduledAt(e.target.value)} - className="h-11 rounded-xl border border-border bg-background px-3 text-sm" - /> - )} -
- -
-

روش پرداخت

-
- {(Object.keys(PAYMENT_LABELS_FA) as PaymentMethod[]).map((method) => ( - - ))} -
-
- -
-

توضیحات (اختیاری)

-