Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1da918f95 | |||
| 86fd07c778 | |||
| ca2a6b58b6 |
@@ -3,9 +3,9 @@
|
|||||||
## Project Goal
|
## Project Goal
|
||||||
Build a reliable, maintainable salon booking platform with Django (backend) and React (frontend), optimized for KSA needs (phone auth, local payments) while keeping a clean path to scale.
|
Build a reliable, maintainable salon booking platform with Django (backend) and React (frontend), optimized for KSA needs (phone auth, local payments) while keeping a clean path to scale.
|
||||||
|
|
||||||
## Coding
|
## 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, well‑named functions > monolithic handlers.
|
- Small, well‑named 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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.notifications.models import Notification
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Notification)
|
||||||
|
class NotificationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"event",
|
||||||
|
"channel",
|
||||||
|
"status",
|
||||||
|
"booking",
|
||||||
|
"recipient",
|
||||||
|
"phone_number",
|
||||||
|
"provider",
|
||||||
|
"sent_at",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
list_filter = ("event", "channel", "status", "provider")
|
||||||
|
search_fields = ("phone_number", "message")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.notifications"
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookings", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Notification",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("phone_number", models.CharField(blank=True, max_length=20)),
|
||||||
|
(
|
||||||
|
"channel",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("sms", "SMS"), ("whatsapp", "WhatsApp")],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("booking_created", "Booking Created"),
|
||||||
|
("booking_confirmed", "Booking Confirmed"),
|
||||||
|
("booking_cancelled", "Booking Cancelled"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("sent", "Sent"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
("skipped", "Skipped"),
|
||||||
|
],
|
||||||
|
default="pending",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("provider", models.CharField(blank=True, max_length=50)),
|
||||||
|
("message", models.TextField(blank=True)),
|
||||||
|
("provider_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("error_message", models.TextField(blank=True)),
|
||||||
|
("sent_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"booking",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.deletion.CASCADE,
|
||||||
|
related_name="notifications",
|
||||||
|
to="bookings.booking",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"recipient",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.deletion.SET_NULL,
|
||||||
|
related_name="notifications",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="notification",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("booking", "recipient", "event", "channel"),
|
||||||
|
name="uniq_notification_booking_event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from apps.bookings.models import Booking
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannel(models.TextChoices):
|
||||||
|
SMS = "sms", "SMS"
|
||||||
|
WHATSAPP = "whatsapp", "WhatsApp"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationEvent(models.TextChoices):
|
||||||
|
BOOKING_CREATED = "booking_created", "Booking Created"
|
||||||
|
BOOKING_CONFIRMED = "booking_confirmed", "Booking Confirmed"
|
||||||
|
BOOKING_CANCELLED = "booking_cancelled", "Booking Cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationStatus(models.TextChoices):
|
||||||
|
PENDING = "pending", "Pending"
|
||||||
|
SENT = "sent", "Sent"
|
||||||
|
FAILED = "failed", "Failed"
|
||||||
|
SKIPPED = "skipped", "Skipped"
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(models.Model):
|
||||||
|
booking = models.ForeignKey(
|
||||||
|
Booking,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notifications",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
recipient = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="notifications",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
phone_number = models.CharField(max_length=20, blank=True)
|
||||||
|
channel = models.CharField(max_length=20, choices=NotificationChannel.choices)
|
||||||
|
event = models.CharField(max_length=50, choices=NotificationEvent.choices)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=NotificationStatus.choices,
|
||||||
|
default=NotificationStatus.PENDING,
|
||||||
|
)
|
||||||
|
provider = models.CharField(max_length=50, blank=True)
|
||||||
|
message = models.TextField(blank=True)
|
||||||
|
provider_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
error_message = models.TextField(blank=True)
|
||||||
|
sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["booking", "recipient", "event", "channel"],
|
||||||
|
name="uniq_notification_booking_event",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.event} to {self.phone_number or self.recipient_id}"
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone, translation
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.accounts.services.otp import PROVIDERS as OTP_PROVIDERS
|
||||||
|
from apps.bookings.models import Booking, BookingStatus
|
||||||
|
from apps.notifications.models import (
|
||||||
|
Notification,
|
||||||
|
NotificationChannel,
|
||||||
|
NotificationEvent,
|
||||||
|
NotificationStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NotificationSendResult:
|
||||||
|
status: str
|
||||||
|
payload: dict
|
||||||
|
error_message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_provider():
|
||||||
|
provider_key = getattr(settings, "NOTIFICATION_PROVIDER", settings.OTP_PROVIDER)
|
||||||
|
provider_cls = OTP_PROVIDERS.get(provider_key)
|
||||||
|
if not provider_cls:
|
||||||
|
raise ValueError(_("Unknown notification provider: %(provider)s") % {"provider": provider_key})
|
||||||
|
return provider_cls(), provider_key
|
||||||
|
|
||||||
|
|
||||||
|
def _format_start_time(booking: Booking) -> str:
|
||||||
|
start_local = timezone.localtime(booking.start_time)
|
||||||
|
return start_local.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_message(booking: Booking, event: str) -> str:
|
||||||
|
start_text = _format_start_time(booking)
|
||||||
|
service_name = booking.service.name
|
||||||
|
salon_name = booking.salon.name
|
||||||
|
|
||||||
|
if event == NotificationEvent.BOOKING_CREATED:
|
||||||
|
return _(
|
||||||
|
"Your booking request is received for %(service)s at %(salon)s on %(start)s."
|
||||||
|
) % {
|
||||||
|
"service": service_name,
|
||||||
|
"salon": salon_name,
|
||||||
|
"start": start_text,
|
||||||
|
}
|
||||||
|
if event == NotificationEvent.BOOKING_CONFIRMED:
|
||||||
|
return _(
|
||||||
|
"Your booking is confirmed for %(service)s at %(salon)s on %(start)s."
|
||||||
|
) % {
|
||||||
|
"service": service_name,
|
||||||
|
"salon": salon_name,
|
||||||
|
"start": start_text,
|
||||||
|
}
|
||||||
|
if event == NotificationEvent.BOOKING_CANCELLED:
|
||||||
|
return _(
|
||||||
|
"Your booking was cancelled for %(service)s at %(salon)s on %(start)s."
|
||||||
|
) % {
|
||||||
|
"service": service_name,
|
||||||
|
"salon": salon_name,
|
||||||
|
"start": start_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
return _("Booking update for %(service)s at %(salon)s on %(start)s.") % {
|
||||||
|
"service": service_name,
|
||||||
|
"salon": salon_name,
|
||||||
|
"start": start_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _send_message(phone_number: str, channel: str, message: str) -> NotificationSendResult:
|
||||||
|
provider, _ = _get_provider()
|
||||||
|
try:
|
||||||
|
if channel == NotificationChannel.SMS:
|
||||||
|
provider.send_sms(phone_number, message)
|
||||||
|
elif channel == NotificationChannel.WHATSAPP:
|
||||||
|
provider.send_whatsapp(phone_number, message)
|
||||||
|
else:
|
||||||
|
raise ValueError(_("Unsupported notification channel"))
|
||||||
|
except Exception as exc: # pragma: no cover - provider failures are environment specific
|
||||||
|
return NotificationSendResult(status=NotificationStatus.FAILED, payload={}, error_message=str(exc))
|
||||||
|
|
||||||
|
return NotificationSendResult(status=NotificationStatus.SENT, payload={}, error_message="")
|
||||||
|
|
||||||
|
|
||||||
|
def _notification_channel() -> str:
|
||||||
|
return getattr(settings, "NOTIFICATION_DEFAULT_CHANNEL", NotificationChannel.SMS)
|
||||||
|
|
||||||
|
|
||||||
|
def send_booking_notification(booking: Booking, recipient, event: str) -> Notification:
|
||||||
|
channel = _notification_channel()
|
||||||
|
phone_number = getattr(recipient, "phone_number", None) or ""
|
||||||
|
|
||||||
|
# Render the message in the recipient's preferred language.
|
||||||
|
with translation.override(getattr(recipient, "preferred_language", None)):
|
||||||
|
message = _build_message(booking, event)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
notification, created = Notification.objects.get_or_create(
|
||||||
|
booking=booking,
|
||||||
|
recipient=recipient,
|
||||||
|
event=event,
|
||||||
|
channel=channel,
|
||||||
|
defaults={
|
||||||
|
"phone_number": phone_number,
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created and notification.status == NotificationStatus.SENT:
|
||||||
|
return notification
|
||||||
|
|
||||||
|
if not phone_number:
|
||||||
|
# Record the skip for auditability when we cannot deliver.
|
||||||
|
notification.status = NotificationStatus.SKIPPED
|
||||||
|
notification.error_message = "Recipient has no phone number"
|
||||||
|
notification.save(update_fields=["status", "error_message"])
|
||||||
|
return notification
|
||||||
|
|
||||||
|
notification.phone_number = phone_number
|
||||||
|
notification.message = message
|
||||||
|
send_result = _send_message(phone_number, channel, message)
|
||||||
|
notification.status = send_result.status
|
||||||
|
notification.provider = getattr(settings, "NOTIFICATION_PROVIDER", settings.OTP_PROVIDER)
|
||||||
|
notification.provider_payload = send_result.payload
|
||||||
|
notification.error_message = send_result.error_message
|
||||||
|
notification.sent_at = timezone.now() if send_result.status == NotificationStatus.SENT else None
|
||||||
|
notification.save(
|
||||||
|
update_fields=[
|
||||||
|
"phone_number",
|
||||||
|
"message",
|
||||||
|
"status",
|
||||||
|
"provider",
|
||||||
|
"provider_payload",
|
||||||
|
"error_message",
|
||||||
|
"sent_at",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return notification
|
||||||
|
|
||||||
|
|
||||||
|
def notify_booking_lifecycle(booking: Booking, event: str) -> list[Notification]:
|
||||||
|
recipients = [booking.customer]
|
||||||
|
if booking.staff and booking.staff.user:
|
||||||
|
recipients.append(booking.staff.user)
|
||||||
|
|
||||||
|
notifications = []
|
||||||
|
for recipient in recipients:
|
||||||
|
notifications.append(send_booking_notification(booking, recipient, event))
|
||||||
|
|
||||||
|
return notifications
|
||||||
|
|
||||||
|
|
||||||
|
def notify_on_status_change(booking: Booking, previous_status: str) -> list[Notification]:
|
||||||
|
if booking.status == previous_status:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Only notify for lifecycle transitions we explicitly support today.
|
||||||
|
if booking.status == BookingStatus.CONFIRMED:
|
||||||
|
return notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CONFIRMED)
|
||||||
|
if booking.status == BookingStatus.CANCELLED:
|
||||||
|
return notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CANCELLED)
|
||||||
|
|
||||||
|
return []
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.accounts.models import User, UserRole
|
||||||
|
from apps.bookings.models import Booking, BookingStatus
|
||||||
|
from apps.notifications.models import Notification, NotificationEvent, NotificationStatus
|
||||||
|
from apps.salons.models import Salon, Service, StaffProfile
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def booking_payload():
|
||||||
|
owner = User.objects.create_user(
|
||||||
|
email="owner@example.com",
|
||||||
|
password="pass",
|
||||||
|
role=UserRole.MANAGER,
|
||||||
|
phone_number="0500000001",
|
||||||
|
)
|
||||||
|
customer = User.objects.create_user(
|
||||||
|
email="customer@example.com",
|
||||||
|
password="pass",
|
||||||
|
phone_number="0500000002",
|
||||||
|
)
|
||||||
|
staff_user = User.objects.create_user(
|
||||||
|
email="staff@example.com",
|
||||||
|
password="pass",
|
||||||
|
role=UserRole.STAFF,
|
||||||
|
phone_number="0500000003",
|
||||||
|
)
|
||||||
|
|
||||||
|
salon = Salon.objects.create(
|
||||||
|
owner=owner,
|
||||||
|
name="Main Salon",
|
||||||
|
description="",
|
||||||
|
address="123 King Rd",
|
||||||
|
city="Riyadh",
|
||||||
|
phone_number="0512345678",
|
||||||
|
)
|
||||||
|
service = Service.objects.create(
|
||||||
|
salon=salon,
|
||||||
|
name="Haircut",
|
||||||
|
description="",
|
||||||
|
duration_minutes=60,
|
||||||
|
price_amount=120,
|
||||||
|
currency="SAR",
|
||||||
|
)
|
||||||
|
staff = StaffProfile.objects.create(user=staff_user, salon=salon)
|
||||||
|
|
||||||
|
start_time = timezone.now() + timedelta(days=1)
|
||||||
|
end_time = start_time + timedelta(minutes=60)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"customer": customer,
|
||||||
|
"staff_user": staff_user,
|
||||||
|
"service": service,
|
||||||
|
"staff": staff,
|
||||||
|
"payload": {
|
||||||
|
"service": service.id,
|
||||||
|
"staff": staff.id,
|
||||||
|
"start_time": start_time.isoformat(),
|
||||||
|
"end_time": end_time.isoformat(),
|
||||||
|
"notes": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_booking_create_sends_notifications(booking_payload):
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=booking_payload["customer"])
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
reverse("booking-list"),
|
||||||
|
booking_payload["payload"],
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
notifications = Notification.objects.filter(event=NotificationEvent.BOOKING_CREATED)
|
||||||
|
assert notifications.count() == 2
|
||||||
|
assert all(notification.status == NotificationStatus.SENT for notification in notifications)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_booking_status_change_sends_notifications_once(booking_payload):
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=booking_payload["customer"])
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
reverse("booking-list"),
|
||||||
|
booking_payload["payload"],
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
booking_id = Booking.objects.get(customer=booking_payload["customer"]).id
|
||||||
|
update_payload = {"status": BookingStatus.CONFIRMED}
|
||||||
|
|
||||||
|
client.force_authenticate(user=booking_payload["staff_user"])
|
||||||
|
response_update = client.patch(
|
||||||
|
reverse("booking-detail", args=[booking_id]),
|
||||||
|
update_payload,
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response_update.status_code == 200
|
||||||
|
|
||||||
|
notifications = Notification.objects.filter(event=NotificationEvent.BOOKING_CONFIRMED)
|
||||||
|
assert notifications.count() == 2
|
||||||
|
|
||||||
|
response_repeat = client.patch(
|
||||||
|
reverse("booking-detail", args=[booking_id]),
|
||||||
|
update_payload,
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response_repeat.status_code == 200
|
||||||
|
|
||||||
|
notifications_repeat = Notification.objects.filter(event=NotificationEvent.BOOKING_CONFIRMED)
|
||||||
|
assert notifications_repeat.count() == 2
|
||||||
@@ -33,10 +33,12 @@ class BasePaymentGateway:
|
|||||||
) -> PaymentInitResult:
|
) -> PaymentInitResult:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def capture_payment(self, external_id: str) -> None:
|
def capture_payment(self, external_id: str, amount: Optional[int] = None) -> None:
|
||||||
|
"""Capture an authorized payment. Amount in minor units; omit for full capture."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None:
|
def refund_payment(self, external_id: str, amount: Optional[int] = None) -> None:
|
||||||
|
"""Refund a paid/captured payment. Amount in minor units; omit for full refund."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
@@ -101,10 +103,36 @@ class MoyasarGateway(BasePaymentGateway):
|
|||||||
payload=data,
|
payload=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
def capture_payment(self, external_id: str) -> None:
|
def capture_payment(self, external_id: str, amount: Optional[int] = None) -> None:
|
||||||
|
"""Capture an authorized payment. Amount in minor units; omit for full capture."""
|
||||||
self._assert_config()
|
self._assert_config()
|
||||||
raise NotImplementedError("Moyasar capture not implemented yet")
|
url = f"{self.base_url}/v1/payments/{external_id}/capture"
|
||||||
|
payload = {} if amount is None else {"amount": amount}
|
||||||
|
try:
|
||||||
|
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise PaymentGatewayError("Failed to reach Moyasar for capture") from exc
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
data = response.json() if response.content else {}
|
||||||
|
raise PaymentGatewayError(
|
||||||
|
"Moyasar capture failed",
|
||||||
|
status_code=response.status_code,
|
||||||
|
payload=data,
|
||||||
|
)
|
||||||
|
|
||||||
def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None:
|
def refund_payment(self, external_id: str, amount: Optional[int] = None) -> None:
|
||||||
|
"""Refund a paid/captured payment. Amount in minor units; omit for full refund."""
|
||||||
self._assert_config()
|
self._assert_config()
|
||||||
raise NotImplementedError("Moyasar refund not implemented yet")
|
url = f"{self.base_url}/v1/payments/{external_id}/refund"
|
||||||
|
payload = {} if amount is None else {"amount": amount}
|
||||||
|
try:
|
||||||
|
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise PaymentGatewayError("Failed to reach Moyasar for refund") from exc
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
data = response.json() if response.content else {}
|
||||||
|
raise PaymentGatewayError(
|
||||||
|
"Moyasar refund failed",
|
||||||
|
status_code=response.status_code,
|
||||||
|
payload=data,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Tests for Moyasar capture and refund gateway methods."""
|
||||||
|
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from apps.payments.services.gateway import MoyasarGateway, PaymentGatewayError
|
||||||
|
|
||||||
|
|
||||||
|
@patch("apps.payments.services.gateway.requests.post")
|
||||||
|
def test_moyasar_capture_calls_api(mock_post):
|
||||||
|
mock_post.return_value = Mock(status_code=200, content=b"{}", json=lambda: {"id": "pay_1", "status": "captured"})
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {
|
||||||
|
"MOYASAR_SECRET_KEY": "sk_test",
|
||||||
|
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
|
||||||
|
}):
|
||||||
|
gateway = MoyasarGateway()
|
||||||
|
gateway.capture_payment("pay_1")
|
||||||
|
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
call_args = mock_post.call_args
|
||||||
|
assert "pay_1/capture" in call_args[0][0]
|
||||||
|
assert call_args[1]["auth"] == ("sk_test", "")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("apps.payments.services.gateway.requests.post")
|
||||||
|
def test_moyasar_refund_calls_api(mock_post):
|
||||||
|
mock_post.return_value = Mock(status_code=200, content=b"{}", json=lambda: {"id": "pay_1", "status": "refunded"})
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {
|
||||||
|
"MOYASAR_SECRET_KEY": "sk_test",
|
||||||
|
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
|
||||||
|
}):
|
||||||
|
gateway = MoyasarGateway()
|
||||||
|
gateway.refund_payment("pay_1")
|
||||||
|
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
call_args = mock_post.call_args
|
||||||
|
assert "pay_1/refund" in call_args[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
@patch("apps.payments.services.gateway.requests.post")
|
||||||
|
def test_moyasar_capture_raises_on_error(mock_post):
|
||||||
|
mock_post.return_value = Mock(status_code=400, content=b'{"message":"Invalid"}', json=lambda: {"message": "Invalid"})
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {
|
||||||
|
"MOYASAR_SECRET_KEY": "sk_test",
|
||||||
|
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
|
||||||
|
}):
|
||||||
|
gateway = MoyasarGateway()
|
||||||
|
with pytest.raises(PaymentGatewayError) as exc_info:
|
||||||
|
gateway.capture_payment("pay_1")
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
@@ -5,3 +5,4 @@ django-cors-headers>=4.3
|
|||||||
psycopg[binary]>=3.1
|
psycopg[binary]>=3.1
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
requests>=2.31
|
requests>=2.31
|
||||||
|
twilio>=9.0
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Booking Lifecycle Notifications (SMS/WhatsApp)
|
||||||
|
|
||||||
|
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
|
||||||
|
|
||||||
|
The requirements for ExecPlans live in `PLANS.md` at the repository root. This document must be maintained in accordance with that file.
|
||||||
|
|
||||||
|
## Purpose / Big Picture
|
||||||
|
|
||||||
|
After this change, a booking will automatically notify the customer and the assigned staff member when it is created, confirmed, or cancelled. You can see it working by creating a booking and observing two notification records (customer + staff), then changing the booking status to confirmed or cancelled and seeing two more notification records for that event. In the console provider, the messages are logged, giving an immediate, user-visible trace of the booking lifecycle.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] (2026-02-28 17:05Z) Created ExecPlan for booking lifecycle notifications and reviewed bookings + notifications gaps.
|
||||||
|
- [x] (2026-02-28 17:30Z) Implemented notifications app with audit-friendly model, providers, and booking message templates.
|
||||||
|
- [x] (2026-02-28 17:40Z) Connected booking create/update flows to notification dispatch with idempotent event handling.
|
||||||
|
- [x] (2026-02-28 17:55Z) Allowed booking status updates with role checks to enable confirmation/cancellation.
|
||||||
|
- [x] (2026-02-28 18:05Z) Added tests for booking notifications (create, status change, no duplicate sends).
|
||||||
|
- [x] (2026-02-28 18:10Z) Updated `docs/risks.md` and validated tests (`python3 -m pytest`).
|
||||||
|
|
||||||
|
## Surprises & Discoveries
|
||||||
|
|
||||||
|
- Observation: Booking status updates were blocked because `status` was read-only on the default booking serializer.
|
||||||
|
Evidence: `PATCH /api/bookings/<id>` returned HTTP 400 when attempting to confirm.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
- Decision: Store every booking notification in a dedicated `Notification` model for auditability, even when skipped.
|
||||||
|
Rationale: Lifecycle messages are user-facing and must be traceable for support and compliance.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
|
- Decision: Reuse existing OTP provider adapters for SMS/WhatsApp delivery, with a new `NOTIFICATION_PROVIDER` setting.
|
||||||
|
Rationale: Avoid duplicate integration code while still allowing independent provider configuration.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
|
- Decision: Default to SMS for booking notifications and use the recipient’s preferred language when formatting messages.
|
||||||
|
Rationale: SMS is the most reliable baseline in KSA, and language preference is already captured on the user.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
|
- Decision: Allow booking status changes via `BookingSerializer` with role-based validation.
|
||||||
|
Rationale: Confirmation/cancellation must be reachable through the existing API, but should still respect basic role boundaries.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
|
|
||||||
|
## Outcomes & Retrospective
|
||||||
|
|
||||||
|
Booking lifecycle notifications are now implemented with audit-friendly records and idempotent sending. Booking creation and status changes (confirmed/cancelled) trigger SMS/WhatsApp notifications for both customer and staff, and role-based validation now governs status updates. Provider adapters remain scaffolds, so production delivery still requires real SMS/WhatsApp wiring.
|
||||||
|
|
||||||
|
## Context and Orientation
|
||||||
|
|
||||||
|
Booking creation and updates are handled in `backend/apps/bookings/views.py` via a DRF `ModelViewSet`. The booking model is in `backend/apps/bookings/models.py`, with `status` indicating lifecycle state. There is currently no notification system beyond OTP scaffolding in `backend/apps/accounts/services/otp.py`. This plan adds a new Django app at `backend/apps/notifications/` to store notification records, format booking lifecycle messages, and dispatch them via SMS or WhatsApp providers.
|
||||||
|
|
||||||
|
A “notification” in this repository means a user-facing message (SMS or WhatsApp) that is stored for auditability in a `Notification` database row. A “lifecycle event” is a booking change that should inform the customer and staff: booking created, confirmed, or cancelled.
|
||||||
|
|
||||||
|
## Plan of Work
|
||||||
|
|
||||||
|
First, create a `notifications` Django app with models and admin registration. Define `Notification`, `NotificationEvent`, `NotificationStatus`, and `NotificationChannel` in `backend/apps/notifications/models.py`. The model must capture booking, recipient, phone number, event, channel, status, provider, message, and send timestamps, and it must be idempotent by preventing duplicates for the same booking + recipient + event + channel. Register the model in `backend/apps/notifications/admin.py` and add `apps.notifications` to `INSTALLED_APPS` in `backend/salon_api/settings.py`.
|
||||||
|
|
||||||
|
Next, implement notification dispatch in `backend/apps/notifications/services.py`. Reuse OTP provider adapters from `apps.accounts.services.otp` with a new `NOTIFICATION_PROVIDER` setting (default to `OTP_PROVIDER`). Add a `NOTIFICATION_DEFAULT_CHANNEL` setting (default `sms`). Implement `send_booking_notification(booking, recipient, event)` to build localized message text using the recipient’s preferred language, send via the provider, and update the notification status. Implement `notify_booking_lifecycle(booking, event)` for initial sends and `notify_on_status_change(booking, previous_status)` to trigger only on status transitions. If the recipient lacks a phone number, record the notification as `skipped` with a reason.
|
||||||
|
|
||||||
|
Then, wire booking lifecycle events in `backend/apps/bookings/views.py`. On `perform_create`, call `notify_booking_lifecycle(..., booking_created)` so both customer and staff receive a message. On `perform_update`, compare the previous status to the new status and call `notify_on_status_change` for confirmed or cancelled transitions. Avoid sending notifications if the status does not change.
|
||||||
|
|
||||||
|
Finally, add tests in `backend/apps/notifications/tests/test_booking_notifications.py`. Cover booking creation (two notifications), status change to confirmed (two notifications), and a repeat status update that should not create duplicates. Ensure tests use phone numbers on users to avoid skipped notifications. Update `docs/risks.md` to mark “No notifications (email/SMS) beyond OTP scaffolding” as addressed once tests pass.
|
||||||
|
|
||||||
|
## Concrete Steps
|
||||||
|
|
||||||
|
Run these commands from the repository root (`/home/m7md/kshkool/Salon`).
|
||||||
|
|
||||||
|
1. Add notifications app code and migrations.
|
||||||
|
- Create `backend/apps/notifications/` with `apps.py`, `models.py`, `services.py`, `admin.py`, and a migration `0001_initial.py`.
|
||||||
|
- Update `backend/salon_api/settings.py` to include `apps.notifications` and notification settings.
|
||||||
|
|
||||||
|
2. Wire booking lifecycle events.
|
||||||
|
- Update `backend/apps/bookings/views.py` to call notification services on create and status changes.
|
||||||
|
|
||||||
|
3. Add tests.
|
||||||
|
- Create `backend/apps/notifications/tests/test_booking_notifications.py`.
|
||||||
|
|
||||||
|
4. Run backend tests.
|
||||||
|
- From `backend/` with the venv active:
|
||||||
|
python3 -m pytest
|
||||||
|
|
||||||
|
## Validation and Acceptance
|
||||||
|
|
||||||
|
- Creating a booking returns HTTP 201 and creates two notification records (customer + staff) with event `booking_created`.
|
||||||
|
- Updating a booking’s status to `confirmed` creates two notification records with event `booking_confirmed`.
|
||||||
|
- Repeating the same status update does not create duplicate notifications (records remain at two for that event).
|
||||||
|
- `python3 -m pytest` passes, and the new tests fail before the change and pass after.
|
||||||
|
|
||||||
|
## Idempotence and Recovery
|
||||||
|
|
||||||
|
Notification creation is idempotent by a uniqueness constraint on booking + recipient + event + channel. Re-running the send logic will update a pending or failed notification rather than creating duplicates. If a migration needs to be reverted, use standard Django migration rollback and re-apply. If a notification provider is misconfigured, notifications will be marked failed and can be retried after fixing settings.
|
||||||
|
|
||||||
|
## Artifacts and Notes
|
||||||
|
|
||||||
|
Expected console-provider log example when creating a booking:
|
||||||
|
|
||||||
|
INFO OTP SMS to 0500000002: Your booking request is received for Haircut at Main Salon on 2026-03-01 10:00.
|
||||||
|
INFO OTP SMS to 0500000003: Your booking request is received for Haircut at Main Salon on 2026-03-01 10:00.
|
||||||
|
|
||||||
|
## Interfaces and Dependencies
|
||||||
|
|
||||||
|
- `backend/apps/notifications/models.py` must define `Notification`, `NotificationEvent`, `NotificationStatus`, `NotificationChannel`.
|
||||||
|
- `backend/apps/notifications/services.py` must expose `send_booking_notification`, `notify_booking_lifecycle`, and `notify_on_status_change`.
|
||||||
|
- `backend/apps/bookings/views.py` must call notification services in `perform_create` and `perform_update`.
|
||||||
|
- `backend/salon_api/settings.py` must define `NOTIFICATION_PROVIDER` and `NOTIFICATION_DEFAULT_CHANNEL` settings.
|
||||||
|
|
||||||
|
Plan Maintenance Note: Created on 2026-02-28 to implement booking lifecycle notifications as the next Phase 1 reliability milestone.
|
||||||
|
Plan Maintenance Note (Update): Marked milestones complete, recorded the booking status update discovery, and documented role-based status validation after implementing notifications and tests on 2026-02-28.
|
||||||
+3
-3
@@ -5,7 +5,7 @@ This file tracks known gaps and risks to address in future iterations.
|
|||||||
## Security And Auth
|
## Security And Auth
|
||||||
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
|
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
|
||||||
- OTP protections are basic; add device fingerprinting and IP throttling if needed.
|
- OTP protections are basic; add device fingerprinting and IP throttling if needed.
|
||||||
- OTP provider adapters (Twilio/Unifonic) are scaffolds only; no real SMS/WhatsApp delivery yet.
|
- Twilio OTP provider is implemented (SMS + WhatsApp); Unifonic remains a scaffold.
|
||||||
- Social login is a placeholder.
|
- Social login is a placeholder.
|
||||||
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows.
|
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows.
|
||||||
|
|
||||||
@@ -16,12 +16,12 @@ This file tracks known gaps and risks to address in future iterations.
|
|||||||
|
|
||||||
## Payments
|
## Payments
|
||||||
- Moyasar payment creation, webhook reconciliation, and idempotency are implemented.
|
- Moyasar payment creation, webhook reconciliation, and idempotency are implemented.
|
||||||
- Refund/capture operations are not implemented yet if required.
|
- Moyasar capture and refund are implemented in the gateway; API endpoints for admin-initiated capture/refund can be added when needed.
|
||||||
|
|
||||||
## Data And UX
|
## Data And UX
|
||||||
- Ratings are not recalculated from reviews.
|
- Ratings are not recalculated from reviews.
|
||||||
- No image upload or storage strategy for photos.
|
- No image upload or storage strategy for photos.
|
||||||
- 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
|
||||||
|
|||||||
@@ -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 Moyasar’s hosted flow, then shows a status page.
|
||||||
|
- Handle callback/return from Moyasar (even if via manual redirect URL in MVP) and surface payment success/failure to the user.
|
||||||
|
- **Booking lifecycle notifications**
|
||||||
|
- Backend already sends notifications on booking create and status changes; align messaging templates with product UX and ensure localization strings exist.
|
||||||
|
- Frontend: surface notification results implicitly via booking status changes and explicit messages on the booking details page.
|
||||||
|
- **Localization foundations**
|
||||||
|
- Backend: ensure `UserLocaleMiddleware` and translation strings cover all user-visible errors in auth, bookings, payments, and notifications.
|
||||||
|
- Frontend: expand `en/ar-sa` translations to cover auth, booking, and payment flows; verify RTL layouts on the new screens.
|
||||||
|
- **Tests for critical flows**
|
||||||
|
- Backend: extend tests where needed to cover new booking/payment edge cases (e.g., tying booking status to payment status if/when introduced).
|
||||||
|
- Frontend: add Vitest tests for:
|
||||||
|
- Phone auth screen flows (request/verify success + errors).
|
||||||
|
- Booking flow (form validation, happy path, displaying server-side errors).
|
||||||
|
- Payment initiation from an existing booking.
|
||||||
|
|
||||||
|
### Phase 2 – Manager Ops Lite (Post-MVP, partially covered now)
|
||||||
|
|
||||||
|
- **Salon and staff management UI**
|
||||||
|
- Use existing salon and staff models to build basic management pages for salon owners/managers (create/update services, staff, availability).
|
||||||
|
- **Calendar views and rescheduling**
|
||||||
|
- Provide calendar views for staff/managers to view daily/weekly bookings and reschedule or cancel within defined rules.
|
||||||
|
- **Reviews and ratings**
|
||||||
|
- Implement review submission and rating recalculation on the backend, with corresponding frontend components.
|
||||||
|
- **Reporting basics**
|
||||||
|
- Lightweight reports for managers (upcoming bookings, simple revenue summaries based on payment status) using existing payments data.
|
||||||
|
|
||||||
|
### Phase 3 – Scale & Compliance (Later)
|
||||||
|
|
||||||
|
- **Audit logging** for admin actions and booking/payment state changes.
|
||||||
|
- **PDPL/GDPR retention policies** and data export tooling.
|
||||||
|
- **Observability**: structured logging, metrics, and basic dashboards for auth failures, OTP send failures, payment errors, and notification outcomes.
|
||||||
|
|
||||||
|
## Architecture Overview Diagram
|
||||||
|
|
||||||
|
A simplified view of the target MVP data flow:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
user["User (web/mobile)"] --> frontend["ReactFrontend"]
|
||||||
|
frontend --> api["DjangoAPI"]
|
||||||
|
|
||||||
|
api --> accounts["AccountsApp"]
|
||||||
|
api --> salons["SalonsApp"]
|
||||||
|
api --> bookings["BookingsApp"]
|
||||||
|
api --> payments["PaymentsApp"]
|
||||||
|
api --> notifications["NotificationsApp"]
|
||||||
|
|
||||||
|
accounts --> otpProviders["OtpProviders"]
|
||||||
|
notifications --> otpProviders
|
||||||
|
payments --> moyasar["MoyasarGateway"]
|
||||||
|
|
||||||
|
bookings --> notifications
|
||||||
|
bookings --> payments
|
||||||
|
salons --> bookings
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This diagram clarifies current coupling and highlights where future refactors (e.g., a dedicated messaging service or payment orchestrator) could sit.
|
||||||
|
|
||||||
|
## Validation And Acceptance For This Plan
|
||||||
|
|
||||||
|
- The roadmap is accepted when:
|
||||||
|
- It clearly maps current backend and frontend capabilities to the Phase 1 MVP goals in `AGENTS.md`.
|
||||||
|
- It identifies the highest-risk design/architecture issues that could impede MVP reliability or future evolution.
|
||||||
|
- It provides a phased, concrete sequence of work packages that can be implemented via ExecPlans (e.g., booking notifications, Moyasar payments, phone auth UX).
|
||||||
|
- Each major feature area (auth, bookings, payments, notifications, localization, tests) should have or adopt an ExecPlan under `docs/execplans/` in line with `PLANS.md` before implementation begins.
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Frontend Notes (MVP Readiness)
|
||||||
|
|
||||||
|
## High-Level Takeaways
|
||||||
|
- `App.jsx` is monolithic and mixes search, payments, and locale controls; no routing exists yet.
|
||||||
|
- Domain logic (API payloads, validation, error handling) lives in UI components instead of hooks/services.
|
||||||
|
- Tests only cover hero copy and RTL behavior; search and payment flows are untested.
|
||||||
|
- Global styles are fragile (likely `::root` typo instead of `:root`).
|
||||||
|
- Auth token handling is ad hoc and should be replaced with a proper auth flow/context.
|
||||||
|
|
||||||
|
## Near-Term Focus
|
||||||
|
- Introduce routing and split into pages (home/search, auth, booking, payment, profile).
|
||||||
|
- Extract API logic into hooks/services to make testing and reuse easier.
|
||||||
|
- Add Vitest coverage for search, booking, and payment flows.
|
||||||
|
- Fix global CSS root selector and stabilize base layout styles.
|
||||||
Generated
+59
-1
@@ -11,7 +11,8 @@
|
|||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-i18next": "^14.1.0"
|
"react-i18next": "^14.1.0",
|
||||||
|
"react-router-dom": "^7.13.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.4.2",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
@@ -1950,6 +1951,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3578,6 +3592,44 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||||
|
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||||
|
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.13.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -3729,6 +3781,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
|
|||||||
@@ -13,12 +13,13 @@
|
|||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-i18next": "^14.1.0"
|
"react-i18next": "^14.1.0",
|
||||||
|
"react-router-dom": "^7.13.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
|
||||||
"@testing-library/jest-dom": "^6.4.2",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
"@testing-library/react": "^14.2.1",
|
"@testing-library/react": "^14.2.1",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
"vitest": "^1.3.1"
|
"vitest": "^1.3.1"
|
||||||
|
|||||||
+24
-240
@@ -1,245 +1,29 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import MainLayout from "./layouts/MainLayout";
|
||||||
import { apiGet, apiPost } from "./api/client";
|
import HomePage from "./pages/HomePage";
|
||||||
import { setLocale } from "./i18n";
|
import BookPage from "./pages/BookPage";
|
||||||
|
import PaymentPage from "./pages/PaymentPage";
|
||||||
|
import ProfilePage from "./pages/ProfilePage";
|
||||||
|
import BookingsPage from "./pages/BookingsPage";
|
||||||
|
import LoginPage from "./pages/LoginPage";
|
||||||
|
import SalonDetailPage from "./pages/SalonDetailPage";
|
||||||
|
import PaymentReturnPage from "./pages/PaymentReturnPage";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [salons, setSalons] = useState([]);
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [status, setStatus] = useState("idle");
|
|
||||||
const [paymentBookingId, setPaymentBookingId] = useState("");
|
|
||||||
const [paymentToken, setPaymentToken] = useState(() => localStorage.getItem("auth_token") || "");
|
|
||||||
const [paymentSourceType, setPaymentSourceType] = useState("stcpay");
|
|
||||||
const [paymentSourceValue, setPaymentSourceValue] = useState("");
|
|
||||||
const [paymentCallbackUrl, setPaymentCallbackUrl] = useState("");
|
|
||||||
const [paymentStatus, setPaymentStatus] = useState("idle");
|
|
||||||
const [paymentResult, setPaymentResult] = useState(null);
|
|
||||||
const [paymentError, setPaymentError] = useState("");
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
|
|
||||||
const idempotencyKey = useMemo(() => {
|
|
||||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return `id-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem("auth_token", paymentToken);
|
|
||||||
}, [paymentToken]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let ignore = false;
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
setStatus("loading");
|
|
||||||
try {
|
|
||||||
const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`);
|
|
||||||
if (!ignore) {
|
|
||||||
setSalons(data);
|
|
||||||
setStatus("ready");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!ignore) {
|
|
||||||
setStatus("error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
return () => {
|
|
||||||
ignore = true;
|
|
||||||
};
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
async function handlePaymentSubmit(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
setPaymentStatus("loading");
|
|
||||||
setPaymentError("");
|
|
||||||
setPaymentResult(null);
|
|
||||||
|
|
||||||
if (!paymentBookingId) {
|
|
||||||
setPaymentStatus("error");
|
|
||||||
setPaymentError(t("payment.errors.bookingRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = { type: paymentSourceType };
|
|
||||||
if (paymentSourceType === "stcpay") {
|
|
||||||
if (!paymentSourceValue) {
|
|
||||||
setPaymentStatus("error");
|
|
||||||
setPaymentError(t("payment.errors.mobileRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
source.mobile = paymentSourceValue;
|
|
||||||
}
|
|
||||||
if (paymentSourceType === "token") {
|
|
||||||
if (!paymentSourceValue) {
|
|
||||||
setPaymentStatus("error");
|
|
||||||
setPaymentError(t("payment.errors.tokenRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
source.token = paymentSourceValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
booking_id: Number(paymentBookingId),
|
|
||||||
provider: "moyasar",
|
|
||||||
idempotency_key: idempotencyKey,
|
|
||||||
source,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (paymentCallbackUrl) {
|
|
||||||
payload.callback_url = paymentCallbackUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await apiPost("/payments/", payload, paymentToken);
|
|
||||||
setPaymentResult(data);
|
|
||||||
setPaymentStatus("ready");
|
|
||||||
if (data?.redirect_url) {
|
|
||||||
window.location.assign(data.redirect_url);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setPaymentStatus("error");
|
|
||||||
setPaymentError(error.message || t("payment.errors.generic"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<BrowserRouter>
|
||||||
<header className="hero">
|
<Routes>
|
||||||
<div className="hero-top">
|
<Route path="/" element={<MainLayout />}>
|
||||||
<p className="eyebrow">{t("hero.eyebrow")}</p>
|
<Route index element={<HomePage />} />
|
||||||
<div className="locale-switch" aria-label={t("locale.label")}>
|
<Route path="salon/:id" element={<SalonDetailPage />} />
|
||||||
<button
|
<Route path="book" element={<BookPage />} />
|
||||||
type="button"
|
<Route path="pay" element={<PaymentPage />} />
|
||||||
className={i18n.language === "ar-sa" ? "active" : ""}
|
<Route path="pay/return" element={<PaymentReturnPage />} />
|
||||||
onClick={() => setLocale("ar-sa")}
|
<Route path="bookings" element={<BookingsPage />} />
|
||||||
>
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
{t("locale.arabic")}
|
<Route path="login" element={<LoginPage />} />
|
||||||
</button>
|
</Route>
|
||||||
<button
|
</Routes>
|
||||||
type="button"
|
</BrowserRouter>
|
||||||
className={i18n.language === "en" ? "active" : ""}
|
|
||||||
onClick={() => setLocale("en")}
|
|
||||||
>
|
|
||||||
{t("locale.english")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1>{t("hero.title")}</h1>
|
|
||||||
<p className="subtitle">{t("hero.subtitle")}</p>
|
|
||||||
<div className="search">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={t("hero.searchPlaceholder")}
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="results">
|
|
||||||
<h2>{t("results.title")}</h2>
|
|
||||||
{status === "loading" && <p>{t("results.loading")}</p>}
|
|
||||||
{status === "error" && (
|
|
||||||
<p className="error">{t("results.error")}</p>
|
|
||||||
)}
|
|
||||||
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
|
|
||||||
<div className="grid">
|
|
||||||
{salons.map((salon) => (
|
|
||||||
<article className="card" key={salon.id}>
|
|
||||||
<div className="card-header">
|
|
||||||
<h3>{salon.name}</h3>
|
|
||||||
<span className="rating">{salon.rating_avg} / 5</span>
|
|
||||||
</div>
|
|
||||||
<p>{salon.description || t("card.noDescription")}</p>
|
|
||||||
<div className="meta">
|
|
||||||
<span>{salon.city}</span>
|
|
||||||
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="payments">
|
|
||||||
<div className="payments-header">
|
|
||||||
<div>
|
|
||||||
<h2>{t("payment.title")}</h2>
|
|
||||||
<p className="payments-subtitle">{t("payment.subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
<span className="payments-badge">{t("payment.badge")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="payments-form" onSubmit={handlePaymentSubmit}>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("payment.bookingId")}</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={paymentBookingId}
|
|
||||||
onChange={(event) => setPaymentBookingId(event.target.value)}
|
|
||||||
placeholder="123"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("payment.accessToken")}</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={paymentToken}
|
|
||||||
onChange={(event) => setPaymentToken(event.target.value)}
|
|
||||||
placeholder={t("payment.accessTokenPlaceholder")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("payment.sourceType")}</span>
|
|
||||||
<select
|
|
||||||
value={paymentSourceType}
|
|
||||||
onChange={(event) => setPaymentSourceType(event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="stcpay">{t("payment.sources.stcpay")}</option>
|
|
||||||
<option value="token">{t("payment.sources.token")}</option>
|
|
||||||
<option value="applepay">{t("payment.sources.applepay")}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("payment.sourceValue")}</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={paymentSourceValue}
|
|
||||||
onChange={(event) => setPaymentSourceValue(event.target.value)}
|
|
||||||
placeholder={t("payment.sourceValuePlaceholder")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("payment.callbackUrl")}</span>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={paymentCallbackUrl}
|
|
||||||
onChange={(event) => setPaymentCallbackUrl(event.target.value)}
|
|
||||||
placeholder="https://example.com/payments/return"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="payments-actions">
|
|
||||||
<button type="submit" disabled={paymentStatus === "loading"}>
|
|
||||||
{paymentStatus === "loading" ? t("payment.processing") : t("payment.payNow")}
|
|
||||||
</button>
|
|
||||||
<p className="helper">
|
|
||||||
{t("payment.idempotency")}: {idempotencyKey}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{paymentStatus === "error" && paymentError && (
|
|
||||||
<p className="error">{paymentError}</p>
|
|
||||||
)}
|
|
||||||
{paymentStatus === "ready" && paymentResult && (
|
|
||||||
<pre className="payment-result">{JSON.stringify(paymentResult, null, 2)}</pre>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
import i18n from "./i18n";
|
import i18n from "./i18n";
|
||||||
|
|
||||||
vi.mock("./api/client", () => ({
|
vi.mock("./api/client", () => ({
|
||||||
@@ -8,10 +9,14 @@ vi.mock("./api/client", () => ({
|
|||||||
apiPost: vi.fn()
|
apiPost: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function TestWrapper({ children }) {
|
||||||
|
return <AuthProvider>{children}</AuthProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
describe("App", () => {
|
describe("App", () => {
|
||||||
it("renders the hero copy", async () => {
|
it("renders the hero copy", async () => {
|
||||||
await i18n.changeLanguage("en");
|
await i18n.changeLanguage("en");
|
||||||
render(<App />);
|
render(<App />, { wrapper: TestWrapper });
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText("Find, compare, and book top salons near you.")
|
await screen.findByText("Find, compare, and book top salons near you.")
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -19,7 +24,7 @@ describe("App", () => {
|
|||||||
|
|
||||||
it("switches to Arabic and sets RTL direction", async () => {
|
it("switches to Arabic and sets RTL direction", async () => {
|
||||||
await i18n.changeLanguage("en");
|
await i18n.changeLanguage("en");
|
||||||
render(<App />);
|
render(<App />, { wrapper: TestWrapper });
|
||||||
const arabicButton = screen.getByRole("button", { name: "العربية" });
|
const arabicButton = screen.getByRole("button", { name: "العربية" });
|
||||||
fireEvent.click(arabicButton);
|
fireEvent.click(arabicButton);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
+55
-13
@@ -2,26 +2,52 @@ import { getActiveLocale } from "../i18n";
|
|||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
|
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
|
||||||
|
|
||||||
async function handleResponse(response) {
|
export class ApiError extends Error {
|
||||||
if (!response.ok) {
|
constructor(message, { status, body } = {}) {
|
||||||
const errorText = await response.text();
|
super(message);
|
||||||
throw new Error(errorText || `Request failed: ${response.status}`);
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
}
|
}
|
||||||
return response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiGet(path) {
|
async function handleResponse(response) {
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const text = await response.text();
|
||||||
headers: {
|
let body = null;
|
||||||
"Accept-Language": getActiveLocale(),
|
try {
|
||||||
},
|
body = text ? JSON.parse(text) : null;
|
||||||
});
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
(body?.detail && typeof body.detail === "string" ? body.detail : null) ||
|
||||||
|
text ||
|
||||||
|
`Request failed: ${response.status}`;
|
||||||
|
throw new ApiError(message, { status: response.status, body });
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseHeaders() {
|
||||||
|
return {
|
||||||
|
"Accept-Language": getActiveLocale(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGet(path, token) {
|
||||||
|
const headers = { ...baseHeaders() };
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, { headers });
|
||||||
return handleResponse(response);
|
return handleResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPost(path, body, token) {
|
export async function apiPost(path, body, token) {
|
||||||
const headers = {
|
const headers = {
|
||||||
"Accept-Language": getActiveLocale(),
|
...baseHeaders(),
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
};
|
};
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -31,7 +57,23 @@ export async function apiPost(path, body, token) {
|
|||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(body),
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPatch(path, body, token) {
|
||||||
|
const headers = {
|
||||||
|
...baseHeaders(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
return handleResponse(response);
|
return handleResponse(response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { setLocale } from "../i18n";
|
||||||
|
|
||||||
|
export default function LocaleSwitch() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="locale-switch" aria-label={t("locale.label")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={i18n.language === "ar-sa" ? "active" : ""}
|
||||||
|
onClick={() => setLocale("ar-sa")}
|
||||||
|
>
|
||||||
|
{t("locale.arabic")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={i18n.language === "en" ? "active" : ""}
|
||||||
|
onClick={() => setLocale("en")}
|
||||||
|
>
|
||||||
|
{t("locale.english")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { usePaymentForm } from "../hooks/usePaymentForm";
|
||||||
|
|
||||||
|
export default function PaymentForm({ bookingId = "", token = "" }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const form = usePaymentForm(bookingId, token);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="payments">
|
||||||
|
<div className="payments-header">
|
||||||
|
<div>
|
||||||
|
<h2>{t("payment.title")}</h2>
|
||||||
|
<p className="payments-subtitle">{t("payment.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<span className="payments-badge">{t("payment.badge")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="payments-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
form.submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.bookingId")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={form.bookingIdInput}
|
||||||
|
onChange={(e) => form.setBookingIdInput(e.target.value)}
|
||||||
|
placeholder="123"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.accessToken")}</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.tokenInput}
|
||||||
|
onChange={(e) => form.setTokenInput(e.target.value)}
|
||||||
|
placeholder={t("payment.accessTokenPlaceholder")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.sourceType")}</span>
|
||||||
|
<select
|
||||||
|
value={form.sourceType}
|
||||||
|
onChange={(e) => form.setSourceType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="stcpay">{t("payment.sources.stcpay")}</option>
|
||||||
|
<option value="token">{t("payment.sources.token")}</option>
|
||||||
|
<option value="applepay">{t("payment.sources.applepay")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.sourceValue")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.sourceValue}
|
||||||
|
onChange={(e) => form.setSourceValue(e.target.value)}
|
||||||
|
placeholder={t("payment.sourceValuePlaceholder")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.callbackUrl")}</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={form.callbackUrl}
|
||||||
|
onChange={(e) => form.setCallbackUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/payments/return"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="payments-actions">
|
||||||
|
<button type="submit" disabled={form.status === "loading"}>
|
||||||
|
{form.status === "loading"
|
||||||
|
? t("payment.processing")
|
||||||
|
: t("payment.payNow")}
|
||||||
|
</button>
|
||||||
|
<p className="helper">
|
||||||
|
{t("payment.idempotency")}: {form.idempotencyKey}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{form.status === "error" && form.error && (
|
||||||
|
<p className="error">{form.error}</p>
|
||||||
|
)}
|
||||||
|
{form.status === "ready" && form.result && (
|
||||||
|
<pre className="payment-result">{JSON.stringify(form.result, null, 2)}</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
export default function ProtectedRoute({ children }) {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="auth-loading">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function SalonCard({ salon }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<article className="card" data-testid="salon-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3>{salon.name}</h3>
|
||||||
|
<span className="rating">{salon.rating_avg} / 5</span>
|
||||||
|
</div>
|
||||||
|
<p>{salon.description || t("card.noDescription")}</p>
|
||||||
|
<div className="meta">
|
||||||
|
<span>{salon.city}</span>
|
||||||
|
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
|
||||||
|
</div>
|
||||||
|
<Link to={`/salon/${salon.id}`} className="card-link">
|
||||||
|
{t("card.viewDetails")}
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSalonSearch } from "../hooks/useSalonSearch";
|
||||||
|
import SalonCard from "./SalonCard";
|
||||||
|
|
||||||
|
export function SearchInput({ value, onChange }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("hero.searchPlaceholder")}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
aria-label={t("hero.searchPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalonSearch({ query }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { salons, status } = useSalonSearch(query);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="results">
|
||||||
|
<h2>{t("results.title")}</h2>
|
||||||
|
{status === "loading" && <p>{t("results.loading")}</p>}
|
||||||
|
{status === "error" && <p className="error">{t("results.error")}</p>}
|
||||||
|
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
|
||||||
|
<div className="grid">
|
||||||
|
{salons.map((salon) => (
|
||||||
|
<SalonCard key={salon.id} salon={salon} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import SalonSearch from "./SalonSearch";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { apiGet } = await import("../api/client");
|
||||||
|
|
||||||
|
function renderWithRouter(ui) {
|
||||||
|
return render(<BrowserRouter>{ui}</BrowserRouter>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SalonSearch", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
apiGet.mockResolvedValue([]);
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading then empty when no results", async () => {
|
||||||
|
renderWithRouter(<SalonSearch query="test" />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(apiGet).toHaveBeenCalledWith("/salons/?q=test");
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/no salons found/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows salon cards when results returned", async () => {
|
||||||
|
apiGet.mockResolvedValue([
|
||||||
|
{ id: 1, name: "Salon A", city: "Riyadh", rating_avg: 4.5, phone_number: "+966500000000" },
|
||||||
|
]);
|
||||||
|
renderWithRouter(<SalonSearch query="salon" />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Salon A")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Riyadh")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { apiGet, apiPost } from "../api/client";
|
||||||
|
|
||||||
|
const STORAGE_ACCESS = "auth_access";
|
||||||
|
const STORAGE_REFRESH = "auth_refresh";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [accessToken, setAccessToken] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem(STORAGE_ACCESS);
|
||||||
|
});
|
||||||
|
const [refreshToken, setRefreshToken] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem(STORAGE_REFRESH);
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const persistTokens = useCallback((access, refresh) => {
|
||||||
|
setAccessToken(access);
|
||||||
|
setRefreshToken(refresh);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
if (access) localStorage.setItem(STORAGE_ACCESS, access);
|
||||||
|
else localStorage.removeItem(STORAGE_ACCESS);
|
||||||
|
if (refresh) localStorage.setItem(STORAGE_REFRESH, refresh);
|
||||||
|
else localStorage.removeItem(STORAGE_REFRESH);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setUser(null);
|
||||||
|
persistTokens(null, null);
|
||||||
|
}, [persistTokens]);
|
||||||
|
|
||||||
|
const login = useCallback((access, refresh, userData) => {
|
||||||
|
persistTokens(access, refresh);
|
||||||
|
setUser(userData);
|
||||||
|
}, [persistTokens]);
|
||||||
|
|
||||||
|
// Restore user from token on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiGet("/auth/me/", accessToken)
|
||||||
|
.then((data) => {
|
||||||
|
setUser(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Token invalid, try refresh
|
||||||
|
if (!refreshToken) {
|
||||||
|
logout();
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiPost("/auth/token/refresh/", { refresh: refreshToken })
|
||||||
|
.then(({ access }) => {
|
||||||
|
persistTokens(access, refreshToken);
|
||||||
|
return apiGet("/auth/me/", access);
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setUser(data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
logout();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [accessToken, refreshToken, logout, persistTokens]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { apiPost } from "../api/client";
|
||||||
|
|
||||||
|
function generateIdempotencyKey() {
|
||||||
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `id-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must match AuthContext's STORAGE_ACCESS so fallback finds the same token
|
||||||
|
const AUTH_TOKEN_KEY = "auth_access";
|
||||||
|
|
||||||
|
export function usePaymentForm(bookingId = "", token = "") {
|
||||||
|
// token: optional auth token from AuthContext; tokenInput: manual override from form
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [bookingIdInput, setBookingIdInput] = useState(bookingId);
|
||||||
|
const [tokenInput, setTokenInput] = useState(() => {
|
||||||
|
if (token) return token;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return localStorage.getItem(AUTH_TOKEN_KEY) || "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
const [sourceType, setSourceType] = useState("stcpay");
|
||||||
|
const [sourceValue, setSourceValue] = useState("");
|
||||||
|
const [callbackUrl, setCallbackUrl] = useState(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return `${window.location.origin}/pay/return`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
const [status, setStatus] = useState("idle");
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const idempotencyKey = useMemo(generateIdempotencyKey, []);
|
||||||
|
|
||||||
|
// Persist token to localStorage when it changes
|
||||||
|
const setTokenInputAndPersist = (value) => {
|
||||||
|
setTokenInput(value);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
if (value) {
|
||||||
|
localStorage.setItem(AUTH_TOKEN_KEY, value);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setStatus("loading");
|
||||||
|
setError("");
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
if (!bookingIdInput) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(t("payment.errors.bookingRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = { type: sourceType };
|
||||||
|
if (sourceType === "stcpay") {
|
||||||
|
if (!sourceValue) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(t("payment.errors.mobileRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.mobile = sourceValue;
|
||||||
|
}
|
||||||
|
if (sourceType === "token") {
|
||||||
|
if (!sourceValue) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(t("payment.errors.tokenRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.token = sourceValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
booking_id: Number(bookingIdInput),
|
||||||
|
provider: "moyasar",
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
if (callbackUrl) {
|
||||||
|
payload.callback_url = callbackUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use tokenInput (form state) so user edits are respected; token prop only initializes it
|
||||||
|
const authToken = tokenInput;
|
||||||
|
try {
|
||||||
|
const data = await apiPost("/payments/", payload, authToken || undefined);
|
||||||
|
setResult(data);
|
||||||
|
setStatus("ready");
|
||||||
|
if (data?.redirect_url) {
|
||||||
|
window.location.assign(data.redirect_url);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(err.message || t("payment.errors.generic"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookingIdInput,
|
||||||
|
setBookingIdInput,
|
||||||
|
tokenInput,
|
||||||
|
setTokenInput: setTokenInputAndPersist,
|
||||||
|
sourceType,
|
||||||
|
setSourceType,
|
||||||
|
sourceValue,
|
||||||
|
setSourceValue,
|
||||||
|
callbackUrl,
|
||||||
|
setCallbackUrl,
|
||||||
|
idempotencyKey,
|
||||||
|
status,
|
||||||
|
result,
|
||||||
|
error,
|
||||||
|
submit,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { apiGet } from "../api/client";
|
||||||
|
|
||||||
|
export function useSalonSearch(query) {
|
||||||
|
const [salons, setSalons] = useState([]);
|
||||||
|
const [status, setStatus] = useState("idle");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ignore = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setStatus("loading");
|
||||||
|
try {
|
||||||
|
const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`);
|
||||||
|
if (!ignore) {
|
||||||
|
setSalons(data);
|
||||||
|
setStatus("ready");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!ignore) {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
ignore = true;
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return { salons, status };
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@
|
|||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"noDescription": "لا يوجد وصف بعد.",
|
"noDescription": "لا يوجد وصف بعد.",
|
||||||
"phoneUnavailable": "الهاتف غير متوفر"
|
"phoneUnavailable": "الهاتف غير متوفر",
|
||||||
|
"viewDetails": "عرض التفاصيل والحجز"
|
||||||
},
|
},
|
||||||
"locale": {
|
"nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
|
||||||
"label": "اللغة",
|
"label": "اللغة",
|
||||||
"arabic": "العربية",
|
"arabic": "العربية",
|
||||||
"english": "الإنجليزية"
|
"english": "الإنجليزية"
|
||||||
|
|||||||
@@ -13,9 +13,10 @@
|
|||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"noDescription": "No description yet.",
|
"noDescription": "No description yet.",
|
||||||
"phoneUnavailable": "Phone unavailable"
|
"phoneUnavailable": "Phone unavailable",
|
||||||
|
"viewDetails": "View details & book"
|
||||||
},
|
},
|
||||||
"locale": {
|
"nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
"arabic": "العربية",
|
"arabic": "العربية",
|
||||||
"english": "English"
|
"english": "English"
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Outlet, Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import LocaleSwitch from "../components/LocaleSwitch";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
export default function MainLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isAuthenticated, logout } = useAuth();
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="main-header">
|
||||||
|
<nav className="main-nav">
|
||||||
|
<Link to="/" className="nav-brand">
|
||||||
|
{t("nav.home")}
|
||||||
|
</Link>
|
||||||
|
<Link to="/book" className="nav-link">
|
||||||
|
{t("nav.book")}
|
||||||
|
</Link>
|
||||||
|
<Link to="/pay" className="nav-link">
|
||||||
|
{t("nav.pay")}
|
||||||
|
</Link>
|
||||||
|
<Link to="/profile" className="nav-link">
|
||||||
|
{t("nav.profile")}
|
||||||
|
</Link>
|
||||||
|
<Link to="/bookings" className="nav-link">
|
||||||
|
{t("nav.bookings")}
|
||||||
|
</Link>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<button type="button" className="nav-link nav-logout" onClick={logout}>
|
||||||
|
{t("nav.logout")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link to="/login" className="nav-link">
|
||||||
|
{t("nav.login")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
<LocaleSwitch />
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { apiGet, apiPost } from "../api/client";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import ProtectedRoute from "../components/ProtectedRoute";
|
||||||
|
|
||||||
|
export default function BookPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const salonId = searchParams.get("salon");
|
||||||
|
|
||||||
|
const [salon, setSalon] = useState(null);
|
||||||
|
const [serviceId, setServiceId] = useState("");
|
||||||
|
const [staffId, setStaffId] = useState("");
|
||||||
|
const [date, setDate] = useState("");
|
||||||
|
const [time, setTime] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!salonId) return;
|
||||||
|
apiGet(`/salons/${salonId}/`)
|
||||||
|
.then(setSalon)
|
||||||
|
.catch(() => setSalon(null));
|
||||||
|
}, [salonId]);
|
||||||
|
|
||||||
|
if (!salonId) {
|
||||||
|
return (
|
||||||
|
<section className="book-page">
|
||||||
|
<h1>{t("book.title")}</h1>
|
||||||
|
<p>{t("book.selectSalon")}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedService = salon?.services?.find((s) => String(s.id) === serviceId);
|
||||||
|
const duration = selectedService?.duration_minutes || 0;
|
||||||
|
|
||||||
|
function computeEndTime(startISO) {
|
||||||
|
if (!startISO || !duration) return null;
|
||||||
|
const start = new Date(startISO);
|
||||||
|
const end = new Date(start.getTime() + duration * 60 * 1000);
|
||||||
|
return end.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
if (!serviceId || !staffId || !date || !time) {
|
||||||
|
setError(t("book.errors.fillAll"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Use Asia/Riyadh offset for backend (KSA)
|
||||||
|
const startISO = `${date}T${time}:00+03:00`;
|
||||||
|
const endISO = computeEndTime(startISO);
|
||||||
|
if (!endISO) {
|
||||||
|
setError(t("book.errors.invalidTime"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const booking = await apiPost(
|
||||||
|
"/bookings/",
|
||||||
|
{
|
||||||
|
service: Number(serviceId),
|
||||||
|
staff: Number(staffId),
|
||||||
|
start_time: startISO,
|
||||||
|
end_time: endISO,
|
||||||
|
notes,
|
||||||
|
},
|
||||||
|
accessToken
|
||||||
|
);
|
||||||
|
navigate(`/pay?booking=${booking.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || t("book.errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<section className="book-page">
|
||||||
|
<h1>{t("book.title")}</h1>
|
||||||
|
{salon && <p className="book-salon">{salon.name}</p>}
|
||||||
|
|
||||||
|
{!salon ? (
|
||||||
|
<p>{t("results.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="book-form">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.service")}</span>
|
||||||
|
<select
|
||||||
|
value={serviceId}
|
||||||
|
onChange={(e) => setServiceId(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">{t("book.selectService")}</option>
|
||||||
|
{salon.services?.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} – {s.duration_minutes} min, {s.price_amount} {s.currency}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.staff")}</span>
|
||||||
|
<select
|
||||||
|
value={staffId}
|
||||||
|
onChange={(e) => setStaffId(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">{t("book.selectStaff")}</option>
|
||||||
|
{salon.staff?.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name || s.title || `Staff ${s.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.date")}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.time")}</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => setTime(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.notes")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder={t("book.notesPlaceholder")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<button type="submit" disabled={loading}>
|
||||||
|
{loading ? t("book.submitting") : t("book.submit")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ProtectedRoute>{content}</ProtectedRoute>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { apiGet } from "../api/client";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import ProtectedRoute from "../components/ProtectedRoute";
|
||||||
|
|
||||||
|
function formatDateTime(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookingsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [bookings, setBookings] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
apiGet("/bookings/", accessToken)
|
||||||
|
.then(setBookings)
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<section className="bookings-page">
|
||||||
|
<h1>{t("bookings.title")}</h1>
|
||||||
|
<p className="bookings-subtitle">{t("bookings.subtitle")}</p>
|
||||||
|
|
||||||
|
{loading && <p>{t("results.loading")}</p>}
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && bookings.length === 0 && (
|
||||||
|
<p>{t("bookings.empty")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && bookings.length > 0 && (
|
||||||
|
<ul className="bookings-list">
|
||||||
|
{bookings.map((b) => (
|
||||||
|
<li key={b.id} className="booking-card">
|
||||||
|
<div className="booking-header">
|
||||||
|
<span className="booking-status">{b.status}</span>
|
||||||
|
<span className="booking-salon">{b.salon_name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="booking-service">{b.service_name}</p>
|
||||||
|
<p className="booking-time">
|
||||||
|
{formatDateTime(b.start_time)} – {formatDateTime(b.end_time)}
|
||||||
|
</p>
|
||||||
|
<p className="booking-price">
|
||||||
|
{b.price_amount} {b.currency}
|
||||||
|
</p>
|
||||||
|
<Link to={`/pay?booking=${b.id}`} className="booking-pay-link">
|
||||||
|
{t("bookings.pay")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ProtectedRoute>{content}</ProtectedRoute>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { SearchInput, default as SalonSearch } from "../components/SalonSearch";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="hero">
|
||||||
|
<div className="hero-top">
|
||||||
|
<p className="eyebrow">{t("hero.eyebrow")}</p>
|
||||||
|
</div>
|
||||||
|
<h1>{t("hero.title")}</h1>
|
||||||
|
<p className="subtitle">{t("hero.subtitle")}</p>
|
||||||
|
<SearchInput value={query} onChange={setQuery} />
|
||||||
|
</header>
|
||||||
|
<SalonSearch query={query} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { apiPost, ApiError } from "../api/client";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [step, setStep] = useState("phone");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [channel, setChannel] = useState("sms");
|
||||||
|
const [requestId, setRequestId] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const from = location.state?.from?.pathname || "/";
|
||||||
|
|
||||||
|
async function handleRequestOtp(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiPost("/auth/phone/request/", {
|
||||||
|
phone_number: phone,
|
||||||
|
channel,
|
||||||
|
});
|
||||||
|
setRequestId(res.request_id);
|
||||||
|
setStep("verify");
|
||||||
|
} catch (err) {
|
||||||
|
const body = err instanceof ApiError ? err.body : null;
|
||||||
|
if (body?.retry_after_seconds) {
|
||||||
|
setError(t("auth.errors.retryAfter", { seconds: body.retry_after_seconds }));
|
||||||
|
} else {
|
||||||
|
setError(err.message || t("auth.errors.generic"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerify(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiPost("/auth/phone/verify/", {
|
||||||
|
request_id: requestId,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
login(res.access, res.refresh, res.user);
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
const body = err instanceof ApiError ? err.body : null;
|
||||||
|
if (body?.retry_after_seconds) {
|
||||||
|
setError(t("auth.errors.retryAfter", { seconds: body.retry_after_seconds }));
|
||||||
|
} else {
|
||||||
|
setError(err.message || t("auth.errors.generic"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === "phone") {
|
||||||
|
return (
|
||||||
|
<section className="auth-page">
|
||||||
|
<h1>{t("auth.title")}</h1>
|
||||||
|
<p className="auth-subtitle">{t("auth.subtitle")}</p>
|
||||||
|
<form onSubmit={handleRequestOtp} className="auth-form">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("auth.phone")}</span>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="+966512345678"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("auth.channel")}</span>
|
||||||
|
<select value={channel} onChange={(e) => setChannel(e.target.value)}>
|
||||||
|
<option value="sms">{t("auth.sms")}</option>
|
||||||
|
<option value="whatsapp">{t("auth.whatsapp")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<button type="submit" disabled={loading}>
|
||||||
|
{loading ? t("auth.sending") : t("auth.sendCode")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="auth-page">
|
||||||
|
<h1>{t("auth.verifyTitle")}</h1>
|
||||||
|
<p className="auth-subtitle">{t("auth.verifySubtitle", { phone })}</p>
|
||||||
|
<form onSubmit={handleVerify} className="auth-form">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("auth.code")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
|
||||||
|
placeholder="123456"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<div className="auth-actions">
|
||||||
|
<button type="submit" disabled={loading || code.length < 6}>
|
||||||
|
{loading ? t("auth.verifying") : t("auth.verify")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="auth-back"
|
||||||
|
onClick={() => {
|
||||||
|
setStep("phone");
|
||||||
|
setCode("");
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("auth.back")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import LoginPage from "./LoginPage";
|
||||||
|
import { AuthProvider } from "../contexts/AuthContext";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
|
||||||
|
vi.mock("../api/client", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
return { ...actual, apiPost: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiPost } = await import("../api/client");
|
||||||
|
|
||||||
|
function renderLogin() {
|
||||||
|
return render(
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<LoginPage />
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LoginPage", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders phone input and send code button", () => {
|
||||||
|
renderLogin();
|
||||||
|
expect(screen.getByLabelText(/phone number/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Send code" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows verify step after successful OTP request", async () => {
|
||||||
|
apiPost.mockResolvedValueOnce({ request_id: "abc-123", expires_at: "2025-01-01T12:00:00Z" });
|
||||||
|
renderLogin();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Send code" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when OTP request fails", async () => {
|
||||||
|
apiPost.mockRejectedValueOnce(new Error("Rate limited"));
|
||||||
|
renderLogin();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Send code" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Rate limited")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import PaymentForm from "../components/PaymentForm";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
export default function PaymentPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const bookingIdFromUrl = searchParams.get("booking") || "";
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
return <PaymentForm bookingId={bookingIdFromUrl} token={accessToken || ""} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useSearchParams, Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function PaymentReturnPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const status = searchParams.get("status") || "";
|
||||||
|
const id = searchParams.get("id") || "";
|
||||||
|
|
||||||
|
const isSuccess = ["paid", "captured", "authorized"].includes(status.toLowerCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="payment-return">
|
||||||
|
<h1>{isSuccess ? t("paymentReturn.success") : t("paymentReturn.title")}</h1>
|
||||||
|
<p>
|
||||||
|
{isSuccess
|
||||||
|
? t("paymentReturn.successMessage")
|
||||||
|
: t("paymentReturn.checkStatus")}
|
||||||
|
</p>
|
||||||
|
{id && <p className="payment-return-id">{t("paymentReturn.reference", { id })}</p>}
|
||||||
|
<Link to="/profile" className="book-cta">
|
||||||
|
{t("paymentReturn.viewBookings")}
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import ProtectedRoute from "../components/ProtectedRoute";
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<section className="profile-page">
|
||||||
|
<h1>{t("profile.title")}</h1>
|
||||||
|
{user && (
|
||||||
|
<p className="profile-phone">
|
||||||
|
{user.phone_number || user.email || t("profile.noContact")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Link to="/bookings" className="book-cta">
|
||||||
|
{t("profile.myBookings")}
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ProtectedRoute>{content}</ProtectedRoute>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { apiGet } from "../api/client";
|
||||||
|
|
||||||
|
export default function SalonDetailPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { id } = useParams();
|
||||||
|
const [salon, setSalon] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
apiGet(`/salons/${id}/`)
|
||||||
|
.then(setSalon)
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) return <p>{t("results.loading")}</p>;
|
||||||
|
if (error) return <p className="error">{error}</p>;
|
||||||
|
if (!salon) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="salon-detail">
|
||||||
|
<h1>{salon.name}</h1>
|
||||||
|
<p>{salon.description || t("card.noDescription")}</p>
|
||||||
|
<div className="meta">
|
||||||
|
<span>{salon.city}</span>
|
||||||
|
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{t("salon.services")}</h2>
|
||||||
|
<ul className="service-list">
|
||||||
|
{salon.services?.map((s) => (
|
||||||
|
<li key={s.id}>
|
||||||
|
{s.name} – {s.duration_minutes} min, {s.price_amount} {s.currency}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>{t("salon.staff")}</h2>
|
||||||
|
<ul className="staff-list">
|
||||||
|
{salon.staff?.map((s) => (
|
||||||
|
<li key={s.id}>{s.name || s.title || `Staff ${s.id}`}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Link to={`/book?salon=${salon.id}`} className="book-cta">
|
||||||
|
{t("book.cta")}
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,42 @@ body {
|
|||||||
padding: 48px 24px 80px;
|
padding: 48px 24px 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #eadfd2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand,
|
||||||
|
.nav-link {
|
||||||
|
color: #1c1b1f;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand:hover,
|
||||||
|
.nav-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logout {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -237,6 +273,151 @@ h1 {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #1c1b1f;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-subtitle {
|
||||||
|
color: #5c5a5f;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-back {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #dad3ca;
|
||||||
|
color: #3c3a3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.salon-detail {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-list,
|
||||||
|
.staff-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-list li,
|
||||||
|
.staff-list li {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #eadfd2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-cta {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: #1c1b1f;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-cta:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 400px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-salon {
|
||||||
|
color: #5c5a5f;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookings-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 24px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-card {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 12px 24px rgba(21, 21, 21, 0.08);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-status {
|
||||||
|
text-transform: capitalize;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-service,
|
||||||
|
.booking-time,
|
||||||
|
.booking-price {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #5c5a5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-pay-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1c1b1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-return {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-return-id {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #5c5a5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-phone {
|
||||||
|
margin: 8px 0 16px;
|
||||||
|
color: #5c5a5f;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #b00020;
|
color: #b00020;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user