diff --git a/AGENTS.md b/AGENTS.md
index a59595f..c7c5f81 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -70,4 +70,4 @@ Build a reliable, maintainable salon booking platform with Django (backend) and
# ExecPlans
-When writing complex features or significant refactors, use an ExecPlan (as described in PLANS.md) from design to implementation. The active ExecPlan is `docs/execplans/payments-moyasar.md`.
+When writing complex features or significant refactors, use an ExecPlan (as described in PLANS.md) from design to implementation. The current active ExecPlan is defined in PLANS.md. Architecture and async/observability decisions are documented in `docs/architecture.md`.
diff --git a/README.md b/README.md
index bd77808..2f10055 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ Location: `backend/`
### Setup
1. Create a virtualenv and install dependencies.
+ - `python3 -m venv venv && source venv/bin/activate` (or `venv\Scripts\activate` on Windows)
- `pip install -r backend/requirements.txt -r backend/requirements-dev.txt`
2. Copy `backend/.env.example` to `backend/.env` and adjust values.
3. Run migrations and start the server.
@@ -21,7 +22,7 @@ After migrations, you can seed demo data:
### Tests
-- `pytest`
+- From project root with venv active: `venv/bin/python3 -m pytest` (run from `backend/` so `pytest.ini` is picked up)
### Core API endpoints (current scaffold)
@@ -61,3 +62,4 @@ The dev server proxies `/api` to `http://localhost:8000`.
## Project Notes
- Known gaps and risks: `docs/risks.md`
+- Architecture and async/observability decisions: `docs/architecture.md`
diff --git a/backend/apps/accounts/services/otp.py b/backend/apps/accounts/services/otp.py
index ce95686..ec7aa8c 100644
--- a/backend/apps/accounts/services/otp.py
+++ b/backend/apps/accounts/services/otp.py
@@ -50,6 +50,8 @@ class ConsoleOtpProvider(BaseOtpProvider):
class TwilioOtpProvider(BaseOtpProvider):
+ """Twilio provider for SMS and WhatsApp OTP delivery. Requires TWILIO_* env vars."""
+
def __init__(self) -> None:
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
@@ -60,15 +62,23 @@ class TwilioOtpProvider(BaseOtpProvider):
if not self.account_sid or not self.auth_token or not self.from_number:
raise ValueError(_("Twilio credentials are not configured"))
- def send_sms(self, to_number: str, message: str) -> None:
+ def _get_client(self):
+ from twilio.rest import Client
self._assert_config()
- raise NotImplementedError(_("Twilio SMS adapter not implemented yet"))
+ return Client(self.account_sid, self.auth_token)
+
+ def send_sms(self, to_number: str, message: str) -> None:
+ client = self._get_client()
+ client.messages.create(body=message, from_=self.from_number, to=to_number)
def send_whatsapp(self, to_number: str, message: str) -> None:
self._assert_config()
if not self.whatsapp_from:
raise ValueError(_("Twilio WhatsApp sender is not configured"))
- raise NotImplementedError(_("Twilio WhatsApp adapter not implemented yet"))
+ client = self._get_client()
+ from_ = f"whatsapp:{self.whatsapp_from}"
+ to = f"whatsapp:{to_number}"
+ client.messages.create(body=message, from_=from_, to=to)
class UnifonicOtpProvider(BaseOtpProvider):
diff --git a/backend/apps/accounts/tests/test_twilio_provider.py b/backend/apps/accounts/tests/test_twilio_provider.py
new file mode 100644
index 0000000..63a2907
--- /dev/null
+++ b/backend/apps/accounts/tests/test_twilio_provider.py
@@ -0,0 +1,51 @@
+"""Tests for Twilio OTP provider implementation."""
+
+import pytest
+from unittest.mock import MagicMock, patch
+
+
+@pytest.mark.django_db
+@patch("apps.accounts.services.otp.TwilioOtpProvider._get_client")
+def test_twilio_send_sms_calls_client(mock_get_client):
+ from apps.accounts.services.otp import TwilioOtpProvider
+
+ mock_client = MagicMock()
+ mock_get_client.return_value = mock_client
+
+ with patch.dict("os.environ", {
+ "TWILIO_ACCOUNT_SID": "AC123",
+ "TWILIO_AUTH_TOKEN": "token",
+ "TWILIO_FROM_NUMBER": "+966500000000",
+ }):
+ provider = TwilioOtpProvider()
+ provider.send_sms("+966512345678", "Your code is 123456")
+
+ mock_client.messages.create.assert_called_once_with(
+ body="Your code is 123456",
+ from_="+966500000000",
+ to="+966512345678",
+ )
+
+
+@pytest.mark.django_db
+@patch("apps.accounts.services.otp.TwilioOtpProvider._get_client")
+def test_twilio_send_whatsapp_calls_client(mock_get_client):
+ from apps.accounts.services.otp import TwilioOtpProvider
+
+ mock_client = MagicMock()
+ mock_get_client.return_value = mock_client
+
+ with patch.dict("os.environ", {
+ "TWILIO_ACCOUNT_SID": "AC123",
+ "TWILIO_AUTH_TOKEN": "token",
+ "TWILIO_FROM_NUMBER": "+966500000000",
+ "TWILIO_WHATSAPP_FROM": "14155238886",
+ }):
+ provider = TwilioOtpProvider()
+ provider.send_whatsapp("+966512345678", "Your code is 123456")
+
+ mock_client.messages.create.assert_called_once_with(
+ body="Your code is 123456",
+ from_="whatsapp:14155238886",
+ to="whatsapp:+966512345678",
+ )
diff --git a/backend/apps/payments/services/gateway.py b/backend/apps/payments/services/gateway.py
index afed0df..2513282 100644
--- a/backend/apps/payments/services/gateway.py
+++ b/backend/apps/payments/services/gateway.py
@@ -33,10 +33,12 @@ class BasePaymentGateway:
) -> PaymentInitResult:
raise NotImplementedError
- def capture_payment(self, external_id: str) -> None:
+ def capture_payment(self, external_id: str, amount: Optional[int] = None) -> None:
+ """Capture an authorized payment. Amount in minor units; omit for full capture."""
raise NotImplementedError
- def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None:
+ def refund_payment(self, external_id: str, amount: Optional[int] = None) -> None:
+ """Refund a paid/captured payment. Amount in minor units; omit for full refund."""
raise NotImplementedError
@@ -101,10 +103,36 @@ class MoyasarGateway(BasePaymentGateway):
payload=data,
)
- def capture_payment(self, external_id: str) -> None:
+ def capture_payment(self, external_id: str, amount: Optional[int] = None) -> None:
+ """Capture an authorized payment. Amount in minor units; omit for full capture."""
self._assert_config()
- raise NotImplementedError("Moyasar capture not implemented yet")
+ url = f"{self.base_url}/v1/payments/{external_id}/capture"
+ payload = {} if amount is None else {"amount": amount}
+ try:
+ response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
+ except requests.RequestException as exc:
+ raise PaymentGatewayError("Failed to reach Moyasar for capture") from exc
+ if response.status_code not in (200, 201):
+ data = response.json() if response.content else {}
+ raise PaymentGatewayError(
+ "Moyasar capture failed",
+ status_code=response.status_code,
+ payload=data,
+ )
- def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None:
+ def refund_payment(self, external_id: str, amount: Optional[int] = None) -> None:
+ """Refund a paid/captured payment. Amount in minor units; omit for full refund."""
self._assert_config()
- raise NotImplementedError("Moyasar refund not implemented yet")
+ url = f"{self.base_url}/v1/payments/{external_id}/refund"
+ payload = {} if amount is None else {"amount": amount}
+ try:
+ response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
+ except requests.RequestException as exc:
+ raise PaymentGatewayError("Failed to reach Moyasar for refund") from exc
+ if response.status_code not in (200, 201):
+ data = response.json() if response.content else {}
+ raise PaymentGatewayError(
+ "Moyasar refund failed",
+ status_code=response.status_code,
+ payload=data,
+ )
diff --git a/backend/apps/payments/tests/test_gateway_capture_refund.py b/backend/apps/payments/tests/test_gateway_capture_refund.py
new file mode 100644
index 0000000..c2265e4
--- /dev/null
+++ b/backend/apps/payments/tests/test_gateway_capture_refund.py
@@ -0,0 +1,54 @@
+"""Tests for Moyasar capture and refund gateway methods."""
+
+from unittest.mock import Mock, patch
+
+import pytest
+
+from apps.payments.services.gateway import MoyasarGateway, PaymentGatewayError
+
+
+@patch("apps.payments.services.gateway.requests.post")
+def test_moyasar_capture_calls_api(mock_post):
+ mock_post.return_value = Mock(status_code=200, content=b"{}", json=lambda: {"id": "pay_1", "status": "captured"})
+
+ with patch.dict("os.environ", {
+ "MOYASAR_SECRET_KEY": "sk_test",
+ "MOYASAR_PUBLISHABLE_KEY": "pk_test",
+ }):
+ gateway = MoyasarGateway()
+ gateway.capture_payment("pay_1")
+
+ mock_post.assert_called_once()
+ call_args = mock_post.call_args
+ assert "pay_1/capture" in call_args[0][0]
+ assert call_args[1]["auth"] == ("sk_test", "")
+
+
+@patch("apps.payments.services.gateway.requests.post")
+def test_moyasar_refund_calls_api(mock_post):
+ mock_post.return_value = Mock(status_code=200, content=b"{}", json=lambda: {"id": "pay_1", "status": "refunded"})
+
+ with patch.dict("os.environ", {
+ "MOYASAR_SECRET_KEY": "sk_test",
+ "MOYASAR_PUBLISHABLE_KEY": "pk_test",
+ }):
+ gateway = MoyasarGateway()
+ gateway.refund_payment("pay_1")
+
+ mock_post.assert_called_once()
+ call_args = mock_post.call_args
+ assert "pay_1/refund" in call_args[0][0]
+
+
+@patch("apps.payments.services.gateway.requests.post")
+def test_moyasar_capture_raises_on_error(mock_post):
+ mock_post.return_value = Mock(status_code=400, content=b'{"message":"Invalid"}', json=lambda: {"message": "Invalid"})
+
+ with patch.dict("os.environ", {
+ "MOYASAR_SECRET_KEY": "sk_test",
+ "MOYASAR_PUBLISHABLE_KEY": "pk_test",
+ }):
+ gateway = MoyasarGateway()
+ with pytest.raises(PaymentGatewayError) as exc_info:
+ gateway.capture_payment("pay_1")
+ assert exc_info.value.status_code == 400
diff --git a/backend/requirements.txt b/backend/requirements.txt
index dc6668c..8f002c8 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -5,3 +5,4 @@ django-cors-headers>=4.3
psycopg[binary]>=3.1
python-dotenv>=1.0
requests>=2.31
+twilio>=9.0
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..53daeec
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,39 @@
+# Architecture
+
+## Overview
+
+The Salon platform is a Django REST API backend with a React/Vite frontend, optimized for KSA (phone auth, Riyadh timezone, Arabic locale).
+
+## Backend Apps and Responsibilities
+
+| App | Responsibility |
+|-----|----------------|
+| **accounts** | User model, phone/OTP auth, JWT tokens, locale preferences. OTP providers (console, Twilio, Unifonic) send SMS/WhatsApp. |
+| **salons** | Salon catalog, services, staff, availability windows, reviews. Read-only public APIs. |
+| **bookings** | Booking model, validation (availability, overlap prevention), status transitions. Triggers notifications on create and status change. |
+| **payments** | Payment model, Moyasar integration (create, capture, refund), webhook reconciliation, idempotency. |
+| **notifications** | Booking lifecycle notifications (SMS/WhatsApp). Reuses OTP providers; sends on booking created/confirmed/cancelled. |
+
+## Data Flow
+
+```
+User → React Frontend → Django API
+ ↓
+ accounts (auth) ──→ OTP providers (Twilio/Unifonic/console)
+ salons (catalog)
+ bookings ──→ notifications ──→ OTP providers
+ payments ──→ Moyasar gateway
+```
+
+## Async and Observability (MVP Decision)
+
+**Decision (MVP):** All OTP sends, booking notifications, and payment gateway calls run **synchronously** in the request/response path. No Celery, RQ, or other task queue for the initial launch.
+
+**Rationale:**
+- Reduces deployment complexity (no Redis, no worker processes).
+- MVP traffic is expected to be low; synchronous latency is acceptable.
+- External calls already use timeouts (e.g. Moyasar: 10s, Twilio: SDK default).
+
+**Future:** When scaling, introduce a task queue (e.g. Celery + Redis) for OTP and notification sends. Payment creation and webhooks should remain synchronous for immediate feedback and idempotency.
+
+**Observability:** Errors are logged via Python `logging` and stored in model metadata (e.g. `Payment.metadata["gateway_error"]`, `Notification.error_message`). Structured logging and metrics are Phase 3 work.
diff --git a/docs/risks.md b/docs/risks.md
index 63ee63d..19abb35 100644
--- a/docs/risks.md
+++ b/docs/risks.md
@@ -5,7 +5,7 @@ This file tracks known gaps and risks to address in future iterations.
## Security And Auth
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
- OTP protections are basic; add device fingerprinting and IP throttling if needed.
-- OTP provider adapters (Twilio/Unifonic) are scaffolds only; no real SMS/WhatsApp delivery yet.
+- Twilio OTP provider is implemented (SMS + WhatsApp); Unifonic remains a scaffold.
- Social login is a placeholder.
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows.
@@ -16,12 +16,12 @@ This file tracks known gaps and risks to address in future iterations.
## Payments
- Moyasar payment creation, webhook reconciliation, and idempotency are implemented.
-- Refund/capture operations are not implemented yet if required.
+- Moyasar capture and refund are implemented in the gateway; API endpoints for admin-initiated capture/refund can be added when needed.
## Data And UX
- Ratings are not recalculated from reviews.
- No image upload or storage strategy for photos.
-- Booking lifecycle notifications are implemented (SMS/WhatsApp via provider scaffolds); production delivery still needs real provider adapters.
+- Booking lifecycle notifications are implemented; Twilio delivers SMS/WhatsApp when OTP_PROVIDER=twilio.
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
## Ops And Compliance
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 33a234a..1182c77 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,7 +11,8 @@
"i18next": "^23.11.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-i18next": "^14.1.0"
+ "react-i18next": "^14.1.0",
+ "react-router-dom": "^7.13.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.2",
@@ -1950,6 +1951,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3578,6 +3592,44 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "7.13.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
+ "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.13.1",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
+ "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.13.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -3729,6 +3781,12 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 01bc678..dc21f48 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -13,12 +13,13 @@
"i18next": "^23.11.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-i18next": "^14.1.0"
+ "react-i18next": "^14.1.0",
+ "react-router-dom": "^7.13.1"
},
"devDependencies": {
- "@vitejs/plugin-react": "^4.2.0",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
+ "@vitejs/plugin-react": "^4.2.0",
"jsdom": "^24.0.0",
"vite": "^5.0.0",
"vitest": "^1.3.1"
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 7f18ae1..1f9fe0b 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,245 +1,29 @@
-import { useEffect, useMemo, useState } from "react";
-import { useTranslation } from "react-i18next";
-import { apiGet, apiPost } from "./api/client";
-import { setLocale } from "./i18n";
+import { BrowserRouter, Routes, Route } from "react-router-dom";
+import MainLayout from "./layouts/MainLayout";
+import HomePage from "./pages/HomePage";
+import BookPage from "./pages/BookPage";
+import PaymentPage from "./pages/PaymentPage";
+import ProfilePage from "./pages/ProfilePage";
+import BookingsPage from "./pages/BookingsPage";
+import LoginPage from "./pages/LoginPage";
+import SalonDetailPage from "./pages/SalonDetailPage";
+import PaymentReturnPage from "./pages/PaymentReturnPage";
export default function App() {
- const [salons, setSalons] = useState([]);
- const [query, setQuery] = useState("");
- const [status, setStatus] = useState("idle");
- const [paymentBookingId, setPaymentBookingId] = useState("");
- const [paymentToken, setPaymentToken] = useState(() => localStorage.getItem("auth_token") || "");
- const [paymentSourceType, setPaymentSourceType] = useState("stcpay");
- const [paymentSourceValue, setPaymentSourceValue] = useState("");
- const [paymentCallbackUrl, setPaymentCallbackUrl] = useState("");
- const [paymentStatus, setPaymentStatus] = useState("idle");
- const [paymentResult, setPaymentResult] = useState(null);
- const [paymentError, setPaymentError] = useState("");
- const { t, i18n } = useTranslation();
-
- const idempotencyKey = useMemo(() => {
- if (typeof crypto !== "undefined" && crypto.randomUUID) {
- return crypto.randomUUID();
- }
- return `id-${Date.now()}-${Math.random().toString(16).slice(2)}`;
- }, []);
-
- useEffect(() => {
- localStorage.setItem("auth_token", paymentToken);
- }, [paymentToken]);
-
- useEffect(() => {
- let ignore = false;
-
- async function load() {
- setStatus("loading");
- try {
- const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`);
- if (!ignore) {
- setSalons(data);
- setStatus("ready");
- }
- } catch (error) {
- if (!ignore) {
- setStatus("error");
- }
- }
- }
-
- load();
- return () => {
- ignore = true;
- };
- }, [query]);
-
- async function handlePaymentSubmit(event) {
- event.preventDefault();
- setPaymentStatus("loading");
- setPaymentError("");
- setPaymentResult(null);
-
- if (!paymentBookingId) {
- setPaymentStatus("error");
- setPaymentError(t("payment.errors.bookingRequired"));
- return;
- }
-
- const source = { type: paymentSourceType };
- if (paymentSourceType === "stcpay") {
- if (!paymentSourceValue) {
- setPaymentStatus("error");
- setPaymentError(t("payment.errors.mobileRequired"));
- return;
- }
- source.mobile = paymentSourceValue;
- }
- if (paymentSourceType === "token") {
- if (!paymentSourceValue) {
- setPaymentStatus("error");
- setPaymentError(t("payment.errors.tokenRequired"));
- return;
- }
- source.token = paymentSourceValue;
- }
-
- const payload = {
- booking_id: Number(paymentBookingId),
- provider: "moyasar",
- idempotency_key: idempotencyKey,
- source,
- };
-
- if (paymentCallbackUrl) {
- payload.callback_url = paymentCallbackUrl;
- }
-
- try {
- const data = await apiPost("/payments/", payload, paymentToken);
- setPaymentResult(data);
- setPaymentStatus("ready");
- if (data?.redirect_url) {
- window.location.assign(data.redirect_url);
- }
- } catch (error) {
- setPaymentStatus("error");
- setPaymentError(error.message || t("payment.errors.generic"));
- }
- }
-
return (
-
-
-
-
- {t("results.title")}
- {status === "loading" && {t("results.loading")}
}
- {status === "error" && (
- {t("results.error")}
- )}
- {status === "ready" && salons.length === 0 && {t("results.empty")}
}
-
- {salons.map((salon) => (
-
-
-
{salon.name}
- {salon.rating_avg} / 5
-
- {salon.description || t("card.noDescription")}
-
- {salon.city}
- {salon.phone_number || t("card.phoneUnavailable")}
-
-
- ))}
-
-
-
-
-
-
-
{t("payment.title")}
-
{t("payment.subtitle")}
-
-
{t("payment.badge")}
-
-
-
-
- {paymentStatus === "error" && paymentError && (
- {paymentError}
- )}
- {paymentStatus === "ready" && paymentResult && (
- {JSON.stringify(paymentResult, null, 2)}
- )}
-
-
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
);
}
diff --git a/frontend/src/App.test.jsx b/frontend/src/App.test.jsx
index dd002a2..d204676 100644
--- a/frontend/src/App.test.jsx
+++ b/frontend/src/App.test.jsx
@@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import App from "./App.jsx";
+import { AuthProvider } from "./contexts/AuthContext";
import i18n from "./i18n";
vi.mock("./api/client", () => ({
@@ -8,10 +9,14 @@ vi.mock("./api/client", () => ({
apiPost: vi.fn()
}));
+function TestWrapper({ children }) {
+ return {children};
+}
+
describe("App", () => {
it("renders the hero copy", async () => {
await i18n.changeLanguage("en");
- render();
+ render(, { wrapper: TestWrapper });
expect(
await screen.findByText("Find, compare, and book top salons near you.")
).toBeInTheDocument();
@@ -19,7 +24,7 @@ describe("App", () => {
it("switches to Arabic and sets RTL direction", async () => {
await i18n.changeLanguage("en");
- render();
+ render(, { wrapper: TestWrapper });
const arabicButton = screen.getByRole("button", { name: "العربية" });
fireEvent.click(arabicButton);
await waitFor(() => {
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 187b41f..61acc0e 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -2,26 +2,52 @@ import { getActiveLocale } from "../i18n";
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
-async function handleResponse(response) {
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(errorText || `Request failed: ${response.status}`);
+export class ApiError extends Error {
+ constructor(message, { status, body } = {}) {
+ super(message);
+ this.name = "ApiError";
+ this.status = status;
+ this.body = body;
}
- return response.json();
}
-export async function apiGet(path) {
- const response = await fetch(`${API_BASE}${path}`, {
- headers: {
- "Accept-Language": getActiveLocale(),
- },
- });
+async function handleResponse(response) {
+ const text = await response.text();
+ let body = null;
+ try {
+ body = text ? JSON.parse(text) : null;
+ } catch {
+ // Ignore
+ }
+
+ if (!response.ok) {
+ const message =
+ (body?.detail && typeof body.detail === "string" ? body.detail : null) ||
+ text ||
+ `Request failed: ${response.status}`;
+ throw new ApiError(message, { status: response.status, body });
+ }
+ return body;
+}
+
+function baseHeaders() {
+ return {
+ "Accept-Language": getActiveLocale(),
+ };
+}
+
+export async function apiGet(path, token) {
+ const headers = { ...baseHeaders() };
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+ const response = await fetch(`${API_BASE}${path}`, { headers });
return handleResponse(response);
}
export async function apiPost(path, body, token) {
const headers = {
- "Accept-Language": getActiveLocale(),
+ ...baseHeaders(),
"Content-Type": "application/json",
};
if (token) {
@@ -31,7 +57,23 @@ export async function apiPost(path, body, token) {
const response = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers,
- body: JSON.stringify(body),
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ return handleResponse(response);
+}
+
+export async function apiPatch(path, body, token) {
+ const headers = {
+ ...baseHeaders(),
+ "Content-Type": "application/json",
+ };
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+ const response = await fetch(`${API_BASE}${path}`, {
+ method: "PATCH",
+ headers,
+ body: body ? JSON.stringify(body) : undefined,
});
return handleResponse(response);
}
diff --git a/frontend/src/components/LocaleSwitch.jsx b/frontend/src/components/LocaleSwitch.jsx
new file mode 100644
index 0000000..ab0a4a5
--- /dev/null
+++ b/frontend/src/components/LocaleSwitch.jsx
@@ -0,0 +1,24 @@
+import { useTranslation } from "react-i18next";
+import { setLocale } from "../i18n";
+
+export default function LocaleSwitch() {
+ const { t, i18n } = useTranslation();
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/PaymentForm.jsx b/frontend/src/components/PaymentForm.jsx
new file mode 100644
index 0000000..b37ef5b
--- /dev/null
+++ b/frontend/src/components/PaymentForm.jsx
@@ -0,0 +1,94 @@
+import { useTranslation } from "react-i18next";
+import { usePaymentForm } from "../hooks/usePaymentForm";
+
+export default function PaymentForm({ bookingId = "", token = "" }) {
+ const { t } = useTranslation();
+ const form = usePaymentForm(bookingId, token);
+
+ return (
+
+
+
+
{t("payment.title")}
+
{t("payment.subtitle")}
+
+
{t("payment.badge")}
+
+
+
+
+ {form.status === "error" && form.error && (
+ {form.error}
+ )}
+ {form.status === "ready" && form.result && (
+ {JSON.stringify(form.result, null, 2)}
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx
new file mode 100644
index 0000000..f8d5968
--- /dev/null
+++ b/frontend/src/components/ProtectedRoute.jsx
@@ -0,0 +1,21 @@
+import { Navigate, useLocation } from "react-router-dom";
+import { useAuth } from "../contexts/AuthContext";
+
+export default function ProtectedRoute({ children }) {
+ const { isAuthenticated, loading } = useAuth();
+ const location = useLocation();
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return children;
+}
diff --git a/frontend/src/components/SalonCard.jsx b/frontend/src/components/SalonCard.jsx
new file mode 100644
index 0000000..5d8e713
--- /dev/null
+++ b/frontend/src/components/SalonCard.jsx
@@ -0,0 +1,22 @@
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+
+export default function SalonCard({ salon }) {
+ const { t } = useTranslation();
+ return (
+
+
+
{salon.name}
+ {salon.rating_avg} / 5
+
+ {salon.description || t("card.noDescription")}
+
+ {salon.city}
+ {salon.phone_number || t("card.phoneUnavailable")}
+
+
+ {t("card.viewDetails")}
+
+
+ );
+}
diff --git a/frontend/src/components/SalonSearch.jsx b/frontend/src/components/SalonSearch.jsx
new file mode 100644
index 0000000..5ee3dee
--- /dev/null
+++ b/frontend/src/components/SalonSearch.jsx
@@ -0,0 +1,37 @@
+import { useTranslation } from "react-i18next";
+import { useSalonSearch } from "../hooks/useSalonSearch";
+import SalonCard from "./SalonCard";
+
+export function SearchInput({ value, onChange }) {
+ const { t } = useTranslation();
+ return (
+
+ onChange(e.target.value)}
+ aria-label={t("hero.searchPlaceholder")}
+ />
+
+ );
+}
+
+export default function SalonSearch({ query }) {
+ const { t } = useTranslation();
+ const { salons, status } = useSalonSearch(query);
+
+ return (
+
+ {t("results.title")}
+ {status === "loading" && {t("results.loading")}
}
+ {status === "error" && {t("results.error")}
}
+ {status === "ready" && salons.length === 0 && {t("results.empty")}
}
+
+ {salons.map((salon) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/SalonSearch.test.jsx b/frontend/src/components/SalonSearch.test.jsx
new file mode 100644
index 0000000..a660666
--- /dev/null
+++ b/frontend/src/components/SalonSearch.test.jsx
@@ -0,0 +1,42 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { vi } from "vitest";
+import { BrowserRouter } from "react-router-dom";
+import SalonSearch from "./SalonSearch";
+import i18n from "../i18n";
+
+vi.mock("../api/client", () => ({
+ apiGet: vi.fn(),
+}));
+
+const { apiGet } = await import("../api/client");
+
+function renderWithRouter(ui) {
+ return render({ui});
+}
+
+describe("SalonSearch", () => {
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ apiGet.mockResolvedValue([]);
+ await i18n.changeLanguage("en");
+ });
+
+ it("shows loading then empty when no results", async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(apiGet).toHaveBeenCalledWith("/salons/?q=test");
+ });
+ expect(screen.getByText(/no salons found/i)).toBeInTheDocument();
+ });
+
+ it("shows salon cards when results returned", async () => {
+ apiGet.mockResolvedValue([
+ { id: 1, name: "Salon A", city: "Riyadh", rating_avg: 4.5, phone_number: "+966500000000" },
+ ]);
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByText("Salon A")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Riyadh")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx
new file mode 100644
index 0000000..364a239
--- /dev/null
+++ b/frontend/src/contexts/AuthContext.jsx
@@ -0,0 +1,95 @@
+import { createContext, useCallback, useContext, useEffect, useState } from "react";
+import { apiGet, apiPost } from "../api/client";
+
+const STORAGE_ACCESS = "auth_access";
+const STORAGE_REFRESH = "auth_refresh";
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }) {
+ const [user, setUser] = useState(null);
+ const [accessToken, setAccessToken] = useState(() => {
+ if (typeof window === "undefined") return null;
+ return localStorage.getItem(STORAGE_ACCESS);
+ });
+ const [refreshToken, setRefreshToken] = useState(() => {
+ if (typeof window === "undefined") return null;
+ return localStorage.getItem(STORAGE_REFRESH);
+ });
+ const [loading, setLoading] = useState(true);
+
+ const persistTokens = useCallback((access, refresh) => {
+ setAccessToken(access);
+ setRefreshToken(refresh);
+ if (typeof window !== "undefined") {
+ if (access) localStorage.setItem(STORAGE_ACCESS, access);
+ else localStorage.removeItem(STORAGE_ACCESS);
+ if (refresh) localStorage.setItem(STORAGE_REFRESH, refresh);
+ else localStorage.removeItem(STORAGE_REFRESH);
+ }
+ }, []);
+
+ const logout = useCallback(() => {
+ setUser(null);
+ persistTokens(null, null);
+ }, [persistTokens]);
+
+ const login = useCallback((access, refresh, userData) => {
+ persistTokens(access, refresh);
+ setUser(userData);
+ }, [persistTokens]);
+
+ // Restore user from token on mount
+ useEffect(() => {
+ if (!accessToken) {
+ setLoading(false);
+ return;
+ }
+ apiGet("/auth/me/", accessToken)
+ .then((data) => {
+ setUser(data);
+ setLoading(false);
+ })
+ .catch(() => {
+ // Token invalid, try refresh
+ if (!refreshToken) {
+ logout();
+ setLoading(false);
+ return;
+ }
+ apiPost("/auth/token/refresh/", { refresh: refreshToken })
+ .then(({ access }) => {
+ persistTokens(access, refreshToken);
+ return apiGet("/auth/me/", access);
+ })
+ .then((data) => {
+ setUser(data);
+ })
+ .catch(() => {
+ logout();
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ });
+ }, [accessToken, refreshToken, logout, persistTokens]);
+
+ const value = {
+ user,
+ accessToken,
+ loading,
+ login,
+ logout,
+ isAuthenticated: !!user,
+ };
+
+ return {children};
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) {
+ throw new Error("useAuth must be used within AuthProvider");
+ }
+ return ctx;
+}
diff --git a/frontend/src/hooks/usePaymentForm.js b/frontend/src/hooks/usePaymentForm.js
new file mode 100644
index 0000000..17e4780
--- /dev/null
+++ b/frontend/src/hooks/usePaymentForm.js
@@ -0,0 +1,123 @@
+import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { apiPost } from "../api/client";
+
+function generateIdempotencyKey() {
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
+ return crypto.randomUUID();
+ }
+ return `id-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+}
+
+// Must match AuthContext's STORAGE_ACCESS so fallback finds the same token
+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(() => {
+ if (typeof window !== "undefined") {
+ return `${window.location.origin}/pay/return`;
+ }
+ return "";
+ });
+ const [status, setStatus] = useState("idle");
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState("");
+
+ 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);
+ }
+ }
+ };
+
+ async function submit() {
+ setStatus("loading");
+ setError("");
+ setResult(null);
+
+ if (!bookingIdInput) {
+ setStatus("error");
+ setError(t("payment.errors.bookingRequired"));
+ return;
+ }
+
+ const source = { type: sourceType };
+ if (sourceType === "stcpay") {
+ if (!sourceValue) {
+ setStatus("error");
+ setError(t("payment.errors.mobileRequired"));
+ return;
+ }
+ source.mobile = sourceValue;
+ }
+ if (sourceType === "token") {
+ if (!sourceValue) {
+ setStatus("error");
+ setError(t("payment.errors.tokenRequired"));
+ return;
+ }
+ source.token = sourceValue;
+ }
+
+ const payload = {
+ booking_id: Number(bookingIdInput),
+ provider: "moyasar",
+ idempotency_key: idempotencyKey,
+ source,
+ };
+ if (callbackUrl) {
+ 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);
+ setResult(data);
+ setStatus("ready");
+ if (data?.redirect_url) {
+ window.location.assign(data.redirect_url);
+ }
+ } catch (err) {
+ setStatus("error");
+ setError(err.message || t("payment.errors.generic"));
+ }
+ }
+
+ return {
+ bookingIdInput,
+ setBookingIdInput,
+ tokenInput,
+ setTokenInput: setTokenInputAndPersist,
+ sourceType,
+ setSourceType,
+ sourceValue,
+ setSourceValue,
+ callbackUrl,
+ setCallbackUrl,
+ idempotencyKey,
+ status,
+ result,
+ error,
+ submit,
+ };
+}
diff --git a/frontend/src/hooks/useSalonSearch.js b/frontend/src/hooks/useSalonSearch.js
new file mode 100644
index 0000000..f0046f8
--- /dev/null
+++ b/frontend/src/hooks/useSalonSearch.js
@@ -0,0 +1,33 @@
+import { useEffect, useState } from "react";
+import { apiGet } from "../api/client";
+
+export function useSalonSearch(query) {
+ const [salons, setSalons] = useState([]);
+ const [status, setStatus] = useState("idle");
+
+ useEffect(() => {
+ let ignore = false;
+
+ async function load() {
+ setStatus("loading");
+ try {
+ const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`);
+ if (!ignore) {
+ setSalons(data);
+ setStatus("ready");
+ }
+ } catch {
+ if (!ignore) {
+ setStatus("error");
+ }
+ }
+ }
+
+ load();
+ return () => {
+ ignore = true;
+ };
+ }, [query]);
+
+ return { salons, status };
+}
diff --git a/frontend/src/i18n/ar-sa.json b/frontend/src/i18n/ar-sa.json
index 9cfd115..4e28701 100644
--- a/frontend/src/i18n/ar-sa.json
+++ b/frontend/src/i18n/ar-sa.json
@@ -13,9 +13,10 @@
},
"card": {
"noDescription": "لا يوجد وصف بعد.",
- "phoneUnavailable": "الهاتف غير متوفر"
+ "phoneUnavailable": "الهاتف غير متوفر",
+ "viewDetails": "عرض التفاصيل والحجز"
},
- "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":"الفريق"},"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": {
"label": "اللغة",
"arabic": "العربية",
"english": "الإنجليزية"
diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json
index e1dec8c..15019dc 100644
--- a/frontend/src/i18n/en.json
+++ b/frontend/src/i18n/en.json
@@ -13,9 +13,10 @@
},
"card": {
"noDescription": "No description yet.",
- "phoneUnavailable": "Phone unavailable"
+ "phoneUnavailable": "Phone unavailable",
+ "viewDetails": "View details & book"
},
- "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"},"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": {
"label": "Language",
"arabic": "العربية",
"english": "English"
diff --git a/frontend/src/layouts/MainLayout.jsx b/frontend/src/layouts/MainLayout.jsx
new file mode 100644
index 0000000..969db36
--- /dev/null
+++ b/frontend/src/layouts/MainLayout.jsx
@@ -0,0 +1,45 @@
+import { Outlet, Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import LocaleSwitch from "../components/LocaleSwitch";
+import { useAuth } from "../contexts/AuthContext";
+
+export default function MainLayout() {
+ const { t } = useTranslation();
+ const { isAuthenticated, logout } = useAuth();
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 3f1cbd1..2a75dd4 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -1,11 +1,14 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
+import { AuthProvider } from "./contexts/AuthContext";
import "./i18n";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
-
+
+
+
);
diff --git a/frontend/src/pages/BookPage.jsx b/frontend/src/pages/BookPage.jsx
new file mode 100644
index 0000000..d24958f
--- /dev/null
+++ b/frontend/src/pages/BookPage.jsx
@@ -0,0 +1,167 @@
+import { useEffect, useState } from "react";
+import { useSearchParams, useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { apiGet, apiPost } from "../api/client";
+import { useAuth } from "../contexts/AuthContext";
+import ProtectedRoute from "../components/ProtectedRoute";
+
+export default function BookPage() {
+ const { t } = useTranslation();
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const { accessToken } = useAuth();
+ const salonId = searchParams.get("salon");
+
+ const [salon, setSalon] = useState(null);
+ const [serviceId, setServiceId] = useState("");
+ const [staffId, setStaffId] = useState("");
+ const [date, setDate] = useState("");
+ const [time, setTime] = useState("");
+ const [notes, setNotes] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ if (!salonId) return;
+ apiGet(`/salons/${salonId}/`)
+ .then(setSalon)
+ .catch(() => setSalon(null));
+ }, [salonId]);
+
+ if (!salonId) {
+ return (
+
+ {t("book.title")}
+ {t("book.selectSalon")}
+
+ );
+ }
+
+ const selectedService = salon?.services?.find((s) => String(s.id) === serviceId);
+ const duration = selectedService?.duration_minutes || 0;
+
+ function computeEndTime(startISO) {
+ if (!startISO || !duration) return null;
+ const start = new Date(startISO);
+ const end = new Date(start.getTime() + duration * 60 * 1000);
+ return end.toISOString();
+ }
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ setError("");
+ if (!serviceId || !staffId || !date || !time) {
+ setError(t("book.errors.fillAll"));
+ return;
+ }
+ // Use Asia/Riyadh offset for backend (KSA)
+ const startISO = `${date}T${time}:00+03:00`;
+ const endISO = computeEndTime(startISO);
+ if (!endISO) {
+ setError(t("book.errors.invalidTime"));
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const booking = await apiPost(
+ "/bookings/",
+ {
+ service: Number(serviceId),
+ staff: Number(staffId),
+ start_time: startISO,
+ end_time: endISO,
+ notes,
+ },
+ accessToken
+ );
+ navigate(`/pay?booking=${booking.id}`);
+ } catch (err) {
+ setError(err.message || t("book.errors.generic"));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ const content = (
+
+ {t("book.title")}
+ {salon && {salon.name}
}
+
+ {!salon ? (
+ {t("results.loading")}
+ ) : (
+
+ )}
+
+ );
+
+ return {content};
+}
diff --git a/frontend/src/pages/BookingsPage.jsx b/frontend/src/pages/BookingsPage.jsx
new file mode 100644
index 0000000..381bedc
--- /dev/null
+++ b/frontend/src/pages/BookingsPage.jsx
@@ -0,0 +1,70 @@
+import { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { apiGet } from "../api/client";
+import { useAuth } from "../contexts/AuthContext";
+import ProtectedRoute from "../components/ProtectedRoute";
+
+function formatDateTime(iso) {
+ if (!iso) return "";
+ const d = new Date(iso);
+ return d.toLocaleString(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+}
+
+export default function BookingsPage() {
+ const { t } = useTranslation();
+ const { accessToken } = useAuth();
+ const [bookings, setBookings] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ if (!accessToken) return;
+ apiGet("/bookings/", accessToken)
+ .then(setBookings)
+ .catch((err) => setError(err.message))
+ .finally(() => setLoading(false));
+ }, [accessToken]);
+
+ const content = (
+
+ {t("bookings.title")}
+ {t("bookings.subtitle")}
+
+ {loading && {t("results.loading")}
}
+ {error && {error}
}
+
+ {!loading && !error && bookings.length === 0 && (
+ {t("bookings.empty")}
+ )}
+
+ {!loading && !error && bookings.length > 0 && (
+
+ {bookings.map((b) => (
+ -
+
+ {b.status}
+ {b.salon_name}
+
+ {b.service_name}
+
+ {formatDateTime(b.start_time)} – {formatDateTime(b.end_time)}
+
+
+ {b.price_amount} {b.currency}
+
+
+ {t("bookings.pay")}
+
+
+ ))}
+
+ )}
+
+ );
+
+ return {content};
+}
diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx
new file mode 100644
index 0000000..b293f1a
--- /dev/null
+++ b/frontend/src/pages/HomePage.jsx
@@ -0,0 +1,21 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { SearchInput, default as SalonSearch } from "../components/SalonSearch";
+
+export default function HomePage() {
+ const { t } = useTranslation();
+ const [query, setQuery] = useState("");
+ return (
+ <>
+
+
+ {t("hero.title")}
+ {t("hero.subtitle")}
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx
new file mode 100644
index 0000000..eaa1509
--- /dev/null
+++ b/frontend/src/pages/LoginPage.jsx
@@ -0,0 +1,137 @@
+import { useState } from "react";
+import { useNavigate, useLocation } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { apiPost, ApiError } from "../api/client";
+import { useAuth } from "../contexts/AuthContext";
+
+export default function LoginPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { login } = useAuth();
+ const [step, setStep] = useState("phone");
+ const [phone, setPhone] = useState("");
+ const [channel, setChannel] = useState("sms");
+ const [requestId, setRequestId] = useState("");
+ const [code, setCode] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ const from = location.state?.from?.pathname || "/";
+
+ async function handleRequestOtp(e) {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+ try {
+ const res = await apiPost("/auth/phone/request/", {
+ phone_number: phone,
+ channel,
+ });
+ setRequestId(res.request_id);
+ setStep("verify");
+ } catch (err) {
+ const body = err instanceof ApiError ? err.body : null;
+ if (body?.retry_after_seconds) {
+ setError(t("auth.errors.retryAfter", { seconds: body.retry_after_seconds }));
+ } else {
+ setError(err.message || t("auth.errors.generic"));
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleVerify(e) {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+ try {
+ const res = await apiPost("/auth/phone/verify/", {
+ request_id: requestId,
+ code,
+ });
+ login(res.access, res.refresh, res.user);
+ navigate(from, { replace: true });
+ } catch (err) {
+ const body = err instanceof ApiError ? err.body : null;
+ if (body?.retry_after_seconds) {
+ setError(t("auth.errors.retryAfter", { seconds: body.retry_after_seconds }));
+ } else {
+ setError(err.message || t("auth.errors.generic"));
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ if (step === "phone") {
+ return (
+
+ {t("auth.title")}
+ {t("auth.subtitle")}
+
+
+ );
+ }
+
+ return (
+
+ {t("auth.verifyTitle")}
+ {t("auth.verifySubtitle", { phone })}
+
+
+ );
+}
diff --git a/frontend/src/pages/LoginPage.test.jsx b/frontend/src/pages/LoginPage.test.jsx
new file mode 100644
index 0000000..9c6e411
--- /dev/null
+++ b/frontend/src/pages/LoginPage.test.jsx
@@ -0,0 +1,60 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { vi } from "vitest";
+import { BrowserRouter } from "react-router-dom";
+import LoginPage from "./LoginPage";
+import { AuthProvider } from "../contexts/AuthContext";
+import i18n from "../i18n";
+
+vi.mock("../api/client", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, apiPost: vi.fn() };
+});
+
+const { apiPost } = await import("../api/client");
+
+function renderLogin() {
+ return render(
+
+
+
+
+
+ );
+}
+
+describe("LoginPage", () => {
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ await i18n.changeLanguage("en");
+ });
+
+ it("renders phone input and send code button", () => {
+ renderLogin();
+ expect(screen.getByLabelText(/phone number/i)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Send code" })).toBeInTheDocument();
+ });
+
+ it("shows verify step after successful OTP request", async () => {
+ apiPost.mockResolvedValueOnce({ request_id: "abc-123", expires_at: "2025-01-01T12:00:00Z" });
+ renderLogin();
+
+ fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
+ fireEvent.click(screen.getByRole("button", { name: "Send code" }));
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
+ });
+ });
+
+ it("shows error when OTP request fails", async () => {
+ apiPost.mockRejectedValueOnce(new Error("Rate limited"));
+ renderLogin();
+
+ fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
+ fireEvent.click(screen.getByRole("button", { name: "Send code" }));
+
+ await waitFor(() => {
+ expect(screen.getByText("Rate limited")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/pages/PaymentPage.jsx b/frontend/src/pages/PaymentPage.jsx
new file mode 100644
index 0000000..59f3dc7
--- /dev/null
+++ b/frontend/src/pages/PaymentPage.jsx
@@ -0,0 +1,10 @@
+import { useSearchParams } from "react-router-dom";
+import PaymentForm from "../components/PaymentForm";
+import { useAuth } from "../contexts/AuthContext";
+
+export default function PaymentPage() {
+ const [searchParams] = useSearchParams();
+ const bookingIdFromUrl = searchParams.get("booking") || "";
+ const { accessToken } = useAuth();
+ return ;
+}
diff --git a/frontend/src/pages/PaymentReturnPage.jsx b/frontend/src/pages/PaymentReturnPage.jsx
new file mode 100644
index 0000000..bf5efcc
--- /dev/null
+++ b/frontend/src/pages/PaymentReturnPage.jsx
@@ -0,0 +1,26 @@
+import { useSearchParams, Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+
+export default function PaymentReturnPage() {
+ const { t } = useTranslation();
+ const [searchParams] = useSearchParams();
+ const status = searchParams.get("status") || "";
+ const id = searchParams.get("id") || "";
+
+ const isSuccess = ["paid", "captured", "authorized"].includes(status.toLowerCase());
+
+ return (
+
+ {isSuccess ? t("paymentReturn.success") : t("paymentReturn.title")}
+
+ {isSuccess
+ ? t("paymentReturn.successMessage")
+ : t("paymentReturn.checkStatus")}
+
+ {id && {t("paymentReturn.reference", { id })}
}
+
+ {t("paymentReturn.viewBookings")}
+
+
+ );
+}
diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx
new file mode 100644
index 0000000..4e79231
--- /dev/null
+++ b/frontend/src/pages/ProfilePage.jsx
@@ -0,0 +1,25 @@
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { useAuth } from "../contexts/AuthContext";
+import ProtectedRoute from "../components/ProtectedRoute";
+
+export default function ProfilePage() {
+ const { t } = useTranslation();
+ const { user } = useAuth();
+
+ const content = (
+
+ {t("profile.title")}
+ {user && (
+
+ {user.phone_number || user.email || t("profile.noContact")}
+
+ )}
+
+ {t("profile.myBookings")}
+
+
+ );
+
+ return {content};
+}
diff --git a/frontend/src/pages/SalonDetailPage.jsx b/frontend/src/pages/SalonDetailPage.jsx
new file mode 100644
index 0000000..ba6920f
--- /dev/null
+++ b/frontend/src/pages/SalonDetailPage.jsx
@@ -0,0 +1,55 @@
+import { useEffect, useState } from "react";
+import { useParams, Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { apiGet } from "../api/client";
+
+export default function SalonDetailPage() {
+ const { t } = useTranslation();
+ const { id } = useParams();
+ const [salon, setSalon] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ if (!id) return;
+ apiGet(`/salons/${id}/`)
+ .then(setSalon)
+ .catch((err) => setError(err.message))
+ .finally(() => setLoading(false));
+ }, [id]);
+
+ if (loading) return {t("results.loading")}
;
+ if (error) return {error}
;
+ if (!salon) return null;
+
+ return (
+
+ {salon.name}
+ {salon.description || t("card.noDescription")}
+
+ {salon.city}
+ {salon.phone_number || t("card.phoneUnavailable")}
+
+
+ {t("salon.services")}
+
+ {salon.services?.map((s) => (
+ -
+ {s.name} – {s.duration_minutes} min, {s.price_amount} {s.currency}
+
+ ))}
+
+
+ {t("salon.staff")}
+
+ {salon.staff?.map((s) => (
+ - {s.name || s.title || `Staff ${s.id}`}
+ ))}
+
+
+
+ {t("book.cta")}
+
+
+ );
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index d4ced49..9c6a282 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -25,6 +25,42 @@ body {
padding: 48px 24px 80px;
}
+.main-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 32px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid #eadfd2;
+}
+
+.main-nav {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+}
+
+.nav-brand,
+.nav-link {
+ color: #1c1b1f;
+ text-decoration: none;
+ font-weight: 600;
+}
+
+.nav-brand:hover,
+.nav-link:hover {
+ text-decoration: underline;
+}
+
+.nav-logout {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font: inherit;
+}
+
.hero {
display: flex;
flex-direction: column;
@@ -237,6 +273,151 @@ h1 {
font-size: 14px;
}
+.card-link {
+ display: inline-block;
+ margin-top: 8px;
+ color: #1c1b1f;
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.card-link:hover {
+ text-decoration: underline;
+}
+
+.auth-page {
+ max-width: 400px;
+ margin: 0 auto;
+}
+
+.auth-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ margin-top: 24px;
+}
+
+.auth-subtitle {
+ color: #5c5a5f;
+ margin: 8px 0 0;
+}
+
+.auth-actions {
+ display: flex;
+ gap: 12px;
+}
+
+.auth-back {
+ background: transparent;
+ border: 1px solid #dad3ca;
+ color: #3c3a3f;
+}
+
+.auth-loading {
+ text-align: center;
+ padding: 48px;
+}
+
+.salon-detail {
+ margin-bottom: 32px;
+}
+
+.service-list,
+.staff-list {
+ list-style: none;
+ padding: 0;
+ margin: 12px 0;
+}
+
+.service-list li,
+.staff-list li {
+ padding: 8px 0;
+ border-bottom: 1px solid #eadfd2;
+}
+
+.book-cta {
+ display: inline-block;
+ margin-top: 24px;
+ padding: 12px 24px;
+ background: #1c1b1f;
+ color: white;
+ font-weight: 600;
+ text-decoration: none;
+ border-radius: 999px;
+}
+
+.book-cta:hover {
+ opacity: 0.9;
+}
+
+.book-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ max-width: 400px;
+ margin-top: 24px;
+}
+
+.book-salon {
+ color: #5c5a5f;
+ margin: 4px 0 0;
+}
+
+.bookings-list {
+ list-style: none;
+ padding: 0;
+ margin: 24px 0 0;
+}
+
+.booking-card {
+ background: white;
+ padding: 20px;
+ border-radius: 16px;
+ box-shadow: 0 12px 24px rgba(21, 21, 21, 0.08);
+ margin-bottom: 16px;
+}
+
+.booking-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.booking-status {
+ text-transform: capitalize;
+ font-weight: 600;
+}
+
+.booking-service,
+.booking-time,
+.booking-price {
+ margin: 8px 0 0;
+ color: #5c5a5f;
+}
+
+.booking-pay-link {
+ display: inline-block;
+ margin-top: 12px;
+ font-weight: 600;
+ color: #1c1b1f;
+}
+
+.payment-return {
+ max-width: 480px;
+ margin: 0 auto;
+ text-align: center;
+}
+
+.payment-return-id {
+ font-size: 14px;
+ color: #5c5a5f;
+}
+
+.profile-phone {
+ margin: 8px 0 16px;
+ color: #5c5a5f;
+}
+
.error {
color: #b00020;
}