diff --git a/AGENTS.md b/AGENTS.md index 383922b..a0e3142 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ Build a reliable, maintainable salon booking platform with Django (backend) and - Booking integrity (availability, staff schedules, overlap prevention). - Payments via Moyasar (payment creation, webhooks, reconciliation). - Notifications for booking lifecycle. +- Localization foundations (i18n plumbing, RTL readiness, locale preferences). - Tests for critical flows. ### Phase 2: Manager Ops @@ -16,6 +17,7 @@ Build a reliable, maintainable salon booking platform with Django (backend) and - Calendar view + rescheduling/cancellation rules. - Reviews/ratings with moderation and recompute. - Reports (revenue, popular services, customer trends). +- Full translation coverage and Arabic UX polish. ### Phase 3: Scale & Compliance - Audit logs and data export. diff --git a/PLANS.md b/PLANS.md index 455b9cd..6c5997b 100644 --- a/PLANS.md +++ b/PLANS.md @@ -4,7 +4,7 @@ This document describes the requirements for an execution plan ("ExecPlan"), a d ## Active ExecPlans -The current execution plan is `docs/execplans/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 diff --git a/backend/apps/accounts/middleware.py b/backend/apps/accounts/middleware.py new file mode 100644 index 0000000..d0134a8 --- /dev/null +++ b/backend/apps/accounts/middleware.py @@ -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 diff --git a/backend/apps/accounts/migrations/0003_preferred_language.py b/backend/apps/accounts/migrations/0003_preferred_language.py new file mode 100644 index 0000000..1868cb9 --- /dev/null +++ b/backend/apps/accounts/migrations/0003_preferred_language.py @@ -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, + ), + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 0c2c6c3..c451775 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -47,6 +47,11 @@ class User(AbstractBaseUser, PermissionsMixin): first_name = models.CharField(max_length=150, blank=True) last_name = models.CharField(max_length=150, blank=True) 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_staff = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 045b21d..66909f2 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -10,7 +10,16 @@ User = get_user_model() class UserSerializer(serializers.ModelSerializer): class Meta: 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"] diff --git a/backend/apps/accounts/services/otp.py b/backend/apps/accounts/services/otp.py index d17ca5c..ce95686 100644 --- a/backend/apps/accounts/services/otp.py +++ b/backend/apps/accounts/services/otp.py @@ -8,6 +8,8 @@ from django.conf import settings from django.contrib.auth.hashers import check_password, make_password from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP logger = logging.getLogger(__name__) @@ -21,13 +23,13 @@ class OtpSendResult: class OtpRateLimitError(RuntimeError): 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 class OtpCooldownError(RuntimeError): 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 @@ -56,17 +58,17 @@ class TwilioOtpProvider(BaseOtpProvider): def _assert_config(self) -> None: if not self.account_sid or not self.auth_token or not self.from_number: - raise ValueError("Twilio credentials are not configured") + raise ValueError(_("Twilio credentials are not configured")) def send_sms(self, to_number: str, message: str) -> None: 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: self._assert_config() if not self.whatsapp_from: - raise ValueError("Twilio WhatsApp sender is not configured") - raise NotImplementedError("Twilio WhatsApp adapter not implemented yet") + raise ValueError(_("Twilio WhatsApp sender is not configured")) + raise NotImplementedError(_("Twilio WhatsApp adapter not implemented yet")) class UnifonicOtpProvider(BaseOtpProvider): @@ -77,17 +79,17 @@ class UnifonicOtpProvider(BaseOtpProvider): def _assert_config(self) -> None: 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: 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: self._assert_config() if not self.whatsapp_sender: - raise ValueError("Unifonic WhatsApp sender is not configured") - raise NotImplementedError("Unifonic WhatsApp adapter not implemented yet") + raise ValueError(_("Unifonic WhatsApp sender is not configured")) + raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet")) PROVIDERS = { @@ -101,7 +103,7 @@ def get_provider() -> BaseOtpProvider: provider_key = settings.OTP_PROVIDER provider_cls = PROVIDERS.get(provider_key) 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() @@ -147,13 +149,15 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo 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: provider.send_sms(phone_number, message) elif channel == OtpChannel.WHATSAPP: provider.send_whatsapp(phone_number, message) else: - raise ValueError("Unsupported OTP channel") + raise ValueError(_("Unsupported OTP channel")) return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat()) diff --git a/backend/apps/accounts/services/phone.py b/backend/apps/accounts/services/phone.py index 2c54730..84ed181 100644 --- a/backend/apps/accounts/services/phone.py +++ b/backend/apps/accounts/services/phone.py @@ -1,9 +1,11 @@ import re +from django.utils.translation import gettext as _ + def normalize_phone_number(raw_phone: str) -> str: if not raw_phone: - raise ValueError("Phone number is required") + raise ValueError(_("Phone number is required")) phone = re.sub(r"[\s\-\(\)]", "", raw_phone) if phone.startswith("00"): @@ -12,7 +14,7 @@ def normalize_phone_number(raw_phone: str) -> str: if phone.startswith("+"): digits = phone[1:] if not digits.isdigit() or not (8 <= len(digits) <= 15): - raise ValueError("Invalid phone number format") + raise ValueError(_("Invalid phone number format")) return "+" + digits 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: 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")) diff --git a/backend/apps/accounts/tests/test_locale.py b/backend/apps/accounts/tests/test_locale.py new file mode 100644 index 0000000..f5eaa7f --- /dev/null +++ b/backend/apps/accounts/tests/test_locale.py @@ -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" diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index 857a598..dedfd13 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404 from rest_framework import generics, permissions, status from rest_framework.response import Response +from django.utils.translation import gettext as _ from rest_framework.views import APIView from rest_framework_simplejwt.tokens import RefreshToken @@ -71,14 +72,14 @@ class OTPVerifyView(APIView): data = serializer.validated_data otp = get_object_or_404(PhoneOTP, id=data["request_id"]) 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() if user and not user.is_phone_verified: user.is_phone_verified = True 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): @@ -95,7 +96,7 @@ class PhoneAuthRequestView(APIView): if not user: if email and User.objects.filter(email=email).exists(): return Response( - {"detail": "Email already in use."}, + {"detail": _("Email already in use.")}, status=status.HTTP_400_BAD_REQUEST, ) user = User.objects.create_user( @@ -134,11 +135,11 @@ class PhoneAuthVerifyView(APIView): data = serializer.validated_data otp = get_object_or_404(PhoneOTP, id=data["request_id"]) 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() 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: user.is_phone_verified = True @@ -160,6 +161,6 @@ class SocialLoginPlaceholderView(APIView): def post(self, request, provider): 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, ) diff --git a/backend/apps/bookings/serializers.py b/backend/apps/bookings/serializers.py index fd6fa28..e006361 100644 --- a/backend/apps/bookings/serializers.py +++ b/backend/apps/bookings/serializers.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from django.utils.translation import gettext_lazy as _ from apps.bookings.models import Booking from apps.salons.models import Service, StaffProfile @@ -46,7 +47,7 @@ class BookingCreateSerializer(serializers.ModelSerializer): service: Service = attrs["service"] staff = attrs.get("staff") 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 def create(self, validated_data): diff --git a/backend/apps/payments/serializers.py b/backend/apps/payments/serializers.py index b21dae4..2f0fac4 100644 --- a/backend/apps/payments/serializers.py +++ b/backend/apps/payments/serializers.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from django.utils.translation import gettext_lazy as _ from apps.bookings.models import Booking from apps.payments.models import Payment, PaymentProvider, PaymentStatus @@ -33,7 +34,7 @@ class PaymentCreateSerializer(serializers.ModelSerializer): def validate_booking_id(self, value): if not Booking.objects.filter(id=value).exists(): - raise serializers.ValidationError("Booking not found") + raise serializers.ValidationError(_("Booking not found")) return value def create(self, validated_data): diff --git a/backend/apps/payments/views.py b/backend/apps/payments/views.py index f4ce014..6081c77 100644 --- a/backend/apps/payments/views.py +++ b/backend/apps/payments/views.py @@ -1,5 +1,6 @@ from rest_framework import permissions, status, viewsets from rest_framework.response import Response +from django.utils.translation import gettext as _ from apps.bookings.models import Booking from apps.payments.models import Payment @@ -39,11 +40,11 @@ class PaymentViewSet(viewsets.ModelViewSet): serializer.is_valid(raise_exception=True) booking = Booking.objects.get(id=serializer.validated_data["booking_id"]) 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() return Response( { - "detail": "Payment record created. Provider integration pending.", + "detail": _("Payment record created. Provider integration pending."), "payment_id": payment.id, "amount": str(payment.amount), "currency": payment.currency, diff --git a/backend/locale/.gitkeep b/backend/locale/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/locale/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/salon_api/settings.py b/backend/salon_api/settings.py index 3d0f54d..eea1364 100644 --- a/backend/salon_api/settings.py +++ b/backend/salon_api/settings.py @@ -31,10 +31,12 @@ INSTALLED_APPS = [ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "apps.accounts.middleware.UserLocaleMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] @@ -96,7 +98,12 @@ AUTH_PASSWORD_VALIDATORS = [ {"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" USE_I18N = True USE_TZ = True diff --git a/docs/execplans/arabic-localization.md b/docs/execplans/arabic-localization.md index f45b643..37dcef9 100644 --- a/docs/execplans/arabic-localization.md +++ b/docs/execplans/arabic-localization.md @@ -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. @@ -6,16 +6,16 @@ The requirements for ExecPlans live in `PLANS.md` at the repository root. This d ## 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 - [x] (2026-02-27 00:00Z) Created initial ExecPlan for Arabic localization readiness. -- [ ] Add backend locale settings, middleware, and user language preference. -- [ ] Wrap backend user-facing strings and generate Arabic translations. -- [ ] Add frontend i18n, RTL support, and language persistence. -- [ ] Validate localized API responses and UI rendering with tests. -- [ ] Update documentation and risks for localization readiness. +- [x] (2026-02-28 12:00Z) Added backend locale settings, LocaleMiddleware, user language preference, and user locale middleware. +- [x] (2026-02-28 12:10Z) Wrapped backend user-facing strings for future translation (no full catalog yet). +- [x] (2026-02-28 12:20Z) Added frontend i18n, RTL support, language persistence, and minimal seed translations. +- [x] (2026-02-28 12:30Z) Added targeted backend and frontend tests for locale selection and RTL behavior. +- [x] (2026-02-28 12:40Z) Updated documentation and risks for staged localization. ## 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: 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. 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. Rationale: This provides consistent localized behavior for logged-in users while respecting browser preferences for guests. 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 -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 -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 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 @@ -64,7 +70,7 @@ Run these commands from the repository root (`/home/kopernikus/kshkool/Salon`). - Run: 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: python3 backend/manage.py makemessages -l ar --ignore frontend --ignore node_modules python3 backend/manage.py compilemessages @@ -82,12 +88,12 @@ Run these commands from the repository root (`/home/kopernikus/kshkool/Salon`). ## 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"}' - {"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 @@ -101,7 +107,7 @@ Expected header behavior after implementing locale selection: Example local storage entry for the frontend: - localStorage["locale"] = "ar-SA" + localStorage["locale"] = "ar-sa" ## 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. -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). diff --git a/docs/risks.md b/docs/risks.md index e1b9989..d387a4f 100644 --- a/docs/risks.md +++ b/docs/risks.md @@ -23,6 +23,7 @@ This file tracks known gaps and risks to address in future iterations. - Ratings are not recalculated from reviews. - No image upload or storage strategy for photos. - 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 - No audit logs for admin actions. diff --git a/frontend/index.html b/frontend/index.html index 4057339..21083d5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,5 +1,5 @@ - + diff --git a/frontend/package.json b/frontend/package.json index 02ce10b..01bc678 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,10 @@ "test": "vitest" }, "dependencies": { + "i18next": "^23.11.5", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-i18next": "^14.1.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.2.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e2b2fd4..bdba936 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,10 +1,13 @@ import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { apiGet } from "./api/client"; +import { setLocale } from "./i18n"; export default function App() { const [salons, setSalons] = useState([]); const [query, setQuery] = useState(""); const [status, setStatus] = useState("idle"); + const { t, i18n } = useTranslation(); useEffect(() => { let ignore = false; @@ -33,15 +36,31 @@ export default function App() { return (
-

