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.
This commit is contained in:
@@ -70,4 +70,4 @@ Build a reliable, maintainable salon booking platform with Django (backend) and
|
|||||||
|
|
||||||
# ExecPlans
|
# 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`.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Location: `backend/`
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Create a virtualenv and install dependencies.
|
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`
|
- `pip install -r backend/requirements.txt -r backend/requirements-dev.txt`
|
||||||
2. Copy `backend/.env.example` to `backend/.env` and adjust values.
|
2. Copy `backend/.env.example` to `backend/.env` and adjust values.
|
||||||
3. Run migrations and start the server.
|
3. Run migrations and start the server.
|
||||||
@@ -21,7 +22,7 @@ After migrations, you can seed demo data:
|
|||||||
|
|
||||||
### Tests
|
### 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)
|
### Core API endpoints (current scaffold)
|
||||||
|
|
||||||
@@ -61,3 +62,4 @@ The dev server proxies `/api` to `http://localhost:8000`.
|
|||||||
## Project Notes
|
## Project Notes
|
||||||
|
|
||||||
- Known gaps and risks: `docs/risks.md`
|
- Known gaps and risks: `docs/risks.md`
|
||||||
|
- Architecture and async/observability decisions: `docs/architecture.md`
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ class ConsoleOtpProvider(BaseOtpProvider):
|
|||||||
|
|
||||||
|
|
||||||
class TwilioOtpProvider(BaseOtpProvider):
|
class TwilioOtpProvider(BaseOtpProvider):
|
||||||
|
"""Twilio provider for SMS and WhatsApp OTP delivery. Requires TWILIO_* env vars."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
||||||
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
|
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:
|
if not self.account_sid or not self.auth_token or not self.from_number:
|
||||||
raise ValueError(_("Twilio credentials are not configured"))
|
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()
|
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:
|
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||||
self._assert_config()
|
self._assert_config()
|
||||||
if not self.whatsapp_from:
|
if not self.whatsapp_from:
|
||||||
raise ValueError(_("Twilio WhatsApp sender is not configured"))
|
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):
|
class UnifonicOtpProvider(BaseOtpProvider):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -33,10 +33,12 @@ class BasePaymentGateway:
|
|||||||
) -> PaymentInitResult:
|
) -> PaymentInitResult:
|
||||||
raise NotImplementedError
|
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
|
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
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
@@ -101,10 +103,36 @@ class MoyasarGateway(BasePaymentGateway):
|
|||||||
payload=data,
|
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()
|
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()
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,3 +5,4 @@ django-cors-headers>=4.3
|
|||||||
psycopg[binary]>=3.1
|
psycopg[binary]>=3.1
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
requests>=2.31
|
requests>=2.31
|
||||||
|
twilio>=9.0
|
||||||
|
|||||||
@@ -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.
|
||||||
+3
-3
@@ -5,7 +5,7 @@ This file tracks known gaps and risks to address in future iterations.
|
|||||||
## Security And Auth
|
## Security And Auth
|
||||||
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
|
- 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 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.
|
- Social login is a placeholder.
|
||||||
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows.
|
- 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
|
## Payments
|
||||||
- Moyasar payment creation, webhook reconciliation, and idempotency are implemented.
|
- 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
|
## Data And UX
|
||||||
- Ratings are not recalculated from reviews.
|
- Ratings are not recalculated from reviews.
|
||||||
- No image upload or storage strategy for photos.
|
- 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.
|
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
|
||||||
|
|
||||||
## Ops And Compliance
|
## Ops And Compliance
|
||||||
|
|||||||
Generated
+59
-1
@@ -11,7 +11,8 @@
|
|||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.4.2",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
@@ -1950,6 +1951,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3578,6 +3592,44 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -3729,6 +3781,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
|
|||||||
@@ -13,12 +13,13 @@
|
|||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
|
||||||
"@testing-library/jest-dom": "^6.4.2",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
"@testing-library/react": "^14.2.1",
|
"@testing-library/react": "^14.2.1",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
"vitest": "^1.3.1"
|
"vitest": "^1.3.1"
|
||||||
|
|||||||
+24
-240
@@ -1,245 +1,29 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import MainLayout from "./layouts/MainLayout";
|
||||||
import { apiGet, apiPost } from "./api/client";
|
import HomePage from "./pages/HomePage";
|
||||||
import { setLocale } from "./i18n";
|
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() {
|
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 (
|
return (
|
||||||
<div className="page">
|
<BrowserRouter>
|
||||||
<header className="hero">
|
<Routes>
|
||||||
<div className="hero-top">
|
<Route path="/" element={<MainLayout />}>
|
||||||
<p className="eyebrow">{t("hero.eyebrow")}</p>
|
<Route index element={<HomePage />} />
|
||||||
<div className="locale-switch" aria-label={t("locale.label")}>
|
<Route path="salon/:id" element={<SalonDetailPage />} />
|
||||||
<button
|
<Route path="book" element={<BookPage />} />
|
||||||
type="button"
|
<Route path="pay" element={<PaymentPage />} />
|
||||||
className={i18n.language === "ar-sa" ? "active" : ""}
|
<Route path="pay/return" element={<PaymentReturnPage />} />
|
||||||
onClick={() => setLocale("ar-sa")}
|
<Route path="bookings" element={<BookingsPage />} />
|
||||||
>
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
{t("locale.arabic")}
|
<Route path="login" element={<LoginPage />} />
|
||||||
</button>
|
</Route>
|
||||||
<button
|
</Routes>
|
||||||
type="button"
|
</BrowserRouter>
|
||||||
className={i18n.language === "en" ? "active" : ""}
|
|
||||||
onClick={() => setLocale("en")}
|
|
||||||
>
|
|
||||||
{t("locale.english")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1>{t("hero.title")}</h1>
|
|
||||||
<p className="subtitle">{t("hero.subtitle")}</p>
|
|
||||||
<div className="search">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={t("hero.searchPlaceholder")}
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="results">
|
|
||||||
<h2>{t("results.title")}</h2>
|
|
||||||
{status === "loading" && <p>{t("results.loading")}</p>}
|
|
||||||
{status === "error" && (
|
|
||||||
<p className="error">{t("results.error")}</p>
|
|
||||||
)}
|
|
||||||
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
|
|
||||||
<div className="grid">
|
|
||||||
{salons.map((salon) => (
|
|
||||||
<article className="card" key={salon.id}>
|
|
||||||
<div className="card-header">
|
|
||||||
<h3>{salon.name}</h3>
|
|
||||||
<span className="rating">{salon.rating_avg} / 5</span>
|
|
||||||
</div>
|
|
||||||
<p>{salon.description || t("card.noDescription")}</p>
|
|
||||||
<div className="meta">
|
|
||||||
<span>{salon.city}</span>
|
|
||||||
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="payments">
|
|
||||||
<div className="payments-header">
|
|
||||||
<div>
|
|
||||||
<h2>{t("payment.title")}</h2>
|
|
||||||
<p className="payments-subtitle">{t("payment.subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
<span className="payments-badge">{t("payment.badge")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="payments-form" onSubmit={handlePaymentSubmit}>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("payment.bookingId")}</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={paymentBookingId}
|
|
||||||
onChange={(event) => setPaymentBookingId(event.target.value)}
|
|
||||||
placeholder="123"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("payment.accessToken")}</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={paymentToken}
|
|
||||||
onChange={(event) => setPaymentToken(event.target.value)}
|
|
||||||
placeholder={t("payment.accessTokenPlaceholder")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("payment.sourceType")}</span>
|
|
||||||
<select
|
|
||||||
value={paymentSourceType}
|
|
||||||
onChange={(event) => setPaymentSourceType(event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="stcpay">{t("payment.sources.stcpay")}</option>
|
|
||||||
<option value="token">{t("payment.sources.token")}</option>
|
|
||||||
<option value="applepay">{t("payment.sources.applepay")}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("payment.sourceValue")}</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={paymentSourceValue}
|
|
||||||
onChange={(event) => setPaymentSourceValue(event.target.value)}
|
|
||||||
placeholder={t("payment.sourceValuePlaceholder")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("payment.callbackUrl")}</span>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={paymentCallbackUrl}
|
|
||||||
onChange={(event) => setPaymentCallbackUrl(event.target.value)}
|
|
||||||
placeholder="https://example.com/payments/return"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="payments-actions">
|
|
||||||
<button type="submit" disabled={paymentStatus === "loading"}>
|
|
||||||
{paymentStatus === "loading" ? t("payment.processing") : t("payment.payNow")}
|
|
||||||
</button>
|
|
||||||
<p className="helper">
|
|
||||||
{t("payment.idempotency")}: {idempotencyKey}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{paymentStatus === "error" && paymentError && (
|
|
||||||
<p className="error">{paymentError}</p>
|
|
||||||
)}
|
|
||||||
{paymentStatus === "ready" && paymentResult && (
|
|
||||||
<pre className="payment-result">{JSON.stringify(paymentResult, null, 2)}</pre>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
import i18n from "./i18n";
|
import i18n from "./i18n";
|
||||||
|
|
||||||
vi.mock("./api/client", () => ({
|
vi.mock("./api/client", () => ({
|
||||||
@@ -8,10 +9,14 @@ vi.mock("./api/client", () => ({
|
|||||||
apiPost: vi.fn()
|
apiPost: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function TestWrapper({ children }) {
|
||||||
|
return <AuthProvider>{children}</AuthProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
describe("App", () => {
|
describe("App", () => {
|
||||||
it("renders the hero copy", async () => {
|
it("renders the hero copy", async () => {
|
||||||
await i18n.changeLanguage("en");
|
await i18n.changeLanguage("en");
|
||||||
render(<App />);
|
render(<App />, { wrapper: TestWrapper });
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText("Find, compare, and book top salons near you.")
|
await screen.findByText("Find, compare, and book top salons near you.")
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -19,7 +24,7 @@ describe("App", () => {
|
|||||||
|
|
||||||
it("switches to Arabic and sets RTL direction", async () => {
|
it("switches to Arabic and sets RTL direction", async () => {
|
||||||
await i18n.changeLanguage("en");
|
await i18n.changeLanguage("en");
|
||||||
render(<App />);
|
render(<App />, { wrapper: TestWrapper });
|
||||||
const arabicButton = screen.getByRole("button", { name: "العربية" });
|
const arabicButton = screen.getByRole("button", { name: "العربية" });
|
||||||
fireEvent.click(arabicButton);
|
fireEvent.click(arabicButton);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
+55
-13
@@ -2,26 +2,52 @@ import { getActiveLocale } from "../i18n";
|
|||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
|
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
|
||||||
|
|
||||||
async function handleResponse(response) {
|
export class ApiError extends Error {
|
||||||
if (!response.ok) {
|
constructor(message, { status, body } = {}) {
|
||||||
const errorText = await response.text();
|
super(message);
|
||||||
throw new Error(errorText || `Request failed: ${response.status}`);
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
}
|
}
|
||||||
return response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiGet(path) {
|
async function handleResponse(response) {
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const text = await response.text();
|
||||||
headers: {
|
let body = null;
|
||||||
"Accept-Language": getActiveLocale(),
|
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);
|
return handleResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPost(path, body, token) {
|
export async function apiPost(path, body, token) {
|
||||||
const headers = {
|
const headers = {
|
||||||
"Accept-Language": getActiveLocale(),
|
...baseHeaders(),
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
};
|
};
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -31,7 +57,23 @@ export async function apiPost(path, body, token) {
|
|||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
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);
|
return handleResponse(response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { setLocale } from "../i18n";
|
||||||
|
|
||||||
|
export default function LocaleSwitch() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="locale-switch" aria-label={t("locale.label")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={i18n.language === "ar-sa" ? "active" : ""}
|
||||||
|
onClick={() => setLocale("ar-sa")}
|
||||||
|
>
|
||||||
|
{t("locale.arabic")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={i18n.language === "en" ? "active" : ""}
|
||||||
|
onClick={() => setLocale("en")}
|
||||||
|
>
|
||||||
|
{t("locale.english")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="payments">
|
||||||
|
<div className="payments-header">
|
||||||
|
<div>
|
||||||
|
<h2>{t("payment.title")}</h2>
|
||||||
|
<p className="payments-subtitle">{t("payment.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<span className="payments-badge">{t("payment.badge")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="payments-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
form.submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.bookingId")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={form.bookingIdInput}
|
||||||
|
onChange={(e) => form.setBookingIdInput(e.target.value)}
|
||||||
|
placeholder="123"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.accessToken")}</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.tokenInput}
|
||||||
|
onChange={(e) => form.setTokenInput(e.target.value)}
|
||||||
|
placeholder={t("payment.accessTokenPlaceholder")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.sourceType")}</span>
|
||||||
|
<select
|
||||||
|
value={form.sourceType}
|
||||||
|
onChange={(e) => form.setSourceType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="stcpay">{t("payment.sources.stcpay")}</option>
|
||||||
|
<option value="token">{t("payment.sources.token")}</option>
|
||||||
|
<option value="applepay">{t("payment.sources.applepay")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.sourceValue")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.sourceValue}
|
||||||
|
onChange={(e) => form.setSourceValue(e.target.value)}
|
||||||
|
placeholder={t("payment.sourceValuePlaceholder")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.callbackUrl")}</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={form.callbackUrl}
|
||||||
|
onChange={(e) => form.setCallbackUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/payments/return"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="payments-actions">
|
||||||
|
<button type="submit" disabled={form.status === "loading"}>
|
||||||
|
{form.status === "loading"
|
||||||
|
? t("payment.processing")
|
||||||
|
: t("payment.payNow")}
|
||||||
|
</button>
|
||||||
|
<p className="helper">
|
||||||
|
{t("payment.idempotency")}: {form.idempotencyKey}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{form.status === "error" && form.error && (
|
||||||
|
<p className="error">{form.error}</p>
|
||||||
|
)}
|
||||||
|
{form.status === "ready" && form.result && (
|
||||||
|
<pre className="payment-result">{JSON.stringify(form.result, null, 2)}</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="auth-loading">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function SalonCard({ salon }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<article className="card" data-testid="salon-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3>{salon.name}</h3>
|
||||||
|
<span className="rating">{salon.rating_avg} / 5</span>
|
||||||
|
</div>
|
||||||
|
<p>{salon.description || t("card.noDescription")}</p>
|
||||||
|
<div className="meta">
|
||||||
|
<span>{salon.city}</span>
|
||||||
|
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
|
||||||
|
</div>
|
||||||
|
<Link to={`/salon/${salon.id}`} className="card-link">
|
||||||
|
{t("card.viewDetails")}
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("hero.searchPlaceholder")}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
aria-label={t("hero.searchPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalonSearch({ query }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { salons, status } = useSalonSearch(query);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="results">
|
||||||
|
<h2>{t("results.title")}</h2>
|
||||||
|
{status === "loading" && <p>{t("results.loading")}</p>}
|
||||||
|
{status === "error" && <p className="error">{t("results.error")}</p>}
|
||||||
|
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
|
||||||
|
<div className="grid">
|
||||||
|
{salons.map((salon) => (
|
||||||
|
<SalonCard key={salon.id} salon={salon} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(<BrowserRouter>{ui}</BrowserRouter>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SalonSearch", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
apiGet.mockResolvedValue([]);
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading then empty when no results", async () => {
|
||||||
|
renderWithRouter(<SalonSearch query="test" />);
|
||||||
|
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(<SalonSearch query="salon" />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Salon A")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Riyadh")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@
|
|||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"noDescription": "لا يوجد وصف بعد.",
|
"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": "اللغة",
|
"label": "اللغة",
|
||||||
"arabic": "العربية",
|
"arabic": "العربية",
|
||||||
"english": "الإنجليزية"
|
"english": "الإنجليزية"
|
||||||
|
|||||||
@@ -13,9 +13,10 @@
|
|||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"noDescription": "No description yet.",
|
"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",
|
"label": "Language",
|
||||||
"arabic": "العربية",
|
"arabic": "العربية",
|
||||||
"english": "English"
|
"english": "English"
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="page">
|
||||||
|
<header className="main-header">
|
||||||
|
<nav className="main-nav">
|
||||||
|
<Link to="/" className="nav-brand">
|
||||||
|
{t("nav.home")}
|
||||||
|
</Link>
|
||||||
|
<Link to="/book" className="nav-link">
|
||||||
|
{t("nav.book")}
|
||||||
|
</Link>
|
||||||
|
<Link to="/pay" className="nav-link">
|
||||||
|
{t("nav.pay")}
|
||||||
|
</Link>
|
||||||
|
<Link to="/profile" className="nav-link">
|
||||||
|
{t("nav.profile")}
|
||||||
|
</Link>
|
||||||
|
<Link to="/bookings" className="nav-link">
|
||||||
|
{t("nav.bookings")}
|
||||||
|
</Link>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<button type="button" className="nav-link nav-logout" onClick={logout}>
|
||||||
|
{t("nav.logout")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link to="/login" className="nav-link">
|
||||||
|
{t("nav.login")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
<LocaleSwitch />
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<section className="book-page">
|
||||||
|
<h1>{t("book.title")}</h1>
|
||||||
|
<p>{t("book.selectSalon")}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
<section className="book-page">
|
||||||
|
<h1>{t("book.title")}</h1>
|
||||||
|
{salon && <p className="book-salon">{salon.name}</p>}
|
||||||
|
|
||||||
|
{!salon ? (
|
||||||
|
<p>{t("results.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="book-form">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.service")}</span>
|
||||||
|
<select
|
||||||
|
value={serviceId}
|
||||||
|
onChange={(e) => setServiceId(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">{t("book.selectService")}</option>
|
||||||
|
{salon.services?.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} – {s.duration_minutes} min, {s.price_amount} {s.currency}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.staff")}</span>
|
||||||
|
<select
|
||||||
|
value={staffId}
|
||||||
|
onChange={(e) => setStaffId(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">{t("book.selectStaff")}</option>
|
||||||
|
{salon.staff?.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name || s.title || `Staff ${s.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.date")}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.time")}</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => setTime(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.notes")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder={t("book.notesPlaceholder")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<button type="submit" disabled={loading}>
|
||||||
|
{loading ? t("book.submitting") : t("book.submit")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ProtectedRoute>{content}</ProtectedRoute>;
|
||||||
|
}
|
||||||
@@ -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 = (
|
||||||
|
<section className="bookings-page">
|
||||||
|
<h1>{t("bookings.title")}</h1>
|
||||||
|
<p className="bookings-subtitle">{t("bookings.subtitle")}</p>
|
||||||
|
|
||||||
|
{loading && <p>{t("results.loading")}</p>}
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && bookings.length === 0 && (
|
||||||
|
<p>{t("bookings.empty")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && bookings.length > 0 && (
|
||||||
|
<ul className="bookings-list">
|
||||||
|
{bookings.map((b) => (
|
||||||
|
<li key={b.id} className="booking-card">
|
||||||
|
<div className="booking-header">
|
||||||
|
<span className="booking-status">{b.status}</span>
|
||||||
|
<span className="booking-salon">{b.salon_name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="booking-service">{b.service_name}</p>
|
||||||
|
<p className="booking-time">
|
||||||
|
{formatDateTime(b.start_time)} – {formatDateTime(b.end_time)}
|
||||||
|
</p>
|
||||||
|
<p className="booking-price">
|
||||||
|
{b.price_amount} {b.currency}
|
||||||
|
</p>
|
||||||
|
<Link to={`/pay?booking=${b.id}`} className="booking-pay-link">
|
||||||
|
{t("bookings.pay")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ProtectedRoute>{content}</ProtectedRoute>;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<header className="hero">
|
||||||
|
<div className="hero-top">
|
||||||
|
<p className="eyebrow">{t("hero.eyebrow")}</p>
|
||||||
|
</div>
|
||||||
|
<h1>{t("hero.title")}</h1>
|
||||||
|
<p className="subtitle">{t("hero.subtitle")}</p>
|
||||||
|
<SearchInput value={query} onChange={setQuery} />
|
||||||
|
</header>
|
||||||
|
<SalonSearch query={query} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="auth-page">
|
||||||
|
<h1>{t("auth.title")}</h1>
|
||||||
|
<p className="auth-subtitle">{t("auth.subtitle")}</p>
|
||||||
|
<form onSubmit={handleRequestOtp} className="auth-form">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("auth.phone")}</span>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="+966512345678"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("auth.channel")}</span>
|
||||||
|
<select value={channel} onChange={(e) => setChannel(e.target.value)}>
|
||||||
|
<option value="sms">{t("auth.sms")}</option>
|
||||||
|
<option value="whatsapp">{t("auth.whatsapp")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<button type="submit" disabled={loading}>
|
||||||
|
{loading ? t("auth.sending") : t("auth.sendCode")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="auth-page">
|
||||||
|
<h1>{t("auth.verifyTitle")}</h1>
|
||||||
|
<p className="auth-subtitle">{t("auth.verifySubtitle", { phone })}</p>
|
||||||
|
<form onSubmit={handleVerify} className="auth-form">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("auth.code")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
|
||||||
|
placeholder="123456"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<div className="auth-actions">
|
||||||
|
<button type="submit" disabled={loading || code.length < 6}>
|
||||||
|
{loading ? t("auth.verifying") : t("auth.verify")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="auth-back"
|
||||||
|
onClick={() => {
|
||||||
|
setStep("phone");
|
||||||
|
setCode("");
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("auth.back")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<LoginPage />
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <PaymentForm bookingId={bookingIdFromUrl} token={accessToken || ""} />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="payment-return">
|
||||||
|
<h1>{isSuccess ? t("paymentReturn.success") : t("paymentReturn.title")}</h1>
|
||||||
|
<p>
|
||||||
|
{isSuccess
|
||||||
|
? t("paymentReturn.successMessage")
|
||||||
|
: t("paymentReturn.checkStatus")}
|
||||||
|
</p>
|
||||||
|
{id && <p className="payment-return-id">{t("paymentReturn.reference", { id })}</p>}
|
||||||
|
<Link to="/profile" className="book-cta">
|
||||||
|
{t("paymentReturn.viewBookings")}
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = (
|
||||||
|
<section className="profile-page">
|
||||||
|
<h1>{t("profile.title")}</h1>
|
||||||
|
{user && (
|
||||||
|
<p className="profile-phone">
|
||||||
|
{user.phone_number || user.email || t("profile.noContact")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Link to="/bookings" className="book-cta">
|
||||||
|
{t("profile.myBookings")}
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ProtectedRoute>{content}</ProtectedRoute>;
|
||||||
|
}
|
||||||
@@ -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 <p>{t("results.loading")}</p>;
|
||||||
|
if (error) return <p className="error">{error}</p>;
|
||||||
|
if (!salon) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="salon-detail">
|
||||||
|
<h1>{salon.name}</h1>
|
||||||
|
<p>{salon.description || t("card.noDescription")}</p>
|
||||||
|
<div className="meta">
|
||||||
|
<span>{salon.city}</span>
|
||||||
|
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{t("salon.services")}</h2>
|
||||||
|
<ul className="service-list">
|
||||||
|
{salon.services?.map((s) => (
|
||||||
|
<li key={s.id}>
|
||||||
|
{s.name} – {s.duration_minutes} min, {s.price_amount} {s.currency}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>{t("salon.staff")}</h2>
|
||||||
|
<ul className="staff-list">
|
||||||
|
{salon.staff?.map((s) => (
|
||||||
|
<li key={s.id}>{s.name || s.title || `Staff ${s.id}`}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Link to={`/book?salon=${salon.id}`} className="book-cta">
|
||||||
|
{t("book.cta")}
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,42 @@ body {
|
|||||||
padding: 48px 24px 80px;
|
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 {
|
.hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -237,6 +273,151 @@ h1 {
|
|||||||
font-size: 14px;
|
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 {
|
.error {
|
||||||
color: #b00020;
|
color: #b00020;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user