Updated PLANS.md, AGENTS.md, and arabic-localization.md to reflect the “foundations now, full translations later” approach and marked progress accordingly.
Implemented localization foundations across backend and frontend (locale settings/middleware, preferred language, i18n wiring, RTL support, minimal Arabic UI strings, Accept-Language). Added targeted backend and frontend tests plus a risks note for pending full translation coverage.
This commit is contained in:
@@ -9,6 +9,7 @@ Build a reliable, maintainable salon booking platform with Django (backend) and
|
|||||||
- Booking integrity (availability, staff schedules, overlap prevention).
|
- Booking integrity (availability, staff schedules, overlap prevention).
|
||||||
- Payments via Moyasar (payment creation, webhooks, reconciliation).
|
- Payments via Moyasar (payment creation, webhooks, reconciliation).
|
||||||
- Notifications for booking lifecycle.
|
- Notifications for booking lifecycle.
|
||||||
|
- Localization foundations (i18n plumbing, RTL readiness, locale preferences).
|
||||||
- Tests for critical flows.
|
- Tests for critical flows.
|
||||||
|
|
||||||
### Phase 2: Manager Ops
|
### Phase 2: Manager Ops
|
||||||
@@ -16,6 +17,7 @@ Build a reliable, maintainable salon booking platform with Django (backend) and
|
|||||||
- Calendar view + rescheduling/cancellation rules.
|
- Calendar view + rescheduling/cancellation rules.
|
||||||
- Reviews/ratings with moderation and recompute.
|
- Reviews/ratings with moderation and recompute.
|
||||||
- Reports (revenue, popular services, customer trends).
|
- Reports (revenue, popular services, customer trends).
|
||||||
|
- Full translation coverage and Arabic UX polish.
|
||||||
|
|
||||||
### Phase 3: Scale & Compliance
|
### Phase 3: Scale & Compliance
|
||||||
- Audit logs and data export.
|
- Audit logs and data export.
|
||||||
|
|||||||
@@ -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/arabic-localization.md`. Keep it updated in line with the requirements below.
|
The current execution plan is `docs/execplans/arabic-localization.md`. It focuses on localization foundations first, with full translation coverage deferred until core flows stabilize. Keep it updated in line with the requirements below.
|
||||||
|
|
||||||
## How to use ExecPlans and PLANS.md
|
## How to use ExecPlans and PLANS.md
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from django.utils import translation
|
||||||
|
|
||||||
|
|
||||||
|
class UserLocaleMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
language = None
|
||||||
|
activated = False
|
||||||
|
user = getattr(request, "user", None)
|
||||||
|
if user and getattr(user, "is_authenticated", False):
|
||||||
|
language = getattr(user, "preferred_language", None)
|
||||||
|
if language:
|
||||||
|
translation.activate(language)
|
||||||
|
request.LANGUAGE_CODE = language
|
||||||
|
activated = True
|
||||||
|
|
||||||
|
response = self.get_response(request)
|
||||||
|
|
||||||
|
active_language = translation.get_language()
|
||||||
|
if active_language:
|
||||||
|
response["Content-Language"] = active_language
|
||||||
|
|
||||||
|
if activated:
|
||||||
|
translation.deactivate()
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0002_phone_auth_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="preferred_language",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("ar-sa", "Arabic (Saudi Arabia)"), ("en", "English")],
|
||||||
|
default="ar-sa",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -47,6 +47,11 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||||||
first_name = models.CharField(max_length=150, blank=True)
|
first_name = models.CharField(max_length=150, blank=True)
|
||||||
last_name = models.CharField(max_length=150, blank=True)
|
last_name = models.CharField(max_length=150, blank=True)
|
||||||
is_phone_verified = models.BooleanField(default=False)
|
is_phone_verified = models.BooleanField(default=False)
|
||||||
|
preferred_language = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=settings.LANGUAGES,
|
||||||
|
default=settings.LANGUAGE_CODE,
|
||||||
|
)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
is_staff = models.BooleanField(default=False)
|
is_staff = models.BooleanField(default=False)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ User = get_user_model()
|
|||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ["id", "email", "phone_number", "first_name", "last_name", "role", "is_phone_verified"]
|
fields = [
|
||||||
|
"id",
|
||||||
|
"email",
|
||||||
|
"phone_number",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"role",
|
||||||
|
"is_phone_verified",
|
||||||
|
"preferred_language",
|
||||||
|
]
|
||||||
read_only_fields = ["id", "role", "is_phone_verified"]
|
read_only_fields = ["id", "role", "is_phone_verified"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from django.conf import settings
|
|||||||
from django.contrib.auth.hashers import check_password, make_password
|
from django.contrib.auth.hashers import check_password, make_password
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -21,13 +23,13 @@ class OtpSendResult:
|
|||||||
|
|
||||||
class OtpRateLimitError(RuntimeError):
|
class OtpRateLimitError(RuntimeError):
|
||||||
def __init__(self, retry_after_seconds: int):
|
def __init__(self, retry_after_seconds: int):
|
||||||
super().__init__("Too many OTP requests. Try again later.")
|
super().__init__(_("Too many OTP requests. Try again later."))
|
||||||
self.retry_after_seconds = retry_after_seconds
|
self.retry_after_seconds = retry_after_seconds
|
||||||
|
|
||||||
|
|
||||||
class OtpCooldownError(RuntimeError):
|
class OtpCooldownError(RuntimeError):
|
||||||
def __init__(self, retry_after_seconds: int):
|
def __init__(self, retry_after_seconds: int):
|
||||||
super().__init__("Please wait before requesting another code.")
|
super().__init__(_("Please wait before requesting another code."))
|
||||||
self.retry_after_seconds = retry_after_seconds
|
self.retry_after_seconds = retry_after_seconds
|
||||||
|
|
||||||
|
|
||||||
@@ -56,17 +58,17 @@ class TwilioOtpProvider(BaseOtpProvider):
|
|||||||
|
|
||||||
def _assert_config(self) -> None:
|
def _assert_config(self) -> None:
|
||||||
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 send_sms(self, to_number: str, message: str) -> None:
|
||||||
self._assert_config()
|
self._assert_config()
|
||||||
raise NotImplementedError("Twilio SMS adapter not implemented yet")
|
raise NotImplementedError(_("Twilio SMS adapter not implemented yet"))
|
||||||
|
|
||||||
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")
|
raise NotImplementedError(_("Twilio WhatsApp adapter not implemented yet"))
|
||||||
|
|
||||||
|
|
||||||
class UnifonicOtpProvider(BaseOtpProvider):
|
class UnifonicOtpProvider(BaseOtpProvider):
|
||||||
@@ -77,17 +79,17 @@ class UnifonicOtpProvider(BaseOtpProvider):
|
|||||||
|
|
||||||
def _assert_config(self) -> None:
|
def _assert_config(self) -> None:
|
||||||
if not self.app_sid or not self.sender_id:
|
if not self.app_sid or not self.sender_id:
|
||||||
raise ValueError("Unifonic credentials are not configured")
|
raise ValueError(_("Unifonic credentials are not configured"))
|
||||||
|
|
||||||
def send_sms(self, to_number: str, message: str) -> None:
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
self._assert_config()
|
self._assert_config()
|
||||||
raise NotImplementedError("Unifonic SMS adapter not implemented yet")
|
raise NotImplementedError(_("Unifonic SMS adapter not implemented yet"))
|
||||||
|
|
||||||
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_sender:
|
if not self.whatsapp_sender:
|
||||||
raise ValueError("Unifonic WhatsApp sender is not configured")
|
raise ValueError(_("Unifonic WhatsApp sender is not configured"))
|
||||||
raise NotImplementedError("Unifonic WhatsApp adapter not implemented yet")
|
raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet"))
|
||||||
|
|
||||||
|
|
||||||
PROVIDERS = {
|
PROVIDERS = {
|
||||||
@@ -101,7 +103,7 @@ def get_provider() -> BaseOtpProvider:
|
|||||||
provider_key = settings.OTP_PROVIDER
|
provider_key = settings.OTP_PROVIDER
|
||||||
provider_cls = PROVIDERS.get(provider_key)
|
provider_cls = PROVIDERS.get(provider_key)
|
||||||
if not provider_cls:
|
if not provider_cls:
|
||||||
raise ValueError(f"Unknown OTP provider: {provider_key}")
|
raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key})
|
||||||
return provider_cls()
|
return provider_cls()
|
||||||
|
|
||||||
|
|
||||||
@@ -147,13 +149,15 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo
|
|||||||
expires_at=PhoneOTP.expiry_at(),
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
)
|
)
|
||||||
|
|
||||||
message = f"Your verification code is {code}. It expires in {settings.OTP_EXPIRY_MINUTES} minutes."
|
message = _(
|
||||||
|
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
||||||
|
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
|
||||||
if channel == OtpChannel.SMS:
|
if channel == OtpChannel.SMS:
|
||||||
provider.send_sms(phone_number, message)
|
provider.send_sms(phone_number, message)
|
||||||
elif channel == OtpChannel.WHATSAPP:
|
elif channel == OtpChannel.WHATSAPP:
|
||||||
provider.send_whatsapp(phone_number, message)
|
provider.send_whatsapp(phone_number, message)
|
||||||
else:
|
else:
|
||||||
raise ValueError("Unsupported OTP channel")
|
raise ValueError(_("Unsupported OTP channel"))
|
||||||
|
|
||||||
return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat())
|
return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat())
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
|
||||||
def normalize_phone_number(raw_phone: str) -> str:
|
def normalize_phone_number(raw_phone: str) -> str:
|
||||||
if not raw_phone:
|
if not raw_phone:
|
||||||
raise ValueError("Phone number is required")
|
raise ValueError(_("Phone number is required"))
|
||||||
|
|
||||||
phone = re.sub(r"[\s\-\(\)]", "", raw_phone)
|
phone = re.sub(r"[\s\-\(\)]", "", raw_phone)
|
||||||
if phone.startswith("00"):
|
if phone.startswith("00"):
|
||||||
@@ -12,7 +14,7 @@ def normalize_phone_number(raw_phone: str) -> str:
|
|||||||
if phone.startswith("+"):
|
if phone.startswith("+"):
|
||||||
digits = phone[1:]
|
digits = phone[1:]
|
||||||
if not digits.isdigit() or not (8 <= len(digits) <= 15):
|
if not digits.isdigit() or not (8 <= len(digits) <= 15):
|
||||||
raise ValueError("Invalid phone number format")
|
raise ValueError(_("Invalid phone number format"))
|
||||||
return "+" + digits
|
return "+" + digits
|
||||||
|
|
||||||
digits = re.sub(r"\D", "", phone)
|
digits = re.sub(r"\D", "", phone)
|
||||||
@@ -23,4 +25,4 @@ def normalize_phone_number(raw_phone: str) -> str:
|
|||||||
if digits.startswith("5") and len(digits) == 9:
|
if digits.startswith("5") and len(digits) == 9:
|
||||||
return "+966" + digits
|
return "+966" + digits
|
||||||
|
|
||||||
raise ValueError("Phone number must be in E.164 format or a valid Saudi mobile")
|
raise ValueError(_("Phone number must be in E.164 format or a valid Saudi mobile"))
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_accept_language_sets_content_language_header(client):
|
||||||
|
response = client.post(
|
||||||
|
reverse("otp_request"),
|
||||||
|
{"phone_number": "123", "channel": "sms"},
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_ACCEPT_LANGUAGE="en",
|
||||||
|
)
|
||||||
|
assert response.headers.get("Content-Language") == "en"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_user_preference_overrides_accept_language(client):
|
||||||
|
user = User.objects.create_user(
|
||||||
|
email="locale@example.com",
|
||||||
|
phone_number="+966512345678",
|
||||||
|
)
|
||||||
|
user.preferred_language = "en"
|
||||||
|
user.save(update_fields=["preferred_language"])
|
||||||
|
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.get(reverse("me"), HTTP_ACCEPT_LANGUAGE="ar-sa")
|
||||||
|
assert response.headers.get("Content-Language") == "en"
|
||||||
@@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework import generics, permissions, status
|
from rest_framework import generics, permissions, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
@@ -71,14 +72,14 @@ class OTPVerifyView(APIView):
|
|||||||
data = serializer.validated_data
|
data = serializer.validated_data
|
||||||
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
|
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
|
||||||
if not verify_otp(otp, data["code"]):
|
if not verify_otp(otp, data["code"]):
|
||||||
return Response({"detail": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
user = User.objects.filter(phone_number=otp.phone_number).first()
|
user = User.objects.filter(phone_number=otp.phone_number).first()
|
||||||
if user and not user.is_phone_verified:
|
if user and not user.is_phone_verified:
|
||||||
user.is_phone_verified = True
|
user.is_phone_verified = True
|
||||||
user.save(update_fields=["is_phone_verified"])
|
user.save(update_fields=["is_phone_verified"])
|
||||||
|
|
||||||
return Response({"detail": "Phone verified"}, status=status.HTTP_200_OK)
|
return Response({"detail": _("Phone verified")}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class PhoneAuthRequestView(APIView):
|
class PhoneAuthRequestView(APIView):
|
||||||
@@ -95,7 +96,7 @@ class PhoneAuthRequestView(APIView):
|
|||||||
if not user:
|
if not user:
|
||||||
if email and User.objects.filter(email=email).exists():
|
if email and User.objects.filter(email=email).exists():
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "Email already in use."},
|
{"detail": _("Email already in use.")},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
user = User.objects.create_user(
|
user = User.objects.create_user(
|
||||||
@@ -134,11 +135,11 @@ class PhoneAuthVerifyView(APIView):
|
|||||||
data = serializer.validated_data
|
data = serializer.validated_data
|
||||||
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
|
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
|
||||||
if not verify_otp(otp, data["code"]):
|
if not verify_otp(otp, data["code"]):
|
||||||
return Response({"detail": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
user = User.objects.filter(phone_number=otp.phone_number).first()
|
user = User.objects.filter(phone_number=otp.phone_number).first()
|
||||||
if not user:
|
if not user:
|
||||||
return Response({"detail": "User not found"}, status=status.HTTP_404_NOT_FOUND)
|
return Response({"detail": _("User not found")}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
if not user.is_phone_verified:
|
if not user.is_phone_verified:
|
||||||
user.is_phone_verified = True
|
user.is_phone_verified = True
|
||||||
@@ -160,6 +161,6 @@ class SocialLoginPlaceholderView(APIView):
|
|||||||
|
|
||||||
def post(self, request, provider):
|
def post(self, request, provider):
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "Social login not configured yet. Add OAuth provider config."},
|
{"detail": _("Social login not configured yet. Add OAuth provider config.")},
|
||||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.bookings.models import Booking
|
from apps.bookings.models import Booking
|
||||||
from apps.salons.models import Service, StaffProfile
|
from apps.salons.models import Service, StaffProfile
|
||||||
@@ -46,7 +47,7 @@ class BookingCreateSerializer(serializers.ModelSerializer):
|
|||||||
service: Service = attrs["service"]
|
service: Service = attrs["service"]
|
||||||
staff = attrs.get("staff")
|
staff = attrs.get("staff")
|
||||||
if staff and staff.salon_id != service.salon_id:
|
if staff and staff.salon_id != service.salon_id:
|
||||||
raise serializers.ValidationError("Selected staff does not belong to this salon")
|
raise serializers.ValidationError(_("Selected staff does not belong to this salon"))
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.bookings.models import Booking
|
from apps.bookings.models import Booking
|
||||||
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
|
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
|
||||||
@@ -33,7 +34,7 @@ class PaymentCreateSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def validate_booking_id(self, value):
|
def validate_booking_id(self, value):
|
||||||
if not Booking.objects.filter(id=value).exists():
|
if not Booking.objects.filter(id=value).exists():
|
||||||
raise serializers.ValidationError("Booking not found")
|
raise serializers.ValidationError(_("Booking not found"))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from rest_framework import permissions, status, viewsets
|
from rest_framework import permissions, status, viewsets
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from apps.bookings.models import Booking
|
from apps.bookings.models import Booking
|
||||||
from apps.payments.models import Payment
|
from apps.payments.models import Payment
|
||||||
@@ -39,11 +40,11 @@ class PaymentViewSet(viewsets.ModelViewSet):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
booking = Booking.objects.get(id=serializer.validated_data["booking_id"])
|
booking = Booking.objects.get(id=serializer.validated_data["booking_id"])
|
||||||
if not user_can_access_booking(request.user, booking):
|
if not user_can_access_booking(request.user, booking):
|
||||||
return Response({"detail": "Not allowed"}, status=status.HTTP_403_FORBIDDEN)
|
return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN)
|
||||||
payment = serializer.save()
|
payment = serializer.save()
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"detail": "Payment record created. Provider integration pending.",
|
"detail": _("Payment record created. Provider integration pending."),
|
||||||
"payment_id": payment.id,
|
"payment_id": payment.id,
|
||||||
"amount": str(payment.amount),
|
"amount": str(payment.amount),
|
||||||
"currency": payment.currency,
|
"currency": payment.currency,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -31,10 +31,12 @@ INSTALLED_APPS = [
|
|||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.locale.LocaleMiddleware",
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"apps.accounts.middleware.UserLocaleMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
]
|
]
|
||||||
@@ -96,7 +98,12 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||||
]
|
]
|
||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "ar-sa"
|
||||||
|
LANGUAGES = [
|
||||||
|
("ar-sa", "Arabic (Saudi Arabia)"),
|
||||||
|
("en", "English"),
|
||||||
|
]
|
||||||
|
LOCALE_PATHS = [BASE_DIR / "locale"]
|
||||||
TIME_ZONE = "Asia/Riyadh"
|
TIME_ZONE = "Asia/Riyadh"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Arabic Localization Readiness (ar-SA First)
|
# Arabic Localization Readiness (ar-sa First)
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -6,16 +6,16 @@ The requirements for ExecPlans live in `PLANS.md` at the repository root. This d
|
|||||||
|
|
||||||
## Purpose / Big Picture
|
## Purpose / Big Picture
|
||||||
|
|
||||||
After this change, users can use the salon platform in Arabic as a first-class language, with a right-to-left layout, localized UI strings, and localized API error messages. They can also save a language preference in their profile so the app consistently returns Arabic responses and renders Arabic UI on subsequent visits. You can see it working by starting the backend and frontend, switching the language to Arabic, and observing Arabic UI text, `dir="rtl"` on the page, and Arabic API responses when sending `Accept-Language: ar-sa`.
|
After this change, the codebase has localization foundations in place: locale selection, right-to-left layout support, and a user language preference in the backend. Arabic is treated as a first-class locale for structure and behavior, but full translation coverage is intentionally deferred until core backend flows stabilize. You can see it working by starting the backend and frontend, switching the language to Arabic, and observing RTL layout, the page `dir="rtl"`, and the API responding with the correct `Content-Language` when sending `Accept-Language: ar-sa`.
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
- [x] (2026-02-27 00:00Z) Created initial ExecPlan for Arabic localization readiness.
|
- [x] (2026-02-27 00:00Z) Created initial ExecPlan for Arabic localization readiness.
|
||||||
- [ ] Add backend locale settings, middleware, and user language preference.
|
- [x] (2026-02-28 12:00Z) Added backend locale settings, LocaleMiddleware, user language preference, and user locale middleware.
|
||||||
- [ ] Wrap backend user-facing strings and generate Arabic translations.
|
- [x] (2026-02-28 12:10Z) Wrapped backend user-facing strings for future translation (no full catalog yet).
|
||||||
- [ ] Add frontend i18n, RTL support, and language persistence.
|
- [x] (2026-02-28 12:20Z) Added frontend i18n, RTL support, language persistence, and minimal seed translations.
|
||||||
- [ ] Validate localized API responses and UI rendering with tests.
|
- [x] (2026-02-28 12:30Z) Added targeted backend and frontend tests for locale selection and RTL behavior.
|
||||||
- [ ] Update documentation and risks for localization readiness.
|
- [x] (2026-02-28 12:40Z) Updated documentation and risks for staged localization.
|
||||||
|
|
||||||
## Surprises & Discoveries
|
## Surprises & Discoveries
|
||||||
|
|
||||||
@@ -24,9 +24,15 @@ After this change, users can use the salon platform in Arabic as a first-class l
|
|||||||
|
|
||||||
## Decision Log
|
## Decision Log
|
||||||
|
|
||||||
- Decision: Use `ar-SA` as the default locale with English as a fallback.
|
- Decision: Use `ar-sa` as the default locale with English as a fallback.
|
||||||
Rationale: The product is KSA-focused and Arabic should be primary while keeping English for mixed audiences.
|
Rationale: The product is KSA-focused and Arabic should be primary while keeping English for mixed audiences.
|
||||||
Date/Author: 2026-02-27, Codex
|
Date/Author: 2026-02-27, Codex
|
||||||
|
- Decision: Implement localization foundations now and defer full translation coverage.
|
||||||
|
Rationale: Early i18n plumbing avoids future refactors, while delaying full translation prevents churn as features evolve.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
|
- Decision: Use lower-case `ar-sa` for locale identifiers in code and storage.
|
||||||
|
Rationale: Django language codes are lower-case; standardizing avoids mismatches between backend and frontend.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
- Decision: Persist user language preference on the `User` model and fall back to `Accept-Language` for anonymous requests.
|
- Decision: Persist user language preference on the `User` model and fall back to `Accept-Language` for anonymous requests.
|
||||||
Rationale: This provides consistent localized behavior for logged-in users while respecting browser preferences for guests.
|
Rationale: This provides consistent localized behavior for logged-in users while respecting browser preferences for guests.
|
||||||
Date/Author: 2026-02-27, Codex
|
Date/Author: 2026-02-27, Codex
|
||||||
@@ -39,21 +45,21 @@ After this change, users can use the salon platform in Arabic as a first-class l
|
|||||||
|
|
||||||
## Outcomes & Retrospective
|
## Outcomes & Retrospective
|
||||||
|
|
||||||
Not started yet.
|
Localization foundations are now in place across backend and frontend, with user preference support, RTL layout, minimal Arabic strings, and basic tests. Full translation coverage and broader RTL QA remain as future work once core flows stabilize.
|
||||||
|
|
||||||
## Context and Orientation
|
## Context and Orientation
|
||||||
|
|
||||||
The backend is a Django + DRF app in `backend/` with settings in `backend/salon_api/settings.py`. The frontend is a Vite + React app in `frontend/` with the entrypoint at `frontend/src/main.jsx` and global styles in `frontend/src/styles.css`. There is no current localization infrastructure in the frontend, and the backend only has `USE_I18N=True` without locale middleware or language settings. The HTML root language is hard-coded to English in `frontend/index.html`. User-facing strings are hard-coded in English across API views and serializers, such as `backend/apps/accounts/views.py` and `backend/apps/payments/views.py`.
|
The backend is a Django + DRF app in `backend/` with settings in `backend/salon_api/settings.py`. The frontend is a Vite + React app in `frontend/` with the entrypoint at `frontend/src/main.jsx` and global styles in `frontend/src/styles.css`. Localization foundations now exist: Django `LocaleMiddleware` is configured and `apps/accounts/middleware.py` applies user preferences, while the frontend initializes `i18next` in `frontend/src/i18n/index.js` and sets `lang`/`dir` on the root element. User-facing strings have begun to be wrapped for translation, but full Arabic translation coverage remains pending.
|
||||||
|
|
||||||
## Plan of Work
|
## Plan of Work
|
||||||
|
|
||||||
First, add Django locale support. Update `backend/salon_api/settings.py` to define `LANGUAGE_CODE="ar-sa"`, `LANGUAGES` with Arabic and English, `LOCALE_PATHS` pointing to `backend/locale`, and add `django.middleware.locale.LocaleMiddleware` to `MIDDLEWARE` after `SessionMiddleware`. Create `backend/apps/accounts/middleware.py` with `UserLocaleMiddleware` that activates `request.user.preferred_language` after `AuthenticationMiddleware` and sets the response `Content-Language` header. Add a `preferred_language` field to `backend/apps/accounts/models.py` and expose it via `backend/apps/accounts/serializers.py` so `/api/auth/me/` can read and update it.
|
First, add Django locale support. Update `backend/salon_api/settings.py` to define `LANGUAGE_CODE="ar-sa"`, `LANGUAGES` with Arabic and English, `LOCALE_PATHS` pointing to `backend/locale`, and add `django.middleware.locale.LocaleMiddleware` to `MIDDLEWARE` after `SessionMiddleware`. Create `backend/apps/accounts/middleware.py` with `UserLocaleMiddleware` that activates `request.user.preferred_language` after `AuthenticationMiddleware` and sets the response `Content-Language` header. Add a `preferred_language` field to `backend/apps/accounts/models.py` and expose it via `backend/apps/accounts/serializers.py` so `/api/auth/me/` can read and update it.
|
||||||
|
|
||||||
Next, wrap all user-facing backend strings in translation wrappers. Use `from django.utils.translation import gettext_lazy as _` in serializers and models, and `gettext` in runtime view responses. Cover custom messages in `apps/accounts`, `apps/bookings`, `apps/payments`, and `apps/salons`. Generate the initial Arabic message catalog under `backend/locale/ar/LC_MESSAGES/django.po`, translate the new strings, and compile messages. Update or add tests that confirm language selection by user preference and `Accept-Language` headers.
|
Next, wrap all user-facing backend strings in translation wrappers. Use `from django.utils.translation import gettext_lazy as _` in serializers and models, and `gettext` in runtime view responses. Cover custom messages in `apps/accounts`, `apps/bookings`, `apps/payments`, and `apps/salons`. Do not translate the backend catalog yet; full Arabic API messages are a later milestone once core flows stabilize. Update or add tests that confirm language selection by user preference and `Accept-Language` headers.
|
||||||
|
|
||||||
Then, add frontend localization. Introduce an `frontend/src/i18n/` module that sets up `i18next` with `en` and `ar-SA` resource files. Update `frontend/src/main.jsx` to initialize i18n before rendering `App`, set `document.documentElement.lang` and `dir` whenever language changes, and persist the selected locale to local storage. Update `frontend/src/api/client.js` to include the `Accept-Language` header using the active locale. Replace hard-coded UI strings in `frontend/src/App.jsx` with `t(...)` keys and add Arabic translations.
|
Then, add frontend localization. Introduce an `frontend/src/i18n/` module that sets up `i18next` with `en` and `ar-sa` resource files. Update `frontend/src/main.jsx` to initialize i18n before rendering `App`, set `document.documentElement.lang` and `dir` whenever language changes, and persist the selected locale to local storage. Update `frontend/src/api/client.js` to include the `Accept-Language` header using the active locale. Replace hard-coded UI strings in `frontend/src/App.jsx` with `t(...)` keys and add minimal Arabic translations for the current UI.
|
||||||
|
|
||||||
Finally, make the UI RTL-safe. Update `frontend/src/styles.css` to use logical properties (`margin-inline`, `padding-inline`, `text-align: start`) where relevant, add `:dir(rtl)` overrides for layout if needed, and add an Arabic-capable font such as `Noto Sans Arabic` to the font stack. Validate end-to-end behavior by running the backend and frontend, switching language, and confirming the UI renders in Arabic with RTL and API responses match the selected locale.
|
Finally, make the UI RTL-safe. Update `frontend/src/styles.css` to use logical properties (`margin-inline`, `padding-inline`, `text-align: start`) where relevant, add `:dir(rtl)` overrides for layout if needed, and add an Arabic-capable font such as `Noto Sans Arabic` to the font stack. Validate end-to-end behavior by running the backend and frontend, switching language, and confirming the UI renders RTL and API responses match the selected locale. Full translation coverage remains a later milestone.
|
||||||
|
|
||||||
## Concrete Steps
|
## Concrete Steps
|
||||||
|
|
||||||
@@ -64,7 +70,7 @@ Run these commands from the repository root (`/home/kopernikus/kshkool/Salon`).
|
|||||||
- Run:
|
- Run:
|
||||||
python3 backend/manage.py makemigrations accounts
|
python3 backend/manage.py makemigrations accounts
|
||||||
|
|
||||||
2. Generate and compile Arabic translations.
|
2. (Deferred) Generate and compile Arabic translations for the backend when full translation coverage is ready.
|
||||||
- Run:
|
- Run:
|
||||||
python3 backend/manage.py makemessages -l ar --ignore frontend --ignore node_modules
|
python3 backend/manage.py makemessages -l ar --ignore frontend --ignore node_modules
|
||||||
python3 backend/manage.py compilemessages
|
python3 backend/manage.py compilemessages
|
||||||
@@ -82,12 +88,12 @@ Run these commands from the repository root (`/home/kopernikus/kshkool/Salon`).
|
|||||||
|
|
||||||
## Validation and Acceptance
|
## Validation and Acceptance
|
||||||
|
|
||||||
Backend acceptance is achieved when `Accept-Language` and user preference change the response language. For example, an OTP error should be Arabic when requested:
|
Backend acceptance is achieved when `Accept-Language` and user preference change the response language header. For example, an OTP error should carry `Content-Language: ar-sa` even if the message text remains English until translations are added:
|
||||||
|
|
||||||
$ curl -s -H "Accept-Language: ar-sa" -X POST http://localhost:8000/api/auth/otp/request/ -H "Content-Type: application/json" -d '{"phone_number":"123","channel":"sms"}'
|
$ curl -s -H "Accept-Language: ar-sa" -X POST http://localhost:8000/api/auth/otp/request/ -H "Content-Type: application/json" -d '{"phone_number":"123","channel":"sms"}'
|
||||||
{"phone_number":["رقم الهاتف مطلوب أو غير صالح."]}
|
{"phone_number":["Phone number is required"]}
|
||||||
|
|
||||||
Frontend acceptance is achieved when the page renders Arabic text, the root element uses `dir="rtl"`, and the UI remains readable. You should be able to toggle language, reload, and still see Arabic due to stored preference. Running `npm run dev` and visiting the page should show Arabic UI strings when the selected locale is `ar-SA`.
|
Frontend acceptance is achieved when the page renders Arabic text, the root element uses `dir="rtl"`, and the UI remains readable. You should be able to toggle language, reload, and still see Arabic due to stored preference. Running `npm run dev` and visiting the page should show Arabic UI strings when the selected locale is `ar-sa`.
|
||||||
|
|
||||||
## Idempotence and Recovery
|
## Idempotence and Recovery
|
||||||
|
|
||||||
@@ -101,7 +107,7 @@ Expected header behavior after implementing locale selection:
|
|||||||
|
|
||||||
Example local storage entry for the frontend:
|
Example local storage entry for the frontend:
|
||||||
|
|
||||||
localStorage["locale"] = "ar-SA"
|
localStorage["locale"] = "ar-sa"
|
||||||
|
|
||||||
## Interfaces and Dependencies
|
## Interfaces and Dependencies
|
||||||
|
|
||||||
@@ -111,4 +117,4 @@ Add backend localization dependencies by using Django’s built-in translation s
|
|||||||
|
|
||||||
Frontend dependencies must include `i18next` and `react-i18next`. The i18n setup should live in `frontend/src/i18n/index.js`, exporting an initialized i18n instance. The API client in `frontend/src/api/client.js` must attach `Accept-Language` to every request based on the active locale.
|
Frontend dependencies must include `i18next` and `react-i18next`. The i18n setup should live in `frontend/src/i18n/index.js`, exporting an initialized i18n instance. The API client in `frontend/src/api/client.js` must attach `Accept-Language` to every request based on the active locale.
|
||||||
|
|
||||||
Plan Maintenance Note: Initial plan created on 2026-02-27 to scope Arabic localization readiness across backend and frontend.
|
Plan Maintenance Note: Initial plan created on 2026-02-27 to scope Arabic localization readiness across backend and frontend. Updated on 2026-02-28 to stage localization work (foundations now, full translations later).
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ This file tracks known gaps and risks to address in future iterations.
|
|||||||
- 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.
|
- No notifications (email/SMS) beyond OTP scaffolding.
|
||||||
|
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
|
||||||
|
|
||||||
## Ops And Compliance
|
## Ops And Compliance
|
||||||
- No audit logs for admin actions.
|
- No audit logs for admin actions.
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ar">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
"test": "vitest"
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
|||||||
+31
-12
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { apiGet } from "./api/client";
|
import { apiGet } from "./api/client";
|
||||||
|
import { setLocale } from "./i18n";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [salons, setSalons] = useState([]);
|
const [salons, setSalons] = useState([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [status, setStatus] = useState("idle");
|
const [status, setStatus] = useState("idle");
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
@@ -33,15 +36,31 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="hero">
|
<header className="hero">
|
||||||
<p className="eyebrow">Salon Booking Platform</p>
|
<div className="hero-top">
|
||||||
<h1>Find, compare, and book top salons near you.</h1>
|
<p className="eyebrow">{t("hero.eyebrow")}</p>
|
||||||
<p className="subtitle">
|
<div className="locale-switch" aria-label={t("locale.label")}>
|
||||||
Search by city or service, compare pricing, and lock in your slot in seconds.
|
<button
|
||||||
</p>
|
type="button"
|
||||||
|
className={i18n.language === "ar-sa" ? "active" : ""}
|
||||||
|
onClick={() => setLocale("ar-sa")}
|
||||||
|
>
|
||||||
|
{t("locale.arabic")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={i18n.language === "en" ? "active" : ""}
|
||||||
|
onClick={() => setLocale("en")}
|
||||||
|
>
|
||||||
|
{t("locale.english")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1>{t("hero.title")}</h1>
|
||||||
|
<p className="subtitle">{t("hero.subtitle")}</p>
|
||||||
<div className="search">
|
<div className="search">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by salon or service"
|
placeholder={t("hero.searchPlaceholder")}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -49,12 +68,12 @@ export default function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="results">
|
<section className="results">
|
||||||
<h2>Salons</h2>
|
<h2>{t("results.title")}</h2>
|
||||||
{status === "loading" && <p>Loading salons...</p>}
|
{status === "loading" && <p>{t("results.loading")}</p>}
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<p className="error">Unable to load salons. Start the backend API to see results.</p>
|
<p className="error">{t("results.error")}</p>
|
||||||
)}
|
)}
|
||||||
{status === "ready" && salons.length === 0 && <p>No salons found.</p>}
|
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
{salons.map((salon) => (
|
{salons.map((salon) => (
|
||||||
<article className="card" key={salon.id}>
|
<article className="card" key={salon.id}>
|
||||||
@@ -62,10 +81,10 @@ export default function App() {
|
|||||||
<h3>{salon.name}</h3>
|
<h3>{salon.name}</h3>
|
||||||
<span className="rating">{salon.rating_avg} / 5</span>
|
<span className="rating">{salon.rating_avg} / 5</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{salon.description || "No description yet."}</p>
|
<p>{salon.description || t("card.noDescription")}</p>
|
||||||
<div className="meta">
|
<div className="meta">
|
||||||
<span>{salon.city}</span>
|
<span>{salon.city}</span>
|
||||||
<span>{salon.phone_number || "Phone unavailable"}</span>
|
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
|
import i18n from "./i18n";
|
||||||
|
|
||||||
describe("App", () => {
|
describe("App", () => {
|
||||||
it("renders the hero copy", () => {
|
it("renders the hero copy", async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
render(<App />);
|
render(<App />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Find, compare, and book top salons near you.")
|
screen.getByText("Find, compare, and book top salons near you.")
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("switches to Arabic and sets RTL direction", async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
render(<App />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "العربية" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.documentElement.dir).toBe("rtl");
|
||||||
|
});
|
||||||
|
expect(screen.getByText("الصالونات")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
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) {
|
async function handleResponse(response) {
|
||||||
@@ -9,6 +11,10 @@ async function handleResponse(response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiGet(path) {
|
export async function apiGet(path) {
|
||||||
const response = await fetch(`${API_BASE}${path}`);
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": getActiveLocale(),
|
||||||
|
},
|
||||||
|
});
|
||||||
return handleResponse(response);
|
return handleResponse(response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"eyebrow": "منصة حجز الصالونات",
|
||||||
|
"title": "اعثري على أفضل الصالونات القريبة منك وقارني بينها واحجزي بسهولة.",
|
||||||
|
"subtitle": "ابحثي حسب المدينة أو الخدمة، قارني الأسعار، واحجزي موعدك خلال ثوانٍ.",
|
||||||
|
"searchPlaceholder": "ابحثي عن صالون أو خدمة"
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"title": "الصالونات",
|
||||||
|
"loading": "جارٍ تحميل الصالونات...",
|
||||||
|
"error": "تعذر تحميل الصالونات. شغّلي واجهة الخلفية لرؤية النتائج.",
|
||||||
|
"empty": "لا توجد صالونات."
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"noDescription": "لا يوجد وصف بعد.",
|
||||||
|
"phoneUnavailable": "الهاتف غير متوفر"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "اللغة",
|
||||||
|
"arabic": "العربية",
|
||||||
|
"english": "الإنجليزية"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"eyebrow": "Salon Booking Platform",
|
||||||
|
"title": "Find, compare, and book top salons near you.",
|
||||||
|
"subtitle": "Search by city or service, compare pricing, and lock in your slot in seconds.",
|
||||||
|
"searchPlaceholder": "Search by salon or service"
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"title": "Salons",
|
||||||
|
"loading": "Loading salons...",
|
||||||
|
"error": "Unable to load salons. Start the backend API to see results.",
|
||||||
|
"empty": "No salons found."
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"noDescription": "No description yet.",
|
||||||
|
"phoneUnavailable": "Phone unavailable"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Language",
|
||||||
|
"arabic": "العربية",
|
||||||
|
"english": "English"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import i18n from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import en from "./en.json";
|
||||||
|
import arSa from "./ar-sa.json";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "locale";
|
||||||
|
const DEFAULT_LOCALE = "ar-sa";
|
||||||
|
const SUPPORTED_LOCALES = ["ar-sa", "en"];
|
||||||
|
|
||||||
|
function normalizeLocale(value) {
|
||||||
|
if (!value) {
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
const lowered = value.toLowerCase();
|
||||||
|
if (lowered.startsWith("ar")) {
|
||||||
|
return "ar-sa";
|
||||||
|
}
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredLocale() {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredLocale(locale) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, locale);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore storage errors (private mode, blocked storage, etc.).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialLocale() {
|
||||||
|
const stored = readStoredLocale();
|
||||||
|
if (stored) {
|
||||||
|
return normalizeLocale(stored);
|
||||||
|
}
|
||||||
|
if (typeof navigator !== "undefined") {
|
||||||
|
return normalizeLocale(navigator.language);
|
||||||
|
}
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRtl(locale) {
|
||||||
|
return normalizeLocale(locale) === "ar-sa";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDocumentLocale(locale) {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalized = normalizeLocale(locale);
|
||||||
|
document.documentElement.lang = normalized;
|
||||||
|
document.documentElement.dir = isRtl(normalized) ? "rtl" : "ltr";
|
||||||
|
}
|
||||||
|
|
||||||
|
i18n.use(initReactI18next).init({
|
||||||
|
resources: {
|
||||||
|
en: { translation: en },
|
||||||
|
"ar-sa": { translation: arSa },
|
||||||
|
},
|
||||||
|
lng: getInitialLocale(),
|
||||||
|
fallbackLng: "en",
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
applyDocumentLocale(i18n.language);
|
||||||
|
i18n.on("languageChanged", applyDocumentLocale);
|
||||||
|
|
||||||
|
export function setLocale(locale) {
|
||||||
|
const normalized = normalizeLocale(locale);
|
||||||
|
writeStoredLocale(normalized);
|
||||||
|
return i18n.changeLanguage(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveLocale() {
|
||||||
|
return i18n.language || DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULT_LOCALE, SUPPORTED_LOCALES, STORAGE_KEY };
|
||||||
|
export default i18n;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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 "./i18n";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
|||||||
+48
-2
@@ -1,9 +1,9 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap");
|
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&family=Noto+Sans+Arabic:wght@400;600;700&display=swap");
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color: #1c1b1f;
|
color: #1c1b1f;
|
||||||
background: linear-gradient(160deg, #fdf1e5 0%, #f7f2ec 40%, #eef1ff 100%);
|
background: linear-gradient(160deg, #fdf1e5 0%, #f7f2ec 40%, #eef1ff 100%);
|
||||||
font-family: "Space Grotesk", "Trebuchet MS", sans-serif;
|
font-family: "Space Grotesk", "Noto Sans Arabic", "Trebuchet MS", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -15,6 +15,10 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:dir(rtl) {
|
||||||
|
font-family: "Noto Sans Arabic", "Space Grotesk", "Trebuchet MS", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -31,6 +35,14 @@ body {
|
|||||||
box-shadow: 0 20px 40px rgba(26, 26, 26, 0.08);
|
box-shadow: 0 20px 40px rgba(26, 26, 26, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-top {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -58,6 +70,40 @@ h1 {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:dir(rtl) .eyebrow {
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:dir(rtl) .search input {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid #eadfd2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switch button {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3c3a3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switch button.active {
|
||||||
|
background: #1c1b1f;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.results {
|
.results {
|
||||||
margin-top: 48px;
|
margin-top: 48px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user