# Club Membership Contract System - سیستم قرارداد باشگاه مشتریان **تاریخ ایجاد:** 2024-12-16 **وضعیت:** ✅ پیاده‌سازی شده **اولویت:** 🔴 بسیار بالا **مرتبط با:** [base-package-payment-system.md](./base-package-payment-system.md) --- ## 📋 فهرست 1. [خلاصه سیستم](#خلاصه-سیستم) 2. [Business Requirements](#business-requirements) 3. [Complete Flow](#complete-flow) 4. [CMS Layer](#cms-layer) 5. [BFF Layer](#bff-layer) 6. [Frontend Layer](#frontend-layer) 7. [OTP SMS Format](#otp-sms-format) 8. [Token Refresh Pattern](#token-refresh-pattern) 9. [Testing Checklist](#testing-checklist) --- ## 🎯 خلاصه سیستم سیستم قرارداد باشگاه مشتریان یک **مدال غیرقابل بسته شدن** است که بعد از پرداخت موفق پکیج پایه، کاربر را ملزم به **امضای قرارداد** می‌کند تا بتواند: 1. عضویت باشگاه مشتریان فعال شود 2. لینک دعوت (Referral Link) نمایش داده شود 3. به امکانات کامل باشگاه مشتریان دسترسی داشته باشد ### ویژگی‌های کلیدی: - ✅ Modal **غیرقابل بسته شدن** (کاربر نمی‌تواند Escape یا Click بیرون را استفاده کند) - ✅ **OTP Verification** برای امنیت بالاتر - ✅ **Automatic Token Refresh** بعد از امضای موفق - ✅ ثبت قرارداد در دیتابیس با **HTML content** و **SignGuid** --- ## 📊 Business Requirements ### شرایط نمایش Modal: ```csharp if (HasPurchasedPackage && !IsClubMemberActive) { // نمایش Modal قرارداد } ``` - **HasPurchasedPackage**: `PackagePurchaseMethod != None` (پرداخت موفق انجام شده) - **IsClubMemberActive**: `ClubMembership.IsActive = true` (قرارداد امضا شده) ### ContractType Enum: ```csharp public enum ContractType { Main = 0, // قرارداد ثبت‌نام اولیه ClubMembership = 1, // قرارداد باشگاه مشتریان } ``` ### OTP Configuration: - **Purpose**: `signClubContract` - **Expiry**: 120 seconds (2 minutes) - **Code Length**: 6 digits - **SMS Provider**: Kavenegar ### ClubMembership Activation Values: ```csharp ClubMembership { IsActive = true, ActivatedAt = DateTime.Now, InitialContribution = 56_000_000, // مبلغ اولیه GiftValue = 25_200_000, // ارزش هدیه (45% از 56M) PurchaseMethod = user.PackagePurchaseMethod } ``` --- ## 🔄 Complete Flow ```mermaid sequenceDiagram participant User participant Frontend participant BFF participant CMS participant SMS as Kavenegar Note over User,SMS: 1️⃣ Payment Successful (قبلاً انجام شده) User->>Frontend: ورود به صفحه Profile Frontend->>Frontend: CheckAndShowClubContractModal() alt HasPurchasedPackage && !IsClubMemberActive Frontend->>User: نمایش Modal غیرقابل بسته شدن User->>User: مطالعه قرارداد (HTML Content) Note over User,SMS: 2️⃣ Request OTP User->>Frontend: کلیک "درخواست کد تایید" Frontend->>BFF: RequestClubContractOtp(SignGuid) BFF->>CMS: CreateNewOtpToken(mobile, "signClubContract") CMS-->>BFF: OTP Code (6 digits) BFF->>SMS: Send SMS(mobile, code, signGuid, fullName) SMS-->>User: پیامک با کد OTP BFF-->>Frontend: Success Frontend->>Frontend: شروع Timer (120 ثانیه) Note over User,SMS: 3️⃣ Accept Contract User->>Frontend: وارد کردن OTP Code Frontend->>BFF: AcceptClubMembershipContract(OtpCode, SignGuid, ContractHtml) BFF->>CMS: AcceptClubMembershipContract(UserId, OtpCode, SignGuid, ContractHtml) CMS->>CMS: VerifyOtpAsync(mobile, "signClubContract", OtpCode) alt OTP Invalid CMS-->>BFF: Error: "کد وارد شده اشتباه است" BFF-->>Frontend: Error Frontend->>User: پیام خطا else OTP Valid CMS->>CMS: ثبت Contract (اگر وجود نداشته باشد) CMS->>CMS: ثبت UserContract (SignGuid, ContractHtml) CMS->>CMS: فعالسازی ClubMembership (IsActive=true) CMS-->>BFF: Success Note over User,SMS: 4️⃣ Token Refresh BFF->>CMS: GetJwtToken(UserId) CMS-->>BFF: New JWT Token BFF-->>Frontend: Success + NewToken Frontend->>Frontend: ذخیره Token در localStorage Frontend->>Frontend: بستن Modal و Refresh صفحه Frontend->>User: نمایش پیام موفقیت + لینک دعوت end end ``` --- ## 💻 CMS Layer ### 📂 File Structure: ``` CMS/ src/CMSMicroservice.Domain/ Enums/ ContractType.cs ✅ Modified src/CMSMicroservice.Application/ ClubMemberships/ Commands/ AcceptClubMembershipContract/ AcceptClubMembershipContractCommand.cs ✅ Created AcceptClubMembershipContractCommandValidator.cs ✅ Created AcceptClubMembershipContractCommandHandler.cs ✅ Created Profiles/ ClubFeatureProfile.cs ✅ Modified src/CMSMicroservice.Protobuf/ Protos/ clubmembership.proto ✅ Modified src/CMSMicroservice.WebApi/ Services/ ClubMembershipService.cs ✅ Modified ``` --- ### 1️⃣ ContractType.cs ```csharp namespace CMSMicroservice.Domain.Enums; /// /// تعیین نوع قرارداد /// public enum ContractType { /// /// قرارداد ثبت‌نام اولیه /// Main = 0, /// /// قرارداد باشگاه مشتریان /// ClubMembership = 1, } ``` **تغییرات:** `CMS = 1` → `ClubMembership = 1` --- ### 2️⃣ AcceptClubMembershipContractCommand.cs ```csharp namespace CMSMicroservice.Application.ClubMemberships.Commands.AcceptClubMembershipContract; /// /// Command برای پذیرش و امضای قرارداد باشگاه مشتریان /// public record AcceptClubMembershipContractCommand { /// /// شناسه کاربر /// public required long UserId { get; init; } /// /// کد OTP ارسال شده به کاربر (6 رقمی) /// public required string OtpCode { get; init; } /// /// شناسه یکتای امضاء (GUID) /// public required string SignGuid { get; init; } /// /// محتوای HTML قرارداد برای ذخیره /// public required string ContractHtml { get; init; } } ``` --- ### 3️⃣ AcceptClubMembershipContractCommandValidator.cs ```csharp using FluentValidation; namespace CMSMicroservice.Application.ClubMemberships.Commands.AcceptClubMembershipContract; public class AcceptClubMembershipContractCommandValidator : AbstractValidator { public AcceptClubMembershipContractCommandValidator() { RuleFor(x => x.UserId) .GreaterThan(0) .WithMessage("شناسه کاربر نامعتبر است"); RuleFor(x => x.OtpCode) .NotEmpty() .WithMessage("کد تایید الزامی است") .Length(6) .WithMessage("کد تایید باید 6 رقمی باشد") .Matches(@"^\d{6}$") .WithMessage("کد تایید فقط باید شامل اعداد باشد"); RuleFor(x => x.SignGuid) .NotEmpty() .WithMessage("شناسه امضاء الزامی است") .Must(guid => Guid.TryParse(guid, out _)) .WithMessage("شناسه امضاء نامعتبر است"); RuleFor(x => x.ContractHtml) .NotEmpty() .WithMessage("محتوای قرارداد الزامی است") .MinimumLength(100) .WithMessage("محتوای قرارداد نامعتبر است"); } } ``` --- ### 4️⃣ AcceptClubMembershipContractCommandHandler.cs ```csharp using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Domain.Entities; using CMSMicroservice.Domain.Enums; using MediatR; using Microsoft.EntityFrameworkCore; namespace CMSMicroservice.Application.ClubMemberships.Commands.AcceptClubMembershipContract; public class AcceptClubMembershipContractCommandHandler : IRequestHandler { private readonly IApplicationDbContext _context; public AcceptClubMembershipContractCommandHandler(IApplicationDbContext context) { _context = context; } public async Task Handle( AcceptClubMembershipContractCommand request, CancellationToken cancellationToken) { // 1️⃣ دریافت کاربر با ClubMembership var user = await _context.Users .Include(u => u.ClubMembership) .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); if (user == null) throw new Exception("کاربر یافت نشد"); // 2️⃣ بررسی پیش‌نیازها if (user.PackagePurchaseMethod == PackagePurchaseMethod.None) throw new Exception("برای فعالسازی باشگاه مشتریان ابتدا باید پکیج پایه را خریداری کنید"); if (user.ClubMembership?.IsActive == true) throw new Exception("باشگاه مشتریان شما قبلاً فعال شده است"); // 3️⃣ تایید OTP var isOtpValid = await VerifyOtpAsync( user.MobileNumber, "signClubContract", request.OtpCode, cancellationToken); if (!isOtpValid) throw new Exception("کد وارد شده اشتباه است یا منقضی شده است"); // 4️⃣ ایجاد/دریافت Contract var contract = await _context.Contracts .FirstOrDefaultAsync(c => c.Type == ContractType.ClubMembership, cancellationToken); if (contract == null) { // اگر Contract وجود نداشته باشد، ایجاد می‌کنیم contract = new Contract { Type = ContractType.ClubMembership, Title = "قرارداد باشگاه مشتریان کارابازار", Description = "شرایط و ضوابط عضویت در باشگاه مشتریان", IsActive = true, CreatedAt = DateTime.Now }; _context.Contracts.Add(contract); await _context.SaveChangesAsync(cancellationToken); } // 5️⃣ ثبت UserContract (امضای کاربر) var userContract = new UserContract { UserId = user.Id, ContractId = contract.Id, SignGuid = request.SignGuid, SignedPdfFile = request.ContractHtml, // HTML content ذخیره می‌شود IsAccepted = true, SignedAt = DateTime.Now }; _context.UserContracts.Add(userContract); // 6️⃣ فعالسازی ClubMembership if (user.ClubMembership == null) { user.ClubMembership = new ClubMembership { UserId = user.Id, IsActive = true, ActivatedAt = DateTime.Now, InitialContribution = 56_000_000, // مبلغ پکیج پایه GiftValue = 25_200_000, // 45% هدیه PurchaseMethod = user.PackagePurchaseMethod }; _context.ClubMemberships.Add(user.ClubMembership); } else { user.ClubMembership.IsActive = true; user.ClubMembership.ActivatedAt = DateTime.Now; user.ClubMembership.InitialContribution = 56_000_000; user.ClubMembership.GiftValue = 25_200_000; user.ClubMembership.PurchaseMethod = user.PackagePurchaseMethod; } await _context.SaveChangesAsync(cancellationToken); return true; } /// /// تایید کد OTP /// private async Task VerifyOtpAsync( string mobile, string purpose, string code, CancellationToken cancellationToken) { var otpToken = await _context.OtpTokens .Where(o => o.Mobile == mobile && o.Purpose == purpose && o.Code == code && !o.IsUsed) .OrderByDescending(o => o.CreatedAt) .FirstOrDefaultAsync(cancellationToken); if (otpToken == null) return false; // بررسی انقضا (120 ثانیه) if ((DateTime.Now - otpToken.CreatedAt).TotalSeconds > 120) return false; // علامت‌گذاری به عنوان استفاده شده otpToken.IsUsed = true; await _context.SaveChangesAsync(cancellationToken); return true; } } ``` **نکات کلیدی:** - ✅ بررسی `PackagePurchaseMethod != None` (باید پکیج خریداری شده باشد) - ✅ جلوگیری از امضای مجدد (`ClubMembership.IsActive == true`) - ✅ تایید OTP با `VerifyOtpAsync` method - ✅ ایجاد Contract اگر وجود نداشته باشد - ✅ ثبت UserContract با SignGuid و HTML content - ✅ فعالسازی ClubMembership با مقادیر مشخص شده --- ### 5️⃣ clubmembership.proto ```protobuf syntax = "proto3"; option csharp_namespace = "CMSMicroservice.Protobuf"; package clubmembership; service ClubMembershipContract { // ... other RPCs ... rpc AcceptClubMembershipContract(AcceptClubMembershipContractRequest) returns (AcceptClubMembershipContractResponse); } message AcceptClubMembershipContractRequest { int64 user_id = 1; string otp_code = 2; string sign_guid = 3; string contract_html = 4; } message AcceptClubMembershipContractResponse { bool success = 1; string message = 2; } ``` --- ### 6️⃣ ClubMembershipService.cs ```csharp public override async Task AcceptClubMembershipContract( AcceptClubMembershipContractRequest request, ServerCallContext context) { try { var command = _mapper.Map(request); var result = await _mediator.Send(command); return new AcceptClubMembershipContractResponse { Success = result, Message = result ? "قرارداد با موفقیت امضا شد" : "خطا در امضای قرارداد" }; } catch (Exception ex) { return new AcceptClubMembershipContractResponse { Success = false, Message = ex.Message }; } } ``` --- ### 7️⃣ ClubFeatureProfile.cs ```csharp using CMSMicroservice.Application.ClubMemberships.Commands.AcceptClubMembershipContract; using CMSMicroservice.Protobuf; using Mapster; namespace CMSMicroservice.Application.Profiles; public class ClubFeatureProfile : IRegister { public void Register(TypeAdapterConfig config) { // ... other mappings ... config.NewConfig() .Map(dest => dest.UserId, src => src.UserId) .Map(dest => dest.OtpCode, src => src.OtpCode) .Map(dest => dest.SignGuid, src => src.SignGuid) .Map(dest => dest.ContractHtml, src => src.ContractHtml); } } ``` --- ## 🔌 BFF Layer ### 📂 File Structure: ``` FrontOffice.BFF/ src/FrontOffice.BFF.Domain/ (No changes - using CMS entities) src/FrontOffice.BFF.Application/ ClubMemberships/ Commands/ RequestClubContractOtp/ RequestClubContractOtpCommand.cs ✅ Created RequestClubContractOtpCommandValidator.cs ✅ Created RequestClubContractOtpCommandHandler.cs ✅ Created (با IKavenegarService) AcceptClubMembershipContract/ AcceptClubMembershipContractCommand.cs ✅ Created AcceptClubMembershipContractCommandValidator.cs ✅ Created AcceptClubMembershipContractCommandHandler.cs ✅ Created Profiles/ ClubMembershipProfile.cs ✅ Modified src/Protobufs/ clubmembership.proto ✅ Modified src/FrontOffice.BFF.WebApi/ Services/ ClubMembershipGrpcService.cs ✅ Modified ``` --- ### 1️⃣ RequestClubContractOtpCommand.cs ```csharp using MediatR; namespace FrontOffice.BFF.Application.ClubMemberships.Commands.RequestClubContractOtp; /// /// Command برای درخواست OTP برای امضای قرارداد باشگاه مشتریان /// public record RequestClubContractOtpCommand : IRequest { /// /// شناسه یکتای امضاء (GUID) - برای ارسال در پیامک /// public required string SignGuid { get; init; } } ``` --- ### 2️⃣ RequestClubContractOtpCommandValidator.cs ```csharp using FluentValidation; namespace FrontOffice.BFF.Application.ClubMemberships.Commands.RequestClubContractOtp; public class RequestClubContractOtpCommandValidator : AbstractValidator { public RequestClubContractOtpCommandValidator() { RuleFor(x => x.SignGuid) .NotEmpty() .WithMessage("شناسه امضاء الزامی است") .Must(guid => Guid.TryParse(guid, out _)) .WithMessage("شناسه امضاء نامعتبر است"); } } ``` --- ### 3️⃣ RequestClubContractOtpCommandHandler.cs ```csharp using System.Text; using FrontOffice.BFF.Application.Common.Interfaces; using MediatR; using OtpService.Protobuf; namespace FrontOffice.BFF.Application.ClubMemberships.Commands.RequestClubContractOtp; public class RequestClubContractOtpCommandHandler : IRequestHandler { private readonly IApplicationContractContext _context; private readonly IKavenegarService _kavenegarService; private readonly ICurrentUserService _currentUserService; public RequestClubContractOtpCommandHandler( IApplicationContractContext context, IKavenegarService kavenegarService, ICurrentUserService currentUserService) { _context = context; _kavenegarService = kavenegarService; _currentUserService = currentUserService; } public async Task Handle( RequestClubContractOtpCommand request, CancellationToken cancellationToken) { // 1️⃣ دریافت شماره موبایل از CurrentUserService var mobileNumber = _currentUserService.MobileNumber; if (string.IsNullOrEmpty(mobileNumber)) throw new Exception("شماره موبایل کاربر یافت نشد"); // 2️⃣ فراخوانی CMS برای ایجاد OTP var otpResponse = await _context.OtpToken.CreateNewOtpTokenAsync( new CreateNewOtpTokenRequest { Mobile = mobileNumber, Purpose = "signClubContract" }, cancellationToken: cancellationToken); if (!otpResponse.Success || string.IsNullOrWhiteSpace(otpResponse.Code)) throw new Exception("خطا در ارسال کد تایید"); // 3️⃣ ارسال پیامک با Kavenegar var fullName = $"{_currentUserService.FirstName} {_currentUserService.LastName}".Trim(); await _kavenegarService.Send( mobile: mobileNumber, new StringBuilder("سلام ") .Append(fullName) .AppendLine(" عزیز") .Append("کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: ") .AppendLine(otpResponse.Code) .AppendLine("شناسه امضاء: ") .AppendLine(request.SignGuid) .AppendLine("کارابازار") .ToString()); return true; } } ``` **نکات کلیدی:** - ✅ استفاده از `IKavenegarService` برای ارسال پیامک (مشابه `CreateNewOtpTokenCommandHandler`) - ✅ دریافت `MobileNumber` از `ICurrentUserService` (از JWT Token) - ✅ Purpose: `signClubContract` - ✅ ارسال `SignGuid` در پیامک برای ردیابی - ✅ Format پیامک شامل: نام کاربر، کد OTP، شناسه امضا، نام شرکت **SMS Format Example:** ``` سلام علی عزیز کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: 123456 شناسه امضاء: a1b2c3d4-e5f6-7890-abcd-ef1234567890 کارابازار ``` --- ### 4️⃣ AcceptClubMembershipContractCommand.cs ```csharp using MediatR; namespace FrontOffice.BFF.Application.ClubMemberships.Commands.AcceptClubMembershipContract; /// /// Command برای پذیرش و امضای قرارداد باشگاه مشتریان /// public record AcceptClubMembershipContractCommand : IRequest { /// /// کد OTP ارسال شده به کاربر (6 رقمی) /// public required string OtpCode { get; init; } /// /// شناسه یکتای امضاء (GUID) /// public required string SignGuid { get; init; } /// /// محتوای HTML قرارداد برای ذخیره /// public required string ContractHtml { get; init; } } ``` **Return Type:** `string?` - JWT Token جدید (اگر موفق بود) --- ### 5️⃣ AcceptClubMembershipContractCommandValidator.cs ```csharp using FluentValidation; namespace FrontOffice.BFF.Application.ClubMemberships.Commands.AcceptClubMembershipContract; public class AcceptClubMembershipContractCommandValidator : AbstractValidator { public AcceptClubMembershipContractCommandValidator() { RuleFor(x => x.OtpCode) .NotEmpty() .WithMessage("کد تایید الزامی است") .Length(6) .WithMessage("کد تایید باید 6 رقمی باشد") .Matches(@"^\d{6}$") .WithMessage("کد تایید فقط باید شامل اعداد باشد"); RuleFor(x => x.SignGuid) .NotEmpty() .WithMessage("شناسه امضاء الزامی است") .Must(guid => Guid.TryParse(guid, out _)) .WithMessage("شناسه امضاء نامعتبر است"); RuleFor(x => x.ContractHtml) .NotEmpty() .WithMessage("محتوای قرارداد الزامی است") .MinimumLength(100) .WithMessage("محتوای قرارداد نامعتبر است"); } } ``` --- ### 6️⃣ AcceptClubMembershipContractCommandHandler.cs ```csharp using CMSMicroservice.Protobuf; using FrontOffice.BFF.Application.Common.Interfaces; using MediatR; using User.Protobuf; namespace FrontOffice.BFF.Application.ClubMemberships.Commands.AcceptClubMembershipContract; public class AcceptClubMembershipContractCommandHandler : IRequestHandler { private readonly IApplicationContractContext _context; private readonly ICurrentUserService _currentUserService; public AcceptClubMembershipContractCommandHandler( IApplicationContractContext context, ICurrentUserService currentUserService) { _context = context; _currentUserService = currentUserService; } public async Task Handle( AcceptClubMembershipContractCommand request, CancellationToken cancellationToken) { var userId = _currentUserService.UserId ?? throw new Exception("کاربر احراز هویت نشده است"); // 1️⃣ فراخوانی CMS برای امضای قرارداد var cmsResponse = await _context.ClubMemberships.AcceptClubMembershipContractAsync( new AcceptClubMembershipContractRequest { UserId = userId, OtpCode = request.OtpCode, SignGuid = request.SignGuid, ContractHtml = request.ContractHtml }, cancellationToken: cancellationToken); if (!cmsResponse.Success) throw new Exception(cmsResponse.Message ?? "خطا در امضای قرارداد"); // 2️⃣ دریافت JWT Token جدید var tokenResponse = await _context.User.GetJwtTokenAsync( new GetJwtTokenRequest { Id = userId }, cancellationToken: cancellationToken); return tokenResponse?.Token; } } ``` **نکات کلیدی:** - ✅ دریافت `UserId` از `ICurrentUserService` - ✅ فراخوانی CMS.AcceptClubMembershipContract - ✅ **Automatic Token Refresh** بعد از موفقیت - ✅ Return کردن token جدید به Frontend --- ### 7️⃣ clubmembership.proto (BFF) ```protobuf syntax = "proto3"; option csharp_namespace = "FrontOffice.BFF.ClubMembership.Protobuf"; package clubmembership; service ClubMembership { // ... other RPCs ... rpc RequestClubContractOtp(RequestClubContractOtpRequest) returns (RequestClubContractOtpResponse); rpc AcceptClubMembershipContract(AcceptClubMembershipContractRequest) returns (AcceptClubMembershipContractResponse); } message RequestClubContractOtpRequest { string sign_guid = 1; } message RequestClubContractOtpResponse { bool success = 1; string message = 2; } message AcceptClubMembershipContractRequest { string otp_code = 1; string sign_guid = 2; string contract_html = 3; } message AcceptClubMembershipContractResponse { bool success = 1; string message = 2; string new_token = 3; // JWT Token جدید } ``` --- ### 8️⃣ ClubMembershipGrpcService.cs ```csharp public override async Task RequestClubContractOtp( RequestClubContractOtpRequest request, ServerCallContext context) { try { var command = _mapper.Map(request); var result = await _mediator.Send(command); return new RequestClubContractOtpResponse { Success = result, Message = result ? "کد تایید ارسال شد" : "خطا در ارسال کد تایید" }; } catch (Exception ex) { return new RequestClubContractOtpResponse { Success = false, Message = ex.Message }; } } public override async Task AcceptClubMembershipContract( AcceptClubMembershipContractRequest request, ServerCallContext context) { try { var command = _mapper.Map(request); var newToken = await _mediator.Send(command); return new AcceptClubMembershipContractResponse { Success = true, Message = "قرارداد با موفقیت امضا شد", NewToken = newToken ?? string.Empty }; } catch (Exception ex) { return new AcceptClubMembershipContractResponse { Success = false, Message = ex.Message, NewToken = string.Empty }; } } ``` --- ### 9️⃣ ClubMembershipProfile.cs ```csharp using FrontOffice.BFF.Application.ClubMemberships.Commands.AcceptClubMembershipContract; using FrontOffice.BFF.Application.ClubMemberships.Commands.RequestClubContractOtp; using FrontOffice.BFF.ClubMembership.Protobuf; using Mapster; namespace FrontOffice.BFF.Application.Profiles; public class ClubMembershipProfile : IRegister { public void Register(TypeAdapterConfig config) { // ... other mappings ... config.NewConfig() .Map(dest => dest.SignGuid, src => src.SignGuid); config.NewConfig() .Map(dest => dest.OtpCode, src => src.OtpCode) .Map(dest => dest.SignGuid, src => src.SignGuid) .Map(dest => dest.ContractHtml, src => src.ContractHtml); } } ``` --- ## 🎨 Frontend Layer ### 📂 File Structure: ``` FrontOffice/ src/FrontOffice.Main/ Pages/Profile/ Index.razor.cs ✅ Modified Components/Dialogs/ ClubMembershipContractDialog.razor ✅ Created ``` --- ### 1️⃣ ClubMembershipContractDialog.razor ```razor @using FrontOffice.BFF.ClubMembership.Protobuf @inject ClubMembership.ClubMembershipClient ClubMembershipClient @inject NavigationManager Navigation @inject ISnackbar Snackbar @inject ILocalStorageService LocalStorage @implements IDisposable @if (_currentStep == ContractStep.ReadContract) { 📜 قرارداد باشگاه مشتریان کارابازار @((MarkupString)GetClubContractHtml()) ⚠️ توجه: برای استفاده از امکانات باشگاه مشتریان و فعالسازی لینک دعوت، باید این قرارداد را امضا کنید. @if (_isLoading) { در حال ارسال... } else { ✅ مطالعه کردم، درخواست کد تایید } } else if (_currentStep == ContractStep.EnterOtp) { 🔐 تایید امضای قرارداد ✅ کد تایید به شماره موبایل شما ارسال شد. @if (_isLoading) { در حال تایید... } else { ✍️ امضای قرارداد } 🔄 ارسال مجدد کد } else if (_currentStep == ContractStep.Success) { 🎉 تبریک! ✅ قرارداد با موفقیت امضا شد و باشگاه مشتریان شما فعال شد. اکنون می‌توانید از لینک دعوت استفاده کنید. ✅ متوجه شدم } @code { [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!; private enum ContractStep { ReadContract, EnterOtp, Success } private ContractStep _currentStep = ContractStep.ReadContract; private bool _isLoading; private string _signGuid = Guid.NewGuid().ToString(); private string _otpCode = string.Empty; private int _remainingSeconds = 120; private System.Threading.Timer? _timer; private async Task RequestOtp() { _isLoading = true; try { var response = await ClubMembershipClient.RequestClubContractOtpAsync( new RequestClubContractOtpRequest { SignGuid = _signGuid }); if (response.Success) { _currentStep = ContractStep.EnterOtp; _remainingSeconds = 120; StartTimer(); Snackbar.Add("کد تایید ارسال شد", Severity.Success); } else { Snackbar.Add(response.Message ?? "خطا در ارسال کد تایید", Severity.Error); } } catch (Exception ex) { Snackbar.Add($"خطا: {ex.Message}", Severity.Error); } finally { _isLoading = false; } } private async Task AcceptContract() { _isLoading = true; try { var response = await ClubMembershipClient.AcceptClubMembershipContractAsync( new AcceptClubMembershipContractRequest { OtpCode = _otpCode, SignGuid = _signGuid, ContractHtml = GetClubContractHtml() }); if (response.Success) { // ذخیره token جدید if (!string.IsNullOrEmpty(response.NewToken)) { await LocalStorage.SetItemAsStringAsync("token", response.NewToken); } _currentStep = ContractStep.Success; StopTimer(); Snackbar.Add("قرارداد با موفقیت امضا شد", Severity.Success); } else { Snackbar.Add(response.Message ?? "خطا در امضای قرارداد", Severity.Error); } } catch (Exception ex) { Snackbar.Add($"خطا: {ex.Message}", Severity.Error); } finally { _isLoading = false; } } private void CloseAndRefresh() { // بستن modal و refresh صفحه برای نمایش لینک دعوت Navigation.NavigateTo(Navigation.Uri, forceLoad: true); } private void StartTimer() { _timer = new System.Threading.Timer(_ => { if (_remainingSeconds > 0) { _remainingSeconds--; InvokeAsync(StateHasChanged); } else { StopTimer(); } }, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); } private void StopTimer() { _timer?.Dispose(); _timer = null; } public void Dispose() { StopTimer(); } private string GetClubContractHtml() { return @"

