feat: added initial implementation
This commit is contained in:
@@ -15,6 +15,7 @@ Use this file first.
|
||||
- `docs/adr/`: architecture decisions
|
||||
- `docs/runbooks/`: operational response guides
|
||||
- `docs/templates/`: ADR/runbook templates
|
||||
- `docs/frontend-spec-requirements.md`: frontend technical specs + requirements (customer MVP)
|
||||
|
||||
## Update Rules (short)
|
||||
- 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
|
||||
/>
|
||||
</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">
|
||||
<span>{t("payment.sourceType")}</span>
|
||||
<select
|
||||
@@ -51,6 +42,7 @@ export default function PaymentForm({ bookingId = "", token = "" }) {
|
||||
<option value="stcpay">{t("payment.sources.stcpay")}</option>
|
||||
<option value="token">{t("payment.sources.token")}</option>
|
||||
<option value="applepay">{t("payment.sources.applepay")}</option>
|
||||
<option value="samsungpay">{t("payment.sources.samsungpay")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<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();
|
||||
});
|
||||
|
||||
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 { apiGet, apiPost } from "../api/client";
|
||||
import { apiGet, apiPost, ApiError } from "../api/client";
|
||||
|
||||
const STORAGE_ACCESS = "auth_access";
|
||||
const STORAGE_REFRESH = "auth_refresh";
|
||||
@@ -50,7 +50,12 @@ export function AuthProvider({ children }) {
|
||||
setUser(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
const status = err instanceof ApiError ? err.status : err?.status;
|
||||
if (status !== 401) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Token invalid, try refresh
|
||||
if (!refreshToken) {
|
||||
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 { apiPost } from "../api/client";
|
||||
import { apiPost, ApiError } from "../api/client";
|
||||
|
||||
function generateIdempotencyKey() {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
@@ -13,16 +13,8 @@ function generateIdempotencyKey() {
|
||||
const AUTH_TOKEN_KEY = "auth_access";
|
||||
|
||||
export function usePaymentForm(bookingId = "", token = "") {
|
||||
// token: optional auth token from AuthContext; tokenInput: manual override from form
|
||||
const { t } = useTranslation();
|
||||
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 [sourceValue, setSourceValue] = useState("");
|
||||
const [callbackUrl, setCallbackUrl] = useState(() => {
|
||||
@@ -34,22 +26,27 @@ export function usePaymentForm(bookingId = "", token = "") {
|
||||
const [status, setStatus] = useState("idle");
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState("");
|
||||
const [idempotencyKey, setIdempotencyKey] = useState(generateIdempotencyKey);
|
||||
const lastBookingId = useRef(bookingId);
|
||||
|
||||
const idempotencyKey = useMemo(generateIdempotencyKey, []);
|
||||
|
||||
// Persist token to localStorage when it changes
|
||||
const setTokenInputAndPersist = (value) => {
|
||||
setTokenInput(value);
|
||||
if (typeof window !== "undefined") {
|
||||
if (value) {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, value);
|
||||
} else {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (lastBookingId.current !== bookingIdInput) {
|
||||
lastBookingId.current = bookingIdInput;
|
||||
setIdempotencyKey(generateIdempotencyKey());
|
||||
}
|
||||
};
|
||||
}, [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() {
|
||||
if (status === "loading") return;
|
||||
setStatus("loading");
|
||||
setError("");
|
||||
setResult(null);
|
||||
@@ -75,6 +72,11 @@ export function usePaymentForm(bookingId = "", token = "") {
|
||||
setError(t("payment.errors.tokenRequired"));
|
||||
return;
|
||||
}
|
||||
if (!callbackUrl) {
|
||||
setStatus("error");
|
||||
setError(t("payment.errors.callbackRequired"));
|
||||
return;
|
||||
}
|
||||
source.token = sourceValue;
|
||||
}
|
||||
|
||||
@@ -88,16 +90,28 @@ export function usePaymentForm(bookingId = "", token = "") {
|
||||
payload.callback_url = callbackUrl;
|
||||
}
|
||||
|
||||
// Use tokenInput (form state) so user edits are respected; token prop only initializes it
|
||||
const authToken = tokenInput;
|
||||
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);
|
||||
setStatus("ready");
|
||||
if (data?.redirect_url) {
|
||||
window.location.assign(data.redirect_url);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.body) {
|
||||
const fieldMessage = getFirstFieldError(err.body);
|
||||
if (fieldMessage) {
|
||||
console.warn("Payment validation error", err.body);
|
||||
setStatus("error");
|
||||
setError(fieldMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setStatus("error");
|
||||
setError(err.message || t("payment.errors.generic"));
|
||||
}
|
||||
@@ -106,8 +120,6 @@ export function usePaymentForm(bookingId = "", token = "") {
|
||||
return {
|
||||
bookingIdInput,
|
||||
setBookingIdInput,
|
||||
tokenInput,
|
||||
setTokenInput: setTokenInputAndPersist,
|
||||
sourceType,
|
||||
setSourceType,
|
||||
sourceValue,
|
||||
|
||||
@@ -11,7 +11,12 @@ export function useSalonSearch(query) {
|
||||
async function load() {
|
||||
setStatus("loading");
|
||||
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) {
|
||||
setSalons(data);
|
||||
setStatus("ready");
|
||||
|
||||
@@ -16,7 +16,84 @@
|
||||
"phoneUnavailable": "الهاتف غير متوفر",
|
||||
"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": "اللغة",
|
||||
"arabic": "العربية",
|
||||
"english": "الإنجليزية"
|
||||
@@ -29,8 +106,6 @@
|
||||
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
|
||||
"badge": "المدفوعات",
|
||||
"bookingId": "رقم الحجز",
|
||||
"accessToken": "رمز الوصول",
|
||||
"accessTokenPlaceholder": "الصقي رمز JWT",
|
||||
"sourceType": "نوع المصدر",
|
||||
"sourceValue": "قيمة المصدر",
|
||||
"sourceValuePlaceholder": "رقم الجوال أو الرمز",
|
||||
@@ -41,13 +116,15 @@
|
||||
"sources": {
|
||||
"stcpay": "stc pay (جوال)",
|
||||
"token": "دفع عبر رمز",
|
||||
"applepay": "Apple Pay"
|
||||
"applepay": "Apple Pay",
|
||||
"samsungpay": "Samsung Pay"
|
||||
},
|
||||
"errors": {
|
||||
"bookingRequired": "رقم الحجز مطلوب.",
|
||||
"mobileRequired": "رقم الجوال مطلوب لـ stc pay.",
|
||||
"tokenRequired": "الرمز مطلوب للدفع عبر الرمز.",
|
||||
"generic": "فشل طلب الدفع."
|
||||
"generic": "فشل طلب الدفع.",
|
||||
"callbackRequired": "رابط العودة مطلوب لعمليات الدفع عبر الرمز."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,84 @@
|
||||
"phoneUnavailable": "Phone unavailable",
|
||||
"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",
|
||||
"arabic": "العربية",
|
||||
"english": "English"
|
||||
@@ -29,8 +106,6 @@
|
||||
"subtitle": "Send a Moyasar payment for an existing booking.",
|
||||
"badge": "Payments",
|
||||
"bookingId": "Booking ID",
|
||||
"accessToken": "Access token",
|
||||
"accessTokenPlaceholder": "Paste JWT access token",
|
||||
"sourceType": "Source type",
|
||||
"sourceValue": "Source value",
|
||||
"sourceValuePlaceholder": "Mobile number or token",
|
||||
@@ -41,13 +116,15 @@
|
||||
"sources": {
|
||||
"stcpay": "stc pay (mobile)",
|
||||
"token": "tokenized payment",
|
||||
"applepay": "Apple Pay"
|
||||
"applepay": "Apple Pay",
|
||||
"samsungpay": "Samsung Pay"
|
||||
},
|
||||
"errors": {
|
||||
"bookingRequired": "Booking ID is required.",
|
||||
"mobileRequired": "Mobile number is required for stc pay.",
|
||||
"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 LocaleSwitch from "../components/LocaleSwitch";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
@@ -9,37 +9,38 @@ export default function MainLayout() {
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="main-header">
|
||||
<nav className="main-nav">
|
||||
<div className="header-row">
|
||||
<Link to="/" className="nav-brand">
|
||||
{t("nav.home")}
|
||||
</Link>
|
||||
<Link to="/book" className="nav-link">
|
||||
{t("nav.book")}
|
||||
</Link>
|
||||
<Link to="/pay" className="nav-link">
|
||||
{t("nav.pay")}
|
||||
</Link>
|
||||
<Link to="/profile" className="nav-link">
|
||||
{t("nav.profile")}
|
||||
</Link>
|
||||
<Link to="/bookings" className="nav-link">
|
||||
{t("nav.bookings")}
|
||||
</Link>
|
||||
{isAuthenticated ? (
|
||||
<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 />
|
||||
<div className="header-actions">
|
||||
{isAuthenticated ? (
|
||||
<button type="button" className="nav-link nav-logout" onClick={logout}>
|
||||
{t("nav.logout")}
|
||||
</button>
|
||||
) : (
|
||||
<Link to="/login" className="nav-link">
|
||||
{t("nav.login")}
|
||||
</Link>
|
||||
)}
|
||||
<LocaleSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<Outlet />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { apiGet, apiPost } from "../api/client";
|
||||
import { apiGet, apiPost, ApiError } from "../api/client";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import ProtectedRoute from "../components/ProtectedRoute";
|
||||
|
||||
@@ -40,11 +40,34 @@ export default function BookPage() {
|
||||
const selectedService = salon?.services?.find((s) => String(s.id) === serviceId);
|
||||
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) {
|
||||
if (!startISO || !duration) return null;
|
||||
const start = new Date(startISO);
|
||||
if (Number.isNaN(start.getTime())) return null;
|
||||
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) {
|
||||
@@ -62,6 +85,7 @@ export default function BookPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const booking = await apiPost(
|
||||
@@ -77,6 +101,14 @@ export default function BookPage() {
|
||||
);
|
||||
navigate(`/pay?booking=${booking.id}`);
|
||||
} 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"));
|
||||
} finally {
|
||||
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 [phone, setPhone] = useState("");
|
||||
const [channel, setChannel] = useState("sms");
|
||||
const [deviceId, setDeviceId] = useState("");
|
||||
const [requestId, setRequestId] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -27,6 +28,7 @@ export default function LoginPage() {
|
||||
const res = await apiPost("/auth/phone/request/", {
|
||||
phone_number: phone,
|
||||
channel,
|
||||
...(deviceId ? { device_id: deviceId } : {}),
|
||||
});
|
||||
setRequestId(res.request_id);
|
||||
setStep("verify");
|
||||
@@ -88,6 +90,15 @@ export default function LoginPage() {
|
||||
<option value="whatsapp">{t("auth.whatsapp")}</option>
|
||||
</select>
|
||||
</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>}
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? t("auth.sending") : t("auth.sendCode")}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { BrowserRouter, MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import LoginPage from "./LoginPage";
|
||||
import { AuthProvider } from "../contexts/AuthContext";
|
||||
import i18n from "../i18n";
|
||||
import { ApiError } from "../api/client";
|
||||
|
||||
vi.mock("../api/client", async (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", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
@@ -57,4 +71,58 @@ describe("LoginPage", () => {
|
||||
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