Salon Booking Platform

-

Find, compare, and book top salons near you.

-

- Search by city or service, compare pricing, and lock in your slot in seconds. -

+
+

{t("hero.eyebrow")}

+
+ + +
+
+

{t("hero.title")}

+

{t("hero.subtitle")}

setQuery(event.target.value)} /> @@ -49,12 +68,12 @@ export default function App() {
-

Salons

- {status === "loading" &&

Loading salons...

} +

{t("results.title")}

+ {status === "loading" &&

{t("results.loading")}

} {status === "error" && ( -

Unable to load salons. Start the backend API to see results.

+

{t("results.error")}

)} - {status === "ready" && salons.length === 0 &&

No salons found.

} + {status === "ready" && salons.length === 0 &&

{t("results.empty")}

}
{salons.map((salon) => (
@@ -62,10 +81,10 @@ export default function App() {

{salon.name}

{salon.rating_avg} / 5
-

{salon.description || "No description yet."}

+

{salon.description || t("card.noDescription")}

{salon.city} - {salon.phone_number || "Phone unavailable"} + {salon.phone_number || t("card.phoneUnavailable")}
))} diff --git a/frontend/src/App.test.jsx b/frontend/src/App.test.jsx index ac62629..97ba514 100644 --- a/frontend/src/App.test.jsx +++ b/frontend/src/App.test.jsx @@ -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 i18n from "./i18n"; describe("App", () => { - it("renders the hero copy", () => { + it("renders the hero copy", async () => { + await i18n.changeLanguage("en"); render(); expect( screen.getByText("Find, compare, and book top salons near you.") ).toBeInTheDocument(); }); + + it("switches to Arabic and sets RTL direction", async () => { + await i18n.changeLanguage("en"); + render(); + fireEvent.click(screen.getByRole("button", { name: "العربية" })); + await waitFor(() => { + expect(document.documentElement.dir).toBe("rtl"); + }); + expect(screen.getByText("الصالونات")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index fd3a206..3538f4a 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -1,3 +1,5 @@ +import { getActiveLocale } from "../i18n"; + const API_BASE = import.meta.env.VITE_API_BASE || "/api"; async function handleResponse(response) { @@ -9,6 +11,10 @@ async function handleResponse(response) { } 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); } diff --git a/frontend/src/i18n/ar-sa.json b/frontend/src/i18n/ar-sa.json new file mode 100644 index 0000000..5cae293 --- /dev/null +++ b/frontend/src/i18n/ar-sa.json @@ -0,0 +1,23 @@ +{ + "hero": { + "eyebrow": "منصة حجز الصالونات", + "title": "اعثري على أفضل الصالونات القريبة منك وقارني بينها واحجزي بسهولة.", + "subtitle": "ابحثي حسب المدينة أو الخدمة، قارني الأسعار، واحجزي موعدك خلال ثوانٍ.", + "searchPlaceholder": "ابحثي عن صالون أو خدمة" + }, + "results": { + "title": "الصالونات", + "loading": "جارٍ تحميل الصالونات...", + "error": "تعذر تحميل الصالونات. شغّلي واجهة الخلفية لرؤية النتائج.", + "empty": "لا توجد صالونات." + }, + "card": { + "noDescription": "لا يوجد وصف بعد.", + "phoneUnavailable": "الهاتف غير متوفر" + }, + "locale": { + "label": "اللغة", + "arabic": "العربية", + "english": "الإنجليزية" + } +} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json new file mode 100644 index 0000000..d54c9df --- /dev/null +++ b/frontend/src/i18n/en.json @@ -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" + } +} diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js new file mode 100644 index 0000000..035ed13 --- /dev/null +++ b/frontend/src/i18n/index.js @@ -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; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index f4379c7..3f1cbd1 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.jsx"; +import "./i18n"; import "./styles.css"; ReactDOM.createRoot(document.getElementById("root")).render( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index dc2c8b3..fbc403e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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 { color: #1c1b1f; 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; } +:dir(rtl) { + font-family: "Noto Sans Arabic", "Space Grotesk", "Trebuchet MS", sans-serif; +} + .page { max-width: 1100px; margin: 0 auto; @@ -31,6 +35,14 @@ body { 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 { letter-spacing: 0.2em; text-transform: uppercase; @@ -58,6 +70,40 @@ h1 { 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 { margin-top: 48px; }