Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6428459313 | |||
| 2a8b6a7b62 |
@@ -15,6 +15,7 @@ Use this file first.
|
|||||||
- `docs/adr/`: architecture decisions
|
- `docs/adr/`: architecture decisions
|
||||||
- `docs/runbooks/`: operational response guides
|
- `docs/runbooks/`: operational response guides
|
||||||
- `docs/templates/`: ADR/runbook templates
|
- `docs/templates/`: ADR/runbook templates
|
||||||
|
- `docs/frontend-spec-requirements.md`: frontend technical specs + requirements (customer MVP)
|
||||||
|
|
||||||
## Update Rules (short)
|
## Update Rules (short)
|
||||||
- Behavior/flow changes: update architecture + affected runbook.
|
- Behavior/flow changes: update architecture + affected runbook.
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# Frontend Technical Specs and Requirements (MVP)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Define the implementation contract for the React frontend against the current backend REST API so the customer flows (auth, search, booking, payment, profile) can ship reliably for KSA.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
In scope:
|
||||||
|
- Customer web app (React + Vite) for Phase 1 flows.
|
||||||
|
- API integration contracts for current backend endpoints.
|
||||||
|
- UX/error/loading behavior and test requirements.
|
||||||
|
- i18n/RTL and KSA timezone handling.
|
||||||
|
|
||||||
|
Out of scope (Phase 2+):
|
||||||
|
- Manager/staff admin dashboards.
|
||||||
|
- Advanced reporting/reviews moderation tools.
|
||||||
|
- Full observability product dashboards.
|
||||||
|
|
||||||
|
## Product and Platform Constraints
|
||||||
|
- Market default: KSA first.
|
||||||
|
- Default locale: `ar-sa`; fallback: `en`.
|
||||||
|
- Timezone baseline: `Asia/Riyadh` (`+03:00`).
|
||||||
|
- API error contract: HTTP status + `detail` where applicable.
|
||||||
|
- Booking/payment operations must avoid duplicate side effects.
|
||||||
|
|
||||||
|
## UX Priorities (Primary Contract)
|
||||||
|
- First screen for authenticated and guest users MUST be a feed of nearby/available salons.
|
||||||
|
- The feed is the default home experience and primary entry to booking.
|
||||||
|
- Main customer navigation MUST be bottom tabs (mobile-first), not header-first navigation.
|
||||||
|
- Bottom tabs MUST include at minimum:
|
||||||
|
- Home/Feed
|
||||||
|
- Bookings
|
||||||
|
- Profile
|
||||||
|
- Optional tabs (when implemented) may include Search/Explore and Payments, but must not displace Home/Feed as default.
|
||||||
|
|
||||||
|
## Frontend Architecture Requirements
|
||||||
|
|
||||||
|
## Runtime and Stack
|
||||||
|
- React 18 + Vite.
|
||||||
|
- React Router (`BrowserRouter`) for route navigation.
|
||||||
|
- `i18next` + `react-i18next` for translations and direction switching.
|
||||||
|
- `fetch` wrapper in `src/api/client.js` as single API boundary.
|
||||||
|
- Auth/session state in `AuthContext`.
|
||||||
|
|
||||||
|
## Route Map (Customer)
|
||||||
|
- `/` home feed (nearby/available salons) + search/filter.
|
||||||
|
- `/salon/:id` salon detail.
|
||||||
|
- `/login` phone OTP login.
|
||||||
|
- `/book?salon=<id>` booking creation.
|
||||||
|
- `/pay?booking=<id>` payment initiation.
|
||||||
|
- `/pay/return` payment callback/return surface.
|
||||||
|
- `/bookings` customer booking history.
|
||||||
|
- `/profile` customer profile summary.
|
||||||
|
|
||||||
|
## Module Boundaries
|
||||||
|
- `src/api/`: all HTTP logic, standardized errors.
|
||||||
|
- `src/contexts/`: auth/session lifecycle only.
|
||||||
|
- `src/hooks/`: domain-side UI logic (`useSalonSearch`, `usePaymentForm`).
|
||||||
|
- `src/pages/`: route-level composition.
|
||||||
|
- `src/components/`: reusable presentation and guarded wrappers.
|
||||||
|
- `src/i18n/`: locale dictionaries and locale/direction state.
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
### FR-1 Phone-First Authentication
|
||||||
|
- Login MUST use:
|
||||||
|
- `POST /api/auth/phone/request/`
|
||||||
|
- `POST /api/auth/phone/verify/`
|
||||||
|
- Password auth endpoint (`/api/auth/token/`) MUST NOT be used (returns 410).
|
||||||
|
- Login request form MUST collect:
|
||||||
|
- `phone_number` (accept KSA local or E.164 input)
|
||||||
|
- `channel` (`sms` or `whatsapp`)
|
||||||
|
- optional: `device_id` (recommended for abuse controls)
|
||||||
|
- Verify step MUST submit `request_id` + 6-digit `code`.
|
||||||
|
- On success, frontend MUST persist `access` and `refresh` tokens and user payload.
|
||||||
|
|
||||||
|
### FR-2 Session Restore and Token Refresh
|
||||||
|
- On app boot, if `access` exists:
|
||||||
|
- call `GET /api/auth/me/`.
|
||||||
|
- If `401`/token invalid:
|
||||||
|
- call `POST /api/auth/token/refresh/` once.
|
||||||
|
- retry `GET /api/auth/me/` with new access token.
|
||||||
|
- If refresh fails, frontend MUST clear tokens and require re-login.
|
||||||
|
|
||||||
|
### FR-3 Salon Discovery
|
||||||
|
- Home MUST render a salon feed by default on first load.
|
||||||
|
- Feed data MUST come from `GET /api/salons/` and support query params for discovery.
|
||||||
|
- Home search MUST call `GET /api/salons/?q=<query>`.
|
||||||
|
- Search SHOULD support additional filters when UI is added:
|
||||||
|
- `city`
|
||||||
|
- `service`
|
||||||
|
- Nearby/available ranking can be client-side initially, but server response MUST remain source of truth.
|
||||||
|
- Result cards MUST link to `/salon/:id`.
|
||||||
|
|
||||||
|
### FR-4 Salon Detail
|
||||||
|
- Detail page MUST call `GET /api/salons/:id/`.
|
||||||
|
- UI MUST render:
|
||||||
|
- salon base info
|
||||||
|
- services (duration, amount, currency)
|
||||||
|
- staff list
|
||||||
|
- optional reviews/photos if present
|
||||||
|
- CTA MUST deep-link to booking flow with salon id.
|
||||||
|
|
||||||
|
### FR-5 Booking Creation
|
||||||
|
- Booking page MUST require authenticated user.
|
||||||
|
- Create booking with `POST /api/bookings/` using:
|
||||||
|
- `service`
|
||||||
|
- `staff` (required)
|
||||||
|
- `start_time`
|
||||||
|
- `end_time`
|
||||||
|
- optional `notes`
|
||||||
|
- `end_time` MUST match service duration exactly.
|
||||||
|
- Datetime submitted to backend MUST include explicit offset (`+03:00` for KSA baseline).
|
||||||
|
- On success (`201`), frontend MUST navigate to payment flow with booking id.
|
||||||
|
|
||||||
|
### FR-6 Booking History
|
||||||
|
- `/bookings` MUST call authenticated `GET /api/bookings/`.
|
||||||
|
- List MUST show booking id, status, salon/service labels, datetime, and price.
|
||||||
|
- Datetime rendering MUST use active locale formatting.
|
||||||
|
|
||||||
|
### FR-7 Payment Initiation (Idempotent)
|
||||||
|
- Payment submission MUST call `POST /api/payments/`.
|
||||||
|
- Payload requirements:
|
||||||
|
- `booking_id` (number)
|
||||||
|
- `provider` = `moyasar`
|
||||||
|
- `idempotency_key` (UUID)
|
||||||
|
- `source` object with supported type (`stcpay`, `token`, `applepay`, `samsungpay`)
|
||||||
|
- `callback_url` required for `source.type=token`
|
||||||
|
- Frontend MUST disable duplicate submits while request is in-flight.
|
||||||
|
- Same payment attempt retry MUST reuse the same `idempotency_key`.
|
||||||
|
- New attempt MUST generate a new key.
|
||||||
|
- If response includes `redirect_url`, frontend MUST redirect.
|
||||||
|
|
||||||
|
### FR-8 Payment Return Handling
|
||||||
|
- `/pay/return` MUST parse query params:
|
||||||
|
- `status`
|
||||||
|
- `id`
|
||||||
|
- Success statuses shown as success UX: `paid`, `captured`, `authorized`.
|
||||||
|
- Non-success statuses MUST show neutral/pending/failure guidance and link to profile/bookings.
|
||||||
|
|
||||||
|
### FR-9 Locale and Direction
|
||||||
|
- App MUST allow switching between `ar-sa` and `en`.
|
||||||
|
- Locale switch MUST:
|
||||||
|
- persist preference in local storage
|
||||||
|
- set `<html lang>`
|
||||||
|
- set `<html dir>` (`rtl` for `ar-sa`, `ltr` for `en`)
|
||||||
|
- API calls MUST include `Accept-Language` header with active locale.
|
||||||
|
|
||||||
|
## API Contract Requirements
|
||||||
|
|
||||||
|
| Endpoint | Auth | Request | Success | Error handling |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `POST /api/auth/phone/request/` | No | `phone_number`, `channel`, optional profile fields | `201` with `request_id`, `expires_at` | `429` may include `retry_after_seconds`; show wait message |
|
||||||
|
| `POST /api/auth/phone/verify/` | No | `request_id`, `code` | `200` with `access`, `refresh`, `user` | `400` invalid/expired code |
|
||||||
|
| `POST /api/auth/token/refresh/` | No | `refresh` | `200` with new `access` | logout on failure |
|
||||||
|
| `GET /api/auth/me/` | Bearer | - | `200` user payload | `401` triggers refresh flow |
|
||||||
|
| `GET /api/salons/` | No | `q`, optional `city`, `service` | `200` list | show localized generic fetch error |
|
||||||
|
| `GET /api/salons/:id/` | No | - | `200` detail object | show detail/fallback |
|
||||||
|
| `POST /api/bookings/` | Bearer | booking payload | `201` booking | `400` field validation errors |
|
||||||
|
| `GET /api/bookings/` | Bearer | - | `200` list | auth + generic errors |
|
||||||
|
| `POST /api/payments/` | Bearer | payment payload | `201` created or `200` reused idempotent record | `400/403` with details; never auto-retry with new key |
|
||||||
|
|
||||||
|
## Error and State Handling Requirements
|
||||||
|
- API wrapper MUST throw structured errors with:
|
||||||
|
- HTTP status
|
||||||
|
- parsed response body
|
||||||
|
- best message (`detail` first, fallback to response text)
|
||||||
|
- For validation objects (`{field: [msg]}`), UI SHOULD render first field message near form and keep raw object in debug logs.
|
||||||
|
- For `429` with `retry_after_seconds`, UI MUST display server-provided cooldown.
|
||||||
|
- All mutating forms MUST expose:
|
||||||
|
- idle/loading/error/success states
|
||||||
|
- submit button disabled while loading
|
||||||
|
|
||||||
|
## Security and Abuse-Resistance Requirements
|
||||||
|
- Use Bearer access token for authenticated endpoints only.
|
||||||
|
- Include optional `device_id` during phone auth request to strengthen backend abuse controls.
|
||||||
|
- Never send raw card PAN/CVV data to backend; use tokenized sources only.
|
||||||
|
- On logout, clear user and both tokens from memory + storage.
|
||||||
|
|
||||||
|
## Accessibility and UX Requirements
|
||||||
|
- All interactive controls MUST have accessible labels.
|
||||||
|
- Auth/booking/payment forms MUST be keyboard usable.
|
||||||
|
- Error text MUST be visible and associated with active form context.
|
||||||
|
- Layout MUST remain usable on mobile widths (`>=320px`) and desktop.
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
- Reliability: no duplicate payment submission side effects for one attempt.
|
||||||
|
- Consistency: API errors surfaced predictably and localized where available.
|
||||||
|
- Maintainability: domain behavior in hooks/services, not route components.
|
||||||
|
- Extensibility: route/module structure must support manager/staff pages later without rewrite.
|
||||||
|
|
||||||
|
## Test Requirements (Frontend)
|
||||||
|
- Test stack: `vitest` + Testing Library.
|
||||||
|
- Required coverage for release:
|
||||||
|
- phone login request + verify success/failure + 429 cooldown message
|
||||||
|
- auth restore and refresh-token fallback
|
||||||
|
- protected route redirect behavior
|
||||||
|
- salon search loading/empty/results/error
|
||||||
|
- booking form validation + API error mapping + success redirect
|
||||||
|
- payment form source validation + idempotency key reuse on retry + redirect behavior
|
||||||
|
- locale switching persists and sets `lang`/`dir`
|
||||||
|
- bookings list rendering and localized datetime output
|
||||||
|
|
||||||
|
Run:
|
||||||
|
- `cd frontend && npm run test`
|
||||||
|
|
||||||
|
## Definition of Done (Frontend)
|
||||||
|
- All FR requirements implemented for in-scope routes.
|
||||||
|
- API integrations match endpoint/payload contract above.
|
||||||
|
- No use of deprecated password login API.
|
||||||
|
- All listed frontend tests pass.
|
||||||
|
- `ar-sa` and `en` UX verified on mobile + desktop.
|
||||||
|
|
||||||
|
## Known Dependencies and Open Decisions
|
||||||
|
- OAuth/social-linking policy is not finalized; keep social login UI hidden for now.
|
||||||
|
- Cancellation and refund policies are not finalized; do not ship irreversible customer actions until policy finalization.
|
||||||
|
- Detailed business-hours/timezone policy beyond current backend validation remains open; keep KSA-offset submission and avoid client-side assumptions that override server validation.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import LocaleSwitch from "./LocaleSwitch";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
|
||||||
|
describe("LocaleSwitch", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists locale and sets html lang/dir", async () => {
|
||||||
|
render(<LocaleSwitch />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "العربية" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.documentElement.lang).toBe("ar-sa");
|
||||||
|
});
|
||||||
|
expect(document.documentElement.dir).toBe("rtl");
|
||||||
|
expect(localStorage.getItem("locale")).toBe("ar-sa");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,15 +33,6 @@ export default function PaymentForm({ bookingId = "", token = "" }) {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
|
||||||
<span>{t("payment.accessToken")}</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={form.tokenInput}
|
|
||||||
onChange={(e) => form.setTokenInput(e.target.value)}
|
|
||||||
placeholder={t("payment.accessTokenPlaceholder")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t("payment.sourceType")}</span>
|
<span>{t("payment.sourceType")}</span>
|
||||||
<select
|
<select
|
||||||
@@ -51,6 +42,7 @@ export default function PaymentForm({ bookingId = "", token = "" }) {
|
|||||||
<option value="stcpay">{t("payment.sources.stcpay")}</option>
|
<option value="stcpay">{t("payment.sources.stcpay")}</option>
|
||||||
<option value="token">{t("payment.sources.token")}</option>
|
<option value="token">{t("payment.sources.token")}</option>
|
||||||
<option value="applepay">{t("payment.sources.applepay")}</option>
|
<option value="applepay">{t("payment.sources.applepay")}</option>
|
||||||
|
<option value="samsungpay">{t("payment.sources.samsungpay")}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
import PaymentForm from "./PaymentForm";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { apiPost } = await import("../api/client");
|
||||||
|
|
||||||
|
describe("PaymentForm", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
Object.defineProperty(globalThis, "crypto", {
|
||||||
|
value: { randomUUID: () => "uuid-1" },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates source details for stc pay", async () => {
|
||||||
|
render(<PaymentForm bookingId="12" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Pay now" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Mobile number is required for stc pay.")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses idempotency key on retry", async () => {
|
||||||
|
apiPost.mockRejectedValueOnce(new Error("fail"));
|
||||||
|
apiPost.mockResolvedValueOnce({ id: "ok" });
|
||||||
|
render(<PaymentForm bookingId="12" />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Source value"), {
|
||||||
|
target: { value: "+966500000000" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Pay now" }));
|
||||||
|
await screen.findByText("fail");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Pay now" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(apiPost).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
const firstPayload = apiPost.mock.calls[0][1];
|
||||||
|
const secondPayload = apiPost.mock.calls[1][1];
|
||||||
|
expect(firstPayload.idempotency_key).toBe("uuid-1");
|
||||||
|
expect(secondPayload.idempotency_key).toBe("uuid-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects when response includes redirect_url", async () => {
|
||||||
|
const assign = vi.fn();
|
||||||
|
Object.defineProperty(window.location, "assign", {
|
||||||
|
value: assign,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
apiPost.mockResolvedValueOnce({ redirect_url: "https://pay.test/redirect" });
|
||||||
|
render(<PaymentForm bookingId="12" />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Source value"), {
|
||||||
|
target: { value: "+966500000000" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Pay now" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(assign).toHaveBeenCalledWith("https://pay.test/redirect");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables submit while loading", async () => {
|
||||||
|
let resolveRequest;
|
||||||
|
apiPost.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveRequest = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<PaymentForm bookingId="12" />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Source value"), {
|
||||||
|
target: { value: "+966500000000" },
|
||||||
|
});
|
||||||
|
const button = screen.getByRole("button", { name: "Pay now" });
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
|
||||||
|
resolveRequest({ id: "done" });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import ProtectedRoute from "./ProtectedRoute";
|
||||||
|
import { AuthProvider } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
function renderProtected(initialEntries = ["/protected"]) {
|
||||||
|
return render(
|
||||||
|
<AuthProvider>
|
||||||
|
<MemoryRouter initialEntries={initialEntries}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/protected"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div>Secret</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/login" element={<div>Login</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProtectedRoute", () => {
|
||||||
|
it("redirects unauthenticated users to login", async () => {
|
||||||
|
renderProtected();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Login")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,4 +39,12 @@ describe("SalonSearch", () => {
|
|||||||
});
|
});
|
||||||
expect(screen.getByText("Riyadh")).toBeInTheDocument();
|
expect(screen.getByText("Riyadh")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows error state when api fails", async () => {
|
||||||
|
apiGet.mockRejectedValueOnce(new Error("boom"));
|
||||||
|
renderWithRouter(<SalonSearch query="fail" />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/unable to load salons/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
import { apiGet, apiPost } from "../api/client";
|
import { apiGet, apiPost, ApiError } from "../api/client";
|
||||||
|
|
||||||
const STORAGE_ACCESS = "auth_access";
|
const STORAGE_ACCESS = "auth_access";
|
||||||
const STORAGE_REFRESH = "auth_refresh";
|
const STORAGE_REFRESH = "auth_refresh";
|
||||||
@@ -50,7 +50,14 @@ export function AuthProvider({ children }) {
|
|||||||
setUser(data);
|
setUser(data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
|
const status = ApiError && err instanceof ApiError ? err.status : err?.status;
|
||||||
|
const message = typeof err?.message === "string" ? err.message : "";
|
||||||
|
const isUnauthorized = status === 401 || message.includes("401");
|
||||||
|
if (!isUnauthorized) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Token invalid, try refresh
|
// Token invalid, try refresh
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
logout();
|
logout();
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
import { AuthProvider, useAuth } from "./AuthContext";
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { apiGet, apiPost } = await import("../api/client");
|
||||||
|
|
||||||
|
function AuthProbe() {
|
||||||
|
const { user, isAuthenticated, loading } = useAuth();
|
||||||
|
if (loading) return <div>Loading</div>;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>auth:{isAuthenticated ? "yes" : "no"}</div>
|
||||||
|
<div>user:{user ? user.name : "none"}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AuthContext", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores session with refresh fallback", async () => {
|
||||||
|
localStorage.setItem("auth_access", "expired");
|
||||||
|
localStorage.setItem("auth_refresh", "refresh-token");
|
||||||
|
|
||||||
|
apiGet.mockRejectedValueOnce(new Error("401"));
|
||||||
|
apiPost.mockResolvedValueOnce({ access: "new-access" });
|
||||||
|
apiGet.mockResolvedValueOnce({ name: "Sara" });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthProbe />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("auth:yes")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("user:Sara")).toBeInTheDocument();
|
||||||
|
expect(apiPost).toHaveBeenCalledWith("/auth/token/refresh/", { refresh: "refresh-token" });
|
||||||
|
expect(apiGet).toHaveBeenCalledWith("/auth/me/", "new-access");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears tokens on refresh failure", async () => {
|
||||||
|
localStorage.setItem("auth_access", "expired");
|
||||||
|
localStorage.setItem("auth_refresh", "refresh-token");
|
||||||
|
|
||||||
|
apiGet.mockRejectedValueOnce(new Error("401"));
|
||||||
|
apiPost.mockRejectedValueOnce(new Error("refresh failed"));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthProbe />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("auth:no")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(localStorage.getItem("auth_access")).toBeNull();
|
||||||
|
expect(localStorage.getItem("auth_refresh")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { apiPost } from "../api/client";
|
import { apiPost, ApiError } from "../api/client";
|
||||||
|
|
||||||
function generateIdempotencyKey() {
|
function generateIdempotencyKey() {
|
||||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||||
@@ -13,16 +13,8 @@ function generateIdempotencyKey() {
|
|||||||
const AUTH_TOKEN_KEY = "auth_access";
|
const AUTH_TOKEN_KEY = "auth_access";
|
||||||
|
|
||||||
export function usePaymentForm(bookingId = "", token = "") {
|
export function usePaymentForm(bookingId = "", token = "") {
|
||||||
// token: optional auth token from AuthContext; tokenInput: manual override from form
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [bookingIdInput, setBookingIdInput] = useState(bookingId);
|
const [bookingIdInput, setBookingIdInput] = useState(bookingId);
|
||||||
const [tokenInput, setTokenInput] = useState(() => {
|
|
||||||
if (token) return token;
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
return localStorage.getItem(AUTH_TOKEN_KEY) || "";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
const [sourceType, setSourceType] = useState("stcpay");
|
const [sourceType, setSourceType] = useState("stcpay");
|
||||||
const [sourceValue, setSourceValue] = useState("");
|
const [sourceValue, setSourceValue] = useState("");
|
||||||
const [callbackUrl, setCallbackUrl] = useState(() => {
|
const [callbackUrl, setCallbackUrl] = useState(() => {
|
||||||
@@ -34,22 +26,33 @@ export function usePaymentForm(bookingId = "", token = "") {
|
|||||||
const [status, setStatus] = useState("idle");
|
const [status, setStatus] = useState("idle");
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const idempotencyRef = useRef(null);
|
||||||
|
if (!idempotencyRef.current) {
|
||||||
|
idempotencyRef.current = generateIdempotencyKey();
|
||||||
|
}
|
||||||
|
const [idempotencyKey, setIdempotencyKey] = useState(idempotencyRef.current);
|
||||||
|
const lastBookingId = useRef(bookingId);
|
||||||
|
|
||||||
const idempotencyKey = useMemo(generateIdempotencyKey, []);
|
useEffect(() => {
|
||||||
|
if (lastBookingId.current !== bookingIdInput) {
|
||||||
// Persist token to localStorage when it changes
|
lastBookingId.current = bookingIdInput;
|
||||||
const setTokenInputAndPersist = (value) => {
|
const nextKey = generateIdempotencyKey();
|
||||||
setTokenInput(value);
|
idempotencyRef.current = nextKey;
|
||||||
if (typeof window !== "undefined") {
|
setIdempotencyKey(nextKey);
|
||||||
if (value) {
|
|
||||||
localStorage.setItem(AUTH_TOKEN_KEY, value);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
}, [bookingIdInput]);
|
||||||
|
|
||||||
|
function getFirstFieldError(body) {
|
||||||
|
if (!body || typeof body !== "object") return "";
|
||||||
|
for (const value of Object.values(body)) {
|
||||||
|
if (Array.isArray(value) && value.length) return value[0];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
if (status === "loading") return;
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
setError("");
|
setError("");
|
||||||
setResult(null);
|
setResult(null);
|
||||||
@@ -75,39 +78,56 @@ export function usePaymentForm(bookingId = "", token = "") {
|
|||||||
setError(t("payment.errors.tokenRequired"));
|
setError(t("payment.errors.tokenRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!callbackUrl) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(t("payment.errors.callbackRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
source.token = sourceValue;
|
source.token = sourceValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
booking_id: Number(bookingIdInput),
|
booking_id: Number(bookingIdInput),
|
||||||
provider: "moyasar",
|
provider: "moyasar",
|
||||||
idempotency_key: idempotencyKey,
|
idempotency_key: idempotencyRef.current,
|
||||||
source,
|
source,
|
||||||
};
|
};
|
||||||
if (callbackUrl) {
|
if (callbackUrl) {
|
||||||
payload.callback_url = callbackUrl;
|
payload.callback_url = callbackUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use tokenInput (form state) so user edits are respected; token prop only initializes it
|
|
||||||
const authToken = tokenInput;
|
|
||||||
try {
|
try {
|
||||||
const data = await apiPost("/payments/", payload, authToken || undefined);
|
const fallbackToken =
|
||||||
|
token ||
|
||||||
|
(typeof window !== "undefined"
|
||||||
|
? localStorage.getItem(AUTH_TOKEN_KEY) || ""
|
||||||
|
: "");
|
||||||
|
const data = await apiPost("/payments/", payload, fallbackToken || undefined);
|
||||||
setResult(data);
|
setResult(data);
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
if (data?.redirect_url) {
|
if (data?.redirect_url) {
|
||||||
window.location.assign(data.redirect_url);
|
window.location.assign(data.redirect_url);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (ApiError && err instanceof ApiError && err.body) {
|
||||||
|
const fieldMessage = getFirstFieldError(err.body);
|
||||||
|
if (fieldMessage) {
|
||||||
|
console.warn("Payment validation error", err.body);
|
||||||
|
setStatus("error");
|
||||||
|
setError(fieldMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
(typeof err?.message === "string" && err.message) || String(err) || "";
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setError(err.message || t("payment.errors.generic"));
|
setError(message || t("payment.errors.generic"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookingIdInput,
|
bookingIdInput,
|
||||||
setBookingIdInput,
|
setBookingIdInput,
|
||||||
tokenInput,
|
|
||||||
setTokenInput: setTokenInputAndPersist,
|
|
||||||
sourceType,
|
sourceType,
|
||||||
setSourceType,
|
setSourceType,
|
||||||
sourceValue,
|
sourceValue,
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ export function useSalonSearch(query) {
|
|||||||
async function load() {
|
async function load() {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
try {
|
try {
|
||||||
const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`);
|
const params = new URLSearchParams();
|
||||||
|
if (query) {
|
||||||
|
params.set("q", query);
|
||||||
|
}
|
||||||
|
const path = params.toString() ? `/salons/?${params.toString()}` : "/salons/";
|
||||||
|
const data = await apiGet(path);
|
||||||
if (!ignore) {
|
if (!ignore) {
|
||||||
setSalons(data);
|
setSalons(data);
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
|
|||||||
@@ -16,7 +16,84 @@
|
|||||||
"phoneUnavailable": "الهاتف غير متوفر",
|
"phoneUnavailable": "الهاتف غير متوفر",
|
||||||
"viewDetails": "عرض التفاصيل والحجز"
|
"viewDetails": "عرض التفاصيل والحجز"
|
||||||
},
|
},
|
||||||
"nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق","unknownStaff":"موظف {{id}}"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
|
"nav": {
|
||||||
|
"home": "الرئيسية",
|
||||||
|
"book": "احجز",
|
||||||
|
"pay": "ادفع",
|
||||||
|
"profile": "الحساب",
|
||||||
|
"bookings": "حجوزاتي",
|
||||||
|
"login": "تسجيل الدخول",
|
||||||
|
"logout": "تسجيل الخروج"
|
||||||
|
},
|
||||||
|
"book": {
|
||||||
|
"title": "احجزي خدمة",
|
||||||
|
"placeholder": "تدفق الحجز قريباً.",
|
||||||
|
"cta": "احجزي الآن",
|
||||||
|
"selectSalon": "اختاري صالوناً من الصفحة الرئيسية للحجز.",
|
||||||
|
"service": "الخدمة",
|
||||||
|
"staff": "الفريق",
|
||||||
|
"date": "التاريخ",
|
||||||
|
"time": "الوقت",
|
||||||
|
"notes": "ملاحظات",
|
||||||
|
"notesPlaceholder": "ملاحظات اختيارية",
|
||||||
|
"selectService": "اختاري الخدمة",
|
||||||
|
"selectStaff": "اختاري الفني",
|
||||||
|
"submit": "تأكيد الحجز",
|
||||||
|
"submitting": "جاري الحجز...",
|
||||||
|
"errors": {
|
||||||
|
"fillAll": "يرجى تعبئة جميع الحقول.",
|
||||||
|
"invalidTime": "تاريخ أو وقت غير صالح.",
|
||||||
|
"generic": "فشل الحجز."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"salon": {
|
||||||
|
"services": "الخدمات",
|
||||||
|
"staff": "الفريق",
|
||||||
|
"unknownStaff": "موظف {{id}}"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "الحساب",
|
||||||
|
"placeholder": "الملف والحجوزات قريباً.",
|
||||||
|
"myBookings": "حجوزاتي",
|
||||||
|
"noContact": "لا توجد معلومات اتصال"
|
||||||
|
},
|
||||||
|
"bookings": {
|
||||||
|
"title": "حجوزاتي",
|
||||||
|
"subtitle": "مواعيدك القادمة والسابقة.",
|
||||||
|
"empty": "لا توجد حجوزات بعد.",
|
||||||
|
"pay": "ادفع الآن"
|
||||||
|
},
|
||||||
|
"paymentReturn": {
|
||||||
|
"title": "حالة الدفع",
|
||||||
|
"success": "تم الدفع بنجاح",
|
||||||
|
"successMessage": "تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.",
|
||||||
|
"checkStatus": "تحققي من بريدك أو الصالون لحالة الدفع.",
|
||||||
|
"reference": "المرجع: {{id}}",
|
||||||
|
"viewBookings": "عرض حجوزاتي"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "تسجيل الدخول",
|
||||||
|
"subtitle": "أدخلي رقم جوالك لاستلام رمز التحقق.",
|
||||||
|
"phone": "رقم الجوال",
|
||||||
|
"channel": "الإرسال عبر",
|
||||||
|
"sms": "رسالة نصية",
|
||||||
|
"whatsapp": "واتساب",
|
||||||
|
"sendCode": "إرسال الرمز",
|
||||||
|
"sending": "جاري الإرسال...",
|
||||||
|
"verifyTitle": "أدخلي الرمز",
|
||||||
|
"verifySubtitle": "أرسلنا رمزاً إلى {{phone}}",
|
||||||
|
"code": "رمز التحقق",
|
||||||
|
"verify": "تحقق",
|
||||||
|
"verifying": "جاري التحقق...",
|
||||||
|
"back": "تغيير الرقم",
|
||||||
|
"errors": {
|
||||||
|
"generic": "حدث خطأ.",
|
||||||
|
"retryAfter": "انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."
|
||||||
|
},
|
||||||
|
"deviceId": "معرّف الجهاز (اختياري)",
|
||||||
|
"deviceIdPlaceholder": "معرّف الجهاز"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
"label": "اللغة",
|
"label": "اللغة",
|
||||||
"arabic": "العربية",
|
"arabic": "العربية",
|
||||||
"english": "الإنجليزية"
|
"english": "الإنجليزية"
|
||||||
@@ -29,8 +106,6 @@
|
|||||||
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
|
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
|
||||||
"badge": "المدفوعات",
|
"badge": "المدفوعات",
|
||||||
"bookingId": "رقم الحجز",
|
"bookingId": "رقم الحجز",
|
||||||
"accessToken": "رمز الوصول",
|
|
||||||
"accessTokenPlaceholder": "الصقي رمز JWT",
|
|
||||||
"sourceType": "نوع المصدر",
|
"sourceType": "نوع المصدر",
|
||||||
"sourceValue": "قيمة المصدر",
|
"sourceValue": "قيمة المصدر",
|
||||||
"sourceValuePlaceholder": "رقم الجوال أو الرمز",
|
"sourceValuePlaceholder": "رقم الجوال أو الرمز",
|
||||||
@@ -41,13 +116,15 @@
|
|||||||
"sources": {
|
"sources": {
|
||||||
"stcpay": "stc pay (جوال)",
|
"stcpay": "stc pay (جوال)",
|
||||||
"token": "دفع عبر رمز",
|
"token": "دفع عبر رمز",
|
||||||
"applepay": "Apple Pay"
|
"applepay": "Apple Pay",
|
||||||
|
"samsungpay": "Samsung Pay"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"bookingRequired": "رقم الحجز مطلوب.",
|
"bookingRequired": "رقم الحجز مطلوب.",
|
||||||
"mobileRequired": "رقم الجوال مطلوب لـ stc pay.",
|
"mobileRequired": "رقم الجوال مطلوب لـ stc pay.",
|
||||||
"tokenRequired": "الرمز مطلوب للدفع عبر الرمز.",
|
"tokenRequired": "الرمز مطلوب للدفع عبر الرمز.",
|
||||||
"generic": "فشل طلب الدفع."
|
"generic": "فشل طلب الدفع.",
|
||||||
|
"callbackRequired": "رابط العودة مطلوب لعمليات الدفع عبر الرمز."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,84 @@
|
|||||||
"phoneUnavailable": "Phone unavailable",
|
"phoneUnavailable": "Phone unavailable",
|
||||||
"viewDetails": "View details & book"
|
"viewDetails": "View details & book"
|
||||||
},
|
},
|
||||||
"nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff","unknownStaff":"Staff {{id}}"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"book": "Book",
|
||||||
|
"pay": "Pay",
|
||||||
|
"profile": "Profile",
|
||||||
|
"bookings": "My bookings",
|
||||||
|
"login": "Sign in",
|
||||||
|
"logout": "Sign out"
|
||||||
|
},
|
||||||
|
"book": {
|
||||||
|
"title": "Book a Service",
|
||||||
|
"placeholder": "Booking flow coming soon.",
|
||||||
|
"cta": "Book now",
|
||||||
|
"selectSalon": "Select a salon from the home page to book.",
|
||||||
|
"service": "Service",
|
||||||
|
"staff": "Staff",
|
||||||
|
"date": "Date",
|
||||||
|
"time": "Time",
|
||||||
|
"notes": "Notes",
|
||||||
|
"notesPlaceholder": "Optional notes",
|
||||||
|
"selectService": "Select service",
|
||||||
|
"selectStaff": "Select staff",
|
||||||
|
"submit": "Confirm booking",
|
||||||
|
"submitting": "Booking...",
|
||||||
|
"errors": {
|
||||||
|
"fillAll": "Please fill all required fields.",
|
||||||
|
"invalidTime": "Invalid date or time.",
|
||||||
|
"generic": "Booking failed."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"salon": {
|
||||||
|
"services": "Services",
|
||||||
|
"staff": "Staff",
|
||||||
|
"unknownStaff": "Staff {{id}}"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Profile",
|
||||||
|
"placeholder": "Profile and bookings coming soon.",
|
||||||
|
"myBookings": "My bookings",
|
||||||
|
"noContact": "No contact info"
|
||||||
|
},
|
||||||
|
"bookings": {
|
||||||
|
"title": "My bookings",
|
||||||
|
"subtitle": "Your upcoming and past appointments.",
|
||||||
|
"empty": "No bookings yet.",
|
||||||
|
"pay": "Pay now"
|
||||||
|
},
|
||||||
|
"paymentReturn": {
|
||||||
|
"title": "Payment status",
|
||||||
|
"success": "Payment successful",
|
||||||
|
"successMessage": "Your payment was completed. You will receive a confirmation by SMS or WhatsApp.",
|
||||||
|
"checkStatus": "Check your email or the salon for payment status.",
|
||||||
|
"reference": "Reference: {{id}}",
|
||||||
|
"viewBookings": "View my bookings"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Sign in",
|
||||||
|
"subtitle": "Enter your phone number to receive a verification code.",
|
||||||
|
"phone": "Phone number",
|
||||||
|
"channel": "Send via",
|
||||||
|
"sms": "SMS",
|
||||||
|
"whatsapp": "WhatsApp",
|
||||||
|
"sendCode": "Send code",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"verifyTitle": "Enter code",
|
||||||
|
"verifySubtitle": "We sent a code to {{phone}}",
|
||||||
|
"code": "Verification code",
|
||||||
|
"verify": "Verify",
|
||||||
|
"verifying": "Verifying...",
|
||||||
|
"back": "Change number",
|
||||||
|
"errors": {
|
||||||
|
"generic": "Something went wrong.",
|
||||||
|
"retryAfter": "Please wait {{seconds}} seconds before trying again."
|
||||||
|
},
|
||||||
|
"deviceId": "Device ID (optional)",
|
||||||
|
"deviceIdPlaceholder": "Device identifier"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
"arabic": "العربية",
|
"arabic": "العربية",
|
||||||
"english": "English"
|
"english": "English"
|
||||||
@@ -29,8 +106,6 @@
|
|||||||
"subtitle": "Send a Moyasar payment for an existing booking.",
|
"subtitle": "Send a Moyasar payment for an existing booking.",
|
||||||
"badge": "Payments",
|
"badge": "Payments",
|
||||||
"bookingId": "Booking ID",
|
"bookingId": "Booking ID",
|
||||||
"accessToken": "Access token",
|
|
||||||
"accessTokenPlaceholder": "Paste JWT access token",
|
|
||||||
"sourceType": "Source type",
|
"sourceType": "Source type",
|
||||||
"sourceValue": "Source value",
|
"sourceValue": "Source value",
|
||||||
"sourceValuePlaceholder": "Mobile number or token",
|
"sourceValuePlaceholder": "Mobile number or token",
|
||||||
@@ -41,13 +116,15 @@
|
|||||||
"sources": {
|
"sources": {
|
||||||
"stcpay": "stc pay (mobile)",
|
"stcpay": "stc pay (mobile)",
|
||||||
"token": "tokenized payment",
|
"token": "tokenized payment",
|
||||||
"applepay": "Apple Pay"
|
"applepay": "Apple Pay",
|
||||||
|
"samsungpay": "Samsung Pay"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"bookingRequired": "Booking ID is required.",
|
"bookingRequired": "Booking ID is required.",
|
||||||
"mobileRequired": "Mobile number is required for stc pay.",
|
"mobileRequired": "Mobile number is required for stc pay.",
|
||||||
"tokenRequired": "Token is required for token payments.",
|
"tokenRequired": "Token is required for token payments.",
|
||||||
"generic": "Payment request failed."
|
"generic": "Payment request failed.",
|
||||||
|
"callbackRequired": "Callback URL is required for token payments."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Outlet, Link } from "react-router-dom";
|
import { Outlet, Link, NavLink } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import LocaleSwitch from "../components/LocaleSwitch";
|
import LocaleSwitch from "../components/LocaleSwitch";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
@@ -9,37 +9,38 @@ export default function MainLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="main-header">
|
<header className="main-header">
|
||||||
<nav className="main-nav">
|
<div className="header-row">
|
||||||
<Link to="/" className="nav-brand">
|
<Link to="/" className="nav-brand">
|
||||||
{t("nav.home")}
|
{t("nav.home")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/book" className="nav-link">
|
<div className="header-actions">
|
||||||
{t("nav.book")}
|
{isAuthenticated ? (
|
||||||
</Link>
|
<button type="button" className="nav-link nav-logout" onClick={logout}>
|
||||||
<Link to="/pay" className="nav-link">
|
{t("nav.logout")}
|
||||||
{t("nav.pay")}
|
</button>
|
||||||
</Link>
|
) : (
|
||||||
<Link to="/profile" className="nav-link">
|
<Link to="/login" className="nav-link">
|
||||||
{t("nav.profile")}
|
{t("nav.login")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/bookings" className="nav-link">
|
)}
|
||||||
{t("nav.bookings")}
|
<LocaleSwitch />
|
||||||
</Link>
|
</div>
|
||||||
{isAuthenticated ? (
|
</div>
|
||||||
<button type="button" className="nav-link nav-logout" onClick={logout}>
|
|
||||||
{t("nav.logout")}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Link to="/login" className="nav-link">
|
|
||||||
{t("nav.login")}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
<LocaleSwitch />
|
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
<nav className="bottom-nav" aria-label={t("nav.home")}>
|
||||||
|
<NavLink to="/" end className="tab-link">
|
||||||
|
{t("nav.home")}
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/bookings" className="tab-link">
|
||||||
|
{t("nav.bookings")}
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/profile" className="tab-link">
|
||||||
|
{t("nav.profile")}
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { apiGet, apiPost } from "../api/client";
|
import { apiGet, apiPost, ApiError } from "../api/client";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import ProtectedRoute from "../components/ProtectedRoute";
|
import ProtectedRoute from "../components/ProtectedRoute";
|
||||||
|
|
||||||
@@ -40,11 +40,34 @@ export default function BookPage() {
|
|||||||
const selectedService = salon?.services?.find((s) => String(s.id) === serviceId);
|
const selectedService = salon?.services?.find((s) => String(s.id) === serviceId);
|
||||||
const duration = selectedService?.duration_minutes || 0;
|
const duration = selectedService?.duration_minutes || 0;
|
||||||
|
|
||||||
|
function formatWithOffset(date, offsetMinutes) {
|
||||||
|
const utcMs = date.getTime() + date.getTimezoneOffset() * 60 * 1000;
|
||||||
|
const target = new Date(utcMs + offsetMinutes * 60 * 1000);
|
||||||
|
const pad = (value) => String(value).padStart(2, "0");
|
||||||
|
const year = target.getUTCFullYear();
|
||||||
|
const month = pad(target.getUTCMonth() + 1);
|
||||||
|
const day = pad(target.getUTCDate());
|
||||||
|
const hours = pad(target.getUTCHours());
|
||||||
|
const minutes = pad(target.getUTCMinutes());
|
||||||
|
const seconds = pad(target.getUTCSeconds());
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}+03:00`;
|
||||||
|
}
|
||||||
|
|
||||||
function computeEndTime(startISO) {
|
function computeEndTime(startISO) {
|
||||||
if (!startISO || !duration) return null;
|
if (!startISO || !duration) return null;
|
||||||
const start = new Date(startISO);
|
const start = new Date(startISO);
|
||||||
|
if (Number.isNaN(start.getTime())) return null;
|
||||||
const end = new Date(start.getTime() + duration * 60 * 1000);
|
const end = new Date(start.getTime() + duration * 60 * 1000);
|
||||||
return end.toISOString();
|
return formatWithOffset(end, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirstFieldError(body) {
|
||||||
|
if (!body || typeof body !== "object") return "";
|
||||||
|
for (const value of Object.values(body)) {
|
||||||
|
if (Array.isArray(value) && value.length) return value[0];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
@@ -62,6 +85,7 @@ export default function BookPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loading) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const booking = await apiPost(
|
const booking = await apiPost(
|
||||||
@@ -77,6 +101,14 @@ export default function BookPage() {
|
|||||||
);
|
);
|
||||||
navigate(`/pay?booking=${booking.id}`);
|
navigate(`/pay?booking=${booking.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.body) {
|
||||||
|
const fieldMessage = getFirstFieldError(err.body);
|
||||||
|
if (fieldMessage) {
|
||||||
|
console.warn("Booking validation error", err.body);
|
||||||
|
setError(fieldMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setError(err.message || t("book.errors.generic"));
|
setError(err.message || t("book.errors.generic"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import BookPage from "./BookPage";
|
||||||
|
import { AuthProvider } from "../contexts/AuthContext";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
|
||||||
|
vi.mock("../components/ProtectedRoute", () => ({
|
||||||
|
default: ({ children }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { apiGet, apiPost } = await import("../api/client");
|
||||||
|
|
||||||
|
function renderBook(initialEntries = ["/book?salon=1"]) {
|
||||||
|
return render(
|
||||||
|
<AuthProvider>
|
||||||
|
<MemoryRouter initialEntries={initialEntries}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/book" element={<BookPage />} />
|
||||||
|
<Route path="/pay" element={<div>Pay Page</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const salonFixture = {
|
||||||
|
id: 1,
|
||||||
|
name: "Riyadh Salon",
|
||||||
|
services: [
|
||||||
|
{ id: 10, name: "Cut", duration_minutes: 60, price_amount: 120, currency: "SAR" },
|
||||||
|
],
|
||||||
|
staff: [{ id: 99, name: "Mona" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("BookPage", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
apiGet.mockResolvedValue(salonFixture);
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates required fields", async () => {
|
||||||
|
renderBook();
|
||||||
|
await screen.findByText("Riyadh Salon");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Confirm booking" }));
|
||||||
|
expect(screen.getByText("Please fill all required fields.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits booking and redirects to payment", async () => {
|
||||||
|
apiPost.mockResolvedValueOnce({ id: 55 });
|
||||||
|
renderBook();
|
||||||
|
|
||||||
|
await screen.findByText("Riyadh Salon");
|
||||||
|
fireEvent.change(screen.getByLabelText("Service"), { target: { value: "10" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Staff"), { target: { value: "99" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Date"), { target: { value: "2026-03-14" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Time"), { target: { value: "10:30" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Confirm booking" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Pay Page")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(apiPost).toHaveBeenCalledWith(
|
||||||
|
"/bookings/",
|
||||||
|
expect.objectContaining({
|
||||||
|
service: 10,
|
||||||
|
staff: 99,
|
||||||
|
start_time: "2026-03-14T10:30:00+03:00",
|
||||||
|
end_time: expect.any(String),
|
||||||
|
}),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const payload = apiPost.mock.calls[0][1];
|
||||||
|
const startMs = new Date(payload.start_time).getTime();
|
||||||
|
const endMs = new Date(payload.end_time).getTime();
|
||||||
|
expect(endMs - startMs).toBe(60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows API error message", async () => {
|
||||||
|
apiPost.mockRejectedValueOnce(new Error("Booking failed"));
|
||||||
|
renderBook();
|
||||||
|
|
||||||
|
await screen.findByText("Riyadh Salon");
|
||||||
|
fireEvent.change(screen.getByLabelText("Service"), { target: { value: "10" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Staff"), { target: { value: "99" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Date"), { target: { value: "2026-03-14" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Time"), { target: { value: "10:30" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Confirm booking" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Booking failed")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { AuthProvider } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
vi.mock("../components/ProtectedRoute", () => ({
|
||||||
|
default: ({ children }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../i18n/index", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
return { ...actual, getActiveLocale: () => "en" };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiGet } = await import("../api/client");
|
||||||
|
const { default: BookingsPage } = await import("./BookingsPage");
|
||||||
|
|
||||||
|
function renderBookings() {
|
||||||
|
return render(
|
||||||
|
<AuthProvider>
|
||||||
|
<MemoryRouter initialEntries={["/bookings"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/bookings" element={<BookingsPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BookingsPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders bookings list with localized datetime", async () => {
|
||||||
|
apiGet.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
status: "confirmed",
|
||||||
|
salon_name: "Glow",
|
||||||
|
service_name: "Cut",
|
||||||
|
start_time: "2026-03-14T10:00:00+03:00",
|
||||||
|
end_time: "2026-03-14T11:00:00+03:00",
|
||||||
|
price_amount: 120,
|
||||||
|
currency: "SAR",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const spy = vi.spyOn(Date.prototype, "toLocaleString");
|
||||||
|
renderBookings();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Glow")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("confirmed")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Cut")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("120 SAR")).toBeInTheDocument();
|
||||||
|
expect(spy).toHaveBeenCalledWith("en", expect.any(Object));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ export default function LoginPage() {
|
|||||||
const [step, setStep] = useState("phone");
|
const [step, setStep] = useState("phone");
|
||||||
const [phone, setPhone] = useState("");
|
const [phone, setPhone] = useState("");
|
||||||
const [channel, setChannel] = useState("sms");
|
const [channel, setChannel] = useState("sms");
|
||||||
|
const [deviceId, setDeviceId] = useState("");
|
||||||
const [requestId, setRequestId] = useState("");
|
const [requestId, setRequestId] = useState("");
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -27,6 +28,7 @@ export default function LoginPage() {
|
|||||||
const res = await apiPost("/auth/phone/request/", {
|
const res = await apiPost("/auth/phone/request/", {
|
||||||
phone_number: phone,
|
phone_number: phone,
|
||||||
channel,
|
channel,
|
||||||
|
...(deviceId ? { device_id: deviceId } : {}),
|
||||||
});
|
});
|
||||||
setRequestId(res.request_id);
|
setRequestId(res.request_id);
|
||||||
setStep("verify");
|
setStep("verify");
|
||||||
@@ -88,6 +90,15 @@ export default function LoginPage() {
|
|||||||
<option value="whatsapp">{t("auth.whatsapp")}</option>
|
<option value="whatsapp">{t("auth.whatsapp")}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("auth.deviceId")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={deviceId}
|
||||||
|
onChange={(e) => setDeviceId(e.target.value)}
|
||||||
|
placeholder={t("auth.deviceIdPlaceholder")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<button type="submit" disabled={loading}>
|
<button type="submit" disabled={loading}>
|
||||||
{loading ? t("auth.sending") : t("auth.sendCode")}
|
{loading ? t("auth.sending") : t("auth.sendCode")}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter, MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import LoginPage from "./LoginPage";
|
import LoginPage from "./LoginPage";
|
||||||
import { AuthProvider } from "../contexts/AuthContext";
|
import { AuthProvider } from "../contexts/AuthContext";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
|
|
||||||
vi.mock("../api/client", async (importOriginal) => {
|
vi.mock("../api/client", async (importOriginal) => {
|
||||||
const actual = await importOriginal();
|
const actual = await importOriginal();
|
||||||
@@ -22,6 +23,19 @@ function renderLogin() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderLoginWithRoutes(initialEntries = ["/login"]) {
|
||||||
|
return render(
|
||||||
|
<AuthProvider>
|
||||||
|
<MemoryRouter initialEntries={initialEntries}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<div>Home</div>} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("LoginPage", () => {
|
describe("LoginPage", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -57,4 +71,58 @@ describe("LoginPage", () => {
|
|||||||
expect(screen.getByText("Rate limited")).toBeInTheDocument();
|
expect(screen.getByText("Rate limited")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows cooldown message on 429 retry-after", async () => {
|
||||||
|
apiPost.mockRejectedValueOnce(
|
||||||
|
new ApiError("Too many", { status: 429, body: { retry_after_seconds: 30 } })
|
||||||
|
);
|
||||||
|
renderLogin();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Send code" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Please wait 30 seconds before trying again.")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies OTP and navigates home", async () => {
|
||||||
|
apiPost
|
||||||
|
.mockResolvedValueOnce({ request_id: "req-1", expires_at: "2025-01-01T12:00:00Z" })
|
||||||
|
.mockResolvedValueOnce({ access: "token", refresh: "refresh", user: { name: "Maha" } });
|
||||||
|
|
||||||
|
renderLoginWithRoutes();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Send code" }));
|
||||||
|
|
||||||
|
await screen.findByLabelText(/verification code/i);
|
||||||
|
fireEvent.change(screen.getByLabelText(/verification code/i), { target: { value: "123456" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Verify" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Home")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when OTP verification fails", async () => {
|
||||||
|
apiPost
|
||||||
|
.mockResolvedValueOnce({ request_id: "req-1", expires_at: "2025-01-01T12:00:00Z" })
|
||||||
|
.mockRejectedValueOnce(new Error("Invalid code"));
|
||||||
|
|
||||||
|
renderLogin();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Send code" }));
|
||||||
|
|
||||||
|
await screen.findByLabelText(/verification code/i);
|
||||||
|
fireEvent.change(screen.getByLabelText(/verification code/i), { target: { value: "123456" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Verify" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Invalid code")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user