Merge branch 'develop' into feature/WNPT-737
This commit is contained in:
commit
464f2b34a2
43 changed files with 1628 additions and 441 deletions
215
FRONTEND_GUIDE.md
Normal file
215
FRONTEND_GUIDE.md
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
# Winofy Frontend Guide
|
||||||
|
|
||||||
|
Internal engineering reference for the Winofy customer/seller web frontend. Source comments elsewhere in this repo (`errors.ts`, `proxy.ts`, `lib/location/client.ts`, ...) cite section numbers from this file directly — keep the numbering stable when editing.
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
Winofy is a Persian/Farsi (RTL) hyperlocal multi-vendor marketplace: customers browse nearby stores by neighborhood/city or GPS location, order products, and track deliveries; sellers manage their own storefront, products, and orders. This repo is the web frontend, built on Next.js 16 against two backends:
|
||||||
|
|
||||||
|
- **Winofy API** (Django/DRF) — stores, products, cart, checkout, orders, addresses, reviews. Staging: `https://winofy-staging.winsoo.ir/api`. **No CORS** — every call must happen server-side (Server Component, Server Action, or Route Handler), never directly from the browser.
|
||||||
|
- **Gooyal accounts** — the shared OAuth2 identity provider used across Winsoo group products, for phone + OTP login. Staging: `https://accounts-staging.gooyal.ir`. CORS is open there, so the login form calls it directly from the browser.
|
||||||
|
|
||||||
|
## 2. Tech stack
|
||||||
|
|
||||||
|
- Next.js 16.3.1, App Router, Turbopack, `output: "standalone"`
|
||||||
|
- React 19.2.8 / TypeScript 5 (`strict`)
|
||||||
|
- Tailwind CSS v4 (`@theme inline` token system — see §5)
|
||||||
|
- Radix UI primitives (dialog, select, tabs, toast, checkbox, label) + `class-variance-authority` / `tailwind-merge`, wrapped by the small `cn()`-based component layer in `src/components/ui/`
|
||||||
|
- `jose` — JWE encryption for the session cookie
|
||||||
|
- `lucide-react` — icon set
|
||||||
|
- `@tanstack/react-query` — the provider is wired in (`src/lib/query-provider.tsx` → `src/app/layout.tsx`), but nothing in the app calls `useQuery`/`useMutation` yet; all data currently comes from Server Components. The plumbing exists if you're the first to need client-side fetching.
|
||||||
|
- `zod` — installed, not yet used anywhere. Same note as above.
|
||||||
|
|
||||||
|
## 3. Getting started
|
||||||
|
|
||||||
|
Prereqs: Node 24 (matches the Docker image), npm.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo>
|
||||||
|
cd winofy
|
||||||
|
npm install
|
||||||
|
cp .env.example .env.local # fill in real values — ask a teammate for staging Gooyal credentials
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Scripts (`package.json`):
|
||||||
|
|
||||||
|
| Script | Runs |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | `next dev` (Turbopack) |
|
||||||
|
| `npm run build` | `next build` |
|
||||||
|
| `npm run start` | `next start` (the standalone build) |
|
||||||
|
| `npm run lint` | ESLint, flat config (`eslint-config-next`) |
|
||||||
|
|
||||||
|
No test runner is configured yet.
|
||||||
|
|
||||||
|
## 4. Architecture
|
||||||
|
|
||||||
|
### 4.1 Route structure
|
||||||
|
|
||||||
|
App Router route groups split the app by audience and auth requirement:
|
||||||
|
|
||||||
|
- `(auth)/login` — phone + OTP login (Gooyal)
|
||||||
|
- `(customer)/` — pages without the persistent shop header (home, location onboarding)
|
||||||
|
- `(customer)/(shop)/` — everything with the shop chrome: stores, products, cart, checkout, orders, addresses, profile
|
||||||
|
- `(seller)/seller` — seller-side dashboard (early stage)
|
||||||
|
- `src/proxy.ts` — Next 16's renamed `middleware.ts` (see §4.3)
|
||||||
|
- `src/app/api/*` — same-origin route handlers, used only where the browser needs to call something itself (session probe, logout, the neighborhood-search proxy — see §4.6)
|
||||||
|
|
||||||
|
### 4.2 Winofy API client & error envelope
|
||||||
|
|
||||||
|
All calls to the Winofy API go through `winofyFetch()` in `src/lib/api/winofy.ts`. It:
|
||||||
|
|
||||||
|
- prefixes `WINOFY_API_BASE_URL`, attaches query params, JSON-encodes the body
|
||||||
|
- attaches `Authorization: Bearer <accessToken>` from the session, unless called with `{ auth: false }`
|
||||||
|
- always sets `cache: "no-store"` — nothing from this API is safe to cache past the current request
|
||||||
|
- logs every request/response in dev via `src/lib/api/dev-logger.ts` (pretty-printed, truncated, color-coded by status) — this is your Network tab, since these calls never reach the browser
|
||||||
|
- throws `ApiError` on non-2xx, parsed from the API's error envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"status_code": 400,
|
||||||
|
"status_message": "...",
|
||||||
|
"details": { "message": "...", "error": "...", "timestamp": "..." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`details.message` is either a plain string or a `{ field: string[] }` map of validation errors — `ApiError.fieldErrors` normalizes that for forms.
|
||||||
|
|
||||||
|
Server Actions wrap `winofyFetch` calls in `callApi()` (`src/lib/api/result.ts`), which never throws — it returns a typed `ActionResult<T>` (`{ ok: true, data }` or `{ ok: false, status, message, fieldErrors }`), so client components can branch on failure without try/catch.
|
||||||
|
|
||||||
|
### 4.3 Auth & sessions
|
||||||
|
|
||||||
|
Login is phone number + OTP against Gooyal, via the OAuth2 password grant with `auth_fields=phone_number:otp`. Two client modules exist because Gooyal is called from two different places:
|
||||||
|
|
||||||
|
- `src/lib/auth/gooyal-client.ts` — browser-side (`"use client"`), used by the login/OTP form. Gooyal's CORS is open, so this calls `accounts-staging.gooyal.ir` directly — it does **not** proxy through our own API routes. Uses the `NEXT_PUBLIC_*` env vars (§8).
|
||||||
|
- `src/lib/auth/gooyal.ts` — server-side, used only for refresh-token exchange. Uses the non-public env vars.
|
||||||
|
|
||||||
|
Once tokens come back, `createSession()` (`src/lib/auth/session.ts`) encrypts them into a JWE (`jose`, `alg: dir`, `enc: A256GCM`) and stores it as the `winofy_session` httpOnly cookie — a 30-day cookie lifetime, independent of the much shorter-lived Gooyal access token inside it.
|
||||||
|
|
||||||
|
`getValidSession()` is the actual source of truth for "is this user logged in": it decrypts the cookie, and if the access token is expired (30s skew), transparently refreshes it and re-persists the new session — deduped per-request via React's `cache()`. Prefer this over the raw `getSession()` everywhere.
|
||||||
|
|
||||||
|
`src/proxy.ts` (Next 16 renamed `middleware.ts` → `proxy.ts`; the export is now `export default function proxy(request)`, not `middleware`) does an **optimistic, cookie-presence-only** check against `AUTH_REQUIRED_PREFIXES` and redirects to `/login?next=...` if the cookie is missing entirely. It deliberately does not decrypt or validate the session — that would mean an extra round-trip on every request. A present-but-expired-and-unrefreshable session still reaches the page; that's what `requireSession(path)` is for — call it at the top of any page under an auth-required prefix, before fetching data, so a dead session redirects cleanly instead of the page crashing on an uncaught 401.
|
||||||
|
|
||||||
|
Cookie writes (`createSession`/`deleteSession`) are wrapped in try/catch: Next.js forbids mutating cookies during a plain Server Component render (only Server Actions and Route Handlers may), but `getValidSession()` can legitimately be called from either. When called from a render, a refreshed token is still used for the rest of that request — it just silently fails to persist, and the next request repeats the refresh. That's intentional, not a bug to "fix" by moving the call elsewhere.
|
||||||
|
|
||||||
|
### 4.4 API layer organization
|
||||||
|
|
||||||
|
`src/lib/api/` is organized by feature, one file per resource, all built on `winofyFetch`:
|
||||||
|
|
||||||
|
```
|
||||||
|
addresses.ts cart.ts catalog.ts checkout.ts
|
||||||
|
locations.ts media.ts notifications.ts orders.ts
|
||||||
|
reviews.ts stores.ts result.ts errors.ts
|
||||||
|
winofy.ts dev-logger.ts
|
||||||
|
seller/
|
||||||
|
analytics.ts orders.ts products.ts reviews.ts store.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Mutations (`add/update/remove/create/cancel...`) are Server Actions, called directly from client components as props — no fetch/API-route indirection. Reads are plain async functions called from Server Components. There's no separate "actions" layer — an earlier version split reads/writes into `src/lib/actions/`, and it was folded into these feature files because the split added indirection without a real benefit.
|
||||||
|
|
||||||
|
### 4.5 Data fetching pattern
|
||||||
|
|
||||||
|
Almost everything renders server-side: pages are `async function` Server Components that call `src/lib/api/*` reads directly and pass plain data down as props. This is why requests are invisible in Chrome's Network tab — they never leave the Next.js server process. Two consequences worth knowing before you go looking for a bug in the wrong place:
|
||||||
|
|
||||||
|
- To *see* these requests, read the dev logger output in your terminal (or `next.config.ts`'s `logging.fetches`, which shows URL/status/timing only, no body).
|
||||||
|
- Client-side navigation between pages (e.g. clicking a store card) can show up in the Network tab as just an RSC fetch (`?_rsc=...`). The store-detail data fetch still happened server-side to produce that payload — it's just bundled into that one request instead of a separate visible `GET /stores/:id`.
|
||||||
|
|
||||||
|
Cart/mutation UI (`ProductGridCard`, `ProductPurchasePanel`, etc.) is the exception: those are client components that call Server Actions via `useTransition`, update local state only after the action confirms success, then `router.refresh()` to reconcile with the true server state.
|
||||||
|
|
||||||
|
### 4.6 Location & neighborhoods
|
||||||
|
|
||||||
|
Location is stored client-side as a plain (unencrypted — not sensitive) cookie, `winofy_location`, via `src/lib/location/{client,server,types}.ts`. Two modes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ mode: "geo"; lat; lng }
|
||||||
|
{ mode: "manual"; cityUuid; cityName; neighborhoodUuid; neighborhoodName }
|
||||||
|
```
|
||||||
|
|
||||||
|
The home page (`(customer)/page.tsx`) reads it server-side to filter `getStores()`; the location picker (`location/search`) writes it client-side after a manual neighborhood pick or a successful geolocation read, and can clear it back to "no location selected."
|
||||||
|
|
||||||
|
Neighborhood **search** goes through a same-origin route handler, `src/app/api/locations/neighborhoods/route.ts`, rather than the client calling the Winofy API directly — necessary because the browser can't reach the non-CORS Winofy API itself. That handler also patches a real backend gap (see gap 1 below).
|
||||||
|
|
||||||
|
Known gaps in this area, numbered so future work can reference them:
|
||||||
|
|
||||||
|
1. Backend does not implement server-side neighborhood search filtering — worked around client-of-the-route-handler-side against both `name` and `city.name`.
|
||||||
|
2. The location cookie is 180 days — deliberate, so the L01 "share your location" onboarding screen doesn't refire every session.
|
||||||
|
3. Geolocation errors are surfaced by `GeolocationPositionError.code` (`PERMISSION_DENIED` / `POSITION_UNAVAILABLE` / `TIMEOUT`) with distinct Persian copy per case. macOS's `kCLErrorLocationUnknown` surfaces here as `POSITION_UNAVAILABLE` — that's CoreLocation reporting "no fix yet," not a frontend bug.
|
||||||
|
|
||||||
|
## 5. Design system
|
||||||
|
|
||||||
|
- RTL throughout: `<html dir="rtl" lang="fa">` in `src/app/layout.tsx`. Write layout CSS RTL-first (`text-right`, logical properties) rather than retrofitting an LTR layout.
|
||||||
|
- Font: Vazirmatn (`next/font/google`, `arabic` + `latin` subsets), exposed as the `--font-vazirmatn` CSS variable and mapped to Tailwind's `font-sans`.
|
||||||
|
- All design tokens live in `src/app/globals.css` as plain hex custom properties under `:root`, re-exposed to Tailwind via `@theme inline`. **Tailwind v4's built-in palette (emerald, slate, etc.) is OKLCH internally** — close to, but not bit-for-bit, the hex values in the design spec — so every color that must match a spec exactly is defined as its own token here rather than borrowed from Tailwind's defaults. Adding a new spec color means adding a token here, not reaching for e.g. `emerald-500` directly.
|
||||||
|
- Persian digit handling: `src/lib/format/persian-digits.ts` (`toPersianDigits` / `toLatinDigits`) — needed anywhere user-facing numbers are displayed, or anywhere user-typed numbers (phone, OTP) are parsed, since Persian keyboards type Persian-Arabic digit glyphs that fail plain `\d` regexes.
|
||||||
|
- Iranian week: Saturday is the first day. Convert from JS's `Date.getDay()` (Sunday = 0) with `(jsDay + 1) % 7` wherever a weekday needs to be shown.
|
||||||
|
|
||||||
|
## 6. Docker & deployment
|
||||||
|
|
||||||
|
Multi-stage `Dockerfile`, `output: "standalone"` — the runtime image ships only `server.js`, `.next/static`, and `public/`, runs as a non-root user, listens on `$PORT` (default `3000`).
|
||||||
|
|
||||||
|
The one thing not to get wrong: **`NEXT_PUBLIC_*` vars are baked into the client bundle at *build* time.** Everything else (API base URL, Gooyal secret, session secret) is read at container *runtime* instead, and must never be baked into an image layer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
docker compose --env-file .env.local up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--env-file .env.local` flag is required — without it, Compose looks for a `.env` file (this repo doesn't have one) to fill the `NEXT_PUBLIC_*` build args in `docker-compose.yml`, and you'll silently get a client bundle built with empty values.
|
||||||
|
|
||||||
|
Plain `docker build`/`run` needs the same `NEXT_PUBLIC_*` values passed explicitly as `--build-arg`s (see `README.md`) plus `--env-file .env.local` at `docker run` time for everything else.
|
||||||
|
|
||||||
|
## 7. Known backend gaps
|
||||||
|
|
||||||
|
These are backend-side, not something to "fix" in this repo — flagged so you don't spend time debugging frontend code that's actually correct:
|
||||||
|
|
||||||
|
- **Cart item `PATCH`/`DELETE` return `403 permission_denied`** on staging, for both the per-item endpoint and the collection endpoints, with a token that can otherwise `GET`/`POST` the same cart. Blocks quantity-decrement and remove-from-cart app-wide until the backend/OAuth scope issue is resolved.
|
||||||
|
- **Addresses and order-groups `GET` return `403`** — current OAuth scope doesn't cover them.
|
||||||
|
- **Neighborhood search isn't filtered server-side** — worked around client-side, see §4.6 gap 1.
|
||||||
|
|
||||||
|
## 8. Environment variables
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env.local` and fill in real values — never commit `.env.local`.
|
||||||
|
|
||||||
|
| Variable | Scope | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `WINOFY_API_BASE_URL` | server-only | Base URL of the Winofy Django/DRF API, e.g. `https://winofy-staging.winsoo.ir/api` |
|
||||||
|
| `GOOYAL_ACCOUNTS_BASE_URL` | server-only | Gooyal OAuth base, used for server-side refresh-token calls |
|
||||||
|
| `GOOYAL_CLIENT_ID` | server-only | OAuth client id, server-side calls |
|
||||||
|
| `GOOYAL_CLIENT_SECRET` | server-only | OAuth client secret, server-side calls |
|
||||||
|
| `GOOYAL_OAUTH_SCOPE` | server-only | Space-separated OAuth scope string, server-side calls |
|
||||||
|
| `NEXT_PUBLIC_GOOYAL_ACCOUNTS_BASE_URL` | public (client bundle) | Same Gooyal base, usable from the browser — the login form talks to Gooyal directly |
|
||||||
|
| `NEXT_PUBLIC_GOOYAL_CLIENT_ID` | public (client bundle) | Client id for the browser-side OAuth exchange |
|
||||||
|
| `NEXT_PUBLIC_GOOYAL_CLIENT_SECRET` | public (client bundle) | Intentionally public — the browser must send this in a Basic Auth header to complete the OTP token exchange directly against Gooyal, so it can never actually be kept confidential in a client-side flow. Gooyal's client registration for this app is configured accordingly; treat it like a client id, not a real secret |
|
||||||
|
| `NEXT_PUBLIC_GOOYAL_OAUTH_SCOPE` | public (client bundle) | Scope string for the browser-side OAuth exchange |
|
||||||
|
| `SESSION_SECRET` | server-only | 32-byte base64 key used to encrypt the session cookie (JWE). Generate with `openssl rand -base64 32` |
|
||||||
|
|
||||||
|
Docker note: the `NEXT_PUBLIC_*` values must be supplied as build args (they're compiled into the client bundle), not just runtime env — see §6.
|
||||||
|
|
||||||
|
## 9. Folder structure reference
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├─ app/
|
||||||
|
│ ├─ (auth)/login/
|
||||||
|
│ ├─ (customer)/
|
||||||
|
│ │ ├─ (shop)/
|
||||||
|
│ │ │ ├─ addresses/ cart/ checkout/ orders/
|
||||||
|
│ │ │ ├─ order-groups/[uuid]/ products/[uuid]/ stores/[uuid]/
|
||||||
|
│ │ │ ├─ profile/ layout.tsx
|
||||||
|
│ │ ├─ location/{error,permission,search}/
|
||||||
|
│ │ ├─ page.tsx (home) error.tsx
|
||||||
|
│ ├─ (seller)/seller/
|
||||||
|
│ ├─ api/{auth/{logout,session}, locations/neighborhoods}/route.ts
|
||||||
|
│ └─ layout.tsx globals.css
|
||||||
|
├─ components/
|
||||||
|
│ ├─ auth/ customer/ home/ location/ product/ store/ ui/
|
||||||
|
├─ lib/
|
||||||
|
│ ├─ api/ — §4.2 / §4.4
|
||||||
|
│ ├─ auth/ — §4.3
|
||||||
|
│ ├─ location/ — §4.6
|
||||||
|
│ ├─ format/ validation/ utils/ order-status.ts query-provider.tsx
|
||||||
|
├─ proxy.ts — §4.3
|
||||||
|
└─ types/api.ts
|
||||||
|
```
|
||||||
149
package-lock.json
generated
149
package-lock.json
generated
|
|
@ -81,6 +81,7 @@
|
||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.7",
|
||||||
|
|
@ -290,28 +291,6 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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/@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": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
|
|
@ -683,9 +662,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -702,9 +678,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -721,9 +694,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -740,9 +710,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -759,9 +726,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -778,9 +742,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -797,9 +758,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -816,9 +774,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -835,9 +790,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -860,9 +812,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -885,9 +834,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -910,9 +856,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -935,9 +878,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -960,9 +900,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -985,9 +922,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1010,9 +944,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1280,9 +1211,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1299,9 +1227,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1318,9 +1243,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1337,9 +1259,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2282,9 +2201,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2302,9 +2218,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2322,9 +2235,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2342,9 +2252,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2506,6 +2413,7 @@
|
||||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
|
|
@ -2516,6 +2424,7 @@
|
||||||
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2565,6 +2474,7 @@
|
||||||
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
|
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.67.0",
|
"@typescript-eslint/scope-manager": "8.67.0",
|
||||||
"@typescript-eslint/types": "8.67.0",
|
"@typescript-eslint/types": "8.67.0",
|
||||||
|
|
@ -2921,9 +2831,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2938,9 +2845,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2955,9 +2859,6 @@
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2972,9 +2873,6 @@
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2989,9 +2887,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3006,9 +2901,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3023,9 +2915,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3040,9 +2929,6 @@
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3057,9 +2943,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3074,9 +2957,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3175,6 +3055,7 @@
|
||||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -3530,6 +3411,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.11.12",
|
"baseline-browser-mapping": "^2.11.12",
|
||||||
"caniuse-lite": "^1.0.30001809",
|
"caniuse-lite": "^1.0.30001809",
|
||||||
|
|
@ -4146,6 +4028,7 @@
|
||||||
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
|
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -4331,6 +4214,7 @@
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
|
|
@ -5818,9 +5702,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5842,9 +5723,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5866,9 +5744,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5890,9 +5765,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -6576,6 +6448,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -6585,6 +6458,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|
@ -7361,6 +7235,7 @@
|
||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -7523,6 +7398,7 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -7843,6 +7719,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
import { getCart } from "@/lib/api/cart";
|
import { getCart } from "@/lib/api/cart";
|
||||||
import { requireSession } from "@/lib/auth/session";
|
import { requireSession } from "@/lib/auth/session";
|
||||||
import { CartItemRow } from "@/components/customer/cart-item-row";
|
import { StoreCartCard } from "@/components/customer/store-cart-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export default async function CartPage() {
|
export default async function CartPage() {
|
||||||
await requireSession("/cart");
|
await requireSession("/cart");
|
||||||
const cart = await getCart();
|
const cart = await getCart();
|
||||||
const hasBlockedGroup = cart.groups.some((g) => !g.meets_minimum_order);
|
|
||||||
|
|
||||||
if (cart.groups.length === 0) {
|
if (cart.groups.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -20,55 +20,64 @@ export default async function CartPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalItemLines = cart.groups.reduce(
|
||||||
|
(sum, g) => sum + g.items.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 py-6">
|
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-6 px-4 pt-4 pb-6">
|
||||||
<h1 className="text-lg font-bold">سبد خرید</h1>
|
{/* 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
|
||||||
{cart.groups.map((group) => (
|
right edge and the last at the left. */}
|
||||||
<section key={group.store.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-4">
|
<div className="flex items-center justify-start gap-3">
|
||||||
<Link href={`/stores/${group.store.uuid}`} className="font-medium">
|
<Link href="/" aria-label="بازگشت">
|
||||||
{group.store.name}
|
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{group.items.map((item) => (
|
|
||||||
<CartItemRow key={item.uuid} item={item} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1 border-t border-border pt-2 text-sm">
|
|
||||||
<div className="flex justify-between text-muted-foreground">
|
|
||||||
<span>جمع کالاها</span>
|
|
||||||
<span>{group.items_subtotal.toLocaleString("fa-IR")} تومان</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-muted-foreground">
|
|
||||||
<span>هزینه ارسال</span>
|
|
||||||
<span>{group.delivery_fee.toLocaleString("fa-IR")} تومان</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between font-semibold">
|
|
||||||
<span>مجموع</span>
|
|
||||||
<span>{group.total.toLocaleString("fa-IR")} تومان</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!group.meets_minimum_order && (
|
|
||||||
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
|
||||||
حداقل مبلغ سفارش برای این فروشگاه رعایت نشده است.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="sticky bottom-4 flex flex-col gap-2 rounded-2xl border border-border bg-background p-4 shadow-lg">
|
|
||||||
<div className="flex justify-between font-semibold">
|
|
||||||
<span>مبلغ قابل پرداخت</span>
|
|
||||||
<span>{cart.grand_total.toLocaleString("fa-IR")} تومان</span>
|
|
||||||
</div>
|
|
||||||
<Link href={hasBlockedGroup ? "#" : "/checkout"} aria-disabled={hasBlockedGroup}>
|
|
||||||
<Button size="lg" className="w-full" disabled={hasBlockedGroup}>
|
|
||||||
ادامه و ثبت سفارش
|
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
<h1 className="text-[18px] font-bold leading-none text-foreground">
|
||||||
|
سبد خرید
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
{cart.groups.map((group) => (
|
||||||
|
<StoreCartCard key={group.store.uuid} group={group}>
|
||||||
|
{!group.meets_minimum_order && (
|
||||||
|
<p className="mt-3 rounded-lg bg-error/10 px-3 py-2 text-xs text-error">
|
||||||
|
حداقل مبلغ سفارش برای این فروشگاه رعایت نشده است.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={group.meets_minimum_order ? `/checkout/${group.store.uuid}` : "#"}
|
||||||
|
aria-disabled={!group.meets_minimum_order}
|
||||||
|
className="mt-4 flex justify-center"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!group.meets_minimum_order}
|
||||||
|
className="w-full rounded-xl bg-primary-action px-8 py-3 text-[14px] font-bold text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
تکمیل سفارش
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</StoreCartCard>
|
||||||
|
))}
|
||||||
|
</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")} کالا)
|
||||||
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
35
src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx
Normal file
35
src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { getCart } from "@/lib/api/cart";
|
||||||
|
import { getAddresses } from "@/lib/api/addresses";
|
||||||
|
import { requireSession } from "@/lib/auth/session";
|
||||||
|
import { CheckoutPayment } from "@/components/customer/checkout-payment";
|
||||||
|
|
||||||
|
export default async function ConfirmPaymentPage({ params }: PageProps<"/checkout/[store]/confirm">) {
|
||||||
|
const { store } = await params;
|
||||||
|
await requireSession(`/checkout/${store}/confirm`);
|
||||||
|
|
||||||
|
const [cart, addresses] = await Promise.all([
|
||||||
|
getCart(),
|
||||||
|
// Known gap (FRONTEND_GUIDE.md §7): addresses GET can 403 on the current OAuth
|
||||||
|
// scope — fall back to empty rather than crashing this page over it.
|
||||||
|
getAddresses().catch(() => []),
|
||||||
|
]);
|
||||||
|
const group = cart.groups.find((g) => g.store.uuid === store);
|
||||||
|
if (!group) notFound();
|
||||||
|
|
||||||
|
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]. */}
|
||||||
|
<div className="flex items-center justify-start gap-3">
|
||||||
|
<Link href={`/checkout/${store}`} aria-label="بازگشت">
|
||||||
|
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-[18px] font-bold leading-none text-foreground">تأیید و پرداخت</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CheckoutPayment group={group} addresses={addresses} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/app/(customer)/(shop)/checkout/[store]/page.tsx
Normal file
31
src/app/(customer)/(shop)/checkout/[store]/page.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { getCart } from "@/lib/api/cart";
|
||||||
|
import { requireSession } from "@/lib/auth/session";
|
||||||
|
import { StoreCartCard } from "@/components/customer/store-cart-card";
|
||||||
|
import { CheckoutFulfillment } from "@/components/customer/checkout-fulfillment";
|
||||||
|
|
||||||
|
export default async function StoreCheckoutPage({ params }: PageProps<"/checkout/[store]">) {
|
||||||
|
const { store } = await params;
|
||||||
|
await requireSession(`/checkout/${store}`);
|
||||||
|
const cart = await getCart();
|
||||||
|
const group = cart.groups.find((g) => g.store.uuid === store);
|
||||||
|
if (!group) notFound();
|
||||||
|
|
||||||
|
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 as /cart, same DOM-order note: first child renders right, last left. */}
|
||||||
|
<div className="flex items-center justify-start gap-3">
|
||||||
|
<Link href="/cart" aria-label="بازگشت">
|
||||||
|
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-[18px] font-bold leading-none text-foreground">سبد خرید</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StoreCartCard group={group} />
|
||||||
|
|
||||||
|
<CheckoutFulfillment group={group} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,8 @@ export default async function ProductDetailPage({ params }: PageProps<"/products
|
||||||
if (!product) notFound();
|
if (!product) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-full flex-1 flex-col bg-white pb-[140px]">
|
<main className="flex min-h-full flex-1 flex-col bg-white pb-35">
|
||||||
<div className="relative h-[320px] w-full shrink-0 overflow-hidden bg-muted">
|
<div className="relative h-80 w-full shrink-0 overflow-hidden bg-muted">
|
||||||
{product.image_url && (
|
{product.image_url && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
|
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
|
||||||
|
|
@ -24,9 +24,9 @@ export default async function ProductDetailPage({ params }: PageProps<"/products
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-5 px-5 pt-5">
|
<div className="flex flex-col gap-5 px-5 pt-5">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-6">
|
||||||
<h1 className="text-[24px] font-bold leading-none text-foreground">{product.name}</h1>
|
<h1 className="text-[24px] font-bold leading-none text-foreground">{product.name}</h1>
|
||||||
<Link href={`/stores/${product.store.uuid}`} className="w-fit text-[14px] font-semibold leading-none text-primary-action">
|
<Link href={`/stores/${product.store.uuid}`} className="w-fit text-[14px] font-medium leading-none text-gray-600">
|
||||||
{product.store.name}
|
{product.store.name}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-headline-medium font-extrabold leading-none text-primary-action">
|
<p className="text-headline-medium font-extrabold leading-none text-primary-action">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import ProfileListMenu from "@/components/profile/profile-menu";
|
||||||
|
import ProfilePageHeadLine from "@/components/profile/profile-page-head-line";
|
||||||
|
import ProfilePersonDetailCard from "@/components/profile/profile-person-detail-card";
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
return <section>
|
||||||
|
<ProfilePageHeadLine title='پروفایل من' />
|
||||||
|
<ProfilePersonDetailCard />
|
||||||
|
<ProfileListMenu />
|
||||||
|
</section>
|
||||||
|
}
|
||||||
16
src/app/(customer)/(shop)/profile/(routes)/wallet/page.tsx
Normal file
16
src/app/(customer)/(shop)/profile/(routes)/wallet/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import ProfilePageHeadLine from '@/components/profile/profile-page-head-line'
|
||||||
|
import TransactionHistory from '@/components/wallet/transaction-history'
|
||||||
|
import WalletBlancCard from '@/components/wallet/wallet-blanc-card'
|
||||||
|
|
||||||
|
const WalletPage = () => {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<ProfilePageHeadLine title='کیف پول من' />
|
||||||
|
<WalletBlancCard />
|
||||||
|
<ProfilePageHeadLine title='تاریخچه تراکنشها' />
|
||||||
|
<TransactionHistory />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WalletPage
|
||||||
28
src/app/(customer)/(shop)/profile/layout.tsx
Normal file
28
src/app/(customer)/(shop)/profile/layout.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { requireSession } from "@/lib/auth/session";
|
||||||
|
import { getAccount } from "@/lib/api/account";
|
||||||
|
import { AccountProvider } from "@/lib/providers/account-provider";
|
||||||
|
|
||||||
|
export default async function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
await requireSession("/login");
|
||||||
|
|
||||||
|
let account: Awaited<ReturnType<typeof getAccount>> | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
account = await getAccount();
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e?.message ?? "خطا در دریافت اطلاعات پروفایل";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-5xl w-full p-4 mx-auto">
|
||||||
|
<AccountProvider account={account} error={error}>
|
||||||
|
{children}
|
||||||
|
</AccountProvider>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { requireSession } from "@/lib/auth/session";
|
|
||||||
import { LogoutButton } from "@/components/customer/logout-button";
|
|
||||||
|
|
||||||
// Minimal placeholder — winofyfrontenddoc.md's bottom-nav references a "پروفایل"
|
|
||||||
// tab, but no profile screen is documented yet in either doc.
|
|
||||||
export default async function ProfilePage() {
|
|
||||||
await requireSession("/profile");
|
|
||||||
return (
|
|
||||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-3 px-4 py-6">
|
|
||||||
<h1 className="text-title-large font-extrabold text-foreground">پروفایل</h1>
|
|
||||||
<Link href="/orders" className="rounded-xl border border-border p-4 text-body-large">
|
|
||||||
سفارشهای من
|
|
||||||
</Link>
|
|
||||||
<Link href="/addresses" className="rounded-xl border border-border p-4 text-body-large">
|
|
||||||
آدرسهای من
|
|
||||||
</Link>
|
|
||||||
<div className="rounded-xl border border-border p-4">
|
|
||||||
<LogoutButton />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -25,45 +25,70 @@ export default async function StoreDetailPage({
|
||||||
});
|
});
|
||||||
if (!store) notFound();
|
if (!store) notFound();
|
||||||
|
|
||||||
const [products, session] = await Promise.all([getProducts({ store: uuid, search, limit: 100 }), getValidSession()]);
|
const [products, session] = await Promise.all([
|
||||||
|
getProducts({ store: uuid, search, limit: 100 }),
|
||||||
|
getValidSession(),
|
||||||
|
]);
|
||||||
const cart = session ? await getCart() : null;
|
const cart = session ? await getCart() : null;
|
||||||
const storeGroup = cart?.groups.find((g) => g.store.uuid === uuid) ?? null;
|
const storeGroup = cart?.groups.find((g) => g.store.uuid === uuid) ?? null;
|
||||||
|
|
||||||
const cartItemByProduct = new Map<string, CartItem>();
|
const cartItemByProduct = new Map<string, CartItem>();
|
||||||
storeGroup?.items.forEach((item) => cartItemByProduct.set(item.product.uuid, item));
|
storeGroup?.items.forEach((item) =>
|
||||||
|
cartItemByProduct.set(item.product.uuid, item),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-full flex-1 flex-col bg-page-bg">
|
<main className="flex min-h-full flex-1 flex-col bg-page-bg">
|
||||||
<div className="relative h-[257px] w-full shrink-0 overflow-hidden rounded-b-3xl bg-muted">
|
<div className="relative h-64.25 w-full shrink-0 overflow-hidden rounded-b-2xl bg-muted">
|
||||||
{store.cover_image_url && (
|
{store.cover_image_url && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={store.cover_image_url} alt="" className="h-full w-full object-cover" />
|
<img
|
||||||
|
src={store.cover_image_url}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-black/0 to-black/70" />
|
<div className="absolute inset-0 bg-gradient-to-b from-black/0 to-black/70" />
|
||||||
<HeroBackButton href="/" />
|
<HeroBackButton href="/" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full h-auto flex flex-col items-center">
|
||||||
|
<StoreInfoCard store={store} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<StoreInfoCard store={store} />
|
<section className="p-6">
|
||||||
|
<h2 className="text-[18px] font-extrabold leading-none text-primary-action">
|
||||||
<section className="px-4 pt-2">
|
توضیحات
|
||||||
<h2 className="text-[18px] font-extrabold leading-none text-primary-action">توضیحات</h2>
|
</h2>
|
||||||
<p className="mt-3 text-right text-[15px] leading-[1.6] text-gray-700">{store.description}</p>
|
<p className="mt-3 text-right text-[15px] leading-[1.6] text-gray-700">
|
||||||
|
{store.description}
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="pb-6">
|
||||||
<StoreSearchBar storeUuid={uuid} defaultValue={search} />
|
<StoreSearchBar storeUuid={uuid} defaultValue={search} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="grid grid-cols-2 gap-3 px-8 pt-2 pb-[120px]">
|
<section className="grid grid-cols-2 gap-3 px-8 pt-2 pb-30">
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<ProductGridCard key={product.uuid} product={product} cartItem={cartItemByProduct.get(product.uuid) ?? null} />
|
<ProductGridCard
|
||||||
|
key={product.uuid}
|
||||||
|
product={product}
|
||||||
|
cartItem={cartItemByProduct.get(product.uuid) ?? null}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{products.length === 0 && (
|
{products.length === 0 && (
|
||||||
<p className="col-span-2 py-8 text-center text-body-medium text-gray-600">محصولی یافت نشد.</p>
|
<p className="col-span-2 py-8 text-center text-body-medium text-gray-600">
|
||||||
|
محصولی یافت نشد.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{storeGroup && <StickyCartBar itemCount={storeGroup.items.length} total={storeGroup.total} />}
|
{storeGroup && (
|
||||||
|
<StickyCartBar
|
||||||
|
itemCount={storeGroup.items.length}
|
||||||
|
total={storeGroup.total}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ export const metadata: Metadata = {
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
return (
|
return (
|
||||||
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} h-full antialiased`}>
|
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} h-full antialiased`} suppressHydrationWarning>
|
||||||
<body className="min-h-full flex flex-col bg-background text-foreground">
|
<body className="min-h-full flex flex-col bg-background text-foreground" suppressHydrationWarning>
|
||||||
<QueryProvider>{children}</QueryProvider>
|
<QueryProvider>{children}</QueryProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function RoleCard({
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-12 w-full items-center justify-center rounded-full text-body-large font-semibold transition-colors",
|
"flex h-12 w-full items-center justify-center rounded-full text-body-large font-semibold transition-colors",
|
||||||
ctaVariant === "primary"
|
ctaVariant === "primary"
|
||||||
? "bg-primary-action text-white hover:bg-primary-action/90"
|
? "border-0 bg-primary-action text-white hover:bg-primary-action/90"
|
||||||
: "border border-primary-action bg-white text-primary-action hover:bg-primary-action/5",
|
: "border border-primary-action bg-white text-primary-action hover:bg-primary-action/5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
|
||||||
import { addToCart } from "@/lib/api/cart";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils/cn";
|
|
||||||
|
|
||||||
export function AddToCartButton({
|
|
||||||
productUuid,
|
|
||||||
quantity = 1,
|
|
||||||
disabled,
|
|
||||||
outOfStock,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
productUuid: string;
|
|
||||||
quantity?: number;
|
|
||||||
disabled?: boolean;
|
|
||||||
outOfStock?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const [feedback, setFeedback] = useState<"added" | "error" | null>(null);
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await addToCart(productUuid, quantity);
|
|
||||||
if (result.ok) {
|
|
||||||
setFeedback("added");
|
|
||||||
router.refresh();
|
|
||||||
setTimeout(() => setFeedback(null), 1500);
|
|
||||||
} else if (result.status === 401) {
|
|
||||||
router.push(`/login?next=${encodeURIComponent(pathname)}`);
|
|
||||||
} else {
|
|
||||||
setFeedback("error");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={feedback === "added" ? "secondary" : "primary"}
|
|
||||||
disabled={disabled || isPending}
|
|
||||||
onClick={handleClick}
|
|
||||||
className={cn("w-full", className)}
|
|
||||||
>
|
|
||||||
{outOfStock ? "ناموجود" : isPending ? "..." : feedback === "added" ? "افزوده شد ✓" : feedback === "error" ? "خطا، دوباره تلاش کنید" : "افزودن به سبد"}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +1,92 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
|
import { Minus, Plus, Trash2 } from "lucide-react";
|
||||||
import { updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
|
import { updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
|
||||||
import type { CartItem } from "@/types/api";
|
import type { CartItem } from "@/types/api";
|
||||||
|
|
||||||
export function CartItemRow({ item }: { item: CartItem }) {
|
export function CartItemRow({ item }: { item: CartItem }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error) return;
|
||||||
|
const timeout = setTimeout(() => setError(null), 3000);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
function changeQuantity(nextQuantity: number) {
|
function changeQuantity(nextQuantity: number) {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
if (nextQuantity <= 0) {
|
const result =
|
||||||
await removeCartItem(item.uuid);
|
nextQuantity <= 0 ? await removeCartItem(item.uuid) : await updateCartItemQuantity(item.uuid, nextQuantity);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
router.refresh();
|
||||||
|
} else if (result.status === 401) {
|
||||||
|
router.push(`/login?next=${encodeURIComponent(pathname)}`);
|
||||||
} else {
|
} else {
|
||||||
await updateCartItemQuantity(item.uuid, nextQuantity);
|
setError(result.message);
|
||||||
}
|
}
|
||||||
router.refresh();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-3 py-3 ${isPending ? "opacity-50" : ""}`}>
|
<div className={`flex flex-col gap-2 py-4 ${isPending ? "opacity-50" : ""}`}>
|
||||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-muted">
|
{/* DOM order intentionally mirrors the screenshot's left→right layout, not reading
|
||||||
{item.product.image_url && (
|
order — under this app's inherited dir="rtl", the first child renders at the
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
right edge and the last at the left. */}
|
||||||
<img src={item.product.image_url} alt="" className="h-full w-full object-cover" />
|
<div className="flex items-start justify-start gap-3">
|
||||||
)}
|
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-muted">
|
||||||
|
{item.product.image_url && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={item.product.image_url} alt="" className="h-full w-full object-cover" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start gap-3 text-right m-2">
|
||||||
|
<span className="text-[14px] font-bold leading-none text-foreground">{item.product.name}</span>
|
||||||
|
<span className="text-[13px] leading-none text-gray-600">
|
||||||
|
{item.product.price.toLocaleString("fa-IR")} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
|
||||||
<span className="text-sm font-medium">{item.product.name}</span>
|
<div className="flex items-center justify-end gap-3">
|
||||||
<span className="text-xs text-muted-foreground">{item.product.price.toLocaleString("fa-IR")} تومان</span>
|
<div className="flex h-8 items-center gap-3 rounded-lg border border-gray-300 px-2">
|
||||||
</div>
|
<button
|
||||||
<div className="flex items-center gap-2 rounded-full border border-border px-2 py-1">
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => changeQuantity(item.quantity - 1)}
|
||||||
|
aria-label="کاهش تعداد"
|
||||||
|
>
|
||||||
|
<Minus className="h-3.5 w-3.5 text-foreground" />
|
||||||
|
</button>
|
||||||
|
<span className="min-w-3 text-center text-[13px] font-bold leading-none text-foreground">
|
||||||
|
{item.quantity.toLocaleString("fa-IR")}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => changeQuantity(item.quantity + 1)}
|
||||||
|
aria-label="افزایش تعداد"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 text-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => changeQuantity(item.quantity - 1)}
|
onClick={() => changeQuantity(0)}
|
||||||
className="flex h-6 w-6 items-center justify-center text-sm"
|
aria-label="حذف از سبد"
|
||||||
aria-label="کاهش تعداد"
|
className="flex h-8 w-8 items-center justify-center disabled:opacity-40"
|
||||||
>
|
>
|
||||||
−
|
<Trash2 className="h-4.5 w-4.5 text-error" />
|
||||||
</button>
|
|
||||||
<span className="w-5 text-center text-sm">{item.quantity}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={() => changeQuantity(item.quantity + 1)}
|
|
||||||
className="flex h-6 w-6 items-center justify-center text-sm"
|
|
||||||
aria-label="افزایش تعداد"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-20 shrink-0 text-end text-sm font-semibold">{item.line_total.toLocaleString("fa-IR")}</span>
|
|
||||||
|
{error && <p className="text-end text-xs text-error">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
110
src/components/customer/checkout-fulfillment.tsx
Normal file
110
src/components/customer/checkout-fulfillment.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { CartGroup } from "@/types/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "دریافت حضوری" vs "ارسال توسط فروشگاه" has no backing field in the Checkout API —
|
||||||
|
* the OpenAPI spec's Checkout schema is address_uuid/delivery_type/scheduled_at/
|
||||||
|
* payment_method/notes only, with delivery_type meaning express-vs-scheduled timing,
|
||||||
|
* not pickup-vs-delivery fulfillment. So this toggle only affects what's *displayed*
|
||||||
|
* here; the actual order submission (next screen) still uses the real delivery fee.
|
||||||
|
*/
|
||||||
|
export function CheckoutFulfillment({ group }: { group: CartGroup }) {
|
||||||
|
const [pickup, setPickup] = useState(false);
|
||||||
|
const deliveryFee = pickup ? 0 : group.delivery_fee;
|
||||||
|
const payable = group.items_subtotal + deliveryFee;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 pb-32">
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h2 className="text-[15px] font-bold leading-none text-foreground">نحوه دریافت</h2>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className={`flex cursor-pointer flex-col gap-1 rounded-2xl border p-4 ${pickup ? "border-primary-action" : "border-gray-300"}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="fulfillment"
|
||||||
|
checked={pickup}
|
||||||
|
onChange={() => setPickup(true)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
{/* 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">
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="text-[14px] font-bold leading-none text-foreground">دریافت حضوری</span>
|
||||||
|
<span className="text-[12px] leading-none text-gray-500">رایگان</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${pickup ? "border-primary-action" : "border-gray-300"}`}
|
||||||
|
>
|
||||||
|
{pickup && <span className="h-2.5 w-2.5 rounded-full bg-primary-action" />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* The data for delivery time is mock, it should be provided from the backend*/}
|
||||||
|
<span className="text-[12px] leading-none text-gray-500">آماده در ۱۵ دقیقه</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className={`flex cursor-pointer flex-col gap-1 rounded-2xl border p-4 ${!pickup ? "border-primary-action" : "border-gray-300"}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="fulfillment"
|
||||||
|
checked={!pickup}
|
||||||
|
onChange={() => setPickup(false)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="text-[14px] font-bold leading-none text-foreground">ارسال توسط فروشگاه</span>
|
||||||
|
<span className="text-[12px] leading-none text-gray-500">
|
||||||
|
{group.delivery_fee.toLocaleString("fa-IR")} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${!pickup ? "border-primary-action" : "border-gray-300"}`}
|
||||||
|
>
|
||||||
|
{!pickup && <span className="h-2.5 w-2.5 rounded-full bg-primary-action" />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* The data for delivery time is mock, it should be provided from the backend*/}
|
||||||
|
<span className="text-[12px] leading-none text-gray-500">۲۵-۳۵ دقیقه</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3 rounded-2xl bg-[#F4F4F5] px-4 py-5">
|
||||||
|
<div className="flex items-center justify-between text-[13px] text-gray-600">
|
||||||
|
<span>جمع کالاها</span>
|
||||||
|
<span>{group.items_subtotal.toLocaleString("fa-IR")} تومان</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[13px] text-gray-600">
|
||||||
|
<span>هزینه ارسال</span>
|
||||||
|
<span>{deliveryFee.toLocaleString("fa-IR")} تومان</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-300 pt-3">
|
||||||
|
<span className="text-[14px] font-semibold leading-none text-foreground">مبلغ قابل پرداخت</span>
|
||||||
|
<span className="text-[16px] font-bold leading-none text-foreground">
|
||||||
|
{payable.toLocaleString("fa-IR")} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-2xl bg-primary-action py-4 text-[15px] font-bold text-white"
|
||||||
|
>
|
||||||
|
پرداخت و ثبت سفارش — {payable.toLocaleString("fa-IR")} تومان
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/components/customer/checkout-payment.tsx
Normal file
110
src/components/customer/checkout-payment.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { submitCheckout } from "@/lib/api/checkout";
|
||||||
|
import type { Address, CartGroup, PaymentMethod } from "@/types/api";
|
||||||
|
|
||||||
|
/** Exact labels per the OpenAPI PaymentMethodEnum description ("(آنلاین)" added to match the screenshot). */
|
||||||
|
const PAYMENT_OPTIONS: { value: PaymentMethod; label: string }[] = [
|
||||||
|
{ value: "wallet", label: "کیف پول وینسو" },
|
||||||
|
{ value: "online", label: "درگاه بانکی (آنلاین)" },
|
||||||
|
{ value: "cash_on_delivery", label: "پرداخت در محل" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CheckoutPayment({ group, addresses }: { group: CartGroup; addresses: Address[] }) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
function handlePay() {
|
||||||
|
setError(null);
|
||||||
|
const addressUuid = addresses.find((a) => a.is_default)?.uuid ?? addresses[0]?.uuid;
|
||||||
|
if (!addressUuid) {
|
||||||
|
setError("برای ثبت سفارش ابتدا یک آدرس اضافه کنید.");
|
||||||
|
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: "",
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
router.push(`/order-groups/${result.data.uuid}`);
|
||||||
|
} else {
|
||||||
|
setError(result.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 pb-44">
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h2 className="text-[15px] font-bold leading-none text-foreground">روش پرداخت</h2>
|
||||||
|
|
||||||
|
{PAYMENT_OPTIONS.map((option) => {
|
||||||
|
const selected = method === option.value;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className={`flex cursor-pointer items-center justify-between rounded-2xl border p-4 ${selected ? "border-primary-action" : "border-gray-200"}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="payment"
|
||||||
|
checked={selected}
|
||||||
|
onChange={() => setMethod(option.value)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
{/* DOM order intentionally mirrors the screenshot's left→right layout — see
|
||||||
|
the inherited dir="rtl" note on the /cart page. No wallet balance line:
|
||||||
|
Winofy's API has no wallet-balance endpoint — that's a Gooyal-side
|
||||||
|
feature (see the wallet.wallet:get_balance OAuth scope) with no
|
||||||
|
documented endpoint available here, so it isn't faked. */}
|
||||||
|
<span className="text-[14px] font-bold leading-none text-foreground">{option.label}</span>
|
||||||
|
<span
|
||||||
|
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${selected ? "border-primary-action" : "border-gray-300"}`}
|
||||||
|
>
|
||||||
|
{selected && <span className="h-2.5 w-2.5 rounded-full bg-primary-action" />}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <p className="text-end text-sm text-error">{error}</p>}
|
||||||
|
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-md flex-col gap-3 bg-white px-4 pt-4 pb-6 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
|
||||||
|
<div className="flex items-center justify-between text-[13px] text-gray-600">
|
||||||
|
<span>
|
||||||
|
{group.items.length.toLocaleString("fa-IR")} کالا از {group.store.name}
|
||||||
|
</span>
|
||||||
|
<span>{payable.toLocaleString("fa-IR")} تومان</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[14px] font-semibold leading-none text-foreground">جمع کل</span>
|
||||||
|
<span className="text-[16px] font-bold leading-none text-foreground">
|
||||||
|
{payable.toLocaleString("fa-IR")} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePay}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full rounded-2xl bg-primary-action py-4 text-[15px] font-bold text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{isPending ? "در حال ثبت سفارش..." : `پرداخت ${payable.toLocaleString("fa-IR")} تومان`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/customer/clear-cart-button.tsx
Normal file
48
src/components/customer/clear-cart-button.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { clearCart } from "@/lib/api/cart";
|
||||||
|
|
||||||
|
export function ClearCartButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error) return;
|
||||||
|
const timeout = setTimeout(() => setError(null), 3000);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
if (!window.confirm("سبد خرید شما بهطور کامل خالی میشود. ادامه میدهید؟")) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await clearCart();
|
||||||
|
if (result.ok) {
|
||||||
|
router.refresh();
|
||||||
|
} else if (result.status === 401) {
|
||||||
|
router.push(`/login?next=${encodeURIComponent(pathname)}`);
|
||||||
|
} else {
|
||||||
|
setError(result.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={isPending}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-medium text-error disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
خالی کردن سبد
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-xs text-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/customer/store-cart-card.tsx
Normal file
38
src/components/customer/store-cart-card.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ShoppingBasket } from "lucide-react";
|
||||||
|
import { CartItemRow } from "@/components/customer/cart-item-row";
|
||||||
|
import type { CartGroup } from "@/types/api";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/** Shared between /cart and /checkout/[store] — the pixel-perfect per-store card. */
|
||||||
|
export function StoreCartCard({ group, children }: { group: CartGroup; children?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="relative rounded-2xl border border-gray-300 bg-white p-6 shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
|
||||||
|
<span className="absolute -top-3 left-4 rounded-lg bg-blue-bg px-2.5 py-1 text-[12px] font-semibold leading-none text-primary-action">
|
||||||
|
{group.items.length.toLocaleString("fa-IR")} کالا
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/stores/${group.store.uuid}`}
|
||||||
|
className="mb-3 flex items-center justify-start gap-2 border-b border-b-[#E4E4E7] pb-5"
|
||||||
|
>
|
||||||
|
<ShoppingBasket className="h-4.5 w-4.5 text-foreground" />
|
||||||
|
<span className="text-[16px] font-bold leading-none text-foreground">{group.store.name}</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<CartItemRow key={item.uuid} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-start border-t border-gray-200 pt-5 pb-1">
|
||||||
|
<span className="text-[13px] font-semibold leading-none text-foreground">
|
||||||
|
جمع فروشگاه: {group.items_subtotal.toLocaleString("fa-IR")} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useState, useTransition, type ReactNode } from "react";
|
import { useEffect, useState, useTransition, type ReactNode } from "react";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { Minus, Plus, X, CheckCircle2 } from "lucide-react";
|
import { Minus, Plus, X, CheckCircle2, Check } from "lucide-react";
|
||||||
import { addToCart } from "@/lib/api/cart";
|
import { addToCart } from "@/lib/api/cart";
|
||||||
import { toPersianDigits } from "@/lib/format/persian-digits";
|
import { toPersianDigits } from "@/lib/format/persian-digits";
|
||||||
import type { ProductDetail } from "@/types/api";
|
import type { ProductDetail } from "@/types/api";
|
||||||
|
|
@ -41,17 +41,19 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe
|
||||||
|
|
||||||
return (
|
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, so "what should appear on the left" must
|
||||||
|
come last in JSX (stepper group, then its own "+" trailing last so it lands
|
||||||
|
leftmost within the group). */}
|
||||||
<div className="flex h-10 items-center justify-between">
|
<div className="flex h-10 items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 text-[14px] font-semibold leading-none ${product.is_out_of_stock ? "text-error" : "text-emerald-500"}`}
|
||||||
|
>
|
||||||
|
{product.is_out_of_stock ? "ناموجود" : "موجود"}
|
||||||
|
{!product.is_out_of_stock && <Check className="h-3.5 w-3.5" />}
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setQuantity((q) => q + 1)}
|
|
||||||
aria-label="افزایش تعداد"
|
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-[20px] border border-gray-300 text-primary-action"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<span className="text-[18px] font-bold leading-none text-black">{toPersianDigits(quantity)}</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||||
|
|
@ -60,10 +62,16 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe
|
||||||
>
|
>
|
||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<span className="text-[18px] font-bold leading-none text-black">{toPersianDigits(quantity)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuantity((q) => q + 1)}
|
||||||
|
aria-label="افزایش تعداد"
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-[20px] border border-gray-300 text-primary-action"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-[14px] font-semibold leading-none ${product.is_out_of_stock ? "text-error" : "text-emerald-500"}`}>
|
|
||||||
{product.is_out_of_stock ? "ناموجود" : "موجود ●"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-px w-full bg-gray-300" />
|
<div className="h-px w-full bg-gray-300" />
|
||||||
|
|
@ -73,35 +81,35 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe
|
||||||
{error && <p className="px-5 text-body-medium text-error">{error}</p>}
|
{error && <p className="px-5 text-body-medium text-error">{error}</p>}
|
||||||
|
|
||||||
{showToast && (
|
{showToast && (
|
||||||
<div className="fixed inset-x-0 bottom-[135px] z-20 mx-auto flex w-[358px] max-w-[calc(100%-32px)] items-center gap-3 rounded-2xl border border-emerald-border bg-emerald-bg p-4 shadow-[0_4px_12px_0_rgba(16,185,129,0.102)]">
|
<div className="fixed inset-x-0 bottom-33.75 z-20 mx-auto flex w-89.5 max-w-[calc(100%-32px)] items-center gap-3 rounded-2xl border border-emerald-border bg-emerald-bg p-4 shadow-[0_4px_12px_0_rgba(16,185,129,0.102)]">
|
||||||
<button type="button" onClick={() => setShowToast(false)} aria-label="بستن">
|
<CheckCircle2 className="h-6 w-6 shrink-0 fill-emerald-500 text-white" />
|
||||||
<X className="h-4 w-4 text-emerald-500" />
|
|
||||||
</button>
|
|
||||||
<span className="flex-1 text-right text-[14px] font-bold leading-none text-emerald-500">
|
<span className="flex-1 text-right text-[14px] font-bold leading-none text-emerald-500">
|
||||||
محصول به سبد خرید اضافه شد
|
محصول به سبد خرید اضافه شد
|
||||||
</span>
|
</span>
|
||||||
<CheckCircle2 className="h-6 w-6 shrink-0 fill-emerald-500 text-white" />
|
<button type="button" onClick={() => setShowToast(false)} aria-label="بستن">
|
||||||
|
<X className="h-4 w-4 text-emerald-500" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-[390px] flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
|
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-97.5 flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
|
||||||
<div className="flex w-full items-center justify-between gap-4">
|
<div className="flex w-full items-center justify-between gap-4">
|
||||||
<button
|
<div className="flex flex-col items-start gap-2">
|
||||||
type="button"
|
|
||||||
onClick={handleAddToCart}
|
|
||||||
disabled={disabled || isPending}
|
|
||||||
className="flex h-[53px] w-[245px] items-center justify-center rounded-xl bg-primary-action text-body-large font-bold text-white disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{isPending ? "در حال افزودن..." : "افزودن به سبد خرید"}
|
|
||||||
</button>
|
|
||||||
<div className="flex flex-col items-end gap-0.5">
|
|
||||||
<span className="text-[12px] leading-none text-gray-600">جمع کل</span>
|
<span className="text-[12px] leading-none text-gray-600">جمع کل</span>
|
||||||
<span className="text-[18px] font-bold leading-none text-foreground">
|
<span className="text-[18px] font-bold leading-none text-foreground">
|
||||||
{(product.price * quantity).toLocaleString("fa-IR")} تومان
|
{(product.price * quantity).toLocaleString("fa-IR")} تومان
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddToCart}
|
||||||
|
disabled={disabled || isPending}
|
||||||
|
className="flex h-13.25 w-61.25 items-center justify-center rounded-xl bg-primary-action text-body-large font-bold text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{isPending ? "در حال افزودن..." : "افزودن به سبد خرید"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 h-[5px] w-[134px] rounded-full bg-black" />
|
<div className="mt-2 h-1.5 w-32 rounded-full bg-black" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
35
src/components/profile/profile-exit-account.tsx
Normal file
35
src/components/profile/profile-exit-account.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChevronLeft, LogOutIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
|
||||||
|
const ProfileExitAccount = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between w-full py-3 cursor-pointer"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-error/10 p-2 rounded-lg">
|
||||||
|
<LogOutIcon className="w-4 h-4 text-error" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-error">خروج</span>
|
||||||
|
</div>
|
||||||
|
<ChevronLeft className="w-5 h-5 text-error" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileExitAccount;
|
||||||
38
src/components/profile/profile-menu-item.tsx
Normal file
38
src/components/profile/profile-menu-item.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ChevronLeft, User } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ProfileMenuItemInterface {
|
||||||
|
href: string;
|
||||||
|
text: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
isLast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileMenuItem = ({
|
||||||
|
href,
|
||||||
|
text,
|
||||||
|
icon,
|
||||||
|
isLast = false,
|
||||||
|
}: ProfileMenuItemInterface) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={`
|
||||||
|
flex items-center justify-between w-full py-3
|
||||||
|
${!isLast ? 'border-b border-border' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-gray-50 p-2 rounded-lg">
|
||||||
|
{icon || <User className="w-4 h-4 text-primary-action" />}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-700">{text}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronLeft className="w-5 h-5 text-gray-500" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileMenuItem;
|
||||||
70
src/components/profile/profile-menu.tsx
Normal file
70
src/components/profile/profile-menu.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ShoppingBag,
|
||||||
|
MapPin,
|
||||||
|
Wallet,
|
||||||
|
Bell,
|
||||||
|
CircleQuestionMark,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import ProfileMenuItem from './profile-menu-item';
|
||||||
|
import ProfileExitAccount from './profile-exit-account';
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
href: '/addresses',
|
||||||
|
text: 'سعادتآباد',
|
||||||
|
icon: <MapPin className="size-5 text-primary-action" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
href: '/orders',
|
||||||
|
text: 'سفارشهای من',
|
||||||
|
icon: <ShoppingBag className="size-5 text-primary-action" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
href: '/addresses',
|
||||||
|
text: 'آدرسهای من',
|
||||||
|
icon: <MapPin className="size-5 text-primary-action" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
href: '/profile/wallet',
|
||||||
|
text: 'کیف پول',
|
||||||
|
icon: <Wallet className="size-5 text-primary-action" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
href: '/profile/notifications',
|
||||||
|
text: 'اعلانها',
|
||||||
|
icon: <Bell className="size-5 text-primary-action" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
href: '/profile/support',
|
||||||
|
text: 'پشتیبانی',
|
||||||
|
icon: <CircleQuestionMark className="size-5 text-primary-action" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ProfileMenu = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{menuItems.map((item, index) => (
|
||||||
|
<ProfileMenuItem
|
||||||
|
key={item.id}
|
||||||
|
href={item.href}
|
||||||
|
text={item.text}
|
||||||
|
icon={item.icon}
|
||||||
|
isLast={index === menuItems.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="w-full h-px bg-border mt-4" />
|
||||||
|
<ProfileExitAccount />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileMenu;
|
||||||
11
src/components/profile/profile-page-head-line.tsx
Normal file
11
src/components/profile/profile-page-head-line.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface ProfilePageHeadLineInterface {
|
||||||
|
title: String;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfilePageHeadLine = ({ title }: ProfilePageHeadLineInterface) => {
|
||||||
|
return <h1 className="font-bold text-2xl">{title}</h1>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfilePageHeadLine
|
||||||
70
src/components/profile/profile-person-detail-card.tsx
Normal file
70
src/components/profile/profile-person-detail-card.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
'use client';
|
||||||
|
import { useAccount } from "@/lib/providers/account-provider";
|
||||||
|
import { getDisplayNameHelper, getInitialsHelper } from "@/lib/utils/account-utils";
|
||||||
|
|
||||||
|
const ProfilePersonDetailCard = () => {
|
||||||
|
const { data, error, isLoading } = useAccount();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="w-full min-h-[128px] rounded-2xl bg-teal-light my-6 py-4 px-10 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-gray-500">در حال بارگذاری...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="w-full min-h-[128px] rounded-2xl bg-teal-light my-6 py-4 px-10 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="w-full min-h-[128px] rounded-2xl bg-teal-light my-6 py-4 px-10 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-gray-500">اطلاعات پروفایل یافت نشد</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = getDisplayNameHelper(data);
|
||||||
|
const initials = getInitialsHelper(data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full min-h-[128px] rounded-2xl bg-teal-light my-6 py-4 px-10 flex items-center justify-center">
|
||||||
|
<div className="flex flex-row items-center w-full">
|
||||||
|
<div className="size-16 bg-primary-action rounded-full flex items-center justify-center text-lg font-bold text-white overflow-hidden shrink-0">
|
||||||
|
{data.avatar ? (
|
||||||
|
<img
|
||||||
|
src={data.avatar}
|
||||||
|
alt={displayName}
|
||||||
|
className="size-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{initials}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col mr-6 items-start">
|
||||||
|
<p className="text-lg font-bold">{displayName}</p>
|
||||||
|
|
||||||
|
{data.phone_number && (
|
||||||
|
<p className="text-sm text-gray-700 my-2 font-normal" dir="ltr">
|
||||||
|
{data.phone_number}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="text-sm font-semibold text-primary-action cursor-pointer underline"
|
||||||
|
onClick={() => alert('latter...')}
|
||||||
|
>
|
||||||
|
ویرایش پروفایل
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfilePersonDetailCard;
|
||||||
|
|
@ -4,11 +4,20 @@ import { useEffect, useState, useTransition } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { Minus, Plus } from "lucide-react";
|
import { Minus, Plus } from "lucide-react";
|
||||||
import { addToCart, updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
|
import {
|
||||||
|
addToCart,
|
||||||
|
updateCartItemQuantity,
|
||||||
|
removeCartItem,
|
||||||
|
} from "@/lib/api/cart";
|
||||||
import type { CartItem, ProductListItem } from "@/types/api";
|
import type { CartItem, ProductListItem } from "@/types/api";
|
||||||
|
|
||||||
/** designspec.md §2.5 — product-card, 157×254, with the two-state add/stepper control. */
|
export function ProductGridCard({
|
||||||
export function ProductGridCard({ product, cartItem }: { product: ProductListItem; cartItem: CartItem | null }) {
|
product,
|
||||||
|
cartItem,
|
||||||
|
}: {
|
||||||
|
product: ProductListItem;
|
||||||
|
cartItem: CartItem | null;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
@ -43,12 +52,13 @@ export function ProductGridCard({ product, cartItem }: { product: ProductListIte
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
if (nextQuantity <= 0) {
|
if (nextQuantity <= 0) {
|
||||||
const result = await removeCartItem(localItem.uuid);
|
const result = await removeCartItem(localItem.uuid);
|
||||||
// Only clear locally once the backend actually confirms removal — otherwise
|
|
||||||
// the card would show "add" again while the item still exists server-side.
|
|
||||||
if (result.ok) setLocalItem(null);
|
if (result.ok) setLocalItem(null);
|
||||||
else setError(result.message);
|
else setError(result.message);
|
||||||
} else {
|
} else {
|
||||||
const result = await updateCartItemQuantity(localItem.uuid, nextQuantity);
|
const result = await updateCartItemQuantity(
|
||||||
|
localItem.uuid,
|
||||||
|
nextQuantity,
|
||||||
|
);
|
||||||
if (result.ok) setLocalItem(result.data);
|
if (result.ok) setLocalItem(result.data);
|
||||||
else setError(result.message);
|
else setError(result.message);
|
||||||
}
|
}
|
||||||
|
|
@ -57,67 +67,83 @@ export function ProductGridCard({ product, cartItem }: { product: ProductListIte
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="relative flex h-[254px] w-[157px] flex-col overflow-hidden rounded-xl border border-gray-300 bg-white shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
|
<article className="relative flex h-63.5 w-39.25 flex-col overflow-hidden rounded-xl border border-gray-300 bg-white shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="absolute inset-x-1 top-1 z-10 rounded-lg bg-error px-2 py-1 text-center text-[11px] leading-tight text-white">
|
<div className="absolute inset-x-1 top-1 z-10 rounded-lg bg-error px-2 py-1 text-center text-[11px] leading-tight text-white">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Link href={`/products/${product.uuid}`} className="block shrink-0">
|
<Link href={`/products/${product.uuid}`} className="block shrink-0">
|
||||||
<div className="relative h-[130px] w-full overflow-hidden rounded-t-xl bg-muted">
|
<div className="relative h-32.5 w-full overflow-hidden rounded-t-xl bg-muted">
|
||||||
{product.image_url && (
|
{product.image_url && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
|
<img
|
||||||
|
src={product.image_url}
|
||||||
|
alt={product.name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/40 to-transparent" />
|
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-linear-to-t from-black/40 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-1 flex-col justify-between p-3">
|
<div className="flex flex-1 flex-col gap-1.5 p-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<Link href={`/products/${product.uuid}`}>
|
||||||
<Link href={`/products/${product.uuid}`}>
|
<h3 className="truncate text-right text-[14px] font-bold leading-none text-foreground">
|
||||||
<h3 className="truncate text-right text-[14px] font-bold leading-none text-foreground">{product.name}</h3>
|
{product.name}
|
||||||
</Link>
|
</h3>
|
||||||
<p className="text-right text-body-medium font-extrabold text-primary-action">
|
</Link>
|
||||||
{product.price.toLocaleString("fa-IR")} تومان
|
<p className="text-right text-body-medium font-extrabold text-primary-action">
|
||||||
</p>
|
{product.price.toLocaleString("fa-IR")} تومان
|
||||||
</div>
|
</p>
|
||||||
|
{disabled && (
|
||||||
|
<span className="mt-auto text-center text-label text-gray-500">
|
||||||
|
ناموجود
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{disabled ? (
|
{!disabled && (
|
||||||
<span className="text-center text-label text-gray-500">ناموجود</span>
|
<div
|
||||||
) : quantity === 0 ? (
|
className={`motion-reduce:transition-none absolute bottom-3 left-3 overflow-hidden rounded-full border transition-[width,background-color,border-color] duration-300 ease-out ${
|
||||||
<button
|
quantity === 0
|
||||||
type="button"
|
? "h-10 w-10 border-transparent bg-primary-action"
|
||||||
onClick={handleAdd}
|
: "h-10 w-28 border-gray-300 bg-white"
|
||||||
disabled={isPending}
|
}`}
|
||||||
aria-label="افزودن به سبد"
|
dir="ltr"
|
||||||
className="flex h-8 w-8 items-center justify-center self-start rounded-full bg-primary-action text-white disabled:opacity-40"
|
>
|
||||||
>
|
<div className="relative flex h-full w-full items-center justify-between px-1">
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-10 w-[110px] items-center justify-between gap-4 rounded-3xl border border-gray-300 bg-white p-1">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleChange(quantity + 1)}
|
onClick={
|
||||||
|
quantity === 0 ? handleAdd : () => handleChange(quantity + 1)
|
||||||
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
aria-label="افزایش تعداد"
|
aria-label={quantity === 0 ? "افزودن به سبد" : "افزایش تعداد"}
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-action text-white disabled:opacity-40"
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-0 bg-primary-action p-0 text-white disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-[18px] font-bold leading-none text-foreground">{quantity.toLocaleString("fa-IR")}</span>
|
|
||||||
<button
|
{quantity > 0 && (
|
||||||
type="button"
|
<>
|
||||||
onClick={() => handleChange(quantity - 1)}
|
<span className="flex h-full w-8 shrink-0 items-center justify-center text-center text-[18px] font-bold leading-none text-foreground">
|
||||||
disabled={isPending}
|
{quantity.toLocaleString("fa-IR")}
|
||||||
aria-label="کاهش تعداد"
|
</span>
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-bg text-primary-action disabled:opacity-40"
|
|
||||||
>
|
<button
|
||||||
<Minus className="h-4 w-4" />
|
type="button"
|
||||||
</button>
|
onClick={() => handleChange(quantity - 1)}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="کاهش تعداد"
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-0 bg-blue-bg p-0 text-primary-action disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,31 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ShoppingBag } from "lucide-react";
|
|
||||||
|
|
||||||
/** designspec.md §2.6 — only rendered when this store's cart group actually has items. */
|
/** designspec.md §2.6 — only rendered when this store's cart group actually has items. */
|
||||||
export function StickyCartBar({ itemCount, total }: { itemCount: number; total: number }) {
|
export function StickyCartBar({ itemCount, total }: { itemCount: number; total: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-[390px] flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
|
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-97.5 flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
|
||||||
|
{/* 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. */}
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<Link
|
<div className="flex flex-col items-start gap-2">
|
||||||
href="/cart"
|
|
||||||
className="flex h-[50px] items-center gap-2 rounded-full bg-primary-action px-6 text-white shadow-[0_6px_16px_-6px_rgba(0,0,0,0.078),0_2px_8px_-2px_rgba(0,0,0,0.071)]"
|
|
||||||
>
|
|
||||||
<span className="relative flex h-[18px] w-[18px] items-center justify-center">
|
|
||||||
<ShoppingBag className="h-[18px] w-[18px]" />
|
|
||||||
<span className="absolute -top-1.5 -left-1.5 flex h-[14px] min-w-[14px] items-center justify-center rounded-full bg-white text-[10px] font-bold text-primary-action">
|
|
||||||
{itemCount.toLocaleString("fa-IR")}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-body-large font-bold">مشاهده سبد</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-0.5">
|
|
||||||
<span className="text-[12px] leading-none text-gray-600">جمع کل</span>
|
<span className="text-[12px] leading-none text-gray-600">جمع کل</span>
|
||||||
<span className="text-[18px] font-bold leading-none text-foreground">
|
<span className="text-[18px] font-bold leading-none text-foreground">
|
||||||
{total.toLocaleString("fa-IR")} تومان
|
{total.toLocaleString("fa-IR")} تومان
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/cart"
|
||||||
|
className="flex h-12.5 items-center gap-2 rounded-xl bg-primary-action py-1 px-5 text-white shadow-[0_6px_16px_-6px_rgba(0,0,0,0.078),0_2px_8px_-2px_rgba(0,0,0,0.071)]"
|
||||||
|
>
|
||||||
|
<span className="text-body-large font-bold">مشاهده سبد</span>
|
||||||
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/20 text-[13px] font-bold text-white">
|
||||||
|
{itemCount.toLocaleString("fa-IR")}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 h-[5px] w-[134px] rounded-full bg-black" />
|
<div className="mt-2 h-1.5 w-32 rounded-full bg-black" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ import type { StoreDetail } from "@/types/api";
|
||||||
function todaysWorkingHours(store: StoreDetail) {
|
function todaysWorkingHours(store: StoreDetail) {
|
||||||
const jsDay = new Date().getDay();
|
const jsDay = new Date().getDay();
|
||||||
const iranianWeekday = (jsDay + 1) % 7;
|
const iranianWeekday = (jsDay + 1) % 7;
|
||||||
return store.working_hours.find((wh) => wh.weekday === iranianWeekday) ?? null;
|
return (
|
||||||
|
store.working_hours.find((wh) => wh.weekday === iranianWeekday) ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(value: string | null) {
|
function formatTime(value: string | null) {
|
||||||
|
|
@ -20,35 +22,52 @@ export function StoreInfoCard({ store }: { store: StoreDetail }) {
|
||||||
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${store.name} ${store.address}`)}`;
|
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${store.name} ${store.address}`)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 -mt-6 rounded-t-3xl bg-white px-4 pt-5 pb-4">
|
<div className="w-[90%] relative z-10 -mt-24 rounded-3xl bg-white px-4 pt-5 pb-4 shadow-[0px_4px_20px_0px_rgba(0,0,0,0.102)]">
|
||||||
<h1 className="text-title-large font-extrabold leading-none text-primary-action">{store.name}</h1>
|
<h1 className="text-title-large font-extrabold leading-none text-primary-action">
|
||||||
<p className="mt-1 text-[14px] leading-none text-gray-700">{store.category.name}</p>
|
{store.name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-[14px] leading-none text-gray-700">
|
||||||
|
{store.category.name}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
{today && (
|
|
||||||
<span className="rounded-full bg-teal-light px-2 py-1 text-[11px] font-semibold leading-none text-primary-action">
|
|
||||||
{today.is_closed ? "تعطیل" : `${formatTime(today.opens_at)} تا ${formatTime(today.closes_at)}`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[13px] leading-none text-gray-600">|</span>
|
|
||||||
<span
|
<span
|
||||||
className={`rounded-full border px-2.5 py-1.5 text-[12px] font-semibold leading-none ${
|
className={`rounded-full border px-2.5 py-1.5 text-[12px] font-semibold leading-none ${
|
||||||
store.is_open ? "border-emerald-border bg-emerald-bg text-emerald-text-strong" : "border-gray-300 bg-gray-100 text-gray-600"
|
store.is_open
|
||||||
|
? "border-emerald-border bg-emerald-bg text-emerald-text-strong"
|
||||||
|
: "border-gray-300 bg-gray-100 text-gray-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${store.is_open ? "bg-emerald-500" : "bg-gray-500"}`} />
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${store.is_open ? "bg-emerald-500" : "bg-gray-500"}`}
|
||||||
|
/>
|
||||||
{store.is_open ? "فعال" : "بسته"}
|
{store.is_open ? "فعال" : "بسته"}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-[13px] leading-none text-gray-600">|</span>
|
||||||
|
{today && (
|
||||||
|
<span className="h-6 flex items-center rounded-full bg-teal-light px-2 py-1 text-[11px] font-semibold leading-none text-primary-action">
|
||||||
|
{today.is_closed
|
||||||
|
? "تعطیل"
|
||||||
|
: `${formatTime(today.opens_at)} تا ${formatTime(today.closes_at)}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-2 rounded-[10px] border border-teal-border p-2">
|
<div className="mt-4 flex items-center gap-2 p-2">
|
||||||
<span className="flex h-8 w-10 shrink-0 items-center justify-center rounded-[10px] border border-teal-border bg-teal-light text-primary-action">
|
<span className="flex h-8 w-10 shrink-0 items-center justify-center rounded-[10px] border border-teal-border bg-teal-light text-primary-action">
|
||||||
<MapPin className="h-4 w-4" />
|
<MapPin className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate text-right text-[14px] leading-normal text-foreground">{store.address}</span>
|
<span className="flex-1 truncate text-right text-[14px] leading-normal text-foreground">
|
||||||
<a href={mapsUrl} target="_blank" rel="noreferrer" className="shrink-0 text-[13px] font-medium text-teal">
|
{store.address}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={mapsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="shrink-0 text-[13px] font-medium text-teal"
|
||||||
|
>
|
||||||
مسیریابی
|
مسیریابی
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -56,7 +75,7 @@ export function StoreInfoCard({ store }: { store: StoreDetail }) {
|
||||||
{store.phone_number && (
|
{store.phone_number && (
|
||||||
<a
|
<a
|
||||||
href={`tel:${store.phone_number}`}
|
href={`tel:${store.phone_number}`}
|
||||||
className="mt-2 flex items-center gap-2 rounded-[10px] border border-teal-border p-2"
|
className="mt-2 flex items-center gap-2 p-2"
|
||||||
>
|
>
|
||||||
<span className="flex h-8 w-10 shrink-0 items-center justify-center rounded-[10px] border border-teal-border bg-teal-light text-primary-action">
|
<span className="flex h-8 w-10 shrink-0 items-center justify-center rounded-[10px] border border-teal-border bg-teal-light text-primary-action">
|
||||||
<Phone className="h-4 w-4" />
|
<Phone className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,14 @@ const buttonVariants = cva(
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
// Tailwind's preflight doesn't zero out <button>'s default border/padding (only
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
// font/color/background/border-radius/opacity) — every variant that doesn't set
|
||||||
|
// its own border must reset it explicitly, or the browser's default UA button
|
||||||
|
// border/bevel survives underneath and throws off icon/label centering.
|
||||||
|
primary: "border-0 bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
secondary: "border-0 bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
outline: "border border-border bg-transparent hover:bg-muted",
|
outline: "border border-border bg-transparent hover:bg-muted",
|
||||||
ghost: "bg-transparent hover:bg-muted",
|
ghost: "border-0 bg-transparent hover:bg-muted",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-12 px-4 text-body-large",
|
default: "h-12 px-4 text-body-large",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export function HeroBackButton({ href }: { href: string }) {
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
aria-label="بازگشت"
|
aria-label="بازگشت"
|
||||||
className="absolute top-4 right-4 flex h-9 w-9 items-center justify-center rounded-full border border-white bg-white/80 backdrop-blur-md"
|
className="absolute top-4 right-4 flex h-9 w-9 items-center justify-center rounded-full bg-black/35 backdrop-blur-lg"
|
||||||
>
|
>
|
||||||
<ArrowRight className="h-[19.8px] w-[19.8px] text-white" />
|
<ArrowRight className="h-[19.8px] w-[19.8px] text-white" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
38
src/components/wallet/transaction-history.tsx
Normal file
38
src/components/wallet/transaction-history.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import React from 'react';
|
||||||
|
import TransactionItem from './transaction-item';
|
||||||
|
|
||||||
|
const transactions = [
|
||||||
|
{ id: 1, amount: 250000, description: 'واریز از کارت', type: 'deposit' },
|
||||||
|
{ id: 2, amount: 45000, description: 'پرداخت فاکتور', type: 'withdraw' },
|
||||||
|
{ id: 3, amount: 120000, description: 'واریز نقدی', type: 'deposit' },
|
||||||
|
{ id: 4, amount: 780000, description: 'حقوق ماهیانه', type: 'deposit' },
|
||||||
|
{ id: 5, amount: 320000, description: 'خرید از فروشگاه', type: 'withdraw' },
|
||||||
|
{ id: 6, amount: 560000, description: 'واریز چک', type: 'deposit' },
|
||||||
|
{ id: 7, amount: 95000, description: 'شارژ اینترنت', type: 'withdraw' },
|
||||||
|
{ id: 8, amount: 1500000, description: 'پرداخت وام', type: 'withdraw' },
|
||||||
|
{ id: 9, amount: 430000, description: 'واریز از همراه', type: 'deposit' },
|
||||||
|
{ id: 10, amount: 210000, description: 'خرید آنلاین', type: 'withdraw' },
|
||||||
|
{ id: 11, amount: 670000, description: 'پاداش عملکرد', type: 'deposit' },
|
||||||
|
{ id: 12, amount: 185000, description: 'پرداخت قبوض', type: 'withdraw' },
|
||||||
|
{ id: 13, amount: 920000, description: 'فروش کالا', type: 'deposit' },
|
||||||
|
{ id: 14, amount: 380000, description: 'انتقال به دوست', type: 'withdraw' },
|
||||||
|
{ id: 15, amount: 110000, description: 'بازگشت وجه', type: 'deposit' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TransactionHistory = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full my-8">
|
||||||
|
{transactions.map((item, index) => (
|
||||||
|
<TransactionItem
|
||||||
|
key={item.id}
|
||||||
|
amount={item.amount}
|
||||||
|
description={item.description}
|
||||||
|
type={item.type}
|
||||||
|
isLast={index === transactions.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TransactionHistory;
|
||||||
49
src/components/wallet/transaction-item.tsx
Normal file
49
src/components/wallet/transaction-item.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { ArrowDownIcon, ArrowUp } from 'lucide-react';
|
||||||
|
import { formatPriceWithToman } from '@/lib/utils/priceUtils';
|
||||||
|
|
||||||
|
interface TransactionItemProps {
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
type: 'deposit' | 'withdraw';
|
||||||
|
isLast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TransactionItem = ({
|
||||||
|
amount,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
isLast = false,
|
||||||
|
}: TransactionItemProps) => {
|
||||||
|
const isDeposit = type === 'deposit';
|
||||||
|
const Icon = isDeposit ? ArrowDownIcon : ArrowUp;
|
||||||
|
|
||||||
|
const amountColor = isDeposit ? 'text-success' : 'text-error';
|
||||||
|
const bgColor = isDeposit ? 'bg-success/10' : 'bg-error/10';
|
||||||
|
const iconColor = isDeposit ? 'text-success' : 'text-error';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex items-center justify-between w-full py-3
|
||||||
|
${!isLast ? 'border-b border-border' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className={`p-2 rounded-full ${bgColor}`}>
|
||||||
|
<Icon className={`w-5 h-5 rotate-45 ${iconColor}`} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-normal flex mr-4">{description}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<span className={`text-sm font-medium ${amountColor}`}>
|
||||||
|
{isDeposit ? '+' : '-'} {formatPriceWithToman(amount)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">۱۴۰۳/۰۸/۲۴</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TransactionItem;
|
||||||
52
src/components/wallet/wallet-blanc-card.tsx
Normal file
52
src/components/wallet/wallet-blanc-card.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { formatPriceWithToman } from '@/lib/utils/priceUtils';
|
||||||
|
import { LogOutIcon, PlusIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
const WalletBlancCard = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
w-full min-h-12 rounded-2xl my-6 py-6 px-6
|
||||||
|
flex flex-col justify-start
|
||||||
|
bg-gradient-to-l from-primary to-primary-action
|
||||||
|
shadow-[0_12px_24px] shadow-primary/25
|
||||||
|
border border-primary/25
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<h4 className="text-sm font-medium text-gray-300">
|
||||||
|
موجودی کیف پول
|
||||||
|
</h4>
|
||||||
|
<h1 className="text-2xl font-bold my-4 text-primary-foreground">
|
||||||
|
{formatPriceWithToman(35000000)}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex w-full gap-4">
|
||||||
|
<button
|
||||||
|
className="
|
||||||
|
w-full bg-white h-12 rounded-xl cursor-pointer
|
||||||
|
flex items-center justify-center gap-2
|
||||||
|
"
|
||||||
|
onClick={() => alert('latter...')}
|
||||||
|
>
|
||||||
|
<PlusIcon className="text-primary-action size-4" />
|
||||||
|
<span className="text-primary-action">شارژ کیف پول</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="
|
||||||
|
w-full h-12 rounded-xl cursor-pointer
|
||||||
|
flex items-center justify-center gap-2
|
||||||
|
border border-primary-foreground
|
||||||
|
"
|
||||||
|
onClick={() => alert('latter...')}
|
||||||
|
>
|
||||||
|
<LogOutIcon className="text-primary-foreground size-4 -rotate-90" />
|
||||||
|
<span className="text-primary-foreground">برداشت</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WalletBlancCard;
|
||||||
8
src/lib/api/account.ts
Normal file
8
src/lib/api/account.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { gooyalFetch } from "./gooyal-accounts";
|
||||||
|
import type { Account } from "@/types/api";
|
||||||
|
|
||||||
|
export async function getAccount(): Promise<Account> {
|
||||||
|
return gooyalFetch<Account>("/users/api/account/");
|
||||||
|
}
|
||||||
|
|
@ -46,17 +46,19 @@ function indent(text: string): string {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev-only request/response logging — the terminal-equivalent of Chrome's Network
|
* Dev-only request/response logging — the terminal-equivalent of Chrome's Network
|
||||||
* tab for server-side calls, since they run on the Next.js server and the browser
|
* tab for every server-side call this app makes (Winofy API, Gooyal accounts),
|
||||||
* never sees them (no CORS on the Winofy API). Never runs in production.
|
* since those run on the Next.js server and the browser never sees them. Shared
|
||||||
|
* across every backend this app talks to, labeled by `service` so interleaved
|
||||||
|
* calls stay easy to tell apart. Never runs in production.
|
||||||
*/
|
*/
|
||||||
export function logWinofyRequest(method: string, url: string, body: unknown) {
|
export function logApiRequest(service: string, method: string, url: string, body: unknown) {
|
||||||
if (!DEBUG) return;
|
if (!DEBUG) return;
|
||||||
console.log(`\n${CYAN}→ [winofy]${RESET} ${BOLD}${method}${RESET} ${url}`);
|
console.log(`\n${CYAN}→ [${service}]${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||||
if (body !== undefined) console.log(`${DIM} body:${RESET}\n${indent(prettyJson(body))}`);
|
if (body !== undefined) console.log(`${DIM} body:${RESET}\n${indent(prettyJson(body))}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logWinofyResponse(method: string, url: string, status: number, data: unknown) {
|
export function logApiResponse(service: string, method: string, url: string, status: number, data: unknown) {
|
||||||
if (!DEBUG) return;
|
if (!DEBUG) return;
|
||||||
console.log(`${statusColor(status)}← [winofy] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`);
|
console.log(`${statusColor(status)}← [${service}] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||||
if (data !== null && data !== undefined) console.log(`${DIM} data:${RESET}\n${indent(prettyJson(data))}`);
|
if (data !== null && data !== undefined) console.log(`${DIM} data:${RESET}\n${indent(prettyJson(data))}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
src/lib/api/gooyal-accounts.ts
Normal file
65
src/lib/api/gooyal-accounts.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import "server-only";
|
||||||
|
import { getValidSession } from "@/lib/auth/session";
|
||||||
|
import { ApiError, type WinofyErrorDetails } from "./errors";
|
||||||
|
import { logWinofyRequest, logWinofyResponse } from "./dev-logger";
|
||||||
|
|
||||||
|
const BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!;
|
||||||
|
|
||||||
|
interface GooyalFetchOptions extends Omit<RequestInit, "body"> {
|
||||||
|
auth?: boolean;
|
||||||
|
params?: Record<string, string | number | boolean | undefined>;
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gooyalFetch<T>(path: string, options: GooyalFetchOptions = {}): Promise<T> {
|
||||||
|
const { auth = true, params, headers, body, ...rest } = options;
|
||||||
|
const method = rest.method ?? "GET";
|
||||||
|
|
||||||
|
const url = new URL(BASE_URL.replace(/\/$/, "") + path);
|
||||||
|
if (params) {
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined) url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalHeaders = new Headers(headers);
|
||||||
|
const hasBody = body !== undefined;
|
||||||
|
if (hasBody && !finalHeaders.has("Content-Type")) {
|
||||||
|
finalHeaders.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth) {
|
||||||
|
const session = await getValidSession();
|
||||||
|
if (session) {
|
||||||
|
finalHeaders.set("Authorization", `Bearer ${session.accessToken}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logWinofyRequest?.(method, url.toString(), body);
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
...rest,
|
||||||
|
headers: finalHeaders,
|
||||||
|
body: hasBody ? JSON.stringify(body) : undefined,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
logWinofyResponse?.(method, url.toString(), res.status, null);
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
logWinofyResponse?.(method, url.toString(), res.status, data);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null;
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
envelope?.status_message ?? res.statusText,
|
||||||
|
envelope?.details ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import "server-only";
|
import "server-only";
|
||||||
import { getValidSession } from "@/lib/auth/session";
|
import { getValidSession } from "@/lib/auth/session";
|
||||||
import { ApiError, type WinofyErrorDetails } from "./errors";
|
import { ApiError, type WinofyErrorDetails } from "./errors";
|
||||||
import { logWinofyRequest, logWinofyResponse } from "./dev-logger";
|
import { logApiRequest, logApiResponse } from "./dev-logger";
|
||||||
|
|
||||||
const BASE_URL = process.env.WINOFY_API_BASE_URL!;
|
const BASE_URL = process.env.WINOFY_API_BASE_URL!;
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ export async function winofyFetch<T>(path: string, options: WinofyFetchOptions =
|
||||||
if (session) finalHeaders.set("Authorization", `Bearer ${session.accessToken}`);
|
if (session) finalHeaders.set("Authorization", `Bearer ${session.accessToken}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
logWinofyRequest(method, url.toString(), body);
|
logApiRequest("winofy", method, url.toString(), body);
|
||||||
|
|
||||||
const res = await fetch(url.toString(), {
|
const res = await fetch(url.toString(), {
|
||||||
...rest,
|
...rest,
|
||||||
|
|
@ -51,12 +51,12 @@ export async function winofyFetch<T>(path: string, options: WinofyFetchOptions =
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 204) {
|
if (res.status === 204) {
|
||||||
logWinofyResponse(method, url.toString(), res.status, null);
|
logApiResponse("winofy", method, url.toString(), res.status, null);
|
||||||
return undefined as T;
|
return undefined as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
logWinofyResponse(method, url.toString(), res.status, data);
|
logApiResponse("winofy", method, url.toString(), res.status, data);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null;
|
const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null;
|
||||||
|
|
|
||||||
|
|
@ -95,4 +95,4 @@ export async function exchangeOtpForToken(phoneNumber: string, otp: string) {
|
||||||
throw new GooyalClientError(res.status, data.error ?? "token_exchange_failed", data.error_description);
|
throw new GooyalClientError(res.status, data.error ?? "token_exchange_failed", data.error_description);
|
||||||
}
|
}
|
||||||
return data as GooyalTokenResponse;
|
return data as GooyalTokenResponse;
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { logApiRequest, logApiResponse } from "@/lib/api/dev-logger";
|
||||||
|
|
||||||
const ACCOUNTS_BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!;
|
const ACCOUNTS_BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!;
|
||||||
const CLIENT_ID = process.env.GOOYAL_CLIENT_ID!;
|
const CLIENT_ID = process.env.GOOYAL_CLIENT_ID!;
|
||||||
|
|
@ -34,11 +35,16 @@ export interface GooyalTokenResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshAccessToken(refreshToken: string): Promise<GooyalTokenResponse> {
|
export async function refreshAccessToken(refreshToken: string): Promise<GooyalTokenResponse> {
|
||||||
|
const url = `${ACCOUNTS_BASE_URL}/oauth2/token/`;
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
grant_type: "refresh_token",
|
grant_type: "refresh_token",
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
});
|
});
|
||||||
const res = await fetch(`${ACCOUNTS_BASE_URL}/oauth2/token/`, {
|
|
||||||
|
// Never print real tokens to the terminal — log the request shape, not the secret values.
|
||||||
|
logApiRequest("gooyal", "POST", url, { grant_type: "refresh_token", refresh_token: "<redacted>" });
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: basicAuthHeader(),
|
Authorization: basicAuthHeader(),
|
||||||
|
|
@ -48,6 +54,12 @@ export async function refreshAccessToken(refreshToken: string): Promise<GooyalTo
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
logApiResponse("gooyal", "POST", url, res.status, {
|
||||||
|
...data,
|
||||||
|
access_token: data.access_token ? "<redacted>" : undefined,
|
||||||
|
refresh_token: data.refresh_token ? "<redacted>" : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new GooyalAuthError(res.status, data.error ?? "refresh_failed", data.error_description);
|
throw new GooyalAuthError(res.status, data.error ?? "refresh_failed", data.error_description);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
src/lib/providers/account-provider.tsx
Normal file
42
src/lib/providers/account-provider.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
import type { Account } from "@/types/api";
|
||||||
|
|
||||||
|
type AccountContextValue = {
|
||||||
|
data: Account | null;
|
||||||
|
error: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AccountContext = createContext<AccountContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AccountProvider({
|
||||||
|
children,
|
||||||
|
account,
|
||||||
|
error = null,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
account: Account | null;
|
||||||
|
error?: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AccountContext.Provider
|
||||||
|
value={{
|
||||||
|
data: account,
|
||||||
|
error,
|
||||||
|
isLoading: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AccountContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccount() {
|
||||||
|
const ctx = useContext(AccountContext);
|
||||||
|
if (ctx === undefined) {
|
||||||
|
throw new Error("useAccount must be used within AccountProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
49
src/lib/utils/account-utils.ts
Normal file
49
src/lib/utils/account-utils.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAccount } from '@/lib/providers/account-provider';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function getInitialsHelper(account: {
|
||||||
|
name?: string | null;
|
||||||
|
first_name?: string | null;
|
||||||
|
last_name?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
}): string {
|
||||||
|
if (account.name?.trim()) {
|
||||||
|
const parts = account.name.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
return parts[0].slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.first_name || account.last_name) {
|
||||||
|
const first = account.first_name?.[0] ?? '';
|
||||||
|
const last = account.last_name?.[0] ?? '';
|
||||||
|
return (first + last).toUpperCase() || '؟';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.username) {
|
||||||
|
return account.username.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return '؟';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayNameHelper(account: {
|
||||||
|
name?: string | null;
|
||||||
|
first_name?: string | null;
|
||||||
|
last_name?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
}): string {
|
||||||
|
if (account.name?.trim()) return account.name.trim();
|
||||||
|
|
||||||
|
const fullName = [account.first_name, account.last_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (fullName) return fullName;
|
||||||
|
|
||||||
|
return account.username || 'کاربر';
|
||||||
|
}
|
||||||
42
src/lib/utils/priceUtils.ts
Normal file
42
src/lib/utils/priceUtils.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* Converts Rial to Toman (divides by 10)
|
||||||
|
*/
|
||||||
|
export const convertRialToToman = (rialAmount: number): number => {
|
||||||
|
return Math.round(rialAmount / 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats number with comma separators (e.g., 3,000,000)
|
||||||
|
*/
|
||||||
|
export const formatNumberWithCommas = (amount: number): string => {
|
||||||
|
return amount.toLocaleString('fa-IR'); // for Persian digits
|
||||||
|
// Or for English digits: return amount.toLocaleString('en-US');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function: receives amount in Rial or Toman, returns formatted with comma separators and "Toman" label
|
||||||
|
*
|
||||||
|
* @param amount - input amount (in Rial or Toman)
|
||||||
|
* @param inputIsRial - whether the input amount is in Rial (default: true)
|
||||||
|
* @returns string - formatted amount with Toman label (e.g., "۳,۰۰۰,۰۰۰ تومان")
|
||||||
|
*/
|
||||||
|
export const formatPriceWithToman = (
|
||||||
|
amount: number,
|
||||||
|
inputIsRial: boolean = true
|
||||||
|
): string => {
|
||||||
|
// If amount is in Rial, convert to Toman first
|
||||||
|
const tomanAmount = inputIsRial ? convertRialToToman(amount) : amount;
|
||||||
|
|
||||||
|
// Add comma separators
|
||||||
|
const formatted = formatNumberWithCommas(tomanAmount);
|
||||||
|
|
||||||
|
// Return with Toman label
|
||||||
|
return `${formatted} تومان`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simpler version: just take an amount in Toman and format it
|
||||||
|
*/
|
||||||
|
export const formatTomanPrice = (tomanAmount: number): string => {
|
||||||
|
return `${formatNumberWithCommas(tomanAmount)} تومان`;
|
||||||
|
};
|
||||||
|
|
@ -310,3 +310,42 @@ export interface SellerAnalytics {
|
||||||
chart: SalesChartPoint[];
|
chart: SalesChartPoint[];
|
||||||
top_products: TopProduct[];
|
top_products: TopProduct[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---- Account / Profile ----------------------------------------------------
|
||||||
|
|
||||||
|
export type Gender = "m" | "f" | "other" | null;
|
||||||
|
export type EducationDegree =
|
||||||
|
| "primary"
|
||||||
|
| "secondary"
|
||||||
|
| "diploma"
|
||||||
|
| "bachelor"
|
||||||
|
| "master"
|
||||||
|
| "phd"
|
||||||
|
| string
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
uuid: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
phone_number: string | null;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
gender: Gender;
|
||||||
|
education_degree: EducationDegree;
|
||||||
|
education_field: string | null;
|
||||||
|
birthdate: string | null; // "YYYY-MM-DD"
|
||||||
|
iban: string | null;
|
||||||
|
iban_verified: boolean;
|
||||||
|
province: number | null;
|
||||||
|
province_name: string | null;
|
||||||
|
city: number | null;
|
||||||
|
city_name: string | null;
|
||||||
|
postal_code: string | null;
|
||||||
|
address: string | null;
|
||||||
|
date_joined: string;
|
||||||
|
last_update: string;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue