3 Commits

55 changed files with 2546 additions and 283 deletions
+3 -2
View File
@@ -5,7 +5,7 @@ Build a reliable, maintainable salon booking platform with Django (backend) and
## Coding ## 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) ## Current Plan (Roadmap)
### Phase 1: Core MVP Reliability ### Phase 1: Core MVP Reliability
@@ -50,6 +50,7 @@ Build a reliable, maintainable salon booking platform with Django (backend) and
- Use explicit, readable model fields and serializers. - Use explicit, readable model fields and serializers.
- Small, wellnamed functions > monolithic handlers. - Small, wellnamed functions > monolithic handlers.
- Prefer predictable error responses (HTTP status + `detail`). - Prefer predictable error responses (HTTP status + `detail`).
- Prefer short, intent-focused comments over silent complexity.
## Known Gaps (Tracked) ## Known Gaps (Tracked)
- See `docs/risks.md` for current gaps/risks to address. - See `docs/risks.md` for current gaps/risks to address.
@@ -69,4 +70,4 @@ Build a reliable, maintainable salon booking platform with Django (backend) and
# ExecPlans # ExecPlans
When writing complex features or significant refactors, use an ExecPlan (as described in PLANS.md) from design to implementation. The active ExecPlan is `docs/execplans/payments-moyasar.md`. When writing complex features or significant refactors, use an ExecPlan (as described in PLANS.md) from design to implementation. The current active ExecPlan is defined in PLANS.md. Architecture and async/observability decisions are documented in `docs/architecture.md`.
+1 -1
View File
@@ -4,7 +4,7 @@ This document describes the requirements for an execution plan ("ExecPlan"), a d
## Active ExecPlans ## 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 ## How to use ExecPlans and PLANS.md
+3 -1
View File
@@ -9,6 +9,7 @@ Location: `backend/`
### Setup ### Setup
1. Create a virtualenv and install dependencies. 1. Create a virtualenv and install dependencies.
- `python3 -m venv venv && source venv/bin/activate` (or `venv\Scripts\activate` on Windows)
- `pip install -r backend/requirements.txt -r backend/requirements-dev.txt` - `pip install -r backend/requirements.txt -r backend/requirements-dev.txt`
2. Copy `backend/.env.example` to `backend/.env` and adjust values. 2. Copy `backend/.env.example` to `backend/.env` and adjust values.
3. Run migrations and start the server. 3. Run migrations and start the server.
@@ -21,7 +22,7 @@ After migrations, you can seed demo data:
### Tests ### Tests
- `pytest` - From project root with venv active: `venv/bin/python3 -m pytest` (run from `backend/` so `pytest.ini` is picked up)
### Core API endpoints (current scaffold) ### Core API endpoints (current scaffold)
@@ -61,3 +62,4 @@ The dev server proxies `/api` to `http://localhost:8000`.
## Project Notes ## Project Notes
- Known gaps and risks: `docs/risks.md` - Known gaps and risks: `docs/risks.md`
- Architecture and async/observability decisions: `docs/architecture.md`
+13
View File
@@ -0,0 +1,13 @@
# Backend Notes (MVP Readiness)
## High-Level Takeaways
- Provider integrations are the main reliability gap: OTP providers are stubbed and 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
- Implement at least one real SMS/WhatsApp provider end-to-end via existing abstractions.
- Decide and document payment lifecycle scope (capture/refund supported vs explicitly out of scope).
- Add timeouts/logging for external calls or introduce minimal async jobs for OTP/notifications.
- Keep booking, payment, and notification orchestration in service layers, not views.
+13 -3
View File
@@ -50,6 +50,8 @@ class ConsoleOtpProvider(BaseOtpProvider):
class TwilioOtpProvider(BaseOtpProvider): class TwilioOtpProvider(BaseOtpProvider):
"""Twilio provider for SMS and WhatsApp OTP delivery. Requires TWILIO_* env vars."""
def __init__(self) -> None: def __init__(self) -> None:
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID") self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN") self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
@@ -60,15 +62,23 @@ class TwilioOtpProvider(BaseOtpProvider):
if not self.account_sid or not self.auth_token or not self.from_number: if not self.account_sid or not self.auth_token or not self.from_number:
raise ValueError(_("Twilio credentials are not configured")) raise ValueError(_("Twilio credentials are not configured"))
def send_sms(self, to_number: str, message: str) -> None: def _get_client(self):
from twilio.rest import Client
self._assert_config() self._assert_config()
raise NotImplementedError(_("Twilio SMS adapter not implemented yet")) return Client(self.account_sid, self.auth_token)
def send_sms(self, to_number: str, message: str) -> None:
client = self._get_client()
client.messages.create(body=message, from_=self.from_number, to=to_number)
def send_whatsapp(self, to_number: str, message: str) -> None: def send_whatsapp(self, to_number: str, message: str) -> None:
self._assert_config() self._assert_config()
if not self.whatsapp_from: if not self.whatsapp_from:
raise ValueError(_("Twilio WhatsApp sender is not configured")) raise ValueError(_("Twilio WhatsApp sender is not configured"))
raise NotImplementedError(_("Twilio WhatsApp adapter not implemented yet")) client = self._get_client()
from_ = f"whatsapp:{self.whatsapp_from}"
to = f"whatsapp:{to_number}"
client.messages.create(body=message, from_=from_, to=to)
class UnifonicOtpProvider(BaseOtpProvider): class UnifonicOtpProvider(BaseOtpProvider):
@@ -0,0 +1,51 @@
"""Tests for Twilio OTP provider implementation."""
import pytest
from unittest.mock import MagicMock, patch
@pytest.mark.django_db
@patch("apps.accounts.services.otp.TwilioOtpProvider._get_client")
def test_twilio_send_sms_calls_client(mock_get_client):
from apps.accounts.services.otp import TwilioOtpProvider
mock_client = MagicMock()
mock_get_client.return_value = mock_client
with patch.dict("os.environ", {
"TWILIO_ACCOUNT_SID": "AC123",
"TWILIO_AUTH_TOKEN": "token",
"TWILIO_FROM_NUMBER": "+966500000000",
}):
provider = TwilioOtpProvider()
provider.send_sms("+966512345678", "Your code is 123456")
mock_client.messages.create.assert_called_once_with(
body="Your code is 123456",
from_="+966500000000",
to="+966512345678",
)
@pytest.mark.django_db
@patch("apps.accounts.services.otp.TwilioOtpProvider._get_client")
def test_twilio_send_whatsapp_calls_client(mock_get_client):
from apps.accounts.services.otp import TwilioOtpProvider
mock_client = MagicMock()
mock_get_client.return_value = mock_client
with patch.dict("os.environ", {
"TWILIO_ACCOUNT_SID": "AC123",
"TWILIO_AUTH_TOKEN": "token",
"TWILIO_FROM_NUMBER": "+966500000000",
"TWILIO_WHATSAPP_FROM": "14155238886",
}):
provider = TwilioOtpProvider()
provider.send_whatsapp("+966512345678", "Your code is 123456")
mock_client.messages.create.assert_called_once_with(
body="Your code is 123456",
from_="whatsapp:14155238886",
to="whatsapp:+966512345678",
)
+24 -2
View File
@@ -1,5 +1,6 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers 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.bookings.services import validate_booking_request
from apps.salons.models import Service, StaffProfile from apps.salons.models import Service, StaffProfile
@@ -27,7 +28,7 @@ class BookingSerializer(serializers.ModelSerializer):
"notes", "notes",
"created_at", "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): def get_staff_name(self, obj):
if not obj.staff: if not obj.staff:
@@ -36,6 +37,27 @@ class BookingSerializer(serializers.ModelSerializer):
last = obj.staff.user.last_name or "" last = obj.staff.user.last_name or ""
return (first + " " + last).strip() or obj.staff.user.email 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 BookingCreateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
+12 -1
View File
@@ -1,7 +1,9 @@
from rest_framework import permissions, viewsets 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.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): class BookingViewSet(viewsets.ModelViewSet):
@@ -21,3 +23,12 @@ class BookingViewSet(viewsets.ModelViewSet):
if self.action == "create": if self.action == "create":
return BookingCreateSerializer return BookingCreateSerializer
return BookingSerializer 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)
+21
View File
@@ -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")
+6
View File
@@ -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",
),
),
]
+64
View File
@@ -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}"
+171
View File
@@ -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
+34 -6
View File
@@ -33,10 +33,12 @@ class BasePaymentGateway:
) -> PaymentInitResult: ) -> PaymentInitResult:
raise NotImplementedError raise NotImplementedError
def capture_payment(self, external_id: str) -> None: def capture_payment(self, external_id: str, amount: Optional[int] = None) -> None:
"""Capture an authorized payment. Amount in minor units; omit for full capture."""
raise NotImplementedError raise NotImplementedError
def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None: def refund_payment(self, external_id: str, amount: Optional[int] = None) -> None:
"""Refund a paid/captured payment. Amount in minor units; omit for full refund."""
raise NotImplementedError raise NotImplementedError
@@ -101,10 +103,36 @@ class MoyasarGateway(BasePaymentGateway):
payload=data, payload=data,
) )
def capture_payment(self, external_id: str) -> None: def capture_payment(self, external_id: str, amount: Optional[int] = None) -> None:
"""Capture an authorized payment. Amount in minor units; omit for full capture."""
self._assert_config() self._assert_config()
raise NotImplementedError("Moyasar capture not implemented yet") url = f"{self.base_url}/v1/payments/{external_id}/capture"
payload = {} if amount is None else {"amount": amount}
try:
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
except requests.RequestException as exc:
raise PaymentGatewayError("Failed to reach Moyasar for capture") from exc
if response.status_code not in (200, 201):
data = response.json() if response.content else {}
raise PaymentGatewayError(
"Moyasar capture failed",
status_code=response.status_code,
payload=data,
)
def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None: def refund_payment(self, external_id: str, amount: Optional[int] = None) -> None:
"""Refund a paid/captured payment. Amount in minor units; omit for full refund."""
self._assert_config() self._assert_config()
raise NotImplementedError("Moyasar refund not implemented yet") url = f"{self.base_url}/v1/payments/{external_id}/refund"
payload = {} if amount is None else {"amount": amount}
try:
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
except requests.RequestException as exc:
raise PaymentGatewayError("Failed to reach Moyasar for refund") from exc
if response.status_code not in (200, 201):
data = response.json() if response.content else {}
raise PaymentGatewayError(
"Moyasar refund failed",
status_code=response.status_code,
payload=data,
)
@@ -0,0 +1,54 @@
"""Tests for Moyasar capture and refund gateway methods."""
from unittest.mock import Mock, patch
import pytest
from apps.payments.services.gateway import MoyasarGateway, PaymentGatewayError
@patch("apps.payments.services.gateway.requests.post")
def test_moyasar_capture_calls_api(mock_post):
mock_post.return_value = Mock(status_code=200, content=b"{}", json=lambda: {"id": "pay_1", "status": "captured"})
with patch.dict("os.environ", {
"MOYASAR_SECRET_KEY": "sk_test",
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
}):
gateway = MoyasarGateway()
gateway.capture_payment("pay_1")
mock_post.assert_called_once()
call_args = mock_post.call_args
assert "pay_1/capture" in call_args[0][0]
assert call_args[1]["auth"] == ("sk_test", "")
@patch("apps.payments.services.gateway.requests.post")
def test_moyasar_refund_calls_api(mock_post):
mock_post.return_value = Mock(status_code=200, content=b"{}", json=lambda: {"id": "pay_1", "status": "refunded"})
with patch.dict("os.environ", {
"MOYASAR_SECRET_KEY": "sk_test",
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
}):
gateway = MoyasarGateway()
gateway.refund_payment("pay_1")
mock_post.assert_called_once()
call_args = mock_post.call_args
assert "pay_1/refund" in call_args[0][0]
@patch("apps.payments.services.gateway.requests.post")
def test_moyasar_capture_raises_on_error(mock_post):
mock_post.return_value = Mock(status_code=400, content=b'{"message":"Invalid"}', json=lambda: {"message": "Invalid"})
with patch.dict("os.environ", {
"MOYASAR_SECRET_KEY": "sk_test",
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
}):
gateway = MoyasarGateway()
with pytest.raises(PaymentGatewayError) as exc_info:
gateway.capture_payment("pay_1")
assert exc_info.value.status_code == 400
+1
View File
@@ -5,3 +5,4 @@ django-cors-headers>=4.3
psycopg[binary]>=3.1 psycopg[binary]>=3.1
python-dotenv>=1.0 python-dotenv>=1.0
requests>=2.31 requests>=2.31
twilio>=9.0
+3
View File
@@ -26,6 +26,7 @@ INSTALLED_APPS = [
"apps.salons", "apps.salons",
"apps.bookings", "apps.bookings",
"apps.payments", "apps.payments",
"apps.notifications",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -140,3 +141,5 @@ OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15")) OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60")) OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60"))
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR") DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR")
NOTIFICATION_PROVIDER = os.getenv("NOTIFICATION_PROVIDER", OTP_PROVIDER)
NOTIFICATION_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms")
+11
View File
@@ -0,0 +1,11 @@
# Docs Notes (MVP Alignment)
## High-Level Takeaways
- The MVP roadmap aligns with Phase 1 goals but needs tighter documentation around provider readiness and async strategy.
- ExecPlan references drift between `AGENTS.md` and `PLANS.md` should be resolved to avoid conflicting guidance.
- Observability and operational visibility are thin; errors are stored but not surfaced through clear runbooks/dashboards.
## Near-Term Focus
- Make ExecPlan references consistent and keep active plans clearly labeled.
- Document whether MVP uses async jobs (and which system) or remains synchronous with strict timeouts.
- Keep `docs/risks.md` current as gaps are closed.
+39
View File
@@ -0,0 +1,39 @@
# Architecture
## Overview
The Salon platform is a Django REST API backend with a React/Vite frontend, optimized for KSA (phone auth, Riyadh timezone, Arabic locale).
## Backend Apps and Responsibilities
| App | Responsibility |
|-----|----------------|
| **accounts** | User model, phone/OTP auth, JWT tokens, locale preferences. OTP providers (console, Twilio, Unifonic) send SMS/WhatsApp. |
| **salons** | Salon catalog, services, staff, availability windows, reviews. Read-only public APIs. |
| **bookings** | Booking model, validation (availability, overlap prevention), status transitions. Triggers notifications on create and status change. |
| **payments** | Payment model, Moyasar integration (create, capture, refund), webhook reconciliation, idempotency. |
| **notifications** | Booking lifecycle notifications (SMS/WhatsApp). Reuses OTP providers; sends on booking created/confirmed/cancelled. |
## Data Flow
```
User → React Frontend → Django API
accounts (auth) ──→ OTP providers (Twilio/Unifonic/console)
salons (catalog)
bookings ──→ notifications ──→ OTP providers
payments ──→ Moyasar gateway
```
## Async and Observability (MVP Decision)
**Decision (MVP):** All OTP sends, booking notifications, and payment gateway calls run **synchronously** in the request/response path. No Celery, RQ, or other task queue for the initial launch.
**Rationale:**
- Reduces deployment complexity (no Redis, no worker processes).
- MVP traffic is expected to be low; synchronous latency is acceptable.
- External calls already use timeouts (e.g. Moyasar: 10s, Twilio: SDK default).
**Future:** When scaling, introduce a task queue (e.g. Celery + Redis) for OTP and notification sends. Payment creation and webhooks should remain synchronous for immediate feedback and idempotency.
**Observability:** Errors are logged via Python `logging` and stored in model metadata (e.g. `Payment.metadata["gateway_error"]`, `Notification.error_message`). Structured logging and metrics are Phase 3 work.
+104
View File
@@ -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 recipients 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 recipients 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 bookings 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.
+3 -3
View File
@@ -5,7 +5,7 @@ This file tracks known gaps and risks to address in future iterations.
## Security And Auth ## Security And Auth
- Phone normalization is KSA-focused and minimal; broaden for multi-country use. - Phone normalization is KSA-focused and minimal; broaden for multi-country use.
- OTP protections are basic; add device fingerprinting and IP throttling if needed. - OTP protections are basic; add device fingerprinting and IP throttling if needed.
- OTP provider adapters (Twilio/Unifonic) are scaffolds only; no real SMS/WhatsApp delivery yet. - Twilio OTP provider is implemented (SMS + WhatsApp); Unifonic remains a scaffold.
- Social login is a placeholder. - Social login is a placeholder.
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows. - USERNAME_FIELD is still `email` while email can be null; verify admin/login flows.
@@ -16,12 +16,12 @@ This file tracks known gaps and risks to address in future iterations.
## Payments ## Payments
- Moyasar payment creation, webhook reconciliation, and idempotency are implemented. - Moyasar payment creation, webhook reconciliation, and idempotency are implemented.
- Refund/capture operations are not implemented yet if required. - Moyasar capture and refund are implemented in the gateway; API endpoints for admin-initiated capture/refund can be added when needed.
## Data And UX ## Data And UX
- Ratings are not recalculated from reviews. - Ratings are not recalculated from reviews.
- No image upload or storage strategy for photos. - No image upload or storage strategy for photos.
- No notifications (email/SMS) beyond OTP scaffolding. - Booking lifecycle notifications are implemented; Twilio delivers SMS/WhatsApp when OTP_PROVIDER=twilio.
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending. - Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
## Ops And Compliance ## Ops And Compliance
+248
View File
@@ -0,0 +1,248 @@
---
name: salon-mvp-roadmap
overview: High-level roadmap to bring the existing Salon Django/React codebase to a reliable MVP aligned with Phase 1 goals in AGENTS.md, plus a review of current architecture and major risks.
todos:
- id: backend-providers-readiness
content: "Harden backend providers: implement at least one real SMS/WhatsApp provider and clarify Moyasar capture/refund behavior for MVP."
status: pending
- id: async-and-observability
content: Decide on async task infrastructure and observability basics for OTP, notifications, and payments, and document the choice.
status: pending
- id: frontend-structure-and-routing
content: Refactor frontend into routed pages with separated components/hooks for search, auth, booking, and payments.
status: pending
- id: auth-and-booking-flows
content: Implement phone-first auth and end-to-end booking flows on the frontend using existing backend APIs.
status: pending
- id: payments-and-notifications-ux
content: Integrate payment initiation and booking lifecycle notifications into user-facing flows, including success/error handling.
status: pending
- id: tests-for-critical-flows
content: Expand backend and frontend tests to cover auth, booking, payment, and notification critical paths for MVP reliability.
status: pending
isProject: false
---
# 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**
- Twilio/Unifonic providers in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)` are stubs with `NotImplementedError` for send methods, yet they are the backbone for both OTP and booking notifications.
- `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**
- Implement at least one real SMS/WhatsApp provider (Twilio or Unifonic) end-to-end, behind the existing provider abstraction in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`, and wire it 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 Moyasars 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.
+14
View File
@@ -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.
+59 -1
View File
@@ -11,7 +11,8 @@
"i18next": "^23.11.5", "i18next": "^23.11.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^14.1.0" "react-i18next": "^14.1.0",
"react-router-dom": "^7.13.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
@@ -1950,6 +1951,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3578,6 +3592,44 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -3729,6 +3781,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+3 -2
View File
@@ -13,12 +13,13 @@
"i18next": "^23.11.5", "i18next": "^23.11.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^14.1.0" "react-i18next": "^14.1.0",
"react-router-dom": "^7.13.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1", "@testing-library/react": "^14.2.1",
"@vitejs/plugin-react": "^4.2.0",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vitest": "^1.3.1" "vitest": "^1.3.1"
+24 -240
View File
@@ -1,245 +1,29 @@
import { useEffect, useMemo, useState } from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useTranslation } from "react-i18next"; import MainLayout from "./layouts/MainLayout";
import { apiGet, apiPost } from "./api/client"; import HomePage from "./pages/HomePage";
import { setLocale } from "./i18n"; import BookPage from "./pages/BookPage";
import PaymentPage from "./pages/PaymentPage";
import ProfilePage from "./pages/ProfilePage";
import BookingsPage from "./pages/BookingsPage";
import LoginPage from "./pages/LoginPage";
import SalonDetailPage from "./pages/SalonDetailPage";
import PaymentReturnPage from "./pages/PaymentReturnPage";
export default function App() { export default function App() {
const [salons, setSalons] = useState([]);
const [query, setQuery] = useState("");
const [status, setStatus] = useState("idle");
const [paymentBookingId, setPaymentBookingId] = useState("");
const [paymentToken, setPaymentToken] = useState(() => localStorage.getItem("auth_token") || "");
const [paymentSourceType, setPaymentSourceType] = useState("stcpay");
const [paymentSourceValue, setPaymentSourceValue] = useState("");
const [paymentCallbackUrl, setPaymentCallbackUrl] = useState("");
const [paymentStatus, setPaymentStatus] = useState("idle");
const [paymentResult, setPaymentResult] = useState(null);
const [paymentError, setPaymentError] = useState("");
const { t, i18n } = useTranslation();
const idempotencyKey = useMemo(() => {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return `id-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}, []);
useEffect(() => {
localStorage.setItem("auth_token", paymentToken);
}, [paymentToken]);
useEffect(() => {
let ignore = false;
async function load() {
setStatus("loading");
try {
const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`);
if (!ignore) {
setSalons(data);
setStatus("ready");
}
} catch (error) {
if (!ignore) {
setStatus("error");
}
}
}
load();
return () => {
ignore = true;
};
}, [query]);
async function handlePaymentSubmit(event) {
event.preventDefault();
setPaymentStatus("loading");
setPaymentError("");
setPaymentResult(null);
if (!paymentBookingId) {
setPaymentStatus("error");
setPaymentError(t("payment.errors.bookingRequired"));
return;
}
const source = { type: paymentSourceType };
if (paymentSourceType === "stcpay") {
if (!paymentSourceValue) {
setPaymentStatus("error");
setPaymentError(t("payment.errors.mobileRequired"));
return;
}
source.mobile = paymentSourceValue;
}
if (paymentSourceType === "token") {
if (!paymentSourceValue) {
setPaymentStatus("error");
setPaymentError(t("payment.errors.tokenRequired"));
return;
}
source.token = paymentSourceValue;
}
const payload = {
booking_id: Number(paymentBookingId),
provider: "moyasar",
idempotency_key: idempotencyKey,
source,
};
if (paymentCallbackUrl) {
payload.callback_url = paymentCallbackUrl;
}
try {
const data = await apiPost("/payments/", payload, paymentToken);
setPaymentResult(data);
setPaymentStatus("ready");
if (data?.redirect_url) {
window.location.assign(data.redirect_url);
}
} catch (error) {
setPaymentStatus("error");
setPaymentError(error.message || t("payment.errors.generic"));
}
}
return ( return (
<div className="page"> <BrowserRouter>
<header className="hero"> <Routes>
<div className="hero-top"> <Route path="/" element={<MainLayout />}>
<p className="eyebrow">{t("hero.eyebrow")}</p> <Route index element={<HomePage />} />
<div className="locale-switch" aria-label={t("locale.label")}> <Route path="salon/:id" element={<SalonDetailPage />} />
<button <Route path="book" element={<BookPage />} />
type="button" <Route path="pay" element={<PaymentPage />} />
className={i18n.language === "ar-sa" ? "active" : ""} <Route path="pay/return" element={<PaymentReturnPage />} />
onClick={() => setLocale("ar-sa")} <Route path="bookings" element={<BookingsPage />} />
> <Route path="profile" element={<ProfilePage />} />
{t("locale.arabic")} <Route path="login" element={<LoginPage />} />
</button> </Route>
<button </Routes>
type="button" </BrowserRouter>
className={i18n.language === "en" ? "active" : ""}
onClick={() => setLocale("en")}
>
{t("locale.english")}
</button>
</div>
</div>
<h1>{t("hero.title")}</h1>
<p className="subtitle">{t("hero.subtitle")}</p>
<div className="search">
<input
type="text"
placeholder={t("hero.searchPlaceholder")}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</header>
<section className="results">
<h2>{t("results.title")}</h2>
{status === "loading" && <p>{t("results.loading")}</p>}
{status === "error" && (
<p className="error">{t("results.error")}</p>
)}
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
<div className="grid">
{salons.map((salon) => (
<article className="card" key={salon.id}>
<div className="card-header">
<h3>{salon.name}</h3>
<span className="rating">{salon.rating_avg} / 5</span>
</div>
<p>{salon.description || t("card.noDescription")}</p>
<div className="meta">
<span>{salon.city}</span>
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
</div>
</article>
))}
</div>
</section>
<section className="payments">
<div className="payments-header">
<div>
<h2>{t("payment.title")}</h2>
<p className="payments-subtitle">{t("payment.subtitle")}</p>
</div>
<span className="payments-badge">{t("payment.badge")}</span>
</div>
<form className="payments-form" onSubmit={handlePaymentSubmit}>
<label className="field">
<span>{t("payment.bookingId")}</span>
<input
type="number"
min="1"
value={paymentBookingId}
onChange={(event) => setPaymentBookingId(event.target.value)}
placeholder="123"
required
/>
</label>
<label className="field">
<span>{t("payment.accessToken")}</span>
<input
type="password"
value={paymentToken}
onChange={(event) => setPaymentToken(event.target.value)}
placeholder={t("payment.accessTokenPlaceholder")}
/>
</label>
<label className="field">
<span>{t("payment.sourceType")}</span>
<select
value={paymentSourceType}
onChange={(event) => setPaymentSourceType(event.target.value)}
>
<option value="stcpay">{t("payment.sources.stcpay")}</option>
<option value="token">{t("payment.sources.token")}</option>
<option value="applepay">{t("payment.sources.applepay")}</option>
</select>
</label>
<label className="field">
<span>{t("payment.sourceValue")}</span>
<input
type="text"
value={paymentSourceValue}
onChange={(event) => setPaymentSourceValue(event.target.value)}
placeholder={t("payment.sourceValuePlaceholder")}
/>
</label>
<label className="field">
<span>{t("payment.callbackUrl")}</span>
<input
type="url"
value={paymentCallbackUrl}
onChange={(event) => setPaymentCallbackUrl(event.target.value)}
placeholder="https://example.com/payments/return"
/>
</label>
<div className="payments-actions">
<button type="submit" disabled={paymentStatus === "loading"}>
{paymentStatus === "loading" ? t("payment.processing") : t("payment.payNow")}
</button>
<p className="helper">
{t("payment.idempotency")}: {idempotencyKey}
</p>
</div>
</form>
{paymentStatus === "error" && paymentError && (
<p className="error">{paymentError}</p>
)}
{paymentStatus === "ready" && paymentResult && (
<pre className="payment-result">{JSON.stringify(paymentResult, null, 2)}</pre>
)}
</section>
</div>
); );
} }
+7 -2
View File
@@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest"; import { vi } from "vitest";
import App from "./App.jsx"; import App from "./App.jsx";
import { AuthProvider } from "./contexts/AuthContext";
import i18n from "./i18n"; import i18n from "./i18n";
vi.mock("./api/client", () => ({ vi.mock("./api/client", () => ({
@@ -8,10 +9,14 @@ vi.mock("./api/client", () => ({
apiPost: vi.fn() apiPost: vi.fn()
})); }));
function TestWrapper({ children }) {
return <AuthProvider>{children}</AuthProvider>;
}
describe("App", () => { describe("App", () => {
it("renders the hero copy", async () => { it("renders the hero copy", async () => {
await i18n.changeLanguage("en"); await i18n.changeLanguage("en");
render(<App />); render(<App />, { wrapper: TestWrapper });
expect( expect(
await screen.findByText("Find, compare, and book top salons near you.") await screen.findByText("Find, compare, and book top salons near you.")
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -19,7 +24,7 @@ describe("App", () => {
it("switches to Arabic and sets RTL direction", async () => { it("switches to Arabic and sets RTL direction", async () => {
await i18n.changeLanguage("en"); await i18n.changeLanguage("en");
render(<App />); render(<App />, { wrapper: TestWrapper });
const arabicButton = screen.getByRole("button", { name: "العربية" }); const arabicButton = screen.getByRole("button", { name: "العربية" });
fireEvent.click(arabicButton); fireEvent.click(arabicButton);
await waitFor(() => { await waitFor(() => {
+55 -13
View File
@@ -2,26 +2,52 @@ import { getActiveLocale } from "../i18n";
const API_BASE = import.meta.env.VITE_API_BASE || "/api"; const API_BASE = import.meta.env.VITE_API_BASE || "/api";
async function handleResponse(response) { export class ApiError extends Error {
if (!response.ok) { constructor(message, { status, body } = {}) {
const errorText = await response.text(); super(message);
throw new Error(errorText || `Request failed: ${response.status}`); this.name = "ApiError";
this.status = status;
this.body = body;
} }
return response.json();
} }
export async function apiGet(path) { async function handleResponse(response) {
const response = await fetch(`${API_BASE}${path}`, { const text = await response.text();
headers: { let body = null;
"Accept-Language": getActiveLocale(), try {
}, body = text ? JSON.parse(text) : null;
}); } catch {
// Ignore
}
if (!response.ok) {
const message =
(body?.detail && typeof body.detail === "string" ? body.detail : null) ||
text ||
`Request failed: ${response.status}`;
throw new ApiError(message, { status: response.status, body });
}
return body;
}
function baseHeaders() {
return {
"Accept-Language": getActiveLocale(),
};
}
export async function apiGet(path, token) {
const headers = { ...baseHeaders() };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${path}`, { headers });
return handleResponse(response); return handleResponse(response);
} }
export async function apiPost(path, body, token) { export async function apiPost(path, body, token) {
const headers = { const headers = {
"Accept-Language": getActiveLocale(), ...baseHeaders(),
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
if (token) { if (token) {
@@ -31,7 +57,23 @@ export async function apiPost(path, body, token) {
const response = await fetch(`${API_BASE}${path}`, { const response = await fetch(`${API_BASE}${path}`, {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify(body), body: body ? JSON.stringify(body) : undefined,
});
return handleResponse(response);
}
export async function apiPatch(path, body, token) {
const headers = {
...baseHeaders(),
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${path}`, {
method: "PATCH",
headers,
body: body ? JSON.stringify(body) : undefined,
}); });
return handleResponse(response); return handleResponse(response);
} }
+24
View File
@@ -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>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useTranslation } from "react-i18next";
import { usePaymentForm } from "../hooks/usePaymentForm";
export default function PaymentForm({ bookingId = "", token = "" }) {
const { t } = useTranslation();
const form = usePaymentForm(bookingId, token);
return (
<section className="payments">
<div className="payments-header">
<div>
<h2>{t("payment.title")}</h2>
<p className="payments-subtitle">{t("payment.subtitle")}</p>
</div>
<span className="payments-badge">{t("payment.badge")}</span>
</div>
<form
className="payments-form"
onSubmit={(e) => {
e.preventDefault();
form.submit();
}}
>
<label className="field">
<span>{t("payment.bookingId")}</span>
<input
type="number"
min="1"
value={form.bookingIdInput}
onChange={(e) => form.setBookingIdInput(e.target.value)}
placeholder="123"
required
/>
</label>
<label className="field">
<span>{t("payment.accessToken")}</span>
<input
type="password"
value={form.tokenInput}
onChange={(e) => form.setTokenInput(e.target.value)}
placeholder={t("payment.accessTokenPlaceholder")}
/>
</label>
<label className="field">
<span>{t("payment.sourceType")}</span>
<select
value={form.sourceType}
onChange={(e) => form.setSourceType(e.target.value)}
>
<option value="stcpay">{t("payment.sources.stcpay")}</option>
<option value="token">{t("payment.sources.token")}</option>
<option value="applepay">{t("payment.sources.applepay")}</option>
</select>
</label>
<label className="field">
<span>{t("payment.sourceValue")}</span>
<input
type="text"
value={form.sourceValue}
onChange={(e) => form.setSourceValue(e.target.value)}
placeholder={t("payment.sourceValuePlaceholder")}
/>
</label>
<label className="field">
<span>{t("payment.callbackUrl")}</span>
<input
type="url"
value={form.callbackUrl}
onChange={(e) => form.setCallbackUrl(e.target.value)}
placeholder="https://example.com/payments/return"
/>
</label>
<div className="payments-actions">
<button type="submit" disabled={form.status === "loading"}>
{form.status === "loading"
? t("payment.processing")
: t("payment.payNow")}
</button>
<p className="helper">
{t("payment.idempotency")}: {form.idempotencyKey}
</p>
</div>
</form>
{form.status === "error" && form.error && (
<p className="error">{form.error}</p>
)}
{form.status === "ready" && form.result && (
<pre className="payment-result">{JSON.stringify(form.result, null, 2)}</pre>
)}
</section>
);
}
@@ -0,0 +1,21 @@
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
export default function ProtectedRoute({ children }) {
const { isAuthenticated, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="auth-loading">
<p>Loading...</p>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
+22
View File
@@ -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>
);
}
+37
View File
@@ -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();
});
});
+95
View File
@@ -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;
}
+123
View File
@@ -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,
};
}
+33
View File
@@ -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 };
}
+3 -2
View File
@@ -13,9 +13,10 @@
}, },
"card": { "card": {
"noDescription": "لا يوجد وصف بعد.", "noDescription": "لا يوجد وصف بعد.",
"phoneUnavailable": "الهاتف غير متوفر" "phoneUnavailable": "الهاتف غير متوفر",
"viewDetails": "عرض التفاصيل والحجز"
}, },
"locale": { "nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
"label": "اللغة", "label": "اللغة",
"arabic": "العربية", "arabic": "العربية",
"english": "الإنجليزية" "english": "الإنجليزية"
+3 -2
View File
@@ -13,9 +13,10 @@
}, },
"card": { "card": {
"noDescription": "No description yet.", "noDescription": "No description yet.",
"phoneUnavailable": "Phone unavailable" "phoneUnavailable": "Phone unavailable",
"viewDetails": "View details & book"
}, },
"locale": { "nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
"label": "Language", "label": "Language",
"arabic": "العربية", "arabic": "العربية",
"english": "English" "english": "English"
+45
View File
@@ -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>
);
}
+4 -1
View File
@@ -1,11 +1,14 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.jsx"; import App from "./App.jsx";
import { AuthProvider } from "./contexts/AuthContext";
import "./i18n"; import "./i18n";
import "./styles.css"; import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<App /> <AuthProvider>
<App />
</AuthProvider>
</React.StrictMode> </React.StrictMode>
); );
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { apiGet, apiPost } from "../api/client";
import { useAuth } from "../contexts/AuthContext";
import ProtectedRoute from "../components/ProtectedRoute";
export default function BookPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { accessToken } = useAuth();
const salonId = searchParams.get("salon");
const [salon, setSalon] = useState(null);
const [serviceId, setServiceId] = useState("");
const [staffId, setStaffId] = useState("");
const [date, setDate] = useState("");
const [time, setTime] = useState("");
const [notes, setNotes] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!salonId) return;
apiGet(`/salons/${salonId}/`)
.then(setSalon)
.catch(() => setSalon(null));
}, [salonId]);
if (!salonId) {
return (
<section className="book-page">
<h1>{t("book.title")}</h1>
<p>{t("book.selectSalon")}</p>
</section>
);
}
const selectedService = salon?.services?.find((s) => String(s.id) === serviceId);
const duration = selectedService?.duration_minutes || 0;
function computeEndTime(startISO) {
if (!startISO || !duration) return null;
const start = new Date(startISO);
const end = new Date(start.getTime() + duration * 60 * 1000);
return end.toISOString();
}
async function handleSubmit(e) {
e.preventDefault();
setError("");
if (!serviceId || !staffId || !date || !time) {
setError(t("book.errors.fillAll"));
return;
}
// Use Asia/Riyadh offset for backend (KSA)
const startISO = `${date}T${time}:00+03:00`;
const endISO = computeEndTime(startISO);
if (!endISO) {
setError(t("book.errors.invalidTime"));
return;
}
setLoading(true);
try {
const booking = await apiPost(
"/bookings/",
{
service: Number(serviceId),
staff: Number(staffId),
start_time: startISO,
end_time: endISO,
notes,
},
accessToken
);
navigate(`/pay?booking=${booking.id}`);
} catch (err) {
setError(err.message || t("book.errors.generic"));
} finally {
setLoading(false);
}
}
const content = (
<section className="book-page">
<h1>{t("book.title")}</h1>
{salon && <p className="book-salon">{salon.name}</p>}
{!salon ? (
<p>{t("results.loading")}</p>
) : (
<form onSubmit={handleSubmit} className="book-form">
<label className="field">
<span>{t("book.service")}</span>
<select
value={serviceId}
onChange={(e) => setServiceId(e.target.value)}
required
>
<option value="">{t("book.selectService")}</option>
{salon.services?.map((s) => (
<option key={s.id} value={s.id}>
{s.name} {s.duration_minutes} min, {s.price_amount} {s.currency}
</option>
))}
</select>
</label>
<label className="field">
<span>{t("book.staff")}</span>
<select
value={staffId}
onChange={(e) => setStaffId(e.target.value)}
required
>
<option value="">{t("book.selectStaff")}</option>
{salon.staff?.map((s) => (
<option key={s.id} value={s.id}>
{s.name || s.title || `Staff ${s.id}`}
</option>
))}
</select>
</label>
<label className="field">
<span>{t("book.date")}</span>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</label>
<label className="field">
<span>{t("book.time")}</span>
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
required
/>
</label>
<label className="field">
<span>{t("book.notes")}</span>
<input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("book.notesPlaceholder")}
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? t("book.submitting") : t("book.submit")}
</button>
</form>
)}
</section>
);
return <ProtectedRoute>{content}</ProtectedRoute>;
}
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { apiGet } from "../api/client";
import { useAuth } from "../contexts/AuthContext";
import ProtectedRoute from "../components/ProtectedRoute";
function formatDateTime(iso) {
if (!iso) return "";
const d = new Date(iso);
return d.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
export default function BookingsPage() {
const { t } = useTranslation();
const { accessToken } = useAuth();
const [bookings, setBookings] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!accessToken) return;
apiGet("/bookings/", accessToken)
.then(setBookings)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [accessToken]);
const content = (
<section className="bookings-page">
<h1>{t("bookings.title")}</h1>
<p className="bookings-subtitle">{t("bookings.subtitle")}</p>
{loading && <p>{t("results.loading")}</p>}
{error && <p className="error">{error}</p>}
{!loading && !error && bookings.length === 0 && (
<p>{t("bookings.empty")}</p>
)}
{!loading && !error && bookings.length > 0 && (
<ul className="bookings-list">
{bookings.map((b) => (
<li key={b.id} className="booking-card">
<div className="booking-header">
<span className="booking-status">{b.status}</span>
<span className="booking-salon">{b.salon_name}</span>
</div>
<p className="booking-service">{b.service_name}</p>
<p className="booking-time">
{formatDateTime(b.start_time)} {formatDateTime(b.end_time)}
</p>
<p className="booking-price">
{b.price_amount} {b.currency}
</p>
<Link to={`/pay?booking=${b.id}`} className="booking-pay-link">
{t("bookings.pay")}
</Link>
</li>
))}
</ul>
)}
</section>
);
return <ProtectedRoute>{content}</ProtectedRoute>;
}
+21
View File
@@ -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} />
</>
);
}
+137
View File
@@ -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>
);
}
+60
View File
@@ -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();
});
});
});
+10
View File
@@ -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 || ""} />;
}
+26
View File
@@ -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>
);
}
+25
View File
@@ -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>;
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { apiGet } from "../api/client";
export default function SalonDetailPage() {
const { t } = useTranslation();
const { id } = useParams();
const [salon, setSalon] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!id) return;
apiGet(`/salons/${id}/`)
.then(setSalon)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <p>{t("results.loading")}</p>;
if (error) return <p className="error">{error}</p>;
if (!salon) return null;
return (
<section className="salon-detail">
<h1>{salon.name}</h1>
<p>{salon.description || t("card.noDescription")}</p>
<div className="meta">
<span>{salon.city}</span>
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
</div>
<h2>{t("salon.services")}</h2>
<ul className="service-list">
{salon.services?.map((s) => (
<li key={s.id}>
{s.name} {s.duration_minutes} min, {s.price_amount} {s.currency}
</li>
))}
</ul>
<h2>{t("salon.staff")}</h2>
<ul className="staff-list">
{salon.staff?.map((s) => (
<li key={s.id}>{s.name || s.title || `Staff ${s.id}`}</li>
))}
</ul>
<Link to={`/book?salon=${salon.id}`} className="book-cta">
{t("book.cta")}
</Link>
</section>
);
}
+181
View File
@@ -25,6 +25,42 @@ body {
padding: 48px 24px 80px; padding: 48px 24px 80px;
} }
.main-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 32px;
padding-bottom: 16px;
border-bottom: 1px solid #eadfd2;
}
.main-nav {
display: flex;
gap: 16px;
align-items: center;
}
.nav-brand,
.nav-link {
color: #1c1b1f;
text-decoration: none;
font-weight: 600;
}
.nav-brand:hover,
.nav-link:hover {
text-decoration: underline;
}
.nav-logout {
background: none;
border: none;
cursor: pointer;
font: inherit;
}
.hero { .hero {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -237,6 +273,151 @@ h1 {
font-size: 14px; font-size: 14px;
} }
.card-link {
display: inline-block;
margin-top: 8px;
color: #1c1b1f;
font-weight: 600;
text-decoration: none;
}
.card-link:hover {
text-decoration: underline;
}
.auth-page {
max-width: 400px;
margin: 0 auto;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 24px;
}
.auth-subtitle {
color: #5c5a5f;
margin: 8px 0 0;
}
.auth-actions {
display: flex;
gap: 12px;
}
.auth-back {
background: transparent;
border: 1px solid #dad3ca;
color: #3c3a3f;
}
.auth-loading {
text-align: center;
padding: 48px;
}
.salon-detail {
margin-bottom: 32px;
}
.service-list,
.staff-list {
list-style: none;
padding: 0;
margin: 12px 0;
}
.service-list li,
.staff-list li {
padding: 8px 0;
border-bottom: 1px solid #eadfd2;
}
.book-cta {
display: inline-block;
margin-top: 24px;
padding: 12px 24px;
background: #1c1b1f;
color: white;
font-weight: 600;
text-decoration: none;
border-radius: 999px;
}
.book-cta:hover {
opacity: 0.9;
}
.book-form {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 400px;
margin-top: 24px;
}
.book-salon {
color: #5c5a5f;
margin: 4px 0 0;
}
.bookings-list {
list-style: none;
padding: 0;
margin: 24px 0 0;
}
.booking-card {
background: white;
padding: 20px;
border-radius: 16px;
box-shadow: 0 12px 24px rgba(21, 21, 21, 0.08);
margin-bottom: 16px;
}
.booking-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.booking-status {
text-transform: capitalize;
font-weight: 600;
}
.booking-service,
.booking-time,
.booking-price {
margin: 8px 0 0;
color: #5c5a5f;
}
.booking-pay-link {
display: inline-block;
margin-top: 12px;
font-weight: 600;
color: #1c1b1f;
}
.payment-return {
max-width: 480px;
margin: 0 auto;
text-align: center;
}
.payment-return-id {
font-size: 14px;
color: #5c5a5f;
}
.profile-phone {
margin: 8px 0 16px;
color: #5c5a5f;
}
.error { .error {
color: #b00020; color: #b00020;
} }