Compare commits
7 commits
464f2b34a2
...
7e9ab5d68f
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e9ab5d68f | |||
| a9268b8a94 | |||
| 4e3eb2de9f | |||
|
|
b548c59484 | ||
|
|
c2b4c4a690 | ||
|
|
6c5d02ddd0 | ||
|
|
2048e99bf2 |
34 changed files with 1326 additions and 645 deletions
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/winofy-front.iml" filepath="$PROJECT_DIR$/.idea/winofy-front.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/winofy-front.iml
generated
Normal file
8
.idea/winofy-front.iml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
115
package-lock.json
generated
115
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
30
src/app/(customer)/(shop)/addresses/map/page.tsx
Normal file
30
src/app/(customer)/(shop)/addresses/map/page.tsx
Normal file
|
|
@ -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 (
|
||||
<main className="flex min-h-full flex-1 flex-col">
|
||||
{/* Same header pattern as /cart and /checkout/[store]. */}
|
||||
<div className="flex items-center justify-start gap-3 px-4 pt-4 pb-3">
|
||||
<Link href="/addresses" aria-label="بازگشت">
|
||||
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||
</Link>
|
||||
<h1 className="text-[18px] font-bold leading-none text-foreground">انتخاب موقعیت روی نقشه</h1>
|
||||
</div>
|
||||
|
||||
{/* AddressMapPicker reads useSearchParams() (the `next` return-path handoff) —
|
||||
needs Suspense or `next build` fails, even though `next dev` tolerates it. */}
|
||||
<Suspense fallback={null}>
|
||||
<AddressMapPicker initialCenter={initialCenter} />
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
42
src/app/(customer)/(shop)/addresses/new/page.tsx
Normal file
42
src/app/(customer)/(shop)/addresses/new/page.tsx
Normal file
|
|
@ -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 (
|
||||
<main className="flex min-h-full flex-1 flex-col">
|
||||
{/* Same header pattern as /cart and /checkout/[store]. */}
|
||||
<div className="flex items-center justify-start gap-3 px-4 pt-4 pb-3">
|
||||
<Link href="/addresses/map" aria-label="بازگشت">
|
||||
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||
</Link>
|
||||
<h1 className="text-[18px] font-bold leading-none text-foreground">جزئیات آدرس</h1>
|
||||
</div>
|
||||
|
||||
<AddressDetailsForm
|
||||
fullAddress={typeof address === "string" ? address : ""}
|
||||
latitude={typeof lat === "string" ? Number(lat) : 0}
|
||||
longitude={typeof lng === "string" ? Number(lng) : 0}
|
||||
cityUuid={cityUuid}
|
||||
neighborhoodUuid={neighborhoodUuid}
|
||||
cities={cities}
|
||||
neighborhoods={neighborhoods}
|
||||
next={typeof next === "string" ? next : null}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 py-6">
|
||||
<h1 className="text-lg font-bold">آدرسهای من</h1>
|
||||
<AddressList addresses={addresses} cities={cities} neighborhoods={neighborhoods} />
|
||||
<main className="flex min-h-full flex-1 flex-col gap-3">
|
||||
{/* Same header pattern as /cart and /checkout/[store]. */}
|
||||
<div className="flex items-center justify-start gap-3 px-4 pt-4 pb-3">
|
||||
<Link href="/" aria-label="بازگشت">
|
||||
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||
</Link>
|
||||
<h1 className="text-[18px] font-bold leading-none text-foreground">آدرسهای من</h1>
|
||||
</div>
|
||||
|
||||
<AddressSelector addresses={addresses} next={typeof next === "string" ? next : null} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,7 @@ export default async function CartPage() {
|
|||
);
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-6 px-4 pt-4 pb-6">
|
||||
{/* 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. */}
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-6 px-4 pt-4 pb-28">
|
||||
<div className="flex items-center justify-start gap-3">
|
||||
<Link href="/" aria-label="بازگشت">
|
||||
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||
|
|
@ -49,7 +46,11 @@ export default async function CartPage() {
|
|||
)}
|
||||
|
||||
<Link
|
||||
href={group.meets_minimum_order ? `/checkout/${group.store.uuid}` : "#"}
|
||||
href={
|
||||
group.meets_minimum_order
|
||||
? `/checkout/${group.store.uuid}`
|
||||
: "#"
|
||||
}
|
||||
aria-disabled={!group.meets_minimum_order}
|
||||
className="mt-4 flex justify-center"
|
||||
>
|
||||
|
|
@ -65,19 +66,23 @@ export default async function CartPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-between border-t border-gray-200 pt-4 px-5">
|
||||
<div className="flex flex-col items-start justify-start gap-2">
|
||||
<span className="text-[14px] font-semibold text-foreground">
|
||||
جمع کل سبد ({cart.groups.length.toLocaleString("fa-IR")} فروشگاه،{" "}
|
||||
{totalItemLines.toLocaleString("fa-IR")} کالا)
|
||||
<div className="fixed inset-x-0 bottom-0 z-50 flex w-full flex-col items-center">
|
||||
<div className="flex w-full flex-row justify-between border-t border-gray-200 bg-white px-8 pt-6 pb-6">
|
||||
<div className="flex flex-col items-start justify-start gap-2">
|
||||
<span className="text-[14px] font-semibold text-foreground">
|
||||
جمع کل سبد ({cart.groups.length.toLocaleString("fa-IR")} فروشگاه،{" "}
|
||||
{totalItemLines.toLocaleString("fa-IR")} کالا)
|
||||
</span>
|
||||
|
||||
<p className="text-end text-[12px] leading-normal text-gray-600">
|
||||
هر فروشگاه جداگانه پرداخت و ارسال میشود
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="text-[16px] font-bold leading-none text-foreground">
|
||||
{cart.grand_total.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
<p className="text-end text-[12px] leading-normal text-gray-600">
|
||||
هر فروشگاه جداگانه پرداخت و ارسال میشود
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[16px] font-bold leading-none text-foreground">
|
||||
{cart.grand_total.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-6 px-4 pt-4 pb-6">
|
||||
{/* Same header pattern as /cart and /checkout/[store]. */}
|
||||
|
|
@ -29,7 +37,11 @@ export default async function ConfirmPaymentPage({ params }: PageProps<"/checkou
|
|||
<h1 className="text-[18px] font-bold leading-none text-foreground">تأیید و پرداخت</h1>
|
||||
</div>
|
||||
|
||||
<CheckoutPayment group={group} addresses={addresses} />
|
||||
{isDelivery && (
|
||||
<CheckoutAddress address={address} returnTo={`/checkout/${store}/confirm?fulfillment=delivery`} />
|
||||
)}
|
||||
|
||||
<CheckoutPayment group={group} addresses={addresses} isDelivery={isDelivery} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 py-6">
|
||||
<h1 className="text-lg font-bold">تکمیل سفارش</h1>
|
||||
<CheckoutForm cart={cart} addresses={addresses} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,76 +1,43 @@
|
|||
import Link from "next/link";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getOrderGroups } from "@/lib/api/orders";
|
||||
import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status";
|
||||
"use client";
|
||||
|
||||
import { useOrders } from "@/lib/providers/orders-provider";
|
||||
import OrderItem from "@/components/orders/order-item";
|
||||
import OrderEmpty from "@/components/orders/order-empty";
|
||||
import OrderServerError from "@/components/orders/order-server-error";
|
||||
import OrdersSkeleton from "@/components/orders/orders-skeleton";
|
||||
import OrderHeader from "@/components/orders/order-header";
|
||||
|
||||
export default async function OrdersPage() {
|
||||
return <section>
|
||||
{/* <OrderEmpty /> */}
|
||||
{/* <OrderServerError /> */}
|
||||
{
|
||||
Array.from({ length: 20 }, (_, index) => <OrderItem />)
|
||||
export default function OrdersPage() {
|
||||
const { data, error, isLoading } = useOrders();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="p-4">
|
||||
<OrdersSkeleton />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
{/* <OrderEmpty />
|
||||
<OrderServerError />
|
||||
<OrdersSkeleton /> */}
|
||||
|
||||
{/* <OrdersSkeleton /> */}
|
||||
{/* <OrderServerError /> */}
|
||||
{/* <OrderEmpty /> */}
|
||||
{/* */}
|
||||
if (error) {
|
||||
return (
|
||||
<section className="p-4">
|
||||
<OrderServerError />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
{/* <OrderItem />
|
||||
<OrderItem />
|
||||
<OrderItem />
|
||||
<OrderItem />
|
||||
<OrderItem />
|
||||
<OrderItem />
|
||||
<OrderItem />
|
||||
<OrderItem />
|
||||
<OrderItem />
|
||||
<OrderItem />
|
||||
<OrderItem />
|
||||
<OrderItem /> */}
|
||||
</section>
|
||||
// await requireSession("/orders");
|
||||
// const orderGroups = await getOrderGroups();
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<section className="p-4">
|
||||
<OrderEmpty />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// return (
|
||||
// <main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-4 px-4 py-6">
|
||||
// <h1 className="text-lg font-bold">سفارشهای من</h1>
|
||||
|
||||
// {orderGroups.length === 0 && <p className="text-sm text-muted-foreground">هنوز سفارشی ثبت نکردهاید.</p>}
|
||||
|
||||
// <div className="flex flex-col gap-3">
|
||||
// {orderGroups.map((group) => (
|
||||
// <Link
|
||||
// key={group.uuid}
|
||||
// href={`/order-groups/${group.uuid}`}
|
||||
// className="flex flex-col gap-2 rounded-2xl border border-border p-4"
|
||||
// >
|
||||
// <div className="flex justify-between text-sm text-muted-foreground">
|
||||
// <span>{new Date(group.created_at).toLocaleDateString("fa-IR")}</span>
|
||||
// <span>{PAYMENT_STATUS_LABELS_FA[group.payment_status]}</span>
|
||||
// </div>
|
||||
// <div className="flex flex-wrap gap-1 text-sm">
|
||||
// {group.orders.map((order) => (
|
||||
// <span key={order.uuid} className="rounded-full bg-muted px-2 py-0.5">
|
||||
// {order.store.name}
|
||||
// </span>
|
||||
// ))}
|
||||
// </div>
|
||||
// <div className="flex justify-between font-semibold">
|
||||
// <span>مجموع</span>
|
||||
// <span>{group.total_amount.toLocaleString("fa-IR")} تومان</span>
|
||||
// </div>
|
||||
// </Link>
|
||||
// ))}
|
||||
// </div>
|
||||
// </main>
|
||||
// );
|
||||
}
|
||||
return (
|
||||
<section className="p-4 space-y-3">
|
||||
{data.map((group) => (
|
||||
<OrderItem key={group.uuid} orderGroup={group} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +1,29 @@
|
|||
import OrderItems from '@/components/orders/order-items'
|
||||
import OrderPaymentSticky from '@/components/orders/order-payment-sticky'
|
||||
import OrderStepperStatus from '@/components/orders/order-stepper-status'
|
||||
import OrderTimeAddressInfoAlert from '@/components/orders/order-time-address-info-alert'
|
||||
import React from 'react'
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getOrderGroup } from "@/lib/api/orders";
|
||||
import { OrderDetailProvider } from "@/lib/providers/order-detail-provider";
|
||||
import OrderDetailContent from "@/components/orders/order-detail-content";
|
||||
|
||||
const OrderDetail = () => {
|
||||
return <section className='relative'>
|
||||
|
||||
<h2 className="w-full p-3 px-4 bg-primary-action text-primary-foreground text-md font-medium">
|
||||
در حال آمادهسازی
|
||||
</h2>
|
||||
<OrderStepperStatus currentStep={2} />
|
||||
<OrderTimeAddressInfoAlert />
|
||||
<OrderItems />
|
||||
<OrderPaymentSticky />
|
||||
|
||||
|
||||
</section>
|
||||
interface Props {
|
||||
params: Promise<{ orderId: string }>;
|
||||
}
|
||||
|
||||
export default OrderDetail
|
||||
export default async function OrderDetailPage({ params }: Props) {
|
||||
await requireSession("/login");
|
||||
|
||||
const { orderId } = await params;
|
||||
|
||||
let orderGroup = null;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
orderGroup = await getOrderGroup(orderId);
|
||||
} catch (e: any) {
|
||||
error = e?.message ?? "خطا در دریافت اطلاعات سفارش";
|
||||
}
|
||||
|
||||
return (
|
||||
<OrderDetailProvider order={orderGroup} error={error}>
|
||||
<OrderDetailContent />
|
||||
</OrderDetailProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +1,30 @@
|
|||
import OrderHeader from '@/components/orders/order-header';
|
||||
import React from 'react'
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getOrderGroups } from "@/lib/api/orders";
|
||||
import { OrdersProvider } from "@/lib/providers/orders-provider";
|
||||
|
||||
interface OrderLayoutInterface {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const OrderLayout = async ({ children }: OrderLayoutInterface) => {
|
||||
return <main className='max-w-5xl w-full mx-auto p-0'>
|
||||
<OrderHeader />
|
||||
{children}
|
||||
</main>
|
||||
}
|
||||
export default async function OrderLayout({ children }: OrderLayoutInterface) {
|
||||
await requireSession("/login");
|
||||
|
||||
export default OrderLayout
|
||||
let orderGroups: Awaited<ReturnType<typeof getOrderGroups>> | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
orderGroups = await getOrderGroups();
|
||||
} catch (e: any) {
|
||||
error = e?.message ?? "خطا در دریافت سفارشها";
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl w-full mx-auto p-0">
|
||||
<OrdersProvider orderGroups={orderGroups} error={error}>
|
||||
<OrderHeader />
|
||||
{children}
|
||||
</OrdersProvider>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
240
src/components/customer/address-details-form.tsx
Normal file
240
src/components/customer/address-details-form.tsx
Normal file
|
|
@ -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<AddressLabel>("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<string | null>(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 (
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-6 px-4 pb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-semibold leading-none text-gray-700">آدرس کامل</span>
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-semibold leading-none text-gray-700">عنوان آدرس</span>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
{LABEL_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
onClick={() => selectPreset(preset)}
|
||||
className={`rounded-full border px-4 py-2 text-[13px] font-semibold whitespace-nowrap ${
|
||||
label === preset.value
|
||||
? "border-primary-action bg-blue-bg text-primary-action"
|
||||
: "border-gray-300 bg-white text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{preset.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-semibold leading-none text-gray-700">پلاک</span>
|
||||
<input
|
||||
type="text"
|
||||
value={plaque}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-semibold leading-none text-gray-700">طبقه</span>
|
||||
<input
|
||||
type="text"
|
||||
value={floor}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-semibold leading-none text-gray-700">واحد</span>
|
||||
<input
|
||||
type="text"
|
||||
value={unit}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-semibold leading-none text-gray-700">نام گیرنده</span>
|
||||
<input
|
||||
type="text"
|
||||
value={recipientName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-semibold leading-none text-gray-700">شماره تماس</span>
|
||||
<input
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
value={recipientPhone}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{needsCityPicker && (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-gray-300 bg-gray-50 p-4">
|
||||
<p className="text-[12px] leading-normal text-gray-700">
|
||||
شهر شما مشخص نیست — این فقط لازم است چون هنوز محلهای از صفحه اصلی انتخاب نکردهاید.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
value={manualCityUuid}
|
||||
onChange={(e) => {
|
||||
setManualCityUuid(e.target.value);
|
||||
setManualNeighborhoodUuid("");
|
||||
}}
|
||||
className="h-11 rounded-xl border border-gray-300 bg-white px-3 text-[13px]"
|
||||
required
|
||||
>
|
||||
<option value="">شهر</option>
|
||||
{cities.map((city) => (
|
||||
<option key={city.uuid} value={city.uuid}>
|
||||
{city.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={manualNeighborhoodUuid}
|
||||
onChange={(e) => setManualNeighborhoodUuid(e.target.value)}
|
||||
className="h-11 rounded-xl border border-gray-300 bg-white px-3 text-[13px]"
|
||||
disabled={!manualCityUuid}
|
||||
>
|
||||
<option value="">محله (اختیاری)</option>
|
||||
{filteredNeighborhoods.map((n) => (
|
||||
<option key={n.uuid} value={n.uuid}>
|
||||
{n.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-[13px] text-error">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="mt-auto w-full rounded-2xl bg-primary-action py-4 text-[15px] font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
{isPending ? "در حال ذخیره..." : "ذخیره آدرس"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{addresses.map((address) => (
|
||||
<div key={address.uuid} className="flex items-start justify-between gap-3 rounded-2xl border border-border p-4">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">
|
||||
{LABEL_OPTIONS.find((o) => o.value === address.label)?.label} {address.is_default && "· پیشفرض"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{address.full_address}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{address.recipient_name} — {address.recipient_phone}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" disabled={isPending} onClick={() => handleDelete(address.uuid)}>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{addresses.length === 0 && !isAdding && <p className="text-sm text-muted-foreground">آدرسی ثبت نشده است.</p>}
|
||||
</div>
|
||||
|
||||
{isAdding ? (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border p-4">
|
||||
<div className="flex gap-2">
|
||||
{LABEL_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({ ...f, label: opt.value }))}
|
||||
className={`rounded-full px-3 py-1 text-xs ${form.label === opt.value ? "bg-primary text-primary-foreground" : "border border-border"}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder="آدرس کامل"
|
||||
value={form.full_address}
|
||||
onChange={(e) => setForm((f) => ({ ...f, full_address: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Input placeholder="پلاک" value={form.plaque} onChange={(e) => setForm((f) => ({ ...f, plaque: e.target.value }))} />
|
||||
<Input placeholder="طبقه" value={form.floor} onChange={(e) => setForm((f) => ({ ...f, floor: e.target.value }))} />
|
||||
<Input placeholder="واحد" value={form.unit} onChange={(e) => setForm((f) => ({ ...f, unit: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
value={form.city_uuid}
|
||||
onChange={(e) => setForm((f) => ({ ...f, city_uuid: e.target.value, neighborhood_uuid: "" }))}
|
||||
className="h-11 rounded-xl border border-border bg-background px-3 text-sm"
|
||||
required
|
||||
>
|
||||
<option value="">شهر</option>
|
||||
{cities.map((city) => (
|
||||
<option key={city.uuid} value={city.uuid}>
|
||||
{city.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={form.neighborhood_uuid}
|
||||
onChange={(e) => setForm((f) => ({ ...f, neighborhood_uuid: e.target.value }))}
|
||||
className="h-11 rounded-xl border border-border bg-background px-3 text-sm"
|
||||
disabled={!form.city_uuid}
|
||||
>
|
||||
<option value="">محله (اختیاری)</option>
|
||||
{filteredNeighborhoods.map((n) => (
|
||||
<option key={n.uuid} value={n.uuid}>
|
||||
{n.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder="نام گیرنده"
|
||||
value={form.recipient_name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, recipient_name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
placeholder="09123456789"
|
||||
value={form.recipient_phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, recipient_phone: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_default}
|
||||
onChange={(e) => setForm((f) => ({ ...f, is_default: e.target.checked }))}
|
||||
/>
|
||||
آدرس پیشفرض
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "در حال ثبت..." : "ثبت آدرس"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setIsAdding(false)}>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setIsAdding(true)}>
|
||||
+ افزودن آدرس جدید
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/components/customer/address-selector.tsx
Normal file
106
src/components/customer/address-selector.tsx
Normal file
|
|
@ -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<AddressLabel, string> = {
|
||||
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<string | null>(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 (
|
||||
<div className="flex flex-1 flex-col gap-6 px-4 pb-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
{addresses.map((address) => {
|
||||
const isSelected = address.uuid === selectedUuid;
|
||||
return (
|
||||
<button
|
||||
key={address.uuid}
|
||||
type="button"
|
||||
onClick={() => setSelectedUuid(address.uuid)}
|
||||
className={`flex flex-col gap-1 rounded-2xl border p-4 text-right ${
|
||||
isSelected ? "border-primary-action" : "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{/* DOM order intentionally mirrors the screenshot's left→right layout —
|
||||
see the inherited dir="rtl" note on the /cart page. */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[14px] font-bold leading-none text-foreground">
|
||||
{address.title || ADDRESS_LABEL_FA[address.label]}
|
||||
</span>
|
||||
<span
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${
|
||||
isSelected ? "border-primary-action" : "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{isSelected && <span className="h-2.5 w-2.5 rounded-full bg-primary-action" />}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] leading-normal text-gray-600">{address.full_address}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{addresses.length === 0 && (
|
||||
<p className="py-4 text-center text-[13px] text-gray-600">هنوز آدرسی ثبت نکردهاید.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(next ? `/addresses/map?next=${encodeURIComponent(next)}` : "/addresses/map")}
|
||||
className="rounded-2xl border border-primary-action py-4 text-[14px] font-bold text-primary-action"
|
||||
>
|
||||
افزودن آدرس جدید
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[13px] text-error">{error}</p>}
|
||||
|
||||
{addresses.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={isPending}
|
||||
className="mt-auto w-full rounded-2xl bg-primary-action py-4 text-[15px] font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
{isPending ? "در حال ذخیره..." : "تأیید"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/customer/checkout-address.tsx
Normal file
73
src/components/customer/checkout-address.tsx
Normal file
|
|
@ -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<AddressLabel, string> = {
|
||||
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 (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-[15px] font-bold leading-none text-foreground">
|
||||
آدرس تحویل
|
||||
</h2>
|
||||
|
||||
{address ? (
|
||||
<div className="flex justify-between gap-3 rounded-2xl border border-gray-300 p-4">
|
||||
{/* DOM order intentionally mirrors the screenshot's left→right layout — see the
|
||||
inherited dir="rtl" note on the /cart page. */}
|
||||
<div className="flex flex-col justify-start items-start gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="h-4 w-4 text-primary-action" />
|
||||
<span className="text-[14px] font-bold leading-none text-foreground">
|
||||
{address.title || ADDRESS_LABEL_FA[address.label]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-end text-[13px] leading-normal text-gray-600">
|
||||
{address.full_address}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/addresses?next=${next}`}
|
||||
className="text-[13px] font-semibold leading-none text-primary-action"
|
||||
>
|
||||
تغییر آدرس
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-gray-300 p-6 text-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
|
||||
<MapPin className="h-5 w-5 text-gray-500" />
|
||||
</span>
|
||||
<p className="text-[13px] leading-normal text-gray-600">
|
||||
هنوز آدرسی ثبت نکردهاید. برای ادامه سفارش، لطفاً یک آدرس اضافه
|
||||
کنید.
|
||||
</p>
|
||||
<Link href={`/addresses/map?next=${next}`} className="w-full">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-xl bg-primary-action py-3 text-[14px] font-bold text-white"
|
||||
>
|
||||
افزودن آدرس
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<PaymentMethod, string> = {
|
||||
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<DeliveryType>("express");
|
||||
const [scheduledAt, setScheduledAt] = useState("");
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("cash_on_delivery");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">برای ثبت سفارش ابتدا یک آدرس اضافه کنید.</p>
|
||||
<Link href="/addresses">
|
||||
<Button>افزودن آدرس</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">آدرس تحویل</h2>
|
||||
{addresses.map((address) => (
|
||||
<label
|
||||
key={address.uuid}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-2xl border p-3 text-sm ${addressUuid === address.uuid ? "border-primary" : "border-border"}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="address"
|
||||
checked={addressUuid === address.uuid}
|
||||
onChange={() => setAddressUuid(address.uuid)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">{address.title || address.full_address}</span>
|
||||
<span className="block text-muted-foreground">{address.full_address}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<Link href="/addresses" className="text-xs text-primary underline underline-offset-4">
|
||||
مدیریت آدرسها
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">زمان ارسال</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeliveryType("express")}
|
||||
className={`flex-1 rounded-xl border px-3 py-2 text-sm ${deliveryType === "express" ? "border-primary bg-primary/10" : "border-border"}`}
|
||||
>
|
||||
ارسال فوری
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeliveryType("scheduled")}
|
||||
className={`flex-1 rounded-xl border px-3 py-2 text-sm ${deliveryType === "scheduled" ? "border-primary bg-primary/10" : "border-border"}`}
|
||||
>
|
||||
زمانبندی شده
|
||||
</button>
|
||||
</div>
|
||||
{deliveryType === "scheduled" && (
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="h-11 rounded-xl border border-border bg-background px-3 text-sm"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">روش پرداخت</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(Object.keys(PAYMENT_LABELS_FA) as PaymentMethod[]).map((method) => (
|
||||
<label
|
||||
key={method}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-2xl border p-3 text-sm ${paymentMethod === method ? "border-primary" : "border-border"}`}
|
||||
>
|
||||
<input type="radio" name="payment" checked={paymentMethod === method} onChange={() => setPaymentMethod(method)} />
|
||||
{PAYMENT_LABELS_FA[method]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">توضیحات (اختیاری)</h2>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{error && <p className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between rounded-2xl border border-border p-4 font-semibold">
|
||||
<span>مبلغ قابل پرداخت</span>
|
||||
<span>{cart.grand_total.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="lg" disabled={isPending}>
|
||||
{isPending ? "در حال ثبت سفارش..." : "ثبت نهایی سفارش"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -94,9 +94,12 @@ export function CheckoutFulfillment({ group }: { group: CartGroup }) {
|
|||
</section>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-md flex-col items-center bg-white px-4 pt-4 pb-6 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
|
||||
{/* Pickup has its own confirm+pay screen (no address needed); delivery still
|
||||
goes to the general /checkout form until that flow's design is provided. */}
|
||||
<Link href={pickup ? `/checkout/${group.store.uuid}/confirm` : "/checkout"} className="w-full">
|
||||
{/* Same confirm+pay screen either way — it shows the address section only for
|
||||
delivery, and folds the delivery fee into the total only for delivery. */}
|
||||
<Link
|
||||
href={`/checkout/${group.store.uuid}/confirm?fulfillment=${pickup ? "pickup" : "delivery"}`}
|
||||
className="w-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-2xl bg-primary-action py-4 text-[15px] font-bold text-white"
|
||||
|
|
|
|||
|
|
@ -12,14 +12,22 @@ const PAYMENT_OPTIONS: { value: PaymentMethod; label: string }[] = [
|
|||
{ value: "cash_on_delivery", label: "پرداخت در محل" },
|
||||
];
|
||||
|
||||
export function CheckoutPayment({ group, addresses }: { group: CartGroup; addresses: Address[] }) {
|
||||
export function CheckoutPayment({
|
||||
group,
|
||||
addresses,
|
||||
isDelivery,
|
||||
}: {
|
||||
group: CartGroup;
|
||||
addresses: Address[];
|
||||
isDelivery: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [method, setMethod] = useState<PaymentMethod>("wallet");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Pickup flow — no delivery fee, matches the fulfillment screen's "دریافت حضوری" total.
|
||||
const payable = group.items_subtotal;
|
||||
// Pickup — no delivery fee, matches the fulfillment screen's "دریافت حضوری" total.
|
||||
const payable = group.items_subtotal + (isDelivery ? group.delivery_fee : 0);
|
||||
|
||||
function handlePay() {
|
||||
setError(null);
|
||||
|
|
@ -29,14 +37,16 @@ export function CheckoutPayment({ group, addresses }: { group: CartGroup; addres
|
|||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
// submitCheckout processes the whole cart at once (splits into per-store orders
|
||||
// server-side), not just this store — see PageProps note on the confirm page.
|
||||
const result = await submitCheckout({
|
||||
address_uuid: addressUuid,
|
||||
delivery_type: "express",
|
||||
scheduled_at: null,
|
||||
payment_method: method,
|
||||
notes: "",
|
||||
// Scopes this checkout to just this store's cart group — without this,
|
||||
// the backend checked out the entire cart regardless of which store's
|
||||
// "پرداخت" button was pressed.
|
||||
store_uuids: [group.store.uuid],
|
||||
});
|
||||
if (result.ok) {
|
||||
router.push(`/order-groups/${result.data.uuid}`);
|
||||
|
|
|
|||
98
src/components/location/address-map-leaflet.tsx
Normal file
98
src/components/location/address-map-leaflet.tsx
Normal file
|
|
@ -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 <Marker>.
|
||||
*/
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={locating}
|
||||
aria-label="موقعیت فعلی من"
|
||||
className="absolute bottom-4 left-4 z-[1000] flex h-11 w-11 items-center justify-center rounded-full border-0 bg-white text-primary-action shadow-[0_2px_10px_-2px_rgba(0,0,0,0.2)] disabled:opacity-40"
|
||||
>
|
||||
<LocateFixed className={`h-5 w-5 ${locating ? "animate-pulse" : ""}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddressMapLeaflet({
|
||||
initialCenter,
|
||||
onCenterChange,
|
||||
}: {
|
||||
initialCenter: [number, number];
|
||||
onCenterChange: (lat: number, lng: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<MapContainer
|
||||
center={initialCenter}
|
||||
zoom={15}
|
||||
scrollWheelZoom={true}
|
||||
zoomControl={false}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TileLayer
|
||||
attribution="© Addwin Maps"
|
||||
url={ADDWIN_TILE_URL}
|
||||
maxZoom={19}
|
||||
minZoom={8}
|
||||
/>
|
||||
<ZoomControl position="topleft" />
|
||||
<CenterTracker onMove={onCenterChange} />
|
||||
<RecenterButton />
|
||||
</MapContainer>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="pointer-events-none absolute inset-0 z-[1000] flex items-center justify-center">
|
||||
<div className="-translate-y-full">
|
||||
<svg width="36" height="46" viewBox="0 0 36 46" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M18 0C8.06 0 0 8.06 0 18c0 13.5 18 28 18 28s18-14.5 18-28C36 8.06 27.94 0 18 0Z"
|
||||
fill="#F0642D"
|
||||
/>
|
||||
<circle cx="18" cy="18" r="7" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/location/address-map-picker.tsx
Normal file
73
src/components/location/address-map-picker.tsx
Normal file
|
|
@ -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: () => <div className="h-full w-full animate-pulse bg-gray-100" />,
|
||||
},
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<div className="flex min-h-full flex-1 flex-col">
|
||||
<div className="h-[55vh] w-full shrink-0 overflow-hidden">
|
||||
<AddressMapLeaflet initialCenter={center} onCenterChange={(lat, lng) => setCenter([lat, lng])} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[12px] leading-none text-gray-600">موقعیت انتخابی</span>
|
||||
{/* 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. */}
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={!address.trim()}
|
||||
className="mt-auto w-full rounded-2xl bg-primary-action py-4 text-[15px] font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
تأیید
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/orders/order-detail-content.tsx
Normal file
63
src/components/orders/order-detail-content.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { useOrderDetail } from "@/lib/providers/order-detail-provider";
|
||||
import OrderStepperStatus from "./order-stepper-status";
|
||||
import OrderTimeAddressInfoAlert from "./order-time-address-info-alert";
|
||||
import OrderItems from "./order-items";
|
||||
import OrderPaymentSticky from "./order-payment-sticky";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
placed: "ثبت شد",
|
||||
preparing: "در حال آمادهسازی",
|
||||
ready_to_ship: "آماده ارسال",
|
||||
handed_to_courier: "ارسال شد",
|
||||
delivered: "تحویل داده شد",
|
||||
cancelled: "لغو شد",
|
||||
};
|
||||
|
||||
export default function OrderDetailContent() {
|
||||
const { data: orderGroup, error, isLoading } = useOrderDetail();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center text-gray-500">در حال بارگذاری...</div>;
|
||||
}
|
||||
|
||||
if (error || !orderGroup) {
|
||||
return (
|
||||
<div className="p-8 text-center text-destructive">
|
||||
{error ?? "سفارش یافت نشد"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// وضعیت اصلی رو از اولین سفارش میگیریم (یا میتونی منطق پیچیدهتری بذاری)
|
||||
const mainStatus = orderGroup.orders[0]?.status ?? "placed";
|
||||
|
||||
const statusToStep: Record<string, number> = {
|
||||
placed: 0,
|
||||
preparing: 2,
|
||||
ready_to_ship: 3,
|
||||
handed_to_courier: 3,
|
||||
delivered: 4,
|
||||
cancelled: -1,
|
||||
};
|
||||
|
||||
const currentStep = statusToStep[mainStatus] ?? 0;
|
||||
const statusLabel = STATUS_LABELS[mainStatus] ?? mainStatus;
|
||||
|
||||
return (
|
||||
<section className="relative pb-32">
|
||||
<h2 className="w-full p-3 px-4 bg-primary-action text-primary-foreground text-md font-medium">
|
||||
{statusLabel}
|
||||
</h2>
|
||||
|
||||
{mainStatus !== "cancelled" && (
|
||||
<OrderStepperStatus currentStep={currentStep} />
|
||||
)}
|
||||
|
||||
<OrderTimeAddressInfoAlert order={orderGroup} />
|
||||
<OrderItems order={orderGroup} />
|
||||
<OrderPaymentSticky order={orderGroup} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,95 +1,126 @@
|
|||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
import type { OrderGroup } from "@/types/api";
|
||||
import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status"; // اگه داری
|
||||
|
||||
const OrderItem = () => {
|
||||
interface OrderItemProps {
|
||||
orderGroup: OrderGroup;
|
||||
}
|
||||
|
||||
const OrderItem = ({ orderGroup }: OrderItemProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const goToOrderDetail = () => router.push(`/orders/uuid`);
|
||||
|
||||
const statusClasses: Record<string, string> = {
|
||||
'ارسال شد': 'bg-teal-bg text-primary-action',
|
||||
'در حال آمادهسازی': 'bg-[#E5F3FF] text-[#007BFF]',
|
||||
'در انتظار تأیید': 'bg-[#FFF4E5] text-[#E67E22]',
|
||||
'آماده تحویل حضوری': 'bg-[#EBE5FF] text-[#734DBF]',
|
||||
'لغو شد': 'bg-[#FCE8E8] text-error',
|
||||
'تحویل داده شد': 'bg-emerald-bg text-success',
|
||||
const goToOrderDetail = () => {
|
||||
router.push(`/orders/${orderGroup.uuid}`); // یا مسیر واقعی جزئیات
|
||||
};
|
||||
|
||||
const order = {
|
||||
id: 1,
|
||||
restaurant: 'فستفود برگر',
|
||||
description: 'ساندویچ + نوشیدنی',
|
||||
status: 'آماده تحویل حضوری',
|
||||
time: 'امروز، 11:30',
|
||||
itemsCount: 3,
|
||||
totalPrice: 45000,
|
||||
images: Array.from({ length: 5 }, (_, i) => ({
|
||||
id: i,
|
||||
src: `https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQqIof_DOo6I5-oOLJ1V9_HPfSudXfhewy_5C5Ry7YOO-UXSZ1jvK7-0hw&s=10`,
|
||||
alt: `product-${i}`,
|
||||
})),
|
||||
// وضعیت کلی (از اولین سفارش یا payment_status)
|
||||
const mainOrder = orderGroup.orders[0];
|
||||
const status = mainOrder?.status ?? "placed";
|
||||
|
||||
// مپ وضعیت به فارسی و کلاس
|
||||
const statusMap: Record<string, { label: string; className: string }> = {
|
||||
placed: { label: "ثبت شده", className: "bg-[#FFF4E5] text-[#E67E22]" },
|
||||
preparing: { label: "در حال آمادهسازی", className: "bg-[#E5F3FF] text-[#007BFF]" },
|
||||
ready_to_ship: { label: "آماده ارسال", className: "bg-[#EBE5FF] text-[#734DBF]" },
|
||||
handed_to_courier: { label: "ارسال شد", className: "bg-teal-bg text-primary-action" },
|
||||
delivered: { label: "تحویل داده شد", className: "bg-emerald-bg text-success" },
|
||||
cancelled: { label: "لغو شد", className: "bg-[#FCE8E8] text-error" },
|
||||
};
|
||||
|
||||
const currentStatus = statusMap[status] ?? { label: status, className: "bg-gray-100 text-gray-700" };
|
||||
|
||||
// نام فروشگاهها
|
||||
const storeNames = orderGroup.orders.map(o => o.store.name).join(" + ");
|
||||
|
||||
// تعداد کل کالاها
|
||||
const totalItems = orderGroup.orders.reduce((sum, order) => {
|
||||
return sum + order.items.reduce((s, item) => s + item.quantity, 0);
|
||||
}, 0);
|
||||
|
||||
// تصاویر محصولات (حداکثر ۵ تا)
|
||||
const images = orderGroup.orders
|
||||
.flatMap(order => order.items)
|
||||
.slice(0, 5)
|
||||
.map((item, index) => ({
|
||||
id: index,
|
||||
src: "", // اگر تصویر محصول داری اینجا بذار، فعلاً خالی
|
||||
alt: item.product_name_snapshot,
|
||||
}));
|
||||
|
||||
// تاریخ و ساعت
|
||||
const createdDate = new Date(orderGroup.created_at);
|
||||
const timeText = createdDate.toLocaleDateString("fa-IR", {
|
||||
weekday: "long",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-2xl min-h-32 border border-gray-300 bg-background p-4 mb-2">
|
||||
<div className="flex items-center justify-between w-full gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="size-12 rounded-full bg-gradient-to-br from-primary-action to-primary flex items-center justify-center text-primary-foreground font-bold text-lg">
|
||||
{order.restaurant.charAt(0)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<h2 className="text-md font-bold text-gray-800">{order.restaurant}</h2>
|
||||
<p className="text-sm text-gray-600">{order.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`flex items-center justify-center px-3 py-1 rounded-full text-xs font-bold ${statusClasses[order.status]
|
||||
}`}
|
||||
>
|
||||
{order.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-gray-200 my-4" />
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 w-full">
|
||||
<div className="col-span-2 flex items-center gap-1 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
|
||||
{order.images.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="size-10 flex-shrink-0 rounded-lg bg-gray-200 overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={img.src}
|
||||
alt={img.alt}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="w-full rounded-2xl min-h-32 border border-gray-300 bg-background p-4 mb-2">
|
||||
{/* هدر */}
|
||||
<div className="flex items-center justify-between w-full gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="size-12 rounded-full bg-gradient-to-br from-primary-action to-primary flex items-center justify-center text-primary-foreground font-bold text-lg overflow-hidden">
|
||||
{mainOrder?.store.logo_url ? (
|
||||
<img src={mainOrder.store.logo_url} alt={mainOrder.store.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
mainOrder?.store.name?.charAt(0) ?? "س"
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-col items-start">
|
||||
<h2 className="text-md font-bold text-gray-800 line-clamp-1">{storeNames}</h2>
|
||||
<p className="text-sm text-gray-600 line-clamp-1">
|
||||
{orderGroup.orders.length > 1
|
||||
? `${orderGroup.orders.length} فروشگاه`
|
||||
: mainOrder?.items[0]?.product_name_snapshot ?? "سفارش"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={`flex items-center justify-center px-3 py-1 rounded-full text-xs font-bold ${currentStatus.className}`}>
|
||||
{currentStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex flex-col items-end gap-1">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-600">
|
||||
<span>{order.time}</span>
|
||||
<div className="w-full h-px bg-gray-200 my-4" />
|
||||
|
||||
{/* پایین کارت */}
|
||||
<div className="grid grid-cols-3 gap-3 w-full">
|
||||
<div className="col-span-2 flex items-center gap-1 overflow-x-auto pb-2">
|
||||
{images.length > 0 ? (
|
||||
images.map((img) => (
|
||||
<div key={img.id} className="size-10 flex-shrink-0 rounded-lg bg-gray-200 overflow-hidden">
|
||||
{img.src ? (
|
||||
<img src={img.src} alt={img.alt} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-xs text-gray-400">
|
||||
{img.alt.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">بدون تصویر</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-primary-action text-sm text-left font-bold">
|
||||
<span>
|
||||
{order.itemsCount} کالا • {order.totalPrice.toLocaleString('fa-IR')} تومان
|
||||
</span>
|
||||
|
||||
<div className="col-span-1 flex flex-col items-end gap-1">
|
||||
<div className="text-xs text-gray-600">{timeText}</div>
|
||||
<div className="text-primary-action text-sm font-bold text-left">
|
||||
{totalItems} کالا • {orderGroup.total_amount.toLocaleString("fa-IR")} تومان
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full mt-6 rounded-xl h-12 bg-primary-action text-primary-foreground cursor-pointer hover:bg-primary-action/90 transition-colors font-medium"
|
||||
onClick={goToOrderDetail}
|
||||
>
|
||||
مشاهده وضعیت
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full mt-8 rounded-xl h-12 bg-primary-action text-primary-foreground cursor-pointer hover:bg-primary-action/90 transition-colors font-medium"
|
||||
onClick={goToOrderDetail}
|
||||
>
|
||||
مشاهده وضعیت
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,45 @@
|
|||
import React from 'react'
|
||||
"use client";
|
||||
|
||||
const OrderItems = () => {
|
||||
return (
|
||||
<div className="mt-5 grid grid-cols-1 gap-2 mx-8">
|
||||
<h1 className="text-lg font-bold mb-3">اقلام سفارش</h1>
|
||||
import type { OrderGroup } from "@/types/api";
|
||||
import {formatTomanPrice} from "@/lib/utils/priceUtils";
|
||||
|
||||
{/* order items */}
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex gap-3">
|
||||
<span className="font-semibold text-sm">نان سنگک</span>
|
||||
<span className="text-xs font-medium text-gray-700">10 عدد</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold">۱۲،۰۰۰ تومان</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
interface Props {
|
||||
order: OrderGroup;
|
||||
}
|
||||
|
||||
export default OrderItems
|
||||
const OrderItems = ({ order }: Props) => {
|
||||
// همه آیتمهای همه فروشگاهها رو جمع میکنیم
|
||||
const allItems = order.orders.flatMap((o) =>
|
||||
o.items.map((item) => ({
|
||||
...item,
|
||||
storeName: o.store.name,
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-5 grid grid-cols-1 gap-3 mx-8">
|
||||
<h1 className="text-lg font-bold mb-3">اقلام سفارش</h1>
|
||||
|
||||
{allItems.map((item) => (
|
||||
<div key={item.uuid} className="flex items-center justify-between w-full">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="font-semibold text-sm">{item.product_name_snapshot}</span>
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
{item.quantity.toLocaleString("fa-IR")} عدد
|
||||
</span>
|
||||
</div>
|
||||
{order.orders.length > 1 && (
|
||||
<span className="text-xs text-gray-500">{item.storeName}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-bold">
|
||||
{formatTomanPrice(item.line_total)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderItems;
|
||||
|
|
@ -1,26 +1,40 @@
|
|||
const OrderPaymentSticky = () => {
|
||||
"use client";
|
||||
|
||||
import type { OrderGroup } from "@/types/api";
|
||||
import {formatTomanPrice} from "@/lib/utils/priceUtils";
|
||||
|
||||
interface Props {
|
||||
order: OrderGroup;
|
||||
}
|
||||
|
||||
const OrderPaymentSticky = ({ order }: Props) => {
|
||||
const paymentMethodLabel: Record<string, string> = {
|
||||
wallet: "پرداخت از کیف پول",
|
||||
online: "پرداخت آنلاین",
|
||||
cash_on_delivery: "پرداخت در محل",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-background border-t border-gray-100 w-full">
|
||||
<div className="px-5 pt-5 pb-3 max-w-sm mx-auto w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-lg font-medium text-foreground">
|
||||
مبلغ پرداختی
|
||||
</span>
|
||||
<span className="text-lg font-medium text-foreground">مبلغ پرداختی</span>
|
||||
<span className="text-xs font-normal text-gray-700">
|
||||
پرداخت از کیف پول
|
||||
</span>
|
||||
{paymentMethodLabel[order.payment_method] ?? order.payment_method}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-medium text-foreground">
|
||||
1000000 تومان
|
||||
</span>
|
||||
{formatTomanPrice(order.total_amount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* فعلاً فقط نمایش، بعداً میتونی دکمه لغو اضافه کنی */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full h-12 rounded-xl border border-error bg-background text-error text-[15px] font-medium active:scale-[0.98] transition-transform cursor-pointer"
|
||||
>
|
||||
پرداخت از کیف پول
|
||||
لغو سفارش
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -28,7 +42,7 @@ const OrderPaymentSticky = () => {
|
|||
<div className="w-[134px] h-[5px] rounded-full bg-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderPaymentSticky
|
||||
export default OrderPaymentSticky;
|
||||
|
|
@ -1,23 +1,35 @@
|
|||
import { ClockIcon, MapPinIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
"use client";
|
||||
|
||||
const OrderTimeAddressInfoAlert = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-start p-4 bg-teal-bg rounded-2xl my-4 mx-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="size-5 text-primary-action" />
|
||||
<p className="text-md font-bold text-primary-action">
|
||||
زمان تخمینی تحویل: ۳۰–۴۵ دقیقه دیگر
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPinIcon className="size-5 text-primary-action" />
|
||||
<p className="text-sm text-gray-700">
|
||||
نارمک، خیابان شهید دستغیب، پلاک ۱۲، واحد ۳
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
import { ClockIcon, MapPinIcon } from "lucide-react";
|
||||
import type { OrderGroup } from "@/types/api";
|
||||
|
||||
interface Props {
|
||||
order: OrderGroup;
|
||||
}
|
||||
|
||||
export default OrderTimeAddressInfoAlert
|
||||
const OrderTimeAddressInfoAlert = ({ order }: Props) => {
|
||||
// زمان تخمینی (فعلاً ساده)
|
||||
const estimatedTime =
|
||||
order.delivery_type === "express"
|
||||
? "۳۰–۴۵ دقیقه دیگر"
|
||||
: order.scheduled_at
|
||||
? new Date(order.scheduled_at).toLocaleString("fa-IR")
|
||||
: "زمان مشخص نشده";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-start p-4 bg-teal-bg rounded-2xl my-4 mx-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="size-5 text-primary-action" />
|
||||
<p className="text-md font-bold text-primary-action">
|
||||
زمان تخمینی تحویل: {estimatedTime}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPinIcon className="size-5 text-primary-action" />
|
||||
<p className="text-sm text-gray-700">{order.full_address}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderTimeAddressInfoAlert;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { winofyFetch } from "./winofy";
|
||||
import { callApi, type ActionResult } from "./result";
|
||||
import type { DeliveryType, OrderGroup, PaymentMethod } from "@/types/api";
|
||||
|
|
@ -10,8 +11,21 @@ export interface CheckoutInput {
|
|||
scheduled_at: string | null;
|
||||
payment_method: PaymentMethod;
|
||||
notes: string;
|
||||
/**
|
||||
* Which store-cart-groups to check out, by store uuid. Required (not optional) —
|
||||
* every caller must be explicit about scope now; there's no more implicit
|
||||
* "everything in the cart" default. This is what fixes the bug where paying for
|
||||
* one store's order submitted every store's order: previously nothing on this
|
||||
* request identified which store(s) were intended, so the backend checked out
|
||||
* the whole cart every time.
|
||||
*/
|
||||
store_uuids: string[];
|
||||
}
|
||||
|
||||
export async function submitCheckout(input: CheckoutInput): Promise<ActionResult<OrderGroup>> {
|
||||
return callApi(() => winofyFetch<OrderGroup>("/v1/checkout/", { method: "POST", body: input }));
|
||||
const result = await callApi(() => winofyFetch<OrderGroup>("/v1/checkout/", { method: "POST", body: input }));
|
||||
// Only the scoped store(s)' items are removed server-side now — the cart page
|
||||
// (and the sticky-cart-bar badge) needs a fresh fetch to stop showing them.
|
||||
if (result.ok) revalidatePath("/cart");
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import "server-only";
|
||||
import { getValidSession } from "@/lib/auth/session";
|
||||
import { ApiError, type WinofyErrorDetails } from "./errors";
|
||||
import { logWinofyRequest, logWinofyResponse } from "./dev-logger";
|
||||
import { logApiRequest, logApiResponse } from "./dev-logger";
|
||||
|
||||
const BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!;
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ export async function gooyalFetch<T>(path: string, options: GooyalFetchOptions =
|
|||
}
|
||||
}
|
||||
|
||||
logWinofyRequest?.(method, url.toString(), body);
|
||||
logApiRequest("gooyal", method, url.toString(), body);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
...rest,
|
||||
|
|
@ -45,12 +45,12 @@ export async function gooyalFetch<T>(path: string, options: GooyalFetchOptions =
|
|||
});
|
||||
|
||||
if (res.status === 204) {
|
||||
logWinofyResponse?.(method, url.toString(), res.status, null);
|
||||
logApiResponse("gooyal", method, url.toString(), res.status, null);
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
logWinofyResponse?.(method, url.toString(), res.status, data);
|
||||
logApiResponse("gooyal", method, url.toString(), res.status, data);
|
||||
|
||||
if (!res.ok) {
|
||||
const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null;
|
||||
|
|
|
|||
42
src/lib/providers/order-detail-provider.tsx
Normal file
42
src/lib/providers/order-detail-provider.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import {createContext, useContext, type ReactNode} from "react";
|
||||
import type {Order, OrderGroup} from "@/types/api";
|
||||
|
||||
type OrderDetailContextValue = {
|
||||
data: OrderGroup | null;
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const OrderDetailContext = createContext<OrderDetailContextValue | undefined>(undefined);
|
||||
|
||||
export function OrderDetailProvider({
|
||||
children,
|
||||
order,
|
||||
error = null,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
order: OrderGroup | null;
|
||||
error?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<OrderDetailContext.Provider
|
||||
value={{
|
||||
data: order,
|
||||
error,
|
||||
isLoading: false,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</OrderDetailContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOrderDetail() {
|
||||
const ctx = useContext(OrderDetailContext);
|
||||
if (ctx === undefined) {
|
||||
throw new Error("useOrderDetail must be used within OrderDetailProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
42
src/lib/providers/orders-provider.tsx
Normal file
42
src/lib/providers/orders-provider.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { OrderGroup } from "@/types/api";
|
||||
|
||||
type OrdersContextValue = {
|
||||
data: OrderGroup[] | null;
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const OrdersContext = createContext<OrdersContextValue | undefined>(undefined);
|
||||
|
||||
export function OrdersProvider({
|
||||
children,
|
||||
orderGroups,
|
||||
error = null,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
orderGroups: OrderGroup[] | null;
|
||||
error?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<OrdersContext.Provider
|
||||
value={{
|
||||
data: orderGroups,
|
||||
error,
|
||||
isLoading: false,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</OrdersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOrders() {
|
||||
const ctx = useContext(OrdersContext);
|
||||
if (ctx === undefined) {
|
||||
throw new Error("useOrders must be used within OrdersProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue