commit fc06bb6fcddfecd6b62afe3d0b039bfef402a66d Author: mohammad Date: Fri Feb 27 15:01:06 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3229932 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Python +__pycache__/ +*.py[cod] +*.sqlite3 +*.log +.env +.venv/ +venv/ + +# Django +/staticfiles/ +/media/ + +# Node +node_modules/ +dist/ + +# OS +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..1500280 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Salon Booking Platform + +Scaffolded Django + React starter for a salon booking platform. + +## Backend + +Location: `backend/` + +### Setup + +1. Create a virtualenv and install dependencies. +2. Copy `backend/.env.example` to `backend/.env` and adjust values. +3. Run migrations and start the server. + +### Core API endpoints (current scaffold) + +- `POST /api/auth/register/` +- `POST /api/auth/token/` +- `POST /api/auth/token/refresh/` +- `GET/PATCH /api/auth/me/` +- `POST /api/auth/otp/request/` +- `POST /api/auth/otp/verify/` +- `POST /api/auth/social//` (placeholder) +- `GET /api/salons/` +- `GET /api/salons//` +- `GET /api/salons//services/` +- `GET /api/salons//staff/` +- `GET /api/salons//reviews/` +- `GET/POST /api/bookings/` +- `GET /api/bookings//` +- `GET/POST /api/payments/` + +## Frontend + +Location: `frontend/` + +### Setup + +1. Install dependencies via `npm install`. +2. Run `npm run dev`. + +The dev server proxies `/api` to `http://localhost:8000`. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..9060fb9 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,8 @@ +DJANGO_SECRET_KEY=changeme +DJANGO_DEBUG=1 +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DATABASE_URL=postgres://postgres:postgres@localhost:5432/salon +CORS_ALLOWED_ORIGINS=http://localhost:5173 +OTP_PROVIDER=console +OTP_EXPIRY_MINUTES=5 +DEFAULT_CURRENCY=SAR diff --git a/backend/apps/__init__.py b/backend/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/__init__.py b/backend/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/admin.py b/backend/apps/accounts/admin.py new file mode 100644 index 0000000..9e79a18 --- /dev/null +++ b/backend/apps/accounts/admin.py @@ -0,0 +1,33 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin + +from apps.accounts.models import PhoneOTP, User + + +@admin.register(User) +class UserAdmin(DjangoUserAdmin): + model = User + list_display = ("email", "phone_number", "role", "is_staff", "is_phone_verified") + list_filter = ("role", "is_staff", "is_phone_verified") + ordering = ("email",) + search_fields = ("email", "phone_number") + fieldsets = ( + (None, {"fields": ("email", "password")}), + ("Personal", {"fields": ("first_name", "last_name", "phone_number")}), + ("Roles", {"fields": ("role", "is_phone_verified")}), + ("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}), + ("Dates", {"fields": ("last_login",)}), + ) + add_fieldsets = ( + (None, { + "classes": ("wide",), + "fields": ("email", "password1", "password2", "role"), + }), + ) + + +@admin.register(PhoneOTP) +class PhoneOTPAdmin(admin.ModelAdmin): + list_display = ("phone_number", "channel", "provider", "created_at", "expires_at", "verified_at") + list_filter = ("channel", "provider") + search_fields = ("phone_number",) diff --git a/backend/apps/accounts/apps.py b/backend/apps/accounts/apps.py new file mode 100644 index 0000000..e3ceb5a --- /dev/null +++ b/backend/apps/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.accounts" diff --git a/backend/apps/accounts/migrations/__init__.py b/backend/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py new file mode 100644 index 0000000..da2708c --- /dev/null +++ b/backend/apps/accounts/models.py @@ -0,0 +1,78 @@ +import uuid +from datetime import timedelta + +from django.conf import settings +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager +from django.db import models +from django.utils import timezone + + +class UserRole(models.TextChoices): + ADMIN = "admin", "Admin" + MANAGER = "manager", "Salon Manager" + STAFF = "staff", "Staff" + CUSTOMER = "customer", "Customer" + + +class UserManager(BaseUserManager): + def create_user(self, email, password=None, **extra_fields): + if not email: + raise ValueError("Email is required") + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + extra_fields.setdefault("role", UserRole.ADMIN) + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True") + return self.create_user(email, password, **extra_fields) + + +class User(AbstractBaseUser, PermissionsMixin): + email = models.EmailField(unique=True) + phone_number = models.CharField(max_length=20, unique=True, null=True, blank=True) + role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.CUSTOMER) + 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) + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + objects = UserManager() + + USERNAME_FIELD = "email" + + def __str__(self): + return self.email + + +class OtpChannel(models.TextChoices): + SMS = "sms", "SMS" + WHATSAPP = "whatsapp", "WhatsApp" + + +class PhoneOTP(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + phone_number = models.CharField(max_length=20) + channel = models.CharField(max_length=20, choices=OtpChannel.choices) + provider = models.CharField(max_length=50) + code_hash = models.CharField(max_length=128) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() + verified_at = models.DateTimeField(null=True, blank=True) + + def is_expired(self): + return timezone.now() >= self.expires_at + + @classmethod + def expiry_at(cls): + return timezone.now() + timedelta(minutes=settings.OTP_EXPIRY_MINUTES) diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py new file mode 100644 index 0000000..3d9d8d3 --- /dev/null +++ b/backend/apps/accounts/serializers.py @@ -0,0 +1,41 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from apps.accounts.models import OtpChannel, PhoneOTP + +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"] + read_only_fields = ["id", "role", "is_phone_verified"] + + +class RegisterSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, min_length=8) + + class Meta: + model = User + fields = ["email", "password", "phone_number", "first_name", "last_name"] + + def create(self, validated_data): + return User.objects.create_user(**validated_data) + + +class OTPRequestSerializer(serializers.Serializer): + phone_number = serializers.CharField(max_length=20) + channel = serializers.ChoiceField(choices=OtpChannel.choices) + + +class OTPVerifySerializer(serializers.Serializer): + request_id = serializers.UUIDField() + code = serializers.CharField(max_length=6) + + +class OTPStatusSerializer(serializers.ModelSerializer): + class Meta: + model = PhoneOTP + fields = ["id", "phone_number", "channel", "provider", "created_at", "expires_at", "verified_at"] + read_only_fields = fields diff --git a/backend/apps/accounts/services/__init__.py b/backend/apps/accounts/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/services/otp.py b/backend/apps/accounts/services/otp.py new file mode 100644 index 0000000..098bbaa --- /dev/null +++ b/backend/apps/accounts/services/otp.py @@ -0,0 +1,83 @@ +import logging +import secrets +from dataclasses import dataclass + +from django.conf import settings +from django.contrib.auth.hashers import check_password, make_password +from django.utils import timezone + +from apps.accounts.models import OtpChannel, PhoneOTP + +logger = logging.getLogger(__name__) + + +@dataclass +class OtpSendResult: + request_id: str + expires_at: str + + +class BaseOtpProvider: + def send_sms(self, to_number: str, message: str) -> None: + raise NotImplementedError + + def send_whatsapp(self, to_number: str, message: str) -> None: + raise NotImplementedError + + +class ConsoleOtpProvider(BaseOtpProvider): + def send_sms(self, to_number: str, message: str) -> None: + logger.info("OTP SMS to %s: %s", to_number, message) + + def send_whatsapp(self, to_number: str, message: str) -> None: + logger.info("OTP WhatsApp to %s: %s", to_number, message) + + +PROVIDERS = { + "console": ConsoleOtpProvider, +} + + +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}") + return provider_cls() + + +def generate_code(length: int = 6) -> str: + digits = "0123456789" + return "".join(secrets.choice(digits) for _ in range(length)) + + +def create_and_send_otp(phone_number: str, channel: str) -> OtpSendResult: + provider = get_provider() + code = generate_code() + otp = PhoneOTP.objects.create( + phone_number=phone_number, + channel=channel, + provider=settings.OTP_PROVIDER, + code_hash=make_password(code), + expires_at=PhoneOTP.expiry_at(), + ) + + message = f"Your verification code is {code}. It expires in {settings.OTP_EXPIRY_MINUTES} 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") + + return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat()) + + +def verify_otp(otp: PhoneOTP, code: str) -> bool: + if otp.verified_at or otp.is_expired(): + return False + if not check_password(code, otp.code_hash): + return False + otp.verified_at = timezone.now() + otp.save(update_fields=["verified_at"]) + return True diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py new file mode 100644 index 0000000..25d093d --- /dev/null +++ b/backend/apps/accounts/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView + +from apps.accounts.views import MeView, OTPRequestView, OTPVerifyView, RegisterView, SocialLoginPlaceholderView + +urlpatterns = [ + path("register/", RegisterView.as_view(), name="register"), + path("me/", MeView.as_view(), name="me"), + path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("otp/request/", OTPRequestView.as_view(), name="otp_request"), + path("otp/verify/", OTPVerifyView.as_view(), name="otp_verify"), + path("social//", SocialLoginPlaceholderView.as_view(), name="social_login"), +] diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py new file mode 100644 index 0000000..271a74f --- /dev/null +++ b/backend/apps/accounts/views.py @@ -0,0 +1,72 @@ +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 rest_framework.views import APIView + +from apps.accounts.models import PhoneOTP +from apps.accounts.serializers import ( + OTPRequestSerializer, + OTPVerifySerializer, + RegisterSerializer, + UserSerializer, +) +from apps.accounts.services.otp import create_and_send_otp, verify_otp + +User = get_user_model() + + +class RegisterView(generics.CreateAPIView): + serializer_class = RegisterSerializer + permission_classes = [permissions.AllowAny] + + +class MeView(generics.RetrieveUpdateAPIView): + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_object(self): + return self.request.user + + +class OTPRequestView(APIView): + permission_classes = [permissions.AllowAny] + + def post(self, request): + serializer = OTPRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + result = create_and_send_otp(data["phone_number"], data["channel"]) + return Response( + {"request_id": result.request_id, "expires_at": result.expires_at}, + status=status.HTTP_201_CREATED, + ) + + +class OTPVerifyView(APIView): + permission_classes = [permissions.AllowAny] + + def post(self, request): + serializer = OTPVerifySerializer(data=request.data) + serializer.is_valid(raise_exception=True) + 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) + + 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) + + +class SocialLoginPlaceholderView(APIView): + permission_classes = [permissions.AllowAny] + + def post(self, request, provider): + return Response( + {"detail": "Social login not configured yet. Add OAuth provider config."}, + status=status.HTTP_501_NOT_IMPLEMENTED, + ) diff --git a/backend/apps/bookings/__init__.py b/backend/apps/bookings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/bookings/admin.py b/backend/apps/bookings/admin.py new file mode 100644 index 0000000..9cf8a0d --- /dev/null +++ b/backend/apps/bookings/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from apps.bookings.models import Booking + +admin.site.register(Booking) diff --git a/backend/apps/bookings/apps.py b/backend/apps/bookings/apps.py new file mode 100644 index 0000000..c594560 --- /dev/null +++ b/backend/apps/bookings/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BookingsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.bookings" diff --git a/backend/apps/bookings/migrations/__init__.py b/backend/apps/bookings/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/bookings/models.py b/backend/apps/bookings/models.py new file mode 100644 index 0000000..8bbcfda --- /dev/null +++ b/backend/apps/bookings/models.py @@ -0,0 +1,32 @@ +from django.conf import settings +from django.db import models + +from apps.salons.models import Salon, Service, StaffProfile + + +class BookingStatus(models.TextChoices): + PENDING = "pending", "Pending" + CONFIRMED = "confirmed", "Confirmed" + CANCELLED = "cancelled", "Cancelled" + COMPLETED = "completed", "Completed" + + +class Booking(models.Model): + salon = models.ForeignKey(Salon, on_delete=models.CASCADE, related_name="bookings") + customer = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="bookings", + ) + service = models.ForeignKey(Service, on_delete=models.PROTECT) + staff = models.ForeignKey(StaffProfile, on_delete=models.SET_NULL, null=True, blank=True) + start_time = models.DateTimeField() + end_time = models.DateTimeField() + status = models.CharField(max_length=20, choices=BookingStatus.choices, default=BookingStatus.PENDING) + price_amount = models.DecimalField(max_digits=10, decimal_places=2) + currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR")) + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.customer.email} - {self.service.name}" diff --git a/backend/apps/bookings/serializers.py b/backend/apps/bookings/serializers.py new file mode 100644 index 0000000..fd6fa28 --- /dev/null +++ b/backend/apps/bookings/serializers.py @@ -0,0 +1,65 @@ +from rest_framework import serializers + +from apps.bookings.models import Booking +from apps.salons.models import Service, StaffProfile + + +class BookingSerializer(serializers.ModelSerializer): + salon_name = serializers.CharField(source="salon.name", read_only=True) + service_name = serializers.CharField(source="service.name", read_only=True) + staff_name = serializers.SerializerMethodField() + + class Meta: + model = Booking + fields = [ + "id", + "salon", + "salon_name", + "service", + "service_name", + "staff", + "staff_name", + "start_time", + "end_time", + "status", + "price_amount", + "currency", + "notes", + "created_at", + ] + read_only_fields = ["id", "salon", "status", "price_amount", "currency", "created_at"] + + def get_staff_name(self, obj): + if not obj.staff: + return None + first = obj.staff.user.first_name or "" + last = obj.staff.user.last_name or "" + return (first + " " + last).strip() or obj.staff.user.email + + +class BookingCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Booking + fields = ["service", "staff", "start_time", "end_time", "notes"] + + def validate(self, attrs): + 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") + return attrs + + def create(self, validated_data): + request = self.context["request"] + service = validated_data["service"] + return Booking.objects.create( + salon=service.salon, + customer=request.user, + service=service, + staff=validated_data.get("staff"), + start_time=validated_data["start_time"], + end_time=validated_data["end_time"], + notes=validated_data.get("notes", ""), + price_amount=service.price_amount, + currency=service.currency, + ) diff --git a/backend/apps/bookings/urls.py b/backend/apps/bookings/urls.py new file mode 100644 index 0000000..ddc0f1b --- /dev/null +++ b/backend/apps/bookings/urls.py @@ -0,0 +1,11 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from apps.bookings.views import BookingViewSet + +router = DefaultRouter() +router.register(r"", BookingViewSet, basename="booking") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/backend/apps/bookings/views.py b/backend/apps/bookings/views.py new file mode 100644 index 0000000..3b9c1ba --- /dev/null +++ b/backend/apps/bookings/views.py @@ -0,0 +1,23 @@ +from rest_framework import permissions, viewsets + +from apps.bookings.models import Booking +from apps.bookings.serializers import BookingCreateSerializer, BookingSerializer + + +class BookingViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + user = self.request.user + if getattr(user, "is_superuser", False) or user.role == "admin": + return Booking.objects.all().order_by("-created_at") + if user.role == "manager": + return Booking.objects.filter(salon__owner=user).order_by("-created_at") + if user.role == "staff": + return Booking.objects.filter(staff__user=user).order_by("-created_at") + return Booking.objects.filter(customer=user).order_by("-created_at") + + def get_serializer_class(self): + if self.action == "create": + return BookingCreateSerializer + return BookingSerializer diff --git a/backend/apps/payments/__init__.py b/backend/apps/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/payments/admin.py b/backend/apps/payments/admin.py new file mode 100644 index 0000000..96ad72f --- /dev/null +++ b/backend/apps/payments/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from apps.payments.models import Payment + +admin.site.register(Payment) diff --git a/backend/apps/payments/apps.py b/backend/apps/payments/apps.py new file mode 100644 index 0000000..d94ae34 --- /dev/null +++ b/backend/apps/payments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.payments" diff --git a/backend/apps/payments/migrations/__init__.py b/backend/apps/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/payments/models.py b/backend/apps/payments/models.py new file mode 100644 index 0000000..a56ab35 --- /dev/null +++ b/backend/apps/payments/models.py @@ -0,0 +1,36 @@ +from django.conf import settings +from django.db import models + +from apps.bookings.models import Booking + + +class PaymentProvider(models.TextChoices): + HYPERPAY = "hyperpay", "HyperPay" + PAYTABS = "paytabs", "PayTabs" + MOYASAR = "moyasar", "Moyasar" + TAP = "tap", "Tap" + AMAZON_PAYMENT_SERVICES = "amazon_payment_services", "Amazon Payment Services" + CHECKOUT = "checkout", "Checkout.com" + OTHER = "other", "Other" + + +class PaymentStatus(models.TextChoices): + CREATED = "created", "Created" + AUTHORIZED = "authorized", "Authorized" + CAPTURED = "captured", "Captured" + FAILED = "failed", "Failed" + REFUNDED = "refunded", "Refunded" + + +class Payment(models.Model): + booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments") + provider = models.CharField(max_length=50, choices=PaymentProvider.choices) + status = models.CharField(max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.CREATED) + amount = models.DecimalField(max_digits=10, decimal_places=2) + currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR")) + external_id = models.CharField(max_length=200, blank=True) + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.provider} {self.amount} {self.currency}" diff --git a/backend/apps/payments/serializers.py b/backend/apps/payments/serializers.py new file mode 100644 index 0000000..b21dae4 --- /dev/null +++ b/backend/apps/payments/serializers.py @@ -0,0 +1,48 @@ +from rest_framework import serializers + +from apps.bookings.models import Booking +from apps.payments.models import Payment, PaymentProvider, PaymentStatus + + +class PaymentSerializer(serializers.ModelSerializer): + booking_id = serializers.IntegerField(source="booking.id", read_only=True) + + class Meta: + model = Payment + fields = [ + "id", + "booking_id", + "provider", + "status", + "amount", + "currency", + "external_id", + "metadata", + "created_at", + ] + read_only_fields = fields + + +class PaymentCreateSerializer(serializers.ModelSerializer): + booking_id = serializers.IntegerField(write_only=True) + provider = serializers.ChoiceField(choices=PaymentProvider.choices) + + class Meta: + model = Payment + fields = ["booking_id", "provider"] + + def validate_booking_id(self, value): + if not Booking.objects.filter(id=value).exists(): + raise serializers.ValidationError("Booking not found") + return value + + def create(self, validated_data): + booking = Booking.objects.get(id=validated_data["booking_id"]) + return Payment.objects.create( + booking=booking, + provider=validated_data["provider"], + status=PaymentStatus.CREATED, + amount=booking.price_amount, + currency=booking.currency, + metadata={}, + ) diff --git a/backend/apps/payments/urls.py b/backend/apps/payments/urls.py new file mode 100644 index 0000000..082f141 --- /dev/null +++ b/backend/apps/payments/urls.py @@ -0,0 +1,11 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from apps.payments.views import PaymentViewSet + +router = DefaultRouter() +router.register(r"", PaymentViewSet, basename="payment") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/backend/apps/payments/views.py b/backend/apps/payments/views.py new file mode 100644 index 0000000..f4ce014 --- /dev/null +++ b/backend/apps/payments/views.py @@ -0,0 +1,53 @@ +from rest_framework import permissions, status, viewsets +from rest_framework.response import Response + +from apps.bookings.models import Booking +from apps.payments.models import Payment +from apps.payments.serializers import PaymentCreateSerializer, PaymentSerializer + + +def user_can_access_booking(user, booking: Booking) -> bool: + if getattr(user, "is_superuser", False) or user.role == "admin": + return True + if user.role == "manager": + return booking.salon.owner_id == user.id + if user.role == "staff": + return booking.staff_id and booking.staff.user_id == user.id + return booking.customer_id == user.id + + +class PaymentViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + user = self.request.user + if getattr(user, "is_superuser", False) or user.role == "admin": + return Payment.objects.all().order_by("-created_at") + if user.role == "manager": + return Payment.objects.filter(booking__salon__owner=user).order_by("-created_at") + if user.role == "staff": + return Payment.objects.filter(booking__staff__user=user).order_by("-created_at") + return Payment.objects.filter(booking__customer=user).order_by("-created_at") + + def get_serializer_class(self): + if self.action == "create": + return PaymentCreateSerializer + return PaymentSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + 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) + payment = serializer.save() + return Response( + { + "detail": "Payment record created. Provider integration pending.", + "payment_id": payment.id, + "amount": str(payment.amount), + "currency": payment.currency, + "status": payment.status, + }, + status=status.HTTP_201_CREATED, + ) diff --git a/backend/apps/salons/__init__.py b/backend/apps/salons/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/salons/admin.py b/backend/apps/salons/admin.py new file mode 100644 index 0000000..0e08839 --- /dev/null +++ b/backend/apps/salons/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from apps.salons.models import Review, Salon, SalonPhoto, Service, StaffProfile + +admin.site.register(Salon) +admin.site.register(SalonPhoto) +admin.site.register(Service) +admin.site.register(StaffProfile) +admin.site.register(Review) diff --git a/backend/apps/salons/apps.py b/backend/apps/salons/apps.py new file mode 100644 index 0000000..737d7ae --- /dev/null +++ b/backend/apps/salons/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SalonsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.salons" diff --git a/backend/apps/salons/migrations/__init__.py b/backend/apps/salons/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/salons/models.py b/backend/apps/salons/models.py new file mode 100644 index 0000000..39853b9 --- /dev/null +++ b/backend/apps/salons/models.py @@ -0,0 +1,70 @@ +from django.conf import settings +from django.db import models + + +class Salon(models.Model): + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="owned_salons", + ) + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + address = models.CharField(max_length=255) + city = models.CharField(max_length=100) + latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + phone_number = models.CharField(max_length=20, blank=True) + email = models.EmailField(blank=True) + website = models.URLField(blank=True) + rating_avg = models.DecimalField(max_digits=3, decimal_places=2, default=0) + rating_count = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name + + +class SalonPhoto(models.Model): + salon = models.ForeignKey(Salon, on_delete=models.CASCADE, related_name="photos") + image_url = models.URLField() + alt_text = models.CharField(max_length=200, blank=True) + sort_order = models.PositiveIntegerField(default=0) + + def __str__(self): + return f"{self.salon.name} photo" + + +class Service(models.Model): + salon = models.ForeignKey(Salon, on_delete=models.CASCADE, related_name="services") + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + duration_minutes = models.PositiveIntegerField() + price_amount = models.DecimalField(max_digits=10, decimal_places=2) + currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR")) + is_active = models.BooleanField(default=True) + + def __str__(self): + return f"{self.name} - {self.salon.name}" + + +class StaffProfile(models.Model): + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + salon = models.ForeignKey(Salon, on_delete=models.CASCADE, related_name="staff") + title = models.CharField(max_length=200, blank=True) + bio = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + + def __str__(self): + return f"{self.user.email} - {self.salon.name}" + + +class Review(models.Model): + salon = models.ForeignKey(Salon, on_delete=models.CASCADE, related_name="reviews") + customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reviews") + rating = models.PositiveSmallIntegerField() + comment = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Review {self.rating} for {self.salon.name}" diff --git a/backend/apps/salons/serializers.py b/backend/apps/salons/serializers.py new file mode 100644 index 0000000..22188f3 --- /dev/null +++ b/backend/apps/salons/serializers.py @@ -0,0 +1,71 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from apps.salons.models import Review, Salon, SalonPhoto, Service, StaffProfile + +User = get_user_model() + + +class SalonPhotoSerializer(serializers.ModelSerializer): + class Meta: + model = SalonPhoto + fields = ["id", "image_url", "alt_text", "sort_order"] + + +class ServiceSerializer(serializers.ModelSerializer): + class Meta: + model = Service + fields = ["id", "name", "description", "duration_minutes", "price_amount", "currency", "is_active"] + + +class StaffSerializer(serializers.ModelSerializer): + name = serializers.SerializerMethodField() + + class Meta: + model = StaffProfile + fields = ["id", "name", "title", "bio", "is_active"] + + def get_name(self, obj): + first = obj.user.first_name or "" + last = obj.user.last_name or "" + return (first + " " + last).strip() or obj.user.email + + +class ReviewSerializer(serializers.ModelSerializer): + customer_name = serializers.SerializerMethodField() + + class Meta: + model = Review + fields = ["id", "rating", "comment", "created_at", "customer_name"] + + def get_customer_name(self, obj): + first = obj.customer.first_name or "" + last = obj.customer.last_name or "" + return (first + " " + last).strip() or obj.customer.email + + +class SalonSerializer(serializers.ModelSerializer): + class Meta: + model = Salon + fields = [ + "id", + "name", + "description", + "address", + "city", + "phone_number", + "email", + "website", + "rating_avg", + "rating_count", + ] + + +class SalonDetailSerializer(SalonSerializer): + photos = SalonPhotoSerializer(many=True, read_only=True) + services = ServiceSerializer(many=True, read_only=True) + staff = StaffSerializer(many=True, read_only=True) + reviews = ReviewSerializer(many=True, read_only=True) + + class Meta(SalonSerializer.Meta): + fields = SalonSerializer.Meta.fields + ["photos", "services", "staff", "reviews"] diff --git a/backend/apps/salons/urls.py b/backend/apps/salons/urls.py new file mode 100644 index 0000000..dc6fbac --- /dev/null +++ b/backend/apps/salons/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from apps.salons.views import SalonReviewsView, SalonServicesView, SalonStaffView, SalonViewSet + +router = DefaultRouter() +router.register(r"", SalonViewSet, basename="salon") + +urlpatterns = [ + path("", include(router.urls)), + path("/services/", SalonServicesView.as_view(), name="salon_services"), + path("/staff/", SalonStaffView.as_view(), name="salon_staff"), + path("/reviews/", SalonReviewsView.as_view(), name="salon_reviews"), +] diff --git a/backend/apps/salons/views.py b/backend/apps/salons/views.py new file mode 100644 index 0000000..c8e4ea3 --- /dev/null +++ b/backend/apps/salons/views.py @@ -0,0 +1,57 @@ +from django.db.models import Q +from rest_framework import generics, permissions, viewsets + +from apps.salons.models import Review, Salon, Service, StaffProfile +from apps.salons.serializers import ( + ReviewSerializer, + SalonDetailSerializer, + SalonSerializer, + ServiceSerializer, + StaffSerializer, +) + + +class SalonViewSet(viewsets.ReadOnlyModelViewSet): + permission_classes = [permissions.AllowAny] + + def get_queryset(self): + queryset = Salon.objects.all() + city = self.request.query_params.get("city") + query = self.request.query_params.get("q") + service = self.request.query_params.get("service") + if city: + queryset = queryset.filter(city__iexact=city) + if query: + queryset = queryset.filter(Q(name__icontains=query) | Q(description__icontains=query)) + if service: + queryset = queryset.filter(services__name__icontains=service) + return queryset.distinct() + + def get_serializer_class(self): + if self.action == "retrieve": + return SalonDetailSerializer + return SalonSerializer + + +class SalonServicesView(generics.ListAPIView): + serializer_class = ServiceSerializer + permission_classes = [permissions.AllowAny] + + def get_queryset(self): + return Service.objects.filter(salon_id=self.kwargs["salon_id"], is_active=True) + + +class SalonStaffView(generics.ListAPIView): + serializer_class = StaffSerializer + permission_classes = [permissions.AllowAny] + + def get_queryset(self): + return StaffProfile.objects.filter(salon_id=self.kwargs["salon_id"], is_active=True) + + +class SalonReviewsView(generics.ListAPIView): + serializer_class = ReviewSerializer + permission_classes = [permissions.AllowAny] + + def get_queryset(self): + return Review.objects.filter(salon_id=self.kwargs["salon_id"]).order_by("-created_at") diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..6eb6d5b --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "salon_api.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..cdf6add --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +Django>=5.0 +djangorestframework>=3.14 +djangorestframework-simplejwt>=5.3 +django-cors-headers>=4.3 +psycopg[binary]>=3.1 +python-dotenv>=1.0 diff --git a/backend/salon_api/__init__.py b/backend/salon_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/salon_api/asgi.py b/backend/salon_api/asgi.py new file mode 100644 index 0000000..c6e37a7 --- /dev/null +++ b/backend/salon_api/asgi.py @@ -0,0 +1,6 @@ +import os +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "salon_api.settings") + +application = get_asgi_application() diff --git a/backend/salon_api/settings.py b/backend/salon_api/settings.py new file mode 100644 index 0000000..c56d093 --- /dev/null +++ b/backend/salon_api/settings.py @@ -0,0 +1,128 @@ +import os +from pathlib import Path +from datetime import timedelta +from urllib.parse import urlparse + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "unsafe-dev-key") +DEBUG = os.getenv("DJANGO_DEBUG", "0") == "1" +ALLOWED_HOSTS = [h.strip() for h in os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",") if h.strip()] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "corsheaders", + "apps.accounts", + "apps.salons", + "apps.bookings", + "apps.payments", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "salon_api.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "salon_api.wsgi.application" +ASGI_APPLICATION = "salon_api.asgi.application" + + +def parse_database_url(database_url: str): + parsed = urlparse(database_url) + if parsed.scheme not in {"postgres", "postgresql"}: + return None + return { + "ENGINE": "django.db.backends.postgresql", + "NAME": parsed.path.lstrip("/"), + "USER": parsed.username, + "PASSWORD": parsed.password, + "HOST": parsed.hostname, + "PORT": parsed.port or "5432", + } + + +DATABASE_URL = os.getenv("DATABASE_URL") +if DATABASE_URL: + parsed_db = parse_database_url(DATABASE_URL) +else: + parsed_db = None + +DATABASES = { + "default": parsed_db + or { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "Asia/Riyadh" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +AUTH_USER_MODEL = "accounts.User" + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_PERMISSION_CLASSES": ( + "rest_framework.permissions.IsAuthenticatedOrReadOnly", + ), +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), + "AUTH_HEADER_TYPES": ("Bearer",), +} + +CORS_ALLOWED_ORIGINS = [ + origin.strip() + for origin in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") + if origin.strip() +] + +OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console") +OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5")) +DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR") diff --git a/backend/salon_api/urls.py b/backend/salon_api/urls.py new file mode 100644 index 0000000..3a65edc --- /dev/null +++ b/backend/salon_api/urls.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/auth/", include("apps.accounts.urls")), + path("api/salons/", include("apps.salons.urls")), + path("api/bookings/", include("apps.bookings.urls")), + path("api/payments/", include("apps.payments.urls")), +] diff --git a/backend/salon_api/wsgi.py b/backend/salon_api/wsgi.py new file mode 100644 index 0000000..79e7c40 --- /dev/null +++ b/backend/salon_api/wsgi.py @@ -0,0 +1,6 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "salon_api.settings") + +application = get_wsgi_application() diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4057339 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Salon Booking + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1dea190 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "salon-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.0.0" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..e2b2fd4 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { apiGet } from "./api/client"; + +export default function App() { + const [salons, setSalons] = useState([]); + const [query, setQuery] = useState(""); + const [status, setStatus] = useState("idle"); + + useEffect(() => { + let ignore = false; + + async function load() { + setStatus("loading"); + try { + const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`); + if (!ignore) { + setSalons(data); + setStatus("ready"); + } + } catch (error) { + if (!ignore) { + setStatus("error"); + } + } + } + + load(); + return () => { + ignore = true; + }; + }, [query]); + + 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. +

+
+ setQuery(event.target.value)} + /> +
+
+ +
+

Salons

+ {status === "loading" &&

Loading salons...

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

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

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

No salons found.

} +
+ {salons.map((salon) => ( +
+
+

{salon.name}

+ {salon.rating_avg} / 5 +
+

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

+
+ {salon.city} + {salon.phone_number || "Phone unavailable"} +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..fd3a206 --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,14 @@ +const API_BASE = import.meta.env.VITE_API_BASE || "/api"; + +async function handleResponse(response) { + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `Request failed: ${response.status}`); + } + return response.json(); +} + +export async function apiGet(path) { + const response = await fetch(`${API_BASE}${path}`); + return handleResponse(response); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..f4379c7 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.jsx"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + +); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..dc2c8b3 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,114 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk: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; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; +} + +.page { + max-width: 1100px; + margin: 0 auto; + padding: 48px 24px 80px; +} + +.hero { + display: flex; + flex-direction: column; + gap: 16px; + padding: 32px; + border-radius: 24px; + background: rgba(255, 255, 255, 0.8); + box-shadow: 0 20px 40px rgba(26, 26, 26, 0.08); +} + +.eyebrow { + letter-spacing: 0.2em; + text-transform: uppercase; + font-size: 12px; + font-weight: 700; +} + +h1 { + font-size: 40px; + margin: 0; +} + +.subtitle { + font-size: 18px; + margin: 0; + max-width: 640px; +} + +.search input { + width: 100%; + max-width: 520px; + padding: 14px 16px; + border-radius: 12px; + border: 1px solid #dad3ca; + font-size: 16px; +} + +.results { + margin-top: 48px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.card { + background: white; + padding: 20px; + border-radius: 16px; + box-shadow: 0 12px 24px rgba(21, 21, 21, 0.08); + display: flex; + flex-direction: column; + gap: 12px; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.rating { + background: #ffcc80; + padding: 4px 8px; + border-radius: 999px; + font-weight: 700; +} + +.meta { + display: flex; + justify-content: space-between; + color: #5c5a5f; + font-size: 14px; +} + +.error { + color: #b00020; +} + +@media (max-width: 600px) { + h1 { + font-size: 30px; + } + + .hero { + padding: 24px; + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..3fde111 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api": "http://localhost:8000" + } + } +});