From a1da918f95da5911d31e6d722fcdc1d404deea0c Mon Sep 17 00:00:00 2001 From: mohammad Date: Sat, 28 Feb 2026 15:33:50 +0300 Subject: [PATCH] Enhance documentation, implement Twilio OTP delivery, and update payment gateway methods. Updated AGENTS.md and README.md for clarity on ExecPlans and architecture. Added Twilio as a dependency and implemented capture/refund methods in MoyasarGateway. Improved frontend routing with react-router-dom and added authentication context. Updated styles and localization files for better user experience. --- AGENTS.md | 2 +- README.md | 4 +- backend/apps/accounts/services/otp.py | 16 +- .../accounts/tests/test_twilio_provider.py | 51 ++++ backend/apps/payments/services/gateway.py | 40 ++- .../tests/test_gateway_capture_refund.py | 54 ++++ backend/requirements.txt | 1 + docs/architecture.md | 39 +++ docs/risks.md | 6 +- frontend/package-lock.json | 60 +++- frontend/package.json | 5 +- frontend/src/App.jsx | 264 ++---------------- frontend/src/App.test.jsx | 9 +- frontend/src/api/client.js | 68 ++++- frontend/src/components/LocaleSwitch.jsx | 24 ++ frontend/src/components/PaymentForm.jsx | 94 +++++++ frontend/src/components/ProtectedRoute.jsx | 21 ++ frontend/src/components/SalonCard.jsx | 22 ++ frontend/src/components/SalonSearch.jsx | 37 +++ frontend/src/components/SalonSearch.test.jsx | 42 +++ frontend/src/contexts/AuthContext.jsx | 95 +++++++ frontend/src/hooks/usePaymentForm.js | 123 ++++++++ frontend/src/hooks/useSalonSearch.js | 33 +++ frontend/src/i18n/ar-sa.json | 5 +- frontend/src/i18n/en.json | 5 +- frontend/src/layouts/MainLayout.jsx | 45 +++ frontend/src/main.jsx | 5 +- frontend/src/pages/BookPage.jsx | 167 +++++++++++ frontend/src/pages/BookingsPage.jsx | 70 +++++ frontend/src/pages/HomePage.jsx | 21 ++ frontend/src/pages/LoginPage.jsx | 137 +++++++++ frontend/src/pages/LoginPage.test.jsx | 60 ++++ frontend/src/pages/PaymentPage.jsx | 10 + frontend/src/pages/PaymentReturnPage.jsx | 26 ++ frontend/src/pages/ProfilePage.jsx | 25 ++ frontend/src/pages/SalonDetailPage.jsx | 55 ++++ frontend/src/styles.css | 181 ++++++++++++ 37 files changed, 1645 insertions(+), 277 deletions(-) create mode 100644 backend/apps/accounts/tests/test_twilio_provider.py create mode 100644 backend/apps/payments/tests/test_gateway_capture_refund.py create mode 100644 docs/architecture.md create mode 100644 frontend/src/components/LocaleSwitch.jsx create mode 100644 frontend/src/components/PaymentForm.jsx create mode 100644 frontend/src/components/ProtectedRoute.jsx create mode 100644 frontend/src/components/SalonCard.jsx create mode 100644 frontend/src/components/SalonSearch.jsx create mode 100644 frontend/src/components/SalonSearch.test.jsx create mode 100644 frontend/src/contexts/AuthContext.jsx create mode 100644 frontend/src/hooks/usePaymentForm.js create mode 100644 frontend/src/hooks/useSalonSearch.js create mode 100644 frontend/src/layouts/MainLayout.jsx create mode 100644 frontend/src/pages/BookPage.jsx create mode 100644 frontend/src/pages/BookingsPage.jsx create mode 100644 frontend/src/pages/HomePage.jsx create mode 100644 frontend/src/pages/LoginPage.jsx create mode 100644 frontend/src/pages/LoginPage.test.jsx create mode 100644 frontend/src/pages/PaymentPage.jsx create mode 100644 frontend/src/pages/PaymentReturnPage.jsx create mode 100644 frontend/src/pages/ProfilePage.jsx create mode 100644 frontend/src/pages/SalonDetailPage.jsx 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("hero.eyebrow")}

-
- - -
-
-

{t("hero.title")}

-

{t("hero.subtitle")}

-
- setQuery(event.target.value)} - /> -
-
- -
-

{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")} -
- -
- - - - - -
- -

- {t("payment.idempotency")}: {idempotencyKey} -

-
-
- - {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")} +
+ +
{ + e.preventDefault(); + form.submit(); + }} + > + + + + + +
+ +

+ {t("payment.idempotency")}: {form.idempotencyKey} +

+
+
+ + {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 ( +
+

Loading...

+
+ ); + } + + 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")}

+ ) : ( +
+ + + + + + + + + + + {error &&

{error}

} + +
+ )} +
+ ); + + 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.eyebrow")}

+
+

{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")}

+
+ + + {error &&

{error}

} + +
+
+ ); + } + + return ( +
+

{t("auth.verifyTitle")}

+

{t("auth.verifySubtitle", { phone })}

+
+ + {error &&

{error}

} +
+ + +
+
+
+ ); +} 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; }