diff --git a/.env.example b/.env.example index ebcd130..1ab15cf 100644 --- a/.env.example +++ b/.env.example @@ -75,7 +75,7 @@ ZARINPAL_SANDBOX=false # ── SMS: Kavenegar ──────────────────────────────────────────────────────────── # Empty = OTP is logged to API console (fine for dev, not for production) -KAVENEGAR_API_KEY= +KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F43334672576B526F5A4B4B795665493D # ── Snappfood webhook ───────────────────────────────────────────────────────── SNAPPFOOD_WEBHOOK_SECRET=change-me-snappfood-secret diff --git a/docker-compose.admin.yml b/docker-compose.admin.yml index 6f4ed0f..aead233 100644 --- a/docker-compose.admin.yml +++ b/docker-compose.admin.yml @@ -16,7 +16,7 @@ services: extra_hosts: - "mirror:host-gateway" args: - DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-171.22.25.73:5002/dotnet/sdk:10.0} + DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr-mirror.liara.ir/dotnet/sdk:10.0} DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mcr-mirror.liara.ir/dotnet/aspnet:10.0} container_name: meezi-admin-api restart: unless-stopped @@ -52,8 +52,8 @@ services: extra_hosts: - "mirror:host-gateway" args: - NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:5000/library/node:20-alpine} - NPM_REGISTRY: ${NPM_REGISTRY:-http://mirror:8081/repository/npm-group/} + NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine} + NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/} NEXT_PUBLIC_ADMIN_API_URL: ${NEXT_PUBLIC_ADMIN_API_URL:-http://localhost:5081} container_name: meezi-admin-web restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index f9ace7b..adf49a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,7 +57,7 @@ services: extra_hosts: - "mirror:host-gateway" args: - DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-171.22.25.73:5002/dotnet/sdk:10.0} + DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr-mirror.liara.ir/dotnet/sdk:10.0} DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mcr-mirror.liara.ir/dotnet/aspnet:10.0} container_name: meezi-api restart: unless-stopped @@ -103,8 +103,8 @@ services: extra_hosts: - "mirror:host-gateway" args: - NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:5000/library/node:20-alpine} - NPM_REGISTRY: ${NPM_REGISTRY:-http://mirror:8081/repository/npm-group/} + NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine} + NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080} container_name: meezi-web restart: unless-stopped @@ -124,8 +124,8 @@ services: extra_hosts: - "mirror:host-gateway" args: - NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:5000/library/node:20-alpine} - NPM_REGISTRY: ${NPM_REGISTRY:-http://mirror:8081/repository/npm-group/} + NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine} + NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/} MEEZI_API_URL: http://api:8080 NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010} container_name: meezi-website @@ -148,8 +148,8 @@ services: extra_hosts: - "mirror:host-gateway" args: - NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:5000/library/node:20-alpine} - NPM_REGISTRY: ${NPM_REGISTRY:-http://mirror:8081/repository/npm-group/} + NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine} + NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_FINDER_URL:-http://localhost:3103} container_name: meezi-finder diff --git a/docker/admin-web/Dockerfile b/docker/admin-web/Dockerfile index 01e43c9..4cce145 100644 --- a/docker/admin-web/Dockerfile +++ b/docker/admin-web/Dockerfile @@ -1,9 +1,9 @@ -ARG NODE_IMAGE=node:20-alpine +ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine FROM ${NODE_IMAGE} AS deps WORKDIR /app COPY web/admin/package*.json ./ -ARG NPM_REGISTRY=https://registry.npmjs.org +ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/ # Install deps then ensure Alpine (musl) SWC binary is present RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \ && NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \ diff --git a/docker/finder/Dockerfile b/docker/finder/Dockerfile index 71b5d1b..bf927f1 100644 --- a/docker/finder/Dockerfile +++ b/docker/finder/Dockerfile @@ -1,9 +1,9 @@ -ARG NODE_IMAGE=node:20-alpine +ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine FROM ${NODE_IMAGE} AS deps WORKDIR /app COPY web/finder/package*.json ./ -ARG NPM_REGISTRY=https://registry.npmjs.org +ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/ RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} FROM ${NODE_IMAGE} AS builder diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile index e1eb758..5e8372b 100644 --- a/docker/web/Dockerfile +++ b/docker/web/Dockerfile @@ -1,9 +1,9 @@ -ARG NODE_IMAGE=node:20-alpine +ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine FROM ${NODE_IMAGE} AS deps WORKDIR /app COPY web/dashboard/package*.json ./ -ARG NPM_REGISTRY=https://registry.npmjs.org +ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/ RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} FROM ${NODE_IMAGE} AS builder diff --git a/docker/website/Dockerfile b/docker/website/Dockerfile index 1dc5a0e..8df28c4 100644 --- a/docker/website/Dockerfile +++ b/docker/website/Dockerfile @@ -1,9 +1,9 @@ -ARG NODE_IMAGE=node:20-alpine +ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine FROM ${NODE_IMAGE} AS deps WORKDIR /app COPY web/website/package*.json ./ -ARG NPM_REGISTRY=https://registry.npmjs.org +ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/ # Install deps then ensure Alpine (musl) SWC binary is present RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \ && NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \ diff --git a/nuget.docker.config b/nuget.docker.config index 8e82404..76f1e4a 100644 --- a/nuget.docker.config +++ b/nuget.docker.config @@ -1,12 +1,10 @@ - + - + diff --git a/src/Meezi.API/Controllers/AuthController.cs b/src/Meezi.API/Controllers/AuthController.cs index 72a16a8..b2a1db9 100644 --- a/src/Meezi.API/Controllers/AuthController.cs +++ b/src/Meezi.API/Controllers/AuthController.cs @@ -19,17 +19,23 @@ public class AuthController : ControllerBase private readonly IValidator _sendOtpValidator; private readonly IValidator _verifyOtpValidator; private readonly IValidator _refreshValidator; + private readonly IValidator _registerValidator; + private readonly IValidator _verifyRegisterValidator; public AuthController( IAuthService authService, IValidator sendOtpValidator, IValidator verifyOtpValidator, - IValidator refreshValidator) + IValidator refreshValidator, + IValidator registerValidator, + IValidator verifyRegisterValidator) { _authService = authService; _sendOtpValidator = sendOtpValidator; _verifyOtpValidator = verifyOtpValidator; _refreshValidator = refreshValidator; + _registerValidator = registerValidator; + _verifyRegisterValidator = verifyRegisterValidator; } [HttpPost("send-otp")] @@ -78,6 +84,37 @@ public class AuthController : ControllerBase return Ok(new ApiResponse(true, data)); } + [HttpPost("register")] + [EnableRateLimiting("auth-otp")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task Register([FromBody] RegisterRequest request, CancellationToken cancellationToken) + { + var validation = await _registerValidator.ValidateAsync(request, cancellationToken); + if (!validation.IsValid) + return BadRequest(ValidationError(validation)); + + var (success, data, code, message) = await _authService.RegisterAsync(request, cancellationToken); + if (!success) + return ErrorResult(code!, message!); + + return Ok(new ApiResponse(true, data)); + } + + [HttpPost("verify-register")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task VerifyRegister([FromBody] VerifyRegisterRequest request, CancellationToken cancellationToken) + { + var validation = await _verifyRegisterValidator.ValidateAsync(request, cancellationToken); + if (!validation.IsValid) + return BadRequest(ValidationError(validation)); + + var (success, data, code, message) = await _authService.VerifyRegisterAsync(request, cancellationToken); + if (!success) + return ErrorResult(code!, message!); + + return Ok(new ApiResponse(true, data)); + } + [HttpGet("me")] [Authorize] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -120,6 +157,7 @@ public class AuthController : ControllerBase new ApiResponse(false, null, new ApiError(code, message))), "NOT_FOUND" => NotFound(new ApiResponse(false, null, new ApiError(code, message))), "INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse(false, null, new ApiError(code, message))), + "ALREADY_REGISTERED" => Conflict(new ApiResponse(false, null, new ApiError(code, message))), _ => BadRequest(new ApiResponse(false, null, new ApiError(code, message))) }; } diff --git a/src/Meezi.API/Models/Auth/AuthDtos.cs b/src/Meezi.API/Models/Auth/AuthDtos.cs index b5c8c82..7856f46 100644 --- a/src/Meezi.API/Models/Auth/AuthDtos.cs +++ b/src/Meezi.API/Models/Auth/AuthDtos.cs @@ -6,6 +6,12 @@ public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null) public record RefreshTokenRequest(string RefreshToken); +/// Step 1 of self-registration: send OTP to a new phone number. +public record RegisterRequest(string Phone, string CafeName); + +/// Step 2 of self-registration: verify OTP and create the cafe account. +public record VerifyRegisterRequest(string Phone, string Code); + public record AuthTokenResponse( string AccessToken, string RefreshToken, diff --git a/src/Meezi.API/Services/AuthService.cs b/src/Meezi.API/Services/AuthService.cs index 8a5777a..cd5bff7 100644 --- a/src/Meezi.API/Services/AuthService.cs +++ b/src/Meezi.API/Services/AuthService.cs @@ -1,5 +1,7 @@ using Meezi.API.Models.Auth; using Meezi.API.Security; +using Meezi.Core.Entities; +using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.Core.Utilities; using Meezi.Infrastructure.Data; @@ -162,6 +164,139 @@ public class AuthService : IAuthService return (true, tokens, null, null); } + public async Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> RegisterAsync( + RegisterRequest request, + CancellationToken cancellationToken = default) + { + var phone = PhoneNormalizer.Normalize(request.Phone); + var cafeName = request.CafeName.Trim(); + + var redis = _redis.GetDatabase(); + var maxAttempts = _configuration.GetValue("Auth:MaxOtpAttemptsPerHour", DefaultMaxOtpAttemptsPerHour); + + if (_http.HttpContext is not null) + { + var ip = ClientIpResolver.GetClientIp(_http.HttpContext); + var ipCheck = await _abuse.CheckAuthOtpByIpAsync(ip, cancellationToken); + if (!ipCheck.Allowed) + return (false, null, ipCheck.ErrorCode, ipCheck.Message); + } + + // Check if this phone already owns a cafe — suggest login instead + var alreadyOwner = await _db.Employees + .AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken); + if (alreadyOwner) + return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in."); + + var attemptsKey = $"otp:attempts:{phone}"; + if (maxAttempts > 0) + { + var attempts = await redis.StringGetAsync(attemptsKey); + if (attempts.HasValue && (int)attempts >= maxAttempts) + return (false, null, "RATE_LIMITED", "Too many OTP requests. Try again later."); + } + + var otp = Random.Shared.Next(100000, 999999).ToString(); + await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds)); + // Store the cafe name alongside the OTP so verify-register can create the cafe + await redis.StringSetAsync($"reg_meta:{phone}", cafeName, TimeSpan.FromSeconds(OtpTtlSeconds)); + + if (string.IsNullOrWhiteSpace(_configuration["Kavenegar:ApiKey"])) + _logger.LogWarning("DEV REGISTER OTP for {Phone}: {Otp} (configure Kavenegar:ApiKey to send SMS)", phone, otp); + + try + { + await _smsService.SendOtpAsync(phone, otp, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send registration OTP SMS"); + return (false, null, "SMS_FAILED", "Could not send verification code."); + } + + if (maxAttempts > 0) + { + var newAttempts = await redis.StringIncrementAsync(attemptsKey); + if (newAttempts == 1) + await redis.KeyExpireAsync(attemptsKey, TimeSpan.FromHours(1)); + } + + _logger.LogInformation("Registration OTP sent for phone ending {Suffix}", phone[^4..]); + return (true, new SendOtpResponse(true, OtpTtlSeconds), null, null); + } + + public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyRegisterAsync( + VerifyRegisterRequest request, + CancellationToken cancellationToken = default) + { + var phone = PhoneNormalizer.Normalize(request.Phone); + var code = OtpNormalizer.Normalize(request.Code); + if (!OtpNormalizer.IsValidSixDigitCode(code)) + return (false, null, "INVALID_OTP", "Invalid or expired verification code."); + + var redis = _redis.GetDatabase(); + + var storedOtp = await redis.StringGetAsync($"otp:{phone}"); + if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code) + return (false, null, "INVALID_OTP", "Invalid or expired verification code."); + + var cafeName = (await redis.StringGetAsync($"reg_meta:{phone}")).ToString(); + if (string.IsNullOrWhiteSpace(cafeName)) + return (false, null, "REGISTRATION_EXPIRED", "Registration session expired. Please start again."); + + // Double-check no owner was created in the meantime (race condition guard) + var alreadyOwner = await _db.Employees + .AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken); + if (alreadyOwner) + { + await redis.KeyDeleteAsync($"otp:{phone}"); + await redis.KeyDeleteAsync($"reg_meta:{phone}"); + return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in."); + } + + // Generate a unique slug + var slug = await GenerateUniqueSlugAsync(cancellationToken); + + var cafe = new Cafe + { + Name = cafeName, + Slug = slug, + PreferredLanguage = "fa", + PlanTier = PlanTier.Free, + }; + + var owner = new Employee + { + CafeId = cafe.Id, + Name = cafeName, // owner display name defaults to cafe name until they update it + Phone = phone, + Role = EmployeeRole.Owner, + }; + + _db.Cafes.Add(cafe); + _db.Employees.Add(owner); + await _db.SaveChangesAsync(cancellationToken); + + await redis.KeyDeleteAsync($"otp:{phone}"); + await redis.KeyDeleteAsync($"reg_meta:{phone}"); + + _logger.LogInformation("New cafe registered: {CafeId} by phone ending {Suffix}", cafe.Id, phone[^4..]); + + var tokens = await IssueTokensAsync(owner, cafe, cancellationToken); + return (true, tokens, null, null); + } + + private async Task GenerateUniqueSlugAsync(CancellationToken ct) + { + string slug; + do + { + // e.g. "cafe-a3f9b2c" + slug = "cafe-" + Guid.NewGuid().ToString("N")[..7]; + } while (await _db.Cafes.AnyAsync(c => c.Slug == slug, ct)); + return slug; + } + private async Task IssueTokensAsync( Core.Entities.Employee employee, Core.Entities.Cafe cafe, diff --git a/src/Meezi.API/Services/IAuthService.cs b/src/Meezi.API/Services/IAuthService.cs index 7cf48f3..39b3f52 100644 --- a/src/Meezi.API/Services/IAuthService.cs +++ b/src/Meezi.API/Services/IAuthService.cs @@ -15,4 +15,12 @@ public interface IAuthService Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync( RefreshTokenRequest request, CancellationToken cancellationToken = default); + + Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> RegisterAsync( + RegisterRequest request, + CancellationToken cancellationToken = default); + + Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyRegisterAsync( + VerifyRegisterRequest request, + CancellationToken cancellationToken = default); } diff --git a/src/Meezi.API/Validators/AuthValidators.cs b/src/Meezi.API/Validators/AuthValidators.cs index f2f673a..b28119c 100644 --- a/src/Meezi.API/Validators/AuthValidators.cs +++ b/src/Meezi.API/Validators/AuthValidators.cs @@ -37,3 +37,34 @@ public class RefreshTokenRequestValidator : AbstractValidator x.RefreshToken).NotEmpty(); } } + +public class RegisterRequestValidator : AbstractValidator +{ + public RegisterRequestValidator() + { + RuleFor(x => x.Phone) + .NotEmpty() + .Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p))) + .WithMessage("Invalid Iranian mobile number."); + + RuleFor(x => x.CafeName) + .NotEmpty() + .MaximumLength(100) + .WithMessage("Cafe name must be between 1 and 100 characters."); + } +} + +public class VerifyRegisterRequestValidator : AbstractValidator +{ + public VerifyRegisterRequestValidator() + { + RuleFor(x => x.Phone) + .NotEmpty() + .Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p))) + .WithMessage("Invalid Iranian mobile number."); + + RuleFor(x => x.Code) + .Must(OtpNormalizer.IsValidSixDigitCode) + .WithMessage("OTP must be 6 digits."); + } +} diff --git a/src/Meezi.Admin.API/Validators/AuthValidators.cs b/src/Meezi.Admin.API/Validators/AuthValidators.cs index 850cd62..c4cbed7 100644 --- a/src/Meezi.Admin.API/Validators/AuthValidators.cs +++ b/src/Meezi.Admin.API/Validators/AuthValidators.cs @@ -22,3 +22,11 @@ public class VerifyOtpRequestValidator : AbstractValidator .WithMessage("OTP must be 6 digits."); } } + +public class RefreshTokenRequestValidator : AbstractValidator +{ + public RefreshTokenRequestValidator() + { + RuleFor(x => x.RefreshToken).NotEmpty(); + } +} diff --git a/src/Meezi.Infrastructure/Data/DemoMenuCatalog.cs b/src/Meezi.Infrastructure/Data/DemoMenuCatalog.cs index 1b04b94..a4c603e 100644 --- a/src/Meezi.Infrastructure/Data/DemoMenuCatalog.cs +++ b/src/Meezi.Infrastructure/Data/DemoMenuCatalog.cs @@ -37,6 +37,7 @@ public static class DemoMenuCatalog public static IReadOnlyList Categories { get; } = [ + new("cat_demo_coffee", "قهوه تخصصی", "Specialty Coffee", "قهوة متخصصة", 0, IconPresetId: "drinks-hot"), new("cat_demo_drinks", "نوشیدنی گرم", "Hot drinks", "مشروبات ساخنة", 1, IconPresetId: "drinks-hot"), new("cat_demo_cold", "نوشیدنی سرد", "Cold drinks", "مشروبات باردة", 2, IconPresetId: "drinks-cold"), new("cat_demo_breakfast", "صبحانه", "Breakfast", "فطور", 3, IconPresetId: "breakfast"), @@ -47,6 +48,28 @@ public static class DemoMenuCatalog public static IReadOnlyList Items { get; } = [ + // ── Specialty Coffee (20 items) ────────────────────────────────────── + new("item_demo_cf_espresso", "cat_demo_coffee", "اسپرسو", "Espresso", "إسبريسو", "دوبل شات، تازه آسیاب", 65_000, 0, "espresso"), + new("item_demo_cf_doppio", "cat_demo_coffee", "دوپیو", "Doppio", "دوبيو", "دو شات اسپرسو", 80_000, 0, "espresso"), + new("item_demo_cf_ristretto", "cat_demo_coffee", "ریسترتو", "Ristretto", "ريستريتو", "کوتاه و غلیظ", 75_000, 0, "espresso"), + new("item_demo_cf_lungo", "cat_demo_coffee", "لونگو", "Lungo", "لونغو", "اسپرسو کشیده", 70_000, 0, "espresso"), + new("item_demo_cf_americano", "cat_demo_coffee", "آمریکانو", "Americano", "أمريكانو", "اسپرسو و آب داغ", 75_000, 0, "cappuccino"), + new("item_demo_cf_cappuccino", "cat_demo_coffee", "کاپوچینو", "Cappuccino", "كابتشينو", "شیر بخار و فوم", 110_000, 10, "cappuccino"), + new("item_demo_cf_latte", "cat_demo_coffee", "لاته", "Latte", "لاتيه", "شیر بخار گرفته", 120_000, 0, "latte"), + new("item_demo_cf_flat_white", "cat_demo_coffee", "فلت وایت", "Flat White", "فلات وايت", "میکرو فوم نرم", 115_000, 0, "latte"), + new("item_demo_cf_cortado", "cat_demo_coffee", "کورتادو", "Cortado", "كورتادو", "نسبت مساوی قهوه و شیر", 95_000, 0, "espresso"), + new("item_demo_cf_macchiato", "cat_demo_coffee", "ماکیاتو", "Macchiato", "ماكياتو", "اسپرسو با کمی فوم", 90_000, 0, "cappuccino"), + new("item_demo_cf_mocha", "cat_demo_coffee", "موکا", "Mocha", "موكا", "شکلات تلخ و اسپرسو", 135_000, 0, "mocha"), + new("item_demo_cf_affogato", "cat_demo_coffee", "افوگاتو", "Affogato", "أفوغاتو", "اسپرسو روی بستنی وانیل", 140_000, 0, "espresso"), + new("item_demo_cf_cold_brew", "cat_demo_coffee", "کولد برو", "Cold Brew", "كولد برو", "دم ۱۲ ساعته", 140_000, 0, "iced_coffee"), + new("item_demo_cf_iced_latte", "cat_demo_coffee", "آیس لاته", "Iced Latte", "آيس لاتيه", "سرد و خنک", 130_000, 0, "iced_coffee"), + new("item_demo_cf_iced_ameri", "cat_demo_coffee", "آیس آمریکانو", "Iced Americano", "آيس أمريكانو", null, 95_000, 0, "iced_coffee"), + new("item_demo_cf_nitro", "cat_demo_coffee", "نیترو کافی", "Nitro Cold Brew", "نيترو كافي", "با نیتروژن کربن‌دار", 155_000, 0, "iced_coffee"), + new("item_demo_cf_v60", "cat_demo_coffee", "V60", "Pour Over V60", "في 60", "دم‌آوری دستی", 145_000, 0, "espresso"), + new("item_demo_cf_aeropress", "cat_demo_coffee", "ایروپرس", "AeroPress", "إيروبريس", "طعم نرم و کامل", 140_000, 0, "espresso"), + new("item_demo_cf_turkish", "cat_demo_coffee", "قهوه ترک", "Turkish Coffee", "قهوة تركية", "سنتی با هل", 80_000, 0, "espresso"), + new("item_demo_cf_specialty", "cat_demo_coffee", "قهوه تک‌خاستگاه", "Single Origin", "أحادي المنشأ", "هر هفته از منشأ جدید", 165_000, 0, "espresso"), + // Hot drinks new("item_demo_espresso", "cat_demo_drinks", "اسپرسو", "Espresso", "إسبريسو", "دوبل یا سینگل", 65_000, 0, "espresso"), new("item_demo_americano", "cat_demo_drinks", "آمریکانو", "Americano", "أمريكانو", null, 75_000, 0, "cappuccino"), @@ -129,6 +152,10 @@ file static class DemoMenuCategoryKinds public static MenuItemVisualKind KindFor(string itemId, string food101Class) { + // All specialty coffee items are drinks + if (itemId.StartsWith("item_demo_cf_", StringComparison.Ordinal)) + return MenuItemVisualKind.Drink; + if (itemId.Contains("demo_iced", StringComparison.Ordinal) || itemId.Contains("demo_cold", StringComparison.Ordinal) || itemId.Contains("demo_lemonade", StringComparison.Ordinal) diff --git a/src/Meezi.Infrastructure/Data/DevelopmentDataSeeder.cs b/src/Meezi.Infrastructure/Data/DevelopmentDataSeeder.cs index c47a7a1..495da02 100644 --- a/src/Meezi.Infrastructure/Data/DevelopmentDataSeeder.cs +++ b/src/Meezi.Infrastructure/Data/DevelopmentDataSeeder.cs @@ -47,7 +47,7 @@ public static class DevelopmentDataSeeder CafeId = cafe.Id, BranchId = "branch_demo_main", Name = "مدیر دمو", - Phone = "09121234567", + Phone = "09212273138", Role = EmployeeRole.Owner, BaseSalary = 0 }; @@ -97,10 +97,12 @@ public static class DevelopmentDataSeeder await SeedDemoOpenShiftsAsync(db, cafe.Id, logger); var ownerEmp = await db.Employees.FirstOrDefaultAsync(e => e.Id == "emp_demo_owner"); - if (ownerEmp is not null && ownerEmp.BranchId is null) + if (ownerEmp is not null) { - ownerEmp.BranchId = "branch_demo_main"; - await db.SaveChangesAsync(); + var changed = false; + if (ownerEmp.BranchId is null) { ownerEmp.BranchId = "branch_demo_main"; changed = true; } + if (ownerEmp.Phone != "09212273138") { ownerEmp.Phone = "09212273138"; changed = true; } + if (changed) await db.SaveChangesAsync(); } await DemoEmployeesSeeder.EnsureEmployeesAsync(db, cafe.Id, logger); diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index c96f735..0f3e3e8 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -41,7 +41,18 @@ "rateLimited": "Too many code requests. Wait up to one hour or contact support.", "notFound": "No account found for this mobile number.", "smsFailed": "Could not send SMS. Please try again.", - "invalidOtp": "Invalid or expired verification code." + "invalidOtp": "Invalid or expired verification code.", + "register": "Create account", + "registerSubtitle": "Register your cafe on Meezi", + "cafeName": "Cafe or restaurant name", + "cafeNamePlaceholder": "e.g. Roya Cafe", + "createAccount": "Create account", + "alreadyHaveAccount": "Already have an account?", + "loginLink": "Sign in", + "noAccount": "Don't have an account?", + "registerLink": "Register", + "alreadyRegistered": "This phone is already registered. Please sign in.", + "registrationExpired": "Registration session expired. Please try again." }, "nav": { "aria": "Main navigation", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index af2ec22..ed6808d 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -41,7 +41,18 @@ "rateLimited": "تعداد درخواست کد بیش از حد است. حداکثر یک ساعت صبر کنید یا با پشتیبانی تماس بگیرید.", "notFound": "حسابی با این شماره موبایل یافت نشد.", "smsFailed": "ارسال پیامک ناموفق بود. دوباره تلاش کنید.", - "invalidOtp": "کد تأیید نادرست یا منقضی شده است." + "invalidOtp": "کد تأیید نادرست یا منقضی شده است.", + "register": "ثبت‌نام", + "registerSubtitle": "کافه خود را در میزی ثبت کنید", + "cafeName": "نام کافه یا رستوران", + "cafeNamePlaceholder": "مثال: کافه رویا", + "createAccount": "ایجاد حساب", + "alreadyHaveAccount": "حساب دارید؟", + "loginLink": "ورود", + "noAccount": "حساب ندارید؟", + "registerLink": "ثبت‌نام", + "alreadyRegistered": "این شماره قبلاً ثبت‌نام کرده است. لطفاً وارد شوید.", + "registrationExpired": "زمان ثبت‌نام منقضی شد. دوباره تلاش کنید." }, "nav": { "aria": "منوی اصلی", diff --git a/web/dashboard/src/app/[locale]/login/page.tsx b/web/dashboard/src/app/[locale]/login/page.tsx index c69468c..7a9fe69 100644 --- a/web/dashboard/src/app/[locale]/login/page.tsx +++ b/web/dashboard/src/app/[locale]/login/page.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; -import { useRouter } from "@/i18n/routing"; +import { useRouter, Link } from "@/i18n/routing"; import { apiPost, ApiClientError } from "@/lib/api/client"; import type { AuthTokenResponse } from "@/lib/api/types"; import { useAuthStore } from "@/lib/stores/auth.store"; @@ -27,8 +27,6 @@ export default function LoginPage() { switch (err.code) { case "RATE_LIMITED": return t("rateLimited"); - case "NOT_FOUND": - return t("notFound"); case "SMS_FAILED": return t("smsFailed"); case "INVALID_OTP": @@ -47,6 +45,11 @@ export default function LoginPage() { await apiPost("/api/auth/send-otp", { phone }); setStep("otp"); } catch (e) { + if (e instanceof ApiClientError && e.code === "NOT_FOUND") { + // No account → take them to register with phone pre-filled + router.push(`/register?phone=${encodeURIComponent(phone)}`); + return; + } setError(authErrorMessage(e)); } finally { setLoading(false); @@ -137,6 +140,13 @@ export default function LoginPage() { {error && (