قرارداد عضویت در باشگاه مشتریان کارابازار

این قرارداد بین کاربر محترم (عضو باشگاه) و شرکت کارابازار منعقد می‌گردد.

ماده 1: تعهدات شرکت

  • ارائه خدمات باشگاه مشتریان طبق شرایط اعلام شده
  • امکان دعوت سایر کاربران از طریق لینک دعوت اختصاصی
  • دریافت کمیسیون از خریدهای زیرمجموعه‌ها

ماده 2: تعهدات کاربر

  • رعایت قوانین و مقررات باشگاه مشتریان
  • عدم سوء استفاده از لینک دعوت
  • رعایت اصول اخلاقی در معرفی افراد

ماده 3: جزئیات مالی

  • مبلغ پرداختی: 56,000,000 تومان
  • ارزش هدیه: 25,200,000 تومان (45% مبلغ پرداختی)
  • کل شارژ کیف پول: 56,000,000 تومان

با امضای این قرارداد، شما تمامی شرایط و ضوابط فوق را می‌پذیرید.

"; } } ``` **نکات کلیدی:** - ✅ استفاده از `IMudDialogInstance` (نه `MudDialogInstance`) - ✅ سه مرحله: ReadContract → EnterOtp → Success - ✅ Timer countdown برای OTP (120 ثانیه) - ✅ ذخیره token جدید در localStorage - ✅ Refresh صفحه بعد از موفقیت - ✅ HTML contract content در `GetClubContractHtml()` --- ### 2️⃣ Index.razor.cs (Profile Page) ```csharp private bool _hasPurchasedPackage; private bool _isClubMemberActive; private bool CanShowReferralLink => _hasPurchasedPackage && _isClubMemberActive; protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { await LoadUserData(); await CheckAndShowClubContractModal(); StateHasChanged(); } } private async Task CheckAndShowClubContractModal() { // اگر کاربر پکیج خریده ولی قرارداد امضا نکرده if (_hasPurchasedPackage && !_isClubMemberActive) { var options = new DialogOptions { BackdropClick = false, // غیرقابل بسته شدن با کلیک بیرون CloseOnEscapeKey = false, // غیرقابل بسته شدن با Escape CloseButton = false, // بدون دکمه Close MaxWidth = MaxWidth.Medium, FullWidth = true }; await DialogService.ShowAsync("", options); } } ``` **نکات کلیدی:** - ✅ `BackdropClick = false` (نه `DisableBackdropClick`) - ✅ `CloseOnEscapeKey = false` - ✅ `CloseButton = false` - ✅ فراخوانی در `OnAfterRenderAsync` --- ## 📱 OTP SMS Format ### Message Template: ``` سلام {نام کاربر} عزیز کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: {کد 6 رقمی} شناسه امضاء: {GUID} کارابازار ``` ### Real Example: ``` سلام علی احمدی عزیز کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: 123456 شناسه امضاء: a1b2c3d4-e5f6-7890-abcd-ef1234567890 کارابازار ``` ### Code Implementation: ```csharp await _kavenegarService.Send( mobile: mobileNumber, new StringBuilder("سلام ") .Append(fullName) .AppendLine(" عزیز") .Append("کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: ") .AppendLine(otpResponse.Code) .AppendLine("شناسه امضاء: ") .AppendLine(request.SignGuid) .AppendLine("کارابازار") .ToString()); ``` --- ## 🔄 Token Refresh Pattern ### چرا Token Refresh؟ بعد از امضای قرارداد، وضعیت کاربر تغییر می‌کند: - `ClubMembership.IsActive` از `false` به `true` تغییر می‌کند - JWT Token فعلی claim‌های قدیمی دارد - برای نمایش لینک دعوت، نیاز به token جدید با claim‌های به‌روز شده داریم ### Flow: ``` 1. AcceptContract موفق شد ↓ 2. BFF.AcceptClubMembershipContractCommandHandler ├─ فراخوانی CMS.AcceptClubMembershipContract └─ فراخوانی CMS.GetJwtToken(userId) → token جدید ↓ 3. Frontend دریافت token جدید └─ ذخیره در localStorage ↓ 4. Refresh صفحه └─ لینک دعوت نمایش داده می‌شود ``` ### Code: ```csharp // BFF Handler var tokenResponse = await _context.User.GetJwtTokenAsync( new GetJwtTokenRequest { Id = userId }, cancellationToken: cancellationToken); return tokenResponse?.Token; ``` ```csharp // Frontend if (!string.IsNullOrEmpty(response.NewToken)) { await LocalStorage.SetItemAsStringAsync("token", response.NewToken); } Navigation.NavigateTo(Navigation.Uri, forceLoad: true); ``` --- ## ✅ Testing Checklist ### 1️⃣ CMS Layer Tests: - [ ] `AcceptClubMembershipContractCommandValidator` validation rules - [ ] `AcceptClubMembershipContractCommandHandler`: - [ ] کاربر یافت نمی‌شود → Exception - [ ] PackagePurchaseMethod = None → Exception - [ ] ClubMembership.IsActive = true → Exception (جلوگیری از امضای مجدد) - [ ] OTP نامعتبر → Exception - [ ] OTP منقضی شده → Exception - [ ] امضای موفق → ClubMembership.IsActive = true - [ ] مقادیر صحیح: InitialContribution, GiftValue, ActivatedAt ### 2️⃣ BFF Layer Tests: - [ ] `RequestClubContractOtpCommandHandler`: - [ ] MobileNumber از CurrentUserService دریافت می‌شود - [ ] OTP از CMS دریافت می‌شود - [ ] پیامک با IKavenegarService ارسال می‌شود - [ ] SignGuid در پیامک موجود است - [ ] `AcceptClubMembershipContractCommandHandler`: - [ ] فراخوانی CMS موفق - [ ] Token جدید دریافت و return می‌شود ### 3️⃣ Frontend Tests: - [ ] Modal نمایش داده می‌شود وقتی `HasPurchasedPackage && !IsClubMemberActive` - [ ] Modal **غیرقابل بسته شدن** است (Escape, Backdrop Click, Close Button) - [ ] درخواست OTP موفق → مرحله EnterOtp - [ ] Timer countdown کار می‌کند (120 ثانیه) - [ ] امضای موفق → مرحله Success - [ ] Token refresh و reload صفحه - [ ] لینک دعوت نمایش داده می‌شود ### 4️⃣ Integration Tests: - [ ] End-to-End Flow: Payment → Modal → OTP → Sign → Refresh → Referral Link - [ ] پیامک واقعی ارسال می‌شود - [ ] Contract و UserContract در دیتابیس ثبت می‌شود - [ ] ClubMembership فعال می‌شود --- ## 🔗 Related Documents - [Base Package Payment System](./base-package-payment-system.md) - [Network Commission System](./network-commission-system.md) - [Binary Tree Guide](./binary-tree-guide.md) --- ## 📝 Notes ### تغییرات مهم: 1. **ContractType.ClubMembership** - نام تغییر کرد از `CMS` به `ClubMembership` 2. **IKavenegarService** - الزامی برای ارسال پیامک در BFF 3. **IMudDialogInstance** - type صحیح برای MudDialog 4. **BackdropClick** - جایگزین `DisableBackdropClick` ### نکات امنیتی: - ✅ OTP Verification قبل از امضا - ✅ جلوگیری از امضای مجدد (ClubMembership.IsActive check) - ✅ بررسی PackagePurchaseMethod (کاربر باید پکیج خریده باشد) - ✅ Token Refresh برای claims جدید ### Known Issues: - هیچ موردی گزارش نشده ✅ --- **آخرین به‌روزرسانی:** 2024-12-16 **مستندساز:** GitHub Copilot **وضعیت Build:** ✅ All Green (CMS, BFF, Frontend)