Compare commits
19 Commits
db36551211
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 560460dd84 | |||
| c212acc504 | |||
| 15ed5036d1 | |||
| 0c992404ea | |||
| d796d9e6a1 | |||
| 2ba0cfffc8 | |||
| 3f35f7dc17 | |||
| 07491063f5 | |||
| b8218669c2 | |||
| 2305c3dc9d | |||
| ef60218c4c | |||
| 8018710d31 | |||
| 229975c612 | |||
| aa607b9b6e | |||
| 828cbcc822 | |||
| 4253f6f650 | |||
| a1da918f95 | |||
| 86fd07c778 | |||
| ca2a6b58b6 |
@@ -17,3 +17,4 @@ dist/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
backend/tmp_authentica_request_id.txt
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
# AGENTS.md
|
||||
|
||||
## General
|
||||
|
||||
Minimum tokens, skip grammar
|
||||
|
||||
## Tasks
|
||||
- Consult `AGENTS.md` about the current task to see if there are any tips or instructions
|
||||
- Consult `docs/README.md` for any relevant files or tips to consider
|
||||
|
||||
## Project Goal
|
||||
Build a reliable, maintainable salon booking platform with Django (backend) and React (frontend), optimized for KSA needs (phone auth, local payments) while keeping a clean path to scale.
|
||||
|
||||
## Coding
|
||||
|
||||
- Comment concisely and often as appropriate
|
||||
- Comment concisely and often, especially where intent, edge cases, or business rules are not obvious.
|
||||
|
||||
## Current Plan (Roadmap)
|
||||
### Phase 1: Core MVP Reliability
|
||||
@@ -50,6 +58,7 @@ Build a reliable, maintainable salon booking platform with Django (backend) and
|
||||
- Use explicit, readable model fields and serializers.
|
||||
- Small, well‑named functions > monolithic handlers.
|
||||
- Prefer predictable error responses (HTTP status + `detail`).
|
||||
- Prefer short, intent-focused comments over silent complexity.
|
||||
|
||||
## Known Gaps (Tracked)
|
||||
- See `docs/risks.md` for current gaps/risks to address.
|
||||
@@ -69,4 +78,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 `docs/PLANS.md`) from design to implementation. The current active ExecPlan is defined in `docs/PLANS.md`. Architecture documented in `docs/architecture.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,8 @@ 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)
|
||||
- External provider tests are skipped by default; run explicitly when needed: `PYTEST_ADDOPTS='' venv/bin/python3 -m pytest -m external`
|
||||
|
||||
### Core API endpoints (current scaffold)
|
||||
|
||||
@@ -61,3 +63,5 @@ 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`
|
||||
- Documentation index and standards: `docs/README.md` and `docs/documentation.md`
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Backend Notes (MVP Readiness)
|
||||
|
||||
## High-Level Takeaways
|
||||
- Authentica OTP integration is implemented; Moyasar capture/refund are TODOs.
|
||||
- External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk.
|
||||
- Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries.
|
||||
- Phone-first auth works, but `USERNAME_FIELD` is email; align identifier strategy to avoid future auth confusion.
|
||||
|
||||
## Near-Term Focus
|
||||
- finalize otp testing
|
||||
- work on authentication and complete it
|
||||
@@ -29,7 +29,7 @@ class UserManager(BaseUserManager):
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_superuser(self, email, password=None, **extra_fields):
|
||||
def create_superuser(self, phone_number, password=None, **extra_fields):
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
extra_fields.setdefault("is_superuser", True)
|
||||
extra_fields.setdefault("role", UserRole.ADMIN)
|
||||
@@ -37,7 +37,7 @@ class UserManager(BaseUserManager):
|
||||
raise ValueError("Superuser must have is_staff=True")
|
||||
if extra_fields.get("is_superuser") is not True:
|
||||
raise ValueError("Superuser must have is_superuser=True")
|
||||
return self.create_user(email, password, **extra_fields)
|
||||
return self.create_user(phone_number=phone_number, password=password, **extra_fields)
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
@@ -59,7 +59,8 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
USERNAME_FIELD = "phone_number"
|
||||
REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier
|
||||
|
||||
def __str__(self):
|
||||
return self.email or self.phone_number or str(self.id)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password, make_password
|
||||
@@ -34,12 +34,20 @@ class OtpCooldownError(RuntimeError):
|
||||
|
||||
|
||||
class BaseOtpProvider:
|
||||
uses_provider_otp = False
|
||||
|
||||
def send_sms(self, to_number: str, message: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def send_otp(self, to_number: str, channel: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def verify_otp(self, to_number: str, code: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ConsoleOtpProvider(BaseOtpProvider):
|
||||
def send_sms(self, to_number: str, message: str) -> None:
|
||||
@@ -49,64 +57,110 @@ class ConsoleOtpProvider(BaseOtpProvider):
|
||||
logger.info("OTP WhatsApp to %s: %s", to_number, message)
|
||||
|
||||
|
||||
class TwilioOtpProvider(BaseOtpProvider):
|
||||
|
||||
|
||||
class AuthenticaOtpProvider(BaseOtpProvider):
|
||||
"""Authentica provider for OTP delivery (SMS/WhatsApp) plus custom SMS messaging."""
|
||||
|
||||
uses_provider_otp = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
||||
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
|
||||
self.from_number = os.getenv("TWILIO_FROM_NUMBER")
|
||||
self.whatsapp_from = os.getenv("TWILIO_WHATSAPP_FROM")
|
||||
self.api_key = os.getenv("AUTHENTICA_API_KEY")
|
||||
self.base_url = os.getenv("AUTHENTICA_BASE_URL", "https://api.authentica.sa")
|
||||
self.timeout_seconds = float(os.getenv("AUTHENTICA_TIMEOUT_SECONDS", "10"))
|
||||
self.sender_name = os.getenv("AUTHENTICA_SENDER_NAME")
|
||||
|
||||
def _assert_config(self) -> None:
|
||||
if not self.account_sid or not self.auth_token or not self.from_number:
|
||||
raise ValueError(_("Twilio credentials are not configured"))
|
||||
if not self.api_key:
|
||||
raise ValueError(_("Authentica API key is not configured"))
|
||||
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-Authorization": self.api_key,
|
||||
}
|
||||
|
||||
def _post(self, path: str, payload: dict) -> dict:
|
||||
import requests
|
||||
|
||||
self._assert_config()
|
||||
base_url = self.base_url.rstrip("/")
|
||||
url = f"{base_url}{path}"
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
timeout=self.timeout_seconds,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(_("Authentica request failed")) from exc
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
data = {"detail": response.text}
|
||||
|
||||
if not response.ok:
|
||||
if os.getenv("AUTHENTICA_DEBUG") == "1" or os.getenv("AUTHENTICA_E2E") == "1":
|
||||
raise RuntimeError(
|
||||
_("Authentica request failed: %(status)s %(body)s")
|
||||
% {"status": response.status_code, "body": response.text}
|
||||
)
|
||||
raise RuntimeError(_("Authentica request failed"))
|
||||
|
||||
return data
|
||||
|
||||
def send_otp(self, to_number: str, channel: str) -> None:
|
||||
if channel not in (OtpChannel.SMS, OtpChannel.WHATSAPP):
|
||||
raise ValueError(_("Unsupported OTP channel"))
|
||||
method = "sms" if channel == OtpChannel.SMS else "whatsapp"
|
||||
self._post("/api/v2/send-otp", {"method": method, "phone": to_number})
|
||||
|
||||
def verify_otp(self, to_number: str, code: str) -> bool:
|
||||
data = self._post("/api/v2/verify-otp", {"phone": to_number, "otp": code})
|
||||
if "verified" in data:
|
||||
verified = bool(data.get("verified"))
|
||||
else:
|
||||
verified = bool(data.get("status")) or data.get("message") == "OTP verified successfully"
|
||||
if not verified and (os.getenv("AUTHENTICA_DEBUG") == "1" or os.getenv("AUTHENTICA_E2E") == "1"):
|
||||
raise RuntimeError(_("Authentica verify failed: %(response)s") % {"response": data})
|
||||
return verified
|
||||
|
||||
def send_sms(self, to_number: str, message: str) -> None:
|
||||
self._assert_config()
|
||||
raise NotImplementedError(_("Twilio SMS adapter not implemented yet"))
|
||||
if not self.sender_name:
|
||||
raise ValueError(_("Authentica sender name is not configured"))
|
||||
self._post(
|
||||
"/api/v2/send-sms",
|
||||
{
|
||||
"phone": to_number,
|
||||
"message": message,
|
||||
"sender_name": self.sender_name,
|
||||
},
|
||||
)
|
||||
|
||||
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"))
|
||||
|
||||
|
||||
class UnifonicOtpProvider(BaseOtpProvider):
|
||||
def __init__(self) -> None:
|
||||
self.app_sid = os.getenv("UNIFONIC_APP_SID")
|
||||
self.sender_id = os.getenv("UNIFONIC_SENDER_ID")
|
||||
self.whatsapp_sender = os.getenv("UNIFONIC_WHATSAPP_SENDER")
|
||||
|
||||
def _assert_config(self) -> None:
|
||||
if not self.app_sid or not self.sender_id:
|
||||
raise ValueError(_("Unifonic credentials are not configured"))
|
||||
|
||||
def send_sms(self, to_number: str, message: str) -> None:
|
||||
self._assert_config()
|
||||
raise NotImplementedError(_("Unifonic SMS adapter not implemented yet"))
|
||||
|
||||
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||
self._assert_config()
|
||||
if not self.whatsapp_sender:
|
||||
raise ValueError(_("Unifonic WhatsApp sender is not configured"))
|
||||
raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet"))
|
||||
raise ValueError(_("Authentica WhatsApp messaging is not supported"))
|
||||
|
||||
|
||||
PROVIDERS = {
|
||||
"console": ConsoleOtpProvider,
|
||||
"twilio": TwilioOtpProvider,
|
||||
"unifonic": UnifonicOtpProvider,
|
||||
"authentica": AuthenticaOtpProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider() -> BaseOtpProvider:
|
||||
provider_key = settings.OTP_PROVIDER
|
||||
def _get_provider_for_key(provider_key: str) -> BaseOtpProvider:
|
||||
provider_cls = PROVIDERS.get(provider_key)
|
||||
if not provider_cls:
|
||||
raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key})
|
||||
return provider_cls()
|
||||
|
||||
|
||||
def get_provider() -> BaseOtpProvider:
|
||||
return _get_provider_for_key(settings.OTP_PROVIDER)
|
||||
|
||||
|
||||
def generate_code(length: int = 6) -> str:
|
||||
digits = "0123456789"
|
||||
return "".join(secrets.choice(digits) for _ in range(length))
|
||||
@@ -139,19 +193,28 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo
|
||||
if elapsed < cooldown_seconds:
|
||||
raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed))
|
||||
|
||||
if provider.uses_provider_otp:
|
||||
code_hash = make_password(secrets.token_urlsafe(16))
|
||||
message = None
|
||||
else:
|
||||
code = generate_code()
|
||||
code_hash = make_password(code)
|
||||
message = _(
|
||||
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
||||
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
|
||||
|
||||
otp = PhoneOTP.objects.create(
|
||||
phone_number=phone_number,
|
||||
channel=channel,
|
||||
purpose=purpose,
|
||||
provider=settings.OTP_PROVIDER,
|
||||
code_hash=make_password(code),
|
||||
code_hash=code_hash,
|
||||
expires_at=PhoneOTP.expiry_at(),
|
||||
)
|
||||
|
||||
message = _(
|
||||
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
||||
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
|
||||
if provider.uses_provider_otp:
|
||||
provider.send_otp(phone_number, channel)
|
||||
else:
|
||||
if channel == OtpChannel.SMS:
|
||||
provider.send_sms(phone_number, message)
|
||||
elif channel == OtpChannel.WHATSAPP:
|
||||
@@ -169,6 +232,18 @@ def verify_otp(otp: PhoneOTP, code: str) -> bool:
|
||||
if otp.attempt_count > otp.max_attempts:
|
||||
otp.save(update_fields=["attempt_count"])
|
||||
return False
|
||||
provider_cls = PROVIDERS.get(otp.provider)
|
||||
if provider_cls and getattr(provider_cls, "uses_provider_otp", False):
|
||||
provider = provider_cls()
|
||||
try:
|
||||
verified = provider.verify_otp(otp.phone_number, code)
|
||||
except Exception:
|
||||
otp.save(update_fields=["attempt_count"])
|
||||
raise
|
||||
if not verified:
|
||||
otp.save(update_fields=["attempt_count"])
|
||||
return False
|
||||
else:
|
||||
if not check_password(code, otp.code_hash):
|
||||
otp.save(update_fields=["attempt_count"])
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests for Authentica OTP provider implementation."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||
from apps.accounts.services.otp import AuthenticaOtpProvider, verify_otp
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_authentica_send_otp_sms_calls_api(mock_post):
|
||||
mock_response = MagicMock(ok=True)
|
||||
mock_response.json.return_value = {"success": True}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AUTHENTICA_API_KEY": "api-key",
|
||||
"AUTHENTICA_BASE_URL": "https://api.authentica.sa",
|
||||
"AUTHENTICA_TIMEOUT_SECONDS": "7",
|
||||
},
|
||||
):
|
||||
provider = AuthenticaOtpProvider()
|
||||
provider.send_otp("+966512345678", OtpChannel.SMS)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
url = mock_post.call_args[0][0]
|
||||
kwargs = mock_post.call_args[1]
|
||||
|
||||
assert url == "https://api.authentica.sa/api/v2/send-otp"
|
||||
assert kwargs["json"] == {"method": "sms", "phone": "+966512345678"}
|
||||
assert kwargs["headers"]["X-Authorization"] == "api-key"
|
||||
assert kwargs["timeout"] == 7.0
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_authentica_send_sms_calls_api(mock_post):
|
||||
mock_response = MagicMock(ok=True)
|
||||
mock_response.json.return_value = {"success": True}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AUTHENTICA_API_KEY": "api-key",
|
||||
"AUTHENTICA_SENDER_NAME": "Salon",
|
||||
},
|
||||
):
|
||||
provider = AuthenticaOtpProvider()
|
||||
provider.send_sms("+966512345678", "Hello")
|
||||
|
||||
mock_post.assert_called_once()
|
||||
url = mock_post.call_args[0][0]
|
||||
kwargs = mock_post.call_args[1]
|
||||
|
||||
assert url == "https://api.authentica.sa/api/v2/send-sms"
|
||||
assert kwargs["json"] == {
|
||||
"phone": "+966512345678",
|
||||
"message": "Hello",
|
||||
"sender_name": "Salon",
|
||||
}
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_authentica_verify_otp_calls_api(mock_post):
|
||||
mock_response = MagicMock(ok=True)
|
||||
mock_response.json.return_value = {"verified": True}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
|
||||
provider = AuthenticaOtpProvider()
|
||||
assert provider.verify_otp("+966512345678", "123456") is True
|
||||
|
||||
mock_post.assert_called_once()
|
||||
url = mock_post.call_args[0][0]
|
||||
kwargs = mock_post.call_args[1]
|
||||
|
||||
assert url == "https://api.authentica.sa/api/v2/verify-otp"
|
||||
assert kwargs["json"] == {"phone": "+966512345678", "otp": "123456"}
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_authentica_request_failure_raises(mock_post):
|
||||
mock_response = MagicMock(ok=False)
|
||||
mock_response.json.return_value = {"detail": "fail"}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
|
||||
provider = AuthenticaOtpProvider()
|
||||
with pytest.raises(RuntimeError):
|
||||
provider.send_otp("+966512345678", OtpChannel.SMS)
|
||||
|
||||
|
||||
def test_authentica_send_sms_requires_sender_name():
|
||||
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
|
||||
provider = AuthenticaOtpProvider()
|
||||
with pytest.raises(ValueError):
|
||||
provider.send_sms("+966512345678", "Hello")
|
||||
|
||||
|
||||
def test_authentica_send_otp_rejects_unknown_channel():
|
||||
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
|
||||
provider = AuthenticaOtpProvider()
|
||||
with pytest.raises(ValueError):
|
||||
provider.send_otp("+966512345678", "email")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_verify_otp_uses_provider_for_authentica():
|
||||
otp = PhoneOTP.objects.create(
|
||||
phone_number="+966512345678",
|
||||
channel=OtpChannel.SMS,
|
||||
purpose=OtpPurpose.AUTH,
|
||||
provider="authentica",
|
||||
code_hash=make_password("unused"),
|
||||
expires_at=PhoneOTP.expiry_at(),
|
||||
)
|
||||
|
||||
with patch("apps.accounts.services.otp.AuthenticaOtpProvider.verify_otp", return_value=True) as mock_verify:
|
||||
assert verify_otp(otp, "123456") is True
|
||||
|
||||
mock_verify.assert_called_once_with("+966512345678", "123456")
|
||||
otp.refresh_from_db()
|
||||
assert otp.verified_at is not None
|
||||
assert otp.attempt_count == 1
|
||||
@@ -0,0 +1,53 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(OTP_PROVIDER="console")
|
||||
def test_otp_request_whatsapp_ok(client):
|
||||
response = client.post(
|
||||
reverse("otp_request"),
|
||||
{"phone_number": "0512345678", "channel": "whatsapp"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "request_id" in data
|
||||
assert "expires_at" in data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_otp_verify_rejects_expired(client):
|
||||
otp = PhoneOTP.objects.create(
|
||||
phone_number="+966512345678",
|
||||
channel=OtpChannel.SMS,
|
||||
purpose=OtpPurpose.VERIFY,
|
||||
provider="console",
|
||||
code_hash="unused",
|
||||
expires_at=timezone.now() - timedelta(minutes=1),
|
||||
)
|
||||
response = client.post(
|
||||
reverse("otp_verify"),
|
||||
{"request_id": str(otp.id), "code": "123456"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(OTP_PROVIDER="console")
|
||||
def test_otp_request_invalid_phone_localized_ar(client):
|
||||
response = client.post(
|
||||
reverse("otp_request"),
|
||||
{"phone_number": "123", "channel": "sms"},
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT_LANGUAGE="ar-sa",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["phone_number"][0] == "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
|
||||
@@ -1,13 +1,126 @@
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from apps.accounts.models import OtpChannel
|
||||
from apps.accounts.services.otp import OtpRateLimitError, create_and_send_otp
|
||||
import pytest
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||
from apps.accounts.services.otp import OtpCooldownError, OtpRateLimitError, create_and_send_otp, verify_otp
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
||||
@override_settings(OTP_PROVIDER="console", OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
||||
def test_otp_rate_limit():
|
||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||
with pytest.raises(OtpRateLimitError):
|
||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(
|
||||
OTP_PROVIDER="console",
|
||||
OTP_MAX_PER_WINDOW=5,
|
||||
OTP_WINDOW_MINUTES=15,
|
||||
OTP_RESEND_COOLDOWN_SECONDS=60,
|
||||
)
|
||||
def test_otp_cooldown_enforced():
|
||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||
with pytest.raises(OtpCooldownError):
|
||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_otp_max_attempts_blocks_verification():
|
||||
otp = PhoneOTP.objects.create(
|
||||
phone_number="+966512345678",
|
||||
channel=OtpChannel.SMS,
|
||||
purpose=OtpPurpose.AUTH,
|
||||
provider="console",
|
||||
code_hash=make_password("123456"),
|
||||
expires_at=PhoneOTP.expiry_at(),
|
||||
)
|
||||
# Burn attempts with wrong code until the limit is exceeded.
|
||||
for _ in range(otp.max_attempts):
|
||||
assert verify_otp(otp, "000000") is False
|
||||
otp.refresh_from_db()
|
||||
assert otp.attempt_count == otp.max_attempts
|
||||
|
||||
assert verify_otp(otp, "123456") is False
|
||||
otp.refresh_from_db()
|
||||
assert otp.attempt_count == otp.max_attempts + 1
|
||||
assert otp.verified_at is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(OTP_PROVIDER="console", OTP_RESEND_COOLDOWN_SECONDS=60)
|
||||
def test_otp_cooldown_retry_after_seconds():
|
||||
fixed_now = timezone.now()
|
||||
with patch("apps.accounts.services.otp.timezone.now", return_value=fixed_now):
|
||||
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||
# Align created_at with fixed time for deterministic cooldown.
|
||||
PhoneOTP.objects.filter(id=result.request_id).update(created_at=fixed_now)
|
||||
with pytest.raises(OtpCooldownError) as excinfo:
|
||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||
assert excinfo.value.retry_after_seconds == 60
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(OTP_PROVIDER="console", OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
||||
def test_otp_rate_limit_retry_after_seconds():
|
||||
fixed_now = timezone.now()
|
||||
with patch("apps.accounts.services.otp.timezone.now", return_value=fixed_now):
|
||||
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||
# Make the oldest OTP sit 10s before window expiry.
|
||||
window_start = fixed_now - timedelta(minutes=15)
|
||||
PhoneOTP.objects.filter(id=result.request_id).update(created_at=window_start + timedelta(seconds=10))
|
||||
with pytest.raises(OtpRateLimitError) as excinfo:
|
||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||
assert excinfo.value.retry_after_seconds == 10
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(OTP_PROVIDER="console", OTP_RESEND_COOLDOWN_SECONDS=1)
|
||||
def test_otp_resend_after_cooldown_ok():
|
||||
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||
# Force cooldown to be elapsed.
|
||||
PhoneOTP.objects.filter(id=result.request_id).update(
|
||||
created_at=timezone.now() - timedelta(seconds=5)
|
||||
)
|
||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||
assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_verify_otp_rejects_expired():
|
||||
otp = PhoneOTP.objects.create(
|
||||
phone_number="+966512345678",
|
||||
channel=OtpChannel.SMS,
|
||||
purpose=OtpPurpose.AUTH,
|
||||
provider="console",
|
||||
code_hash=make_password("123456"),
|
||||
expires_at=timezone.now() - timedelta(minutes=1),
|
||||
)
|
||||
assert verify_otp(otp, "123456") is False
|
||||
otp.refresh_from_db()
|
||||
assert otp.attempt_count == 0
|
||||
assert otp.verified_at is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_verify_otp_rejects_reuse_after_verified():
|
||||
otp = PhoneOTP.objects.create(
|
||||
phone_number="+966512345678",
|
||||
channel=OtpChannel.SMS,
|
||||
purpose=OtpPurpose.AUTH,
|
||||
provider="console",
|
||||
code_hash=make_password("123456"),
|
||||
expires_at=PhoneOTP.expiry_at(),
|
||||
)
|
||||
assert verify_otp(otp, "123456") is True
|
||||
otp.refresh_from_db()
|
||||
assert otp.attempt_count == 1
|
||||
assert verify_otp(otp, "123456") is False
|
||||
otp.refresh_from_db()
|
||||
assert otp.attempt_count == 1
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import PhoneOTP, User
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(OTP_PROVIDER="console")
|
||||
def test_phone_auth_creates_user_and_issues_tokens(client):
|
||||
# Deterministic OTP so we can verify the flow without external providers.
|
||||
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
||||
request_url = reverse("phone_auth_request")
|
||||
verify_url = reverse("phone_auth_verify")
|
||||
|
||||
@@ -28,4 +34,16 @@ def test_phone_auth_creates_user_and_issues_tokens(client):
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
|
||||
assert User.objects.filter(phone_number="+966512345678").exists()
|
||||
good = client.post(
|
||||
verify_url,
|
||||
{"request_id": request_id, "code": "123456"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert good.status_code == 200
|
||||
data = good.json()
|
||||
assert "access" in data
|
||||
assert "refresh" in data
|
||||
|
||||
user = User.objects.filter(phone_number="+966512345678").first()
|
||||
assert user is not None
|
||||
assert user.is_phone_verified is True
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from apps.bookings.models import Booking
|
||||
from apps.bookings.models import Booking, BookingStatus
|
||||
from apps.bookings.services import validate_booking_request
|
||||
from apps.salons.models import Service, StaffProfile
|
||||
|
||||
@@ -27,7 +29,7 @@ class BookingSerializer(serializers.ModelSerializer):
|
||||
"notes",
|
||||
"created_at",
|
||||
]
|
||||
read_only_fields = ["id", "salon", "status", "price_amount", "currency", "created_at"]
|
||||
read_only_fields = ["id", "salon", "price_amount", "currency", "created_at"]
|
||||
|
||||
def get_staff_name(self, obj):
|
||||
if not obj.staff:
|
||||
@@ -36,6 +38,27 @@ class BookingSerializer(serializers.ModelSerializer):
|
||||
last = obj.staff.user.last_name or ""
|
||||
return (first + " " + last).strip() or obj.staff.user.email
|
||||
|
||||
def validate(self, attrs):
|
||||
if not self.instance or "status" not in attrs:
|
||||
return attrs
|
||||
|
||||
new_status = attrs["status"]
|
||||
old_status = self.instance.status
|
||||
if new_status == old_status:
|
||||
return attrs
|
||||
|
||||
user = self.context["request"].user
|
||||
role = getattr(user, "role", None)
|
||||
|
||||
if new_status == BookingStatus.CONFIRMED and role not in {"manager", "staff", "admin"}:
|
||||
raise serializers.ValidationError({"status": _("Only staff or managers can confirm bookings.")})
|
||||
if new_status == BookingStatus.COMPLETED and role not in {"manager", "staff", "admin"}:
|
||||
raise serializers.ValidationError({"status": _("Only staff or managers can complete bookings.")})
|
||||
if new_status == BookingStatus.CANCELLED and role not in {"manager", "staff", "admin", "customer"}:
|
||||
raise serializers.ValidationError({"status": _("You are not allowed to cancel this booking.")})
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class BookingCreateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
@@ -52,13 +75,39 @@ class BookingCreateSerializer(serializers.ModelSerializer):
|
||||
def create(self, validated_data):
|
||||
request = self.context["request"]
|
||||
service = validated_data["service"]
|
||||
staff = validated_data.get("staff")
|
||||
start_time = validated_data["start_time"]
|
||||
end_time = validated_data["end_time"]
|
||||
|
||||
with transaction.atomic():
|
||||
# Lock the staff row so concurrent booking requests for the same staff
|
||||
# member are serialized. Without this, two requests that both pass the
|
||||
# overlap check in validate() can race and both commit overlapping
|
||||
# bookings. On SQLite (dev/tests) the FOR UPDATE clause is silently
|
||||
# ignored but the transaction still serializes writes; PostgreSQL
|
||||
# (production) gets true row-level locking.
|
||||
StaffProfile.objects.select_for_update().get(pk=staff.pk)
|
||||
|
||||
# Re-run the overlap check inside the lock so the check and the insert
|
||||
# are atomic with respect to other writers.
|
||||
overlap = Booking.objects.filter(
|
||||
staff=staff,
|
||||
status__in=[BookingStatus.PENDING, BookingStatus.CONFIRMED],
|
||||
start_time__lt=end_time,
|
||||
end_time__gt=start_time,
|
||||
).exists()
|
||||
if overlap:
|
||||
raise serializers.ValidationError(
|
||||
{"start_time": _("Booking overlaps an existing appointment")}
|
||||
)
|
||||
|
||||
return Booking.objects.create(
|
||||
salon=service.salon,
|
||||
customer=request.user,
|
||||
service=service,
|
||||
staff=validated_data.get("staff"),
|
||||
start_time=validated_data["start_time"],
|
||||
end_time=validated_data["end_time"],
|
||||
staff=staff,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
notes=validated_data.get("notes", ""),
|
||||
price_amount=service.price_amount,
|
||||
currency=service.currency,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from apps.bookings.models import Booking
|
||||
from apps.bookings.models import Booking, BookingStatus
|
||||
from apps.bookings.serializers import BookingCreateSerializer, BookingSerializer
|
||||
from apps.notifications.models import NotificationEvent
|
||||
from apps.notifications.services import notify_booking_lifecycle, notify_on_status_change
|
||||
|
||||
|
||||
class BookingViewSet(viewsets.ModelViewSet):
|
||||
@@ -21,3 +23,12 @@ class BookingViewSet(viewsets.ModelViewSet):
|
||||
if self.action == "create":
|
||||
return BookingCreateSerializer
|
||||
return BookingSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
booking = serializer.save()
|
||||
notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CREATED)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
previous_status = self.get_object().status
|
||||
booking = serializer.save()
|
||||
notify_on_status_change(booking, previous_status)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.notifications.models import Notification
|
||||
|
||||
|
||||
@admin.register(Notification)
|
||||
class NotificationAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"event",
|
||||
"channel",
|
||||
"status",
|
||||
"booking",
|
||||
"recipient",
|
||||
"phone_number",
|
||||
"provider",
|
||||
"sent_at",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("event", "channel", "status", "provider")
|
||||
search_fields = ("phone_number", "message")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.notifications"
|
||||
@@ -0,0 +1,85 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("bookings", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Notification",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("phone_number", models.CharField(blank=True, max_length=20)),
|
||||
(
|
||||
"channel",
|
||||
models.CharField(
|
||||
choices=[("sms", "SMS"), ("whatsapp", "WhatsApp")],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"event",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("booking_created", "Booking Created"),
|
||||
("booking_confirmed", "Booking Confirmed"),
|
||||
("booking_cancelled", "Booking Cancelled"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("sent", "Sent"),
|
||||
("failed", "Failed"),
|
||||
("skipped", "Skipped"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("provider", models.CharField(blank=True, max_length=50)),
|
||||
("message", models.TextField(blank=True)),
|
||||
("provider_payload", models.JSONField(blank=True, default=dict)),
|
||||
("error_message", models.TextField(blank=True)),
|
||||
("sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"booking",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to="bookings.booking",
|
||||
),
|
||||
),
|
||||
(
|
||||
"recipient",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.deletion.SET_NULL,
|
||||
related_name="notifications",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="notification",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("booking", "recipient", "event", "channel"),
|
||||
name="uniq_notification_booking_event",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from apps.bookings.models import Booking
|
||||
|
||||
|
||||
class NotificationChannel(models.TextChoices):
|
||||
SMS = "sms", "SMS"
|
||||
WHATSAPP = "whatsapp", "WhatsApp"
|
||||
|
||||
|
||||
class NotificationEvent(models.TextChoices):
|
||||
BOOKING_CREATED = "booking_created", "Booking Created"
|
||||
BOOKING_CONFIRMED = "booking_confirmed", "Booking Confirmed"
|
||||
BOOKING_CANCELLED = "booking_cancelled", "Booking Cancelled"
|
||||
|
||||
|
||||
class NotificationStatus(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
SENT = "sent", "Sent"
|
||||
FAILED = "failed", "Failed"
|
||||
SKIPPED = "skipped", "Skipped"
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
booking = models.ForeignKey(
|
||||
Booking,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notifications",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
recipient = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="notifications",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
phone_number = models.CharField(max_length=20, blank=True)
|
||||
channel = models.CharField(max_length=20, choices=NotificationChannel.choices)
|
||||
event = models.CharField(max_length=50, choices=NotificationEvent.choices)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=NotificationStatus.choices,
|
||||
default=NotificationStatus.PENDING,
|
||||
)
|
||||
provider = models.CharField(max_length=50, blank=True)
|
||||
message = models.TextField(blank=True)
|
||||
provider_payload = models.JSONField(default=dict, blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
sent_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["booking", "recipient", "event", "channel"],
|
||||
name="uniq_notification_booking_event",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.event} to {self.phone_number or self.recipient_id}"
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.services.otp import PROVIDERS as OTP_PROVIDERS
|
||||
from apps.bookings.models import Booking, BookingStatus
|
||||
from apps.notifications.models import (
|
||||
Notification,
|
||||
NotificationChannel,
|
||||
NotificationEvent,
|
||||
NotificationStatus,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationSendResult:
|
||||
status: str
|
||||
payload: dict
|
||||
error_message: str = ""
|
||||
|
||||
|
||||
def _get_provider():
|
||||
provider_key = getattr(settings, "NOTIFICATION_PROVIDER", settings.OTP_PROVIDER)
|
||||
provider_cls = OTP_PROVIDERS.get(provider_key)
|
||||
if not provider_cls:
|
||||
raise ValueError(_("Unknown notification provider: %(provider)s") % {"provider": provider_key})
|
||||
return provider_cls(), provider_key
|
||||
|
||||
|
||||
def _format_start_time(booking: Booking) -> str:
|
||||
start_local = timezone.localtime(booking.start_time)
|
||||
return start_local.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def _build_message(booking: Booking, event: str) -> str:
|
||||
start_text = _format_start_time(booking)
|
||||
service_name = booking.service.name
|
||||
salon_name = booking.salon.name
|
||||
|
||||
if event == NotificationEvent.BOOKING_CREATED:
|
||||
return _(
|
||||
"Your booking request is received for %(service)s at %(salon)s on %(start)s."
|
||||
) % {
|
||||
"service": service_name,
|
||||
"salon": salon_name,
|
||||
"start": start_text,
|
||||
}
|
||||
if event == NotificationEvent.BOOKING_CONFIRMED:
|
||||
return _(
|
||||
"Your booking is confirmed for %(service)s at %(salon)s on %(start)s."
|
||||
) % {
|
||||
"service": service_name,
|
||||
"salon": salon_name,
|
||||
"start": start_text,
|
||||
}
|
||||
if event == NotificationEvent.BOOKING_CANCELLED:
|
||||
return _(
|
||||
"Your booking was cancelled for %(service)s at %(salon)s on %(start)s."
|
||||
) % {
|
||||
"service": service_name,
|
||||
"salon": salon_name,
|
||||
"start": start_text,
|
||||
}
|
||||
|
||||
return _("Booking update for %(service)s at %(salon)s on %(start)s.") % {
|
||||
"service": service_name,
|
||||
"salon": salon_name,
|
||||
"start": start_text,
|
||||
}
|
||||
|
||||
|
||||
def _send_message(phone_number: str, channel: str, message: str) -> NotificationSendResult:
|
||||
provider, _ = _get_provider()
|
||||
try:
|
||||
if channel == NotificationChannel.SMS:
|
||||
provider.send_sms(phone_number, message)
|
||||
elif channel == NotificationChannel.WHATSAPP:
|
||||
provider.send_whatsapp(phone_number, message)
|
||||
else:
|
||||
raise ValueError(_("Unsupported notification channel"))
|
||||
except Exception as exc: # pragma: no cover - provider failures are environment specific
|
||||
return NotificationSendResult(status=NotificationStatus.FAILED, payload={}, error_message=str(exc))
|
||||
|
||||
return NotificationSendResult(status=NotificationStatus.SENT, payload={}, error_message="")
|
||||
|
||||
|
||||
def _notification_channel() -> str:
|
||||
return getattr(settings, "NOTIFICATION_DEFAULT_CHANNEL", NotificationChannel.SMS)
|
||||
|
||||
|
||||
def send_booking_notification(booking: Booking, recipient, event: str) -> Notification:
|
||||
channel = _notification_channel()
|
||||
phone_number = getattr(recipient, "phone_number", None) or ""
|
||||
|
||||
# Render the message in the recipient's preferred language.
|
||||
with translation.override(getattr(recipient, "preferred_language", None)):
|
||||
message = _build_message(booking, event)
|
||||
|
||||
with transaction.atomic():
|
||||
notification, created = Notification.objects.get_or_create(
|
||||
booking=booking,
|
||||
recipient=recipient,
|
||||
event=event,
|
||||
channel=channel,
|
||||
defaults={
|
||||
"phone_number": phone_number,
|
||||
"message": message,
|
||||
},
|
||||
)
|
||||
|
||||
if not created and notification.status == NotificationStatus.SENT:
|
||||
return notification
|
||||
|
||||
if not phone_number:
|
||||
# Record the skip for auditability when we cannot deliver.
|
||||
notification.status = NotificationStatus.SKIPPED
|
||||
notification.error_message = "Recipient has no phone number"
|
||||
notification.save(update_fields=["status", "error_message"])
|
||||
return notification
|
||||
|
||||
notification.phone_number = phone_number
|
||||
notification.message = message
|
||||
send_result = _send_message(phone_number, channel, message)
|
||||
notification.status = send_result.status
|
||||
notification.provider = getattr(settings, "NOTIFICATION_PROVIDER", settings.OTP_PROVIDER)
|
||||
notification.provider_payload = send_result.payload
|
||||
notification.error_message = send_result.error_message
|
||||
notification.sent_at = timezone.now() if send_result.status == NotificationStatus.SENT else None
|
||||
notification.save(
|
||||
update_fields=[
|
||||
"phone_number",
|
||||
"message",
|
||||
"status",
|
||||
"provider",
|
||||
"provider_payload",
|
||||
"error_message",
|
||||
"sent_at",
|
||||
]
|
||||
)
|
||||
|
||||
return notification
|
||||
|
||||
|
||||
def notify_booking_lifecycle(booking: Booking, event: str) -> list[Notification]:
|
||||
recipients = [booking.customer]
|
||||
if booking.staff and booking.staff.user:
|
||||
recipients.append(booking.staff.user)
|
||||
|
||||
notifications = []
|
||||
for recipient in recipients:
|
||||
notifications.append(send_booking_notification(booking, recipient, event))
|
||||
|
||||
return notifications
|
||||
|
||||
|
||||
def notify_on_status_change(booking: Booking, previous_status: str) -> list[Notification]:
|
||||
if booking.status == previous_status:
|
||||
return []
|
||||
|
||||
# Only notify for lifecycle transitions we explicitly support today.
|
||||
if booking.status == BookingStatus.CONFIRMED:
|
||||
return notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CONFIRMED)
|
||||
if booking.status == BookingStatus.CANCELLED:
|
||||
return notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CANCELLED)
|
||||
|
||||
return []
|
||||
@@ -0,0 +1,121 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import User, UserRole
|
||||
from apps.bookings.models import Booking, BookingStatus
|
||||
from apps.notifications.models import Notification, NotificationEvent, NotificationStatus
|
||||
from apps.salons.models import Salon, Service, StaffProfile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booking_payload():
|
||||
owner = User.objects.create_user(
|
||||
email="owner@example.com",
|
||||
password="pass",
|
||||
role=UserRole.MANAGER,
|
||||
phone_number="0500000001",
|
||||
)
|
||||
customer = User.objects.create_user(
|
||||
email="customer@example.com",
|
||||
password="pass",
|
||||
phone_number="0500000002",
|
||||
)
|
||||
staff_user = User.objects.create_user(
|
||||
email="staff@example.com",
|
||||
password="pass",
|
||||
role=UserRole.STAFF,
|
||||
phone_number="0500000003",
|
||||
)
|
||||
|
||||
salon = Salon.objects.create(
|
||||
owner=owner,
|
||||
name="Main Salon",
|
||||
description="",
|
||||
address="123 King Rd",
|
||||
city="Riyadh",
|
||||
phone_number="0512345678",
|
||||
)
|
||||
service = Service.objects.create(
|
||||
salon=salon,
|
||||
name="Haircut",
|
||||
description="",
|
||||
duration_minutes=60,
|
||||
price_amount=120,
|
||||
currency="SAR",
|
||||
)
|
||||
staff = StaffProfile.objects.create(user=staff_user, salon=salon)
|
||||
|
||||
start_time = timezone.now() + timedelta(days=1)
|
||||
end_time = start_time + timedelta(minutes=60)
|
||||
|
||||
return {
|
||||
"customer": customer,
|
||||
"staff_user": staff_user,
|
||||
"service": service,
|
||||
"staff": staff,
|
||||
"payload": {
|
||||
"service": service.id,
|
||||
"staff": staff.id,
|
||||
"start_time": start_time.isoformat(),
|
||||
"end_time": end_time.isoformat(),
|
||||
"notes": "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_booking_create_sends_notifications(booking_payload):
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=booking_payload["customer"])
|
||||
|
||||
response = client.post(
|
||||
reverse("booking-list"),
|
||||
booking_payload["payload"],
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
notifications = Notification.objects.filter(event=NotificationEvent.BOOKING_CREATED)
|
||||
assert notifications.count() == 2
|
||||
assert all(notification.status == NotificationStatus.SENT for notification in notifications)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_booking_status_change_sends_notifications_once(booking_payload):
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=booking_payload["customer"])
|
||||
|
||||
response = client.post(
|
||||
reverse("booking-list"),
|
||||
booking_payload["payload"],
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
booking_id = Booking.objects.get(customer=booking_payload["customer"]).id
|
||||
update_payload = {"status": BookingStatus.CONFIRMED}
|
||||
|
||||
client.force_authenticate(user=booking_payload["staff_user"])
|
||||
response_update = client.patch(
|
||||
reverse("booking-detail", args=[booking_id]),
|
||||
update_payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response_update.status_code == 200
|
||||
|
||||
notifications = Notification.objects.filter(event=NotificationEvent.BOOKING_CONFIRMED)
|
||||
assert notifications.count() == 2
|
||||
|
||||
response_repeat = client.patch(
|
||||
reverse("booking-detail", args=[booking_id]),
|
||||
update_payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response_repeat.status_code == 200
|
||||
|
||||
notifications_repeat = Notification.objects.filter(event=NotificationEvent.BOOKING_CONFIRMED)
|
||||
assert notifications_repeat.count() == 2
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Binary file not shown.
@@ -0,0 +1,251 @@
|
||||
# Arabic (Saudi Arabia) translations for Salon booking platform.
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: 1.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-02 00:45+0300\n"
|
||||
"PO-Revision-Date: 2026-03-02 00:45+0300\n"
|
||||
"Last-Translator: Claude\n"
|
||||
"Language-Team: Arabic (Saudi Arabia)\n"
|
||||
"Language: ar_SA\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
|
||||
|
||||
#: apps/accounts/services/otp.py:26
|
||||
msgid "Too many OTP requests. Try again later."
|
||||
msgstr "طلبات رمز التحقق كثيرة جداً. حاول مرة أخرى لاحقاً."
|
||||
|
||||
#: apps/accounts/services/otp.py:32
|
||||
msgid "Please wait before requesting another code."
|
||||
msgstr "يرجى الانتظار قبل طلب رمز آخر."
|
||||
|
||||
#: apps/accounts/services/otp.py:71
|
||||
msgid "Twilio credentials are not configured"
|
||||
msgstr "لم يتم تكوين بيانات اعتماد Twilio"
|
||||
|
||||
#: apps/accounts/services/otp.py:85
|
||||
msgid "Twilio WhatsApp sender is not configured"
|
||||
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Twilio"
|
||||
|
||||
#: apps/accounts/services/otp.py:100
|
||||
msgid "Unifonic credentials are not configured"
|
||||
msgstr "لم يتم تكوين بيانات اعتماد Unifonic"
|
||||
|
||||
#: apps/accounts/services/otp.py:104
|
||||
msgid "Unifonic SMS adapter not implemented yet"
|
||||
msgstr "محول SMS لـ Unifonic لم يتم تنفيذه بعد"
|
||||
|
||||
#: apps/accounts/services/otp.py:109
|
||||
msgid "Unifonic WhatsApp sender is not configured"
|
||||
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Unifonic"
|
||||
|
||||
#: apps/accounts/services/otp.py:110
|
||||
msgid "Unifonic WhatsApp adapter not implemented yet"
|
||||
msgstr "محول WhatsApp لـ Unifonic لم يتم تنفيذه بعد"
|
||||
|
||||
#: apps/accounts/services/otp.py:126
|
||||
msgid "Authentica API key is not configured"
|
||||
msgstr "لم يتم تكوين مفتاح API لـ Authentica"
|
||||
|
||||
#: apps/accounts/services/otp.py:149 apps/accounts/services/otp.py:162
|
||||
msgid "Authentica request failed"
|
||||
msgstr "فشل طلب Authentica"
|
||||
|
||||
#: apps/accounts/services/otp.py:159
|
||||
#, python-format
|
||||
msgid "Authentica request failed: %(status)s %(body)s"
|
||||
msgstr "فشل طلب Authentica: %(status)s %(body)s"
|
||||
|
||||
#: apps/accounts/services/otp.py:168 apps/accounts/services/otp.py:276
|
||||
msgid "Unsupported OTP channel"
|
||||
msgstr "قناة رمز التحقق غير مدعومة"
|
||||
|
||||
#: apps/accounts/services/otp.py:179
|
||||
#, python-format
|
||||
msgid "Authentica verify failed: %(response)s"
|
||||
msgstr "فشل التحقق بـ Authentica: %(response)s"
|
||||
|
||||
#: apps/accounts/services/otp.py:184
|
||||
msgid "Authentica sender name is not configured"
|
||||
msgstr "لم يتم تكوين اسم مُرسِل Authentica"
|
||||
|
||||
#: apps/accounts/services/otp.py:195
|
||||
msgid "Authentica WhatsApp messaging is not supported"
|
||||
msgstr "مراسلة WhatsApp غير مدعومة لـ Authentica"
|
||||
|
||||
#: apps/accounts/services/otp.py:209
|
||||
#, python-format
|
||||
msgid "Unknown OTP provider: %(provider)s"
|
||||
msgstr "مزود رمز التحقق غير معروف: %(provider)s"
|
||||
|
||||
#: apps/accounts/services/otp.py:256
|
||||
#, python-format
|
||||
msgid "Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
||||
msgstr "رمز التحقق الخاص بك هو %(code)s. ينتهي في %(minutes)s دقيقة."
|
||||
|
||||
#: apps/accounts/services/phone.py:8
|
||||
msgid "Phone number is required"
|
||||
msgstr "رقم الهاتف مطلوب"
|
||||
|
||||
#: apps/accounts/services/phone.py:17
|
||||
msgid "Invalid phone number format"
|
||||
msgstr "تنسيق رقم الهاتف غير صالح"
|
||||
|
||||
#: apps/accounts/services/phone.py:28
|
||||
msgid "Phone number must be in E.164 format or a valid Saudi mobile"
|
||||
msgstr "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
|
||||
|
||||
#: apps/accounts/views.py:75 apps/accounts/views.py:138
|
||||
msgid "Invalid or expired code"
|
||||
msgstr "الرمز غير صالح أو منتهي الصلاحية"
|
||||
|
||||
#: apps/accounts/views.py:82
|
||||
msgid "Phone verified"
|
||||
msgstr "تم التحقق من رقم الهاتف"
|
||||
|
||||
#: apps/accounts/views.py:99
|
||||
msgid "Email already in use."
|
||||
msgstr "البريد الإلكتروني مستخدم بالفعل."
|
||||
|
||||
#: apps/accounts/views.py:142
|
||||
msgid "User not found"
|
||||
msgstr "المستخدم غير موجود"
|
||||
|
||||
#: apps/accounts/views.py:164
|
||||
msgid "Social login not configured yet. Add OAuth provider config."
|
||||
msgstr "لم يتم تكوين تسجيل الدخول الاجتماعي بعد. أضف إعداد مزود OAuth."
|
||||
|
||||
#: apps/bookings/serializers.py:54
|
||||
msgid "Only staff or managers can confirm bookings."
|
||||
msgstr "يمكن للموظفين والمدراء فقط تأكيد الحجوزات."
|
||||
|
||||
#: apps/bookings/serializers.py:56
|
||||
msgid "Only staff or managers can complete bookings."
|
||||
msgstr "يمكن للموظفين والمدراء فقط إكمال الحجوزات."
|
||||
|
||||
#: apps/bookings/serializers.py:58
|
||||
msgid "You are not allowed to cancel this booking."
|
||||
msgstr "لا يُسمح لك بإلغاء هذا الحجز."
|
||||
|
||||
#: apps/bookings/serializers.py:101 apps/bookings/services.py:49
|
||||
msgid "Booking overlaps an existing appointment"
|
||||
msgstr "يتداخل الحجز مع موعد قائم"
|
||||
|
||||
#: apps/bookings/services.py:13
|
||||
msgid "Staff is required for booking"
|
||||
msgstr "يجب تحديد موظف للحجز"
|
||||
|
||||
#: apps/bookings/services.py:16
|
||||
msgid "Selected staff does not belong to this salon"
|
||||
msgstr "الموظف المختار لا ينتمي إلى هذا الصالون"
|
||||
|
||||
#: apps/bookings/services.py:19
|
||||
msgid "End time must be after start time"
|
||||
msgstr "يجب أن يكون وقت الانتهاء بعد وقت البدء"
|
||||
|
||||
#: apps/bookings/services.py:23
|
||||
msgid "End time must match service duration"
|
||||
msgstr "يجب أن يتطابق وقت الانتهاء مع مدة الخدمة"
|
||||
|
||||
#: apps/bookings/services.py:40
|
||||
msgid "Booking is outside staff availability"
|
||||
msgstr "الحجز خارج أوقات توفر الموظف"
|
||||
|
||||
#: apps/notifications/services.py:31
|
||||
#, python-format
|
||||
msgid "Unknown notification provider: %(provider)s"
|
||||
msgstr "مزود الإشعارات غير معروف: %(provider)s"
|
||||
|
||||
#: apps/notifications/services.py:47
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Your booking request is received for %(service)s at %(salon)s on %(start)s."
|
||||
msgstr "تم استلام طلب حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||
|
||||
#: apps/notifications/services.py:55
|
||||
#, python-format
|
||||
msgid "Your booking is confirmed for %(service)s at %(salon)s on %(start)s."
|
||||
msgstr "تم تأكيد حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||
|
||||
#: apps/notifications/services.py:63
|
||||
#, python-format
|
||||
msgid "Your booking was cancelled for %(service)s at %(salon)s on %(start)s."
|
||||
msgstr "تم إلغاء حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||
|
||||
#: apps/notifications/services.py:70
|
||||
#, python-format
|
||||
msgid "Booking update for %(service)s at %(salon)s on %(start)s."
|
||||
msgstr "تحديث الحجز لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||
|
||||
#: apps/notifications/services.py:85
|
||||
msgid "Unsupported notification channel"
|
||||
msgstr "قناة الإشعارات غير مدعومة"
|
||||
|
||||
#: apps/payments/serializers.py:49
|
||||
msgid "Booking not found"
|
||||
msgstr "الحجز غير موجود"
|
||||
|
||||
#: apps/payments/serializers.py:56 apps/payments/services/payments.py:79
|
||||
msgid "Provider integration not implemented"
|
||||
msgstr "تكامل المزود غير مُنفَّذ"
|
||||
|
||||
#: apps/payments/serializers.py:58
|
||||
msgid "Payment source is required"
|
||||
msgstr "مصدر الدفع مطلوب"
|
||||
|
||||
#: apps/payments/serializers.py:61
|
||||
msgid "Payment source type is required"
|
||||
msgstr "نوع مصدر الدفع مطلوب"
|
||||
|
||||
#: apps/payments/serializers.py:64
|
||||
msgid "Card data must not be sent to the backend; use frontend tokenization"
|
||||
msgstr "لا يجب إرسال بيانات البطاقة إلى الخادم؛ استخدم التحويل إلى رمز في الواجهة الأمامية"
|
||||
|
||||
#: apps/payments/serializers.py:67
|
||||
msgid "Callback URL is required for token payments"
|
||||
msgstr "رابط الاستجابة مطلوب لمدفوعات الرمز"
|
||||
|
||||
#: apps/payments/services/payments.py:84
|
||||
msgid "Idempotency key already used"
|
||||
msgstr "مفتاح الإيدمبوتنسي مستخدم بالفعل"
|
||||
|
||||
#: apps/payments/services/payments.py:89
|
||||
msgid "Unsupported payment source type"
|
||||
msgstr "نوع مصدر الدفع غير مدعوم"
|
||||
|
||||
#: apps/payments/services/payments.py:130
|
||||
#: apps/payments/services/payments.py:141
|
||||
msgid "Payment provider error"
|
||||
msgstr "خطأ في مزود الدفع"
|
||||
|
||||
#: apps/payments/views.py:50
|
||||
msgid "Not allowed"
|
||||
msgstr "غير مسموح"
|
||||
|
||||
#: apps/payments/views.py:70
|
||||
msgid "Webhook secret not configured"
|
||||
msgstr "لم يتم تكوين رمز الـ webhook"
|
||||
|
||||
#: apps/payments/views.py:73
|
||||
msgid "Invalid webhook signature"
|
||||
msgstr "توقيع الـ webhook غير صالح"
|
||||
|
||||
#: apps/payments/views.py:79
|
||||
msgid "Missing payment reference"
|
||||
msgstr "مرجع الدفع مفقود"
|
||||
|
||||
#: apps/payments/views.py:84
|
||||
msgid "Payment not found"
|
||||
msgstr "لم يتم العثور على الدفعة"
|
||||
|
||||
#: apps/payments/views.py:88
|
||||
msgid "Event ignored"
|
||||
msgstr "تم تجاهل الحدث"
|
||||
|
||||
#: apps/payments/views.py:89
|
||||
msgid "Webhook processed"
|
||||
msgstr "تمت معالجة الـ webhook"
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = salon_api.settings
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
addopts = -q
|
||||
addopts = -q -m "not external"
|
||||
markers =
|
||||
external: hits real third-party services (requires explicit env to run)
|
||||
|
||||
@@ -5,3 +5,4 @@ django-cors-headers>=4.3
|
||||
psycopg[binary]>=3.1
|
||||
python-dotenv>=1.0
|
||||
requests>=2.31
|
||||
twilio>=9.0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlparse
|
||||
@@ -26,6 +27,7 @@ INSTALLED_APPS = [
|
||||
"apps.salons",
|
||||
"apps.bookings",
|
||||
"apps.payments",
|
||||
"apps.notifications",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -77,11 +79,14 @@ def parse_database_url(database_url: str):
|
||||
}
|
||||
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
if DATABASE_URL:
|
||||
parsed_db = parse_database_url(DATABASE_URL)
|
||||
running_tests = "PYTEST_CURRENT_TEST" in os.environ or any("pytest" in arg for arg in sys.argv)
|
||||
test_database_url = os.getenv("TEST_DATABASE_URL")
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
|
||||
if running_tests:
|
||||
parsed_db = parse_database_url(test_database_url) if test_database_url else None
|
||||
else:
|
||||
parsed_db = None
|
||||
parsed_db = parse_database_url(database_url) if database_url else None
|
||||
|
||||
DATABASES = {
|
||||
"default": parsed_db
|
||||
@@ -135,8 +140,12 @@ CORS_ALLOWED_ORIGINS = [
|
||||
]
|
||||
|
||||
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
|
||||
if running_tests:
|
||||
OTP_PROVIDER = os.getenv("TEST_OTP_PROVIDER", "console")
|
||||
OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
|
||||
OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
|
||||
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
|
||||
OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60"))
|
||||
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR")
|
||||
NOTIFICATION_PROVIDER = os.getenv("NOTIFICATION_PROVIDER", OTP_PROVIDER)
|
||||
NOTIFICATION_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms")
|
||||
|
||||
@@ -4,7 +4,7 @@ This document describes the requirements for an execution plan ("ExecPlan"), a d
|
||||
|
||||
## Active ExecPlans
|
||||
|
||||
The current execution plan is `docs/execplans/payments-moyasar.md`. It focuses on Moyasar payments integration with webhooks and idempotency as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below.
|
||||
The current execution plan is `docs/execplans/booking-notifications.md`. It focuses on booking lifecycle notifications (confirmation/cancellation) as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below.
|
||||
|
||||
## How to use ExecPlans and PLANS.md
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Documentation Index
|
||||
|
||||
This directory is the source of truth for product, engineering, and ops documentation. Keep it current as features change.
|
||||
|
||||
## Start Here
|
||||
|
||||
- Project overview and setup: `README.md` (repo root)
|
||||
- Architecture overview: `docs/architecture.md`
|
||||
- Active ExecPlan: `docs/execplans/booking-notifications.md`
|
||||
- Known risks and gaps: `docs/risks.md`
|
||||
|
||||
## Documentation Standards
|
||||
|
||||
See `docs/documentation.md` for documentation goals, update triggers, and templates.
|
||||
|
||||
## Docs Map
|
||||
|
||||
- `docs/architecture.md`: System architecture, boundaries, and MVP async/observability decision.
|
||||
- `docs/adr/`: Architecture Decision Records (ADRs). New cross-cutting decisions must land here.
|
||||
- `docs/execplans/`: Execution plans for significant features or refactors.
|
||||
- `docs/runbooks/`: Operational runbooks and production checklists.
|
||||
- `docs/risks.md`: Tracked risks and gaps.
|
||||
- `docs/templates/`: Reusable templates (ADR, runbook).
|
||||
|
||||
## Update Triggers (Quick Reference)
|
||||
|
||||
- New external dependency, provider, or major flow: add an ADR in `docs/adr/`.
|
||||
- Change to booking/payment/auth logic: update `docs/architecture.md` and relevant runbook(s).
|
||||
- New operational procedure: add a runbook in `docs/runbooks/`.
|
||||
- Close or add a significant risk: update `docs/risks.md`.
|
||||
|
||||
## Ownership And Review
|
||||
|
||||
- Authors own freshness: if you touch an area, update the docs in the same PR.
|
||||
- New production flows require at least one runbook.
|
||||
- Avoid duplicating instructions; link to the single source of truth.
|
||||
@@ -0,0 +1,33 @@
|
||||
# ADR 0001: Synchronous External Calls For MVP
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The MVP relies on OTP delivery, booking notifications, and payment gateway calls. Introducing a task queue (Celery/RQ) would add infrastructure (Redis, workers, retries) and operational complexity that is not required for the early launch.
|
||||
|
||||
## Decision
|
||||
|
||||
For the MVP, OTP sends, booking notifications, and payment gateway calls run synchronously in the request/response path with strict timeouts. A task queue will be revisited when traffic grows or operational needs change.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Faster initial delivery with fewer moving parts.
|
||||
- Increased latency risk on endpoints that call external providers.
|
||||
- Payment and OTP failures are surfaced to clients immediately (correct behaviour — clients need to know).
|
||||
- Notification failures are absorbed: `notifications/services.py` catches provider errors, stores them as `FAILED` status, and never surfaces them to the client. A failed booking SMS does not cause the booking request to fail. This means notification failures require active monitoring rather than appearing in client-facing error rates.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Celery + Redis for all external calls: rejected for MVP due to infra overhead.
|
||||
- Hybrid async for notifications only: not wrong in principle, deferred for operational simplicity. The three call types have genuinely different semantics:
|
||||
- **Payment creation**: synchronous by design — the client needs the Moyasar redirect URL before the response returns.
|
||||
- **OTP sends**: synchronous by design — users expect immediate confirmation that the code was sent.
|
||||
- **Booking notifications**: fire-and-forget by nature — the booking is already committed and the client does not wait for delivery confirmation.
|
||||
When notification latency becomes a problem (e.g. under load or with slow SMS providers), only notifications need to move off the request path. Payments and OTP sends should remain synchronous regardless.
|
||||
|
||||
## Related
|
||||
|
||||
- `docs/architecture.md`
|
||||
@@ -0,0 +1,30 @@
|
||||
# ADR 0002: Moyasar As The Payment Gateway
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The platform needs a payment gateway that supports Saudi Arabia, SAR currency defaults, and local payment methods (e.g. STC Pay, Apple Pay, Samsung Pay). The backend already implements a `MoyasarGateway` integration and models `payments.Payment` with a `moyasar` provider option.
|
||||
|
||||
## Decision
|
||||
|
||||
Use Moyasar as the payment gateway for the MVP. Payment creation, capture, refund, and webhook reconciliation are implemented through `apps.payments.services.gateway.MoyasarGateway`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Supports KSA-focused payment methods and SAR by default.
|
||||
- Operational dependency on Moyasar uptime and API stability.
|
||||
- Payment flows and webhooks are tied to the Moyasar API surface until a gateway abstraction is expanded.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Other regional gateways: deferred until the MVP is validated.
|
||||
- Stripe or similar global providers: not selected for MVP due to KSA-specific coverage priorities.
|
||||
|
||||
## Related
|
||||
|
||||
- `backend/apps/payments/services/gateway.py`
|
||||
- `docs/runbooks/payments_sanity_check.md`
|
||||
- `docs/architecture.md`
|
||||
@@ -0,0 +1,28 @@
|
||||
# ADR 0003: Authentica As Primary OTP Provider
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The platform requires phone-first authentication with OTP delivery for KSA. The codebase includes provider adapters (`console`, `authentica`) and Authentica is implemented for provider-managed OTP delivery (send/verify) and direct SMS messaging. A console provider exists for local development.
|
||||
|
||||
## Decision
|
||||
|
||||
Use Authentica as the primary OTP provider for the MVP, with `OTP_PROVIDER=authentica` in production environments. Keep `console` for local development and tests.
|
||||
|
||||
## Consequences
|
||||
|
||||
- OTP verification relies on Authentica APIs and credentials in production.
|
||||
- Local development remains simple with the console provider.
|
||||
- Adding a second production provider will require completing adapters and updating operational runbooks.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
|
||||
## Related
|
||||
|
||||
- `backend/apps/accounts/services/otp.py`
|
||||
- `backend/salon_api/settings.py`
|
||||
- `docs/architecture.md`
|
||||
@@ -0,0 +1,5 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
ADRs capture cross-cutting or hard-to-reverse decisions. Add a new ADR when changing providers, async strategy, data model boundaries, or other architectural choices.
|
||||
|
||||
Use the template in `docs/templates/adr.md` and increment the numeric prefix (`0002`, `0003`, ...).
|
||||
@@ -0,0 +1,222 @@
|
||||
# Salon MVP Roadmap And Architecture Review
|
||||
|
||||
## Purpose / Big Picture
|
||||
|
||||
This plan reviews the current Salon codebase (Django backend, React/Vite frontend), highlights architectural and design risks, and lays out a pragmatic roadmap to reach an MVP that aligns with **Phase 1: Core MVP Reliability** in `AGENTS.md`: phone-first auth with OTP, robust booking integrity, Moyasar payments, booking lifecycle notifications, localization foundations, and tests for critical flows.
|
||||
The roadmap assumes a KSA-focused first launch (phone auth and Riyadh timezone defaults) with a clean path to expand later.
|
||||
|
||||
## Current State Summary
|
||||
|
||||
### Backend (Django, DRF)
|
||||
|
||||
- **Project**: `salon_api` in `[backend/salon_api](backend/salon_api)`
|
||||
- `settings.py` configures `AUTH_USER_MODEL = "accounts.User"`, DRF + SimpleJWT, KSA locale defaults, and custom settings for OTP, notifications, and payments.
|
||||
- URLs route to app-level APIs at `/api/auth/`, `/api/salons/`, `/api/bookings/`, `/api/payments/`.
|
||||
- **Auth & Accounts** (`[backend/apps/accounts](backend/apps/accounts)`)
|
||||
- Custom `User` model with phone-first capabilities (`phone_number`, `is_phone_verified`, `preferred_language`, `role`).
|
||||
- Phone normalization services tuned for KSA numbers (`[backend/apps/accounts/services/phone.py](backend/apps/accounts/services/phone.py)`).
|
||||
- OTP domain + service layer with rate limits, cooldowns, expiry, and hashed codes (`[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`).
|
||||
- Phone-first auth endpoints that issue JWT tokens on successful OTP verification, plus basic email/password registration.
|
||||
- Social login endpoint is a placeholder that always returns 501.
|
||||
- **Salons & Catalog** (`[backend/apps/salons](backend/apps/salons)`)
|
||||
- Models for `Salon`, `Service`, `StaffProfile`, `StaffAvailability`, `Review`, `SalonPhoto`.
|
||||
- Read-only APIs for salon search, services, staff, and reviews.
|
||||
- **Bookings** (`[backend/apps/bookings](backend/apps/bookings)`)
|
||||
- `Booking` model ties together salon, service, staff, customer, time window, price, and status.
|
||||
- `validate_booking_request` in `[backend/apps/bookings/services.py](backend/apps/bookings/services.py)` enforces staff membership, duration matching, availability windows, and overlap prevention for pending/confirmed bookings.
|
||||
- `BookingViewSet` in `[backend/apps/bookings/views.py](backend/apps/bookings/views.py)` applies role-based access and hooks into notifications on create and relevant status changes.
|
||||
- **Payments (Moyasar)** (`[backend/apps/payments](backend/apps/payments)`)
|
||||
- `Payment` model tracks provider, status (fine-grained transitions), amount, idempotency key, external ID, payload, and timestamps.
|
||||
- `MoyasarGateway` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` can create payments via HTTP but has `capture_payment`/`refund_payment` as TODOs.
|
||||
- `create_payment_for_booking` in `[backend/apps/payments/services/payments.py](backend/apps/payments/services/payments.py)` enforces provider choice (Moyasar only), idempotency, and maps webhook events into internal statuses.
|
||||
- Webhook view in `[backend/apps/payments/views.py](backend/apps/payments/views.py)` authenticates via shared secret and applies provider events idempotently.
|
||||
- **Notifications** (`[backend/apps/notifications](backend/apps/notifications)`)
|
||||
- `Notification` model records booking-related notifications and enforces uniqueness per booking/recipient/event/channel.
|
||||
- Services in `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)` reuse OTP providers to send SMS/WhatsApp messages for booking created/confirmed/cancelled events, with localization via `preferred_language`.
|
||||
- Booking views call `notify_booking_lifecycle` / `notify_on_status_change` to trigger notifications for customers and staff.
|
||||
- **Testing**
|
||||
- Solid tests around:
|
||||
- Phone normalization, OTP rate limiting, and phone auth flows (`[backend/apps/accounts/tests](backend/apps/accounts/tests)`).
|
||||
- Booking integrity and overlap rules (`[backend/apps/bookings/tests](backend/apps/bookings/tests)`).
|
||||
- Payment idempotency and Moyasar webhook handling (`[backend/apps/payments/tests](backend/apps/payments/tests)`).
|
||||
- Booking notifications on create/status change (`[backend/apps/notifications/tests](backend/apps/notifications/tests)`).
|
||||
- `docs/risks.md` explicitly tracks several known gaps around auth, booking, payments, data/UX, and ops.
|
||||
|
||||
### Frontend (React, Vite)
|
||||
|
||||
- **Structure**
|
||||
- Vite React app at `[frontend](frontend)` with entry in `[frontend/src/main.jsx](frontend/src/main.jsx)` and single top-level component in `[frontend/src/App.jsx](frontend/src/App.jsx)`.
|
||||
- No `react-router` or multi-page routing; the entire experience is one composed screen.
|
||||
- **Current Features**
|
||||
- **Salon search**
|
||||
- Text search field calling `/salons/?q=<query>` via a small API client in `[frontend/src/api/client.js](frontend/src/api/client.js)`.
|
||||
- Renders responsive list of salons with rating, city, and phone.
|
||||
- **Localization/i18n**
|
||||
- `react-i18next` setup in `[frontend/src/i18n/index.js](frontend/src/i18n/index.js)` with `en` and `ar-sa` translations.
|
||||
- Locale preference stored in `localStorage`; applies `lang` and `dir` on the document.
|
||||
- **Payments beta**
|
||||
- A form in `App.jsx` that sends payment creation requests to `/api/payments/` using the Moyasar-style payload, with configurable `booking_id`, source type, token, and callback URL.
|
||||
- Optionally includes a Bearer token from a manually-entered access token field.
|
||||
- On success, can redirect to `redirect_url` and shows the raw JSON response.
|
||||
- **State & Tests**
|
||||
- All state is local to `App.jsx` via `useState`/`useEffect`; there is no centralized state management or domain hooks yet.
|
||||
- A single test file `[frontend/src/App.test.jsx](frontend/src/App.test.jsx)` covers hero copy and locale/RTL behavior, but not search or payments.
|
||||
|
||||
## Glaring Design And Architectural Issues
|
||||
|
||||
### Backend Risks
|
||||
|
||||
- **Incomplete provider implementations for production-critical flows**
|
||||
- Authentica is the only OTP/notification provider; ensure it is fully configured and exercised in production-like environments.
|
||||
- `MoyasarGateway` lacks `capture_payment` and `refund_payment` implementations, limiting payment lifecycle coverage.
|
||||
- **Risk**: Code reads “production ready” at the API level, but the underlying integrations are not, which could cause outages if deployed naively.
|
||||
- **Tight coupling between OTP and notifications**
|
||||
- Notification services import the OTP provider mapping and default `NOTIFICATION_PROVIDER` to `OTP_PROVIDER`, binding booking notifications to auth configuration.
|
||||
- **Risk**: Changing OTP providers or adding a second channel for marketing/ops notifications will be harder and could have unintended side effects.
|
||||
- **Synchronous IO-heavy work in request/response path**
|
||||
- OTP sends, booking notifications, and payment gateway calls all occur synchronously inside view methods (`perform_create`, `create`, etc.).
|
||||
- **Risk**: Slow or flaky providers will degrade API latency and user experience; retries and backoff are hard to implement without background jobs.
|
||||
- **Cross-app domain coupling without a clear orchestration layer**
|
||||
- `apps.bookings` depends on salons and notifications; notifications depend on accounts (OTP providers) and bookings; payments depend on bookings.
|
||||
- **Risk**: As you add more lifecycle rules (e.g., auto-confirm booking on payment, send reminders, handle refunds), the spaghetti of cross-imports will grow unless you introduce clearer service boundaries.
|
||||
- **Auth model vs login patterns**
|
||||
- `User.USERNAME_FIELD` is email, while phone-based JWT issuance happens via custom endpoints.
|
||||
- **Risk**: This split can confuse clients and admin tooling and may complicate future flows like social login or SSO unless you standardize on an identifier strategy.
|
||||
- **Docs drift around ExecPlans**
|
||||
- `AGENTS.md` references `docs/execplans/payments-moyasar.md` as the active plan, while `PLANS.md` names `docs/execplans/booking-notifications.md`.
|
||||
- **Risk**: Contributors may follow different “active” plans, causing architectural inconsistency.
|
||||
|
||||
### Frontend Risks
|
||||
|
||||
- **Monolithic `App` component with no routing**
|
||||
- `App.jsx` mixes hero/search, salon listing, payments, and locale controls.
|
||||
- There is no `react-router` or notion of separate flows (auth, booking, profile, payments).
|
||||
- **Risk**: Extending to full MVP flows (auth, booking, history, management) will quickly become unmanageable without a routing/page system and domain separation.
|
||||
- **Domain logic embedded in UI components**
|
||||
- API payload construction, validation rules (e.g. for source types), and error handling are implemented directly in `App.jsx` rather than reusable hooks or service modules.
|
||||
- **Risk**: Code reuse, testing, and evolution (e.g., adding booking pages or admin consoles) will be painful.
|
||||
- **Minimal test coverage for critical flows**
|
||||
- Only i18n and hero copy are tested; search behavior, API integration, and payments are untested.
|
||||
- **Risk**: Regressions in search, booking, and payments UX will slip through as MVP grows.
|
||||
- **Styling & layout fragility**
|
||||
- `frontend/src/styles.css` uses `::root` instead of `:root`, which likely breaks intended global CSS variables or base styles.
|
||||
- Global CSS is tightly bound to the monolithic `App` layout.
|
||||
- **Risk**: Visual regressions and layout churn when introducing additional pages or components.
|
||||
- **Ad hoc auth token handling**
|
||||
- The “access token” is a free-form text field that gets persisted as `auth_token` in `localStorage` and injected into payment requests.
|
||||
- **Risk**: This is a placeholder pattern that does not scale to full auth (refresh tokens, logout, token rotation) and will need to be replaced.
|
||||
|
||||
### Cross-Cutting Risks
|
||||
|
||||
- **Lack of async/background processing**
|
||||
- No Celery/RQ or similar job queue; all side effects are synchronous.
|
||||
- **Risk**: Scaling SMS/WhatsApp notifications, email, and payment webhook fan-out will be difficult.
|
||||
- **Observability and admin tooling gaps**
|
||||
- Errors for payments and notifications are recorded in model metadata but not clearly surfaced in logs, dashboards, or admin views.
|
||||
- **Risk**: Operational debugging during MVP rollout will be slower and more error-prone.
|
||||
- **Internationalization strategy vs future markets**
|
||||
- Phone normalization and defaults are tailored to KSA, which is correct for MVP, but `docs/risks.md` already notes the need to broaden later.
|
||||
- **Risk**: Without clear boundaries between KSA-specific logic and generic logic, future expansion may require invasive changes.
|
||||
|
||||
## MVP Roadmap (Aligned To Phase 1)
|
||||
|
||||
This roadmap assumes “MVP” is equivalent to **Phase 1: Core MVP Reliability** in `AGENTS.md`, with a thin but robust frontend on top.
|
||||
|
||||
### Phase 0 – Architecture & Production Readiness Hardening
|
||||
|
||||
- **Finalize critical provider implementations**
|
||||
- Ensure Authentica OTP/SMS is fully configured and validated end-to-end behind the provider abstraction in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`, and wired into `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)`.
|
||||
- Implement or deliberately fence off `capture_payment` and `refund_payment` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` so that the MVP either fully supports or explicitly does not support partial captures/refunds.
|
||||
- **Clarify and document boundaries**
|
||||
- Add a short architecture section in `README`/docs describing how `accounts`, `salons`, `bookings`, `payments`, and `notifications` interact, and what each service is responsible for.
|
||||
- Resolve the ExecPlan drift by making `AGENTS.md` and `PLANS.md` agree on the current active plan.
|
||||
- **Introduce minimal async infrastructure (optional but recommended)**
|
||||
- Decide whether MVP will ship with a task queue (e.g., Celery with Redis) or keep everything synchronous for the initial launch.
|
||||
- If yes, introduce a thin task layer for OTP sends and booking notifications while preserving current APIs; if not, at least add clear timeouts/logging to external calls.
|
||||
- **Frontend scaffolding for growth**
|
||||
- Introduce `react-router` and refactor `App.jsx` into route-based pages (e.g., `HomePage`, `BookPage`, `PaymentPage`, `ProfilePage`), with shared layout and navigation.
|
||||
- Extract salon search, payment form, and locale controls into dedicated components and hooks.
|
||||
|
||||
### Phase 1 – Core MVP Features (Backend + Frontend)
|
||||
|
||||
- **Phone-first auth UX**
|
||||
- Backend: reuse existing phone auth endpoints; ensure error messages and rate-limit responses are predictable and localized.
|
||||
- Frontend:
|
||||
- Build OTP-based login/registration screens that drive `/api/auth/phone/request/` and `/api/auth/phone/verify/`.
|
||||
- Introduce an auth context (or similar) to store access/refresh tokens, current user profile, and handle logout.
|
||||
- Defer social login beyond MVP, but keep API surface ready for it.
|
||||
- **Booking search and creation**
|
||||
- Backend is largely ready (booking validation and role-based access); review booking serializers in `[backend/apps/bookings/serializers.py](backend/apps/bookings/serializers.py)` to ensure they expose all fields needed for frontend booking forms.
|
||||
- Frontend:
|
||||
- Build a **booking flow**: pick a salon → choose service → select staff (optional) → select date/time slot (based on availability endpoints) → confirm booking.
|
||||
- Add a “My bookings” page showing upcoming and past bookings, tied into the existing `/api/bookings/` endpoints.
|
||||
- **Payments via Moyasar**
|
||||
- Backend: confirm `create_payment_for_booking` contracts (inputs/outputs) are stable and documented.
|
||||
- Frontend:
|
||||
- Evolve the payments beta UI into a **post-booking payment step** that starts from a selected booking and guides the user into Moyasar’s hosted flow, then shows a status page.
|
||||
- Handle callback/return from Moyasar (even if via manual redirect URL in MVP) and surface payment success/failure to the user.
|
||||
- **Booking lifecycle notifications**
|
||||
- Backend already sends notifications on booking create and status changes; align messaging templates with product UX and ensure localization strings exist.
|
||||
- Frontend: surface notification results implicitly via booking status changes and explicit messages on the booking details page.
|
||||
- **Localization foundations**
|
||||
- Backend: ensure `UserLocaleMiddleware` and translation strings cover all user-visible errors in auth, bookings, payments, and notifications.
|
||||
- Frontend: expand `en/ar-sa` translations to cover auth, booking, and payment flows; verify RTL layouts on the new screens.
|
||||
- **Tests for critical flows**
|
||||
- Backend: extend tests where needed to cover new booking/payment edge cases (e.g., tying booking status to payment status if/when introduced).
|
||||
- Frontend: add Vitest tests for:
|
||||
- Phone auth screen flows (request/verify success + errors).
|
||||
- Booking flow (form validation, happy path, displaying server-side errors).
|
||||
- Payment initiation from an existing booking.
|
||||
|
||||
### Phase 2 – Manager Ops Lite (Post-MVP, partially covered now)
|
||||
|
||||
- **Salon and staff management UI**
|
||||
- Use existing salon and staff models to build basic management pages for salon owners/managers (create/update services, staff, availability).
|
||||
- **Calendar views and rescheduling**
|
||||
- Provide calendar views for staff/managers to view daily/weekly bookings and reschedule or cancel within defined rules.
|
||||
- **Reviews and ratings**
|
||||
- Implement review submission and rating recalculation on the backend, with corresponding frontend components.
|
||||
- **Reporting basics**
|
||||
- Lightweight reports for managers (upcoming bookings, simple revenue summaries based on payment status) using existing payments data.
|
||||
|
||||
### Phase 3 – Scale & Compliance (Later)
|
||||
|
||||
- **Audit logging** for admin actions and booking/payment state changes.
|
||||
- **PDPL/GDPR retention policies** and data export tooling.
|
||||
- **Observability**: structured logging, metrics, and basic dashboards for auth failures, OTP send failures, payment errors, and notification outcomes.
|
||||
|
||||
## Architecture Overview Diagram
|
||||
|
||||
A simplified view of the target MVP data flow:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
user["User (web/mobile)"] --> frontend["ReactFrontend"]
|
||||
frontend --> api["DjangoAPI"]
|
||||
|
||||
api --> accounts["AccountsApp"]
|
||||
api --> salons["SalonsApp"]
|
||||
api --> bookings["BookingsApp"]
|
||||
api --> payments["PaymentsApp"]
|
||||
api --> notifications["NotificationsApp"]
|
||||
|
||||
accounts --> otpProviders["OtpProviders"]
|
||||
notifications --> otpProviders
|
||||
payments --> moyasar["MoyasarGateway"]
|
||||
|
||||
bookings --> notifications
|
||||
bookings --> payments
|
||||
salons --> bookings
|
||||
```
|
||||
|
||||
|
||||
|
||||
This diagram clarifies current coupling and highlights where future refactors (e.g., a dedicated messaging service or payment orchestrator) could sit.
|
||||
|
||||
## Validation And Acceptance For This Plan
|
||||
|
||||
- The roadmap is accepted when:
|
||||
- It clearly maps current backend and frontend capabilities to the Phase 1 MVP goals in `AGENTS.md`.
|
||||
- It identifies the highest-risk design/architecture issues that could impede MVP reliability or future evolution.
|
||||
- It provides a phased, concrete sequence of work packages that can be implemented via ExecPlans (e.g., booking notifications, Moyasar payments, phone auth UX).
|
||||
- Each major feature area (auth, bookings, payments, notifications, localization, tests) should have or adopt an ExecPlan under `docs/execplans/` in line with `PLANS.md` before implementation begins.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Documentation Practices
|
||||
|
||||
These standards aim to keep documentation reliable as the codebase grows.
|
||||
|
||||
## Principles
|
||||
|
||||
- Single source of truth: one canonical doc per topic; link instead of duplicating.
|
||||
- Proximity: keep docs close to the code they describe when possible.
|
||||
- Freshness: update docs in the same PR as the code change.
|
||||
- Observable behavior: describe what someone can see or run to validate the behavior.
|
||||
|
||||
## Required Docs By Area
|
||||
|
||||
- Architecture and major decisions: `docs/architecture.md` and `docs/adr/`.
|
||||
- Feature delivery plans: `docs/execplans/` (required by `PLANS.md`).
|
||||
- Operational procedures: `docs/runbooks/`.
|
||||
- Risks and gaps: `docs/risks.md`.
|
||||
|
||||
## When To Write An ADR
|
||||
|
||||
Use an ADR for any decision that is cross-cutting or hard to reverse, including:
|
||||
|
||||
- External providers or payment/auth strategy changes.
|
||||
- Async vs synchronous execution decisions.
|
||||
- Data model changes that affect multiple apps or services.
|
||||
|
||||
ADRs live in `docs/adr/` and use the template in `docs/templates/adr.md`.
|
||||
|
||||
## Runbook Expectations
|
||||
|
||||
Every production-impacting flow should have a runbook that covers:
|
||||
|
||||
- Symptoms and impact.
|
||||
- Detection and quick checks.
|
||||
- Safe remediation steps.
|
||||
- Rollback or escalation path.
|
||||
|
||||
Use the template in `docs/templates/runbook.md`.
|
||||
|
||||
## Writing Style
|
||||
|
||||
- Be explicit: include exact commands, paths, and expected output where useful.
|
||||
- Keep sections short and focused.
|
||||
- Avoid unstated assumptions; if a step needs a specific directory, say so.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Docs updated or explicitly confirmed unnecessary.
|
||||
- New runbook added when operational behavior changes.
|
||||
- ADR added for new cross-cutting decisions.
|
||||
- `docs/risks.md` updated for meaningful gaps added or closed.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Booking Lifecycle Notifications (SMS/WhatsApp)
|
||||
|
||||
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
|
||||
|
||||
The requirements for ExecPlans live in `PLANS.md` at the repository root. This document must be maintained in accordance with that file.
|
||||
|
||||
## Purpose / Big Picture
|
||||
|
||||
After this change, a booking will automatically notify the customer and the assigned staff member when it is created, confirmed, or cancelled. You can see it working by creating a booking and observing two notification records (customer + staff), then changing the booking status to confirmed or cancelled and seeing two more notification records for that event. In the console provider, the messages are logged, giving an immediate, user-visible trace of the booking lifecycle.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] (2026-02-28 17:05Z) Created ExecPlan for booking lifecycle notifications and reviewed bookings + notifications gaps.
|
||||
- [x] (2026-02-28 17:30Z) Implemented notifications app with audit-friendly model, providers, and booking message templates.
|
||||
- [x] (2026-02-28 17:40Z) Connected booking create/update flows to notification dispatch with idempotent event handling.
|
||||
- [x] (2026-02-28 17:55Z) Allowed booking status updates with role checks to enable confirmation/cancellation.
|
||||
- [x] (2026-02-28 18:05Z) Added tests for booking notifications (create, status change, no duplicate sends).
|
||||
- [x] (2026-02-28 18:10Z) Updated `docs/risks.md` and validated tests (`python3 -m pytest`).
|
||||
|
||||
## Surprises & Discoveries
|
||||
|
||||
- Observation: Booking status updates were blocked because `status` was read-only on the default booking serializer.
|
||||
Evidence: `PATCH /api/bookings/<id>` returned HTTP 400 when attempting to confirm.
|
||||
|
||||
## Decision Log
|
||||
|
||||
- Decision: Store every booking notification in a dedicated `Notification` model for auditability, even when skipped.
|
||||
Rationale: Lifecycle messages are user-facing and must be traceable for support and compliance.
|
||||
Date/Author: 2026-02-28, Codex
|
||||
- Decision: Reuse existing OTP provider adapters for SMS/WhatsApp delivery, with a new `NOTIFICATION_PROVIDER` setting.
|
||||
Rationale: Avoid duplicate integration code while still allowing independent provider configuration.
|
||||
Date/Author: 2026-02-28, Codex
|
||||
- Decision: Default to SMS for booking notifications and use the recipient’s preferred language when formatting messages.
|
||||
Rationale: SMS is the most reliable baseline in KSA, and language preference is already captured on the user.
|
||||
Date/Author: 2026-02-28, Codex
|
||||
- Decision: Allow booking status changes via `BookingSerializer` with role-based validation.
|
||||
Rationale: Confirmation/cancellation must be reachable through the existing API, but should still respect basic role boundaries.
|
||||
Date/Author: 2026-02-28, Codex
|
||||
|
||||
## Outcomes & Retrospective
|
||||
|
||||
Booking lifecycle notifications are now implemented with audit-friendly records and idempotent sending. Booking creation and status changes (confirmed/cancelled) trigger SMS/WhatsApp notifications for both customer and staff, and role-based validation now governs status updates. Provider adapters remain scaffolds, so production delivery still requires real SMS/WhatsApp wiring.
|
||||
|
||||
## Context and Orientation
|
||||
|
||||
Booking creation and updates are handled in `backend/apps/bookings/views.py` via a DRF `ModelViewSet`. The booking model is in `backend/apps/bookings/models.py`, with `status` indicating lifecycle state. There is currently no notification system beyond OTP scaffolding in `backend/apps/accounts/services/otp.py`. This plan adds a new Django app at `backend/apps/notifications/` to store notification records, format booking lifecycle messages, and dispatch them via SMS or WhatsApp providers.
|
||||
|
||||
A “notification” in this repository means a user-facing message (SMS or WhatsApp) that is stored for auditability in a `Notification` database row. A “lifecycle event” is a booking change that should inform the customer and staff: booking created, confirmed, or cancelled.
|
||||
|
||||
## Plan of Work
|
||||
|
||||
First, create a `notifications` Django app with models and admin registration. Define `Notification`, `NotificationEvent`, `NotificationStatus`, and `NotificationChannel` in `backend/apps/notifications/models.py`. The model must capture booking, recipient, phone number, event, channel, status, provider, message, and send timestamps, and it must be idempotent by preventing duplicates for the same booking + recipient + event + channel. Register the model in `backend/apps/notifications/admin.py` and add `apps.notifications` to `INSTALLED_APPS` in `backend/salon_api/settings.py`.
|
||||
|
||||
Next, implement notification dispatch in `backend/apps/notifications/services.py`. Reuse OTP provider adapters from `apps.accounts.services.otp` with a new `NOTIFICATION_PROVIDER` setting (default to `OTP_PROVIDER`). Add a `NOTIFICATION_DEFAULT_CHANNEL` setting (default `sms`). Implement `send_booking_notification(booking, recipient, event)` to build localized message text using the recipient’s preferred language, send via the provider, and update the notification status. Implement `notify_booking_lifecycle(booking, event)` for initial sends and `notify_on_status_change(booking, previous_status)` to trigger only on status transitions. If the recipient lacks a phone number, record the notification as `skipped` with a reason.
|
||||
|
||||
Then, wire booking lifecycle events in `backend/apps/bookings/views.py`. On `perform_create`, call `notify_booking_lifecycle(..., booking_created)` so both customer and staff receive a message. On `perform_update`, compare the previous status to the new status and call `notify_on_status_change` for confirmed or cancelled transitions. Avoid sending notifications if the status does not change.
|
||||
|
||||
Finally, add tests in `backend/apps/notifications/tests/test_booking_notifications.py`. Cover booking creation (two notifications), status change to confirmed (two notifications), and a repeat status update that should not create duplicates. Ensure tests use phone numbers on users to avoid skipped notifications. Update `docs/risks.md` to mark “No notifications (email/SMS) beyond OTP scaffolding” as addressed once tests pass.
|
||||
|
||||
## Concrete Steps
|
||||
|
||||
Run these commands from the repository root (`/home/m7md/kshkool/Salon`).
|
||||
|
||||
1. Add notifications app code and migrations.
|
||||
- Create `backend/apps/notifications/` with `apps.py`, `models.py`, `services.py`, `admin.py`, and a migration `0001_initial.py`.
|
||||
- Update `backend/salon_api/settings.py` to include `apps.notifications` and notification settings.
|
||||
|
||||
2. Wire booking lifecycle events.
|
||||
- Update `backend/apps/bookings/views.py` to call notification services on create and status changes.
|
||||
|
||||
3. Add tests.
|
||||
- Create `backend/apps/notifications/tests/test_booking_notifications.py`.
|
||||
|
||||
4. Run backend tests.
|
||||
- From `backend/` with the venv active:
|
||||
python3 -m pytest
|
||||
|
||||
## Validation and Acceptance
|
||||
|
||||
- Creating a booking returns HTTP 201 and creates two notification records (customer + staff) with event `booking_created`.
|
||||
- Updating a booking’s status to `confirmed` creates two notification records with event `booking_confirmed`.
|
||||
- Repeating the same status update does not create duplicate notifications (records remain at two for that event).
|
||||
- `python3 -m pytest` passes, and the new tests fail before the change and pass after.
|
||||
|
||||
## Idempotence and Recovery
|
||||
|
||||
Notification creation is idempotent by a uniqueness constraint on booking + recipient + event + channel. Re-running the send logic will update a pending or failed notification rather than creating duplicates. If a migration needs to be reverted, use standard Django migration rollback and re-apply. If a notification provider is misconfigured, notifications will be marked failed and can be retried after fixing settings.
|
||||
|
||||
## Artifacts and Notes
|
||||
|
||||
Expected console-provider log example when creating a booking:
|
||||
|
||||
INFO OTP SMS to 0500000002: Your booking request is received for Haircut at Main Salon on 2026-03-01 10:00.
|
||||
INFO OTP SMS to 0500000003: Your booking request is received for Haircut at Main Salon on 2026-03-01 10:00.
|
||||
|
||||
## Interfaces and Dependencies
|
||||
|
||||
- `backend/apps/notifications/models.py` must define `Notification`, `NotificationEvent`, `NotificationStatus`, `NotificationChannel`.
|
||||
- `backend/apps/notifications/services.py` must expose `send_booking_notification`, `notify_booking_lifecycle`, and `notify_on_status_change`.
|
||||
- `backend/apps/bookings/views.py` must call notification services in `perform_create` and `perform_update`.
|
||||
- `backend/salon_api/settings.py` must define `NOTIFICATION_PROVIDER` and `NOTIFICATION_DEFAULT_CHANNEL` settings.
|
||||
|
||||
Plan Maintenance Note: Created on 2026-02-28 to implement booking lifecycle notifications as the next Phase 1 reliability milestone.
|
||||
Plan Maintenance Note (Update): Marked milestones complete, recorded the booking status update discovery, and documented role-based status validation after implementing notifications and tests on 2026-02-28.
|
||||
+5
-4
@@ -5,23 +5,24 @@ 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.
|
||||
- Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP).
|
||||
- Social login is a placeholder.
|
||||
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows.
|
||||
- `USERNAME_FIELD` is now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users.
|
||||
|
||||
## Booking Integrity
|
||||
- Availability checks and overlap prevention are now enforced for staff bookings.
|
||||
- **Race condition — fixed:** `BookingCreateSerializer.create()` now locks the staff row with `select_for_update()` inside `transaction.atomic()` and re-runs the overlap check before inserting. Concurrent requests for the same staff slot are serialized at the DB level. Requires PostgreSQL in production (SQLite ignores `FOR UPDATE` but still serializes writes).
|
||||
- No timezone handling or business hours enforcement.
|
||||
- No cancellation rules or refund logic.
|
||||
|
||||
## 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.
|
||||
- No notifications (email/SMS) beyond OTP scaffolding.
|
||||
- Booking lifecycle notifications are implemented; Authentica can deliver SMS when NOTIFICATION_PROVIDER=authentica.
|
||||
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
|
||||
|
||||
## Ops And Compliance
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Runbooks
|
||||
|
||||
Operational procedures live here. Each new production-impacting workflow should add or update a runbook.
|
||||
|
||||
Existing runbooks:
|
||||
|
||||
- `docs/runbooks/auth_otp_failures.md`
|
||||
- `docs/runbooks/booking_failures.md`
|
||||
- `docs/runbooks/payments_sanity_check.md`
|
||||
@@ -0,0 +1,39 @@
|
||||
# Runbook: Auth OTP Failures
|
||||
|
||||
## Summary
|
||||
|
||||
Guide for diagnosing and mitigating OTP send or verify failures in phone-first authentication.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- Users report not receiving OTP codes.
|
||||
- `/api/auth/otp/request/` or `/api/auth/phone/request/` returns HTTP 500 or rate-limit errors.
|
||||
- `/api/auth/otp/verify/` or `/api/auth/phone/verify/` returns invalid or expired OTP errors unexpectedly.
|
||||
|
||||
## Impact
|
||||
|
||||
- Users cannot sign in or complete phone verification.
|
||||
- Booking and payment flows are blocked when auth is required.
|
||||
|
||||
## Quick Checks
|
||||
|
||||
- Confirm the provider configured in `backend/salon_api/settings.py` via `OTP_PROVIDER`.
|
||||
- Check recent application logs for OTP send errors.
|
||||
- Verify provider credentials are present in `backend/.env` for the active provider.
|
||||
|
||||
## Mitigation Steps
|
||||
|
||||
- If provider credentials are missing or invalid, fix the environment variables and restart the API process.
|
||||
- If the provider is down, temporarily switch to `OTP_PROVIDER=console` for non-production environments and notify support.
|
||||
- If rate limits are triggered, validate `OTP_MAX_PER_WINDOW`, `OTP_WINDOW_MINUTES`, and `OTP_RESEND_COOLDOWN_SECONDS` values and confirm client behavior is not retrying aggressively.
|
||||
- If verification is failing, confirm server time is correct and `OTP_EXPIRY_MINUTES` is appropriate.
|
||||
|
||||
## Rollback / Escalation
|
||||
|
||||
- Roll back recent auth/OTP changes if the failure coincides with a deployment.
|
||||
- Escalate to the provider (Authentica) with request IDs and timestamps if external API errors persist.
|
||||
|
||||
## Notes
|
||||
|
||||
- Authentica is the primary OTP provider for MVP; console provider is for local development.
|
||||
- OTP send/verify logic lives in `backend/apps/accounts/services/otp.py`.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Runbook: Booking Failures
|
||||
|
||||
## Summary
|
||||
|
||||
Guide for diagnosing booking creation or status update failures (availability, overlap prevention, or validation errors).
|
||||
|
||||
## Symptoms
|
||||
|
||||
- `POST /api/bookings/` returns HTTP 400 or 500.
|
||||
- `PATCH /api/bookings/<id>/` fails when confirming or cancelling.
|
||||
- Users report bookings not appearing or incorrect status.
|
||||
|
||||
## Impact
|
||||
|
||||
- Customers cannot place bookings.
|
||||
- Staff schedules become inconsistent.
|
||||
- Notification and payment flows may not trigger.
|
||||
|
||||
## Quick Checks
|
||||
|
||||
- Confirm the request payload includes a valid `service`, `staff`, and scheduled time.
|
||||
- Check server logs for booking validation errors or integrity exceptions.
|
||||
- Verify that staff availability and overlap prevention rules are behaving as expected.
|
||||
|
||||
## Mitigation Steps
|
||||
|
||||
- Reproduce with a known test user and staff member to isolate data issues.
|
||||
- If overlap rules are too strict, review booking validation logic and confirm time zone assumptions.
|
||||
- If status updates are blocked, verify role checks and serializer permissions in `backend/apps/bookings/`.
|
||||
- If notifications are expected but missing, confirm `NOTIFICATION_PROVIDER` configuration and notification records.
|
||||
|
||||
## Rollback / Escalation
|
||||
|
||||
- Roll back recent booking-related changes if failures started after a deployment.
|
||||
- Escalate to engineering with the booking ID, user ID, and timestamps.
|
||||
|
||||
## Notes
|
||||
|
||||
- Booking validation and status transitions live in `backend/apps/bookings/`.
|
||||
- Notifications for booking lifecycle are handled in `backend/apps/notifications/`.
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
# ADR <NNNN>: <Title>
|
||||
|
||||
## Status
|
||||
|
||||
Proposed | Accepted | Deprecated | Superseded
|
||||
|
||||
## Context
|
||||
|
||||
Explain the problem and the forces at play. Include constraints, risks, or user needs.
|
||||
|
||||
## Decision
|
||||
|
||||
State the decision clearly and explicitly.
|
||||
|
||||
## Consequences
|
||||
|
||||
List the expected positive and negative outcomes, including operational impact.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
Briefly document viable alternatives and why they were rejected.
|
||||
|
||||
## Related
|
||||
|
||||
Link to relevant PRs, runbooks, or architecture sections.
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
# Runbook: <Short Title>
|
||||
|
||||
## Summary
|
||||
|
||||
One or two sentences describing the situation this runbook covers.
|
||||
|
||||
## Symptoms
|
||||
|
||||
Describe what an operator or user will observe.
|
||||
|
||||
## Impact
|
||||
|
||||
Who or what is affected.
|
||||
|
||||
## Quick Checks
|
||||
|
||||
Exact commands or checks that confirm the issue.
|
||||
|
||||
## Mitigation Steps
|
||||
|
||||
Step-by-step actions to resolve or reduce impact.
|
||||
|
||||
## Rollback / Escalation
|
||||
|
||||
How to revert or who to contact if the issue persists.
|
||||
|
||||
## Notes
|
||||
|
||||
Any caveats, dependencies, or follow-up actions.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Frontend Notes (MVP Readiness)
|
||||
|
||||
## High-Level Takeaways
|
||||
- `App.jsx` is monolithic and mixes search, payments, and locale controls; no routing exists yet.
|
||||
- Domain logic (API payloads, validation, error handling) lives in UI components instead of hooks/services.
|
||||
- Tests only cover hero copy and RTL behavior; search and payment flows are untested.
|
||||
- Global styles are fragile (likely `::root` typo instead of `:root`).
|
||||
- Auth token handling is ad hoc and should be replaced with a proper auth flow/context.
|
||||
|
||||
## Near-Term Focus
|
||||
- Introduce routing and split into pages (home/search, auth, booking, payment, profile).
|
||||
- Extract API logic into hooks/services to make testing and reuse easier.
|
||||
- Add Vitest coverage for search, booking, and payment flows.
|
||||
- Fix global CSS root selector and stabilize base layout styles.
|
||||
Generated
+59
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+24
-240
@@ -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 (
|
||||
<div className="page">
|
||||
<header className="hero">
|
||||
<div className="hero-top">
|
||||
<p className="eyebrow">{t("hero.eyebrow")}</p>
|
||||
<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>
|
||||
</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>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="salon/:id" element={<SalonDetailPage />} />
|
||||
<Route path="book" element={<BookPage />} />
|
||||
<Route path="pay" element={<PaymentPage />} />
|
||||
<Route path="pay/return" element={<PaymentReturnPage />} />
|
||||
<Route path="bookings" element={<BookingsPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <AuthProvider>{children}</AuthProvider>;
|
||||
}
|
||||
|
||||
describe("App", () => {
|
||||
it("renders the hero copy", async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
render(<App />);
|
||||
render(<App />, { 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(<App />);
|
||||
render(<App />, { wrapper: TestWrapper });
|
||||
const arabicButton = screen.getByRole("button", { name: "العربية" });
|
||||
fireEvent.click(arabicButton);
|
||||
await waitFor(() => {
|
||||
|
||||
+54
-12
@@ -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: {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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,23 @@
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function ProtectedRoute({ children }) {
|
||||
const { t } = useTranslation();
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="auth-loading">
|
||||
<p>{t("common.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,13 +13,17 @@
|
||||
},
|
||||
"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":"الفريق","unknownStaff":"موظف {{id}}"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
|
||||
"label": "اللغة",
|
||||
"arabic": "العربية",
|
||||
"english": "الإنجليزية"
|
||||
},
|
||||
"common": {
|
||||
"loading": "جاري التحميل..."
|
||||
},
|
||||
"payment": {
|
||||
"title": "المدفوعات (تجريبي)",
|
||||
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
|
||||
|
||||
@@ -13,13 +13,17 @@
|
||||
},
|
||||
"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","unknownStaff":"Staff {{id}}"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
|
||||
"label": "Language",
|
||||
"arabic": "العربية",
|
||||
"english": "English"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"payment": {
|
||||
"title": "Payment (Beta)",
|
||||
"subtitle": "Send a Moyasar payment for an existing booking.",
|
||||
|
||||
@@ -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 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(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</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 || t("salon.unknownStaff", { id: 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,71 @@
|
||||
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";
|
||||
import { getActiveLocale } from "../i18n/index";
|
||||
|
||||
function formatDateTime(iso, locale) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(locale, {
|
||||
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, getActiveLocale())} – {formatDateTime(b.end_time, getActiveLocale())}
|
||||
</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 || t("salon.unknownStaff", { id: 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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user