2 Commits

Author SHA1 Message Date
mohd 6428459313 failed tests btw 2026-03-14 23:42:21 +03:00
mohd 2a8b6a7b62 feat: added initial implementation 2026-03-14 23:30:56 +03:00
19 changed files with 976 additions and 81 deletions
+1
View File
@@ -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.
+216
View File
@@ -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");
});
});
+1 -9
View File
@@ -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();
});
});
}); });
+9 -2
View File
@@ -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();
});
});
+49 -29
View File
@@ -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,
+6 -1
View File
@@ -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");
+83 -6
View File
@@ -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": "رابط العودة مطلوب لعمليات الدفع عبر الرمز."
} }
} }
} }
+83 -6
View File
@@ -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."
} }
} }
} }
+26 -25
View File
@@ -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>
); );
} }
+34 -2
View File
@@ -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);
+100
View File
@@ -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();
});
});
});
+65
View File
@@ -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();
});
});
+11
View File
@@ -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")}
+69 -1
View File
@@ -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();
});
});
}); });