{error}

)} + +

+ {t("noAccount")}{" "} + + {t("registerLink")} + +

diff --git a/web/dashboard/src/app/[locale]/register/page.tsx b/web/dashboard/src/app/[locale]/register/page.tsx new file mode 100644 index 0000000..4eb8d46 --- /dev/null +++ b/web/dashboard/src/app/[locale]/register/page.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { useRouter, Link } from "@/i18n/routing"; +import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; +import { apiPost, ApiClientError } from "@/lib/api/client"; +import type { AuthTokenResponse } from "@/lib/api/types"; +import { useAuthStore } from "@/lib/stores/auth.store"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { LabeledField } from "@/components/ui/labeled-field"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +function RegisterForm() { + const t = useTranslations("auth"); + const router = useRouter(); + const setAuth = useAuthStore((s) => s.setAuth); + const searchParams = useSearchParams(); + + const [phone, setPhone] = useState(searchParams.get("phone") ?? ""); + const [cafeName, setCafeName] = useState(""); + const [code, setCode] = useState(""); + const [step, setStep] = useState<"info" | "otp">("info"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const errorMessage = (err: unknown) => { + if (err instanceof ApiClientError) { + switch (err.code) { + case "RATE_LIMITED": return t("rateLimited"); + case "ALREADY_REGISTERED": return t("alreadyRegistered"); + case "SMS_FAILED": return t("smsFailed"); + case "INVALID_OTP": return t("invalidOtp"); + case "REGISTRATION_EXPIRED": return t("registrationExpired"); + default: return err.message; + } + } + return err instanceof Error ? err.message : String(err); + }; + + const sendOtp = async () => { + setLoading(true); + setError(null); + try { + await apiPost("/api/auth/register", { phone, cafeName }); + setStep("otp"); + } catch (e) { + setError(errorMessage(e)); + } finally { + setLoading(false); + } + }; + + const verifyOtp = async () => { + setLoading(true); + setError(null); + try { + const data = await apiPost("/api/auth/verify-register", { phone, code }); + setAuth(data); + router.push("/"); + } catch (e) { + setError(errorMessage(e)); + } finally { + setLoading(false); + } + }; + + return ( +
+ + + {t("register")} +

{t("registerSubtitle")}

+
+ + {step === "info" ? ( +
{ + e.preventDefault(); + if (!loading) void sendOtp(); + }} + > + + setCafeName(e.target.value)} + placeholder={t("cafeNamePlaceholder")} + autoComplete="organization" + required + /> + + + setPhone(e.target.value)} + placeholder={t("phonePlaceholder")} + dir="ltr" + className="text-end" + autoComplete="tel" + required + /> + + +
+ ) : ( +
{ + e.preventDefault(); + if (!loading) void verifyOtp(); + }} + > + + setCode(e.target.value)} + placeholder={t("otpPlaceholder")} + maxLength={6} + dir="ltr" + className="text-center tracking-widest" + autoComplete="one-time-code" + /> + + + +
+ )} + + {error && ( +

{error}

+ )} + +

+ {t("alreadyHaveAccount")}{" "} + + {t("loginLink")} + +

+
+
+
+ ); +} + +export default function RegisterPage() { + return ( + + + + ); +} diff --git a/web/finder/src/app/page.tsx b/web/finder/src/app/page.tsx new file mode 100644 index 0000000..dc21ec0 --- /dev/null +++ b/web/finder/src/app/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +// Middleware handles locale routing, but if it ever misses, redirect to /fa +export default function RootPage() { + redirect("/fa"); +} diff --git a/web/finder/src/middleware.ts b/web/finder/src/middleware.ts new file mode 100644 index 0000000..67ea9d6 --- /dev/null +++ b/web/finder/src/middleware.ts @@ -0,0 +1,9 @@ +import createMiddleware from "next-intl/middleware"; +import { routing } from "./i18n/routing"; + +export default createMiddleware(routing); + +export const config = { + // Match all pathnames except Next.js internals and static files + matcher: ["/((?!_next|_vercel|.*\\..*).*)"], +};