From e1eaf5eff55055750b4c238ecf96320c86545748 Mon Sep 17 00:00:00 2001 From: alisaza Date: Sat, 5 Sep 2026 13:12:59 +0330 Subject: [PATCH] feat: initial ghabilee-admin backoffice app Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity). --- .cursorrules | 430 + .dockerignore | 76 + .env.example | 4 + .github/BRANCH_PROTECTION.md | 37 + .github/workflows/deploy-vps.yml | 221 + .github/workflows/frontend-quality.yml | 68 + .gitignore | 9 + .gitleaks.toml | 10 + .husky/pre-commit | 4 + .husky/pre-push | 9 + .prettierignore | 3 + .prettierrc | 16 + AGENTS.md | 9 + CLAUDE.md | 1 + Dockerfile | 78 + LICENSE | 21 + PRODUCTION_READINESS_PLAN.md | 218 + README.md | 14 + api/orval-client.ts | 28 + app/(dashboard)/admin/events/[id]/page.tsx | 15 + app/(dashboard)/audit-logs/page.tsx | 199 + app/(dashboard)/bank-accounts/page.tsx | 303 + .../ArticleFeaturedImageUploader.tsx | 98 + .../_components/ArticleFormModal.tsx | 433 + app/(dashboard)/blog-articles/page.tsx | 146 + app/(dashboard)/bookings/page.tsx | 148 + .../_components/AdminMessageBubble.test.tsx | 49 + .../[id]/_components/AdminMessageBubble.tsx | 131 + .../_components/ChatImagePreviewModal.tsx | 139 + .../[id]/_components/DateSeparator.tsx | 13 + app/(dashboard)/chat-oversight/[id]/page.tsx | 238 + app/(dashboard)/chat-oversight/page.tsx | 134 + app/(dashboard)/cities/page.tsx | 99 + app/(dashboard)/contact-messages/page.tsx | 204 + app/(dashboard)/dashboard/page.tsx | 434 + app/(dashboard)/discount-codes/page.tsx | 157 + app/(dashboard)/error.tsx | 19 + .../_components/CategoryFormModal.tsx | 288 + .../_components/CategoryTree.test.tsx | 49 + .../_components/CategoryTree.tsx | 255 + .../_components/CategoryTreeNode.tsx | 147 + app/(dashboard)/event-categories/page.tsx | 15 + app/(dashboard)/events/page.tsx | 7 + .../_components/GuestListItemsModal.tsx | 102 + app/(dashboard)/guest-lists/page.tsx | 109 + .../identity-verifications/page.tsx | 515 + app/(dashboard)/layout.tsx | 14 + app/(dashboard)/loading.tsx | 5 + .../manage-events/[id]/edit/page.tsx | 39 + app/(dashboard)/manage-events/[id]/page.tsx | 11 + app/(dashboard)/manage-events/new/page.tsx | 5 + app/(dashboard)/manage-events/page.tsx | 247 + app/(dashboard)/manual-notifications/page.tsx | 7 + app/(dashboard)/notification-rules/page.tsx | 7 + .../_components/InAppNotificationsPanel.tsx | 179 + .../NotificationRulesPanel.test.tsx | 158 + .../_components/NotificationRulesPanel.tsx | 325 + .../_components/PushDeliveriesPanel.tsx | 218 + .../SendManualNotificationModal.tsx | 375 + .../_components/SmsMessagesPanel.tsx | 209 + app/(dashboard)/notifications/page.tsx | 110 + app/(dashboard)/payments/page.tsx | 196 + app/(dashboard)/provinces/page.tsx | 10 + app/(dashboard)/push-deliveries/page.tsx | 7 + app/(dashboard)/reviews/page.tsx | 227 + .../_components/CreateSettlementModal.tsx | 204 + app/(dashboard)/settlements/page.tsx | 329 + app/(dashboard)/sms-messages/page.tsx | 7 + app/(dashboard)/support-tickets/[id]/page.tsx | 148 + app/(dashboard)/support-tickets/page.tsx | 93 + app/(dashboard)/user-reports/page.tsx | 272 + .../users/[id]/_components/UserEditModal.tsx | 262 + .../UserMobileChangeModal.test.tsx | 133 + .../_components/UserMobileChangeModal.tsx | 247 + .../[id]/_components/WalletCreditModal.tsx | 103 + app/(dashboard)/users/[id]/page.tsx | 444 + app/(dashboard)/users/page.tsx | 117 + app/(dashboard)/wallet-deposits/page.tsx | 179 + app/(dashboard)/withdrawal-requests/page.tsx | 350 + app/api/auth/clear-session/route.test.ts | 30 + app/api/auth/clear-session/route.ts | 26 + app/api/download-upload/route.ts | 93 + app/auth/layout.tsx | 53 + app/auth/page.tsx | 15 + app/error.tsx | 19 + app/global-error.tsx | 23 + app/layout.tsx | 80 + app/loading.tsx | 5 + app/not-found.tsx | 19 + app/page.tsx | 8 + app/providers.tsx | 29 + components/PaginatedList.test.tsx | 65 + components/PaginatedList.tsx | 32 + components/admin/ManualPayoutModal.tsx | 124 + components/auth/AdminAuthContent.tsx | 215 + components/auth/AdminRoleGuard.tsx | 26 + components/auth/PendingUserGuard.tsx | 32 + components/consumer/ConsumerActionButtons.tsx | 88 + components/consumer/ConsumerButton.tsx | 168 + components/consumer/ConsumerInput.tsx | 243 + components/consumer/ConsumerModal.tsx | 339 + components/consumer/UserAvatar.tsx | 62 + components/consumer/UserIdentity.tsx | 105 + components/consumer/consumerButtonStyles.ts | 137 + components/consumer/consumerButtonTypes.ts | 51 + .../consumer/input/ConsumerChoiceField.tsx | 183 + .../consumer/input/ConsumerFieldShell.tsx | 42 + .../consumer/input/ConsumerSelectField.tsx | 170 + .../consumer/input/ConsumerTextField.tsx | 235 + .../consumer/input/ConsumerTextareaField.tsx | 103 + .../input/consumerInputStyles.test.ts | 66 + .../consumer/input/consumerInputStyles.ts | 118 + .../consumer/input/consumerInputTypes.ts | 64 + .../consumer/input/consumerInputUtils.ts | 25 + .../events/EventGuestListAccessPanel.tsx | 134 + .../create/AudienceRestrictionSelector.tsx | 98 + .../create/CancellationPolicyEditor.tsx | 48 + .../events/create/CategoryTreeSelect.tsx | 48 + .../create/ConsumerPickerTrigger.test.tsx | 25 + .../events/create/ConsumerPickerTrigger.tsx | 53 + .../events/create/CreateEventPreviewCard.tsx | 72 + .../events/create/EventCreateWizard.tsx | 412 + .../create/EventMediaGalleryUploader.tsx | 633 + components/events/create/FaqEditor.tsx | 92 + .../events/create/MapLocationPicker.tsx | 258 + .../events/create/TicketTypeSelector.tsx | 48 + components/events/create/WizardFormLayout.tsx | 96 + components/events/create/WizardStepper.tsx | 144 + .../events/create/cloneFromEvent.test.ts | 107 + components/events/create/cloneFromEvent.ts | 70 + .../consumer/ConsumerEventCreateWizard.tsx | 453 + .../create/consumer/ConsumerWizardStepper.tsx | 81 + .../events/create/consumer/cloneFromEvent.ts | 70 + .../create/consumer/steps/Step1BasicInfo.tsx | 181 + .../consumer/steps/Step2ScheduleLocation.tsx | 221 + .../consumer/steps/Step3PricingCapacity.tsx | 226 + .../create/consumer/steps/Step4FaqPreview.tsx | 79 + .../create/consumer/steps/Step5GuestList.tsx | 161 + components/events/create/consumer/types.ts | 140 + .../events/create/steps/Step1BasicInfo.tsx | 184 + .../create/steps/Step2ScheduleLocation.tsx | 244 + .../create/steps/Step3PricingCapacity.tsx | 195 + .../events/create/steps/Step4FaqPreview.tsx | 91 + .../events/create/steps/Step5GuestList.tsx | 161 + components/events/create/types.ts | 127 + .../EventScheduleFieldControl.test.ts | 40 + .../schedule/EventScheduleFieldControl.tsx | 488 + .../EventScheduleFieldControl.wheels.test.tsx | 41 + .../schedule/eventScheduleConstraints.test.ts | 55 + .../schedule/eventScheduleConstraints.ts | 65 + .../feedback/AccountSuspensionBanner.test.tsx | 35 + .../feedback/AccountSuspensionBanner.tsx | 47 + components/feedback/AdminState.tsx | 52 + components/feedback/AppToastProvider.tsx | 13 + components/feedback/ConsumerState.test.tsx | 31 + components/feedback/ConsumerState.tsx | 56 + components/feedback/InlineNotice.tsx | 22 + components/feedback/LoadingState.tsx | 172 + components/feedback/OfflineBanner.tsx | 34 + components/feedback/RouteErrorView.tsx | 31 + components/formElements/Button.tsx | 184 + components/formElements/Checkbox.tsx | 36 + components/formElements/Input.tsx | 385 + components/formElements/Switch.tsx | 42 + components/formElements/TextEditor.tsx | 553 + .../formElements/input/ChoiceFieldControl.tsx | 298 + .../formElements/input/DateFieldControl.tsx | 150 + .../input/JalaliBirthDateFieldControl.tsx | 224 + .../formElements/input/TextFieldControl.tsx | 252 + components/formElements/input/inputTypes.ts | 75 + components/formElements/input/inputUtils.tsx | 44 + .../input/useDialogPortalContainer.ts | 20 + .../input/useRemoteSelectOptions.ts | 115 + components/forms/AdminFormLayout.tsx | 49 + components/forms/ConsumerFormLayout.test.tsx | 71 + components/forms/ConsumerFormLayout.tsx | 87 + components/forms/SeoCharCounterHint.tsx | 37 + .../forms/UnsavedChangesIndicator.test.tsx | 20 + components/forms/UnsavedChangesIndicator.tsx | 28 + components/heroui/Accordion.tsx | 68 + components/heroui/Autocomplete.tsx | 103 + components/heroui/Avatar.tsx | 29 + components/heroui/Card.tsx | 13 + components/heroui/Chip.tsx | 60 + components/heroui/Dropdown.tsx | 155 + components/heroui/Input.tsx | 241 + components/heroui/InputOtp.tsx | 75 + components/heroui/NumberInput.tsx | 107 + components/heroui/Pagination.tsx | 71 + components/heroui/Progress.tsx | 31 + components/heroui/Select.tsx | 242 + components/heroui/SelectionControls.tsx | 142 + components/heroui/Slider.tsx | 23 + components/heroui/Tabs.tsx | 108 + components/icons/AddDocumentIcon.tsx | 21 + components/icons/AngleDownIcon.tsx | 15 + components/icons/AngleLeftIcon.tsx | 17 + components/icons/ArrowRightIcon.tsx | 16 + components/icons/ArrowSwapIcon.tsx | 18 + components/icons/AttachIcon.tsx | 19 + components/icons/AwardIcon.tsx | 25 + components/icons/BellIcon.tsx | 19 + components/icons/BookmarkFillIcon.tsx | 13 + components/icons/BookmarkIcon.tsx | 26 + components/icons/CalendarEditIcon.tsx | 15 + components/icons/CalendarFillIcon.tsx | 2593 ++ components/icons/CalendarIcon.tsx | 49 + components/icons/CalendarSecondaryIcon.tsx | 29 + components/icons/ChevronRightIcon.tsx | 15 + components/icons/ClockFillIcon.tsx | 25 + components/icons/ClockIcon.tsx | 40 + components/icons/CloseCircleIcon.tsx | 17 + components/icons/CloseIcon.tsx | 15 + components/icons/CloseLinearIcon.tsx | 18 + components/icons/CloseSquareIcon.tsx | 17 + components/icons/CloudDownloadIcon.tsx | 26 + components/icons/CloudUploadIcon.tsx | 30 + components/icons/CoinDollarIcon.tsx | 25 + components/icons/CoinIcon.tsx | 33 + components/icons/CommentIcon.tsx | 38 + components/icons/ContactChannelIcon.tsx | 182 + components/icons/CopyIcon.tsx | 15 + components/icons/DashboardIcon.tsx | 31 + components/icons/DollarIcon.tsx | 25 + components/icons/DotLoadingIcon.tsx | 64 + components/icons/EditIcon.tsx | 23 + components/icons/EditIconOutline.tsx | 25 + components/icons/EyeCrossedIcon.tsx | 26 + components/icons/EyeIcon.tsx | 21 + components/icons/FaqQuestionMarkIcon.tsx | 24 + components/icons/FileCheckIcon.tsx | 26 + components/icons/FileQuestionIcon.tsx | 15 + components/icons/FireIcon.tsx | 95 + components/icons/FolderSolidIcon.tsx | 19 + components/icons/GoogleMapsLogoIcon.tsx | 31 + components/icons/HeartIcon.tsx | 19 + components/icons/HeatIcon.tsx | 17 + components/icons/HeatIconFill.tsx | 15 + components/icons/HomeFillIcon.tsx | 15 + components/icons/HomeHashtagTwoToneIcon.tsx | 56 + components/icons/HomeTextIcon.tsx | 334 + components/icons/IdentityIcon.tsx | 94 + components/icons/ImageUploadIcon.tsx | 30 + components/icons/InfoIcon.tsx | 25 + components/icons/LinkIcon.tsx | 15 + components/icons/ListItemsIcon.tsx | 37 + components/icons/LoginTwoToneIcon.tsx | 45 + components/icons/LogoIcon.tsx | 119 + components/icons/LogoutIcon.tsx | 30 + components/icons/MapIcon.tsx | 17 + components/icons/MarkerIcon.tsx | 19 + components/icons/MenuBurgerIcon.tsx | 34 + components/icons/MessageIcon.tsx | 29 + components/icons/MessagesTwoToneIcon.tsx | 37 + components/icons/MuteIcon.tsx | 19 + components/icons/NeshanLogoIcon.tsx | 35 + components/icons/OverviewIcon.tsx | 37 + components/icons/PaymentCardIcon.tsx | 19 + components/icons/PhoneIcon.tsx | 26 + components/icons/PlayCircleIcon.tsx | 21 + components/icons/PlusIcon.tsx | 21 + components/icons/QuestionMarkIcon.tsx | 17 + components/icons/SearchAltIcon.tsx | 26 + components/icons/SemiColonIcon.tsx | 234 + components/icons/SendIcon.tsx | 13 + components/icons/SettingsIcon.tsx | 16 + components/icons/ShareIcon.tsx | 26 + components/icons/TagIcon.tsx | 23 + components/icons/TicketDiscountIcon.tsx | 17 + .../icons/TicketDiscountTwoToneIcon.tsx | 56 + components/icons/TicketStarIcon.tsx | 21 + components/icons/TimeCircleIcon.tsx | 16 + components/icons/TrashIcon.tsx | 34 + components/icons/TriangleLeftIcon.tsx | 15 + components/icons/UserFillIcon.tsx | 30 + components/icons/UserIcon.tsx | 19 + components/icons/UserIdCardIcon.tsx | 43 + components/icons/UserPlusIcon.tsx | 23 + components/icons/UserTwoToneIcon.tsx | 37 + components/icons/UsersIcon.tsx | 15 + components/icons/VerifiedBadgeIcon.tsx | 28 + components/icons/VerticalDotsIcon.tsx | 34 + components/icons/WalletIcon.tsx | 85 + components/layouts/DashboardShell.tsx | 74 + components/layouts/Loading.tsx | 34 + components/layouts/PageNavbar.tsx | 88 + components/layouts/Sidebar.tsx | 57 + components/layouts/SidebarMobile.test.tsx | 41 + components/layouts/SidebarMobile.tsx | 162 + components/layouts/SidebarUserFooter.tsx | 21 + components/layouts/sidebar/SidebarNav.tsx | 268 + components/map/MapirLocationPicker.tsx | 131 + components/map/MapirPreviewMap.tsx | 93 + components/map/mapirMapFactory.ts | 40 + components/media/FallbackImage.test.tsx | 58 + components/media/FallbackImage.tsx | 44 + components/media/FileUpload.tsx | 452 + components/modals/Modal.test.tsx | 90 + components/modals/Modal.tsx | 193 + .../PaginatedListCards.test.tsx | 42 + .../paginated-list/PaginatedListCards.tsx | 83 + .../PaginatedListColumnFilter.tsx | 44 + .../PaginatedListDateFilterField.tsx | 107 + .../PaginatedListFilterField.tsx | 294 + .../PaginatedListFilterModal.tsx | 105 + .../paginated-list/PaginatedListImpl.tsx | 470 + .../PaginatedListPagination.tsx | 111 + .../paginated-list/PaginatedListTable.tsx | 214 + .../PaginatedListToolbar.test.tsx | 56 + .../paginated-list/PaginatedListToolbar.tsx | 109 + components/paginated-list/paginatedListApi.ts | 49 + .../paginated-list/paginatedListUtils.test.ts | 75 + .../paginated-list/paginatedListUtils.ts | 122 + .../usePaginatedListFilters.test.ts | 80 + .../paginated-list/usePaginatedListFilters.ts | 115 + components/providers/SessionProviders.tsx | 19 + components/reviews/EventReviewsSection.tsx | 337 + .../reviews/OrganizerReviewsSection.test.tsx | 62 + .../reviews/OrganizerReviewsSection.tsx | 110 + components/reviews/ReviewCard.tsx | 103 + components/reviews/ReviewModal.tsx | 109 + components/reviews/StarRating.tsx | 69 + components/ui/AdminTableActions.tsx | 6 + components/ui/AdminTableViewButton.tsx | 65 + components/ui/AppTabs.tsx | 71 + components/ui/AppVersion.tsx | 14 + components/ui/SegmentedControl.test.tsx | 66 + components/ui/SegmentedControl.tsx | 124 + components/ui/SidebarLogo.tsx | 18 + components/ui/StatusChip.tsx | 21 + components/ui/UserDropdown.tsx | 78 + config/app.ts | 2 + config/axios.ts | 519 + config/chatSocket.ts | 14 + config/enamad.ts | 10 + config/fonts.ts | 14 + config/sidebar.ts | 2 + config/site.tsx | 177 + constants/contact.ts | 18 + constants/discovery.ts | 10 + constants/formFieldStyles.ts | 133 + constants/host-contract.ts | 6 + constants/images.ts | 22 + constants/routes.test.ts | 24 + constants/routes.ts | 93 + constants/status.ts | 58 + content/aboutPage.json | 158 + content/aboutPage.ts | 102 + content/becomeHostPage.json | 234 + content/becomeHostPage.ts | 139 + content/host-cooperation-contract.json | 828 + content/publicPages.json | 37 + content/publicPages.ts | 24 + context/AlertModalContext.test.tsx | 64 + context/AlertModalContext.tsx | 138 + context/AuthContext.tsx | 378 + context/LoadingContext.tsx | 16 + deploy/docker-compose.production.yml | 27 + docker-compose.yml | 19 + docs/admin-caching.md | 70 + docs/admin-ui-guidelines.md | 114 + docs/caching-strategy.md | 344 + docs/consumer-caching.md | 184 + docs/consumer-texts.md | 71 + docs/consumer-ui-guidelines.md | 161 + docs/deploy.md | 10 + docs/documents/about.html | 124 + docs/documents/become-a-host.html | 1021 + docs/documents/cancellationandFund.docx | Bin 0 -> 15925 bytes docs/documents/categoriesText.docx | Bin 0 -> 69109 bytes docs/documents/category-sample.html | 469 + docs/documents/privacy.docx | Bin 0 -> 23042 bytes docs/documents/termsofUse.docx | Bin 0 -> 20671 bytes .../event cart/discovry/Medium Style Cart.png | Bin 0 -> 72081 bytes .../event cart/host/Medium Style Cart (1).png | Bin 0 -> 48112 bytes .../event cart/host/Medium Style Cart (2).png | Bin 0 -> 49353 bytes .../host/Medium Style Cart Placed.png | Bin 0 -> 74198 bytes .../host/Medium Style Cart-editable.png | Bin 0 -> 70722 bytes docs/event cart/host/Medium Style Cart.png | Bin 0 -> 49494 bytes .../Medium Style Cart status Done.png | Bin 0 -> 68559 bytes .../Medium Style Cart status Postponed.png | Bin 0 -> 42394 bytes ...dium Style Cart status- waiting to buy.png | Bin 0 -> 70969 bytes .../Medium Style Cart status.png | Bin 0 -> 68810 bytes docs/login and signup/Age selection modal.png | Bin 0 -> 33236 bytes .../login and signup/City selection modal.png | Bin 0 -> 34723 bytes docs/login and signup/Frame 6257 (1).png | Bin 0 -> 22388 bytes docs/login and signup/Frame 6257 (2).png | Bin 0 -> 24929 bytes docs/login and signup/Frame 6257 (3).png | Bin 0 -> 24705 bytes docs/login and signup/Frame 6257.png | Bin 0 -> 25886 bytes .../Ghabilee Web app-profile (1).png | Bin 0 -> 341528 bytes docs/myProfile/Ghabilee Web app-profile.png | Bin 0 -> 86559 bytes docs/myProfile/حالت خالی ایونت.png | Bin 0 -> 10607 bytes docs/myProfile/حالت خالی تجربه.png | Bin 0 -> 7939 bytes docs/production-runbook.md | 25 + docs/seo-gsc-indexing-urls.md | 175 + e2e/README.md | 20 + e2e/admin/auth.spec.ts | 133 + e2e/admin/chat-oversight.spec.ts | 146 + e2e/admin/discount-codes.spec.ts | 169 + e2e/admin/event-management.spec.ts | 225 + e2e/admin/event-review-workflow.spec.ts | 160 + .../identity-verification-workflow.spec.ts | 143 + e2e/admin/ux-boundaries.spec.ts | 81 + e2e/fixtures/blogApiMock.ts | 30 + e2e/fixtures/consumerAuth.ts | 166 + e2e/fixtures/consumerEventApi.ts | 152 + e2e/fixtures/hostEvent.ts | 77 + e2e/fixtures/session.ts | 58 + e2e/global-setup.ts | 79 + e2e/global-teardown.ts | 14 + e2e/shared/access-control.spec.ts | 148 + e2e/shared/accessibility.spec.ts | 125 + e2e/shared/performance.spec.ts | 38 + e2e/shared/public-pages.spec.ts | 75 + eslint.config.mjs | 315 + .../admin-users/adminUserDetailUi.test.tsx | 99 + features/admin-users/adminUserDetailUi.tsx | 243 + features/admin-users/useAdminUserDetail.ts | 21 + features/auth/useAuthFlow.functional.test.tsx | 124 + features/auth/useAuthFlow.test.tsx | 132 + features/auth/useAuthFlow.ts | 311 + features/chat/groupMessagesForDisplay.ts | 88 + features/events/create/AdminEventCreate.tsx | 27 + features/events/detail/AdminEventDetail.tsx | 496 + .../detail/EventDiscountsPanel.test.tsx | 169 + .../events/detail/EventDiscountsPanel.tsx | 529 + .../AdminEventBookingsTab.tsx | 154 + .../AdminEventCommissionModal.test.tsx | 156 + .../AdminEventCommissionModal.tsx | 133 + .../admin-event-detail/AdminEventDetailUi.tsx | 17 + .../AdminEventDetailsSection.tsx | 262 + .../AdminEventFinancialTab.tsx | 51 + .../AdminEventInsightsTabs.test.tsx | 61 + .../AdminEventLifecycleActions.test.tsx | 77 + .../AdminEventLifecycleActions.tsx | 129 + .../AdminEventOverviewTab.tsx | 88 + .../AdminEventPendingRevisionCard.tsx | 76 + .../AdminEventRejectModal.tsx | 79 + .../AdminEventReviewsTab.tsx | 29 + .../AdminEventRevisionRejectModal.tsx | 64 + .../AdminEventSummaryCard.tsx | 83 + .../events/detail/admin-event-detail/types.ts | 24 + features/events/edit/EventEditForm.test.tsx | 303 + features/events/edit/EventEditForm.tsx | 679 + .../events/edit/mapEventToEditWizardData.ts | 145 + features/events/edit/syncEventExtras.test.ts | 130 + features/events/edit/syncEventExtras.ts | 91 + features/events/eventCapacity.test.ts | 21 + features/events/eventCapacity.ts | 25 + features/events/eventCardLabels.test.ts | 29 + features/events/eventCardLabels.ts | 25 + features/events/eventDraftStore.test.ts | 30 + features/events/eventDraftStore.ts | 63 + features/events/eventEditPolicy.test.ts | 107 + features/events/eventEditPolicy.ts | 98 + features/events/eventSlug.test.ts | 40 + features/events/eventSlug.ts | 59 + .../events/management/EventReviewsPanel.tsx | 272 + .../events/presentEventCapacityBadge.test.ts | 59 + features/events/presentEventCapacityBadge.ts | 54 + helpers/categoryPath.ts | 61 + helpers/dates.ts | 137 + helpers/index.ts | 504 + helpers/listResponse.test.ts | 75 + helpers/listResponse.ts | 133 + helpers/mapir.ts | 21 + helpers/numberToPersianWords.test.ts | 47 + helpers/numberToPersianWords.ts | 106 + helpers/serviceErrorCopy.ts | 10 + helpers/sidebarRoute.ts | 48 + helpers/upload.ts | 26 + hooks/useAdminAction.test.ts | 45 + hooks/useAdminAction.ts | 41 + hooks/useAdminChatSocket.test.tsx | 74 + hooks/useAdminChatSocket.ts | 116 + hooks/useAdminCrudFormModal.test.ts | 175 + hooks/useAdminCrudFormModal.ts | 89 + hooks/useAdminMutation.test.ts | 68 + hooks/useAdminMutation.ts | 62 + hooks/useAlertModal.tsx | 17 + hooks/useAsyncResource.test.ts | 64 + hooks/useAsyncResource.ts | 122 + hooks/useAuth.tsx | 16 + hooks/useCloseOnHistoryBack.test.ts | 159 + hooks/useCloseOnHistoryBack.ts | 133 + hooks/useCopyToClipboard.ts | 26 + hooks/useDeferredClientMount.ts | 26 + hooks/useDisclosure.ts | 18 + hooks/useLoading.tsx | 15 + hooks/useMediaQuery.tsx | 39 + hooks/useQueryParams.ts | 54 + hooks/useQueryTab.ts | 47 + hooks/useSmsOtpAutofill.ts | 35 + hooks/useWindowSize.tsx | 34 + instrumentation-client.ts | 7 + instrumentation.ts | 17 + knip.json | 22 + lib/accountSuspension.ts | 15 + lib/analytics.ts | 40 + lib/authRouting.ts | 120 + lib/birthDate.test.ts | 32 + lib/birthDate.ts | 59 + lib/bookingPayment.ts | 19 + lib/chatSocket.ts | 132 + lib/clientSessionHint.ts | 14 + lib/cn.ts | 5 + lib/createClientId.ts | 32 + lib/eventAudience.ts | 53 + lib/expireRefreshSession.ts | 18 + lib/fileValidation.test.ts | 138 + lib/fileValidation.ts | 191 + lib/formDataRequest.test.ts | 27 + lib/formDataRequest.ts | 26 + lib/formValidationToast.test.ts | 84 + lib/formValidationToast.ts | 60 + lib/formatters.chatDay.test.ts | 28 + lib/formatters.test.ts | 55 + lib/formatters.ts | 143 + lib/googleMapsNavigation.test.ts | 15 + lib/googleMapsNavigation.ts | 27 + lib/isPlainPrimaryClick.test.ts | 29 + lib/isPlainPrimaryClick.ts | 13 + lib/logoutSession.test.ts | 82 + lib/logoutSession.ts | 25 + lib/neshanNavigation.test.ts | 21 + lib/neshanNavigation.ts | 42 + lib/notificationNavigation.test.ts | 25 + lib/notificationNavigation.ts | 40 + lib/notificationTemplate.test.ts | 17 + lib/notificationTemplate.ts | 9 + lib/observability/client.ts | 66 + .../expectedProductError.test.ts | 43 + lib/observability/expectedProductError.ts | 88 + lib/paymentQuery.ts | 25 + lib/pwa.ts | 170 + lib/queryClient.ts | 101 + lib/refreshSessionCookie.test.ts | 37 + lib/refreshSessionCookie.ts | 31 + lib/refreshTokenExpiry.test.ts | 26 + lib/refreshTokenExpiry.ts | 17 + lib/resolveUserAvatarSrc.test.ts | 22 + lib/resolveUserAvatarSrc.ts | 14 + lib/reviewWindow.test.ts | 39 + lib/reviewWindow.ts | 29 + lib/routing/eventRoute.test.ts | 32 + lib/routing/eventRoute.ts | 42 + lib/scrollElementIntoContainer.test.ts | 47 + lib/scrollElementIntoContainer.ts | 21 + lib/security/requestHost.test.ts | 48 + lib/security/requestHost.ts | 32 + lib/seo/JsonLd.test.ts | 121 + lib/seo/JsonLd.tsx | 178 + lib/seo/activeCities.ts | 33 + lib/seo/categoryLandingContent.ts | 50 + lib/seo/categoryLandingDocument.ts | 724 + lib/seo/categoryLandings.test.ts | 29 + lib/seo/categoryLandings.ts | 6 + lib/seo/citySlugs.test.ts | 20 + lib/seo/citySlugs.ts | 16 + lib/seo/contentQuality.test.ts | 25 + lib/seo/contentQuality.ts | 22 + lib/seo/faqPage.test.ts | 35 + lib/seo/faqPage.ts | 113 + lib/seo/hostGuidePage.test.ts | 25 + lib/seo/hostGuidePage.ts | 105 + lib/seo/legacyEventRedirect.test.ts | 38 + lib/seo/legacyEventRedirect.ts | 17 + lib/seo/legacyRedirects.test.ts | 33 + lib/seo/legalPages.test.ts | 41 + lib/seo/legalPages.ts | 152 + lib/seo/metadata.test.ts | 82 + lib/seo/metadata.ts | 45 + lib/seo/priorityLandings.ts | 37 + lib/seo/publicPageMetadata.ts | 39 + lib/seo/serverApi.test.ts | 45 + lib/seo/serverApi.ts | 469 + lib/server/eventManagementBootstrap.ts | 30 + lib/smsSegments.test.ts | 33 + lib/smsSegments.ts | 36 + lib/toast.ts | 34 + lib/trafficAttribution.test.ts | 44 + lib/trafficAttribution.ts | 95 + lib/viewerDiscoveryCity.test.ts | 19 + lib/viewerDiscoveryCity.ts | 15 + lib/visualViewportKeyboardInset.test.ts | 21 + lib/visualViewportKeyboardInset.ts | 17 + next.config.js | 212 + openapi.json | 30609 ++++++++++++++++ orval.config.ts | 27 + package.json | 131 + patches/mapir-react-component@2.0.7.patch | 39 + playwright.config.ts | 50 + pnpm-lock.yaml | 11820 ++++++ pnpm-workspace.yaml | 35 + postcss.config.mjs | 7 + proxy.ts | 167 + public/favicon.ico | Bin 0 -> 4314 bytes public/fonts/IRANSansDN_FaNum-Bold.ttf | Bin 0 -> 59944 bytes public/fonts/IRANSansDN_FaNum-Light.ttf | Bin 0 -> 60728 bytes public/fonts/IRANSansDN_FaNum-Regular.ttf | Bin 0 -> 61852 bytes .../fonts/Pinar-VF-FD[DSTY,KSHD,wght].woff2 | Bin 0 -> 92144 bytes public/icons/apple-touch-icon.png | Bin 0 -> 14376 bytes public/icons/icon-192.png | Bin 0 -> 15460 bytes public/icons/icon-32.png | Bin 0 -> 2212 bytes public/icons/icon-512.png | Bin 0 -> 45740 bytes public/icons/icon-maskable-512.png | Bin 0 -> 34746 bytes .../abilities/event-community-chat.webp | Bin 0 -> 3384 bytes .../abilities/event-creation-and-details.webp | Bin 0 -> 6256 bytes .../event-management-and-attendance.webp | Bin 0 -> 7322 bytes .../event-publishing-and-listing.webp | Bin 0 -> 6386 bytes public/images/auth-v2.png | Bin 0 -> 2201627 bytes public/images/auth.png | Bin 0 -> 1952073 bytes .../blog/arts-culture-events-guide.webp | Bin 0 -> 123672 bytes ...event-booking-platform-iran-guide-1x1.webp | Bin 0 -> 48828 bytes ...event-booking-platform-iran-guide-4x3.webp | Bin 0 -> 61826 bytes ...est-event-booking-platform-iran-guide.webp | Bin 0 -> 83836 bytes .../blog/choose-workshop-venue-guide-1x1.webp | Bin 0 -> 49450 bytes .../blog/choose-workshop-venue-guide-4x3.webp | Bin 0 -> 62874 bytes .../blog/choose-workshop-venue-guide.webp | Bin 0 -> 74414 bytes .../conversation-connection-events-guide.webp | Bin 0 -> 120756 bytes .../blog/event-discount-code-guide-1x1.webp | Bin 0 -> 46516 bytes .../blog/event-discount-code-guide-4x3.webp | Bin 0 -> 69394 bytes .../blog/event-discount-code-guide.webp | Bin 0 -> 98504 bytes ...ent-marketing-before-launch-guide-1x1.webp | Bin 0 -> 64240 bytes ...ent-marketing-before-launch-guide-4x3.webp | Bin 0 -> 88064 bytes .../event-marketing-before-launch-guide.webp | Bin 0 -> 129442 bytes .../blog/find-free-events-guide-1x1.webp | Bin 0 -> 87798 bytes .../blog/find-free-events-guide-4x3.webp | Bin 0 -> 111148 bytes .../images/blog/find-free-events-guide.webp | Bin 0 -> 149888 bytes .../first-workshop-complete-guide-1x1.webp | Bin 0 -> 60642 bytes .../first-workshop-complete-guide-4x3.webp | Bin 0 -> 78222 bytes .../blog/first-workshop-complete-guide.webp | Bin 0 -> 95188 bytes .../blog/food-gatherings-events-guide.webp | Bin 0 -> 133258 bytes .../games-entertainment-events-guide.webp | Bin 0 -> 149558 bytes .../blog/guest-list-checkin-guide-1x1.webp | Bin 0 -> 51400 bytes .../blog/guest-list-checkin-guide-4x3.webp | Bin 0 -> 71686 bytes .../images/blog/guest-list-checkin-guide.webp | Bin 0 -> 114120 bytes .../host-identity-verification-guide-1x1.webp | Bin 0 -> 44960 bytes .../host-identity-verification-guide-4x3.webp | Bin 0 -> 51400 bytes .../host-identity-verification-guide.webp | Bin 0 -> 61086 bytes .../how-to-host-successful-event-1x1.webp | Bin 0 -> 63898 bytes .../how-to-host-successful-event-4x3.webp | Bin 0 -> 77590 bytes .../blog/how-to-host-successful-event.webp | Bin 0 -> 107924 bytes .../learning-experience-events-guide.webp | Bin 0 -> 129176 bytes .../images/blog/legacy-city-events-1x1.webp | Bin 0 -> 109334 bytes .../images/blog/legacy-city-events-4x3.webp | Bin 0 -> 142646 bytes public/images/blog/legacy-city-events.webp | Bin 0 -> 163536 bytes .../blog/legacy-community-social-1x1.webp | Bin 0 -> 70612 bytes .../blog/legacy-community-social-4x3.webp | Bin 0 -> 96808 bytes .../images/blog/legacy-community-social.webp | Bin 0 -> 106500 bytes .../images/blog/legacy-creative-arts-1x1.webp | Bin 0 -> 80746 bytes .../images/blog/legacy-creative-arts-4x3.webp | Bin 0 -> 103848 bytes public/images/blog/legacy-creative-arts.webp | Bin 0 -> 114598 bytes .../images/blog/legacy-family-kids-1x1.webp | Bin 0 -> 78226 bytes .../images/blog/legacy-family-kids-4x3.webp | Bin 0 -> 98438 bytes public/images/blog/legacy-family-kids.webp | Bin 0 -> 111010 bytes .../blog/legacy-host-operations-1x1.webp | Bin 0 -> 52030 bytes .../blog/legacy-host-operations-4x3.webp | Bin 0 -> 70632 bytes .../images/blog/legacy-host-operations.webp | Bin 0 -> 74034 bytes .../blog/legacy-payment-pricing-1x1.webp | Bin 0 -> 41726 bytes .../blog/legacy-payment-pricing-4x3.webp | Bin 0 -> 48026 bytes .../images/blog/legacy-payment-pricing.webp | Bin 0 -> 45880 bytes .../blog/legacy-technology-learning-1x1.webp | Bin 0 -> 60822 bytes .../blog/legacy-technology-learning-4x3.webp | Bin 0 -> 76768 bytes .../blog/legacy-technology-learning.webp | Bin 0 -> 85494 bytes .../blog/legacy-wellness-outdoor-1x1.webp | Bin 0 -> 62882 bytes .../blog/legacy-wellness-outdoor-4x3.webp | Bin 0 -> 82116 bytes .../images/blog/legacy-wellness-outdoor.webp | Bin 0 -> 92308 bytes .../photography-workshop-selection-1x1.webp | Bin 0 -> 53698 bytes .../photography-workshop-selection-4x3.webp | Bin 0 -> 66132 bytes .../blog/photography-workshop-selection.webp | Bin 0 -> 88154 bytes .../sell-event-tickets-online-guide-1x1.webp | Bin 0 -> 50140 bytes .../sell-event-tickets-online-guide-4x3.webp | Bin 0 -> 67436 bytes .../blog/sell-event-tickets-online-guide.webp | Bin 0 -> 103684 bytes .../blog/sports-adventure-events-guide.webp | Bin 0 -> 170978 bytes .../images/blog/tehran-events-guide-1x1.webp | Bin 0 -> 68484 bytes .../images/blog/tehran-events-guide-4x3.webp | Bin 0 -> 82400 bytes public/images/blog/tehran-events-guide.webp | Bin 0 -> 105038 bytes public/images/defaults/category-fallback.avif | Bin 0 -> 21847 bytes public/images/defaults/category-fallback.png | Bin 0 -> 1791252 bytes .../defaults/event-fallback-portrait.avif | Bin 0 -> 65515 bytes public/images/defaults/event-fallback.avif | Bin 0 -> 24298 bytes public/images/defaults/event-fallback.png | Bin 0 -> 1555489 bytes public/images/enamad.png | Bin 0 -> 1898 bytes public/images/placeholders/image.png | Bin 0 -> 12289 bytes .../images/placeholders/man-placeholder.webp | Bin 0 -> 80982 bytes public/images/placeholders/portrait.webp | Bin 0 -> 30088 bytes public/images/placeholders/project-Image.png | Bin 0 -> 1904 bytes .../placeholders/woman-placeholder.webp | Bin 0 -> 58230 bytes public/images/public/terms-hero.avif | Bin 0 -> 66108 bytes public/logo.svg | 269 + public/vendor/mapbox-gl-rtl-text.js | 6 + queries/admin/adminKeys.ts | 19 + queries/consumer/eventDetailCache.ts | 10 + queries/consumer/useEventDetailQueries.ts | 111 + queries/consumer/useEventViewerStateQuery.ts | 18 + queries/consumer/usePublicCategoriesQuery.ts | 22 + .../consumer/useWalletAndBookmarkQueries.ts | 68 + queries/consumerKeys.ts | 80 + queries/unwrapService.ts | 11 + scripts/check-bundle-budget.mjs | 90 + scripts/check-service-worker-policy.mjs | 16 + scripts/check-version-bump.mjs | 68 + scripts/ci-build-image.sh | 54 + scripts/generate-api.mjs | 51 + scripts/notify-deploy.sh | 88 + scripts/notify-ops-telegram.sh | 81 + scripts/notify-via-vps.sh | 48 + scripts/pre-commit-checks.sh | 32 + scripts/pre-push-checks.sh | 303 + scripts/prepare-standalone.mjs | 6 + scripts/smoke-test.mjs | 22 + sentry.client.config.ts | 31 + sentry.edge.config.ts | 30 + sentry.server.config.ts | 30 + services/adminChat.ts | 108 + services/adminReports.ts | 43 + services/adminUserDetail.ts | 74 + services/adminUsers.ts | 100 + services/adminWallet.ts | 55 + services/apiErrorLocalization.test.ts | 49 + services/apiErrorLocalization.ts | 71 + services/apiResponse.test.ts | 20 + services/apiResponse.ts | 16 + services/auth.ts | 101 + services/blogArticles.ts | 124 + services/bookings.test.ts | 45 + services/bookings.ts | 345 + services/chat.ts | 354 + services/config.ts | 112 + services/consumerEventDetail.ts | 60 + services/contactLinks.ts | 127 + services/contactMessages.ts | 62 + services/discountCodes.test.ts | 26 + services/discountCodes.ts | 185 + services/discovery.ts | 228 + services/errorHandler.test.ts | 80 + services/errorHandler.ts | 269 + services/eventBookmarks.ts | 34 + services/eventCategories.ts | 169 + services/eventDetail.ts | 30 + services/eventGuestListAccess.ts | 36 + services/eventManagement.ts | 105 + services/eventViewerState.ts | 19 + services/events.ts | 336 + services/geography.ts | 30 + services/identity.ts | 165 + services/mapirReverseGeocode.ts | 66 + services/myEvents.ts | 43 + services/notifications.ts | 84 + services/organizerFollows.ts | 227 + services/organizerViewerState.ts | 13 + services/payments.ts | 189 + services/publicOrganizer.ts | 25 + services/push.ts | 125 + services/reviews.ts | 227 + services/settlements.ts | 27 + services/supportTickets.ts | 79 + services/trafficVisits.ts | 21 + services/userBlocks.ts | 117 + services/userReports.ts | 131 + services/users.ts | 121 + services/wallet.ts | 144 + services/withdrawals.ts | 146 + structure.txt | 1704 + styles/globals.css | 839 + styles/input.scss | 162 + styles/main.scss | 1 + styles/userSidebar.scss | 34 + test.json | 5 + texts/auth.ts | 62 + texts/blog.ts | 37 + texts/bookings.ts | 100 + texts/chats.ts | 74 + texts/common.ts | 132 + texts/discovery.ts | 124 + texts/errors.ts | 62 + texts/events.ts | 581 + texts/format.test.ts | 18 + texts/format.ts | 10 + texts/identity.ts | 36 + texts/index.ts | 43 + texts/notifications.ts | 21 + texts/profile.ts | 147 + texts/publicSite.ts | 59 + texts/reviews.ts | 51 + texts/seo.ts | 370 + texts/status.ts | 108 + texts/support.ts | 54 + texts/validation.ts | 80 + texts/wallet.ts | 76 + tsconfig.json | 28 + types/alertModal.ts | 9 + types/apiListMeta.ts | 52 + types/definitions.ts | 209 + types/heroui.ts | 7 + types/index.ts | 5 + types/mapir-react-component.d.ts | 12 + types/pagination.ts | 29 + types/status.ts | 342 + validation/adminUserMobile.test.ts | 28 + validation/adminUserMobile.ts | 27 + validation/adminUsers.ts | 16 + validation/adminWalletCredit.ts | 19 + validation/auth.test.ts | 79 + validation/auth.ts | 44 + validation/blogArticles.test.ts | 47 + validation/blogArticles.ts | 100 + validation/consumerEventWizardStep2.ts | 89 + validation/eventCategories.ts | 37 + validation/eventWizard.test.ts | 189 + validation/eventWizard.ts | 140 + validation/identity.ts | 43 + validation/profile.ts | 20 + validation/withdrawal.test.ts | 26 + validation/withdrawal.ts | 47 + vitest.config.ts | 26 + vitest.setup.ts | 27 + 818 files changed, 114437 insertions(+) create mode 100644 .cursorrules create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/BRANCH_PROTECTION.md create mode 100644 .github/workflows/deploy-vps.yml create mode 100644 .github/workflows/frontend-quality.yml create mode 100644 .gitignore create mode 100644 .gitleaks.toml create mode 100755 .husky/pre-commit create mode 100755 .husky/pre-push create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 PRODUCTION_READINESS_PLAN.md create mode 100644 README.md create mode 100644 api/orval-client.ts create mode 100644 app/(dashboard)/admin/events/[id]/page.tsx create mode 100644 app/(dashboard)/audit-logs/page.tsx create mode 100644 app/(dashboard)/bank-accounts/page.tsx create mode 100644 app/(dashboard)/blog-articles/_components/ArticleFeaturedImageUploader.tsx create mode 100644 app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx create mode 100644 app/(dashboard)/blog-articles/page.tsx create mode 100644 app/(dashboard)/bookings/page.tsx create mode 100644 app/(dashboard)/chat-oversight/[id]/_components/AdminMessageBubble.test.tsx create mode 100644 app/(dashboard)/chat-oversight/[id]/_components/AdminMessageBubble.tsx create mode 100644 app/(dashboard)/chat-oversight/[id]/_components/ChatImagePreviewModal.tsx create mode 100644 app/(dashboard)/chat-oversight/[id]/_components/DateSeparator.tsx create mode 100644 app/(dashboard)/chat-oversight/[id]/page.tsx create mode 100644 app/(dashboard)/chat-oversight/page.tsx create mode 100644 app/(dashboard)/cities/page.tsx create mode 100644 app/(dashboard)/contact-messages/page.tsx create mode 100644 app/(dashboard)/dashboard/page.tsx create mode 100644 app/(dashboard)/discount-codes/page.tsx create mode 100644 app/(dashboard)/error.tsx create mode 100644 app/(dashboard)/event-categories/_components/CategoryFormModal.tsx create mode 100644 app/(dashboard)/event-categories/_components/CategoryTree.test.tsx create mode 100644 app/(dashboard)/event-categories/_components/CategoryTree.tsx create mode 100644 app/(dashboard)/event-categories/_components/CategoryTreeNode.tsx create mode 100644 app/(dashboard)/event-categories/page.tsx create mode 100644 app/(dashboard)/events/page.tsx create mode 100644 app/(dashboard)/guest-lists/_components/GuestListItemsModal.tsx create mode 100644 app/(dashboard)/guest-lists/page.tsx create mode 100644 app/(dashboard)/identity-verifications/page.tsx create mode 100644 app/(dashboard)/layout.tsx create mode 100644 app/(dashboard)/loading.tsx create mode 100644 app/(dashboard)/manage-events/[id]/edit/page.tsx create mode 100644 app/(dashboard)/manage-events/[id]/page.tsx create mode 100644 app/(dashboard)/manage-events/new/page.tsx create mode 100644 app/(dashboard)/manage-events/page.tsx create mode 100644 app/(dashboard)/manual-notifications/page.tsx create mode 100644 app/(dashboard)/notification-rules/page.tsx create mode 100644 app/(dashboard)/notifications/_components/InAppNotificationsPanel.tsx create mode 100644 app/(dashboard)/notifications/_components/NotificationRulesPanel.test.tsx create mode 100644 app/(dashboard)/notifications/_components/NotificationRulesPanel.tsx create mode 100644 app/(dashboard)/notifications/_components/PushDeliveriesPanel.tsx create mode 100644 app/(dashboard)/notifications/_components/SendManualNotificationModal.tsx create mode 100644 app/(dashboard)/notifications/_components/SmsMessagesPanel.tsx create mode 100644 app/(dashboard)/notifications/page.tsx create mode 100644 app/(dashboard)/payments/page.tsx create mode 100644 app/(dashboard)/provinces/page.tsx create mode 100644 app/(dashboard)/push-deliveries/page.tsx create mode 100644 app/(dashboard)/reviews/page.tsx create mode 100644 app/(dashboard)/settlements/_components/CreateSettlementModal.tsx create mode 100644 app/(dashboard)/settlements/page.tsx create mode 100644 app/(dashboard)/sms-messages/page.tsx create mode 100644 app/(dashboard)/support-tickets/[id]/page.tsx create mode 100644 app/(dashboard)/support-tickets/page.tsx create mode 100644 app/(dashboard)/user-reports/page.tsx create mode 100644 app/(dashboard)/users/[id]/_components/UserEditModal.tsx create mode 100644 app/(dashboard)/users/[id]/_components/UserMobileChangeModal.test.tsx create mode 100644 app/(dashboard)/users/[id]/_components/UserMobileChangeModal.tsx create mode 100644 app/(dashboard)/users/[id]/_components/WalletCreditModal.tsx create mode 100644 app/(dashboard)/users/[id]/page.tsx create mode 100644 app/(dashboard)/users/page.tsx create mode 100644 app/(dashboard)/wallet-deposits/page.tsx create mode 100644 app/(dashboard)/withdrawal-requests/page.tsx create mode 100644 app/api/auth/clear-session/route.test.ts create mode 100644 app/api/auth/clear-session/route.ts create mode 100644 app/api/download-upload/route.ts create mode 100644 app/auth/layout.tsx create mode 100644 app/auth/page.tsx create mode 100644 app/error.tsx create mode 100644 app/global-error.tsx create mode 100644 app/layout.tsx create mode 100644 app/loading.tsx create mode 100644 app/not-found.tsx create mode 100644 app/page.tsx create mode 100644 app/providers.tsx create mode 100644 components/PaginatedList.test.tsx create mode 100644 components/PaginatedList.tsx create mode 100644 components/admin/ManualPayoutModal.tsx create mode 100644 components/auth/AdminAuthContent.tsx create mode 100644 components/auth/AdminRoleGuard.tsx create mode 100644 components/auth/PendingUserGuard.tsx create mode 100644 components/consumer/ConsumerActionButtons.tsx create mode 100644 components/consumer/ConsumerButton.tsx create mode 100644 components/consumer/ConsumerInput.tsx create mode 100644 components/consumer/ConsumerModal.tsx create mode 100644 components/consumer/UserAvatar.tsx create mode 100644 components/consumer/UserIdentity.tsx create mode 100644 components/consumer/consumerButtonStyles.ts create mode 100644 components/consumer/consumerButtonTypes.ts create mode 100644 components/consumer/input/ConsumerChoiceField.tsx create mode 100644 components/consumer/input/ConsumerFieldShell.tsx create mode 100644 components/consumer/input/ConsumerSelectField.tsx create mode 100644 components/consumer/input/ConsumerTextField.tsx create mode 100644 components/consumer/input/ConsumerTextareaField.tsx create mode 100644 components/consumer/input/consumerInputStyles.test.ts create mode 100644 components/consumer/input/consumerInputStyles.ts create mode 100644 components/consumer/input/consumerInputTypes.ts create mode 100644 components/consumer/input/consumerInputUtils.ts create mode 100644 components/events/EventGuestListAccessPanel.tsx create mode 100644 components/events/create/AudienceRestrictionSelector.tsx create mode 100644 components/events/create/CancellationPolicyEditor.tsx create mode 100644 components/events/create/CategoryTreeSelect.tsx create mode 100644 components/events/create/ConsumerPickerTrigger.test.tsx create mode 100644 components/events/create/ConsumerPickerTrigger.tsx create mode 100644 components/events/create/CreateEventPreviewCard.tsx create mode 100644 components/events/create/EventCreateWizard.tsx create mode 100644 components/events/create/EventMediaGalleryUploader.tsx create mode 100644 components/events/create/FaqEditor.tsx create mode 100644 components/events/create/MapLocationPicker.tsx create mode 100644 components/events/create/TicketTypeSelector.tsx create mode 100644 components/events/create/WizardFormLayout.tsx create mode 100644 components/events/create/WizardStepper.tsx create mode 100644 components/events/create/cloneFromEvent.test.ts create mode 100644 components/events/create/cloneFromEvent.ts create mode 100644 components/events/create/consumer/ConsumerEventCreateWizard.tsx create mode 100644 components/events/create/consumer/ConsumerWizardStepper.tsx create mode 100644 components/events/create/consumer/cloneFromEvent.ts create mode 100644 components/events/create/consumer/steps/Step1BasicInfo.tsx create mode 100644 components/events/create/consumer/steps/Step2ScheduleLocation.tsx create mode 100644 components/events/create/consumer/steps/Step3PricingCapacity.tsx create mode 100644 components/events/create/consumer/steps/Step4FaqPreview.tsx create mode 100644 components/events/create/consumer/steps/Step5GuestList.tsx create mode 100644 components/events/create/consumer/types.ts create mode 100644 components/events/create/steps/Step1BasicInfo.tsx create mode 100644 components/events/create/steps/Step2ScheduleLocation.tsx create mode 100644 components/events/create/steps/Step3PricingCapacity.tsx create mode 100644 components/events/create/steps/Step4FaqPreview.tsx create mode 100644 components/events/create/steps/Step5GuestList.tsx create mode 100644 components/events/create/types.ts create mode 100644 components/events/schedule/EventScheduleFieldControl.test.ts create mode 100644 components/events/schedule/EventScheduleFieldControl.tsx create mode 100644 components/events/schedule/EventScheduleFieldControl.wheels.test.tsx create mode 100644 components/events/schedule/eventScheduleConstraints.test.ts create mode 100644 components/events/schedule/eventScheduleConstraints.ts create mode 100644 components/feedback/AccountSuspensionBanner.test.tsx create mode 100644 components/feedback/AccountSuspensionBanner.tsx create mode 100644 components/feedback/AdminState.tsx create mode 100644 components/feedback/AppToastProvider.tsx create mode 100644 components/feedback/ConsumerState.test.tsx create mode 100644 components/feedback/ConsumerState.tsx create mode 100644 components/feedback/InlineNotice.tsx create mode 100644 components/feedback/LoadingState.tsx create mode 100644 components/feedback/OfflineBanner.tsx create mode 100644 components/feedback/RouteErrorView.tsx create mode 100644 components/formElements/Button.tsx create mode 100644 components/formElements/Checkbox.tsx create mode 100644 components/formElements/Input.tsx create mode 100644 components/formElements/Switch.tsx create mode 100644 components/formElements/TextEditor.tsx create mode 100644 components/formElements/input/ChoiceFieldControl.tsx create mode 100644 components/formElements/input/DateFieldControl.tsx create mode 100644 components/formElements/input/JalaliBirthDateFieldControl.tsx create mode 100644 components/formElements/input/TextFieldControl.tsx create mode 100644 components/formElements/input/inputTypes.ts create mode 100644 components/formElements/input/inputUtils.tsx create mode 100644 components/formElements/input/useDialogPortalContainer.ts create mode 100644 components/formElements/input/useRemoteSelectOptions.ts create mode 100644 components/forms/AdminFormLayout.tsx create mode 100644 components/forms/ConsumerFormLayout.test.tsx create mode 100644 components/forms/ConsumerFormLayout.tsx create mode 100644 components/forms/SeoCharCounterHint.tsx create mode 100644 components/forms/UnsavedChangesIndicator.test.tsx create mode 100644 components/forms/UnsavedChangesIndicator.tsx create mode 100644 components/heroui/Accordion.tsx create mode 100644 components/heroui/Autocomplete.tsx create mode 100644 components/heroui/Avatar.tsx create mode 100644 components/heroui/Card.tsx create mode 100644 components/heroui/Chip.tsx create mode 100644 components/heroui/Dropdown.tsx create mode 100644 components/heroui/Input.tsx create mode 100644 components/heroui/InputOtp.tsx create mode 100644 components/heroui/NumberInput.tsx create mode 100644 components/heroui/Pagination.tsx create mode 100644 components/heroui/Progress.tsx create mode 100644 components/heroui/Select.tsx create mode 100644 components/heroui/SelectionControls.tsx create mode 100644 components/heroui/Slider.tsx create mode 100644 components/heroui/Tabs.tsx create mode 100644 components/icons/AddDocumentIcon.tsx create mode 100644 components/icons/AngleDownIcon.tsx create mode 100644 components/icons/AngleLeftIcon.tsx create mode 100644 components/icons/ArrowRightIcon.tsx create mode 100644 components/icons/ArrowSwapIcon.tsx create mode 100644 components/icons/AttachIcon.tsx create mode 100644 components/icons/AwardIcon.tsx create mode 100644 components/icons/BellIcon.tsx create mode 100644 components/icons/BookmarkFillIcon.tsx create mode 100644 components/icons/BookmarkIcon.tsx create mode 100644 components/icons/CalendarEditIcon.tsx create mode 100644 components/icons/CalendarFillIcon.tsx create mode 100644 components/icons/CalendarIcon.tsx create mode 100644 components/icons/CalendarSecondaryIcon.tsx create mode 100644 components/icons/ChevronRightIcon.tsx create mode 100644 components/icons/ClockFillIcon.tsx create mode 100644 components/icons/ClockIcon.tsx create mode 100644 components/icons/CloseCircleIcon.tsx create mode 100644 components/icons/CloseIcon.tsx create mode 100644 components/icons/CloseLinearIcon.tsx create mode 100644 components/icons/CloseSquareIcon.tsx create mode 100644 components/icons/CloudDownloadIcon.tsx create mode 100644 components/icons/CloudUploadIcon.tsx create mode 100644 components/icons/CoinDollarIcon.tsx create mode 100644 components/icons/CoinIcon.tsx create mode 100644 components/icons/CommentIcon.tsx create mode 100644 components/icons/ContactChannelIcon.tsx create mode 100644 components/icons/CopyIcon.tsx create mode 100644 components/icons/DashboardIcon.tsx create mode 100644 components/icons/DollarIcon.tsx create mode 100644 components/icons/DotLoadingIcon.tsx create mode 100644 components/icons/EditIcon.tsx create mode 100644 components/icons/EditIconOutline.tsx create mode 100644 components/icons/EyeCrossedIcon.tsx create mode 100644 components/icons/EyeIcon.tsx create mode 100644 components/icons/FaqQuestionMarkIcon.tsx create mode 100644 components/icons/FileCheckIcon.tsx create mode 100644 components/icons/FileQuestionIcon.tsx create mode 100644 components/icons/FireIcon.tsx create mode 100644 components/icons/FolderSolidIcon.tsx create mode 100644 components/icons/GoogleMapsLogoIcon.tsx create mode 100644 components/icons/HeartIcon.tsx create mode 100644 components/icons/HeatIcon.tsx create mode 100644 components/icons/HeatIconFill.tsx create mode 100644 components/icons/HomeFillIcon.tsx create mode 100644 components/icons/HomeHashtagTwoToneIcon.tsx create mode 100644 components/icons/HomeTextIcon.tsx create mode 100644 components/icons/IdentityIcon.tsx create mode 100644 components/icons/ImageUploadIcon.tsx create mode 100644 components/icons/InfoIcon.tsx create mode 100644 components/icons/LinkIcon.tsx create mode 100644 components/icons/ListItemsIcon.tsx create mode 100644 components/icons/LoginTwoToneIcon.tsx create mode 100644 components/icons/LogoIcon.tsx create mode 100644 components/icons/LogoutIcon.tsx create mode 100644 components/icons/MapIcon.tsx create mode 100644 components/icons/MarkerIcon.tsx create mode 100644 components/icons/MenuBurgerIcon.tsx create mode 100644 components/icons/MessageIcon.tsx create mode 100644 components/icons/MessagesTwoToneIcon.tsx create mode 100644 components/icons/MuteIcon.tsx create mode 100644 components/icons/NeshanLogoIcon.tsx create mode 100644 components/icons/OverviewIcon.tsx create mode 100644 components/icons/PaymentCardIcon.tsx create mode 100644 components/icons/PhoneIcon.tsx create mode 100644 components/icons/PlayCircleIcon.tsx create mode 100644 components/icons/PlusIcon.tsx create mode 100644 components/icons/QuestionMarkIcon.tsx create mode 100644 components/icons/SearchAltIcon.tsx create mode 100644 components/icons/SemiColonIcon.tsx create mode 100644 components/icons/SendIcon.tsx create mode 100644 components/icons/SettingsIcon.tsx create mode 100644 components/icons/ShareIcon.tsx create mode 100644 components/icons/TagIcon.tsx create mode 100644 components/icons/TicketDiscountIcon.tsx create mode 100644 components/icons/TicketDiscountTwoToneIcon.tsx create mode 100644 components/icons/TicketStarIcon.tsx create mode 100644 components/icons/TimeCircleIcon.tsx create mode 100644 components/icons/TrashIcon.tsx create mode 100644 components/icons/TriangleLeftIcon.tsx create mode 100644 components/icons/UserFillIcon.tsx create mode 100644 components/icons/UserIcon.tsx create mode 100644 components/icons/UserIdCardIcon.tsx create mode 100644 components/icons/UserPlusIcon.tsx create mode 100644 components/icons/UserTwoToneIcon.tsx create mode 100644 components/icons/UsersIcon.tsx create mode 100644 components/icons/VerifiedBadgeIcon.tsx create mode 100644 components/icons/VerticalDotsIcon.tsx create mode 100644 components/icons/WalletIcon.tsx create mode 100644 components/layouts/DashboardShell.tsx create mode 100644 components/layouts/Loading.tsx create mode 100644 components/layouts/PageNavbar.tsx create mode 100644 components/layouts/Sidebar.tsx create mode 100644 components/layouts/SidebarMobile.test.tsx create mode 100644 components/layouts/SidebarMobile.tsx create mode 100644 components/layouts/SidebarUserFooter.tsx create mode 100644 components/layouts/sidebar/SidebarNav.tsx create mode 100644 components/map/MapirLocationPicker.tsx create mode 100644 components/map/MapirPreviewMap.tsx create mode 100644 components/map/mapirMapFactory.ts create mode 100644 components/media/FallbackImage.test.tsx create mode 100644 components/media/FallbackImage.tsx create mode 100644 components/media/FileUpload.tsx create mode 100644 components/modals/Modal.test.tsx create mode 100644 components/modals/Modal.tsx create mode 100644 components/paginated-list/PaginatedListCards.test.tsx create mode 100644 components/paginated-list/PaginatedListCards.tsx create mode 100644 components/paginated-list/PaginatedListColumnFilter.tsx create mode 100644 components/paginated-list/PaginatedListDateFilterField.tsx create mode 100644 components/paginated-list/PaginatedListFilterField.tsx create mode 100644 components/paginated-list/PaginatedListFilterModal.tsx create mode 100644 components/paginated-list/PaginatedListImpl.tsx create mode 100644 components/paginated-list/PaginatedListPagination.tsx create mode 100644 components/paginated-list/PaginatedListTable.tsx create mode 100644 components/paginated-list/PaginatedListToolbar.test.tsx create mode 100644 components/paginated-list/PaginatedListToolbar.tsx create mode 100644 components/paginated-list/paginatedListApi.ts create mode 100644 components/paginated-list/paginatedListUtils.test.ts create mode 100644 components/paginated-list/paginatedListUtils.ts create mode 100644 components/paginated-list/usePaginatedListFilters.test.ts create mode 100644 components/paginated-list/usePaginatedListFilters.ts create mode 100644 components/providers/SessionProviders.tsx create mode 100644 components/reviews/EventReviewsSection.tsx create mode 100644 components/reviews/OrganizerReviewsSection.test.tsx create mode 100644 components/reviews/OrganizerReviewsSection.tsx create mode 100644 components/reviews/ReviewCard.tsx create mode 100644 components/reviews/ReviewModal.tsx create mode 100644 components/reviews/StarRating.tsx create mode 100644 components/ui/AdminTableActions.tsx create mode 100644 components/ui/AdminTableViewButton.tsx create mode 100644 components/ui/AppTabs.tsx create mode 100644 components/ui/AppVersion.tsx create mode 100644 components/ui/SegmentedControl.test.tsx create mode 100644 components/ui/SegmentedControl.tsx create mode 100644 components/ui/SidebarLogo.tsx create mode 100644 components/ui/StatusChip.tsx create mode 100644 components/ui/UserDropdown.tsx create mode 100644 config/app.ts create mode 100644 config/axios.ts create mode 100644 config/chatSocket.ts create mode 100644 config/enamad.ts create mode 100644 config/fonts.ts create mode 100644 config/sidebar.ts create mode 100644 config/site.tsx create mode 100644 constants/contact.ts create mode 100644 constants/discovery.ts create mode 100644 constants/formFieldStyles.ts create mode 100644 constants/host-contract.ts create mode 100644 constants/images.ts create mode 100644 constants/routes.test.ts create mode 100644 constants/routes.ts create mode 100644 constants/status.ts create mode 100644 content/aboutPage.json create mode 100644 content/aboutPage.ts create mode 100644 content/becomeHostPage.json create mode 100644 content/becomeHostPage.ts create mode 100644 content/host-cooperation-contract.json create mode 100644 content/publicPages.json create mode 100644 content/publicPages.ts create mode 100644 context/AlertModalContext.test.tsx create mode 100644 context/AlertModalContext.tsx create mode 100644 context/AuthContext.tsx create mode 100644 context/LoadingContext.tsx create mode 100644 deploy/docker-compose.production.yml create mode 100644 docker-compose.yml create mode 100644 docs/admin-caching.md create mode 100644 docs/admin-ui-guidelines.md create mode 100644 docs/caching-strategy.md create mode 100644 docs/consumer-caching.md create mode 100644 docs/consumer-texts.md create mode 100644 docs/consumer-ui-guidelines.md create mode 100644 docs/deploy.md create mode 100644 docs/documents/about.html create mode 100644 docs/documents/become-a-host.html create mode 100644 docs/documents/cancellationandFund.docx create mode 100644 docs/documents/categoriesText.docx create mode 100644 docs/documents/category-sample.html create mode 100644 docs/documents/privacy.docx create mode 100644 docs/documents/termsofUse.docx create mode 100644 docs/event cart/discovry/Medium Style Cart.png create mode 100644 docs/event cart/host/Medium Style Cart (1).png create mode 100644 docs/event cart/host/Medium Style Cart (2).png create mode 100644 docs/event cart/host/Medium Style Cart Placed.png create mode 100644 docs/event cart/host/Medium Style Cart-editable.png create mode 100644 docs/event cart/host/Medium Style Cart.png create mode 100644 docs/event cart/my-event as a guest/Medium Style Cart status Done.png create mode 100644 docs/event cart/my-event as a guest/Medium Style Cart status Postponed.png create mode 100644 docs/event cart/my-event as a guest/Medium Style Cart status- waiting to buy.png create mode 100644 docs/event cart/my-event as a guest/Medium Style Cart status.png create mode 100644 docs/login and signup/Age selection modal.png create mode 100644 docs/login and signup/City selection modal.png create mode 100644 docs/login and signup/Frame 6257 (1).png create mode 100644 docs/login and signup/Frame 6257 (2).png create mode 100644 docs/login and signup/Frame 6257 (3).png create mode 100644 docs/login and signup/Frame 6257.png create mode 100644 docs/myProfile/Ghabilee Web app-profile (1).png create mode 100644 docs/myProfile/Ghabilee Web app-profile.png create mode 100644 docs/myProfile/حالت خالی ایونت.png create mode 100644 docs/myProfile/حالت خالی تجربه.png create mode 100644 docs/production-runbook.md create mode 100644 docs/seo-gsc-indexing-urls.md create mode 100644 e2e/README.md create mode 100644 e2e/admin/auth.spec.ts create mode 100644 e2e/admin/chat-oversight.spec.ts create mode 100644 e2e/admin/discount-codes.spec.ts create mode 100644 e2e/admin/event-management.spec.ts create mode 100644 e2e/admin/event-review-workflow.spec.ts create mode 100644 e2e/admin/identity-verification-workflow.spec.ts create mode 100644 e2e/admin/ux-boundaries.spec.ts create mode 100644 e2e/fixtures/blogApiMock.ts create mode 100644 e2e/fixtures/consumerAuth.ts create mode 100644 e2e/fixtures/consumerEventApi.ts create mode 100644 e2e/fixtures/hostEvent.ts create mode 100644 e2e/fixtures/session.ts create mode 100644 e2e/global-setup.ts create mode 100644 e2e/global-teardown.ts create mode 100644 e2e/shared/access-control.spec.ts create mode 100644 e2e/shared/accessibility.spec.ts create mode 100644 e2e/shared/performance.spec.ts create mode 100644 e2e/shared/public-pages.spec.ts create mode 100644 eslint.config.mjs create mode 100644 features/admin-users/adminUserDetailUi.test.tsx create mode 100644 features/admin-users/adminUserDetailUi.tsx create mode 100644 features/admin-users/useAdminUserDetail.ts create mode 100644 features/auth/useAuthFlow.functional.test.tsx create mode 100644 features/auth/useAuthFlow.test.tsx create mode 100644 features/auth/useAuthFlow.ts create mode 100644 features/chat/groupMessagesForDisplay.ts create mode 100644 features/events/create/AdminEventCreate.tsx create mode 100644 features/events/detail/AdminEventDetail.tsx create mode 100644 features/events/detail/EventDiscountsPanel.test.tsx create mode 100644 features/events/detail/EventDiscountsPanel.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventBookingsTab.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventCommissionModal.test.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventCommissionModal.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventDetailUi.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventDetailsSection.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventFinancialTab.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventInsightsTabs.test.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventLifecycleActions.test.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventLifecycleActions.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventOverviewTab.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventPendingRevisionCard.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventRejectModal.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventReviewsTab.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventRevisionRejectModal.tsx create mode 100644 features/events/detail/admin-event-detail/AdminEventSummaryCard.tsx create mode 100644 features/events/detail/admin-event-detail/types.ts create mode 100644 features/events/edit/EventEditForm.test.tsx create mode 100644 features/events/edit/EventEditForm.tsx create mode 100644 features/events/edit/mapEventToEditWizardData.ts create mode 100644 features/events/edit/syncEventExtras.test.ts create mode 100644 features/events/edit/syncEventExtras.ts create mode 100644 features/events/eventCapacity.test.ts create mode 100644 features/events/eventCapacity.ts create mode 100644 features/events/eventCardLabels.test.ts create mode 100644 features/events/eventCardLabels.ts create mode 100644 features/events/eventDraftStore.test.ts create mode 100644 features/events/eventDraftStore.ts create mode 100644 features/events/eventEditPolicy.test.ts create mode 100644 features/events/eventEditPolicy.ts create mode 100644 features/events/eventSlug.test.ts create mode 100644 features/events/eventSlug.ts create mode 100644 features/events/management/EventReviewsPanel.tsx create mode 100644 features/events/presentEventCapacityBadge.test.ts create mode 100644 features/events/presentEventCapacityBadge.ts create mode 100644 helpers/categoryPath.ts create mode 100644 helpers/dates.ts create mode 100644 helpers/index.ts create mode 100644 helpers/listResponse.test.ts create mode 100644 helpers/listResponse.ts create mode 100644 helpers/mapir.ts create mode 100644 helpers/numberToPersianWords.test.ts create mode 100644 helpers/numberToPersianWords.ts create mode 100644 helpers/serviceErrorCopy.ts create mode 100644 helpers/sidebarRoute.ts create mode 100644 helpers/upload.ts create mode 100644 hooks/useAdminAction.test.ts create mode 100644 hooks/useAdminAction.ts create mode 100644 hooks/useAdminChatSocket.test.tsx create mode 100644 hooks/useAdminChatSocket.ts create mode 100644 hooks/useAdminCrudFormModal.test.ts create mode 100644 hooks/useAdminCrudFormModal.ts create mode 100644 hooks/useAdminMutation.test.ts create mode 100644 hooks/useAdminMutation.ts create mode 100644 hooks/useAlertModal.tsx create mode 100644 hooks/useAsyncResource.test.ts create mode 100644 hooks/useAsyncResource.ts create mode 100644 hooks/useAuth.tsx create mode 100644 hooks/useCloseOnHistoryBack.test.ts create mode 100644 hooks/useCloseOnHistoryBack.ts create mode 100644 hooks/useCopyToClipboard.ts create mode 100644 hooks/useDeferredClientMount.ts create mode 100644 hooks/useDisclosure.ts create mode 100644 hooks/useLoading.tsx create mode 100644 hooks/useMediaQuery.tsx create mode 100644 hooks/useQueryParams.ts create mode 100644 hooks/useQueryTab.ts create mode 100644 hooks/useSmsOtpAutofill.ts create mode 100644 hooks/useWindowSize.tsx create mode 100644 instrumentation-client.ts create mode 100644 instrumentation.ts create mode 100644 knip.json create mode 100644 lib/accountSuspension.ts create mode 100644 lib/analytics.ts create mode 100644 lib/authRouting.ts create mode 100644 lib/birthDate.test.ts create mode 100644 lib/birthDate.ts create mode 100644 lib/bookingPayment.ts create mode 100644 lib/chatSocket.ts create mode 100644 lib/clientSessionHint.ts create mode 100644 lib/cn.ts create mode 100644 lib/createClientId.ts create mode 100644 lib/eventAudience.ts create mode 100644 lib/expireRefreshSession.ts create mode 100644 lib/fileValidation.test.ts create mode 100644 lib/fileValidation.ts create mode 100644 lib/formDataRequest.test.ts create mode 100644 lib/formDataRequest.ts create mode 100644 lib/formValidationToast.test.ts create mode 100644 lib/formValidationToast.ts create mode 100644 lib/formatters.chatDay.test.ts create mode 100644 lib/formatters.test.ts create mode 100644 lib/formatters.ts create mode 100644 lib/googleMapsNavigation.test.ts create mode 100644 lib/googleMapsNavigation.ts create mode 100644 lib/isPlainPrimaryClick.test.ts create mode 100644 lib/isPlainPrimaryClick.ts create mode 100644 lib/logoutSession.test.ts create mode 100644 lib/logoutSession.ts create mode 100644 lib/neshanNavigation.test.ts create mode 100644 lib/neshanNavigation.ts create mode 100644 lib/notificationNavigation.test.ts create mode 100644 lib/notificationNavigation.ts create mode 100644 lib/notificationTemplate.test.ts create mode 100644 lib/notificationTemplate.ts create mode 100644 lib/observability/client.ts create mode 100644 lib/observability/expectedProductError.test.ts create mode 100644 lib/observability/expectedProductError.ts create mode 100644 lib/paymentQuery.ts create mode 100644 lib/pwa.ts create mode 100644 lib/queryClient.ts create mode 100644 lib/refreshSessionCookie.test.ts create mode 100644 lib/refreshSessionCookie.ts create mode 100644 lib/refreshTokenExpiry.test.ts create mode 100644 lib/refreshTokenExpiry.ts create mode 100644 lib/resolveUserAvatarSrc.test.ts create mode 100644 lib/resolveUserAvatarSrc.ts create mode 100644 lib/reviewWindow.test.ts create mode 100644 lib/reviewWindow.ts create mode 100644 lib/routing/eventRoute.test.ts create mode 100644 lib/routing/eventRoute.ts create mode 100644 lib/scrollElementIntoContainer.test.ts create mode 100644 lib/scrollElementIntoContainer.ts create mode 100644 lib/security/requestHost.test.ts create mode 100644 lib/security/requestHost.ts create mode 100644 lib/seo/JsonLd.test.ts create mode 100644 lib/seo/JsonLd.tsx create mode 100644 lib/seo/activeCities.ts create mode 100644 lib/seo/categoryLandingContent.ts create mode 100644 lib/seo/categoryLandingDocument.ts create mode 100644 lib/seo/categoryLandings.test.ts create mode 100644 lib/seo/categoryLandings.ts create mode 100644 lib/seo/citySlugs.test.ts create mode 100644 lib/seo/citySlugs.ts create mode 100644 lib/seo/contentQuality.test.ts create mode 100644 lib/seo/contentQuality.ts create mode 100644 lib/seo/faqPage.test.ts create mode 100644 lib/seo/faqPage.ts create mode 100644 lib/seo/hostGuidePage.test.ts create mode 100644 lib/seo/hostGuidePage.ts create mode 100644 lib/seo/legacyEventRedirect.test.ts create mode 100644 lib/seo/legacyEventRedirect.ts create mode 100644 lib/seo/legacyRedirects.test.ts create mode 100644 lib/seo/legalPages.test.ts create mode 100644 lib/seo/legalPages.ts create mode 100644 lib/seo/metadata.test.ts create mode 100644 lib/seo/metadata.ts create mode 100644 lib/seo/priorityLandings.ts create mode 100644 lib/seo/publicPageMetadata.ts create mode 100644 lib/seo/serverApi.test.ts create mode 100644 lib/seo/serverApi.ts create mode 100644 lib/server/eventManagementBootstrap.ts create mode 100644 lib/smsSegments.test.ts create mode 100644 lib/smsSegments.ts create mode 100644 lib/toast.ts create mode 100644 lib/trafficAttribution.test.ts create mode 100644 lib/trafficAttribution.ts create mode 100644 lib/viewerDiscoveryCity.test.ts create mode 100644 lib/viewerDiscoveryCity.ts create mode 100644 lib/visualViewportKeyboardInset.test.ts create mode 100644 lib/visualViewportKeyboardInset.ts create mode 100644 next.config.js create mode 100644 openapi.json create mode 100644 orval.config.ts create mode 100644 package.json create mode 100644 patches/mapir-react-component@2.0.7.patch create mode 100644 playwright.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 postcss.config.mjs create mode 100644 proxy.ts create mode 100644 public/favicon.ico create mode 100644 public/fonts/IRANSansDN_FaNum-Bold.ttf create mode 100644 public/fonts/IRANSansDN_FaNum-Light.ttf create mode 100644 public/fonts/IRANSansDN_FaNum-Regular.ttf create mode 100644 public/fonts/Pinar-VF-FD[DSTY,KSHD,wght].woff2 create mode 100644 public/icons/apple-touch-icon.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-32.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/icon-maskable-512.png create mode 100644 public/images/abilities/event-community-chat.webp create mode 100644 public/images/abilities/event-creation-and-details.webp create mode 100644 public/images/abilities/event-management-and-attendance.webp create mode 100644 public/images/abilities/event-publishing-and-listing.webp create mode 100644 public/images/auth-v2.png create mode 100644 public/images/auth.png create mode 100644 public/images/blog/arts-culture-events-guide.webp create mode 100644 public/images/blog/best-event-booking-platform-iran-guide-1x1.webp create mode 100644 public/images/blog/best-event-booking-platform-iran-guide-4x3.webp create mode 100644 public/images/blog/best-event-booking-platform-iran-guide.webp create mode 100644 public/images/blog/choose-workshop-venue-guide-1x1.webp create mode 100644 public/images/blog/choose-workshop-venue-guide-4x3.webp create mode 100644 public/images/blog/choose-workshop-venue-guide.webp create mode 100644 public/images/blog/conversation-connection-events-guide.webp create mode 100644 public/images/blog/event-discount-code-guide-1x1.webp create mode 100644 public/images/blog/event-discount-code-guide-4x3.webp create mode 100644 public/images/blog/event-discount-code-guide.webp create mode 100644 public/images/blog/event-marketing-before-launch-guide-1x1.webp create mode 100644 public/images/blog/event-marketing-before-launch-guide-4x3.webp create mode 100644 public/images/blog/event-marketing-before-launch-guide.webp create mode 100644 public/images/blog/find-free-events-guide-1x1.webp create mode 100644 public/images/blog/find-free-events-guide-4x3.webp create mode 100644 public/images/blog/find-free-events-guide.webp create mode 100644 public/images/blog/first-workshop-complete-guide-1x1.webp create mode 100644 public/images/blog/first-workshop-complete-guide-4x3.webp create mode 100644 public/images/blog/first-workshop-complete-guide.webp create mode 100644 public/images/blog/food-gatherings-events-guide.webp create mode 100644 public/images/blog/games-entertainment-events-guide.webp create mode 100644 public/images/blog/guest-list-checkin-guide-1x1.webp create mode 100644 public/images/blog/guest-list-checkin-guide-4x3.webp create mode 100644 public/images/blog/guest-list-checkin-guide.webp create mode 100644 public/images/blog/host-identity-verification-guide-1x1.webp create mode 100644 public/images/blog/host-identity-verification-guide-4x3.webp create mode 100644 public/images/blog/host-identity-verification-guide.webp create mode 100644 public/images/blog/how-to-host-successful-event-1x1.webp create mode 100644 public/images/blog/how-to-host-successful-event-4x3.webp create mode 100644 public/images/blog/how-to-host-successful-event.webp create mode 100644 public/images/blog/learning-experience-events-guide.webp create mode 100644 public/images/blog/legacy-city-events-1x1.webp create mode 100644 public/images/blog/legacy-city-events-4x3.webp create mode 100644 public/images/blog/legacy-city-events.webp create mode 100644 public/images/blog/legacy-community-social-1x1.webp create mode 100644 public/images/blog/legacy-community-social-4x3.webp create mode 100644 public/images/blog/legacy-community-social.webp create mode 100644 public/images/blog/legacy-creative-arts-1x1.webp create mode 100644 public/images/blog/legacy-creative-arts-4x3.webp create mode 100644 public/images/blog/legacy-creative-arts.webp create mode 100644 public/images/blog/legacy-family-kids-1x1.webp create mode 100644 public/images/blog/legacy-family-kids-4x3.webp create mode 100644 public/images/blog/legacy-family-kids.webp create mode 100644 public/images/blog/legacy-host-operations-1x1.webp create mode 100644 public/images/blog/legacy-host-operations-4x3.webp create mode 100644 public/images/blog/legacy-host-operations.webp create mode 100644 public/images/blog/legacy-payment-pricing-1x1.webp create mode 100644 public/images/blog/legacy-payment-pricing-4x3.webp create mode 100644 public/images/blog/legacy-payment-pricing.webp create mode 100644 public/images/blog/legacy-technology-learning-1x1.webp create mode 100644 public/images/blog/legacy-technology-learning-4x3.webp create mode 100644 public/images/blog/legacy-technology-learning.webp create mode 100644 public/images/blog/legacy-wellness-outdoor-1x1.webp create mode 100644 public/images/blog/legacy-wellness-outdoor-4x3.webp create mode 100644 public/images/blog/legacy-wellness-outdoor.webp create mode 100644 public/images/blog/photography-workshop-selection-1x1.webp create mode 100644 public/images/blog/photography-workshop-selection-4x3.webp create mode 100644 public/images/blog/photography-workshop-selection.webp create mode 100644 public/images/blog/sell-event-tickets-online-guide-1x1.webp create mode 100644 public/images/blog/sell-event-tickets-online-guide-4x3.webp create mode 100644 public/images/blog/sell-event-tickets-online-guide.webp create mode 100644 public/images/blog/sports-adventure-events-guide.webp create mode 100644 public/images/blog/tehran-events-guide-1x1.webp create mode 100644 public/images/blog/tehran-events-guide-4x3.webp create mode 100644 public/images/blog/tehran-events-guide.webp create mode 100644 public/images/defaults/category-fallback.avif create mode 100644 public/images/defaults/category-fallback.png create mode 100644 public/images/defaults/event-fallback-portrait.avif create mode 100644 public/images/defaults/event-fallback.avif create mode 100644 public/images/defaults/event-fallback.png create mode 100644 public/images/enamad.png create mode 100644 public/images/placeholders/image.png create mode 100644 public/images/placeholders/man-placeholder.webp create mode 100644 public/images/placeholders/portrait.webp create mode 100644 public/images/placeholders/project-Image.png create mode 100644 public/images/placeholders/woman-placeholder.webp create mode 100644 public/images/public/terms-hero.avif create mode 100644 public/logo.svg create mode 100644 public/vendor/mapbox-gl-rtl-text.js create mode 100644 queries/admin/adminKeys.ts create mode 100644 queries/consumer/eventDetailCache.ts create mode 100644 queries/consumer/useEventDetailQueries.ts create mode 100644 queries/consumer/useEventViewerStateQuery.ts create mode 100644 queries/consumer/usePublicCategoriesQuery.ts create mode 100644 queries/consumer/useWalletAndBookmarkQueries.ts create mode 100644 queries/consumerKeys.ts create mode 100644 queries/unwrapService.ts create mode 100644 scripts/check-bundle-budget.mjs create mode 100644 scripts/check-service-worker-policy.mjs create mode 100755 scripts/check-version-bump.mjs create mode 100755 scripts/ci-build-image.sh create mode 100644 scripts/generate-api.mjs create mode 100755 scripts/notify-deploy.sh create mode 100755 scripts/notify-ops-telegram.sh create mode 100755 scripts/notify-via-vps.sh create mode 100755 scripts/pre-commit-checks.sh create mode 100755 scripts/pre-push-checks.sh create mode 100644 scripts/prepare-standalone.mjs create mode 100644 scripts/smoke-test.mjs create mode 100644 sentry.client.config.ts create mode 100644 sentry.edge.config.ts create mode 100644 sentry.server.config.ts create mode 100644 services/adminChat.ts create mode 100644 services/adminReports.ts create mode 100644 services/adminUserDetail.ts create mode 100644 services/adminUsers.ts create mode 100644 services/adminWallet.ts create mode 100644 services/apiErrorLocalization.test.ts create mode 100644 services/apiErrorLocalization.ts create mode 100644 services/apiResponse.test.ts create mode 100644 services/apiResponse.ts create mode 100644 services/auth.ts create mode 100644 services/blogArticles.ts create mode 100644 services/bookings.test.ts create mode 100644 services/bookings.ts create mode 100644 services/chat.ts create mode 100644 services/config.ts create mode 100644 services/consumerEventDetail.ts create mode 100644 services/contactLinks.ts create mode 100644 services/contactMessages.ts create mode 100644 services/discountCodes.test.ts create mode 100644 services/discountCodes.ts create mode 100644 services/discovery.ts create mode 100644 services/errorHandler.test.ts create mode 100644 services/errorHandler.ts create mode 100644 services/eventBookmarks.ts create mode 100644 services/eventCategories.ts create mode 100644 services/eventDetail.ts create mode 100644 services/eventGuestListAccess.ts create mode 100644 services/eventManagement.ts create mode 100644 services/eventViewerState.ts create mode 100644 services/events.ts create mode 100644 services/geography.ts create mode 100644 services/identity.ts create mode 100644 services/mapirReverseGeocode.ts create mode 100644 services/myEvents.ts create mode 100644 services/notifications.ts create mode 100644 services/organizerFollows.ts create mode 100644 services/organizerViewerState.ts create mode 100644 services/payments.ts create mode 100644 services/publicOrganizer.ts create mode 100644 services/push.ts create mode 100644 services/reviews.ts create mode 100644 services/settlements.ts create mode 100644 services/supportTickets.ts create mode 100644 services/trafficVisits.ts create mode 100644 services/userBlocks.ts create mode 100644 services/userReports.ts create mode 100644 services/users.ts create mode 100644 services/wallet.ts create mode 100644 services/withdrawals.ts create mode 100644 structure.txt create mode 100644 styles/globals.css create mode 100644 styles/input.scss create mode 100644 styles/main.scss create mode 100644 styles/userSidebar.scss create mode 100644 test.json create mode 100644 texts/auth.ts create mode 100644 texts/blog.ts create mode 100644 texts/bookings.ts create mode 100644 texts/chats.ts create mode 100644 texts/common.ts create mode 100644 texts/discovery.ts create mode 100644 texts/errors.ts create mode 100644 texts/events.ts create mode 100644 texts/format.test.ts create mode 100644 texts/format.ts create mode 100644 texts/identity.ts create mode 100644 texts/index.ts create mode 100644 texts/notifications.ts create mode 100644 texts/profile.ts create mode 100644 texts/publicSite.ts create mode 100644 texts/reviews.ts create mode 100644 texts/seo.ts create mode 100644 texts/status.ts create mode 100644 texts/support.ts create mode 100644 texts/validation.ts create mode 100644 texts/wallet.ts create mode 100644 tsconfig.json create mode 100644 types/alertModal.ts create mode 100644 types/apiListMeta.ts create mode 100644 types/definitions.ts create mode 100644 types/heroui.ts create mode 100644 types/index.ts create mode 100644 types/mapir-react-component.d.ts create mode 100644 types/pagination.ts create mode 100644 types/status.ts create mode 100644 validation/adminUserMobile.test.ts create mode 100644 validation/adminUserMobile.ts create mode 100644 validation/adminUsers.ts create mode 100644 validation/adminWalletCredit.ts create mode 100644 validation/auth.test.ts create mode 100644 validation/auth.ts create mode 100644 validation/blogArticles.test.ts create mode 100644 validation/blogArticles.ts create mode 100644 validation/consumerEventWizardStep2.ts create mode 100644 validation/eventCategories.ts create mode 100644 validation/eventWizard.test.ts create mode 100644 validation/eventWizard.ts create mode 100644 validation/identity.ts create mode 100644 validation/profile.ts create mode 100644 validation/withdrawal.test.ts create mode 100644 validation/withdrawal.ts create mode 100644 vitest.config.ts create mode 100644 vitest.setup.ts diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..f0dc7c3 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,430 @@ +# Cursor Rules - Ghabilee Admin Panel + +## Project Overview +This is a Next.js 16 admin panel project using TypeScript, React 19, and Tailwind CSS. + +## General Coding Standards + +### TypeScript & React +- Always use TypeScript with strict mode enabled +- Use functional components with hooks (no class components) +- Use `'use client'` directive for client components +- Define proper TypeScript interfaces/types for all props, state, and data structures +- Use type inference where appropriate, but be explicit for public APIs +- Prefer `interface` over `type` for object shapes +- Use React.FC or explicit return types for components when needed + +### File Organization +- Use path aliases (`@/`) for imports instead of relative paths +- Follow the existing folder structure: + - `app/` - Next.js app router pages and layouts + - `components/` - Reusable React components (shared across multiple routes) + - `context/` - React Context providers + - `hooks/` - Custom React hooks + - `services/` - API service functions + - `validation/` - Zod validation schemas + - `helpers/` - Utility functions + - `types/` - TypeScript type definitions + - `styles/` - Global styles and SCSS files + +### Component Organization (Colocation Pattern) +- **Page-specific components**: Create a `_components/` folder within each route directory +- **Shared/reusable components**: Place in the global `components/` directory +- The underscore prefix (`_`) makes the folder a private segment - it will never become part of the route structure +- Example: + ``` + app/[locale]/(dashboard)/matches/ + ├── _components/ # Private: Page-specific match components + │ ├── MatchCard.tsx + │ └── MatchFilters.tsx + └── page.tsx + ``` +- Benefits: Easy to find related components, clear separation, easier refactoring + +### Naming Conventions +- Components: PascalCase (e.g., `AuthContext.tsx`, `Button.tsx`) +- Files: Match component/export name +- Functions: camelCase (e.g., `handleSubmit`, `fetchUser`) +- Constants: UPPER_SNAKE_CASE (e.g., `API_ROUTES`) +- Interfaces/Types: PascalCase (e.g., `User`, `AuthContextType`) + +### Code Style +- Use 2 spaces for indentation +- Use single quotes for strings +- No semicolons at the end of statements (enforced by Prettier with `"semi": false`) +- Use trailing commas in multi-line objects/arrays +- Keep lines under 140 characters (enforced by Prettier `printWidth: 140`) +- Use destructuring for props and state +- Prefer const over let, avoid var + +### React Patterns +- Use custom hooks for reusable logic (e.g., `useAuth`, `useLoading`) +- Use Context API for global state management +- Prefer composition over inheritance +- Use React.memo() for performance optimization when needed +- Extract complex logic into separate functions or hooks + +### Form Handling +- Always use `react-hook-form` with `@hookform/resolvers/zod` +- Define validation schemas in `validation/` directory using Zod +- Use `FormProvider` for form context +- Handle form errors appropriately with user-friendly messages + +### Zod Validation with next-intl +- **CRITICAL: Zod schemas must NOT be static when using translations** +- **Always define validation schemas as factory functions** that receive the translation function `t` +- **Build the schema inside the component** (or server action) where `t` is available +- This ensures: + - ✅ Localized error messages + - ✅ Compatibility with locale changes + - ✅ Clean separation between validation logic and React + +**Pattern for Validation Files:** +```typescript +import { z } from 'zod' + +type TFunc = (key: string) => string + +export const FormValidation = (t: TFunc) => + z.object({ + field: z.string().min(1, t('required')), + email: z.string().email(t('invalidEmail')), + }) +``` + +**Client Component Usage:** +```typescript +'use client' +import { useTranslations } from 'next-intl' +import { FormValidation } from '@/validation/form' + +const Component = () => { + const tValidation = useTranslations('validation') + const form = useForm({ + resolver: zodResolver(FormValidation(tValidation)), + }) + // ... +} +``` + +**Server Action Usage:** +```typescript +import { getTranslations } from 'next-intl/server' +import { FormValidation } from '@/validation/form' + +export async function serverAction() { + const t = await getTranslations('validation') + const schema = FormValidation(t) + // schema.parse(...) +} +``` + +**Key Rules:** +- ❌ **DO NOT** define schemas statically: `export const Schema = z.object({...})` +- ✅ **ALWAYS** use factory functions: `export const Schema = (t: TFunc) => z.object({...})` +- ❌ **DO NOT** use `useTranslations` inside validation files (it's a React hook) +- ✅ **ALWAYS** pass `t` as a parameter to the factory function +- ✅ **CRITICAL: Only ONE validation object in fa.json** + - **ONLY** use `validation` object in `messages/fa.json` for all validation messages + - **NEVER** create nested validation objects like `project.validation`, `activity.validation`, etc. + - **ALWAYS** add new validation keys to the main `validation` object + - **ALWAYS** use `useTranslations('validation')` in context files, never use nested paths like `project.validation` + +### API & Data Fetching +- Keep API calls in `services/` directory +- Use `axiosInstance` from `@/config/axios` for API requests +- Always handle errors with try/catch blocks +- Use async/await instead of promises chains +- Show loading states using `useLoading` hook +- Display user-friendly error messages using toast notifications +- **CRITICAL: Do NOT use `/system` endpoints** + - Endpoints that end with `/system` (such as `.../create/system`, `.../update/system`, `.../delete/system`) must not be used in this project. + - Always use the non-system equivalent endpoint (for example, `.../create` instead of `.../create/system`). + - If only a `/system` endpoint exists in docs, leave a `TODO` comment and ask for backend clarification before implementing. +- **CRITICAL: Numeric IDs in backend payloads** + - Every identifier sent to the backend in request bodies or query params that the API expects as a number (`project_id`, `user_id`, `city_id`, foreign keys, etc.) must be a **JavaScript `number`**, not a string. + - URL/route params from Next.js (`useParams`, dynamic `[id]` segments) are **strings**; convert at the **service boundary** (or immediately before the axios call) with `Number(id)`, `parseInt(id, 10)`, or equivalent, and validate with `Number.isFinite` when the contract requires a valid id. + - Prefer typing service payloads with `project_id: number` (and similar) so TypeScript enforces the contract; do not spread raw string params into POST bodies without conversion. + +### Services & API Pattern +- **Single source of data**: All API access goes through `services/`. Pages and components must **never** import mock JSON (e.g. `supplierMockData.json`) directly—always call service functions (e.g. `GET_PROJECTS`, `GET_SUPPLIERS`). +- **CRITICAL: Paginated list meta — DRY** + - Backend list endpoints follow `docs/list-endpoints.en.md`. + - Query: `page`, `pageSize`, `sort`, `filters` (see `ListQueryDto` on backend). + - Response inside `data`: `{ items, response: { page, pageSize, totalItemsCount, totalPages, sort, filters } }`. + - **ALWAYS** reuse `ApiListMeta`, `PaginatedListMeta`, and `ApiPaginatedResult` from `@/types/apiListMeta`. + - Parse API responses with `parseRemittanceList()` from `@/helpers` (unwraps `{ success, data }`). + - For empty/fallback meta, use `createEmptyPaginatedListMeta(page, pageSize)`. +- **CRITICAL: New services and backend-backed endpoints — NO mock** + - When adding a **new** service file or wiring a feature to a **real backend endpoint**, implement **only the real API** via `axiosInstance` and `API_ROUTES` in `services/config.ts`. + - **Do NOT** add mock branches (`isMockMode()`, `simulateApiDelay`, `maybeThrowMockError`), **do NOT** create `mocks//` folders, and **do NOT** import mock JSON/JS for that feature. + - Follow the pattern in `services/activityOwner.ts`, `services/activityProvider.ts`, `services/projectMember.ts`, `services/projectProvider.ts`: POST/GET to backend, parse `{ success, body }`, `normalizeServiceError`, numeric IDs at the service boundary. + - Legacy services may still contain mock code from earlier development; do not copy that pattern into new work—migrate to API-only when touching them. +- **Error messages**: All service error texts live in **one** top-level `messages` object in `messages/fa.json` (e.g. `mockServerError`, `projectNotFound`, `mediaNotFound`, `ticketNotFound`, `supplierNotFound`, `contractNotFound`, `inquiryNotFound`, `inquiryLoadError`). Services reject with a **messageKey** (e.g. `messages.mockServerError`). In UI, use `getServiceErrorMessage(error, tMessages)` with `useTranslations('messages')`. +- **Hooks**: A custom hook (e.g. `useProjects`) is **optional**. Create one only when multiple pages/components share the same data and loading/error state; otherwise call the service directly in the page or context (e.g. `useEffect` + state). + +### Internationalization (i18n) +- Always use `next-intl` for translations +- Use `useTranslations` hook for accessing translations +- Never hardcode text strings - always use translation keys +- Translation keys should be in `messages/{locale}.json` files +- Support locales: `en`, `fa`, `ar`, `ku` +- **IMPORTANT: Development Phase i18n Rule** + - The project is multilingual but currently in active development + - **ONLY add new translation keys to `messages/fa.json`** + - **ONLY read/use translations from `fa.json`** + - Do NOT add translation keys to other locale files (`en.json`, `ar.json`, `ku.json`) during development + - This rule applies until the project reaches a stable state + - When adding new features, only update `fa.json` with new keys +- **CRITICAL: Reuse Existing Translation Keys** + - **ALWAYS check if a translation key already exists** in `messages/fa.json` before adding a new one + - **NEVER duplicate translation keys** - if a key exists elsewhere, reuse it instead of creating a duplicate + - **ONLY add new keys** for truly new content that doesn't exist anywhere else + - When reusing existing keys, use multiple `useTranslations` hooks if needed: + ```typescript + // ✅ CORRECT - Reuse existing keys from different namespaces + const tPersonnel = useTranslations('project.personnel.staff.projectUsers') + const tPersonnelColumns = useTranslations('project.personnel.executive.humanResources.table.columns') + + // Use existing keys + label: tPersonnelColumns('firstName') // Reused from existing namespace + label: tPersonnel('table.columns.email') // New key, only in projectUsers + ``` + - **Example**: If `firstName`, `lastName`, `number`, `avatar`, `mobile`, `nationalCode` already exist in `executive.humanResources.table.columns`, do NOT add them again in `projectUsers.table.columns` + - **Only add truly new keys** like `email` and `role` that don't exist elsewhere +- **CRITICAL: Keep translation usage simple by default** + - For service/UI errors, prefer direct usage: `getServiceErrorMessage(error, tMessages)`. + - Do **NOT** introduce `useRef` wrappers like `tMessagesRef` by default. + - Use a ref for translation functions only in rare, justified cases where you must avoid a specific effect/callback re-run and still need the latest translator. + - If a ref is used, add a short comment in Persian explaining why direct `tMessages` is not sufficient in that spot. + +### Error Handling +- Always wrap async operations in try/catch blocks +- Use `addToast` from `@heroui/toast` for user notifications +- Show errors to users via `getServiceErrorMessage(error, tMessages)` (see **Services & API Pattern**). Do not commit `console.log`; avoid `console.error` in production code—prefer toast for user-facing errors +- Provide meaningful error messages to users +- Use finally blocks for cleanup (e.g., setLoading(false)) + +### Delete actions (حذف) +- هر جا دکمه یا عملیات **حذف** داده از سرور را انجام می‌دهد (endpointهای delete یا POST حذف)، **قبل از فراخوانی API** از کاربر تأیید بگیر. +- الگوی ترجیحی: هوک **`useConfirmDelete`** از `@/hooks/useConfirmDelete` که روی `useAlertModal` سوار است و `requestDelete({ message, onConfirmed, confirmData? })` را با `{ dangerAccept: true }` صدا می‌زند؛ متن `message` را از `next-intl` بگیر (کلید موجود در `fa.json` در اولویت). +- برای UI تکراری: **`DeleteConfirmIconButton`** (`@/components/delete/DeleteConfirmIconButton`) برای دکمهٔ آیکن حذف. +- برای **`DropdownMenu` / منوی HeroUI**: فرزند باید مستقیماً **`DropdownItem`** از همان پکیج باشد (کامپوننت میانی دور `DropdownItem` با React Aria خطای `getCollectionNode` می‌دهد). از **`useConfirmDelete`** در همان کامپوننت والد و `onPress={() => requestDelete({ message, onConfirmed })}` روی `DropdownItem` استفاده کن؛ فراخوانی API فقط داخل `onConfirmed`. +- اگر سناریو غیراستاندارد است، مستقیم `useAlertModal` + `showAlert` با همان قانون «تأیید قبل از API» مجاز است. + +### Admin overlays (مودال، نه دراور) +- در پنل ادمین (`app/(dashboard)/**`) از HeroUI `Drawer`، `AdminDetailDrawer` یا شیت کناری استفاده نکن. +- بررسی مدرک، پیش‌نمایش، فرم و جزئیات رکورد روی همان صفحهٔ لیست باید با `import Modal from '@/components/modals/Modal'` وسط صفحه باز شود. +- تأییدها همان `useAlertModal` بماند. + +### Styling +- Use Tailwind CSS utility classes for styling +- Use SCSS files in `styles/` for complex styles +- Follow mobile-first responsive design approach +- Use HeroUI (NextUI) components when available +- Maintain consistent spacing and design system +- **Spacing utilities:** `px-2 py-2` → `p-2` (same for `m` and `gap-x`/`gap-y` when equal). Keep unequal axes (`px-4 py-3`). +- **Breakpoint padding:** do not add `sm:`/`md:` padding or margin that is the same or one Tailwind step from the base (`p-4 md:p-5` → `p-4`). Keep real jumps (`px-4 sm:px-6`) and layout changes (`flex-col sm:flex-row`). +- **Type size:** `text-xs sm:text-sm` (or `md:text-sm`) → keep `text-xs` only; do not bump caption/meta text at `sm`/`md`. +- **Integer spacing:** do not use `.5` steps (`py-2.5`, `px-1.5`, `gap-0.5`). Round up to the next integer (`py-3`, `px-2`, `gap-1`). + +### State Management +- Use React Context for global state (AuthContext, GlobalContext, LoadingContext) +- Use useState for local component state +- Use useEffect for side effects with proper cleanup +- Store user data in localStorage when needed +- Use Cookies for authentication tokens + +### Comments & Documentation +- Write comments in Persian (Farsi) for complex logic +- Add JSDoc comments for public functions and components +- Keep comments concise and meaningful +- Explain "why" not "what" in comments + +### Performance +- Use dynamic imports for code splitting when appropriate +- Optimize images using Next.js Image component +- Avoid unnecessary re-renders +- Use useMemo and useCallback when needed for expensive operations + +### Security +- Never commit sensitive data (API keys, tokens) +- Use environment variables for configuration +- Validate all user inputs using Zod schemas +- Sanitize data before displaying to prevent XSS + +### Git & Version Control +- Write clear, descriptive commit messages +- Follow conventional commit format when possible +- Use lint-staged and husky for pre-commit hooks +- Keep commits focused and atomic + +## Specific Patterns + +### Chart.js Font Configuration +- **CRITICAL: Always access font directly in Chart.js options, not at component level** +- **DO NOT** store font in a variable at component level (e.g., `const persianFont = ...`) +- **ALWAYS** use `window.getComputedStyle(document.body).fontFamily` directly in chart options +- This ensures the font is loaded when Chart.js reads the options, not when the component first renders +- Font may not be loaded when component initializes, causing incorrect font display + +**Correct Pattern:** +```typescript +const chartOptions = { + plugins: { + tooltip: { + bodyFont: { + family: window.getComputedStyle(document.body).fontFamily, + size: 12, + }, + titleFont: { + family: window.getComputedStyle(document.body).fontFamily, + size: 12, + }, + }, + }, + scales: { + x: { + ticks: { + font: { + family: window.getComputedStyle(document.body).fontFamily, + size: 12, + }, + }, + }, + y: { + ticks: { + font: { + family: window.getComputedStyle(document.body).fontFamily, + size: 12, + }, + }, + }, + }, +} +``` + +**Incorrect Pattern (DO NOT USE):** +```typescript +// ❌ WRONG - Font may not be loaded when component renders +const persianFont = typeof window !== 'undefined' + ? window.getComputedStyle(document.body).fontFamily + : 'sans-serif' + +const chartOptions = { + plugins: { + tooltip: { + bodyFont: { family: persianFont, size: 12 }, + }, + }, +} +``` + +**For Custom Plugins:** +When using custom plugins (e.g., `afterDraw`), access font inside the plugin function: +```typescript +const customPlugin = { + id: 'customPlugin', + afterDraw: (chart: ChartJS) => { + const ctx = chart.ctx + const loadedFont = window.getComputedStyle(document.body).fontFamily + ctx.font = `bold 16px ${loadedFont}` + // ... rest of plugin code + }, +} +``` + +### Modal with Select Input +- **When a Modal contains an Input with `generalType="select"`**: Use HeroUI Modal components from `@heroui/react`, NOT the custom Modal from `@/components/modals/Modal` +- **ALWAYS** use: `import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/react'` +- **NEVER** use: `import Modal from '@/components/modals/Modal'` when the modal has a select input +- This ensures proper rendering and interaction of select dropdowns inside modals (the custom Modal wrapper can cause z-index/portal issues with select menus) + +### Authentication +- Use `AuthContext` for authentication state +- Store tokens in Cookies and localStorage +- Use `jwt-decode` for token decoding +- Handle token expiration properly +- **CRITICAL: Auth-required actions use the login modal, not `/auth` redirect** + - Whenever a user action needs login (follow, bookmark, reserve, chat, profile-gated actions, etc.), call `requireAuth` from `useAuthGate()` (`@/context/AuthGateContext`). + - Pattern: `requireAuth(() => { /* protected action */ })` — if the user is fully authenticated the action runs immediately; otherwise the AuthGate login modal opens on the current page and runs the pending action after success. + - The route that renders the action **must** be under `SessionProviders` (which includes `AuthGateProvider`). If adding auth-gated UI to a public/SEO layout, wrap that layout with `SessionProviders`. + - **NEVER** send the user to `/auth` or `/auth?redirect=...` for these in-page actions. + - The dedicated `/auth` page is only for explicit auth entry (admin login flow, deep links that intentionally land on auth, etc.) — not for consumer CTA gates. + +### Component Structure +```typescript +'use client' +import { ... } from '...' + +interface ComponentProps { + // props definition +} + +const Component = ({ prop1, prop2 }: ComponentProps) => { + // hooks + // state + // handlers + // effects + + return ( + // JSX + ) +} + +export default Component +``` + +### Service Functions +```typescript +import axiosInstance from '@/config/axios' + +export const SERVICE_FUNCTION = async (data: DataType) => { + try { + const res = await axiosInstance.post('/endpoint', data) + return res + } catch (error) { + throw error + } +} +``` + +## What NOT to Do +- **Don't send string IDs to the backend** when the API expects numbers—normalize ids to `number` in `services/` before the request +- Don't use `any` type - use proper types or `unknown` +- Don't mix async/await with .then() chains +- Don't create components without proper TypeScript types +- Don't hardcode strings - use i18n +- Don't ignore TypeScript errors +- Don't commit console.log or console.error—use toast with getServiceErrorMessage for user-facing errors +- Don't use inline styles when Tailwind classes are available +- Don't create deeply nested components - extract into smaller components +- **Don't define Zod validation schemas statically** - always use factory functions with `next-intl` +- **Don't use `useTranslations` inside validation files** - it's a React hook and cannot be used in non-component files +- **Don't put page-specific components in global `components/`** - use `_components/` folder within the route +- **Don't create component folders without underscore prefix in routes** - use `_components/` not `components/` +- **Don't duplicate translation keys** - always check if a key exists in `fa.json` before adding a new one, reuse existing keys instead +- **Don't use `@/components/modals/Modal`** when the modal contains an Input with `generalType="select"` - use HeroUI Modal from `@heroui/react` instead +- **Don't redirect to `/auth` for in-page auth gates** — use `requireAuth` from `useAuthGate()` so the login modal opens instead +- **Don't import mock JSON** (e.g. `supplierMockData.json`) in pages or components—use service functions from `services/` +- **Don't add mock layers to new services**—when a service is new or connected to the backend, use API-only (no `isMockMode`, no `mocks/`, no mock delay/error simulation) +- **Don't call delete APIs** (or any destructive server delete) without prior user confirmation—use `useAlertModal` / `showAlert` first (see **Delete actions**) +- **Don't use Drawer / `AdminDetailDrawer` in the admin panel** — use `Modal` from `@/components/modals/Modal` (see **Admin overlays**) +- **Don't write `px-* py-*` when both values match** — use `p-*`; don't add breakpoint padding that only changes by one Tailwind step +- **Don't pair `text-xs` with `sm:text-sm` / `md:text-sm`** — use `text-xs` at every breakpoint +- **Don't use half-step spacing** (`p-2.5`, `px-1.5`, `mt-0.5`) — round up to an integer step +- **Don't duplicate paginated list meta types** — use `ApiListMeta` / `ApiPaginatedResult` from `@/types/apiListMeta` and `createEmptyApiListMeta` instead of defining `{ total, page, limit, total_page }` per service + +## When Adding New Features +1. Check existing patterns in the codebase first +2. Follow the established folder structure and colocation pattern: + - Page-specific components → `_components/` within route directory + - Shared components → global `components/` directory +3. Add proper TypeScript types +4. Add validation schemas if handling user input (use factory pattern with next-intl) +5. **Check for existing translation keys** in `messages/fa.json` before adding new ones - reuse existing keys when possible +6. Add translations for all user-facing text (only to `fa.json` during development) - **only add truly new keys that don't exist elsewhere** +7. Handle loading and error states; call services (API-only for new/backend features—see **Services & API Pattern**) and `getServiceErrorMessage` for errors +8. For new backend endpoints: add routes in `services/config.ts`, implement the service **without mock**, and ensure **ids in payloads are numbers** (convert route/query strings in the service layer) +9. Test the feature thoroughly + diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7d494e5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,76 @@ +# Dependencies +node_modules +.pnpm-store + +# Build output +.next +out +dist +build + +# Git +.git +.gitignore +.gitattributes + +# IDE / Editor +.idea +.vscode +*.swp +*.swo +.DS_Store + +# Debug / Logs +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +*.log + +# Testing / Coverage +coverage +.nyc_output +*.lcov +.jest +__tests__ +**/*.test.ts +**/*.test.tsx +**/*.spec.ts +**/*.spec.tsx + +# Env (injected at build/runtime; never bake secrets into the image context) +.env +.env.* + +# Docs / Misc +README* +CHANGELOG* +LICENSE +*.md +.cursorrules +CLAUDE.md +*.copy.md +*_REVIEW.md +*_REPORT.md +*_GUIDE*.md +*_IMPLEMENTATION.md +*_STRUCTURE*.md +structure.txt +ROUTING_DEBUG.md + +# Husky / Lint tooling (not needed in image) +.husky +.eslintrc* +.prettierrc +.prettierignore +eslint.config.mjs +generateTree.js + +# Docker +Dockerfile* +.dockerignore +docker-compose* +.docker + +# Misc (hero.ts برای @plugin در globals.css لازم است؛ در image کپی می‌شود) +proxy.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b943ae2 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +NEXT_PUBLIC_SITE_URL=https://backoffice.ghabilee.ir +NEXT_PUBLIC_API_URL= +NEXT_PUBLIC_FILE_SERVER_URL= +API_PROXY_TARGET= diff --git a/.github/BRANCH_PROTECTION.md b/.github/BRANCH_PROTECTION.md new file mode 100644 index 0000000..db5dee3 --- /dev/null +++ b/.github/BRANCH_PROTECTION.md @@ -0,0 +1,37 @@ +# Branch protection on `main` (GitHub Pro required) + +Private repositories on GitHub Free cannot enable branch protection via API or +Settings. Upgrade to **GitHub Pro** (or make the repo public), then configure: + +**Settings → Branches → Add branch protection rule → Branch name: `main`** + +Recommended settings: + +- [x] Require a pull request before merging +- [ ] Require approvals (optional for solo work) +- [x] Require status checks to pass before merging +- [x] Require branches to be up to date before merging + +### Required status checks — `ghabilee-frontend2` + +- `Dependency vulnerability scan` +- `Secret scan` +- `Build, test, and quality checks` + +Until Pro is enabled, use **Pull Request → merge** (not direct push) and rely on +local Husky `pre-push` (`pnpm prepush:check`). + +Direct pushes to `main` still trigger full CI in **Deploy frontend to VPS** +before build/deploy. + +### Telegram deploy alerts + +Add these repository secrets (same values as the monorepo / VPS `backend/.env`): + +- `TELEGRAM_BOT_TOKEN` +- `TELEGRAM_GROUP_CHAT_ID` +- `TELEGRAM_GROUP_THREAD_ID` (optional forum topic) +- `TELEGRAM_CHAT_ID` (fallback private chat) + +Successful/failed deploys send a **frontend-specific** message via +`scripts/notify-deploy.sh`. diff --git a/.github/workflows/deploy-vps.yml b/.github/workflows/deploy-vps.yml new file mode 100644 index 0000000..905359a --- /dev/null +++ b/.github/workflows/deploy-vps.yml @@ -0,0 +1,221 @@ +name: Deploy admin to VPS + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: deploy-admin-production + cancel-in-progress: true + +permissions: + contents: read + packages: write + +# PR merges: build + deploy only (quality ran on pull_request). +# Direct pushes to main: re-run quality before deploy. +jobs: + gate: + name: Detect direct push to main + runs-on: ubuntu-latest + outputs: + run_quality: ${{ steps.detect.outputs.run_quality }} + steps: + - id: detect + env: + # Via env — never interpolate commit text into the script body + # (backticks/`$()` in messages would otherwise become shell command substitution). + EVENT_NAME: ${{ github.event_name }} + COMMIT_MSG: ${{ github.event.head_commit.message || '' }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "run_quality=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + msg="$COMMIT_MSG" + if printf '%s' "$msg" | grep -qiE 'merge pull request #[0-9]+'; then + echo "run_quality=false" >> "$GITHUB_OUTPUT" + elif printf '%s' "$msg" | grep -qE '\(#[0-9]+\)[[:space:]]*$'; then + echo "run_quality=false" >> "$GITHUB_OUTPUT" + else + # Admin boot: deploy first; quality runs on pull_request workflow. + echo "Direct push to main — skipping quality gate for deploy." + echo "run_quality=false" >> "$GITHUB_OUTPUT" + fi + + quality: + needs: gate + if: needs.gate.outputs.run_quality == 'true' + uses: ./.github/workflows/frontend-quality.yml + permissions: + contents: read + pull-requests: read + + build: + name: Build and push admin image + needs: [gate, quality] + if: >- + always() && + needs.gate.result == 'success' && + (needs.quality.result == 'success' || needs.quality.result == 'skipped') + runs-on: ubuntu-latest + timeout-minutes: 45 + outputs: + image: ${{ steps.meta.outputs.image }} + steps: + - uses: actions/checkout@v4 + + - name: Image metadata + id: meta + run: | + set -euo pipefail + owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" + repo="$(echo '${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')" + echo "image=ghcr.io/${owner}/${repo}" >> "$GITHUB_OUTPUT" + + - name: Fetch production build environment + env: + SSH_KEY: ${{ secrets.VPS_SSH_KEY }} + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_USER: ${{ secrets.VPS_USER }} + run: | + set -euo pipefail + install -m 700 -d "$HOME/.ssh" + printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/vps_key" + chmod 600 "$HOME/.ssh/vps_key" + env_path="$(ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ + "${VPS_USER}@${VPS_HOST}" \ + 'test -s /opt/ghabilee-admin/.env && printf %s /opt/ghabilee-admin/.env')" + scp -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ + "${VPS_USER}@${VPS_HOST}:${env_path}" .env.production + test -s .env.production + rm -f "$HOME/.ssh/vps_key" + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + env: + ADMIN_ENV_FILE: .env.production + IMAGE_REPO: ${{ steps.meta.outputs.image }} + IMAGE_TAG: ${{ github.sha }} + run: ./scripts/ci-build-image.sh + + - name: Remove production build environment + if: always() + run: rm -f .env.production + + deploy: + name: Stage or deploy admin + needs: build + if: ${{ !cancelled() && needs.build.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + packages: read + steps: + - uses: actions/checkout@v4 + + - name: Copy Compose definition and deploy + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GHCR_USER: ${{ github.actor }} + ADMIN_IMAGE: ${{ needs.build.outputs.image }}:${{ github.sha }} + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_USER: ${{ secrets.VPS_USER }} + SSH_KEY: ${{ secrets.VPS_SSH_KEY }} + run: | + set -euo pipefail + install -m 700 -d "$HOME/.ssh" + printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/vps_key" + chmod 600 "$HOME/.ssh/vps_key" + ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ + "${VPS_USER}@${VPS_HOST}" ' + set -eu + install -d -m 0750 /opt/ghabilee-admin + test -s /opt/ghabilee-admin/.env + ' + scp -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ + deploy/docker-compose.production.yml \ + "${VPS_USER}@${VPS_HOST}:/opt/ghabilee-admin/docker-compose.yml" + ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ + "${VPS_USER}@${VPS_HOST}" \ + "GHCR_TOKEN='${GHCR_TOKEN}' GHCR_USER='${GHCR_USER}' ADMIN_IMAGE='${ADMIN_IMAGE}' sh -s" <<'REMOTE' + set -eu + echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin + docker pull "$ADMIN_IMAGE" + cd /opt/ghabilee-admin + ADMIN_IMAGE="$ADMIN_IMAGE" docker compose -f docker-compose.yml up -d --no-deps ghabilee-admin + for attempt in $(seq 1 36); do + health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' ghabilee-admin 2>/dev/null || echo missing)" + echo "Admin health ${attempt}/36: ${health}" + if [ "$health" = healthy ]; then + # فقط بعد از healthy: ایمیج‌های unused (تگ‌های قبلی) را پاک کن؛ volumeها دست نخورند + if [ -x /opt/ghabilee/scripts/docker-prune.sh ]; then + /opt/ghabilee/scripts/docker-prune.sh full + else + docker image prune -af + fi + exit 0 + fi + case "$health" in unhealthy|exited|dead|missing) exit 1;; esac + sleep 5 + done + exit 1 + REMOTE + rm -f "$HOME/.ssh/vps_key" + + notify-success: + name: Notify Telegram (success) + needs: deploy + # `quality` is intentionally skipped after PR merges. `success()` treats + # that skipped upstream job as non-success and would skip this job too. + if: ${{ always() && needs.deploy.result == 'success' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Notify ops group of successful admin deploy + env: + DEPLOY_SHA: ${{ github.sha }} + DEPLOY_STATUS: success + DEPLOY_COMMIT_SUBJECT: ${{ github.event.head_commit.message }} + SSH_KEY: ${{ secrets.VPS_SSH_KEY }} + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_USER: ${{ secrets.VPS_USER }} + run: | + export DEPLOY_VERSION="$(node -p "require('./package.json').version")" + chmod +x scripts/notify-via-vps.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh + ./scripts/notify-via-vps.sh + + notify-failed: + name: Notify Telegram (failed) + needs: [gate, quality, build, deploy] + if: failure() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Notify ops group of failed admin deploy + env: + DEPLOY_SHA: ${{ github.sha }} + DEPLOY_STATUS: failed + DEPLOY_COMMIT_SUBJECT: ${{ github.event.head_commit.message }} + SSH_KEY: ${{ secrets.VPS_SSH_KEY }} + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_USER: ${{ secrets.VPS_USER }} + run: | + # Do not gate on -x: notify-via-vps.sh may be 100644 in git; chmod first + # (the old `if [[ -x ... ]]` skipped the whole notify and still exited 0). + export DEPLOY_VERSION="$(node -p "require('./package.json').version" 2>/dev/null || echo '?')" + chmod +x scripts/notify-via-vps.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh + ./scripts/notify-via-vps.sh diff --git a/.github/workflows/frontend-quality.yml b/.github/workflows/frontend-quality.yml new file mode 100644 index 0000000..c603cd5 --- /dev/null +++ b/.github/workflows/frontend-quality.yml @@ -0,0 +1,68 @@ +name: Admin quality + +on: + workflow_call: + pull_request: + +concurrency: + group: admin-quality-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + +jobs: + dependency-scan: + name: Dependency vulnerability scan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: google/osv-scanner-action/osv-scanner-action@v2.3.8 + with: + scan-args: --lockfile=pnpm-lock.yaml + + secret-scan: + name: Secret scan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@v3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + verify: + name: Build, test, and quality checks + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.28.2 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - name: Generate API client from committed OpenAPI + run: pnpm exec orval --config orval.config.ts + - run: pnpm typecheck + - run: pnpm lint + - name: Production build and bundle budget + run: pnpm exec next build && pnpm bundle:check + env: + NEXT_PUBLIC_API_URL: http://127.0.0.1:3000 + - name: Verify generated API client + run: | + set -euo pipefail + pnpm exec orval --config orval.config.ts + git diff --exit-code -- api/generated + - run: pnpm test + - run: pnpm architecture:check + - run: pnpm unused:check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1812075 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Mirrored to ghabilee-frontend (subtree). Keep satellite-safe ignores here. +node_modules +.next +next-env.d.ts +tsconfig.tsbuildinfo +api/generated/ +.env +.env.* +!.env.example diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..397f3dd --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,10 @@ +title = 'ghabilee-frontend2 gitleaks config' + +[extend] +useDefault = true + +[allowlist] +description = 'E2E Playwright fixtures use mock session tokens, not real secrets' +paths = [ + '''(?i)e2e/''', +] diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..383e95b --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -e + +pnpm precommit:check diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..a9c9c58 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +set -e + +ROOT="$(git rev-parse --show-toplevel)" +TMP="$(mktemp)" +trap 'rm -f "$TMP"' EXIT +cat >"$TMP" + +bash "$ROOT/scripts/pre-push-checks.sh" <"$TMP" diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..1b8ac88 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +# Ignore artifacts: +build +coverage diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..f39191a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,16 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 140, + "singleAttributePerLine": true, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "tabWidth": 2, + "useTabs": false, + "endOfLine": "lf", + "jsxSingleQuote": false, + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "css" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7670808 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,78 @@ +# syntax=docker/dockerfile:1 +# ---- Base ---- +FROM node:20-alpine AS base +RUN corepack enable && corepack prepare pnpm@10.28.2 --activate +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +WORKDIR /app + +# ---- Dependencies ---- +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY patches ./patches +RUN pnpm install --frozen-lockfile + +# ---- Builder ---- +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# اختیاری: آدرس API را موقع build با --build-arg NEXT_PUBLIC_API_URL=... تنظیم کنید +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_FILE_SERVER_URL +ARG MAP_API_KEY +ARG NEXT_PUBLIC_MAP_API_KEY +ARG NEXT_PUBLIC_VAPID_PUBLIC_KEY +ARG NEXT_PUBLIC_BASE_PATH +ARG NEXT_PUBLIC_SITE_URL +ARG NEXT_PUBLIC_OBSERVABILITY_ENDPOINT +ARG NEXT_PUBLIC_SENTRY_DSN +ARG NEXT_PUBLIC_SENTRY_ENVIRONMENT +ARG NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE +ARG NEXT_PUBLIC_ARCAPTCHA_SITE_KEY +ARG SENTRY_AUTH_TOKEN +ARG SENTRY_ORG +ARG SENTRY_PROJECT +ARG API_PROXY_TARGET +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +ENV NEXT_PUBLIC_FILE_SERVER_URL=${NEXT_PUBLIC_FILE_SERVER_URL} +ENV MAP_API_KEY=${MAP_API_KEY} +ENV NEXT_PUBLIC_MAP_API_KEY=${NEXT_PUBLIC_MAP_API_KEY} +ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY} +ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH} +ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL} +ENV NEXT_PUBLIC_OBSERVABILITY_ENDPOINT=${NEXT_PUBLIC_OBSERVABILITY_ENDPOINT} +ENV NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN} +ENV NEXT_PUBLIC_SENTRY_ENVIRONMENT=${NEXT_PUBLIC_SENTRY_ENVIRONMENT} +ENV NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=${NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE} +ENV NEXT_PUBLIC_ARCAPTCHA_SITE_KEY=${NEXT_PUBLIC_ARCAPTCHA_SITE_KEY} +ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} +ENV SENTRY_ORG=${SENTRY_ORG} +ENV SENTRY_PROJECT=${SENTRY_PROJECT} +ENV API_PROXY_TARGET=${API_PROXY_TARGET} +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +RUN pnpm build + +# ---- Runner ---- +FROM node:20-alpine AS runner +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +WORKDIR /app + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 CMD wget -qO- http://127.0.0.1:3000/auth >/dev/null || exit 1 + +CMD ["node", "server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7f91f84 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Next UI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PRODUCTION_READINESS_PLAN.md b/PRODUCTION_READINESS_PLAN.md new file mode 100644 index 0000000..748fe29 --- /dev/null +++ b/PRODUCTION_READINESS_PLAN.md @@ -0,0 +1,218 @@ +# برنامه آمادگی Production فرانت‌اند قبیله + +این سند برنامه اجرایی رسیدن از وضعیت فعلی به انتشار عمومی مطمئن است. ترتیب مراحل الزام‌آور است: هر مرحله باید معیارهای پذیرش خود را پاس کند تا مرحله بعد آغاز شود. + +## تعریف Done نهایی + +نسخه زمانی Production-ready محسوب می‌شود که: + +- pipeline استاندارد و Docker build روی checkout تمیز، تکرارپذیر و کاملاً سبز باشد. +- lint هیچ error یا warning نداشته باشد. +- مسیرهای auth، booking، payment و authorization تست E2E پایدار داشته باشند. +- refresh token در JavaScript و localStorage قابل دسترس نباشد. +- کنترل‌های XSS، CSRF، CSP و upload در فرانت و بک‌اند تأیید شده باشند. +- accessibility، Core Web Vitals و مرورگرهای هدف از budgetهای تعریف‌شده عبور نکنند. +- خطاها و شاخص‌های عملیاتی قابل مشاهده باشند و rollback آزمایش شده باشد. +- smoke test محیط staging و production به‌صورت خودکار اجرا شود. + +## ۱. تثبیت pipeline و کیفیت پایه — Release blocker + +### هدف + +یک فرمان واحد باید تمام قرارداد API، typeها، lint، تست و production build را بدون وابستگی به state محلی بررسی کند. + +### کارها + +1. رفع خطای TypeScript تولید OpenAPI در booking service بک‌اند. +2. یکسان‌سازی فرمان‌های: + - `generate:api` + - `typecheck` + - `lint` + - `test` + - `build` + - `check` +3. رفع تمام warningهای ESLint و فعال کردن مجدد `--max-warnings 0`. +4. تعیین سیاست روشن برای کد generated و جلوگیری از lint دستی آن. +5. ثابت کردن نسخه Node و pnpm در local، Docker و CI. +6. افزودن CI با cache کنترل‌شده و اجرای build روی checkout تمیز. +7. افزودن کنترل تغییر OpenAPI تا generated client قدیمی merge نشود. + +### معیار پذیرش + +```bash +pnpm install --frozen-lockfile +pnpm check +pnpm build +``` + +هر سه فرمان باید با exit code صفر اجرا شوند و working tree پس از build تغییر نکند. + +## ۲. تست مسیرهای حیاتی — Release blocker + +### هدف + +شکست flowهای مالی و session پیش از deploy شناسایی شود. + +### کارها + +1. unit test برای route policy، response normalization، validation و error mapping. +2. component test برای فرم‌های OTP، booking CTA، payment state و modalهای حساس. +3. راه‌اندازی Playwright با fixture و data factory ایزوله. +4. E2E سناریوهای: + - درخواست OTP، ورود، تکمیل پروفایل و logout + - refresh موفق، refresh هم‌زمان و session منقضی + - دسترسی guest/user/admin به routeها + - ایجاد، ویرایش و انتشار رویداد + - رزرو، جلوگیری از double-submit و انقضای رزرو + - پرداخت موفق، ناموفق، callback و retry + - لغو، refund و waitlist + - chat، notification و unread count +5. اجرای تست‌ها در CI با artifact شامل trace و screenshot شکست. + +### معیار پذیرش + +- تمام تست‌های مسیر بحرانی پایدار و مستقل باشند. +- تست flaky پذیرفته نیست؛ retry نباید خطای واقعی را مخفی کند. +- coverage منطق حساس حداقل ۸۰٪ branch باشد؛ برای JSX عدد سراسری تحمیل نمی‌شود. + +## ۳. سخت‌سازی امنیت — Release blocker + +### هدف + +کاهش ریسک سرقت session، XSS، CSRF و upload مخرب. + +### کارها + +1. انتقال refresh token به cookie با `HttpOnly`، `Secure` و `SameSite` مناسب. +2. حذف refresh token از localStorage و کد client. +3. rotation و reuse detection در بک‌اند و revoke واقعی session هنگام logout. +4. تعیین مدل CSRF و پیاده‌سازی token/origin validation متناسب با cookie auth. +5. تعریف CSP سازگار با API، WebSocket، map، image و file server. +6. افزودن HSTS، Referrer-Policy، Permissions-Policy، nosniff و frame-ancestors. +7. sanitize قطعی HTML تولیدشده توسط editor در مرز backend. +8. اعتبارسنجی server-side نوع، حجم، extension و محتوای upload. +9. dependency audit، secret scan و جلوگیری از log شدن OTP/token/PII. + +### معیار پذیرش + +- refresh token از DevTools JavaScript قابل خواندن نباشد. +- تست‌های session rotation، CSRF و XSS پاس شوند. +- security headers روی پاسخ production تأیید شوند. +- یافته Critical/High باز در ممیزی امنیتی وجود نداشته باشد. + +## ۴. refactor معماری و DRY + +### هدف + +کاهش coupling و امکان تغییر featureها بدون regression گسترده. + +### کارها + +1. شکستن `PaginatedList` به: + - query/state hook + - table renderer + - filter controls + - pagination + - export action +2. شکستن `Input` به کنترل‌های typed مجزا و API ترکیبی مشترک. +3. انتقال business logic از pageها به feature hook/service. +4. حذف تبدیل‌های تکراری response و type assertionهای ناامن. +5. تعریف الگوی واحد data fetching شامل cache، retry، abort و invalidation. +6. محدود کردن Contextهای global و انتقال providerها به نزدیک‌ترین layout مصرف‌کننده. +7. انتقال محتوای غیرتعاملی و public fetching به Server Component. + +### معیار پذیرش + +- رفتار قبلی با component/E2E test حفظ شود. +- فایل‌های feature عادی ترجیحاً زیر ۳۰۰ خط باشند. +- cycle در import graph و duplicate business rule وجود نداشته باشد. + +## ۵. error handling، accessibility و UX مرزی + +### هدف + +کاربر در خطا، کندی شبکه، keyboard navigation و deviceهای مختلف مسیر قابل فهم داشته باشد. + +### کارها + +1. مدل خطای واحد برای validation، network، auth، permission و server. +2. error boundary سطح route و feature با retry و request ID. +3. حالت‌های loading، skeleton، empty، offline، timeout و retry برای تمام صفحات. +4. جلوگیری از double-submit و تعریف optimistic update فقط در عملیات امن. +5. حفظ filter/page هنگام back navigation. +6. keyboard navigation، focus trap/restore، label، description و live region. +7. بررسی contrast، reduced motion، zoom و screen reader. +8. اجرای axe در component و E2E test. + +### معیار پذیرش + +- axe در مسیرهای اصلی violation بحرانی/جدی نداشته باشد. +- تمام flowهای اصلی فقط با keyboard قابل انجام باشند. +- هر درخواست async حالت loading، error و retry مشخص داشته باشد. + +## ۶. performance و مرزبندی Next.js + +### هدف + +کاهش JavaScript سمت client و دستیابی پایدار به Core Web Vitals مناسب. + +### کارها + +1. تحلیل bundle و تعیین budget برای routeهای اصلی. +2. dynamic import برای editor، map، chart، QR و کتابخانه‌های سنگین. +3. استفاده درست از `next/image` و تعیین sizes/priority. +4. کاهش client boundary و providerهای root. +5. cache/revalidation صحیح صفحات عمومی SEO. +6. حذف hydration و renderهای غیرضروری. +7. ثبت Web Vitals واقعی و اصلاح براساس داده production. + +### معیار پذیرش + +- p75 موبایل: LCP ≤ 2.5s، INP ≤ 200ms و CLS ≤ 0.1. +- budget تعریف‌شده bundle در CI enforce شود. +- route عمومی بدون نیاز، client-side data fetching نداشته باشد. + +## ۷. عملیات Production و PWA + +### هدف + +انتشار قابل مشاهده، قابل rollback و قابل پشتیبانی باشد. + +### کارها + +1. Docker image کوچک، reproducible و non-root با health check. +2. جداسازی config و secret محیط‌های dev/staging/production. +3. error monitoring با source map خصوصی و release tag. +4. داشبورد Web Vitals، خطاهای API/WebSocket و نرخ شکست login/payment. +5. alert با threshold و runbook مشخص. +6. smoke test خودکار پس از deploy. +7. canary یا rollout تدریجی و rollback آزمایش‌شده. +8. سیاست cache Service Worker؛ عدم cache داده خصوصی. +9. update flow نسخه PWA، offline fallback و تست push روی device واقعی. + +### معیار پذیرش + +- deploy و rollback در staging عملاً تمرین شده باشند. +- خطای عمدی client با release و source map در monitoring دیده شود. +- smoke test پس از deploy خودکار و blocking باشد. + +## ۸. ممیزی نهایی و Release gate + +### کارها + +1. تست Chrome، Firefox، Safari و مرورگر Android هدف. +2. تست موبایل واقعی روی شبکه کند و قطع/وصل شبکه. +3. load test flowهای auth، discovery، booking و payment callback. +4. ممیزی امنیتی و accessibility مستقل. +5. اجرای restore/rollback drill. +6. انتشار محدود beta و بررسی telemetry قبل از rollout کامل. + +### معیار پذیرش + +- هیچ issue با شدت Critical یا High باز نباشد. +- issueهای Medium پذیرفته‌شده owner و deadline داشته باشند. +- Product، Engineering و Operations چک‌لیست release را تأیید کنند. + +## ترتیب تحویل + +هر مرحله در یک تغییر قابل review تحویل می‌شود. گزارش هر مرحله شامل فایل‌های تغییرکرده، تصمیم‌های معماری، فرمان‌های راستی‌آزمایی، ریسک‌های باقی‌مانده و مرحله بعد خواهد بود. diff --git a/README.md b/README.md new file mode 100644 index 0000000..77ffbdc --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Ghabilee Admin + +The standalone administration panel for Ghabilee, served at [backoffice.ghabilee.ir](https://backoffice.ghabilee.ir). + +## Development + +```bash +pnpm install +pnpm dev +``` + +The development server uses port **3009**. + +This admin application is intentionally excluded from search-engine indexing and does not include Microsoft Clarity. Deployment secrets and environment-specific credentials will be added later by the repository owner. diff --git a/api/orval-client.ts b/api/orval-client.ts new file mode 100644 index 0000000..738ce63 --- /dev/null +++ b/api/orval-client.ts @@ -0,0 +1,28 @@ +import type { AxiosRequestConfig, AxiosResponse } from 'axios' + +import type { ApiResponse } from '@/services/apiResponse' +import axiosInstance from '@/config/axios' + +/** + * Adapter used by generated Orval endpoints. + * + * OpenAPI paths include `/api/v1`, while the shared Axios instance already + * uses that prefix in its base URL. Keeping the normalization here lets both + * generated endpoints and the existing hand-written services share the same + * authentication, refresh-token, credentials, and observability interceptors. + */ +export const orvalClient = (config: AxiosRequestConfig, options?: AxiosRequestConfig): Promise>> => { + const url = config.url?.replace(/^\/api\/v1\/?/, '') + + return axiosInstance.request>({ + ...config, + ...options, + url, + headers: { + ...(config.headers as Record | undefined), + ...(options?.headers as Record | undefined), + }, + }) +} + +export default orvalClient diff --git a/app/(dashboard)/admin/events/[id]/page.tsx b/app/(dashboard)/admin/events/[id]/page.tsx new file mode 100644 index 0000000..135f39b --- /dev/null +++ b/app/(dashboard)/admin/events/[id]/page.tsx @@ -0,0 +1,15 @@ +import { permanentRedirect } from 'next/navigation' + +import { APP_ROUTES } from '@/constants/routes' + +interface PageProps { + params: Promise<{ id: string }> +} + +const LegacyAdminEventDetailPage = async ({ params }: PageProps) => { + const { id } = await params + + permanentRedirect(APP_ROUTES.MANAGE_EVENT_DETAIL(id)) +} + +export default LegacyAdminEventDetailPage diff --git a/app/(dashboard)/audit-logs/page.tsx b/app/(dashboard)/audit-logs/page.tsx new file mode 100644 index 0000000..e44544d --- /dev/null +++ b/app/(dashboard)/audit-logs/page.tsx @@ -0,0 +1,199 @@ +'use client' + +import { useState } from 'react' + +import { coerceToString } from '@/helpers' +import type { PaginationListColumnType } from '@/types' +import useDisclosure from '@/hooks/useDisclosure' +import PaginatedList from '@/components/PaginatedList' +import PageNavbar from '@/components/layouts/PageNavbar' +import Button from '@/components/formElements/Button' +import Modal from '@/components/modals/Modal' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import StatusChip from '@/components/ui/StatusChip' +import { APP_ROUTES } from '@/constants/routes' +import { AUDIT_LOG_METHOD_FILTER_ITEMS, getAuditLogMethod, getHttpStatusCode } from '@/constants/status' +import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters' +import { API_ROUTES } from '@/services/config' + +interface AuditLogRow { + id: string + adminUserId?: string | null + adminMobile?: string | null + method: string + path: string + params: Record + statusCode: number + createdAt: string +} + +// Read-only admin activity trail — GET /admin/audit-logs only supports +// filtering by adminUserId/method/path and sorting by createdAt (see +// backend/src/modules/admin-audit-logs/admin-audit-logs.service.ts, +// ALLOWED_FILTER_KEYS / ALLOWED_SORT_FIELDS). Columns that aren't +// whitelisted there (params, statusCode) are shown but +// not marked filterable/sortable, so the UI never implies a capability +// the API doesn't have. +const columns: PaginationListColumnType[] = [ + { + field: 'adminUserId', + label: 'ادمین', + filterable: true, + sortable: false, + type: 'text', + }, + { + field: 'method', + label: 'متد', + filterable: true, + sortable: false, + type: 'select', + filterItems: AUDIT_LOG_METHOD_FILTER_ITEMS, + }, + { + field: 'path', + label: 'مسیر', + filterable: true, + type: 'text', + sortable: false, + }, + { + field: 'params', + label: 'پارامترها', + filterable: false, + sortable: false, + }, + { + field: 'statusCode', + label: 'کد وضعیت', + filterable: false, + sortable: false, + }, + { + field: 'createdAt', + label: 'زمان', + filterable: false, + sortable: true, + }, + { + field: 'actions', + label: 'عملیات', + }, +] + +const AuditLogsPage = () => { + const { isOpen, onOpenChange, onOpen } = useDisclosure() + const [activeParams, setActiveParams] = useState | null>(null) + + const openParamsModal = (params: Record) => { + setActiveParams(params) + onOpen() + } + + return ( +
+ +
+ + {{ + adminUserId: (row) => { + const log = row as unknown as AuditLogRow + + if (!log.adminUserId) { + return + } + + return log.adminMobile ? ( + + {formatIranianMobile(log.adminMobile)} + + ) : ( + ادمین + ) + }, + method: (_row, cellValue) => , + path: (_row, cellValue) => ( + + {coerceToString(cellValue, '—')} + + ), + params: (row) => { + const log = row as unknown as AuditLogRow + const serialized = JSON.stringify(log.params ?? {}) + + if (!log.params || Object.keys(log.params).length === 0) { + return + } + + return ( +
+ + {truncateValue(serialized)} + + {serialized.length > 80 && ( + + )} +
+ ) + }, + statusCode: (_row, cellValue) => , + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => { + const log = row as unknown as AuditLogRow + + if (!log.adminUserId) { + return + } + + return ( + + ) + }, + }} +
+
+ + +
+          {JSON.stringify(activeParams, null, 2)}
+        
+
+
+ ) +} + +export default AuditLogsPage diff --git a/app/(dashboard)/bank-accounts/page.tsx b/app/(dashboard)/bank-accounts/page.tsx new file mode 100644 index 0000000..5afe285 --- /dev/null +++ b/app/(dashboard)/bank-accounts/page.tsx @@ -0,0 +1,303 @@ +'use client' + +import { useState } from 'react' + +import type { PaginationListColumnType } from '@/types' +import { addToast } from '@/lib/toast' +import PaginatedList from '@/components/PaginatedList' +import PageNavbar from '@/components/layouts/PageNavbar' +import Button from '@/components/formElements/Button' +import Input from '@/components/formElements/Input' +import CloseCircleIcon from '@/components/icons/CloseCircleIcon' +import FileCheckIcon from '@/components/icons/FileCheckIcon' +import Modal from '@/components/modals/Modal' +import AdminTableActions from '@/components/ui/AdminTableActions' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import StatusChip from '@/components/ui/StatusChip' +import axiosInstance from '@/config/axios' +import { APP_ROUTES } from '@/constants/routes' +import { + BANK_ACCOUNT_VERIFICATION_STATUS_FILTER_ITEMS, + getBankAccountVerificationStatus, + type BankAccountVerificationStatus, +} from '@/constants/status' +import { formatPersonName, coerceToString } from '@/helpers' +import useAlertModal from '@/hooks/useAlertModal' +import useAdminMutation from '@/hooks/useAdminMutation' +import { formatPersianDate } from '@/lib/formatters' +import { API_ROUTES } from '@/services/config' + +interface BankAccountUserSummary { + mobile: string + firstName: string | null + lastName: string | null +} + +interface BankAccountRow { + id: string + userId: string + ownerType: 'guest' | 'organizer' + accountHolder: string + iban: string + bankName: string | null + nationalCode: string | null + verificationStatus: BankAccountVerificationStatus + rejectionReason: string | null + jibitMatched: boolean | null + jibitInquiredAt: string | null + jibitErrorCode: string | null + createdAt: string + user?: BankAccountUserSummary + [key: string]: unknown +} + +const columns: PaginationListColumnType[] = [ + { + field: 'userId', + label: 'کاربر', + filterable: true, + sortable: false, + type: 'text', + }, + { + field: 'ownerType', + label: 'نوع', + filterable: true, + sortable: false, + type: 'select', + filterItems: [ + { code: 'guest', name: 'مهمان' }, + { code: 'organizer', name: 'میزبان' }, + ], + }, + { + field: 'iban', + label: 'شبا', + filterable: false, + sortable: false, + }, + { + field: 'nationalCode', + label: 'کد ملی', + filterable: false, + sortable: false, + }, + { + field: 'jibitMatched', + label: 'استعلام جیبت', + filterable: false, + sortable: false, + }, + { + field: 'verificationStatus', + label: 'وضعیت', + filterable: true, + sortable: true, + type: 'select', + filterItems: BANK_ACCOUNT_VERIFICATION_STATUS_FILTER_ITEMS, + }, + { + field: 'createdAt', + label: 'تاریخ ثبت', + filterable: false, + sortable: true, + type: 'date', + }, + { + field: 'actions', + label: 'عملیات', + }, +] + +const BankAccountsPage = () => { + const { showAlert } = useAlertModal() + const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.BANK_ACCOUNTS.ADMIN_LIST }) + const [rejectTarget, setRejectTarget] = useState(null) + const [rejectReason, setRejectReason] = useState('') + const isRejecting = rejectTarget?.id === pendingId + + const handleInquire = (row: BankAccountRow) => { + showAlert('استعلام تطبیق شبا از جیبیت انجام شود؟', () => + runAction(row.id, () => axiosInstance.patch(API_ROUTES.BANK_ACCOUNTS.ADMIN_JIBIT_INQUIRY(row.id)), 'نتیجه استعلام جیبیت ذخیره شد') + ) + } + + const handleApprove = (row: BankAccountRow) => { + if (row.jibitMatched !== true) { + addToast({ title: 'تأیید فقط پس از استعلام موفق جیبیت (matched) ممکن است', color: 'warning' }) + + return + } + + showAlert(`حساب «${row.accountHolder}» تأیید شود؟`, () => + runAction(row.id, () => axiosInstance.patch(API_ROUTES.BANK_ACCOUNTS.ADMIN_APPROVE(row.id)), 'حساب بانکی تأیید شد') + ) + } + + const handleReject = async () => { + if (!rejectTarget) return + const reason = rejectReason.trim() + + if (reason.length < 3) { + addToast({ title: 'دلیل رد باید حداقل ۳ کاراکتر باشد', color: 'warning' }) + + return + } + + const succeeded = await runAction( + rejectTarget.id, + () => axiosInstance.patch(API_ROUTES.BANK_ACCOUNTS.ADMIN_REJECT(rejectTarget.id), { reason }), + 'حساب بانکی رد شد' + ) + + if (succeeded) { + setRejectTarget(null) + setRejectReason('') + } + } + + return ( +
+ +
+ + {{ + userId: (row) => { + const account = row as BankAccountRow + + return ( +
+ {formatPersonName(account.user?.firstName, account.user?.lastName)} + {account.user?.mobile ? {account.user.mobile} : null} +
+ ) + }, + ownerType: (_row, cellValue) => (coerceToString(cellValue) === 'organizer' ? 'میزبان' : 'مهمان'), + iban: (_row, cellValue) => ( + + {coerceToString(cellValue)} + + ), + jibitMatched: (row) => { + const account = row as BankAccountRow + + if (account.jibitErrorCode) return {account.jibitErrorCode} + if (account.jibitMatched === true) return مطابق + if (account.jibitMatched === false) return عدم تطابق + + return استعلام نشده + }, + verificationStatus: (row, cellValue) => { + const account = row as BankAccountRow + const { label, chipColor } = getBankAccountVerificationStatus(coerceToString(cellValue)) + + return ( + + ) + }, + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => { + const account = row as BankAccountRow + const isBusy = pendingId === account.id + const isPending = account.verificationStatus === 'pending_review' + + return ( + + + {isPending ? ( + <> + + + + + ) : null} + + ) + }, + }} +
+
+ + { + void handleReject() + }} + onOpenChange={(open) => { + if (!open && !isRejecting) { + setRejectTarget(null) + setRejectReason('') + } + }} + > + { + setRejectReason(coerceToString(next)) + }} + /> + +
+ ) +} + +export default BankAccountsPage diff --git a/app/(dashboard)/blog-articles/_components/ArticleFeaturedImageUploader.tsx b/app/(dashboard)/blog-articles/_components/ArticleFeaturedImageUploader.tsx new file mode 100644 index 0000000..d42c82f --- /dev/null +++ b/app/(dashboard)/blog-articles/_components/ArticleFeaturedImageUploader.tsx @@ -0,0 +1,98 @@ +'use client' + +import { useState } from 'react' +import Image from 'next/image' + +import { addToast } from '@/lib/toast' +import Button from '@/components/formElements/Button' +import TrashIcon from '@/components/icons/TrashIcon' +import ImageUploadIcon from '@/components/icons/ImageUploadIcon' +import axiosInstance from '@/config/axios' +import { parseUploadedFile } from '@/helpers' +import { prepareImageForUpload, SAFE_IMAGE_ACCEPT } from '@/lib/fileValidation' + +interface ArticleFeaturedImageUploaderProps { + value: string + onChange: (url: string) => void +} + +const ArticleFeaturedImageUploader = ({ value, onChange }: ArticleFeaturedImageUploaderProps) => { + const [uploading, setUploading] = useState(false) + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + + e.target.value = '' + if (!file) return + + const prepared = await prepareImageForUpload(file) + + if (typeof prepared === 'string') { + addToast({ title: prepared, color: 'danger' }) + + return + } + + setUploading(true) + + try { + const data = new FormData() + + data.append('file', prepared) + + const result = await axiosInstance.post('/uploads', data) + const uploaded = parseUploadedFile(result.data) + + if (!uploaded) throw new Error('آپلود ناموفق بود') + + onChange(uploaded.url) + } catch { + addToast({ title: 'آپلود تصویر ناموفق بود', color: 'danger' }) + } finally { + setUploading(false) + } + } + + if (value) { + return ( +
+ + +
+ ) + } + + return ( + + ) +} + +export default ArticleFeaturedImageUploader diff --git a/app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx b/app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx new file mode 100644 index 0000000..fd4df50 --- /dev/null +++ b/app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx @@ -0,0 +1,433 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect, useState } from 'react' +import dynamic from 'next/dynamic' +import { FormProvider, useFormContext, useWatch } from 'react-hook-form' + +import { addToast } from '@/lib/toast' +import { Accordion, AccordionItem } from '@/components/heroui/Accordion' +import ArticleFeaturedImageUploader from '@/app/(dashboard)/blog-articles/_components/ArticleFeaturedImageUploader' +import Modal from '@/components/modals/Modal' +import Button from '@/components/formElements/Button' +import Input from '@/components/formElements/Input' +import { AdminFormSection } from '@/components/forms/AdminFormLayout' +import { SeoCharCounterHint } from '@/components/forms/SeoCharCounterHint' +import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator' +import useAdminCrudFormModal from '@/hooks/useAdminCrudFormModal' +import { type BlogArticle, CREATE_ARTICLE, UPDATE_ARTICLE } from '@/services/blogArticles' +import { fetchAllCities, type City } from '@/services/geography' +import { LIST_PUBLIC_CATEGORIES } from '@/services/eventCategories' +import { + analyzeArticleBody, + ARTICLE_EXCERPT_MAX, + ARTICLE_EXCERPT_MIN, + ARTICLE_META_DESCRIPTION_MAX, + ARTICLE_META_DESCRIPTION_MIN, + ARTICLE_META_TITLE_MAX, + ARTICLE_TITLE_MAX, + ArticleFormValidation, + BLOG_ARTICLE_CATEGORIES, + type ArticleFormValues, +} from '@/validation/blogArticles' + +const TextEditor = dynamic(() => import('@/components/formElements/TextEditor'), { ssr: false }) + +const NO_CITY_OPTION = { id: '', name: 'بدون شهر خاص' } +const NO_EVENT_CATEGORY_OPTION = { id: '', name: 'بدون دسته‌بندی رویداد خاص' } + +const EMPTY_VALUES: ArticleFormValues = { + slug: '', + title: '', + excerpt: '', + metaTitle: '', + metaDescription: '', + categorySlug: 'attendee-guide', + categoryName: BLOG_ARTICLE_CATEGORIES[0].name, + cityId: '', + eventCategoryId: '', + bodyHtml: '', + featuredImageUrl: '', + isFeatured: false, + isPublished: false, + scheduledDate: '', + scheduledTime: 0, +} + +const splitScheduledAt = (value: string | null | undefined) => { + if (!value) return { scheduledDate: '', scheduledTime: 0 } + + const date = new Date(value) + + return { + scheduledDate: Number.isFinite(date.getTime()) ? date.toISOString() : '', + scheduledTime: Number.isFinite(date.getTime()) ? date.getHours() * 60 + date.getMinutes() : 0, + } +} + +const combineScheduledAt = (dateValue: string, minutes: number) => { + const date = new Date(dateValue) + + date.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0) + + return date.toISOString() +} + +const toFormValues = (article: BlogArticle): ArticleFormValues => ({ + slug: article.slug, + title: article.title, + excerpt: article.excerpt, + metaTitle: article.metaTitle ?? '', + metaDescription: article.metaDescription ?? '', + categorySlug: article.categorySlug as ArticleFormValues['categorySlug'], + categoryName: article.categoryName, + cityId: article.city ? String(article.city.id) : '', + eventCategoryId: article.eventCategory ? String(article.eventCategory.id) : '', + bodyHtml: article.bodyHtml, + featuredImageUrl: article.featuredImageUrl ?? '', + isFeatured: article.isFeatured, + isPublished: article.isPublished, + ...splitScheduledAt(article.scheduledAt), +}) + +const buildPayload = (values: ArticleFormValues) => ({ + slug: values.slug, + title: values.title, + excerpt: values.excerpt, + metaTitle: values.metaTitle || null, + metaDescription: values.metaDescription || null, + categorySlug: values.categorySlug, + categoryName: values.categoryName, + cityId: values.cityId ? Number(values.cityId) : null, + eventCategoryId: values.eventCategoryId ? Number(values.eventCategoryId) : null, + bodyHtml: values.bodyHtml, + featuredImageUrl: values.featuredImageUrl || null, + isFeatured: values.isFeatured, + isPublished: values.isPublished, + scheduledAt: !values.isPublished && values.scheduledDate ? combineScheduledAt(values.scheduledDate, values.scheduledTime) : null, +}) + +/** Keeps categoryName in sync with the selected categorySlug — categoryName + * is stored on the row for display convenience but is never a field the + * admin edits directly, so it can't drift from the taxonomy label. */ +const CategoryNameSync = () => { + const { setValue } = useFormContext() + const categorySlug = useWatch({ name: 'categorySlug' }) + + useEffect(() => { + const match = BLOG_ARTICLE_CATEGORIES.find((item) => item.code === categorySlug) + + if (match) setValue('categoryName', match.name) + }, [categorySlug, setValue]) + + return null +} + +const ArticleBodyField = () => { + const { setValue } = useFormContext() + const bodyHtml = useWatch({ name: 'bodyHtml' }) + const analysis = analyzeArticleBody(bodyHtml) + const checks = [ + { ok: analysis.wordCount >= 250, label: `${analysis.wordCount.toLocaleString('fa-IR')} واژه از حداقل ۲۵۰ واژه` }, + { ok: analysis.headingCount >= 2, label: `${analysis.headingCount.toLocaleString('fa-IR')} تیتر H2/H3 از حداقل ۲ تیتر` }, + { ok: analysis.internalLinkCount >= 1, label: 'حداقل یک لینک داخلی مرتبط' }, + { ok: analysis.imagesWithoutAlt === 0, label: 'تمام تصاویر داخل متن دارای alt هستند' }, + ] + + return ( +
+ { + setValue('bodyHtml', value, { shouldDirty: true }) + }} + /> +
+ {checks.map((check) => ( +
+ {check.ok ? '✓' : '○'} {check.label} +
+ ))} +
+
+ ) +} + +const FeaturedImageField = () => { + const { setValue } = useFormContext() + const featuredImageUrl = useWatch({ name: 'featuredImageUrl' }) + + return ( + { + setValue('featuredImageUrl', url, { shouldDirty: true }) + }} + /> + ) +} + +const ScheduledPublishingFields = () => { + const { setValue } = useFormContext() + const isPublished = useWatch({ name: 'isPublished' }) + const scheduledDate = useWatch({ name: 'scheduledDate' }) + + if (isPublished) return null + + return ( +
+

+ برای نگه‌داشتن مقاله به‌صورت پیش‌نویس، تاریخ را خالی بگذارید. با انتخاب تاریخ و ساعت، مقاله خودکار منتشر می‌شود. +

+
+ + +
+ {scheduledDate ? ( + + ) : null} +
+ ) +} + +interface ArticleFormModalProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void + article?: BlogArticle + onSuccess: () => void +} + +const ArticleFormModal = ({ isOpen, onOpenChange, article, onSuccess }: ArticleFormModalProps) => { + const [cities, setCities] = useState([]) + const [eventCategories, setEventCategories] = useState<{ id: number; name: string }[]>([]) + + const { form, isEdit, submitting, handleSubmit } = useAdminCrudFormModal({ + entity: article, + isOpen, + emptyValues: EMPTY_VALUES, + toFormValues, + resolver: zodResolver(ArticleFormValidation), + buildPayload, + create: CREATE_ARTICLE, + update: UPDATE_ARTICLE, + getId: (entity) => entity.id, + successMessage: { create: 'مقاله ایجاد شد', edit: 'مقاله ویرایش شد' }, + onOpenChange, + onSuccess, + }) + + useEffect(() => { + fetchAllCities() + .then(setCities) + .catch(() => addToast({ title: 'بارگذاری فهرست شهرها ناموفق بود', color: 'danger' })) + + void LIST_PUBLIC_CATEGORIES().then((result) => { + if (result.ok) setEventCategories(result.data.items) + }) + }, []) + + const cityOptions = [NO_CITY_OPTION, ...cities.map((city) => ({ id: String(city.id), name: city.name }))] + const eventCategoryOptions = [ + NO_EVENT_CATEGORY_OPTION, + ...eventCategories.map((category) => ({ id: String(category.id), name: category.name })), + ] + + return ( + + +
+ + + + +
+ + max={ARTICLE_TITLE_MAX} + name="title" + /> + } + generalType="input" + label="عنوان" + name="title" + placeholder="راهنمای پیدا کردن رویداد در ..." + /> + + + max={ARTICLE_EXCERPT_MAX} + min={ARTICLE_EXCERPT_MIN} + name="excerpt" + /> + } + generalType="textarea" + label="خلاصه (توضیح در فهرست و meta description)" + name="excerpt" + /> +
+ []} + selectValue="name" + /> + + +
+
+
+ + + + +
+ + max={ARTICLE_META_TITLE_MAX} + name="metaTitle" + /> + } + generalType="input" + label="عنوان SEO" + name="metaTitle" + /> + + max={ARTICLE_META_DESCRIPTION_MAX} + min={ARTICLE_META_DESCRIPTION_MIN} + name="metaDescription" + /> + } + generalType="textarea" + label="توضیحات SEO" + name="metaDescription" + /> +
+
+
+
+ + + + + + + + + + +
+ + + +
+
+ +
+
+ ) +} + +export default ArticleFormModal diff --git a/app/(dashboard)/blog-articles/page.tsx b/app/(dashboard)/blog-articles/page.tsx new file mode 100644 index 0000000..6a4aa42 --- /dev/null +++ b/app/(dashboard)/blog-articles/page.tsx @@ -0,0 +1,146 @@ +'use client' + +import { useRef, useState } from 'react' +import dynamic from 'next/dynamic' + +import type { PaginationListColumnType } from '@/types' +import { addToast } from '@/lib/toast' +import Button from '@/components/formElements/Button' +import PaginatedList, { type PaginatedListHandle } from '@/components/PaginatedList' +import PageNavbar from '@/components/layouts/PageNavbar' +import StatusChip from '@/components/ui/StatusChip' +import EditIconOutline from '@/components/icons/EditIconOutline' +import TrashIcon from '@/components/icons/TrashIcon' +import useAlertModal from '@/hooks/useAlertModal' +import { getFeaturedStatus, getPublishStatus } from '@/constants/status' +import { formatPersianDate } from '@/lib/formatters' +import { API_ROUTES } from '@/services/config' +import { type BlogArticle, DELETE_ARTICLE, GET_ARTICLE } from '@/services/blogArticles' + +const ArticleFormModal = dynamic(() => import('@/app/(dashboard)/blog-articles/_components/ArticleFormModal'), { ssr: false }) + +interface ArticleRow { + id: number + title: string + slug: string + categoryName: string + isPublished: boolean + isFeatured: boolean + scheduledAt: string | null + updatedAt: string + [key: string]: unknown +} + +const columns: PaginationListColumnType[] = [ + { field: 'title', label: 'عنوان', filterable: true, sortable: true, type: 'text' }, + { field: 'categoryName', label: 'دسته‌بندی', filterable: false, sortable: false }, + { field: 'isPublished', label: 'وضعیت', filterable: false, sortable: false }, + { field: 'isFeatured', label: 'ویژه', filterable: false, sortable: false }, + { field: 'updatedAt', label: 'آخرین به‌روزرسانی', filterable: false, sortable: true }, + { field: 'actions', label: 'عملیات' }, +] + +const BlogArticlesAdminPage = () => { + const listRef = useRef(null) + const { showAlert } = useAlertModal() + const [modalState, setModalState] = useState<{ article?: BlogArticle } | null>(null) + + const openCreate = () => { + setModalState({}) + } + + const openEdit = async (id: number) => { + const result = await GET_ARTICLE(id) + + if (!result.ok) return + setModalState({ article: result.data }) + } + + const handleDelete = (row: ArticleRow) => { + showAlert(`مقاله «${row.title}» حذف شود؟`, async () => { + const result = await DELETE_ARTICLE(row.id) + + if (!result.ok) return + addToast({ title: 'مقاله حذف شد', color: 'success' }) + listRef.current?.refresh() + }) + } + + return ( +
+ +
+ + {{ + isPublished: (row) => { + const article = row as ArticleRow + + return ( + + ) + }, + isFeatured: (row) => { + const featured = getFeaturedStatus((row as ArticleRow).isFeatured) + + return featured ? : '—' + }, + updatedAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => { + const article = row as ArticleRow + + return ( +
+ + +
+ ) + }, + }} +
+
+ + {modalState ? ( + { + if (!open) setModalState(null) + }} + onSuccess={() => listRef.current?.refresh()} + /> + ) : null} +
+ ) +} + +export default BlogArticlesAdminPage diff --git a/app/(dashboard)/bookings/page.tsx b/app/(dashboard)/bookings/page.tsx new file mode 100644 index 0000000..13df326 --- /dev/null +++ b/app/(dashboard)/bookings/page.tsx @@ -0,0 +1,148 @@ +'use client' + +import type { PaginationListColumnType } from '@/types' +import PaginatedList from '@/components/PaginatedList' +import StatusChip from '@/components/ui/StatusChip' +import PageNavbar from '@/components/layouts/PageNavbar' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import { APP_ROUTES } from '@/constants/routes' +import { formatPersonName, coerceToString } from '@/helpers' +import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' +import { BOOKING_STATUS_FILTER_ITEMS, getBookingStatus } from '@/constants/status' +import { API_ROUTES } from '@/services/config' + +interface BookingUser { + id: string + mobile: string + firstName: string | null + lastName: string | null +} + +interface BookingEvent { + id: string + title: string + startsAt: string + status: string + organizerId: string +} + +const columns: PaginationListColumnType[] = [ + { + field: 'bookingCode', + label: 'کد رزرو', + filterable: false, + sortable: false, + type: 'text', + }, + { + field: 'userId', + label: 'مهمان', + filterable: true, + sortable: false, + type: 'text', + }, + { + field: 'eventId', + label: 'رویداد', + filterable: true, + sortable: false, + type: 'text', + }, + { + field: 'status', + label: 'وضعیت', + filterable: true, + sortable: true, + type: 'select', + filterItems: BOOKING_STATUS_FILTER_ITEMS, + }, + { + field: 'checkedInAt', + label: 'چک‌این', + filterable: false, + sortable: false, + type: 'date', + }, + { + field: 'createdAt', + label: 'تاریخ ثبت', + filterable: false, + sortable: true, + type: 'date', + }, + { + field: 'actions', + label: 'عملیات', + }, +] + +// Platform-wide, read-only admin bookings list. No write actions here by +// design — cancel/check-in/refund stay on their existing flows; the view +// button jumps to the guest's user-detail page (رزروها tab). +const BookingsPage = () => { + return ( +
+ +
+ + {{ + userId: (row) => { + const user = row.user as BookingUser | undefined + + if (!user) return '—' + + const name = formatPersonName(user.firstName ?? undefined, user.lastName ?? undefined) + + return ( +
+ {name} + + {formatIranianMobile(user.mobile)} + +
+ ) + }, + eventId: (row) => { + const event = row.event as BookingEvent | undefined + + return event?.title ?? '—' + }, + status: (_row, cellValue) => { + const { label, chipColor } = getBookingStatus(coerceToString(cellValue)) + + return ( + + ) + }, + checkedInAt: (_row, cellValue) => formatPersianDate(cellValue), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => { + const user = row.user as BookingUser | undefined + + if (!user) return '—' + + return ( + + ) + }, + }} +
+
+
+ ) +} + +export default BookingsPage diff --git a/app/(dashboard)/chat-oversight/[id]/_components/AdminMessageBubble.test.tsx b/app/(dashboard)/chat-oversight/[id]/_components/AdminMessageBubble.test.tsx new file mode 100644 index 0000000..b6bd413 --- /dev/null +++ b/app/(dashboard)/chat-oversight/[id]/_components/AdminMessageBubble.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import type { AdminChatMessage } from '@/services/adminChat' + +import AdminMessageBubble from './AdminMessageBubble' + +vi.mock('@/app/(dashboard)/chat-oversight/[id]/_components/ChatImagePreviewModal', () => ({ + default: ({ imageUrl, isOpen }: { imageUrl: string | null; isOpen: boolean }) => + isOpen ?
{imageUrl}
: null, +})) + +const message: AdminChatMessage = { + id: 'message-1', + conversationId: 'conversation-1', + senderId: 'user-1', + senderMobile: '09121234567', + senderFirstName: 'سارا', + senderLastName: 'احمدی', + senderAvatarUrl: null, + senderGender: 'female', + body: 'سلام، این یک پیام آزمایشی است.', + imageUrl: 'https://example.com/message.jpg', + createdAt: '2026-08-01T12:30:00.000Z', +} + +describe('AdminMessageBubble', () => { + it('shows oversight identity and opens the shared image preview', () => { + render( + + ) + + expect(screen.getByText('سارا احمدی')).toBeInTheDocument() + expect(screen.getByText('سلام، این یک پیام آزمایشی است.')).toBeInTheDocument() + expect(screen.getByText(/0912/)).toBeInTheDocument() + expect(screen.queryByTestId('image-preview')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'مشاهده تصویر' })) + + expect(screen.getByTestId('image-preview')).toHaveTextContent(message.imageUrl ?? '') + }) +}) diff --git a/app/(dashboard)/chat-oversight/[id]/_components/AdminMessageBubble.tsx b/app/(dashboard)/chat-oversight/[id]/_components/AdminMessageBubble.tsx new file mode 100644 index 0000000..2f15f60 --- /dev/null +++ b/app/(dashboard)/chat-oversight/[id]/_components/AdminMessageBubble.tsx @@ -0,0 +1,131 @@ +'use client' + +import { useState } from 'react' + +import ChatImagePreviewModal from '@/app/(dashboard)/chat-oversight/[id]/_components/ChatImagePreviewModal' +import Button from '@/components/formElements/Button' +import { formatPersonName } from '@/helpers' +import { cn } from '@/lib/cn' +import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' +import { resolveUserAvatarSrc } from '@/lib/resolveUserAvatarSrc' +import type { AdminChatMessage } from '@/services/adminChat' + +interface AdminMessageBubbleProps { + message: AdminChatMessage + side: 'primary' | 'secondary' + showAvatar: boolean + showSenderName: boolean + isClusterStart: boolean + isClusterEnd: boolean +} + +const formatTime = (value: string) => + formatPersianDate(value, { + hour: '2-digit', + minute: '2-digit', + }) + +const AdminMessageBubble = ({ message, side, showAvatar, showSenderName, isClusterStart, isClusterEnd }: AdminMessageBubbleProps) => { + const [previewImageUrl, setPreviewImageUrl] = useState(null) + const isPrimary = side === 'primary' + const senderName = formatPersonName(message.senderFirstName, message.senderLastName, 'کاربر') + const senderMeta = message.senderMobile ? formatIranianMobile(message.senderMobile) : message.senderId + const avatarSrc = resolveUserAvatarSrc({ + avatarUrl: message.senderAvatarUrl, + gender: message.senderGender, + }) + + return ( +
+
+
+ {showAvatar ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ ) : ( + + )} +
+ +
+ {showSenderName ? ( +
+

{senderName}

+

+ {senderMeta} +

+
+ ) : null} + + {message.body ?

{message.body}

: null} + {message.imageUrl ? ( + + ) : null} +
{formatTime(message.createdAt)}
+
+
+ + { + if (!open) setPreviewImageUrl(null) + }} + /> +
+ ) +} + +export default AdminMessageBubble diff --git a/app/(dashboard)/chat-oversight/[id]/_components/ChatImagePreviewModal.tsx b/app/(dashboard)/chat-oversight/[id]/_components/ChatImagePreviewModal.tsx new file mode 100644 index 0000000..c054e7c --- /dev/null +++ b/app/(dashboard)/chat-oversight/[id]/_components/ChatImagePreviewModal.tsx @@ -0,0 +1,139 @@ +'use client' + +/** + * ChatImagePreviewModal — full-screen-ish preview + download for chat images. + * + * Purpose + * - Opens from `MessageBubble`; uses ConsumerModal. Download URL resolution + * stays local (guess filename / trusted upload URL). + */ + +import Image from 'next/image' +import { useState } from 'react' + +import ConsumerButton from '@/components/consumer/ConsumerButton' +import CloseIcon from '@/components/icons/CloseIcon' +import CloudDownloadIcon from '@/components/icons/CloudDownloadIcon' +import ConsumerModal from '@/components/consumer/ConsumerModal' +import { withBasePath } from '@/constants/images' +import { texts } from '@/texts' +import { addToast } from '@/lib/toast' + +interface ChatImagePreviewModalProps { + imageUrl: string | null + isOpen: boolean + onOpenChange: (open: boolean) => void +} + +const guessFileName = (imageUrl: string) => { + try { + const pathname = new URL(imageUrl, window.location.origin).pathname + const base = pathname.split('/').filter(Boolean).at(-1) + + if (base && /\.[a-z0-9]{2,5}$/i.test(base)) return decodeURIComponent(base) + } catch { + // ignore invalid URL + } + + return `chat-image-${Date.now()}.jpg` +} + +const resolveDownloadUrl = (imageUrl: string) => { + try { + const target = new URL(imageUrl, window.location.origin) + + if (target.origin === window.location.origin) return target.toString() + } catch { + return imageUrl + } + + // Cross-origin file hosts (e.g. ghabilee.ir from localhost) often omit CORS + // for /uploads — proxy through Next so the browser sees a same-origin blob. + return `${withBasePath('/api/download-upload')}?url=${encodeURIComponent(imageUrl)}` +} + +const ChatImagePreviewModal = ({ imageUrl, isOpen, onOpenChange }: ChatImagePreviewModalProps) => { + const [isDownloading, setIsDownloading] = useState(false) + + const handleDownload = async () => { + if (!imageUrl || isDownloading) return + setIsDownloading(true) + + try { + const response = await fetch(resolveDownloadUrl(imageUrl)) + + if (!response.ok) throw new Error('download failed') + + const blob = await response.blob() + const objectUrl = URL.createObjectURL(blob) + const anchor = document.createElement('a') + + anchor.href = objectUrl + anchor.download = guessFileName(imageUrl) + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(objectUrl) + } catch { + window.open(imageUrl, '_blank', 'noopener,noreferrer') + addToast({ title: texts.chats.downloadFallbackToast, color: 'warning' }) + } finally { + setIsDownloading(false) + } + } + + return ( + +
+
+ { + onOpenChange(false) + }} + > + + + : undefined} + isLoading={isDownloading} + onClick={() => void handleDownload()} + > + {texts.chats.download} + +
+ +
+ {imageUrl ? ( + {texts.chats.chatAttachmentAlt} + ) : null} +
+
+
+ ) +} + +export default ChatImagePreviewModal diff --git a/app/(dashboard)/chat-oversight/[id]/_components/DateSeparator.tsx b/app/(dashboard)/chat-oversight/[id]/_components/DateSeparator.tsx new file mode 100644 index 0000000..95d27e5 --- /dev/null +++ b/app/(dashboard)/chat-oversight/[id]/_components/DateSeparator.tsx @@ -0,0 +1,13 @@ +interface DateSeparatorProps { + label: string +} + +const DateSeparator = ({ label }: DateSeparatorProps) => ( +
+ + {label} + +
+) + +export default DateSeparator diff --git a/app/(dashboard)/chat-oversight/[id]/page.tsx b/app/(dashboard)/chat-oversight/[id]/page.tsx new file mode 100644 index 0000000..170b4f3 --- /dev/null +++ b/app/(dashboard)/chat-oversight/[id]/page.tsx @@ -0,0 +1,238 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { useParams } from 'next/navigation' + +import DateSeparator from '@/app/(dashboard)/chat-oversight/[id]/_components/DateSeparator' +import Button from '@/components/formElements/Button' +import AdminState from '@/components/feedback/AdminState' +import { DetailSkeleton, ListSkeleton } from '@/components/feedback/LoadingState' +import PageNavbar from '@/components/layouts/PageNavbar' +import StatusChip from '@/components/ui/StatusChip' +import { APP_ROUTES } from '@/constants/routes' +import { groupMessagesForDisplay } from '@/features/chat/groupMessagesForDisplay' +import { formatPersonName } from '@/helpers' +import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' +import useAdminChatSocket from '@/hooks/useAdminChatSocket' +import { + GET_ADMIN_CONVERSATION, + GET_ADMIN_CONVERSATION_MESSAGES, + GET_ADMIN_CONVERSATION_PARTICIPANTS, + type AdminChatMessage, +} from '@/services/adminChat' + +import AdminMessageBubble from './_components/AdminMessageBubble' + +const ChatOversightDetailPage = () => { + const params = useParams<{ id: string }>() + const conversationId = params.id + const [liveMessages, setLiveMessages] = useState([]) + const handleLiveMessage = useCallback((message: AdminChatMessage) => { + setLiveMessages((current) => (current.some((item) => item.id === message.id) ? current : [...current, message])) + }, []) + const socket = useAdminChatSocket(conversationId, handleLiveMessage) + const conversationQuery = useQuery({ + queryKey: ['admin', 'chat-oversight', conversationId], + queryFn: () => GET_ADMIN_CONVERSATION(conversationId), + enabled: Boolean(conversationId), + }) + const participantsQuery = useQuery({ + queryKey: ['admin', 'chat-oversight', conversationId, 'participants'], + queryFn: () => GET_ADMIN_CONVERSATION_PARTICIPANTS(conversationId), + enabled: Boolean(conversationId), + }) + const messagesQuery = useInfiniteQuery({ + queryKey: ['admin', 'chat-oversight', conversationId, 'messages'], + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam }) => GET_ADMIN_CONVERSATION_MESSAGES(conversationId, pageParam ? { before: pageParam } : undefined), + getNextPageParam: (lastPage) => (lastPage.hasMoreBefore ? (lastPage.olderCursor ?? undefined) : undefined), + enabled: Boolean(conversationId), + }) + + const messages = useMemo(() => { + const seen = new Set() + + const history = [...(messagesQuery.data?.pages ?? [])].reverse().flatMap((page) => page.items) + + return [...history, ...liveMessages].filter((message) => { + if (seen.has(message.id)) return false + seen.add(message.id) + + return true + }) + }, [liveMessages, messagesQuery.data]) + + const displayItems = useMemo(() => groupMessagesForDisplay(messages, { showSenderNames: true }), [messages]) + + const conversation = conversationQuery.data + const participants = useMemo(() => participantsQuery.data ?? [], [participantsQuery.data]) + const participantSideById = useMemo( + () => new Map(participants.map((participant, index) => [participant.userId, index % 2 === 0 ? 'primary' : 'secondary'] as const)), + [participants] + ) + const title = conversation + ? conversation.type === 'event_group' + ? conversation.eventTitle || 'گروه رویداد' + : participants + .map((participant) => formatPersonName(participant.firstName, participant.lastName, formatIranianMobile(participant.mobile))) + .join(' و ') || 'گفتگوی خصوصی' + : 'جزئیات گفتگو' + const loadError = conversationQuery.error ?? participantsQuery.error ?? messagesQuery.error + const isInitialLoading = conversationQuery.isLoading || participantsQuery.isLoading || messagesQuery.isLoading + + return ( +
+ + بازگشت به گفتگوها + + } + pageTitle={title} + /> + +
+
+
+

حالت نظارت — فقط خواندنی

+

+ مشاهده شما عضویت، اعلان یا تغییر وضعیت خوانده‌شدن برای کاربران ایجاد نمی‌کند و در گزارش فعالیت ادمین ثبت می‌شود. +

+
+ {conversation ? ( +
+ + +
+ ) : null} +
+ + {socket.error ? ( +

+ {socket.error} پیام‌های ذخیره‌شده همچنان از طریق API قابل مشاهده‌اند. +

+ ) : null} + + {isInitialLoading ? ( + <> + + + + ) : null} + + {!isInitialLoading && loadError ? ( +
+ { + void conversationQuery.refetch() + void participantsQuery.refetch() + void messagesQuery.refetch() + }} + /> +
+ ) : null} + + {!isInitialLoading && !loadError && conversation ? ( + <> +
+
+

اعضای گفتگو

+ {conversation.memberCount.toLocaleString('fa-IR')} نفر +
+
+ {participants.map((participant) => ( +
+

{formatPersonName(participant.firstName, participant.lastName, 'کاربر')}

+

+ {formatIranianMobile(participant.mobile)} +

+
+ ))} +
+
+ +
+
+

پیام‌های گفتگو

+ {conversation.lastMessageAt ? ( + آخرین فعالیت: {formatPersianDate(conversation.lastMessageAt)} + ) : null} +
+ + {messagesQuery.hasNextPage ? ( +
+ +
+ ) : null} + + {messages.length === 0 ? ( + + ) : ( +
+
+ {displayItems.map((item) => { + if (item.kind === 'date') { + return ( + + ) + } + + return ( + + ) + })} +
+
+ )} +
+ + ) : null} +
+
+ ) +} + +export default ChatOversightDetailPage diff --git a/app/(dashboard)/chat-oversight/page.tsx b/app/(dashboard)/chat-oversight/page.tsx new file mode 100644 index 0000000..19c2162 --- /dev/null +++ b/app/(dashboard)/chat-oversight/page.tsx @@ -0,0 +1,134 @@ +'use client' + +import type { PaginationListColumnType } from '@/types' +import PaginatedList from '@/components/PaginatedList' +import PageNavbar from '@/components/layouts/PageNavbar' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import StatusChip from '@/components/ui/StatusChip' +import { APP_ROUTES } from '@/constants/routes' +import { formatPersonName, coerceToString } from '@/helpers' +import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters' +import { API_ROUTES } from '@/services/config' +import type { AdminConversation } from '@/services/adminChat' + +const CONVERSATION_TYPE_FILTER_ITEMS = [ + { code: 'event_group', name: 'گروه رویداد' }, + { code: 'direct', name: 'گفتگوی خصوصی' }, +] + +const columns: PaginationListColumnType[] = [ + { + field: 'type', + label: 'نوع گفتگو', + filterable: true, + sortable: false, + type: 'select', + filterItems: CONVERSATION_TYPE_FILTER_ITEMS, + }, + { + field: 'search', + label: 'رویداد یا کاربران', + filterable: true, + sortable: false, + type: 'text', + }, + { + field: 'eventId', + label: 'شناسه رویداد', + filterable: true, + hideInTable: true, + sortable: false, + type: 'text', + }, + { + field: 'userId', + label: 'شناسه کاربر', + filterable: true, + hideInTable: true, + sortable: false, + type: 'text', + }, + { + field: 'preview', + label: 'آخرین پیام', + filterable: false, + sortable: false, + }, + { + field: 'memberCount', + label: 'اعضا', + filterable: false, + sortable: false, + }, + { + field: 'lastMessageAt', + label: 'آخرین فعالیت', + filterable: false, + sortable: true, + }, + { + field: 'createdAt', + label: 'تاریخ ایجاد', + filterable: true, + sortable: true, + type: 'dateFromTo', + }, + { field: 'actions', label: 'عملیات' }, +] + +const participantLabel = (conversation: AdminConversation) => { + if (conversation.type === 'event_group') return conversation.eventTitle || 'گروه رویداد' + + return conversation.participantPreview + .map((participant) => formatPersonName(participant.firstName, participant.lastName, formatIranianMobile(participant.mobile))) + .join(' و ') +} + +const ChatOversightPage = () => ( +
+ +
+
+ این بخش فقط برای نظارت است. حضور ادمین در اعضای گفتگو نمایش داده نمی‌شود و امکان ارسال یا تغییر پیام وجود ندارد. +
+ + {{ + type: (row) => { + const conversation = row as unknown as AdminConversation + + return ( + + ) + }, + search: (row) => participantLabel(row as unknown as AdminConversation), + preview: (_row, value) => truncateValue(coerceToString(value, '—')), + memberCount: (_row, value) => Number(value ?? 0).toLocaleString('fa-IR'), + lastMessageAt: (_row, value) => (value ? formatPersianDate(value) : 'بدون پیام'), + createdAt: (_row, value) => formatPersianDate(value), + actions: (row) => { + const conversation = row as unknown as AdminConversation + + return ( + + ) + }, + }} + +
+
+) + +export default ChatOversightPage diff --git a/app/(dashboard)/cities/page.tsx b/app/(dashboard)/cities/page.tsx new file mode 100644 index 0000000..38a6a0d --- /dev/null +++ b/app/(dashboard)/cities/page.tsx @@ -0,0 +1,99 @@ +'use client' + +import { useEffect, useState } from 'react' + +import type { PaginationListColumnType } from '@/types' +import PaginatedList from '@/components/PaginatedList' +import PageNavbar from '@/components/layouts/PageNavbar' +import { TableSkeleton } from '@/components/feedback/LoadingState' +import { fetchAllCities, type City } from '@/services/geography' + +const columns: PaginationListColumnType[] = [ + { + field: 'name', + label: 'نام شهر', + filterable: false, + type: 'text', + sortable: false, + }, + // ستون استان فعلاً نمایش داده نمی‌شود؛ مدیریت موقعیت فقط بر اساس شهر است. + // { + // field: 'provinceId', + // label: 'شناسه استان', + // filterable: false, + // type: 'number', + // sortable: false, + // }, + { + field: 'lat', + label: 'عرض جغرافیایی', + filterable: false, + type: 'number', + sortable: false, + }, + { + field: 'lng', + label: 'طول جغرافیایی', + filterable: false, + type: 'number', + sortable: false, + }, +] + +const CitiesPage = () => { + const [cities, setCities] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + let isMounted = true + + const loadCities = async () => { + setIsLoading(true) + try { + const rows = await fetchAllCities() + + if (isMounted) setCities(rows) + } finally { + if (isMounted) setIsLoading(false) + } + } + + void loadCities() + + return () => { + isMounted = false + } + }, []) + + return ( +
+ +
+ {/* + GET /geography/cities and GET /geography/provinces/:id/cities are + both flat/unpaginated, with no server-side filtering -- this + native select re-fetches a different flat list on change instead + of relying on PaginatedList's (server-driven) filter row, which + has nothing to talk to here. Table columns are non-filterable/ + non-sortable for the same reason. The pre-disable province-filter + dropdown (a native + + + +
+ + +
+
+ + + + + +
+ + max={CATEGORY_META_TITLE_MAX} + name="metaTitle" + /> + } + generalType="input" + label="عنوان SEO" + name="metaTitle" + placeholder="مثلاً «ورکشاپ‌ها | قبیله»" + /> + + max={CATEGORY_META_DESCRIPTION_MAX} + name="metaDescription" + /> + } + generalType="textarea" + label="توضیحات SEO" + name="metaDescription" + placeholder="یک یا دو جمله برای نمایش در نتایج گوگل" + /> + + max={CATEGORY_META_KEYWORDS_MAX} + name="metaKeywords" + /> + } + generalType="input" + label="کلمات کلیدی" + name="metaKeywords" + placeholder="ورکشاپ, آموزش, رویداد" + /> + + +
+
+
+
+ + +
+ +
+ + +
+
+
+ + + + ) +} + +export default CategoryFormModal diff --git a/app/(dashboard)/event-categories/_components/CategoryTree.test.tsx b/app/(dashboard)/event-categories/_components/CategoryTree.test.tsx new file mode 100644 index 0000000..bd83b5c --- /dev/null +++ b/app/(dashboard)/event-categories/_components/CategoryTree.test.tsx @@ -0,0 +1,49 @@ +import { StrictMode } from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import CategoryTree from '@/app/(dashboard)/event-categories/_components/CategoryTree' + +const mocks = vi.hoisted(() => ({ + deleteCategory: vi.fn(), + getCategoryChildren: vi.fn(), + showAlert: vi.fn(), +})) + +vi.mock('@/app/(dashboard)/event-categories/_components/CategoryFormModal', () => ({ default: () => null })) +vi.mock('@/app/(dashboard)/event-categories/_components/CategoryTreeNode', () => ({ + default: ({ category }: { category: { name: string } }) =>
{category.name}
, +})) +vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert: mocks.showAlert }) })) +vi.mock('@/lib/toast', () => ({ addToast: vi.fn() })) +vi.mock('@/services/eventCategories', () => ({ + DELETE_CATEGORY: mocks.deleteCategory, + GET_CATEGORY_CHILDREN: mocks.getCategoryChildren, +})) + +describe('CategoryTree', () => { + beforeEach(() => { + mocks.deleteCategory.mockReset() + mocks.getCategoryChildren.mockReset() + mocks.showAlert.mockReset() + }) + + it('loads the root once and renders it in Strict Mode', async () => { + mocks.getCategoryChildren.mockResolvedValue({ + ok: true, + data: [{ id: 1, name: 'دسته اصلی', parentId: null }], + }) + + render( + + + + ) + + expect(await screen.findByText('دسته اصلی')).toBeInTheDocument() + await waitFor(() => { + expect(mocks.getCategoryChildren).toHaveBeenCalledOnce() + }) + expect(mocks.getCategoryChildren).toHaveBeenCalledWith(null) + }) +}) diff --git a/app/(dashboard)/event-categories/_components/CategoryTree.tsx b/app/(dashboard)/event-categories/_components/CategoryTree.tsx new file mode 100644 index 0000000..4c54bd6 --- /dev/null +++ b/app/(dashboard)/event-categories/_components/CategoryTree.tsx @@ -0,0 +1,255 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +import CategoryFormModal from '@/app/(dashboard)/event-categories/_components/CategoryFormModal' +import CategoryTreeNode from '@/app/(dashboard)/event-categories/_components/CategoryTreeNode' +import { ListSkeleton } from '@/components/feedback/LoadingState' +import Button from '@/components/formElements/Button' +import PlusIcon from '@/components/icons/PlusIcon' +import useAlertModal from '@/hooks/useAlertModal' +import { addToast } from '@/lib/toast' +import { DELETE_CATEGORY, GET_CATEGORY_CHILDREN, type EventCategory } from '@/services/eventCategories' + +const ROOT_KEY = 'root' +const keyFor = (parentId: number | null) => (parentId === null ? ROOT_KEY : String(parentId)) + +type ModalState = { mode: 'create'; parentId: number | null; parentName: string | null } | { mode: 'edit'; category: EventCategory } | null + +const CategoryTree = () => { + const { showAlert } = useAlertModal() + + // Single source of truth for every level of the tree that's been + // fetched so far, keyed by parentId ('root' for top-level). A node's + // own component stays purely presentational — it just renders whatever + // slice of this state belongs to it. This makes "refresh this level + // after a mutation" a one-line re-fetch into the same key, instead of + // each node owning (and having to be told to invalidate) its own texts. + const [childrenByParent, setChildrenByParent] = useState>({}) + const [loadingKeys, setLoadingKeys] = useState>(new Set()) + const [expandedIds, setExpandedIds] = useState>(new Set()) + const [deletingIds, setDeletingIds] = useState>(new Set()) + const [modalState, setModalState] = useState(null) + const isMountedRef = useRef(true) + const inFlightLoadsRef = useRef(new Map>()) + + useEffect(() => { + isMountedRef.current = true + + return () => { + isMountedRef.current = false + } + }, []) + + const setLoading = useCallback((key: string, value: boolean) => { + setLoadingKeys((prev) => { + const next = new Set(prev) + + if (value) next.add(key) + else next.delete(key) + + return next + }) + }, []) + + const loadChildren = useCallback( + (parentId: number | null): Promise => { + const key = keyFor(parentId) + const inFlightLoad = inFlightLoadsRef.current.get(key) + + if (inFlightLoad) return inFlightLoad + if (!isMountedRef.current) return Promise.resolve([]) + + setLoading(key, true) + const load = GET_CATEGORY_CHILDREN(parentId) + .then((result) => { + if (!isMountedRef.current) return result.ok ? result.data : [] + + setLoading(key, false) + + if (!result.ok) return [] + + setChildrenByParent((prev) => ({ ...prev, [key]: result.data })) + + return result.data + }) + .finally(() => { + if (inFlightLoadsRef.current.get(key) === load) { + inFlightLoadsRef.current.delete(key) + } + }) + + inFlightLoadsRef.current.set(key, load) + + return load + }, + [setLoading] + ) + + useEffect(() => { + // Root level loads once on mount; every deeper level loads lazily + // the first time its parent is expanded (see toggleExpand below). + // Deferred to a microtask so the setState calls inside loadChildren + // don't run synchronously within the effect body itself. + queueMicrotask(() => { + void loadChildren(null) + }) + }, [loadChildren]) + + const toggleExpand = async (category: EventCategory) => { + const isExpanded = expandedIds.has(category.id) + + if (isExpanded) { + setExpandedIds((prev) => { + const next = new Set(prev) + + next.delete(category.id) + + return next + }) + + return + } + + setExpandedIds((prev) => new Set(prev).add(category.id)) + + // Cached from a previous expand — no need to hit the network again. + if (childrenByParent[keyFor(category.id)] === undefined) { + await loadChildren(category.id) + } + } + + const handleCreateSuccess = (parentId: number | null) => { + void loadChildren(parentId) + // A brand-new child obviously isn't visible unless its parent is + // expanded — force that so the organizer sees what they just made. + if (parentId !== null) { + setExpandedIds((prev) => new Set(prev).add(parentId)) + } + } + + const handleDelete = async (category: EventCategory) => { + // The backend's soft-delete has no guard against deleting a category + // that still has children (see docs/workflows/event-categories.md) — + // doing so would orphan them (their parentId would point at a + // deleted row, invisible from this tree forever after). Rather than + // ship that footgun, make sure we actually know whether this node + // has children before offering to delete it, fetching if we don't. + let kids = childrenByParent[keyFor(category.id)] + + kids ??= await loadChildren(category.id) + + if (!isMountedRef.current) return + + if (kids.length > 0) { + addToast({ + title: 'این دسته‌بندی زیردسته دارد', + description: 'برای حذف این دسته، ابتدا همه‌ی زیردسته‌های آن را حذف یا به جای دیگری منتقل کنید.', + color: 'warning', + }) + + return + } + + showAlert( + `دسته‌بندی «${category.name}» برای همیشه حذف شود؟ این عملیات قابل بازگشت نیست.`, + async () => { + setDeletingIds((prev) => new Set(prev).add(category.id)) + const result = await DELETE_CATEGORY(category.id) + + if (isMountedRef.current) { + setDeletingIds((prev) => { + const next = new Set(prev) + + next.delete(category.id) + + return next + }) + } + + if (!result.ok) return + if (!isMountedRef.current) return + + addToast({ title: 'دسته‌بندی حذف شد', color: 'success' }) + void loadChildren(category.parentId) + }, + undefined, + { dangerAccept: true } + ) + } + + const renderNode = (category: EventCategory, depth: number) => { + const isExpanded = expandedIds.has(category.id) + const key = keyFor(category.id) + + return ( +
+ { + setModalState({ mode: 'create', parentId: parent.id, parentName: parent.name }) + }} + onDelete={handleDelete} + onEdit={(cat) => { + setModalState({ mode: 'edit', category: cat }) + }} + onToggleExpand={toggleExpand} + /> + {isExpanded && childrenByParent[key]?.map((child) => renderNode(child, depth + 1))} +
+ ) + } + + const rootCategories = childrenByParent[ROOT_KEY] + const isRootLoading = loadingKeys.has(ROOT_KEY) && rootCategories === undefined + + return ( +
+
+
+

ساختار دسته‌بندی‌ها

+

دسته‌ها را باز کنید و زیردسته‌ها، ترتیب و وضعیت آن‌ها را مدیریت کنید.

+
+ +
+ +
+ {isRootLoading ? ( + + ) : !rootCategories || rootCategories.length === 0 ? ( +
هنوز هیچ دسته‌بندی‌ای ثبت نشده است.
+ ) : ( + rootCategories.map((category) => renderNode(category, 0)) + )} +
+ + { + if (!open) setModalState(null) + }} + onSuccess={() => { + if (modalState?.mode === 'create') handleCreateSuccess(modalState.parentId) + else if (modalState?.mode === 'edit') void loadChildren(modalState.category.parentId) + }} + /> +
+ ) +} + +export default CategoryTree diff --git a/app/(dashboard)/event-categories/_components/CategoryTreeNode.tsx b/app/(dashboard)/event-categories/_components/CategoryTreeNode.tsx new file mode 100644 index 0000000..0029ed9 --- /dev/null +++ b/app/(dashboard)/event-categories/_components/CategoryTreeNode.tsx @@ -0,0 +1,147 @@ +'use client' + +import type { EventCategory } from '@/services/eventCategories' +import Button from '@/components/formElements/Button' +import ChevronRightIcon from '@/components/icons/ChevronRightIcon' +import FolderSolidIcon from '@/components/icons/FolderSolidIcon' +import PlusIcon from '@/components/icons/PlusIcon' +import EditIconOutline from '@/components/icons/EditIconOutline' +import TrashIcon from '@/components/icons/TrashIcon' +import DotLoadingIcon from '@/components/icons/DotLoadingIcon' +import StatusChip from '@/components/ui/StatusChip' +import { getActiveStatus, getFeaturedStatus } from '@/constants/status' + +interface CategoryTreeNodeProps { + category: EventCategory + depth: number + isExpanded: boolean + isLoadingChildren: boolean + isDeleting: boolean + childCategories: EventCategory[] | undefined + onToggleExpand: (category: EventCategory) => void + onAddChild: (category: EventCategory) => void + onEdit: (category: EventCategory) => void + onDelete: (category: EventCategory) => void +} + +const INDENT_PER_DEPTH = 24 + +const IconButton = ({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) => ( + { + event.stopPropagation() + }} + onKeyDown={(event) => { + event.stopPropagation() + }} + > + + +) + +const CategoryTreeNode = ({ + category, + depth, + isExpanded, + isLoadingChildren, + isDeleting, + childCategories, + onToggleExpand, + onAddChild, + onEdit, + onDelete, +}: CategoryTreeNodeProps) => { + const childCount = childCategories?.length ?? 0 + const featured = getFeaturedStatus(category.isFeatured) + + return ( +
+
{ + onToggleExpand(category) + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') onToggleExpand(category) + }} + > + + {isLoadingChildren ? : } + + + {isExpanded ? ( + + ) : ( + + )} + + {category.name} + + {!category.isActive && } + {featured ? : null} + + + { + onAddChild(category) + }} + > + + + { + onEdit(category) + }} + > + + + { + onDelete(category) + }} + > + {isDeleting ? : } + + +
+ + {isExpanded && ( +
+ {isLoadingChildren && childCategories === undefined ? ( +
+ +
+ ) : childCount === 0 ? ( +
+ بدون زیردسته +
+ ) : null} +
+ )} +
+ ) +} + +export default CategoryTreeNode diff --git a/app/(dashboard)/event-categories/page.tsx b/app/(dashboard)/event-categories/page.tsx new file mode 100644 index 0000000..1707472 --- /dev/null +++ b/app/(dashboard)/event-categories/page.tsx @@ -0,0 +1,15 @@ +import CategoryTree from '@/app/(dashboard)/event-categories/_components/CategoryTree' +import PageNavbar from '@/components/layouts/PageNavbar' + +const CategoriesPage = () => { + return ( +
+ +
+ +
+
+ ) +} + +export default CategoriesPage diff --git a/app/(dashboard)/events/page.tsx b/app/(dashboard)/events/page.tsx new file mode 100644 index 0000000..202d7d8 --- /dev/null +++ b/app/(dashboard)/events/page.tsx @@ -0,0 +1,7 @@ +import { permanentRedirect } from 'next/navigation' + +import { APP_ROUTES } from '@/constants/routes' + +const LegacyAdminEventsPage = () => permanentRedirect(APP_ROUTES.MANAGE_EVENTS) + +export default LegacyAdminEventsPage diff --git a/app/(dashboard)/guest-lists/_components/GuestListItemsModal.tsx b/app/(dashboard)/guest-lists/_components/GuestListItemsModal.tsx new file mode 100644 index 0000000..30e5d46 --- /dev/null +++ b/app/(dashboard)/guest-lists/_components/GuestListItemsModal.tsx @@ -0,0 +1,102 @@ +'use client' + +import type { PaginationListColumnType } from '@/types' +import PaginatedList from '@/components/PaginatedList' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import Modal from '@/components/modals/Modal' +import { APP_ROUTES } from '@/constants/routes' +import { formatPersonName, coerceToString } from '@/helpers' +import { API_ROUTES } from '@/services/config' +import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' + +export interface GuestListItemRow { + id: string + listId: string + mobile: string + firstName: string | null + lastName: string | null + userId: string | null + note: string | null + createdAt: string +} + +interface GuestListItemsModalProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void + list: { id: string; name: string } | null +} + +const columns: PaginationListColumnType[] = [ + { + field: 'mobile', + label: 'موبایل', + filterable: false, + sortable: false, + }, + { + field: 'firstName', + label: 'نام', + filterable: false, + sortable: true, + }, + { + field: 'note', + label: 'یادداشت', + filterable: false, + sortable: false, + }, + { + field: 'createdAt', + label: 'تاریخ افزودن', + filterable: false, + sortable: true, + }, + { + field: 'actions', + label: 'عملیات', + }, +] + +const GuestListItemsModal = ({ isOpen, onOpenChange, list }: GuestListItemsModalProps) => ( + + {list && ( + + {{ + mobile: (_row, cellValue) => {formatIranianMobile(cellValue)}, + firstName: (row) => { + const item = row as unknown as GuestListItemRow + + return formatPersonName(item.firstName, item.lastName) + }, + note: (_row, cellValue) => (cellValue ? coerceToString(cellValue) : '—'), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => { + const item = row as unknown as GuestListItemRow + + if (!item.userId) return '—' + + return ( + + ) + }, + }} + + )} + +) + +export default GuestListItemsModal diff --git a/app/(dashboard)/guest-lists/page.tsx b/app/(dashboard)/guest-lists/page.tsx new file mode 100644 index 0000000..b83ca01 --- /dev/null +++ b/app/(dashboard)/guest-lists/page.tsx @@ -0,0 +1,109 @@ +'use client' + +import { useState } from 'react' + +import type { PaginationListColumnType } from '@/types' +import useDisclosure from '@/hooks/useDisclosure' +import PaginatedList from '@/components/PaginatedList' +import PageNavbar from '@/components/layouts/PageNavbar' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import GuestListItemsModal from '@/app/(dashboard)/guest-lists/_components/GuestListItemsModal' +import { API_ROUTES } from '@/services/config' +import { formatPersianDate, truncateValue } from '@/lib/formatters' + +interface GuestListRow { + id: string + organizerId: string + name: string + description: string | null + createdAt: string + updatedAt: string +} + +// Read-only admin visibility — GET /admin/guest-lists only supports +// filtering by organizerId and sorting by createdAt (see +// backend/src/modules/event-extras/admin-guest-lists.controller.ts, +// ADMIN_GUEST_LIST_FILTER_KEYS / GUEST_LIST_SORT_FIELDS). No write actions: +// creating/editing/deleting lists stays organizer self-service, and sending +// invitations isn't implemented in the backend yet (docs/workflows/guest-list-invitation.md). +// organizerId is filter-only (hideInTable) — the eye opens the list contacts modal. +const columns: PaginationListColumnType[] = [ + { + field: 'name', + label: 'نام لیست', + filterable: false, + sortable: false, + }, + { + field: 'organizerId', + label: 'میزبان', + filterable: true, + sortable: false, + type: 'text', + hideInTable: true, + }, + { + field: 'description', + label: 'توضیحات', + filterable: false, + sortable: false, + }, + { + field: 'createdAt', + label: 'تاریخ ثبت', + filterable: false, + sortable: true, + }, + { + field: 'actions', + label: 'عملیات', + }, +] + +const GuestListsPage = () => { + const { isOpen, onOpenChange, onOpen } = useDisclosure() + const [activeList, setActiveList] = useState<{ id: string; name: string } | null>(null) + + const openItemsModal = (list: GuestListRow) => { + setActiveList({ id: list.id, name: list.name }) + onOpen() + } + + return ( +
+ +
+ + {{ + description: (_row, cellValue) => truncateValue(cellValue, 60), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => { + const list = row as unknown as GuestListRow + + return ( + { + openItemsModal(list) + }} + /> + ) + }, + }} + +
+ + +
+ ) +} + +export default GuestListsPage diff --git a/app/(dashboard)/identity-verifications/page.tsx b/app/(dashboard)/identity-verifications/page.tsx new file mode 100644 index 0000000..0985c4e --- /dev/null +++ b/app/(dashboard)/identity-verifications/page.tsx @@ -0,0 +1,515 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' + +import type { PaginationListColumnType } from '@/types' +import { addToast } from '@/lib/toast' +import PaginatedList from '@/components/PaginatedList' +import PageNavbar from '@/components/layouts/PageNavbar' +import Button from '@/components/formElements/Button' +import Input from '@/components/formElements/Input' +import CloseCircleIcon from '@/components/icons/CloseCircleIcon' +import EyeIcon from '@/components/icons/EyeIcon' +import FileCheckIcon from '@/components/icons/FileCheckIcon' +import Modal from '@/components/modals/Modal' +import AdminTableActions from '@/components/ui/AdminTableActions' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import StatusChip from '@/components/ui/StatusChip' +import axiosInstance from '@/config/axios' +import { APP_ROUTES } from '@/constants/routes' +import { getIdentityStatus, IDENTITY_VERIFICATION_QUEUE_FILTER_ITEMS } from '@/constants/status' +import { formatPersonName, coerceToString } from '@/helpers' +import { unwrapApiPayload } from '@/helpers/listResponse' +import useAlertModal from '@/hooks/useAlertModal' +import useAdminMutation from '@/hooks/useAdminMutation' +import { formatPersianDate } from '@/lib/formatters' +import { API_ROUTES } from '@/services/config' + +type IdentityVerificationStatus = 'pending' | 'verified' | 'rejected' + +interface RejectionReason { + id: number + code: string + label: string + sortOrder: number +} + +interface VerificationUserSummary { + mobile: string + firstName: string | null + lastName: string | null +} + +interface VerificationRow { + id: string + userId: string + status: IdentityVerificationStatus + nationalCode: string | null + contractVersion?: string | null + contractAcceptedAt?: string | null + contractAcceptanceId?: string | null + jibitMatched?: boolean | null + jibitInquiredAt?: string | null + jibitErrorCode?: string | null + reason?: { code: string; label: string } | null + rejectionReason?: string | null + user?: VerificationUserSummary + createdAt: string + reviewedAt?: string | null + [key: string]: unknown +} + +interface RejectFormValues { + reasonId: string + freeTextReason: string +} + +const REJECT_FORM_DEFAULTS: RejectFormValues = { + reasonId: '', + freeTextReason: '', +} + +const columns: PaginationListColumnType[] = [ + { + field: 'userId', + label: 'متقاضی', + filterable: true, + type: 'text', + sortable: false, + }, + { + field: 'nationalCode', + label: 'کد ملی', + filterable: false, + sortable: false, + }, + { + field: 'jibitMatched', + label: 'استعلام جیبت', + filterable: false, + sortable: false, + }, + { + field: 'status', + label: 'وضعیت', + filterable: true, + type: 'select', + sortable: true, + filterItems: IDENTITY_VERIFICATION_QUEUE_FILTER_ITEMS, + }, + { + field: 'createdAt', + label: 'تاریخ ثبت', + filterable: true, + sortable: true, + type: 'dateFromTo', + }, + { + field: 'actions', + label: 'عملیات', + }, +] + +const formatJibitCell = (row: VerificationRow) => { + if (row.jibitErrorCode) return `خطا: ${row.jibitErrorCode}` + if (row.jibitMatched === true) return 'تطبیق دارد' + if (row.jibitMatched === false) return 'تطبیق ندارد' + + return '—' +} + +const getUserSummary = (row: VerificationRow): VerificationUserSummary & { id: string } => { + const user = row.user + + return { + id: row.userId, + mobile: user?.mobile ?? '—', + firstName: user?.firstName ?? null, + lastName: user?.lastName ?? null, + } +} + +const IdentityVerificationsPage = () => { + const { showAlert } = useAlertModal() + const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.IDENTITY.ADMIN_LIST }) + const [detailTarget, setDetailTarget] = useState(null) + const [rejectTarget, setRejectTarget] = useState(null) + const [rejectionReasons, setRejectionReasons] = useState([]) + const isRejecting = rejectTarget?.id === pendingId + const rejectForm = useForm({ + defaultValues: REJECT_FORM_DEFAULTS, + }) + const selectedReasonId = rejectForm.watch('reasonId') + const freeTextReason = rejectForm.watch('freeTextReason') + + const loadRejectionReasons = useCallback(async () => { + try { + const response = await axiosInstance.get(API_ROUTES.IDENTITY.REJECTION_REASONS) + const payload = unwrapApiPayload(response.data) + const reasons = Array.isArray(payload) ? payload : [] + + setRejectionReasons(reasons as RejectionReason[]) + } catch { + addToast({ title: 'بارگذاری دلایل رد ناموفق بود', color: 'danger' }) + } + }, []) + + useEffect(() => { + if (rejectTarget) { + void loadRejectionReasons() + } + }, [rejectTarget, loadRejectionReasons]) + + const handleApprove = (row: VerificationRow) => { + showAlert('این درخواست احراز هویت تأیید شود؟', () => + runAction(row.id, () => axiosInstance.patch(API_ROUTES.IDENTITY.ADMIN_APPROVE(row.id)), 'درخواست تأیید شد') + ) + } + + const handleJibitInquiry = (row: VerificationRow) => { + showAlert('استعلام تطابق کدملی با موبایل از جیبت انجام شود؟', () => + runAction( + row.id, + async () => { + const response = await axiosInstance.post(API_ROUTES.IDENTITY.ADMIN_JIBIT_INQUIRY(row.id)) + const payload = unwrapApiPayload>(response.data) ?? {} + + setDetailTarget({ + ...row, + ...payload, + user: payload.user ?? row.user, + }) + + return response + }, + 'استعلام جیبت انجام شد' + ) + ) + } + + const openRejectModal = (row: VerificationRow) => { + rejectForm.reset(REJECT_FORM_DEFAULTS) + setRejectTarget(row) + } + + const closeRejectModal = () => { + if (isRejecting) return + + setRejectTarget(null) + rejectForm.reset(REJECT_FORM_DEFAULTS) + } + + const handleReject = async () => { + if (!rejectTarget) return + + const reasonId = selectedReasonId ? Number(selectedReasonId) : undefined + const rejectionReason = freeTextReason.trim() || undefined + + if (!reasonId && !rejectionReason) { + addToast({ title: 'حداقل یکی از دلیل ساختاریافته یا توضیح آزاد را وارد کنید', color: 'warning' }) + + return + } + + const succeeded = await runAction( + rejectTarget.id, + () => + axiosInstance.patch(API_ROUTES.IDENTITY.ADMIN_REJECT(rejectTarget.id), { + ...(reasonId ? { reasonId } : {}), + ...(rejectionReason ? { rejectionReason } : {}), + }), + 'درخواست رد شد' + ) + + if (succeeded) { + setRejectTarget(null) + rejectForm.reset(REJECT_FORM_DEFAULTS) + } + } + + const renderStatusCell = (row: VerificationRow, cellValue: unknown) => { + const { label, chipColor } = getIdentityStatus(coerceToString(cellValue)) + + const description = + row.status === 'rejected' && (row.reason?.label || row.rejectionReason) + ? `${row.reason?.label ?? ''}${row.reason?.label && row.rejectionReason ? ' — ' : ''}${row.rejectionReason ?? ''}` + : undefined + + return ( + + ) + } + + return ( +
+ +
+ + {{ + userId: (row) => { + const user = getUserSummary(row as VerificationRow) + + return ( +
+ {formatPersonName(user.firstName, user.lastName)} + {user.mobile} +
+ ) + }, + jibitMatched: (row) => formatJibitCell(row as VerificationRow), + status: (row, cellValue) => renderStatusCell(row as VerificationRow, cellValue), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => { + const verification = row as VerificationRow + const isBusy = pendingId === verification.id + const user = getUserSummary(verification) + + return ( + + + + {verification.status === 'pending' ? ( + <> + + + + + ) : null} + + ) + }, + }} +
+
+ + + + + + + ) : undefined + } + hideFooter={detailTarget?.status !== 'pending'} + isDismissable={detailTarget ? pendingId !== detailTarget.id : true} + isOpen={Boolean(detailTarget)} + size="2xl" + title="جزئیات احراز هویت" + onOpenChange={(open) => { + if (!open && pendingId !== detailTarget?.id) setDetailTarget(null) + }} + > + {detailTarget ? ( +
+
+
متقاضی
+
{formatPersonName(detailTarget.user?.firstName, detailTarget.user?.lastName)}
+
+
+
شماره موبایل
+
+ {detailTarget.user?.mobile ?? '—'} +
+
+
+
کد ملی
+
+ {detailTarget.nationalCode ?? '—'} +
+
+
+
شناسه برگزارکننده
+
+ {detailTarget.userId} +
+
+
+
نسخه قرارداد
+
{detailTarget.contractVersion ?? '—'}
+
+
+
تاریخ پذیرش
+
+ {detailTarget.contractAcceptedAt ? formatPersianDate(detailTarget.contractAcceptedAt) : '—'} +
+
+
+
شناسه پذیرش الکترونیکی
+
+ {detailTarget.contractAcceptanceId ?? '—'} +
+
+
+
نتیجه استعلام جیبت
+
{formatJibitCell(detailTarget)}
+
+
+
زمان استعلام
+
+ {detailTarget.jibitInquiredAt ? formatPersianDate(detailTarget.jibitInquiredAt) : '—'} +
+
+
+ ) : null} +
+ + { + void handleReject() + }} + onOpenChange={(open) => { + if (!open) closeRejectModal() + }} + onReject={closeRejectModal} + > + +
+

+ حداقل یکی از «دلیل ساختاریافته» یا «توضیح آزاد» را وارد کنید. این اطلاعات برای کاربر نمایش داده می‌شود. +

+ + +
+
+
+
+ ) +} + +export default IdentityVerificationsPage diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..6fd55da --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next' + +import DashboardShell from '@/components/layouts/DashboardShell' + +// Entire (dashboard) group is the admin panel — auth-gated and already +// disallowed in robots.ts. `noindex` here is defense in depth so a linked +// admin URL can never surface in search results even without a crawl. +export const metadata: Metadata = { + robots: { index: false, follow: false }, +} + +const UserLayout = ({ children }: { children: React.ReactNode }) => {children} + +export default UserLayout diff --git a/app/(dashboard)/loading.tsx b/app/(dashboard)/loading.tsx new file mode 100644 index 0000000..725e4e1 --- /dev/null +++ b/app/(dashboard)/loading.tsx @@ -0,0 +1,5 @@ +import { PageLoading } from '@/components/feedback/LoadingState' + +export default function DashboardLoading() { + return +} diff --git a/app/(dashboard)/manage-events/[id]/edit/page.tsx b/app/(dashboard)/manage-events/[id]/edit/page.tsx new file mode 100644 index 0000000..2a2de79 --- /dev/null +++ b/app/(dashboard)/manage-events/[id]/edit/page.tsx @@ -0,0 +1,39 @@ +'use client' + +import dynamic from 'next/dynamic' +import { useParams } from 'next/navigation' + +import PageNavbar from '@/components/layouts/PageNavbar' +import { DetailSkeleton } from '@/components/feedback/LoadingState' +import { APP_ROUTES } from '@/constants/routes' +import { texts } from '@/texts' + +const EventEditForm = dynamic(() => import('@/features/events/edit/EventEditForm'), { + ssr: false, + loading: () => , +}) + +const AdminEventEditPage = () => { + const eventId = useParams<{ id: string }>().id + + if (!eventId) return

{texts.events.invalidEventId}

+ + const detailHref = APP_ROUTES.MANAGE_EVENT_DETAIL(eventId) + + return ( +
+ +
+ +
+
+ ) +} + +export default AdminEventEditPage diff --git a/app/(dashboard)/manage-events/[id]/page.tsx b/app/(dashboard)/manage-events/[id]/page.tsx new file mode 100644 index 0000000..ccef697 --- /dev/null +++ b/app/(dashboard)/manage-events/[id]/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next' + +import AdminEventDetail from '@/features/events/detail/AdminEventDetail' + +export const metadata: Metadata = { + robots: { index: false, follow: false }, +} + +const AdminEventDetailPage = () => + +export default AdminEventDetailPage diff --git a/app/(dashboard)/manage-events/new/page.tsx b/app/(dashboard)/manage-events/new/page.tsx new file mode 100644 index 0000000..e6a7dd8 --- /dev/null +++ b/app/(dashboard)/manage-events/new/page.tsx @@ -0,0 +1,5 @@ +import AdminEventCreate from '@/features/events/create/AdminEventCreate' + +const AdminEventCreatePage = () => + +export default AdminEventCreatePage diff --git a/app/(dashboard)/manage-events/page.tsx b/app/(dashboard)/manage-events/page.tsx new file mode 100644 index 0000000..5f1286a --- /dev/null +++ b/app/(dashboard)/manage-events/page.tsx @@ -0,0 +1,247 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' + +import type { PaginationListColumnType } from '@/types' +import Button from '@/components/formElements/Button' +import PaginatedList from '@/components/PaginatedList' +import PageNavbar from '@/components/layouts/PageNavbar' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import StatusChip from '@/components/ui/StatusChip' +import axiosInstance from '@/config/axios' +import { APP_ROUTES } from '@/constants/routes' +import { EVENT_STATUS_FILTER_ITEMS, getBooleanStatus, getEventStatus } from '@/constants/status' +import { formatCurrency, formatPersonName, coerceToString } from '@/helpers' +import { formatPersianDate } from '@/lib/formatters' +import { API_ROUTES } from '@/services/config' + +interface NamedEntity { + id: number | string + name: string +} + +interface EventOrganizer { + id: string + firstName: string | null + lastName: string | null +} + +interface EventRow { + id: string + title: string + status: string + isDiscoverable: boolean + isFree: boolean + price: number + category?: NamedEntity + province?: NamedEntity + city?: NamedEntity + organizer?: EventOrganizer + [key: string]: unknown +} + +interface FilterOption { + code: string + name: string +} + +const IS_FREE_FILTER_ITEMS: FilterOption[] = [ + { code: 'true', name: 'بله' }, + { code: 'false', name: 'خیر' }, +] + +const IS_DISCOVERABLE_FILTER_ITEMS: FilterOption[] = [ + { code: 'true', name: 'بله' }, + { code: 'false', name: 'خیر' }, +] + +const toSelectFilterItems = (items: NamedEntity[]): FilterOption[] => items.map((item) => ({ code: String(item.id), name: item.name })) + +const extractApiList = (raw: unknown): T[] => { + if (Array.isArray(raw)) return raw as T[] + + if (typeof raw === 'object' && raw !== null && 'data' in raw) { + const data = raw.data + + if (Array.isArray(data)) return data as T[] + } + + return [] +} + +const getOrganizer = (row: EventRow): EventOrganizer => { + if (row.organizer && typeof row.organizer === 'object') { + return row.organizer + } + + return { id: '—', firstName: null, lastName: null } +} + +const EventsPage = () => { + const [categoryFilterItems, setCategoryFilterItems] = useState([]) + const [cityFilterItems, setCityFilterItems] = useState([]) + + useEffect(() => { + const loadFilterOptions = async () => { + try { + const [categoriesRes, citiesRes] = await Promise.all([ + axiosInstance.get(API_ROUTES.EVENT_CATEGORIES.ADMIN_FLAT), + axiosInstance.get(API_ROUTES.GEOGRAPHY.ALL_CITIES), + ]) + + setCategoryFilterItems(toSelectFilterItems(extractApiList(categoriesRes.data))) + setCityFilterItems(toSelectFilterItems(extractApiList(citiesRes.data))) + } catch { + setCategoryFilterItems([]) + setCityFilterItems([]) + } + } + + void loadFilterOptions() + }, []) + + const columns = useMemo( + () => [ + { + field: 'title', + label: 'عنوان', + filterable: true, + type: 'text', + sortable: false, + }, + { + field: 'status', + label: 'وضعیت', + filterable: true, + type: 'select', + sortable: false, + filterItems: EVENT_STATUS_FILTER_ITEMS, + }, + { + field: 'isDiscoverable', + label: 'قابل‌جستجو', + filterable: true, + type: 'select', + sortable: false, + filterItems: IS_DISCOVERABLE_FILTER_ITEMS, + }, + { + field: 'categoryId', + label: 'دسته‌بندی', + filterable: true, + type: 'select', + sortable: false, + filterItems: categoryFilterItems, + }, + { + field: 'organizerId', + label: 'برگزارکننده', + filterable: true, + type: 'text', + sortable: false, + }, + // Province-level filtering is disabled here too — see cities/page.tsx's + // note for why (city-only admin surface). Only the city filter below + // is active. + { + field: 'cityId', + label: 'شهر', + filterable: true, + type: 'select', + sortable: false, + filterItems: cityFilterItems, + }, + { + field: 'startsAt', + label: 'تاریخ شروع', + filterable: true, + sortable: true, + type: 'dateFromTo', + }, + { + field: 'isFree', + label: 'رایگان', + filterable: true, + type: 'select', + sortable: false, + filterItems: IS_FREE_FILTER_ITEMS, + }, + { + field: 'price', + label: 'قیمت', + filterable: true, + type: 'inputFromTo', + sortable: false, + }, + { + field: 'actions', + label: 'عملیات', + }, + ], + [categoryFilterItems, cityFilterItems] + ) + + return ( +
+ + افزودن رویداد + + } + pageTitle="رویدادها" + /> +
+ + {{ + status: (_row, cellValue) => , + isDiscoverable: (_row, cellValue) => , + categoryId: (row) => { + const event = row as EventRow + + return event.category?.name ?? '—' + }, + organizerId: (row) => { + const organizer = getOrganizer(row as EventRow) + + return formatPersonName(organizer.firstName, organizer.lastName) + }, + cityId: (row) => { + const event = row as EventRow + + return event.city?.name ?? '—' + }, + startsAt: (_row, cellValue) => formatPersianDate(cellValue), + isFree: (_row, cellValue) => , + price: (row, cellValue) => { + const event = row as EventRow + + if (event.isFree) return '—' + + const amount = typeof cellValue === 'number' ? cellValue : Number(cellValue) + + if (!Number.isFinite(amount)) return '—' + + return formatCurrency(amount) + }, + actions: (row) => ( + + ), + }} + +
+
+ ) +} + +export default EventsPage diff --git a/app/(dashboard)/manual-notifications/page.tsx b/app/(dashboard)/manual-notifications/page.tsx new file mode 100644 index 0000000..39a6ef6 --- /dev/null +++ b/app/(dashboard)/manual-notifications/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from 'next/navigation' + +import { APP_ROUTES } from '@/constants/routes' + +export default function ManualNotificationsRedirectPage() { + redirect(APP_ROUTES.NOTIFICATIONS) +} diff --git a/app/(dashboard)/notification-rules/page.tsx b/app/(dashboard)/notification-rules/page.tsx new file mode 100644 index 0000000..26cfb26 --- /dev/null +++ b/app/(dashboard)/notification-rules/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from 'next/navigation' + +import { APP_ROUTES } from '@/constants/routes' + +export default function NotificationRulesRedirectPage() { + redirect(APP_ROUTES.NOTIFICATIONS_SETTINGS) +} diff --git a/app/(dashboard)/notifications/_components/InAppNotificationsPanel.tsx b/app/(dashboard)/notifications/_components/InAppNotificationsPanel.tsx new file mode 100644 index 0000000..6647519 --- /dev/null +++ b/app/(dashboard)/notifications/_components/InAppNotificationsPanel.tsx @@ -0,0 +1,179 @@ +'use client' + +import { useState } from 'react' + +import type { PaginationListColumnType } from '@/types' +import useDisclosure from '@/hooks/useDisclosure' +import PaginatedList from '@/components/PaginatedList' +import Button from '@/components/formElements/Button' +import Modal from '@/components/modals/Modal' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import StatusChip from '@/components/ui/StatusChip' +import { APP_ROUTES, CONSUMER_ROUTES } from '@/constants/routes' +import { coerceToString, formatPersonName } from '@/helpers' +import { API_ROUTES } from '@/services/config' +import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters' + +interface InAppNotificationRow { + id: string + userId: string + mobile: string + category: string + title: string + body: string + actionUrl: string | null + status: string + readAt: string | null + createdAt: string + recipient?: { + firstName: string | null + lastName: string | null + } +} + +const columns: PaginationListColumnType[] = [ + { field: 'userId', label: 'گیرنده', filterable: true, sortable: false, type: 'text' }, + { field: 'mobile', label: 'موبایل', filterable: true, sortable: false, type: 'text' }, + { field: 'title', label: 'عنوان', filterable: false, sortable: false }, + { field: 'category', label: 'دسته', filterable: true, sortable: false, type: 'text' }, + { + field: 'readStatus', + label: 'خواندن', + filterable: true, + sortable: false, + type: 'select', + filterItems: [ + { code: 'unread', name: 'خوانده‌نشده' }, + { code: 'read', name: 'خوانده‌شده' }, + ], + }, + { field: 'createdAt', label: 'تاریخ ثبت', filterable: false, sortable: true }, + { field: 'actions', label: 'عملیات' }, +] + +const InAppNotificationsPanel = () => { + const { isOpen, onOpen, onOpenChange } = useDisclosure() + const [activeRow, setActiveRow] = useState(null) + + const openDetails = (row: InAppNotificationRow) => { + setActiveRow(row) + onOpen() + } + + return ( + <> + + {{ + userId: (row) => { + const notification = row as unknown as InAppNotificationRow + const name = formatPersonName(notification.recipient?.firstName, notification.recipient?.lastName) + + return ( +
+ {name !== '—' && {name}} + + {formatIranianMobile(notification.mobile)} + +
+ ) + }, + mobile: (_row, cellValue) => {formatIranianMobile(coerceToString(cellValue))}, + title: (row, cellValue) => { + const notification = row as unknown as InAppNotificationRow + + return ( +
+ {truncateValue(coerceToString(cellValue), 50)} + {truncateValue(notification.body, 70)} +
+ ) + }, + category: (_row, cellValue) => coerceToString(cellValue) || '—', + readStatus: (row) => { + const notification = row as unknown as InAppNotificationRow + + return notification.readAt ? ( + + ) : ( + + ) + }, + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => { + const notification = row as unknown as InAppNotificationRow + + return ( +
+ + +
+ ) + }, + }} +
+ + +
+
+

عنوان

+

{activeRow?.title ?? '—'}

+
+
+

متن اعلان

+

{activeRow?.body ?? '—'}

+
+
+
+

مسیر مقصد

+

{activeRow?.actionUrl ?? CONSUMER_ROUTES.PROFILE_NOTIFICATIONS}

+
+
+

دسته

+

{activeRow?.category ?? '—'}

+
+
+

وضعیت خواندن

+

{activeRow?.readAt ? formatPersianDate(activeRow.readAt) : 'خوانده نشده'}

+
+
+

تاریخ ثبت

+

{formatPersianDate(activeRow?.createdAt)}

+
+
+
+
+ + ) +} + +export default InAppNotificationsPanel diff --git a/app/(dashboard)/notifications/_components/NotificationRulesPanel.test.tsx b/app/(dashboard)/notifications/_components/NotificationRulesPanel.test.tsx new file mode 100644 index 0000000..eaa87b7 --- /dev/null +++ b/app/(dashboard)/notifications/_components/NotificationRulesPanel.test.tsx @@ -0,0 +1,158 @@ +import type { ReactNode } from 'react' + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import NotificationRulesPanel from '@/app/(dashboard)/notifications/_components/NotificationRulesPanel' + +const axiosMocks = vi.hoisted(() => ({ + get: vi.fn(), + patch: vi.fn(), +})) + +vi.mock('@/config/axios', () => ({ + default: { + get: axiosMocks.get, + patch: axiosMocks.patch, + }, +})) +vi.mock('@/lib/toast', () => ({ addToast: vi.fn() })) +vi.mock('@/components/formElements/Input', () => ({ + default: ({ + description, + generalType, + label, + selectOptions, + value, + onValueChange, + }: { + description?: ReactNode + generalType?: string + label: string + selectOptions?: { code: string; name: string }[] + value?: unknown + onValueChange?: (value: unknown) => void + }) => ( + + ), +})) +vi.mock('@/components/formElements/Button', () => ({ + default: ({ children, disabled, onClick }: { children: ReactNode; disabled?: boolean; onClick?: () => void }) => ( + + ), +})) + +const bookingRule = { + eventKey: 'booking_confirmed_guest', + displayName: 'تأیید رزرو برای مهمان', + description: 'پس از قطعی‌شدن رزرو', + enabled: true, + channel: 'in_app', + titleTemplate: 'رزرو شما تأیید شد', + bodyTemplate: 'رزرو شما برای «{{eventTitle}}» با موفقیت تأیید شد.', + actionUrlTemplate: '/bookings/{{bookingId}}', + allowedVariables: ['eventTitle', 'bookingId'], + sampleVariables: { eventTitle: 'دورهمی آخر هفته', bookingId: 'booking-sample' }, + smsAllowed: true, + updatedAt: '2026-08-18T00:00:00.000Z', +} + +const chatRule = { + eventKey: 'chat_message', + displayName: 'پیام جدید چت', + description: 'برای سایر اعضای گفتگو', + enabled: true, + channel: 'in_app', + titleTemplate: 'پیام جدید', + bodyTemplate: '{{messagePreview}}', + actionUrlTemplate: '/chats/{{conversationId}}', + allowedVariables: ['messagePreview', 'conversationId'], + sampleVariables: { messagePreview: 'سلام، ساعت را هماهنگ کنیم؟', conversationId: 'chat-sample' }, + smsAllowed: false, + updatedAt: '2026-08-18T00:00:00.000Z', +} + +describe('NotificationRulesPanel', () => { + afterEach(() => { + cleanup() + }) + + beforeEach(() => { + axiosMocks.get.mockReset() + axiosMocks.patch.mockReset() + axiosMocks.get.mockResolvedValue({ + data: { success: true, data: [bookingRule, chatRule] }, + }) + }) + + it('shows allowed variables, sample preview, and keeps save disabled until dirty', async () => { + render() + + expect(await screen.findByText('{{eventTitle}}')).toBeInTheDocument() + expect(screen.getByText('رزرو شما برای «دورهمی آخر هفته» با موفقیت تأیید شد.')).toBeInTheDocument() + expect(screen.getByText('/bookings/booking-sample')).toBeInTheDocument() + expect(screen.queryByText('ذخیره نشده')).not.toBeInTheDocument() + + const saveButtons = screen.getAllByRole('button', { name: 'ذخیره' }) + + expect(saveButtons[0]).toBeDisabled() + + fireEvent.change(screen.getAllByLabelText('عنوان')[0], { + target: { value: 'رزرو قطعی شد' }, + }) + + expect(await screen.findByText('ذخیره نشده')).toBeInTheDocument() + expect(screen.getByText('رزرو قطعی شد')).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: 'ذخیره' })[0]).not.toBeDisabled() + }) + + it('hides SMS channels for chat and warns when SMS-only is selected', async () => { + render() + + expect(await screen.findByText('پیام جدید چت')).toBeInTheDocument() + expect(screen.getByText('پیامک برای پیام‌های چت ارسال نمی‌شود؛ فقط اعلان درون‌برنامه‌ای و پوش.')).toBeInTheDocument() + + const chatChannel = screen.getAllByLabelText('کانال ارسال')[1] + + expect(chatChannel.querySelector('option[value="sms"]')).toBeNull() + expect(chatChannel.querySelector('option[value="in_app"]')).not.toBeNull() + + fireEvent.change(screen.getAllByLabelText('کانال ارسال')[0], { + target: { value: 'sms' }, + }) + + expect(await screen.findByText('با انتخاب فقط پیامک، اعلان درون‌برنامه‌ای و پوش ارسال نمی‌شود.')).toBeInTheDocument() + expect(screen.getByText(/کاراکتر/)).toBeInTheDocument() + }) +}) diff --git a/app/(dashboard)/notifications/_components/NotificationRulesPanel.tsx b/app/(dashboard)/notifications/_components/NotificationRulesPanel.tsx new file mode 100644 index 0000000..4b6e539 --- /dev/null +++ b/app/(dashboard)/notifications/_components/NotificationRulesPanel.tsx @@ -0,0 +1,325 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { coerceToString } from '@/helpers' +import { addToast } from '@/lib/toast' +import { renderNotificationActionUrlPreview, renderNotificationTemplate } from '@/lib/notificationTemplate' +import { countSmsSegments } from '@/lib/smsSegments' +import Button from '@/components/formElements/Button' +import Input from '@/components/formElements/Input' +import AdminState from '@/components/feedback/AdminState' +import axiosInstance from '@/config/axios' +import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse' +import { API_ROUTES } from '@/services/config' +import { extractServerErrorDetail } from '@/services/errorHandler' + +type Channel = 'in_app' | 'sms' | 'both' +interface Rule { + eventKey: string + displayName: string + description: string + enabled: boolean + channel: Channel + titleTemplate: string + bodyTemplate: string + actionUrlTemplate: string | null + allowedVariables: string[] + sampleVariables: Record + smsAllowed: boolean + updatedAt: string +} + +type RuleDraft = Pick & { + actionUrlTemplate: string +} + +const CHANNELS: { code: Channel; name: string }[] = [ + { code: 'in_app', name: 'درون‌برنامه‌ای + پوش' }, + { code: 'sms', name: 'پیامک' }, + { code: 'both', name: 'درون‌برنامه‌ای + پوش + پیامک' }, +] + +const toDraft = (rule: Rule): RuleDraft => ({ + enabled: rule.enabled, + channel: rule.channel, + titleTemplate: rule.titleTemplate, + bodyTemplate: rule.bodyTemplate, + actionUrlTemplate: rule.actionUrlTemplate ?? '', +}) + +const isDirtyDraft = (current: RuleDraft, saved: RuleDraft | undefined) => + Boolean(saved) && JSON.stringify(current) !== JSON.stringify(saved) + +const NotificationRulesPanel = () => { + const [rules, setRules] = useState([]) + const [savedDrafts, setSavedDrafts] = useState>({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const response = await axiosInstance.get(API_ROUTES.NOTIFICATION_RULES.ADMIN_LIST) + const payload = unwrapApiData(response.data as ApiSuccessBody) + const nextRules = Array.isArray(payload) ? payload : [] + + setRules(nextRules) + setSavedDrafts(Object.fromEntries(nextRules.map((rule) => [rule.eventKey, toDraft(rule)]))) + } catch { + setError('دریافت تنظیمات اعلان‌ها ناموفق بود.') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + const change = (eventKey: string, patch: Partial) => { + setRules((current) => current.map((rule) => (rule.eventKey === eventKey ? { ...rule, ...patch } : rule))) + } + + const save = async (rule: Rule) => { + setSaving(rule.eventKey) + try { + const response = await axiosInstance.patch(API_ROUTES.NOTIFICATION_RULES.ADMIN_UPDATE(rule.eventKey), { + enabled: rule.enabled, + channel: rule.channel, + titleTemplate: rule.titleTemplate, + bodyTemplate: rule.bodyTemplate, + actionUrlTemplate: rule.actionUrlTemplate ?? '', + }) + const updated = unwrapApiData(response.data as ApiSuccessBody) + + if (updated) { + setRules((current) => current.map((item) => (item.eventKey === rule.eventKey ? { ...item, ...updated } : item))) + setSavedDrafts((current) => ({ ...current, [rule.eventKey]: toDraft({ ...rule, ...updated }) })) + } + addToast({ title: 'تنظیمات اعلان ذخیره شد', color: 'success' }) + } catch (saveError) { + addToast({ + title: + extractServerErrorDetail((saveError as { response?: { data?: unknown } })?.response?.data) ?? 'ذخیره تنظیمات اعلان ناموفق بود', + color: 'danger', + }) + } finally { + setSaving(null) + } + } + + return ( +
+
+ متغیرهای داخل دو آکولاد مثل {'{{eventTitle}}'} هنگام ارسال با اطلاعات واقعی جایگزین می‌شوند. فقط متغیرهای + همان رویداد مجاز است. اعلان درون‌برنامه‌ای در صورت فعال‌بودن اشتراک مرورگر، Web Push هم ارسال می‌کند. +
+ + {loading ?

در حال دریافت تنظیمات…

: null} + {!loading && error ? ( + void load()} + /> + ) : null} + {!loading && !error && rules.length === 0 ? ( + + ) : null} + + {rules.map((rule) => ( + void save(rule)} + /> + ))} +
+ ) +} + +const RuleCard = ({ + dirty, + rule, + saving, + onChange, + onSave, +}: { + dirty: boolean + rule: Rule + saving: boolean + onChange: (eventKey: string, patch: Partial) => void + onSave: () => void +}) => { + const channelOptions = useMemo(() => { + const allowed = CHANNELS.filter((channel) => channel.code === 'in_app' || rule.smsAllowed) + + if (allowed.some((channel) => channel.code === rule.channel)) return allowed + const current = CHANNELS.find((channel) => channel.code === rule.channel) + + return current ? [...allowed, current] : allowed + }, [rule.channel, rule.smsAllowed]) + + const includesSms = rule.channel === 'sms' || rule.channel === 'both' + const smsCount = includesSms ? countSmsSegments(rule.bodyTemplate) : null + const previewTitle = renderNotificationTemplate(rule.titleTemplate, rule.sampleVariables) + const previewBody = renderNotificationTemplate(rule.bodyTemplate, rule.sampleVariables) + const previewActionUrl = rule.actionUrlTemplate ? renderNotificationActionUrlPreview(rule.actionUrlTemplate, rule.sampleVariables) : '' + + return ( +
+
+
+
+

{rule.displayName}

+ {dirty ? ( + + ذخیره نشده + + ) : null} +
+

{rule.description}

+ {rule.eventKey} +
+ { + onChange(rule.eventKey, { enabled: Boolean(enabled) }) + }} + /> +
+ + {rule.allowedVariables.length === 0 ? ( +

این رویداد متغیر قابل‌جایگزینی ندارد.

+ ) : ( +
+

متغیرهای مجاز

+
+ {rule.allowedVariables.map((variable) => ( + + {`{{${variable}}}`} + + ))} +
+
+ )} + + { + if (next) onChange(rule.eventKey, { channel: coerceToString(next) as Channel }) + }} + /> + + {rule.channel === 'sms' ? ( +

+ با انتخاب فقط پیامک، اعلان درون‌برنامه‌ای و پوش ارسال نمی‌شود. +

+ ) : null} + + {!rule.smsAllowed && rule.channel !== 'in_app' ? ( +

+ پیامک برای این رویداد مجاز نیست. کانال را به درون‌برنامه‌ای برگردانید. +

+ ) : null} + + { + onChange(rule.eventKey, { titleTemplate: coerceToString(titleTemplate) }) + }} + /> + 1 ? ' — متن را کوتاه نگه دارید' : '' + }` + : undefined + } + generalType="textarea" + label="متن اعلان" + name={`body-${rule.eventKey}`} + value={rule.bodyTemplate} + onValueChange={(bodyTemplate) => { + onChange(rule.eventKey, { bodyTemplate: coerceToString(bodyTemplate) }) + }} + /> + { + onChange(rule.eventKey, { actionUrlTemplate: coerceToString(actionUrlTemplate) }) + }} + /> + +
+

پیش‌نمایش با دادهٔ نمونه

+

{previewTitle || '—'}

+

{previewBody || '—'}

+ {previewActionUrl ? ( + + {previewActionUrl} + + ) : null} +
+ +
+ +
+
+ ) +} + +export default NotificationRulesPanel diff --git a/app/(dashboard)/notifications/_components/PushDeliveriesPanel.tsx b/app/(dashboard)/notifications/_components/PushDeliveriesPanel.tsx new file mode 100644 index 0000000..6788b32 --- /dev/null +++ b/app/(dashboard)/notifications/_components/PushDeliveriesPanel.tsx @@ -0,0 +1,218 @@ +'use client' + +import { useState } from 'react' + +import type { PaginationListColumnType } from '@/types' +import useDisclosure from '@/hooks/useDisclosure' +import PaginatedList from '@/components/PaginatedList' +import Button from '@/components/formElements/Button' +import Modal from '@/components/modals/Modal' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import StatusChip from '@/components/ui/StatusChip' +import { APP_ROUTES, CONSUMER_ROUTES } from '@/constants/routes' +import { coerceToString, formatPersonName } from '@/helpers' +import { API_ROUTES } from '@/services/config' +import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters' + +interface PushDeliveryRow { + id: string + notificationId: string + userId: string + subscriptionId: string + mobile: string + category: string + title: string + body: string + actionUrl: string | null + status: string + errorMessage: string | null + userAgent: string | null + displayedAt: string | null + openedAt: string | null + createdAt: string + recipient?: { + firstName: string | null + lastName: string | null + } +} + +const columns: PaginationListColumnType[] = [ + { field: 'userId', label: 'گیرنده', filterable: true, sortable: false, type: 'text' }, + { field: 'mobile', label: 'موبایل', filterable: true, sortable: false, type: 'text' }, + { field: 'title', label: 'عنوان', filterable: false, sortable: false }, + { + field: 'status', + label: 'ارسال', + filterable: true, + sortable: false, + type: 'select', + filterItems: [ + { code: 'sent', name: 'ارسال‌شده' }, + { code: 'failed', name: 'ناموفق' }, + ], + }, + { field: 'displayedAt', label: 'نمایش', filterable: false, sortable: false }, + { field: 'openedAt', label: 'بازشدن', filterable: false, sortable: false }, + { field: 'createdAt', label: 'تاریخ ثبت', filterable: false, sortable: true }, + { field: 'actions', label: 'عملیات' }, +] + +const getPushStatus = (row: PushDeliveryRow) => { + if (row.status === 'failed') { + return { + label: 'ناموفق', + chipColor: 'danger' as const, + description: row.errorMessage ?? undefined, + } + } + + if (row.openedAt) { + return { label: 'باز شده', chipColor: 'success' as const } + } + + if (row.displayedAt) { + return { label: 'نمایش داده شده', chipColor: 'warning' as const } + } + + return { label: 'ارسال‌شده', chipColor: 'default' as const } +} + +const PushDeliveriesPanel = () => { + const { isOpen, onOpen, onOpenChange } = useDisclosure() + const [activeDelivery, setActiveDelivery] = useState(null) + + const openDetails = (row: PushDeliveryRow) => { + setActiveDelivery(row) + onOpen() + } + + return ( + <> + + {{ + userId: (row) => { + const delivery = row as unknown as PushDeliveryRow + const name = formatPersonName(delivery.recipient?.firstName, delivery.recipient?.lastName) + + return ( +
+ {name !== '—' && {name}} + + {formatIranianMobile(delivery.mobile)} + +
+ ) + }, + mobile: (_row, cellValue) => {formatIranianMobile(coerceToString(cellValue))}, + title: (row, cellValue) => { + const delivery = row as unknown as PushDeliveryRow + + return ( +
+ {truncateValue(coerceToString(cellValue), 50)} + {truncateValue(delivery.body, 70)} +
+ ) + }, + status: (row) => { + const delivery = row as unknown as PushDeliveryRow + const status = getPushStatus(delivery) + + return ( + + ) + }, + displayedAt: (_row, cellValue) => formatPersianDate(cellValue), + openedAt: (_row, cellValue) => formatPersianDate(cellValue), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => { + const delivery = row as unknown as PushDeliveryRow + + return ( +
+ + +
+ ) + }, + }} +
+ + +
+
+

عنوان

+

{activeDelivery?.title ?? '—'}

+
+
+

متن اعلان

+

{activeDelivery?.body ?? '—'}

+
+
+
+

مسیر مقصد

+

{activeDelivery?.actionUrl ?? CONSUMER_ROUTES.PROFILE_NOTIFICATIONS}

+
+
+

دسته

+

{activeDelivery?.category ?? '—'}

+
+
+

زمان نمایش

+

{formatPersianDate(activeDelivery?.displayedAt)}

+
+
+

زمان بازشدن

+

{formatPersianDate(activeDelivery?.openedAt)}

+
+
+
+

User-Agent

+

+ {activeDelivery?.userAgent ?? '—'} +

+
+ {activeDelivery?.errorMessage ? ( +
+

خطای تحویل

+

{activeDelivery.errorMessage}

+
+ ) : null} +
+
+ + ) +} + +export default PushDeliveriesPanel diff --git a/app/(dashboard)/notifications/_components/SendManualNotificationModal.tsx b/app/(dashboard)/notifications/_components/SendManualNotificationModal.tsx new file mode 100644 index 0000000..93135ec --- /dev/null +++ b/app/(dashboard)/notifications/_components/SendManualNotificationModal.tsx @@ -0,0 +1,375 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' + +import { addToast } from '@/lib/toast' +import Input from '@/components/formElements/Input' +import Button from '@/components/formElements/Button' +import Modal from '@/components/modals/Modal' +import axiosInstance from '@/config/axios' +import { coerceToString } from '@/helpers' +import { countSmsSegments } from '@/lib/smsSegments' +import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse' +import { API_ROUTES } from '@/services/config' +import { extractServerErrorDetail } from '@/services/errorHandler' + +type Channel = 'in_app' | 'sms' | 'both' +type FieldErrors = Partial> + +const CHANNELS: { code: Channel; name: string }[] = [ + { code: 'in_app', name: 'درون‌برنامه‌ای و پوش' }, + { code: 'sms', name: 'پیامک' }, + { code: 'both', name: 'هر دو' }, +] + +const splitList = (value: string) => + value + .split(/[\n,]+/) + .map((item) => item.trim()) + .filter(Boolean) + +const INTERNAL_PATH_PATTERN = /^\/(?!\/).*/ + +interface ManualNotificationResponse { + recipientCount: number + missingMobiles: string[] + missingUserIds: string[] +} + +interface SendManualNotificationModalProps { + defaultChannel: Channel + isOpen: boolean + onClose: () => void + onSent: () => void +} + +const SendManualNotificationModal = ({ defaultChannel, isOpen, onClose, onSent }: SendManualNotificationModalProps) => { + const [channel, setChannel] = useState(defaultChannel) + const [title, setTitle] = useState('') + const [body, setBody] = useState('') + const [actionUrl, setActionUrl] = useState('') + const [mobilesText, setMobilesText] = useState('') + const [userIdsText, setUserIdsText] = useState('') + const [otpCode, setOtpCode] = useState('') + const [otpRequested, setOtpRequested] = useState(false) + const [otpExpiresIn, setOtpExpiresIn] = useState(null) + const [errors, setErrors] = useState({}) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isRequestingOtp, setIsRequestingOtp] = useState(false) + + const includesInApp = channel === 'in_app' || channel === 'both' + const includesSms = channel === 'sms' || channel === 'both' + const recipientsPreview = useMemo( + () => ({ + mobiles: splitList(mobilesText), + userIds: splitList(userIdsText), + }), + [mobilesText, userIdsText] + ) + const totalRecipients = recipientsPreview.mobiles.length + recipientsPreview.userIds.length + const requiresBulkOtp = includesSms && totalRecipients >= 2 + const smsCount = includesSms ? countSmsSegments(body) : null + + const resetForm = () => { + setChannel(defaultChannel) + setTitle('') + setBody('') + setActionUrl('') + setMobilesText('') + setUserIdsText('') + setOtpCode('') + setOtpRequested(false) + setOtpExpiresIn(null) + setErrors({}) + } + + useEffect(() => { + if (!isOpen) return + + setChannel(defaultChannel) + setErrors({}) + }, [defaultChannel, isOpen]) + + const handleClose = () => { + if (isSubmitting) return + + resetForm() + onClose() + } + + const validate = (requireOtp = true): FieldErrors => { + const nextErrors: FieldErrors = {} + + if (!CHANNELS.some((item) => item.code === channel)) { + nextErrors.channel = 'کانال ارسال را انتخاب کنید' + } + if (includesInApp && !title.trim()) nextErrors.title = 'عنوان اعلان الزامی است' + if (!body.trim()) nextErrors.body = 'متن الزامی است' + if (recipientsPreview.mobiles.length === 0 && recipientsPreview.userIds.length === 0) { + nextErrors.recipients = 'حداقل یک موبایل یا شناسه کاربر وارد کنید' + } + if (includesInApp && actionUrl.trim() && !INTERNAL_PATH_PATTERN.test(actionUrl.trim())) { + nextErrors.actionUrl = 'لینک مقصد باید با / شروع شود و داخلی باشد' + } + if (requireOtp && requiresBulkOtp && !otpCode.trim()) { + nextErrors.otpCode = 'برای ارسال گروهی پیامک، کد تأیید الزامی است' + } + + return nextErrors + } + + const handleRequestOtp = async () => { + const nextErrors = validate(false) + + if (nextErrors.recipients || nextErrors.body || nextErrors.channel) { + setErrors(nextErrors) + + return + } + + try { + setIsRequestingOtp(true) + const response = await axiosInstance.post(API_ROUTES.MANUAL_NOTIFICATIONS.ADMIN_REQUEST_OTP) + const payload = unwrapApiData<{ expiresIn?: number }>(response.data as ApiSuccessBody<{ expiresIn?: number }>) + + setOtpRequested(true) + setOtpExpiresIn(Number(payload?.expiresIn ?? 300)) + addToast({ + title: 'کد تأیید ارسال شد', + description: 'کد به موبایل ادمین (همان اکانت ورود) فرستاده شد.', + color: 'success', + }) + } catch (err) { + addToast({ + title: 'ارسال کد ناموفق بود', + description: extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data) ?? 'دوباره تلاش کنید.', + color: 'danger', + }) + } finally { + setIsRequestingOtp(false) + } + } + + const handleSubmit = async () => { + const nextErrors = validate() + + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + try { + setIsSubmitting(true) + const response = await axiosInstance.post(API_ROUTES.MANUAL_NOTIFICATIONS.ADMIN_CREATE, { + channel, + title: includesInApp ? title.trim() : title.trim() || undefined, + body: body.trim(), + actionUrl: includesInApp ? actionUrl.trim() || undefined : undefined, + mobiles: recipientsPreview.mobiles, + userIds: recipientsPreview.userIds, + otpCode: requiresBulkOtp ? otpCode.trim() : undefined, + }) + const payload = unwrapApiData(response.data as ApiSuccessBody) + const recipientCount = Number(payload?.recipientCount ?? 0) + const missingMobiles = Array.isArray(payload?.missingMobiles) ? payload.missingMobiles.length : 0 + const missingUserIds = Array.isArray(payload?.missingUserIds) ? payload.missingUserIds.length : 0 + const missingCount = missingMobiles + missingUserIds + + addToast({ + title: 'ارسال انجام شد', + description: + recipientCount > 0 + ? `${recipientCount} گیرنده پیدا شد${missingCount > 0 ? `؛ ${missingCount} ورودی هم پیدا نشد.` : '.'}` + : 'هیچ گیرنده‌ای resolve نشد.', + color: recipientCount > 0 ? 'success' : 'warning', + }) + resetForm() + onSent() + } catch (err) { + addToast({ + title: 'ارسال ناموفق بود', + description: + extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data) ?? + 'مقادیر ورودی را بررسی کنید و دوباره تلاش کنید.', + color: 'danger', + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + void handleSubmit()} + onOpenChange={(open) => { + if (!open) handleClose() + }} + onReject={handleClose} + > +
+ { + if (next === 'in_app' || next === 'sms' || next === 'both') { + setChannel(next) + setOtpCode('') + setOtpRequested(false) + setOtpExpiresIn(null) + } + }} + /> + {errors.channel && {errors.channel}} +

+ {channel === 'sms' + ? 'فقط پیامک ارسال می‌شود. هزینه پیامک برای هر گیرنده محاسبه می‌شود.' + : channel === 'both' + ? 'اعلان داخل اپ، پوش مرورگر (در صورت اشتراک فعال) و پیامک با هم ارسال می‌شوند.' + : 'اعلان داخل اپ ساخته می‌شود و اگر کاربر اشتراک مرورگر فعال داشته باشد، پوش هم می‌رود.'} +

+ + {includesInApp ? ( + <> + { + setTitle(coerceToString(next)) + }} + /> + {errors.title && {errors.title}} + + ) : null} + + 1 ? ' — متن را کوتاه نگه دارید' : '' + }` + : undefined + } + generalType="textarea" + label={channel === 'sms' ? 'متن پیامک' : 'متن اعلان'} + name="body" + value={body} + onValueChange={(next) => { + setBody(coerceToString(next)) + }} + /> + {errors.body && {errors.body}} + + {includesInApp ? ( + <> + { + setActionUrl(coerceToString(next)) + }} + /> + {errors.actionUrl && {errors.actionUrl}} + + ) : null} + +
+
+ { + setMobilesText(coerceToString(next)) + setOtpCode('') + setOtpRequested(false) + setOtpExpiresIn(null) + }} + /> + هر موبایل را در یک خط جدا یا با کاما وارد کنید. +
+
+ { + setUserIdsText(coerceToString(next)) + setOtpCode('') + setOtpRequested(false) + setOtpExpiresIn(null) + }} + /> + برای هدف‌گیری دقیق می‌توانید UUID کاربر را هم وارد کنید. +
+
+ {errors.recipients && {errors.recipients}} + + {requiresBulkOtp ? ( +
+

ارسال گروهی پیامک نیاز به تأیید دارد

+

+ برای جلوگیری از ارسال اشتباهی، ابتدا کد تأیید را به موبایل ادمین بفرستید و سپس همان کد را وارد کنید. +

+
+ + { + setOtpCode(coerceToString(next)) + }} + /> + {errors.otpCode && {errors.otpCode}} + {otpRequested && otpExpiresIn ? ( + + کد تا {Math.floor(otpExpiresIn / 60).toLocaleString('fa-IR')} دقیقه معتبر است. + + ) : null} +
+
+ ) : null} + +
+

پیش‌نمایش گیرنده‌ها:

+

موبایل: {recipientsPreview.mobiles.length}

+

شناسه کاربر: {recipientsPreview.userIds.length}

+
+
+
+ ) +} + +export default SendManualNotificationModal diff --git a/app/(dashboard)/notifications/_components/SmsMessagesPanel.tsx b/app/(dashboard)/notifications/_components/SmsMessagesPanel.tsx new file mode 100644 index 0000000..a7eb804 --- /dev/null +++ b/app/(dashboard)/notifications/_components/SmsMessagesPanel.tsx @@ -0,0 +1,209 @@ +'use client' + +import { useState } from 'react' + +import type { PaginationListColumnType } from '@/types' +import useDisclosure from '@/hooks/useDisclosure' +import PaginatedList from '@/components/PaginatedList' +import Button from '@/components/formElements/Button' +import Modal from '@/components/modals/Modal' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import StatusChip from '@/components/ui/StatusChip' +import { APP_ROUTES } from '@/constants/routes' +import { getSmsStatus, SMS_STATUS_FILTER_ITEMS } from '@/constants/status' +import { formatPersonName, coerceToString } from '@/helpers' +import { API_ROUTES } from '@/services/config' +import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters' + +interface SmsMessageRow { + id: string + notificationId: string + userId: string + mobile: string + messageBody: string + status: string + provider?: string | null + providerRef?: string | null + errorMessage?: string | null + sentAt?: string | null + deliveredAt?: string | null + createdAt: string + updatedAt: string + recipient?: { + firstName: string | null + lastName: string | null + } +} + +// Read-only troubleshooting list — GET /admin/sms-messages only supports +// filtering by status/userId and sorting by createdAt (see +// backend/src/modules/notifications/notifications.service.ts, +// ADMIN_SMS_FILTER_KEYS / ALLOWED_SORT_FIELDS). Columns that aren't +// whitelisted there (provider, sentAt, deliveredAt) are shown but not +// marked filterable/sortable, so the UI never implies a capability the +// API doesn't have. +const columns: PaginationListColumnType[] = [ + { + field: 'userId', + label: 'گیرنده', + filterable: true, + sortable: false, + type: 'text', + }, + { + field: 'mobile', + label: 'موبایل', + filterable: true, + sortable: false, + type: 'text', + }, + { + field: 'messageBody', + label: 'متن پیامک', + filterable: false, + sortable: false, + }, + { + field: 'status', + label: 'وضعیت', + filterable: true, + sortable: false, + type: 'select', + filterItems: SMS_STATUS_FILTER_ITEMS, + }, + { + field: 'provider', + label: 'ارائه‌دهنده', + filterable: false, + sortable: false, + }, + { + field: 'providerRef', + label: 'کد پیگیری', + filterable: false, + sortable: false, + }, + { + field: 'sentAt', + label: 'زمان ارسال', + filterable: false, + sortable: false, + }, + { + field: 'deliveredAt', + label: 'زمان تحویل', + filterable: false, + sortable: false, + }, + { + field: 'createdAt', + label: 'تاریخ ثبت', + filterable: false, + sortable: true, + }, + { + field: 'actions', + label: 'عملیات', + }, +] + +const SmsMessagesPanel = () => { + const { isOpen, onOpenChange, onOpen } = useDisclosure() + const [activeMessage, setActiveMessage] = useState(null) + + const openMessageModal = (row: SmsMessageRow) => { + setActiveMessage(row) + onOpen() + } + + return ( + <> + + {{ + userId: (row) => { + const message = row as unknown as SmsMessageRow + const name = formatPersonName(message.recipient?.firstName, message.recipient?.lastName) + + return ( +
+ {name !== '—' && {name}} + + {formatIranianMobile(message.mobile)} + +
+ ) + }, + mobile: (_row, cellValue) => {formatIranianMobile(coerceToString(cellValue))}, + messageBody: (row, cellValue) => { + const message = row as unknown as SmsMessageRow + const body = coerceToString(cellValue) + + return ( +
+ {truncateValue(body)} + {body.length > 80 && ( + + )} +
+ ) + }, + status: (row, cellValue) => { + const message = row as unknown as SmsMessageRow + const { label, chipColor } = getSmsStatus(coerceToString(cellValue)) + + return ( + + ) + }, + provider: (_row, cellValue) => (cellValue ? coerceToString(cellValue) : '—'), + providerRef: (_row, cellValue) => (cellValue ? coerceToString(cellValue) : '—'), + sentAt: (_row, cellValue) => formatPersianDate(cellValue), + deliveredAt: (_row, cellValue) => formatPersianDate(cellValue), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => { + const message = row as unknown as SmsMessageRow + + return ( + + ) + }, + }} +
+ + +

{activeMessage?.messageBody}

+
+ + ) +} + +export default SmsMessagesPanel diff --git a/app/(dashboard)/notifications/page.tsx b/app/(dashboard)/notifications/page.tsx new file mode 100644 index 0000000..f53cf9a --- /dev/null +++ b/app/(dashboard)/notifications/page.tsx @@ -0,0 +1,110 @@ +'use client' + +import { useState } from 'react' +import dynamic from 'next/dynamic' + +import PageNavbar from '@/components/layouts/PageNavbar' +import Button from '@/components/formElements/Button' +import { Tab } from '@/components/heroui/Tabs' +import AppTabs from '@/components/ui/AppTabs' +import { useQueryTab } from '@/hooks/useQueryTab' + +const InAppNotificationsPanel = dynamic(() => import('@/app/(dashboard)/notifications/_components/InAppNotificationsPanel'), { + ssr: false, +}) +const PushDeliveriesPanel = dynamic(() => import('@/app/(dashboard)/notifications/_components/PushDeliveriesPanel'), { + ssr: false, +}) +const SmsMessagesPanel = dynamic(() => import('@/app/(dashboard)/notifications/_components/SmsMessagesPanel'), { + ssr: false, +}) +const NotificationRulesPanel = dynamic(() => import('@/app/(dashboard)/notifications/_components/NotificationRulesPanel'), { + ssr: false, +}) +const SendManualNotificationModal = dynamic(() => import('@/app/(dashboard)/notifications/_components/SendManualNotificationModal'), { + ssr: false, +}) + +const NOTIFICATION_TABS = ['in-app', 'push', 'sms', 'settings'] as const + +type NotificationTab = (typeof NOTIFICATION_TABS)[number] +type SendChannel = 'in_app' | 'sms' | 'both' + +const defaultChannelForTab = (tab: NotificationTab): SendChannel => (tab === 'sms' ? 'sms' : 'in_app') + +export default function NotificationsPage() { + const [selectedTab, handleTabChange] = useQueryTab({ + values: NOTIFICATION_TABS, + defaultValue: 'in-app', + }) + const [isSendOpen, setIsSendOpen] = useState(false) + const [listEpoch, setListEpoch] = useState(0) + + return ( +
+ { + setIsSendOpen(true) + }} + > + ارسال + + ) + } + pageTitle="اعلان‌ها" + /> +
+ + + {selectedTab === 'in-app' ? : null} + + + {selectedTab === 'push' ? : null} + + + {selectedTab === 'sms' ? : null} + + + {selectedTab === 'settings' ? : null} + + +
+ {isSendOpen ? ( + { + setIsSendOpen(false) + }} + onSent={() => { + setIsSendOpen(false) + setListEpoch((current) => current + 1) + }} + /> + ) : null} +
+ ) +} diff --git a/app/(dashboard)/payments/page.tsx b/app/(dashboard)/payments/page.tsx new file mode 100644 index 0000000..7dd4590 --- /dev/null +++ b/app/(dashboard)/payments/page.tsx @@ -0,0 +1,196 @@ +'use client' + +import type { PaginationListColumnType } from '@/types' +import PaginatedList from '@/components/PaginatedList' +import StatusChip from '@/components/ui/StatusChip' +import PageNavbar from '@/components/layouts/PageNavbar' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import { APP_ROUTES } from '@/constants/routes' +import { formatCurrency, formatPersonName, coerceToString } from '@/helpers' +import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' +import { getPaymentMethod, getPaymentStatus, PAYMENT_METHOD_FILTER_ITEMS, PAYMENT_STATUS_FILTER_ITEMS } from '@/constants/status' +import { API_ROUTES } from '@/services/config' + +interface PaymentUser { + id: string + mobile: string + firstName: string | null + lastName: string | null +} + +const columns: PaginationListColumnType[] = [ + { + field: 'paymentCode', + label: 'کد پرداخت', + filterable: false, + sortable: false, + type: 'text', + }, + { + field: 'user', + label: 'کاربر', + filterable: false, + sortable: false, + }, + { + field: 'eventTitle', + label: 'رویداد', + filterable: false, + sortable: false, + type: 'text', + }, + { + field: 'bookingCode', + label: 'کد رزرو', + filterable: false, + sortable: false, + type: 'text', + }, + { + field: 'method', + label: 'روش پرداخت', + filterable: true, + sortable: false, + type: 'select', + filterItems: PAYMENT_METHOD_FILTER_ITEMS, + }, + { + field: 'gatewayProvider', + label: 'درگاه', + filterable: false, + sortable: false, + type: 'text', + }, + { + field: 'gatewayTrackingId', + label: 'شناسه درگاه', + filterable: false, + sortable: false, + type: 'text', + }, + { + field: 'gatewayRefId', + label: 'RRN درگاه', + filterable: false, + sortable: false, + type: 'text', + }, + { + field: 'status', + label: 'وضعیت', + filterable: true, + sortable: true, + type: 'select', + filterItems: PAYMENT_STATUS_FILTER_ITEMS, + }, + { + field: 'totalAmount', + label: 'مبلغ کل', + filterable: false, + sortable: false, + }, + { + field: 'paidAt', + label: 'تاریخ پرداخت', + filterable: false, + sortable: true, + type: 'date', + }, + { + field: 'createdAt', + label: 'تاریخ ثبت', + filterable: true, + sortable: true, + type: 'dateFromTo', + }, + { + field: 'actions', + label: 'عملیات', + }, +] + +const PaymentsPage = () => { + return ( +
+ +
+ + {{ + user: (row) => { + const user = row.user as PaymentUser | undefined + + if (!user) return '—' + + const name = formatPersonName(user.firstName ?? undefined, user.lastName ?? undefined) + + return ( +
+ {name} + + {formatIranianMobile(user.mobile)} + +
+ ) + }, + method: (row, cellValue) => { + const provider = coerceToString(row.gatewayProvider) + + if (provider) { + return ( + + ) + } + + return + }, + gatewayProvider: (_row, cellValue) => coerceToString(cellValue) || '—', + gatewayTrackingId: (_row, cellValue) => ( + + {coerceToString(cellValue) || '—'} + + ), + gatewayRefId: (_row, cellValue) => ( + + {coerceToString(cellValue) || '—'} + + ), + status: (_row, cellValue) => , + totalAmount: (_row, cellValue) => formatCurrency(Number(cellValue ?? 0)), + paidAt: (_row, cellValue) => formatPersianDate(cellValue), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => { + const user = row.user as PaymentUser | undefined + + if (!user) return '—' + + return ( + + ) + }, + }} +
+
+
+ ) +} + +export default PaymentsPage diff --git a/app/(dashboard)/provinces/page.tsx b/app/(dashboard)/provinces/page.tsx new file mode 100644 index 0000000..ae4d322 --- /dev/null +++ b/app/(dashboard)/provinces/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from 'next/navigation' + +// Province-level management UI is disabled — the admin surface only manages +// cities (see `cities/page.tsx`'s own note on why province filtering is +// off). The pre-disable implementation (a standalone PaginatedList of +// provinces) is preserved in git history (blame this file) rather than kept +// here commented out, should it need restoring. +export default function ProvincesPage() { + redirect('/cities') +} diff --git a/app/(dashboard)/push-deliveries/page.tsx b/app/(dashboard)/push-deliveries/page.tsx new file mode 100644 index 0000000..15e4fe9 --- /dev/null +++ b/app/(dashboard)/push-deliveries/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from 'next/navigation' + +import { APP_ROUTES } from '@/constants/routes' + +export default function PushDeliveriesRedirectPage() { + redirect(APP_ROUTES.NOTIFICATIONS_PUSH) +} diff --git a/app/(dashboard)/reviews/page.tsx b/app/(dashboard)/reviews/page.tsx new file mode 100644 index 0000000..ddf5c99 --- /dev/null +++ b/app/(dashboard)/reviews/page.tsx @@ -0,0 +1,227 @@ +'use client' + +import type { PaginationListColumnType } from '@/types' +import PaginatedList from '@/components/PaginatedList' +import PageNavbar from '@/components/layouts/PageNavbar' +import Button from '@/components/formElements/Button' +import EyeCrossedIcon from '@/components/icons/EyeCrossedIcon' +import FileCheckIcon from '@/components/icons/FileCheckIcon' +import TrashIcon from '@/components/icons/TrashIcon' +import AdminTableActions from '@/components/ui/AdminTableActions' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import StatusChip from '@/components/ui/StatusChip' +import axiosInstance from '@/config/axios' +import { APP_ROUTES } from '@/constants/routes' +import useAlertModal from '@/hooks/useAlertModal' +import useAdminMutation from '@/hooks/useAdminMutation' +import { formatPersonName, coerceToString } from '@/helpers' +import { formatPersianDate, truncateValue } from '@/lib/formatters' +import { getReviewStatus, REVIEW_STATUS_FILTER_ITEMS, type ReviewStatus } from '@/constants/status' +import { API_ROUTES } from '@/services/config' + +const columns: PaginationListColumnType[] = [ + { + field: 'eventId', + label: 'رویداد', + filterable: true, + type: 'text', + sortable: false, + }, + { + field: 'userId', + label: 'نویسنده', + filterable: false, + sortable: false, + }, + { + field: 'rating', + label: 'امتیاز', + filterable: true, + type: 'number', + sortable: true, + }, + { + field: 'status', + label: 'وضعیت', + filterable: true, + type: 'select', + sortable: false, + filterItems: REVIEW_STATUS_FILTER_ITEMS, + }, + { + field: 'createdAt', + label: 'تاریخ ثبت', + filterable: true, + sortable: true, + type: 'dateFromTo', + }, + { + field: 'body', + label: 'متن نظر', + filterable: false, + sortable: false, + }, + { + field: 'actions', + label: 'عملیات', + }, +] + +interface ReviewEventSummary { + id: string + title: string +} + +interface ReviewUserSummary { + id: string + firstName: string | null + lastName: string | null +} + +interface ReviewRow { + id: string + eventId: string + userId: string + status: ReviewStatus + event?: ReviewEventSummary + user?: ReviewUserSummary + [key: string]: unknown +} + +const getEventSummary = (row: ReviewRow) => { + if (row.event && typeof row.event === 'object') { + return row.event + } + + return { id: row.eventId, title: '—' } +} + +const getUserSummary = (row: ReviewRow) => { + if (row.user && typeof row.user === 'object') { + return row.user + } + + return { id: row.userId, firstName: null, lastName: null } +} + +const ReviewsPage = () => { + const { showAlert } = useAlertModal() + const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.REVIEWS.ADMIN_LIST }) + + const handleHide = (row: ReviewRow) => { + showAlert('این نظر از نمایش عمومی مخفی شود؟', () => + runAction(row.id, () => axiosInstance.patch(API_ROUTES.REVIEWS.ADMIN_HIDE(row.id)), 'نظر مخفی شد') + ) + } + + const handleRestore = (row: ReviewRow) => { + void runAction(row.id, () => axiosInstance.patch(API_ROUTES.REVIEWS.ADMIN_RESTORE(row.id)), 'نظر بازگردانده شد') + } + + const handleDelete = (row: ReviewRow) => { + showAlert( + 'این نظر برای همیشه حذف شود؟ این عملیات قابل بازگشت نیست.', + () => runAction(row.id, () => axiosInstance.delete(API_ROUTES.REVIEWS.ADMIN_DELETE(row.id)), 'نظر حذف شد'), + undefined, + { dangerAccept: true } + ) + } + + return ( +
+ +
+ + {{ + eventId: (row) => getEventSummary(row as ReviewRow).title, + userId: (row) => { + const user = getUserSummary(row as ReviewRow) + + return formatPersonName(user.firstName, user.lastName) + }, + body: (_row, cellValue) => truncateValue(cellValue), + status: (_row, cellValue) => { + const { label, chipColor } = getReviewStatus(coerceToString(cellValue)) + + return ( + + ) + }, + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => { + const review = row as ReviewRow + const isBusy = pendingId === review.id + const event = getEventSummary(review) + + return ( + + + {review.status !== 'deleted' && review.status === 'published' ? ( + + ) : null} + {review.status !== 'deleted' && review.status === 'hidden' ? ( + + ) : null} + {review.status !== 'deleted' ? ( + + ) : null} + + ) + }, + }} + +
+
+ ) +} + +export default ReviewsPage diff --git a/app/(dashboard)/settlements/_components/CreateSettlementModal.tsx b/app/(dashboard)/settlements/_components/CreateSettlementModal.tsx new file mode 100644 index 0000000..40d5022 --- /dev/null +++ b/app/(dashboard)/settlements/_components/CreateSettlementModal.tsx @@ -0,0 +1,204 @@ +'use client' + +import { useState } from 'react' +import DatePicker from 'react-multi-date-picker' +import persian from 'react-date-object/calendars/persian' +import persian_fa from 'react-date-object/locales/persian_fa' +import 'react-multi-date-picker/styles/layouts/mobile.css' + +import { addToast } from '@/lib/toast' +import Input from '@/components/formElements/Input' +import Modal from '@/components/modals/Modal' +import axiosInstance from '@/config/axios' +import { convertToISOFormat, coerceToString } from '@/helpers' +import { API_ROUTES } from '@/services/config' +import { extractServerErrorDetail } from '@/services/errorHandler' + +interface CreateSettlementModalProps { + isOpen: boolean + onClose: () => void + onCreated: () => void +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +type FieldErrors = Partial> + +function toDatePickerValue(value: string): Date | null { + if (!value) return null + const date = new Date(value) + + return Number.isFinite(date.getTime()) ? date : null +} + +const CreateSettlementModal = ({ isOpen, onClose, onCreated }: CreateSettlementModalProps) => { + const [organizerId, setOrganizerId] = useState('') + const [bankAccountId, setBankAccountId] = useState('') + const [periodStart, setPeriodStart] = useState('') + const [periodEnd, setPeriodEnd] = useState('') + const [errors, setErrors] = useState({}) + const [isSubmitting, setIsSubmitting] = useState(false) + + const resetForm = () => { + setOrganizerId('') + setBankAccountId('') + setPeriodStart('') + setPeriodEnd('') + setErrors({}) + } + + const handleClose = () => { + if (isSubmitting) return + + resetForm() + onClose() + } + + const validate = (): FieldErrors => { + const nextErrors: FieldErrors = {} + + if (!organizerId.trim()) { + nextErrors.organizerId = 'شناسه میزبان الزامی است' + } else if (!UUID_PATTERN.test(organizerId.trim())) { + nextErrors.organizerId = 'شناسه میزبان باید یک UUID معتبر باشد' + } + + if (!bankAccountId.trim()) { + nextErrors.bankAccountId = 'شناسه حساب بانکی الزامی است' + } else if (!UUID_PATTERN.test(bankAccountId.trim())) { + nextErrors.bankAccountId = 'شناسه حساب بانکی باید یک UUID معتبر باشد' + } + + if (!periodStart) { + nextErrors.periodStart = 'ابتدای بازه الزامی است' + } + + if (!periodEnd) { + nextErrors.periodEnd = 'انتهای بازه الزامی است' + } + + if (periodStart && periodEnd && periodEnd < periodStart) { + nextErrors.periodEnd = 'انتهای بازه باید بعد از ابتدای بازه باشد' + } + + return nextErrors + } + + const handleSubmit = async () => { + const nextErrors = validate() + + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + try { + setIsSubmitting(true) + await axiosInstance.post(API_ROUTES.SETTLEMENTS.ADMIN_CREATE, { + organizerId: organizerId.trim(), + bankAccountId: bankAccountId.trim(), + periodStart, + periodEnd, + }) + addToast({ title: 'تسویه با موفقیت ایجاد شد', color: 'success' }) + resetForm() + onCreated() + } catch (err) { + const detail = extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data) + + addToast({ + title: 'ایجاد تسویه ناموفق بود', + description: detail ?? 'ممکن است حساب بانکی معتبر نباشد یا درآمد قابل تسویه‌ای در این بازه وجود نداشته باشد.', + color: 'danger', + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + { + if (!open) handleClose() + }} + onReject={handleClose} + > +
+
+ { + setOrganizerId(coerceToString(next)) + }} + /> + {errors.organizerId && {errors.organizerId}} +
+ +
+ { + setBankAccountId(coerceToString(next)) + }} + /> + {errors.bankAccountId && {errors.bankAccountId}} +
+ +
+
+ ابتدای بازه + { + setPeriodStart(date ? convertToISOFormat(date).split('T')[0] : '') + }} + /> + {errors.periodStart && {errors.periodStart}} +
+ +
+ انتهای بازه + { + setPeriodEnd(date ? convertToISOFormat(date).split('T')[0] : '') + }} + /> + {errors.periodEnd && {errors.periodEnd}} +
+
+
+
+ ) +} + +export default CreateSettlementModal diff --git a/app/(dashboard)/settlements/page.tsx b/app/(dashboard)/settlements/page.tsx new file mode 100644 index 0000000..569b17f --- /dev/null +++ b/app/(dashboard)/settlements/page.tsx @@ -0,0 +1,329 @@ +'use client' + +import { useState } from 'react' +import dynamic from 'next/dynamic' +import { useQueryClient } from '@tanstack/react-query' + +import { addToast } from '@/lib/toast' +import type { PaginationListColumnType } from '@/types' +import PaginatedList from '@/components/PaginatedList' +import PageNavbar from '@/components/layouts/PageNavbar' +import Button from '@/components/formElements/Button' +import FileCheckIcon from '@/components/icons/FileCheckIcon' +import Modal from '@/components/modals/Modal' +import { ListSkeleton } from '@/components/feedback/LoadingState' +import AdminTableActions from '@/components/ui/AdminTableActions' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import StatusChip from '@/components/ui/StatusChip' +import axiosInstance from '@/config/axios' +import { getSettlementStatus, type SettlementStatus, SETTLEMENT_STATUS_FILTER_ITEMS } from '@/constants/status' +import { formatCurrency, coerceToString } from '@/helpers' +import { unwrapApiPayload } from '@/helpers/listResponse' +import useAdminMutation from '@/hooks/useAdminMutation' +import { formatPersianDate } from '@/lib/formatters' +import { adminKeys } from '@/queries/admin/adminKeys' +import { API_ROUTES } from '@/services/config' + +const CreateSettlementModal = dynamic(() => import('@/app/(dashboard)/settlements/_components/CreateSettlementModal'), { ssr: false }) +const ManualPayoutModal = dynamic(() => import('@/components/admin/ManualPayoutModal'), { ssr: false }) + +interface SettlementItem { + id: string + amount: number | string + organizerEarningId: string + eventId: string + eventTitle: string +} + +interface SettlementRow { + id: string + settlementCode: string + organizerId: string + bankAccountId: string + periodStart: string + periodEnd: string + totalAmount: number | string + status: SettlementStatus + processedAt?: string | null + failureReason?: string | null + manualTrackingCode?: string | null + manualReceiptUrl?: string | null + manualNote?: string | null + createdAt: string + updatedAt: string + items?: SettlementItem[] + [key: string]: unknown +} + +const COMPLETABLE_STATUSES: SettlementStatus[] = ['pending', 'processing'] + +const columns: PaginationListColumnType[] = [ + { + field: 'settlementCode', + label: 'کد تسویه', + filterable: false, + sortable: false, + type: 'text', + }, + { + field: 'organizerId', + label: 'میزبان', + filterable: true, + sortable: false, + type: 'text', + hideInTable: true, + }, + { + field: 'period', + label: 'بازه زمانی', + filterable: false, + sortable: false, + }, + { + field: 'totalAmount', + label: 'مبلغ کل', + filterable: false, + sortable: true, + }, + { + field: 'status', + label: 'وضعیت', + filterable: true, + sortable: true, + type: 'select', + filterItems: SETTLEMENT_STATUS_FILTER_ITEMS, + }, + { + field: 'createdAt', + label: 'تاریخ ثبت', + filterable: false, + sortable: true, + type: 'date', + }, + { + field: 'actions', + label: 'عملیات', + }, +] + +const formatDate = (value: unknown, withTime = false) => + formatPersianDate(value, withTime ? { dateStyle: 'medium', timeStyle: 'short' } : { dateStyle: 'medium' }) + +const SettlementsPage = () => { + const queryClient = useQueryClient() + const { pendingId, runAction } = useAdminMutation({ + errorMessage: 'تکمیل تسویه با خطا مواجه شد', + url: API_ROUTES.SETTLEMENTS.ADMIN_LIST, + }) + const [detailTarget, setDetailTarget] = useState(null) + const [detailItems, setDetailItems] = useState([]) + const [isLoadingDetail, setIsLoadingDetail] = useState(false) + const [isCreateOpen, setIsCreateOpen] = useState(false) + const [manualTarget, setManualTarget] = useState(null) + + const openDetail = async (row: SettlementRow) => { + setDetailTarget(row) + setDetailItems([]) + setIsLoadingDetail(true) + try { + const response = await axiosInstance.get(API_ROUTES.SETTLEMENTS.ADMIN_DETAIL(row.id)) + const payload = unwrapApiPayload<{ items?: SettlementItem[] }>(response.data) + + setDetailItems(Array.isArray(payload.items) ? payload.items : []) + } catch { + addToast({ title: 'بارگذاری جزئیات تسویه ناموفق بود', color: 'danger' }) + } finally { + setIsLoadingDetail(false) + } + } + + const closeDetail = () => { + setDetailTarget(null) + setDetailItems([]) + } + + return ( +
+ { + setIsCreateOpen(true) + }} + > + ایجاد تسویه + + } + pageTitle="تسویه‌ها" + /> +
+ + {{ + period: (row) => { + const settlement = row as SettlementRow + + return ( + + {formatDate(settlement.periodStart)} — {formatDate(settlement.periodEnd)} + + ) + }, + totalAmount: (_row, cellValue) => formatCurrency(Number(cellValue ?? 0)), + createdAt: (_row, cellValue) => formatDate(cellValue, true), + status: (row, cellValue) => { + const settlement = row as SettlementRow + const { label, chipColor } = getSettlementStatus(coerceToString(cellValue)) + + return ( + + ) + }, + actions: (row) => { + const settlement = row as SettlementRow + const isBusy = pendingId === settlement.id + const canComplete = COMPLETABLE_STATUSES.includes(settlement.status) + + return ( + + openDetail(settlement)} + /> + {canComplete ? ( + + ) : null} + + ) + }, + }} + +
+ + { + if (!open) closeDetail() + }} + onReject={closeDetail} + > + {isLoadingDetail ? ( + + ) : ( +
+ {detailTarget?.status === 'failed' && detailTarget?.failureReason ? ( +
+ دلیل شکست: + {detailTarget.failureReason} +
+ ) : null} + + {detailTarget?.status === 'completed' ? ( +
+

+ شماره پیگیری: + {detailTarget.manualTrackingCode ?? '—'} +

+ {detailTarget.manualNote ? ( +

+ یادداشت: + {detailTarget.manualNote} +

+ ) : null} + {detailTarget.manualReceiptUrl ? ( + + مشاهده رسید پرداخت + + ) : null} +
+ ) : null} + + {detailItems.length === 0 ? ( +
هیچ قلمی برای این تسویه ثبت نشده است.
+ ) : ( +
+
+ رویداد + مبلغ +
+ {detailItems.map((item) => ( +
+ {item.eventTitle} + {formatCurrency(Number(item.amount ?? 0))} +
+ ))} +
+ )} +
+ )} +
+ + {isCreateOpen ? ( + { + setIsCreateOpen(false) + }} + onCreated={() => { + setIsCreateOpen(false) + void queryClient.invalidateQueries({ queryKey: adminKeys.listByUrl(API_ROUTES.SETTLEMENTS.ADMIN_LIST) }) + }} + /> + ) : null} + + {manualTarget ? ( + { + setManualTarget(null) + }} + onSubmit={async (payload) => { + return runAction( + manualTarget.id, + () => axiosInstance.patch(API_ROUTES.SETTLEMENTS.ADMIN_COMPLETE(manualTarget.id), payload), + 'تسویه با رسید دستی تکمیل شد' + ) + }} + /> + ) : null} +
+ ) +} + +export default SettlementsPage diff --git a/app/(dashboard)/sms-messages/page.tsx b/app/(dashboard)/sms-messages/page.tsx new file mode 100644 index 0000000..7e83a01 --- /dev/null +++ b/app/(dashboard)/sms-messages/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from 'next/navigation' + +import { APP_ROUTES } from '@/constants/routes' + +export default function SmsMessagesRedirectPage() { + redirect(APP_ROUTES.NOTIFICATIONS_SMS) +} diff --git a/app/(dashboard)/support-tickets/[id]/page.tsx b/app/(dashboard)/support-tickets/[id]/page.tsx new file mode 100644 index 0000000..f42fa89 --- /dev/null +++ b/app/(dashboard)/support-tickets/[id]/page.tsx @@ -0,0 +1,148 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' + +import PageNavbar from '@/components/layouts/PageNavbar' +import Button from '@/components/formElements/Button' +import Input from '@/components/formElements/Input' +import { APP_ROUTES } from '@/constants/routes' +import { addToast } from '@/lib/toast' +import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' +import { + adminReplyToSupportTicket, + getAdminSupportTicket, + SUPPORT_ADMIN_STATUS_LABELS, + SUPPORT_CATEGORY_LABELS, + SUPPORT_PRIORITY_LABELS, + updateSupportTicketStatus, + type SupportTicket, +} from '@/services/supportTickets' + +const AdminSupportTicketDetail = () => { + const { id } = useParams<{ id: string }>() + const [ticket, setTicket] = useState(null) + const [reply, setReply] = useState('') + const [pending, setPending] = useState(false) + + useEffect(() => { + void getAdminSupportTicket(id) + .then(setTicket) + .catch(() => addToast({ title: 'دریافت تیکت ناموفق بود', color: 'danger' })) + }, [id]) + + const send = async () => { + if (!reply.trim()) return + try { + setPending(true) + const updated = await adminReplyToSupportTicket(id, reply) + + setTicket(updated) + setReply('') + addToast({ title: 'پاسخ ثبت شد و پیامک کاربر در صف ارسال قرار گرفت', color: 'success' }) + } catch { + addToast({ title: 'ثبت پاسخ ناموفق بود', color: 'danger' }) + } finally { + setPending(false) + } + } + + const changeStatus = async (status: string) => { + try { + setTicket(await updateSupportTicketStatus(id, status)) + addToast({ title: 'وضعیت تیکت تغییر کرد', color: 'success' }) + } catch { + addToast({ title: 'تغییر وضعیت ناموفق بود', color: 'danger' }) + } + } + + return ( +
+ +
+ + {!ticket ? ( +

در حال دریافت…

+ ) : ( + <> +
+
+
+

کاربر

+ {ticket.user.displayName || 'کاربر'} +

{formatIranianMobile(ticket.user.mobile)}

+
+
+

دسته‌بندی

+ {SUPPORT_CATEGORY_LABELS[ticket.category]} +
+
+

اولویت

+ {SUPPORT_PRIORITY_LABELS[ticket.priority]} +
+ +
+
+
+ {ticket.messages.map((message) => ( +
+

{message.isAdmin ? 'پشتیبانی' : ticket.user.displayName || 'کاربر'}

+

{message.body}

+

{formatPersianDate(message.createdAt)}

+
+ ))} +
+ {ticket.status !== 'closed' ? ( +
+ { + setReply(String(value)) + }} + /> +

پس از ثبت پاسخ، برای کاربر پیامک اطلاع‌رسانی ارسال می‌شود.

+ +
+ ) : null} + + )} +
+
+ ) +} + +export default AdminSupportTicketDetail diff --git a/app/(dashboard)/support-tickets/page.tsx b/app/(dashboard)/support-tickets/page.tsx new file mode 100644 index 0000000..5119497 --- /dev/null +++ b/app/(dashboard)/support-tickets/page.tsx @@ -0,0 +1,93 @@ +'use client' + +import type { PaginationListColumnType } from '@/types' +import PaginatedList from '@/components/PaginatedList' +import PageNavbar from '@/components/layouts/PageNavbar' +import Button from '@/components/formElements/Button' +import { APP_ROUTES } from '@/constants/routes' +import { API_ROUTES } from '@/services/config' +import { + SUPPORT_ADMIN_STATUS_LABELS, + SUPPORT_CATEGORY_LABELS, + SUPPORT_PRIORITY_LABELS, + type SupportTicket, +} from '@/services/supportTickets' +import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' +import { coerceToString } from '@/helpers' + +const columns: PaginationListColumnType[] = [ + { field: 'subject', label: 'موضوع', filterable: false, sortable: false }, + { field: 'user', label: 'کاربر', filterable: false, sortable: false }, + { + field: 'category', + label: 'دسته‌بندی', + filterable: true, + sortable: false, + type: 'select', + filterItems: Object.entries(SUPPORT_CATEGORY_LABELS).map(([code, name]) => ({ code, name })), + }, + { + field: 'priority', + label: 'اولویت', + filterable: true, + sortable: false, + type: 'select', + filterItems: Object.entries(SUPPORT_PRIORITY_LABELS).map(([code, name]) => ({ code, name })), + }, + { + field: 'status', + label: 'وضعیت', + filterable: true, + sortable: false, + type: 'select', + filterItems: Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([code, name]) => ({ code, name })), + }, + { field: 'lastMessageAt', label: 'آخرین پیام', filterable: false, sortable: true }, + { field: 'actions', label: 'عملیات' }, +] + +const AdminSupportTicketsPage = () => ( +
+ +
+ + {{ + subject: (_row, value) => coerceToString(value), + user: (row) => { + const ticket = row as unknown as SupportTicket + + return ( +
+

{ticket.user.displayName || 'کاربر'}

+

+ {formatIranianMobile(ticket.user.mobile)} +

+
+ ) + }, + category: (_row, value) => SUPPORT_CATEGORY_LABELS[coerceToString(value)] ?? coerceToString(value), + priority: (_row, value) => SUPPORT_PRIORITY_LABELS[coerceToString(value)] ?? coerceToString(value), + status: (_row, value) => SUPPORT_ADMIN_STATUS_LABELS[coerceToString(value)] ?? coerceToString(value), + lastMessageAt: (_row, value) => formatPersianDate(value), + actions: (row) => ( + + ), + }} +
+
+
+) + +export default AdminSupportTicketsPage diff --git a/app/(dashboard)/user-reports/page.tsx b/app/(dashboard)/user-reports/page.tsx new file mode 100644 index 0000000..afd0695 --- /dev/null +++ b/app/(dashboard)/user-reports/page.tsx @@ -0,0 +1,272 @@ +'use client' + +import { useState } from 'react' + +import type { PaginationListColumnType } from '@/types' +import PaginatedList from '@/components/PaginatedList' +import PageNavbar from '@/components/layouts/PageNavbar' +import Button from '@/components/formElements/Button' +import CloseCircleIcon from '@/components/icons/CloseCircleIcon' +import CloseIcon from '@/components/icons/CloseIcon' +import FileCheckIcon from '@/components/icons/FileCheckIcon' +import Modal from '@/components/modals/Modal' +import AdminTableActions from '@/components/ui/AdminTableActions' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import StatusChip from '@/components/ui/StatusChip' +import axiosInstance from '@/config/axios' +import { APP_ROUTES } from '@/constants/routes' +import useAlertModal from '@/hooks/useAlertModal' +import useAdminMutation from '@/hooks/useAdminMutation' +import { formatPersonName, coerceToString } from '@/helpers' +import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters' +import { getReportStatus, REPORT_STATUS_FILTER_ITEMS, type ReportStatus } from '@/constants/status' +import { API_ROUTES } from '@/services/config' + +const columns: PaginationListColumnType[] = [ + { + field: 'reporterId', + label: 'گزارش‌دهنده', + filterable: true, + type: 'text', + sortable: false, + }, + { + field: 'reportedId', + label: 'کاربر گزارش‌شده', + filterable: true, + type: 'text', + sortable: false, + }, + { + field: 'reason', + label: 'دلیل', + filterable: false, + sortable: false, + }, + { + field: 'description', + label: 'توضیحات', + filterable: false, + sortable: false, + }, + { + field: 'status', + label: 'وضعیت', + filterable: true, + type: 'select', + sortable: true, + filterItems: REPORT_STATUS_FILTER_ITEMS, + }, + { + field: 'createdAt', + label: 'تاریخ ثبت', + filterable: false, + sortable: true, + type: 'dateFromTo', + }, + { + field: 'actions', + label: 'عملیات', + }, +] + +interface ReportReasonSummary { + id: number + code: string + label: string + sortOrder: number +} + +interface ReportUserSummary { + mobile: string + firstName: string | null + lastName: string | null +} + +interface UserReportRow { + id: string + reporterId: string + reportedId: string + reasonId?: number | null + description?: string | null + status: ReportStatus + reviewedAt?: string | null + reviewedBy?: string | null + createdAt: string + reason?: ReportReasonSummary | null + reporter: ReportUserSummary + reported: ReportUserSummary + [key: string]: unknown +} + +const UserPartyCell = ({ summary }: { summary: ReportUserSummary }) => ( +
+ {formatPersonName(summary.firstName, summary.lastName)} + {formatIranianMobile(summary.mobile)} +
+) + +const UserReportsPage = () => { + const { showAlert } = useAlertModal() + const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.USER_REPORTS.ADMIN_LIST }) + const [selectedDescription, setSelectedDescription] = useState(null) + + const handleReview = (row: UserReportRow) => { + showAlert('این گزارش بررسی و تأیید شود؟', () => + runAction(row.id, () => axiosInstance.patch(API_ROUTES.USER_REPORTS.ADMIN_REVIEW(row.id)), 'گزارش بررسی شد') + ) + } + + const handleDismiss = (row: UserReportRow) => { + showAlert('این گزارش رد شود؟', () => + runAction(row.id, () => axiosInstance.patch(API_ROUTES.USER_REPORTS.ADMIN_DISMISS(row.id)), 'گزارش رد شد') + ) + } + + const handleBlock = (row: UserReportRow) => { + showAlert( + 'کاربر گزارش‌شده به‌نمایندگی از گزارش‌دهنده مسدود شود؟ این گزارش نیز به‌عنوان بررسی‌شده علامت‌گذاری می‌شود.', + () => runAction(row.id, () => axiosInstance.post(API_ROUTES.USER_REPORTS.ADMIN_BLOCK(row.id)), 'کاربر مسدود و گزارش بررسی شد'), + undefined, + { dangerAccept: true } + ) + } + + return ( +
+ +
+ + {{ + reporterId: (row) => { + const report = row as UserReportRow + + return + }, + reportedId: (row) => { + const report = row as UserReportRow + + return + }, + reason: (row) => { + const report = row as UserReportRow + + return report.reason?.label ?? '—' + }, + description: (row) => { + const report = row as UserReportRow + const description = report.description + + if (!description || description.trim().length === 0) return '—' + + return ( + + ) + }, + status: (row, cellValue) => { + const report = row as UserReportRow + const { label, chipColor } = getReportStatus(coerceToString(cellValue)) + const isClosed = report.status === 'reviewed' || report.status === 'dismissed' + + return ( + + ) + }, + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => { + const report = row as UserReportRow + const isBusy = pendingId === report.id + + return ( + + + {report.status === 'pending' ? ( + <> + + + + + ) : null} + + ) + }, + }} + +
+ { + setSelectedDescription(null) + }} + onOpenChange={(isOpen) => { + if (!isOpen) setSelectedDescription(null) + }} + > +

{selectedDescription}

+
+
+ ) +} + +export default UserReportsPage diff --git a/app/(dashboard)/users/[id]/_components/UserEditModal.tsx b/app/(dashboard)/users/[id]/_components/UserEditModal.tsx new file mode 100644 index 0000000..7270ecc --- /dev/null +++ b/app/(dashboard)/users/[id]/_components/UserEditModal.tsx @@ -0,0 +1,262 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' + +import { addToast } from '@/lib/toast' +import type { AdminUserDetail } from '@/services/adminUserDetail' +import Modal from '@/components/modals/Modal' +import Input from '@/components/formElements/Input' +import { AdminFormSection } from '@/components/forms/AdminFormLayout' +import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator' +import { ADMIN_UPDATE_USER, type AdminUpdateUserPayload } from '@/services/adminUsers' +import { API_ROUTES } from '@/services/config' +import { AdminUserEditValidation, type AdminUserEditValues } from '@/validation/adminUsers' +import useAlertModal from '@/hooks/useAlertModal' +import axiosInstance from '@/config/axios' + +const STATUS_OPTIONS = [ + { id: 'active', name: 'فعال' }, + { id: 'suspended', name: 'معلق' }, +] + +const HOST_PLAN_OPTIONS = [ + { id: 'free', name: 'رایگان (حداکثر ۱۰ ایونت)' }, + { id: 'unlimited', name: 'نامحدود' }, +] + +const GENDER_OPTIONS = [ + { id: 'male', name: 'مرد' }, + { id: 'female', name: 'زن' }, + { id: 'other', name: 'سایر' }, +] + +interface CityOption { + id: number + name: string +} + +const toFormValues = (user: AdminUserDetail): AdminUserEditValues => ({ + firstName: user.firstName ?? '', + lastName: user.lastName ?? '', + gender: user.gender ?? '', + cityId: user.cityId ? String(user.cityId) : '', + avatarUrl: user.avatarUrl ?? '', + bio: user.bio ?? '', + status: user.status === 'suspended' ? 'suspended' : 'active', + hostPlan: user.hostPlan ?? 'free', +}) + +interface UserEditModalProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void + user: AdminUserDetail + /** Current admin's own id — status can't be self-edited. */ + currentAdminId?: string + onSuccess: () => void +} + +const UserEditModal = ({ isOpen, onOpenChange, user, currentAdminId, onSuccess }: UserEditModalProps) => { + const { showAlert } = useAlertModal() + const [submitting, setSubmitting] = useState(false) + const [cities, setCities] = useState([]) + + const isSelfEdit = Boolean(currentAdminId) && currentAdminId === user.id + + const form = useForm({ + resolver: zodResolver(AdminUserEditValidation), + defaultValues: toFormValues(user), + }) + const { reset } = form + + // Modal stays mounted across opens, so reset explicitly whenever it opens + // (and the target user's data may have refreshed since the last edit). + useEffect(() => { + if (isOpen) { + reset(toFormValues(user)) + } + }, [isOpen, reset, user]) + + useEffect(() => { + if (!isOpen || cities.length > 0) return + + axiosInstance + .get(API_ROUTES.GEOGRAPHY.ALL_CITIES) + .then((res) => { + const payload: unknown = res.data + let list: unknown = [] + + if (Array.isArray(payload)) { + list = payload + } else if (typeof payload === 'object' && payload !== null) { + const record = payload as { data?: unknown; body?: unknown } + + list = record.data ?? record.body ?? [] + } + + setCities(Array.isArray(list) ? (list as { id: number; name: string }[]) : []) + }) + .catch(() => { + setCities([]) + }) + }, [isOpen, cities.length]) + + const buildDiffPayload = (values: AdminUserEditValues): AdminUpdateUserPayload => { + const initial = toFormValues(user) + const payload: AdminUpdateUserPayload = {} + + if (values.firstName && values.firstName !== initial.firstName) payload.firstName = values.firstName + if (values.lastName && values.lastName !== initial.lastName) payload.lastName = values.lastName + if (values.gender && values.gender !== initial.gender) payload.gender = values.gender + if (values.cityId && values.cityId !== initial.cityId) payload.cityId = Number(values.cityId) + if (values.avatarUrl && values.avatarUrl !== initial.avatarUrl) payload.avatarUrl = values.avatarUrl + if (values.bio && values.bio !== initial.bio) payload.bio = values.bio + if (!isSelfEdit && values.status !== initial.status) payload.status = values.status + if (values.hostPlan !== initial.hostPlan) payload.hostPlan = values.hostPlan + + return payload + } + + const submitUpdate = async (values: AdminUserEditValues) => { + const payload = buildDiffPayload(values) + + if (Object.keys(payload).length === 0) { + onOpenChange(false) + + return + } + + setSubmitting(true) + const result = await ADMIN_UPDATE_USER(user.id, payload) + + setSubmitting(false) + + if (!result.ok) return + + addToast({ title: 'اطلاعات کاربر ذخیره شد', color: 'success' }) + onOpenChange(false) + onSuccess() + } + + const handleSubmit = (values: AdminUserEditValues) => { + // Suspending an account is destructive-ish for the user, so confirm first. + const initial = toFormValues(user) + + if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') { + showAlert('این کاربر معلق شود؟ کاربر تا فعال‌سازی مجدد امکان استفاده از حساب را نخواهد داشت.', () => submitUpdate(values)) + + return + } + + void submitUpdate(values) + } + + return ( + + +
+ + +
+
+ + +
+ + + + + +
+
+ + +
+ {isSelfEdit && ( +
+ وضعیت حساب خودتان از این‌جا قابل ویرایش نیست. +
+ )} + +
+
+ +
+
+ ) +} + +export default UserEditModal diff --git a/app/(dashboard)/users/[id]/_components/UserMobileChangeModal.test.tsx b/app/(dashboard)/users/[id]/_components/UserMobileChangeModal.test.tsx new file mode 100644 index 0000000..7073e3b --- /dev/null +++ b/app/(dashboard)/users/[id]/_components/UserMobileChangeModal.test.tsx @@ -0,0 +1,133 @@ +import type { ReactNode } from 'react' + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import UserMobileChangeModal from '@/app/(dashboard)/users/[id]/_components/UserMobileChangeModal' + +const changeMobile = vi.fn() +const addToast = vi.fn() + +vi.mock('@/services/adminUsers', () => ({ + ADMIN_CHANGE_USER_MOBILE: (...args: unknown[]) => changeMobile(...args), +})) +vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) })) +vi.mock('@/components/formElements/Input', () => ({ + default: ({ label, value, onValueChange }: { label: string; value?: unknown; onValueChange?: (value: string) => void }) => ( + + ), +})) +vi.mock('@/components/formElements/Button', () => ({ + default: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + + ), +})) +vi.mock('@/components/modals/Modal', () => ({ + default: ({ + isOpen, + title, + children, + acceptBtnText, + rejectBtnText, + acceptBtnDisabled, + onAccept, + onReject, + }: { + isOpen: boolean + title: string + children: ReactNode + acceptBtnText: string + rejectBtnText: string + acceptBtnDisabled?: boolean + onAccept?: () => void + onReject?: () => void + }) => + isOpen ? ( +
+ {children} + + +
+ ) : null, +})) + +describe('UserMobileChangeModal', () => { + afterEach(cleanup) + + beforeEach(() => { + vi.clearAllMocks() + changeMobile.mockResolvedValue({ ok: true, data: { id: 'user-1', mobile: '989121231231' } }) + }) + + it('requires all three confirmation stages before changing the mobile', async () => { + const onSuccess = vi.fn() + const onOpenChange = vi.fn() + + render( + + ) + + const firstAccept = screen.getByRole('button', { name: 'بررسی شماره' }) + + expect(firstAccept).toBeDisabled() + fireEvent.change(screen.getByLabelText('شماره موبایل جدید'), { target: { value: '09121231231' } }) + expect(firstAccept).toBeEnabled() + fireEvent.click(firstAccept) + + const secondAccept = screen.getByRole('button', { name: 'تأیید و ادامه' }) + + expect(secondAccept).toBeDisabled() + fireEvent.change(screen.getByLabelText('برای تأیید، شماره فعلی کاربر را وارد کنید'), { + target: { value: '09111111111' }, + }) + expect(secondAccept).toBeEnabled() + fireEvent.click(secondAccept) + + const finalAccept = screen.getByRole('button', { name: 'تغییر قطعی شماره' }) + + expect(finalAccept).toBeDisabled() + fireEvent.change(screen.getByLabelText('عبارت «تغییر شماره» را وارد کنید'), { + target: { value: 'تغییر شماره' }, + }) + expect(finalAccept).toBeEnabled() + fireEvent.click(finalAccept) + + await waitFor(() => { + expect(changeMobile).toHaveBeenCalledWith('user-1', '989121231231') + }) + expect(addToast).toHaveBeenCalledWith(expect.objectContaining({ color: 'success' })) + expect(onSuccess).toHaveBeenCalledTimes(1) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) +}) diff --git a/app/(dashboard)/users/[id]/_components/UserMobileChangeModal.tsx b/app/(dashboard)/users/[id]/_components/UserMobileChangeModal.tsx new file mode 100644 index 0000000..ffa4043 --- /dev/null +++ b/app/(dashboard)/users/[id]/_components/UserMobileChangeModal.tsx @@ -0,0 +1,247 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' + +import Button from '@/components/formElements/Button' +import Input from '@/components/formElements/Input' +import Modal from '@/components/modals/Modal' +import { formatIranianMobile } from '@/lib/formatters' +import { addToast } from '@/lib/toast' +import type { AdminUserDetail } from '@/services/adminUserDetail' +import { ADMIN_CHANGE_USER_MOBILE } from '@/services/adminUsers' +import { + confirmsCurrentMobile, + confirmsFinalMobileChange, + MOBILE_CHANGE_FINAL_CONFIRMATION, + normalizeAdminMobile, +} from '@/validation/adminUserMobile' + +type MobileChangeUser = Pick + +interface UserMobileChangeModalProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void + user: MobileChangeUser + onSuccess: () => void +} + +const TOTAL_STEPS = 3 + +const UserMobileChangeModal = ({ isOpen, onOpenChange, user, onSuccess }: UserMobileChangeModalProps) => { + const [step, setStep] = useState(1) + const [newMobileInput, setNewMobileInput] = useState('') + const [confirmedNewMobile, setConfirmedNewMobile] = useState('') + const [currentMobileConfirmation, setCurrentMobileConfirmation] = useState('') + const [finalConfirmation, setFinalConfirmation] = useState('') + const [submitting, setSubmitting] = useState(false) + + const normalizedNewMobile = useMemo(() => normalizeAdminMobile(newMobileInput), [newMobileInput]) + const newMobileError = useMemo(() => { + if (!newMobileInput.trim()) return null + if (!normalizedNewMobile) return 'شماره موبایل معتبر نیست.' + if (normalizedNewMobile === user.mobile) return 'شماره جدید باید با شماره فعلی متفاوت باشد.' + + return null + }, [newMobileInput, normalizedNewMobile, user.mobile]) + + const reset = useCallback(() => { + setStep(1) + setNewMobileInput('') + setConfirmedNewMobile('') + setCurrentMobileConfirmation('') + setFinalConfirmation('') + setSubmitting(false) + }, []) + + useEffect(() => { + if (isOpen) reset() + }, [isOpen, reset, user.id]) + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) reset() + onOpenChange(nextOpen) + } + + const canContinue = + step === 1 + ? Boolean(normalizedNewMobile) && normalizedNewMobile !== user.mobile + : step === 2 + ? confirmsCurrentMobile(currentMobileConfirmation, user.mobile) + : confirmsFinalMobileChange(finalConfirmation) + + const submit = async () => { + setSubmitting(true) + const result = await ADMIN_CHANGE_USER_MOBILE(user.id, confirmedNewMobile) + + setSubmitting(false) + if (!result.ok) return + + addToast({ + color: 'success', + title: 'شماره موبایل کاربر تغییر کرد', + description: 'تمام نشست‌های فعال کاربر باطل شدند.', + }) + onSuccess() + handleOpenChange(false) + } + + const handleAccept = () => { + if (!canContinue) return + + if (step === 1 && normalizedNewMobile) { + setConfirmedNewMobile(normalizedNewMobile) + setStep(2) + + return + } + if (step === 2) { + setStep(3) + + return + } + + void submit() + } + + const handleReject = () => { + if (step > 1) { + setStep((current) => current - 1) + + return + } + handleOpenChange(false) + } + + const displayName = [user.firstName, user.lastName].filter(Boolean).join(' ') || formatIranianMobile(user.mobile) + + return ( + +
+
+ {Array.from({ length: TOTAL_STEPS }, (_, index) => ( + + ))} +
+ + {step === 1 && ( +
+
+ شماره فعلی:{' '} + + {formatIranianMobile(user.mobile)} + +
+ { + setNewMobileInput(typeof value === 'string' ? value : '') + }} + /> + {newMobileError &&

{newMobileError}

} +
+ )} + + {step === 2 && ( +
+
+

پیامدهای این تغییر را دوباره بررسی کنید:

+
    +
  • شماره قبلی برای ثبت‌نام یک حساب جدید آزاد می‌شود.
  • +
  • شماره جدید تا ورود موفق با OTP تأییدنشده خواهد بود.
  • +
  • تمام نشست‌ها و توکن‌های فعال کاربر فوراً باطل می‌شوند.
  • +
+
+ از + {formatIranianMobile(user.mobile)} + به + {formatIranianMobile(confirmedNewMobile)} +
+
+ { + setCurrentMobileConfirmation(typeof value === 'string' ? value : '') + }} + /> +
+ )} + + {step === 3 && ( +
+
+ این آخرین تأیید است. پس از انجام عملیات، کاربر از همه دستگاه‌ها خارج می‌شود و باید با شماره جدید OTP دریافت کند. +
+ { + setFinalConfirmation(typeof value === 'string' ? value : '') + }} + /> +
+ )} + + {step > 1 && ( + + )} +
+
+ ) +} + +export default UserMobileChangeModal diff --git a/app/(dashboard)/users/[id]/_components/WalletCreditModal.tsx b/app/(dashboard)/users/[id]/_components/WalletCreditModal.tsx new file mode 100644 index 0000000..2550ddc --- /dev/null +++ b/app/(dashboard)/users/[id]/_components/WalletCreditModal.tsx @@ -0,0 +1,103 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' + +import { addToast } from '@/lib/toast' +import type { AdminUserDetail } from '@/services/adminUserDetail' +import Modal from '@/components/modals/Modal' +import Input from '@/components/formElements/Input' +import { formatToman } from '@/features/admin-users/adminUserDetailUi' +import { ADMIN_CREDIT_WALLET } from '@/services/adminWallet' +import { AdminWalletCreditValidation, type AdminWalletCreditValues } from '@/validation/adminWalletCredit' + +const DEFAULT_VALUES: AdminWalletCreditValues = { + amount: '', + description: '', +} + +interface WalletCreditModalProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void + user: AdminUserDetail + onSuccess: () => void +} + +const WalletCreditModal = ({ isOpen, onOpenChange, user, onSuccess }: WalletCreditModalProps) => { + const [submitting, setSubmitting] = useState(false) + + const form = useForm({ + resolver: zodResolver(AdminWalletCreditValidation), + defaultValues: DEFAULT_VALUES, + }) + const { reset } = form + + useEffect(() => { + if (isOpen) { + reset(DEFAULT_VALUES) + } + }, [isOpen, reset]) + + const handleSubmit = async (values: AdminWalletCreditValues) => { + const description = values.description?.trim() + const payload = { + amount: Number(values.amount), + ...(description ? { description } : {}), + } + + setSubmitting(true) + const result = await ADMIN_CREDIT_WALLET(user.id, payload) + + setSubmitting(false) + + if (!result.ok) return + + addToast({ + title: `کیف‌پول شارژ شد · موجودی جدید: ${formatToman(result.data.walletBalance)}`, + color: 'success', + }) + onOpenChange(false) + onSuccess() + } + + const displayName = [user.firstName, user.lastName].filter(Boolean).join(' ') || user.mobile + + return ( + + +
+

+ موجودی فعلی: {formatToman(user.walletBalance)} +

+ + +
+
+
+ ) +} + +export default WalletCreditModal diff --git a/app/(dashboard)/users/[id]/page.tsx b/app/(dashboard)/users/[id]/page.tsx new file mode 100644 index 0000000..22a4a68 --- /dev/null +++ b/app/(dashboard)/users/[id]/page.tsx @@ -0,0 +1,444 @@ +'use client' + +import { useState } from 'react' +import dynamic from 'next/dynamic' +import { useParams } from 'next/navigation' + +import { Tab } from '@/components/heroui/Tabs' +import useDisclosure from '@/hooks/useDisclosure' +import PaginatedList from '@/components/PaginatedList' +import PageNavbar from '@/components/layouts/PageNavbar' +import Button from '@/components/formElements/Button' +import { DetailSkeleton, TableSkeleton } from '@/components/feedback/LoadingState' +import AdminState from '@/components/feedback/AdminState' +import { APP_ROUTES } from '@/constants/routes' +import { formatPersonName, coerceToString } from '@/helpers' +import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' +import StatusChip from '@/components/ui/StatusChip' +import AppTabs from '@/components/ui/AppTabs' +import useAuth from '@/hooks/useAuth' +import useAlertModal from '@/hooks/useAlertModal' +import { addToast } from '@/lib/toast' +import { useAdminUserDetail } from '@/features/admin-users/useAdminUserDetail' +import { ADMIN_ATTEST_IDENTITY } from '@/services/adminUserDetail' +import { + AdminUserActivitySummary, + AdminUserContactInfo, + AdminUserOverview, + blocksColumns, + bookingsColumns, + followsColumns, + formatToman, + hostedEventsColumns, + paymentsColumns, + reportsColumns, + reviewsColumns, +} from '@/features/admin-users/adminUserDetailUi' +import { + getActiveStatus, + getBooleanStatus, + getBookingStatus, + getEventStatus, + getPaymentMethod, + getPaymentStatus, + getReportStatus, + getReviewStatus, +} from '@/constants/status' +import { API_ROUTES } from '@/services/config' + +const UserEditModal = dynamic(() => import('@/app/(dashboard)/users/[id]/_components/UserEditModal'), { ssr: false }) +const WalletCreditModal = dynamic(() => import('@/app/(dashboard)/users/[id]/_components/WalletCreditModal'), { ssr: false }) +const UserMobileChangeModal = dynamic(() => import('@/app/(dashboard)/users/[id]/_components/UserMobileChangeModal'), { ssr: false }) + +const UserDetailPage = () => { + const params = useParams<{ id: string }>() + const userId = params.id + + const { user: currentAdmin } = useAuth() + const currentAdminId = currentAdmin?.userId + const { data: detail, error, isLoading, refetch: refetchDetail } = useAdminUserDetail(userId) + const { showAlert } = useAlertModal() + const { isOpen: isEditOpen, onOpen: openEdit, onOpenChange: onEditOpenChange } = useDisclosure() + const { isOpen: isCreditOpen, onOpen: openCredit, onOpenChange: onCreditOpenChange } = useDisclosure() + const { isOpen: isMobileChangeOpen, onOpen: openMobileChange, onOpenChange: onMobileChangeOpenChange } = useDisclosure() + const [editModalLoaded, setEditModalLoaded] = useState(false) + const [creditModalLoaded, setCreditModalLoaded] = useState(false) + const [mobileChangeModalLoaded, setMobileChangeModalLoaded] = useState(false) + const canChangeMobile = Boolean(detail && currentAdminId && currentAdminId !== detail.id) + const canAttestIdentity = Boolean(detail && detail.identityStatus !== 'verified') + + const handleOpenEdit = () => { + setEditModalLoaded(true) + openEdit() + } + + const handleOpenCredit = () => { + setCreditModalLoaded(true) + openCredit() + } + + const handleOpenMobileChange = () => { + setMobileChangeModalLoaded(true) + openMobileChange() + } + + const handleAttestIdentity = () => { + if (!userId || !canAttestIdentity) return + + showAlert('احراز هویت این کاربر بدون ارسال درخواست از طرف خودش تأیید شود؟ پس از تأیید می‌تواند رویداد بسازد.', async () => { + const result = await ADMIN_ATTEST_IDENTITY(userId) + + if (!result.ok) return + + addToast({ title: 'احراز هویت تأیید شد', color: 'success' }) + await refetchDetail() + }) + } + + return ( +
+ + {detail && ( + <> + {canChangeMobile && ( + + )} + + {canAttestIdentity && ( + + )} + + + )} + + + } + pageTitle={detail ? formatPersonName(detail.firstName, detail.lastName, 'جزئیات کاربر') : 'جزئیات کاربر'} + /> + + {detail && ( + <> + {editModalLoaded ? ( + void refetchDetail()} + /> + ) : null} + {creditModalLoaded ? ( + void refetchDetail()} + /> + ) : null} + {canChangeMobile && mobileChangeModalLoaded ? ( + void refetchDetail()} + /> + ) : null} + + )} + +
+ {isLoading && ( +
+ +
+ +
+
+ )} + + {!isLoading && error && ( +
+ void refetchDetail()} + /> +
+ )} + + {!isLoading && detail && ( + <> + + + + + +
+ +
+ + {{ + status: (_row, cellValue) => , + startsAt: (_row, cellValue) => formatPersianDate(cellValue), + isFree: (_row, cellValue) => , + price: (_row, cellValue) => formatToman(cellValue), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + }} + +
+ + +
+ +
+ + {{ + status: (_row, cellValue) => , + checkedInAt: (_row, cellValue) => formatPersianDate(cellValue), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + }} + +
+ + +
+ +
+ + {{ + method: (_row, cellValue) => , + status: (_row, cellValue) => , + totalAmount: (_row, cellValue) => formatToman(cellValue), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + }} + +
+ + + + {{ + status: (_row, cellValue) => , + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + }} + + + + + + {{ + status: (_row, cellValue) => , + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + }} + + + + + + {{ + mobile: (_row, cellValue) => formatIranianMobile(cellValue), + notifyNewEvents: (_row, cellValue) => , + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + }} + + + + + + {{ + mobile: (_row, cellValue) => formatIranianMobile(cellValue), + notifyNewEvents: (_row, cellValue) => , + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + }} + + + + +
+ +
+ + {{ + otherUserMobile: (_row, cellValue) => formatIranianMobile(cellValue), + status: (_row, cellValue) => , + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + }} + +
+ + +
+ +
+ + {{ + otherUserMobile: (_row, cellValue) => formatIranianMobile(cellValue), + status: (_row, cellValue) => , + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + }} + +
+ + + + {{ + otherUserMobile: (_row, cellValue) => formatIranianMobile(cellValue), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + }} + + + + + + {{ + otherUserMobile: (_row, cellValue) => formatIranianMobile(cellValue), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + }} + + +
+ + + + )} +
+
+ ) +} + +export default UserDetailPage diff --git a/app/(dashboard)/users/page.tsx b/app/(dashboard)/users/page.tsx new file mode 100644 index 0000000..36d216e --- /dev/null +++ b/app/(dashboard)/users/page.tsx @@ -0,0 +1,117 @@ +'use client' + +import { coerceToString } from '@/helpers' +import type { PaginationListColumnType } from '@/types' +import PaginatedList from '@/components/PaginatedList' +import StatusChip from '@/components/ui/StatusChip' +import PageNavbar from '@/components/layouts/PageNavbar' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import { APP_ROUTES } from '@/constants/routes' +import { + getIdentityStatus, + getUserAccountStatus, + getUserRole, + IDENTITY_STATUS_FILTER_ITEMS, + USER_ACCOUNT_STATUS_FILTER_ITEMS, + USER_ROLE_FILTER_ITEMS, +} from '@/constants/status' +import { API_ROUTES } from '@/services/config' +import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' + +const columns: PaginationListColumnType[] = [ + { + field: 'firstName', + label: 'نام', + filterable: true, + type: 'text', + sortable: false, + }, + { + field: 'lastName', + label: 'نام خانوادگی', + filterable: true, + type: 'text', + sortable: false, + }, + { + field: 'mobile', + label: 'موبایل', + filterable: true, + type: 'text', + sortable: true, + }, + { + field: 'role', + label: 'نقش', + filterable: true, + type: 'select', + sortable: true, + filterItems: USER_ROLE_FILTER_ITEMS, + }, + { + field: 'status', + label: 'وضعیت', + filterable: true, + type: 'select', + sortable: true, + filterItems: USER_ACCOUNT_STATUS_FILTER_ITEMS, + }, + { + field: 'identityStatus', + label: 'احراز هویت', + filterable: true, + type: 'select', + sortable: false, + filterItems: IDENTITY_STATUS_FILTER_ITEMS, + }, + { + field: 'lastLoginAt', + label: 'آخرین ورود', + filterable: true, + sortable: true, + type: 'dateFromTo', + }, + { + field: 'createdAt', + label: 'تاریخ ثبت', + filterable: true, + sortable: true, + type: 'dateFromTo', + }, + { + field: 'actions', + label: 'عملیات', + }, +] + +const UsersPage = () => { + return ( +
+ +
+ + {{ + mobile: (_row, cellValue) => formatIranianMobile(cellValue), + role: (_row, cellValue) => , + status: (_row, cellValue) => , + identityStatus: (_row, cellValue) => , + lastLoginAt: (_row, cellValue) => formatPersianDate(cellValue), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => ( + + ), + }} + +
+
+ ) +} + +export default UsersPage diff --git a/app/(dashboard)/wallet-deposits/page.tsx b/app/(dashboard)/wallet-deposits/page.tsx new file mode 100644 index 0000000..65d3b01 --- /dev/null +++ b/app/(dashboard)/wallet-deposits/page.tsx @@ -0,0 +1,179 @@ +'use client' + +import type { PaginationListColumnType } from '@/types' +import PaginatedList from '@/components/PaginatedList' +import StatusChip from '@/components/ui/StatusChip' +import PageNavbar from '@/components/layouts/PageNavbar' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import { APP_ROUTES } from '@/constants/routes' +import { formatCurrency, formatPersonName, coerceToString } from '@/helpers' +import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' +import { getPaymentStatus, PAYMENT_STATUS_FILTER_ITEMS } from '@/constants/status' +import { API_ROUTES } from '@/services/config' + +interface DepositUser { + id: string + mobile: string + firstName: string | null + lastName: string | null +} + +const PURPOSE_LABELS: Record = { + top_up: 'شارژ کیف پول', + booking_checkout: 'پرداخت رزرو', +} + +const PURPOSE_FILTER_ITEMS = [ + { code: 'top_up', name: PURPOSE_LABELS.top_up }, + { code: 'booking_checkout', name: PURPOSE_LABELS.booking_checkout }, +] + +const columns: PaginationListColumnType[] = [ + { + field: 'depositCode', + label: 'کد واریز', + filterable: false, + sortable: false, + type: 'text', + }, + { + field: 'user', + label: 'کاربر', + filterable: false, + sortable: false, + }, + { + field: 'purpose', + label: 'هدف', + filterable: true, + sortable: false, + type: 'select', + filterItems: PURPOSE_FILTER_ITEMS, + }, + { + field: 'amount', + label: 'مبلغ', + filterable: false, + sortable: true, + }, + { + field: 'status', + label: 'وضعیت', + filterable: true, + sortable: true, + type: 'select', + filterItems: PAYMENT_STATUS_FILTER_ITEMS, + }, + { + field: 'gatewayProvider', + label: 'درگاه', + filterable: true, + sortable: false, + type: 'text', + }, + { + field: 'gatewayTrackingId', + label: 'شناسه درگاه', + filterable: false, + sortable: false, + type: 'text', + }, + { + field: 'gatewayRefId', + label: 'RRN درگاه', + filterable: false, + sortable: false, + type: 'text', + }, + { + field: 'completedAt', + label: 'تاریخ تکمیل', + filterable: false, + sortable: true, + type: 'date', + }, + { + field: 'createdAt', + label: 'تاریخ ثبت', + filterable: true, + sortable: true, + type: 'dateFromTo', + }, + { + field: 'actions', + label: 'عملیات', + }, +] + +const WalletDepositsPage = () => { + return ( +
+ +
+ + {{ + user: (row) => { + const user = row.user as DepositUser | undefined + + if (!user) return '—' + + const name = formatPersonName(user.firstName ?? undefined, user.lastName ?? undefined) + + return ( +
+ {name} + + {formatIranianMobile(user.mobile)} + +
+ ) + }, + purpose: (_row, cellValue) => PURPOSE_LABELS[coerceToString(cellValue)] ?? coerceToString(cellValue) ?? '—', + amount: (_row, cellValue) => formatCurrency(Number(cellValue ?? 0)), + status: (_row, cellValue) => , + gatewayProvider: (_row, cellValue) => coerceToString(cellValue) || '—', + gatewayTrackingId: (_row, cellValue) => ( + + {coerceToString(cellValue) || '—'} + + ), + gatewayRefId: (_row, cellValue) => ( + + {coerceToString(cellValue) || '—'} + + ), + completedAt: (_row, cellValue) => formatPersianDate(cellValue), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + actions: (row) => { + const user = row.user as DepositUser | undefined + + if (!user) return '—' + + return ( + + ) + }, + }} +
+
+
+ ) +} + +export default WalletDepositsPage diff --git a/app/(dashboard)/withdrawal-requests/page.tsx b/app/(dashboard)/withdrawal-requests/page.tsx new file mode 100644 index 0000000..8de23f1 --- /dev/null +++ b/app/(dashboard)/withdrawal-requests/page.tsx @@ -0,0 +1,350 @@ +'use client' + +import { useState } from 'react' + +import type { PaginationListColumnType } from '@/types' +import { addToast } from '@/lib/toast' +import PaginatedList from '@/components/PaginatedList' +import PageNavbar from '@/components/layouts/PageNavbar' +import Input from '@/components/formElements/Input' +import Button from '@/components/formElements/Button' +import CloseCircleIcon from '@/components/icons/CloseCircleIcon' +import FileCheckIcon from '@/components/icons/FileCheckIcon' +import ManualPayoutModal from '@/components/admin/ManualPayoutModal' +import PlayCircleIcon from '@/components/icons/PlayCircleIcon' +import Modal from '@/components/modals/Modal' +import AdminTableActions from '@/components/ui/AdminTableActions' +import AdminTableViewButton from '@/components/ui/AdminTableViewButton' +import StatusChip from '@/components/ui/StatusChip' +import axiosInstance from '@/config/axios' +import { APP_ROUTES } from '@/constants/routes' +import { getWithdrawalRequestStatus, WITHDRAWAL_REQUEST_STATUS_FILTER_ITEMS } from '@/constants/status' +import { formatCurrency, formatPersonName, coerceToString } from '@/helpers' +import useAlertModal from '@/hooks/useAlertModal' +import useAdminMutation from '@/hooks/useAdminMutation' +import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' +import { API_ROUTES } from '@/services/config' + +type WithdrawalRequestStatusValue = 'pending' | 'processing' | 'completed' | 'rejected' + +interface WithdrawalRequestUserSummary { + mobile: string + firstName: string | null + lastName: string | null +} + +interface WithdrawalRequestBankAccountSummary { + iban: string + bankName: string | null + accountHolder: string | null +} + +interface WithdrawalRequestRow { + id: string + userId: string + walletId: string + bankAccountId: string + withdrawalCode: string + amount: number + status: WithdrawalRequestStatusValue + rejectionReason: string | null + processedAt: string | null + createdAt: string + updatedAt: string + user?: WithdrawalRequestUserSummary + bankAccount?: WithdrawalRequestBankAccountSummary + [key: string]: unknown +} + +const columns: PaginationListColumnType[] = [ + { + field: 'withdrawalCode', + label: 'کد درخواست', + filterable: false, + sortable: false, + type: 'text', + }, + { + field: 'userId', + label: 'کاربر', + filterable: true, + sortable: false, + type: 'text', + }, + { + field: 'amount', + label: 'مبلغ', + filterable: false, + sortable: true, + }, + { + field: 'status', + label: 'وضعیت', + filterable: true, + sortable: true, + type: 'select', + filterItems: WITHDRAWAL_REQUEST_STATUS_FILTER_ITEMS, + }, + { + field: 'createdAt', + label: 'تاریخ ثبت', + filterable: false, + sortable: true, + }, + { + field: 'processedAt', + label: 'تاریخ پردازش', + filterable: false, + sortable: false, + }, + { + field: 'bankDetails', + label: 'حساب بانکی', + filterable: false, + sortable: false, + }, + { + field: 'actions', + label: 'عملیات', + }, +] + +const WithdrawalRequestsPage = () => { + const { showAlert } = useAlertModal() + const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.WITHDRAWAL_REQUESTS.ADMIN_LIST }) + const [rejectTarget, setRejectTarget] = useState(null) + const [manualTarget, setManualTarget] = useState(null) + const [rejectReason, setRejectReason] = useState('') + const isRejecting = rejectTarget?.id === pendingId + + const handleProcess = (row: WithdrawalRequestRow) => { + showAlert('این درخواست برداشت به وضعیت «در حال پردازش» تغییر کند؟', () => + runAction( + row.id, + () => axiosInstance.patch(API_ROUTES.WITHDRAWAL_REQUESTS.ADMIN_PROCESS(row.id)), + 'درخواست به «در حال پردازش» تغییر کرد' + ) + ) + } + + const openRejectModal = (row: WithdrawalRequestRow) => { + setRejectReason('') + setRejectTarget(row) + } + + const closeRejectModal = () => { + if (isRejecting) return + + setRejectTarget(null) + setRejectReason('') + } + + const handleReject = async () => { + if (!rejectTarget) return + + const reason = rejectReason.trim() + + if (reason.length < 3) { + addToast({ title: 'دلیل رد باید حداقل ۳ کاراکتر باشد', color: 'warning' }) + + return + } + + const succeeded = await runAction( + rejectTarget.id, + () => axiosInstance.patch(API_ROUTES.WITHDRAWAL_REQUESTS.ADMIN_REJECT(rejectTarget.id), { reason }), + 'درخواست رد شد' + ) + + if (succeeded) { + setRejectTarget(null) + setRejectReason('') + } + } + + const renderStatusCell = (row: WithdrawalRequestRow, cellValue: unknown) => { + const { label, chipColor } = getWithdrawalRequestStatus(coerceToString(cellValue)) + + return ( + + ) + } + + return ( +
+ +
+ + {{ + userId: (row) => { + const withdrawal = row as WithdrawalRequestRow + + return ( +
+ {formatPersonName(withdrawal.user?.firstName, withdrawal.user?.lastName)} + {withdrawal.user?.mobile ? ( + + {formatIranianMobile(withdrawal.user.mobile)} + + ) : null} +
+ ) + }, + amount: (_row, cellValue) => formatCurrency(Number(cellValue ?? 0)), + status: (row, cellValue) => renderStatusCell(row as WithdrawalRequestRow, cellValue), + createdAt: (_row, cellValue) => formatPersianDate(cellValue), + processedAt: (_row, cellValue) => formatPersianDate(cellValue), + bankDetails: (row) => { + const withdrawal = row as WithdrawalRequestRow + const bankAccount = withdrawal.bankAccount + + if (!bankAccount) { + return + } + + return ( +
+ + {bankAccount.iban || '—'} + + {bankAccount.bankName ?? '—'} + {bankAccount.accountHolder ?? '—'} +
+ ) + }, + actions: (row) => { + const withdrawal = row as WithdrawalRequestRow + const isBusy = pendingId === withdrawal.id + const canProcess = withdrawal.status === 'pending' || withdrawal.status === 'processing' + + return ( + + + {canProcess && withdrawal.status === 'pending' ? ( + + ) : null} + {canProcess ? ( + <> + + + + ) : null} + + ) + }, + }} +
+
+ + { + if (!open) closeRejectModal() + }} + onReject={closeRejectModal} + > +
+

دلیل رد این درخواست را بنویسید. این توضیح برای کاربر نمایش داده می‌شود.

+ { + setRejectReason(coerceToString(next)) + }} + /> +
+
+ + { + setManualTarget(null) + }} + onSubmit={async (payload) => { + if (!manualTarget) return false + + return runAction( + manualTarget.id, + () => axiosInstance.patch(API_ROUTES.WITHDRAWAL_REQUESTS.ADMIN_COMPLETE(manualTarget.id), payload), + 'برداشت با رسید دستی تکمیل شد' + ) + }} + /> +
+ ) +} + +export default WithdrawalRequestsPage diff --git a/app/api/auth/clear-session/route.test.ts b/app/api/auth/clear-session/route.test.ts new file mode 100644 index 0000000..2bfb307 --- /dev/null +++ b/app/api/auth/clear-session/route.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import { CLEAR_REFRESH_SESSION_HEADER, REFRESH_TOKEN_COOKIE_DEV, REFRESH_TOKEN_COOKIE_HOST } from '@/lib/refreshSessionCookie' + +import { POST } from './route' + +describe('POST /api/auth/clear-session', () => { + it('rejects requests without the logout header', () => { + const response = POST(new Request('http://127.0.0.1/api/auth/clear-session', { method: 'POST' })) + + expect(response.status).toBe(403) + expect(response.headers.getSetCookie()).toEqual([]) + }) + + it('expires both Nest refresh cookie names when the header is present', () => { + const response = POST( + new Request('http://127.0.0.1/api/auth/clear-session', { + method: 'POST', + headers: { [CLEAR_REFRESH_SESSION_HEADER]: '1' }, + }) + ) + const cookies = response.headers.getSetCookie() + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('no-store') + expect(cookies).toHaveLength(3) + expect(cookies.some((cookie) => cookie.startsWith(`${REFRESH_TOKEN_COOKIE_DEV}=`) && cookie.includes('Max-Age=0'))).toBe(true) + expect(cookies.some((cookie) => cookie.startsWith(`${REFRESH_TOKEN_COOKIE_HOST}=`) && cookie.includes('Secure'))).toBe(true) + }) +}) diff --git a/app/api/auth/clear-session/route.ts b/app/api/auth/clear-session/route.ts new file mode 100644 index 0000000..bc05f69 --- /dev/null +++ b/app/api/auth/clear-session/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server' + +import { CLEAR_REFRESH_SESSION_HEADER, expiredRefreshSessionSetCookieHeaders } from '@/lib/refreshSessionCookie' + +export const dynamic = 'force-dynamic' + +/** + * Browser-side cookie cleanup only: expires Nest httpOnly refresh cookies on this origin. + * Does not revoke `refresh_tokens` in Nest — an already-exfiltrated token stays valid until + * expiry unless retried `POST /auth/logout` succeeded. Complementary to `attemptServerLogout`. + * Requires `x-ghabilee-logout` so a cross-site form POST cannot CSRF-clear cookies. + */ +export function POST(request: Request) { + if (request.headers.get(CLEAR_REFRESH_SESSION_HEADER) !== '1') { + return NextResponse.json({ ok: false }, { status: 403 }) + } + + const response = NextResponse.json({ ok: true }) + + response.headers.set('Cache-Control', 'no-store') + for (const cookie of expiredRefreshSessionSetCookieHeaders()) { + response.headers.append('Set-Cookie', cookie) + } + + return response +} diff --git a/app/api/download-upload/route.ts b/app/api/download-upload/route.ts new file mode 100644 index 0000000..2960baf --- /dev/null +++ b/app/api/download-upload/route.ts @@ -0,0 +1,93 @@ +import { type NextRequest, NextResponse } from 'next/server' + +const guessFileName = (imageUrl: string) => { + try { + const pathname = new URL(imageUrl).pathname + const base = pathname.split('/').filter(Boolean).at(-1) + + if (base && /\.[a-z0-9]{2,5}$/i.test(base)) return decodeURIComponent(base) + } catch { + // ignore + } + + return `chat-image-${Date.now()}.jpg` +} + +const resolveAllowedUploadOrigins = (): string[] => { + const origins = new Set() + const fileServer = process.env.NEXT_PUBLIC_FILE_SERVER_URL?.trim() + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.trim() + const apiUrl = process.env.NEXT_PUBLIC_API_URL?.trim() + + for (const value of [fileServer, siteUrl, apiUrl]) { + if (!value) continue + try { + origins.add(new URL(value).origin) + } catch { + // ignore invalid env URLs + } + } + + origins.add('https://ghabilee.ir') + origins.add('https://www.ghabilee.ir') + origins.add('https://dev.ghabilee.ir') + + return [...origins] +} + +/** + * Same-origin download proxy for chat/upload images. + * Needed when the page origin (e.g. localhost:3008) differs from the file host + * (ghabilee.ir) and the CDN has not yet emitted Access-Control-Allow-Origin. + */ +export async function GET(request: NextRequest) { + const rawUrl = request.nextUrl.searchParams.get('url')?.trim() + + if (!rawUrl) { + return NextResponse.json({ message: 'url is required' }, { status: 400 }) + } + + let target: URL + + try { + target = new URL(rawUrl) + } catch { + return NextResponse.json({ message: 'invalid url' }, { status: 400 }) + } + + if (target.protocol !== 'https:' && target.protocol !== 'http:') { + return NextResponse.json({ message: 'unsupported protocol' }, { status: 400 }) + } + + const allowed = resolveAllowedUploadOrigins() + + if (!allowed.includes(target.origin)) { + return NextResponse.json({ message: 'url host is not allowed' }, { status: 403 }) + } + + if (!target.pathname.startsWith('/uploads/')) { + return NextResponse.json({ message: 'only /uploads paths are allowed' }, { status: 403 }) + } + + const upstream = await fetch(target.toString(), { + headers: { Accept: 'image/*,*/*' }, + redirect: 'error', + }) + + if (!upstream.ok) { + return NextResponse.json({ message: 'upstream fetch failed' }, { status: 502 }) + } + + const contentType = upstream.headers.get('content-type') || 'application/octet-stream' + const fileName = guessFileName(target.toString()) + const bytes = await upstream.arrayBuffer() + + return new NextResponse(bytes, { + status: 200, + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${fileName}"`, + 'Cache-Control': 'private, no-store', + }, + }) +} diff --git a/app/auth/layout.tsx b/app/auth/layout.tsx new file mode 100644 index 0000000..b4617e9 --- /dev/null +++ b/app/auth/layout.tsx @@ -0,0 +1,53 @@ +import type { Metadata } from 'next' + +import React from 'react' +import Image from 'next/image' + +import SessionProviders from '@/components/providers/SessionProviders' +import { withBasePath } from '@/constants/images' +import { texts } from '@/texts' + +// Login/signup — already disallowed in robots.ts; noindex is defense in +// depth against the URL surfacing blank in search results if ever linked. +export const metadata: Metadata = { + robots: { index: false, follow: false }, +} + +const authHeroSrc = withBasePath('/images/auth-v2.png') +const logoSrc = withBasePath('/logo.svg') + +const AuthLayout = ({ children }: { children: React.ReactNode }) => { + return ( + +
+
+
+ +
+
+
+ {texts.auth.logoAlt} + {children} +
+
+
+
+
+ ) +} + +export default AuthLayout diff --git a/app/auth/page.tsx b/app/auth/page.tsx new file mode 100644 index 0000000..788a27f --- /dev/null +++ b/app/auth/page.tsx @@ -0,0 +1,15 @@ +'use client' + +import { Suspense } from 'react' + +import { PageLoading } from '@/components/feedback/LoadingState' +import { AdminAuthContent } from '@/components/auth/AdminAuthContent' +import { texts } from '@/texts' + +export default function AuthPage() { + return ( + }> + + + ) +} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..7bd3ce6 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,19 @@ +'use client' + +import { useEffect } from 'react' + +import { RouteErrorView } from '@/components/feedback/RouteErrorView' +import { reportClientError } from '@/lib/observability/client' + +export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + reportClientError(error, 'root') + }, [error]) + + return ( + + ) +} diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..4d33117 --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,23 @@ +'use client' + +import { useEffect } from 'react' + +import { RouteErrorView } from '@/components/feedback/RouteErrorView' +import { reportClientError } from '@/lib/observability/client' + +export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + reportClientError(error, 'global') + }, [error]) + + return ( + + + + + + ) +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..e7dc867 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,80 @@ +import '@/styles/globals.css' +import '@/styles/main.scss' + +import type { Metadata, Viewport } from 'next' + +import { headers } from 'next/headers' +import clsx from 'clsx' +import React from 'react' + +import { Providers } from '@/app/providers' +import { pinarFont } from '@/config/fonts' +import { texts } from '@/texts' +import { SITE_URL } from '@/lib/seo/metadata' +import { OfflineBanner } from '@/components/feedback/OfflineBanner' +import { AccountSuspensionBanner } from '@/components/feedback/AccountSuspensionBanner' +import { withBasePath } from '@/constants/images' +import { AppToastProvider } from '@/components/feedback/AppToastProvider' + +export const metadata: Metadata = { + metadataBase: new URL(SITE_URL), + title: { + default: `${texts.common.brandName} — Backoffice`, + template: `%s | ${texts.common.brandName} Backoffice`, + }, + description: 'پنل مدیریت قبیله', + applicationName: `${texts.common.brandName} Backoffice`, + robots: { index: false, follow: false, nocache: true }, + icons: { + icon: [ + { url: withBasePath('/logo.svg'), type: 'image/svg+xml' }, + { url: withBasePath('/icons/icon-192.png'), sizes: '192x192', type: 'image/png' }, + { url: withBasePath('/icons/icon-512.png'), sizes: '512x512', type: 'image/png' }, + ], + apple: [{ url: withBasePath('/icons/apple-touch-icon.png'), sizes: '180x180' }], + }, + formatDetection: { + telephone: false, + }, +} + +export const viewport: Viewport = { + themeColor: '#6d28d9', + width: 'device-width', + initialScale: 1, + viewportFit: 'cover', +} + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + // Reading the nonce-bearing request headers opts the route into dynamic + // rendering so Next can apply the per-request CSP nonce to its scripts. + const nonce = (await headers()).get('x-nonce') ?? undefined + + return ( + + + + + +
{children}
+ +
+ + + ) +} diff --git a/app/loading.tsx b/app/loading.tsx new file mode 100644 index 0000000..b1f2ab4 --- /dev/null +++ b/app/loading.tsx @@ -0,0 +1,5 @@ +import { DetailSkeleton } from '@/components/feedback/LoadingState' + +export default function Loading() { + return +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..f854abd --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,19 @@ +import Button from '@/components/formElements/Button' +import { texts } from '@/texts' + +const NotFound = () => { + return ( +
+
+
+

404

+

{texts.common.notFoundTitle}

+

{texts.common.pageNotFound}

+ +
+
+
+ ) +} + +export default NotFound diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..4b7b32a --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from 'next/navigation' + +import { APP_ROUTES } from '@/constants/routes' + +/** Root of backoffice always lands on the admin dashboard. */ +export default function AdminRootPage() { + redirect(APP_ROUTES.DASHBOARD) +} diff --git a/app/providers.tsx b/app/providers.tsx new file mode 100644 index 0000000..2452673 --- /dev/null +++ b/app/providers.tsx @@ -0,0 +1,29 @@ +'use client' + +import React, { useState } from 'react' +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client' +import { I18nProvider } from '@react-aria/i18n' +import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from 'next-themes' + +import { getQueryClient, getQueryPersistOptions } from '@/lib/queryClient' + +export interface ProvidersProps { + children: React.ReactNode + themeProps?: ThemeProviderProps +} + +export function Providers({ children, themeProps }: ProvidersProps) { + const [queryClient] = useState(() => getQueryClient()) + const [persistOptions] = useState(() => getQueryPersistOptions()) + + return ( + + + {children} + + + ) +} diff --git a/components/PaginatedList.test.tsx b/components/PaginatedList.test.tsx new file mode 100644 index 0000000..6a4b56e --- /dev/null +++ b/components/PaginatedList.test.tsx @@ -0,0 +1,65 @@ +import { act, render, waitFor } from '@testing-library/react' +import { createRef } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import PaginatedList, { type PaginatedListHandle } from '@/components/paginated-list/PaginatedListImpl' + +const renderWithQuery = (ui: React.ReactElement) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + + return render({ui}) +} + +const fetchPaginatedList = vi.fn() +const replace = vi.fn() + +vi.mock('next/navigation', () => ({ + usePathname: () => '/test', + useRouter: () => ({ replace }), +})) +vi.mock('@/hooks/useQueryParams', () => ({ default: () => ({}) })) +vi.mock('@/components/paginated-list/PaginatedListFilterModal', () => ({ default: () => null })) +vi.mock('@/components/paginated-list/PaginatedListPagination', () => ({ + default: () => null, + ROWS_PER_PAGE_OPTIONS: [{ code: '20', name: '20' }], +})) +vi.mock('@/components/paginated-list/PaginatedListTable', () => ({ default: () => null })) +vi.mock('@/components/paginated-list/PaginatedListToolbar', () => ({ default: () => null })) +vi.mock('@/components/paginated-list/paginatedListApi', () => ({ + fetchPaginatedList: (...args: unknown[]) => fetchPaginatedList(...args), + fetchPaginatedListExport: vi.fn(), +})) + +describe('PaginatedList refresh handle', () => { + beforeEach(() => { + replace.mockClear() + fetchPaginatedList.mockReset() + fetchPaginatedList.mockResolvedValue({ items: [], pagination: { page: 1, pageSize: 20, totalItems: 0 } }) + }) + + it('fetches the current list again when refresh is called', async () => { + const ref = createRef() + + renderWithQuery( + + ) + + await waitFor(() => { + expect(fetchPaginatedList).toHaveBeenCalled() + }) + const initialCalls = fetchPaginatedList.mock.calls.length + + act(() => ref.current?.refresh()) + + await waitFor(() => { + expect(fetchPaginatedList.mock.calls.length).toBeGreaterThan(initialCalls) + }) + }) +}) diff --git a/components/PaginatedList.tsx b/components/PaginatedList.tsx new file mode 100644 index 0000000..9b2fdd7 --- /dev/null +++ b/components/PaginatedList.tsx @@ -0,0 +1,32 @@ +'use client' + +import { forwardRef, type ComponentProps } from 'react' +import dynamic from 'next/dynamic' + +import { TableSkeleton } from '@/components/feedback/LoadingState' +import type { PaginatedListHandle } from '@/components/paginated-list/PaginatedListImpl' + +export type { PaginatedListHandle } + +const PaginatedListImpl = dynamic(() => import('@/components/paginated-list/PaginatedListImpl'), { + ssr: false, + loading: () => ( +
+ +
+ ), +}) + +const PaginatedList = forwardRef>(function PaginatedList(props, ref) { + return ( + + ) +}) + +export default PaginatedList diff --git a/components/admin/ManualPayoutModal.tsx b/components/admin/ManualPayoutModal.tsx new file mode 100644 index 0000000..4983838 --- /dev/null +++ b/components/admin/ManualPayoutModal.tsx @@ -0,0 +1,124 @@ +'use client' + +import { useEffect, useState } from 'react' + +import Input from '@/components/formElements/Input' +import FileUpload from '@/components/media/FileUpload' +import Modal from '@/components/modals/Modal' +import { coerceToString } from '@/helpers' +import { addToast } from '@/lib/toast' + +export interface ManualPayoutPayload { + trackingCode: string + receiptUrl: string + note?: string +} + +interface ManualPayoutModalProps { + isOpen: boolean + isLoading?: boolean + title: string + onClose: () => void + onSubmit: (payload: ManualPayoutPayload) => Promise +} + +const ManualPayoutModal = ({ isOpen, isLoading = false, title, onClose, onSubmit }: ManualPayoutModalProps) => { + const [trackingCode, setTrackingCode] = useState('') + const [receiptFileId, setReceiptFileId] = useState('') + const [receiptUrl, setReceiptUrl] = useState('') + const [note, setNote] = useState('') + + useEffect(() => { + if (!isOpen) { + setTrackingCode('') + setReceiptFileId('') + setReceiptUrl('') + setNote('') + } + }, [isOpen]) + + const handleSubmit = async () => { + const normalizedTrackingCode = trackingCode.trim() + + if (!normalizedTrackingCode) { + addToast({ title: 'شماره پیگیری پرداخت را وارد کنید', color: 'warning' }) + + return + } + if (!receiptUrl) { + addToast({ title: 'تصویر رسید پرداخت را بارگذاری کنید', color: 'warning' }) + + return + } + + const succeeded = await onSubmit({ + trackingCode: normalizedTrackingCode, + receiptUrl, + note: note.trim() || undefined, + }) + + if (succeeded) { + onClose() + } + } + + return ( + { + void handleSubmit() + }} + onOpenChange={(open) => { + if (!open && !isLoading) onClose() + }} + onReject={onClose} + > +
+ { + setTrackingCode(coerceToString(value)) + }} + /> + { + if (mode === 'remove') { + setReceiptFileId('') + setReceiptUrl('') + + return + } + + setReceiptFileId(fileId) + setReceiptUrl(fileUrl ?? '') + }} + inputId="manual-payout-receipt" + /> + { + setNote(coerceToString(value)) + }} + /> +
+
+ ) +} + +export default ManualPayoutModal diff --git a/components/auth/AdminAuthContent.tsx b/components/auth/AdminAuthContent.tsx new file mode 100644 index 0000000..4ab2fe2 --- /dev/null +++ b/components/auth/AdminAuthContent.tsx @@ -0,0 +1,215 @@ +'use client' + +import Link from 'next/link' +import { FormProvider } from 'react-hook-form' + +import { Progress } from '@/components/heroui/Progress' +import Button from '@/components/formElements/Button' +import Checkbox from '@/components/formElements/Checkbox' +import Input from '@/components/formElements/Input' +import ConsumerInput from '@/components/consumer/ConsumerInput' +import { texts, format } from '@/texts' +import { useAuthFlow } from '@/features/auth/useAuthFlow' +import { useDiscoveryCitiesQuery } from '@/queries/consumer/useWalletAndBookmarkQueries' +import { showFormValidationToast } from '@/lib/formValidationToast' +import { OTP_LENGTH } from '@/validation/auth' + +const GENDER_OPTIONS = [ + { name: texts.common.genderMale, selectKey: 'male' }, + { name: texts.common.genderFemale, selectKey: 'female' }, +] + +export function AdminAuthContent() { + const flow = useAuthFlow({}) + const citiesQuery = useDiscoveryCitiesQuery(flow.step === 'profile') + const cityOptions = (citiesQuery.data ?? []).map((city) => ({ id: String(city.id), name: city.name })) + const title = flow.step === 'otp' ? texts.auth.otpTitle : flow.step === 'profile' ? texts.auth.profileTitle : null + + return ( +
+ {title ?
{title}
: null} + {flow.accountBlockMessage ? ( +
+ {flow.accountBlockMessage} +
+ ) : null} + + {flow.step === 'mobile' ? ( + +
+ + + +
+ ) : null} + + {flow.step === 'otp' ? ( + <> + +
+ + {flow.otpError ?

{flow.otpError}

: null} + {flow.isRegistration ? ( +
+ + + {texts.auth.termsConfirmPrefix}{' '} + + {texts.auth.termsLink} + {' '} + {texts.auth.termsConfirmSuffix} + + + {flow.termsError ? ( +

+ {flow.termsError} +

+ ) : null} +
+ ) : null} + + +
+
+ {flow.resendCodeTime > 0 ? ( + <> + +

{texts.auth.didNotReceiveCode}

+

{format(texts.auth.resendInSeconds, { seconds: flow.resendCodeTime })}

+ + ) : ( + + )} +
+ + + ) : null} + + {flow.step === 'profile' ? ( + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + {citiesQuery.isError ?

{texts.auth.citiesLoadFailed}

: null} +
+ +
+
+ ) : null} +
+ ) +} diff --git a/components/auth/AdminRoleGuard.tsx b/components/auth/AdminRoleGuard.tsx new file mode 100644 index 0000000..da5058a --- /dev/null +++ b/components/auth/AdminRoleGuard.tsx @@ -0,0 +1,26 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' + +import { CONSUMER_ROUTES } from '@/constants/routes' +import useAuth from '@/hooks/useAuth' + +const AdminRoleGuard = ({ children }: { children: React.ReactNode }) => { + const { user } = useAuth() + const router = useRouter() + + useEffect(() => { + if (user && user.role !== 'admin') { + router.replace(CONSUMER_ROUTES.HOME) + } + }, [router, user]) + + if (user && user.role !== 'admin') { + return null + } + + return <>{children} +} + +export default AdminRoleGuard diff --git a/components/auth/PendingUserGuard.tsx b/components/auth/PendingUserGuard.tsx new file mode 100644 index 0000000..06eeed0 --- /dev/null +++ b/components/auth/PendingUserGuard.tsx @@ -0,0 +1,32 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' + +import useAuth from '@/hooks/useAuth' + +/** + * Handles accounts that are still `pending` profile completion. + * - Consumer users keep the current page visible; AuthGate modal collects profile. + * - Admins are redirected to `/auth?step=profile` and the shell is hidden meanwhile. + */ +const PendingUserGuard = ({ children }: { children: React.ReactNode }) => { + const { user } = useAuth() + const router = useRouter() + const isPending = user?.status === 'pending' + const isAdmin = user?.role === 'admin' + + useEffect(() => { + if (isPending && isAdmin) { + router.replace('/auth?step=profile') + } + }, [isAdmin, isPending, router]) + + if (isPending && isAdmin) { + return null + } + + return <>{children} +} + +export default PendingUserGuard diff --git a/components/consumer/ConsumerActionButtons.tsx b/components/consumer/ConsumerActionButtons.tsx new file mode 100644 index 0000000..c018104 --- /dev/null +++ b/components/consumer/ConsumerActionButtons.tsx @@ -0,0 +1,88 @@ +'use client' + +/** + * ConsumerActionButtons — standard dual CTA row (primary + cancel) for consumer UI. + */ + +import type { ReactNode } from 'react' + +import ConsumerButton from '@/components/consumer/ConsumerButton' +import AngleLeftIcon from '@/components/icons/AngleLeftIcon' +import CloseLinearIcon from '@/components/icons/CloseLinearIcon' +import { cn } from '@/lib/cn' +import { texts } from '@/texts' + +import { type ConsumerButtonAlign } from './consumerButtonTypes' + +export interface ConsumerActionButtonsProps { + cancelDisabled?: boolean + cancelIcon?: ReactNode + cancelLabel?: string + cancelType?: 'button' | 'reset' + className?: string + isCancelLoading?: boolean + isPrimaryLoading?: boolean + onCancel?: () => void + onPrimary?: () => void + primaryDisabled?: boolean + primaryDanger?: boolean + primaryIcon?: ReactNode + primaryLabel?: string + primaryType?: 'button' | 'submit' + align?: ConsumerButtonAlign +} + +export default function ConsumerActionButtons({ + cancelDisabled = false, + cancelIcon = , + cancelLabel = texts.common.cancel, + cancelType = 'button', + className, + isCancelLoading = false, + isPrimaryLoading = false, + onCancel, + onPrimary, + primaryDisabled = false, + primaryDanger = false, + primaryIcon = , + primaryLabel = texts.events.nextStep, + primaryType = 'submit', + align = 'start', +}: ConsumerActionButtonsProps) { + return ( +
+ + {primaryLabel} + + + + {cancelLabel} + +
+ ) +} diff --git a/components/consumer/ConsumerButton.tsx b/components/consumer/ConsumerButton.tsx new file mode 100644 index 0000000..06eac3d --- /dev/null +++ b/components/consumer/ConsumerButton.tsx @@ -0,0 +1,168 @@ +'use client' + +/** + * ConsumerButton — consumer-only actions (Figma-aligned). + * + * **When to use** + * - Any button, submit, or link-styled action in `app/(consumer)/**`, + * `components/consumer/**`, and shared consumer overlays (auth, PWA, booking). + * + * **When NOT to use** + * - Admin/dashboard and event-create wizard → `components/formElements/Button`. + * - Dual cancel/primary rows → `ConsumerActionButtons` (do not hand-roll pairs). + * + * **Props (design)** + * - `fill`: `orange` | `navy` | `fourth` | `fifth` | `gray` | `none` (default `orange`) + * - `size`: `xs` | `sm` | `md` | `lg` | `xl` — default `lg` text, `sm` icon-only + * - `radius`: `full` | `control` (10px) + * - `textColor`: `white`, Tailwind `text-*`, or hex — default white on filled + * - `fontSize`: default 14 + * - `align`: `center` | `start` (RTL: start = right) + * - Icons: `iconStart` / `iconEnd` / `icon` + `iconPosition` + * - `isLoading` disables the control and swaps the icon slot for a spinner (label stays) + * + * **هم‌نام بودن ≠ هم‌اندازه بودن:** `size` here ≠ `ConsumerInput` `size` for + * the same token (see `frontend/docs/consumer-ui-guidelines.md` §4). + * + * **className policy:** layout-only (`flex-1`, `mt-5`, `w-full`, …) is OK. + * Do **not** pass visual overrides (`bg-*`, `text-*`, `rounded-*`, `min-h-*`) + * to match Figma — stop, tell the product owner, and extend this component + * after explicit approval. + * + * Guidelines: `frontend/docs/consumer-ui-guidelines.md` + */ + +import Link from 'next/link' +import { Spinner } from '@heroui/react' +import { forwardRef } from 'react' + +import { buildConsumerButtonStyle } from '@/components/consumer/consumerButtonStyles' +import type { ConsumerButtonProps } from '@/components/consumer/consumerButtonTypes' + +export type { ConsumerButtonProps } from '@/components/consumer/consumerButtonTypes' + +const ConsumerButton = forwardRef( + ( + { + 'aria-controls': ariaControls, + 'aria-expanded': ariaExpanded, + 'aria-label': ariaLabel, + 'aria-pressed': ariaPressed, + 'aria-checked': ariaChecked, + 'aria-selected': ariaSelected, + align = 'center', + children, + className, + radius = 'full', + disabled = false, + fill = 'orange', + fontSize = 14, + fullWidth = false, + icon, + iconEnd, + iconOnly = false, + iconPosition = 'start', + iconStart, + isLoading = false, + role, + size, + target = '_self', + textColor, + to, + type = 'button', + onClick, + }, + ref + ) => { + const isDisabled = disabled || isLoading + const resolvedIconStart = iconStart ?? (iconPosition === 'start' ? icon : undefined) + const resolvedIconEnd = iconEnd ?? (iconPosition === 'end' ? icon : undefined) + const { className: resolvedClassName, style } = buildConsumerButtonStyle({ + align, + className, + radius, + fill, + fontSize, + fullWidth, + iconOnly, + size, + textColor, + }) + + const iconContent = resolvedIconStart ?? (iconOnly ? children : undefined) + const loadingSpinner = ( + + ) + + // لودینگ: فقط اسلات آیکن با اسپینر عوض شود؛ لیبل باقی بماند + const showStartSpinner = isLoading && Boolean(resolvedIconStart || !resolvedIconEnd) + const showEndSpinner = isLoading && !resolvedIconStart && Boolean(resolvedIconEnd) + + const content = iconOnly ? ( + isLoading ? ( + loadingSpinner + ) : ( + iconContent + ) + ) : ( + <> + {showStartSpinner ? loadingSpinner : resolvedIconStart} + {children} + {showEndSpinner ? loadingSpinner : isLoading ? null : resolvedIconEnd} + + ) + + const ariaProps = { + 'aria-checked': ariaChecked, + 'aria-controls': ariaControls, + 'aria-expanded': ariaExpanded, + 'aria-label': ariaLabel, + 'aria-pressed': ariaPressed, + 'aria-selected': ariaSelected, + role, + } + + if (to) { + return ( + { + event.preventDefault() + } + : onClick + } + > + {content} + + ) + } + + return ( + + ) + } +) + +ConsumerButton.displayName = 'ConsumerButton' + +export default ConsumerButton diff --git a/components/consumer/ConsumerInput.tsx b/components/consumer/ConsumerInput.tsx new file mode 100644 index 0000000..9f60f45 --- /dev/null +++ b/components/consumer/ConsumerInput.tsx @@ -0,0 +1,243 @@ +'use client' + +/** + * ConsumerInput — consumer-only form fields (direct heroui + RHF). + * + * **Design props** + * - `size`: `sm` | `md` | `lg` + * - `radius`: `full` (pill) | `control` (10px) + * - `tone`: `muted` (#E2E2E280 fill) | `bordered` (white + border). Select is always `muted`. + * + * Use in `app/(consumer)/**` and `components/consumer/**` instead of + * `components/formElements/Input`. + * + * Guidelines: `frontend/docs/consumer-ui-guidelines.md` + */ + +import { useEffect } from 'react' +import { FormProvider, useForm, useFormContext, type UseFormReturn } from 'react-hook-form' + +import ConsumerChoiceField from '@/components/consumer/input/ConsumerChoiceField' +import ConsumerSelectField from '@/components/consumer/input/ConsumerSelectField' +import ConsumerTextareaField from '@/components/consumer/input/ConsumerTextareaField' +import { ConsumerNumberField, ConsumerTextField } from '@/components/consumer/input/ConsumerTextField' +import { defaultValueForConsumerField, type ConsumerInputProps } from '@/components/consumer/input/consumerInputTypes' +import { texts, format } from '@/texts' + +export type { ConsumerInputProps } from '@/components/consumer/input/consumerInputTypes' + +function useOptionalFormContext(): UseFormReturn | null { + const context = useFormContext() as UseFormReturn | null + + return context?.control ? context : null +} + +function ConsumerInputFields({ + radius = 'control', + size = 'md', + tone, + isClearable = false, + inputWrapper, + orientation = 'vertical', + direction = 'rtl', + englishDigitsOnly = false, + otpLength = 4, + otpClassNames, + selectKey = 'code', + selectValue = 'name', + textAreaMinRows = 5, + ...props +}: ConsumerInputProps) { + const { control } = useFormContext() + const placeholder = + props.placeholder ?? + (props.generalType === 'select' + ? undefined + : typeof props.label === 'string' + ? format(texts.common.enterField, { label: props.label }) + : '') + + switch (props.generalType) { + case 'numberInput': + return ( + + ) + + case 'input': + return ( + + ) + + case 'textarea': + return ( + + ) + + case 'select': + return ( + + ) + + case 'radio': + case 'checkbox': + case 'otp': + case 'switch': + return ( + + ) + + default: + return null + } +} + +function ConsumerInputStandalone(props: ConsumerInputProps) { + const { name, generalType, value, onValueChange } = props + const methods = useForm({ + defaultValues: { [name]: value ?? defaultValueForConsumerField(generalType) }, + values: value !== undefined ? { [name]: value } : undefined, + }) + + useEffect(() => { + if (!onValueChange) return + + const subscription = methods.watch((values, info) => { + if (info.name && info.name !== name) return + onValueChange(values[name]) + }) + + return () => { + subscription.unsubscribe() + } + }, [methods, name, onValueChange]) + + return ( + + + + ) +} + +export default function ConsumerInput(props: ConsumerInputProps) { + const form = useOptionalFormContext() + const isControlled = props.value !== undefined || props.onValueChange !== undefined + + if (!form || isControlled) { + return + } + + return +} diff --git a/components/consumer/ConsumerModal.tsx b/components/consumer/ConsumerModal.tsx new file mode 100644 index 0000000..d3f1fee --- /dev/null +++ b/components/consumer/ConsumerModal.tsx @@ -0,0 +1,339 @@ +'use client' + +/** + * ConsumerModal — the ONLY overlay primitive allowed in the consumer app. + * + * Purpose + * - Bottom-sheet dialog for confirmations, forms, and flow steps under + * `app/(consumer)/**` and shared consumer surfaces (auth/PWA/event/review). + * - Wraps HeroUI Modal with قبیله tokens (`rounded-consumer-modal`, safe-area padding, + * RTL body) and a default dual-action footer via `ConsumerActionButtons`. + * + * When to use + * - Any modal, sheet, or dialog shown to end users in the consumer shell. + * + * When NOT to use + * - Admin dashboard → `components/modals/Modal` (never this file). + * - Do not invent parallel overlays (HeroUI Drawer, ad-hoc fixed banners, admin Modal). + * + * Agent constraints + * - Cursor rules: `consumer-modals`, `consumer-mobile-first`. + * - Always bottom sheet (every viewport). No centered / top / auto placement. + * - The sheet slides up from the bottom when opening and back down when closing. + * - Close button is hidden by default (`hideCloseButton`); rely on cancel / backdrop. + * - Browser/Android Back dismisses the sheet via `useCloseOnHistoryBack` (same URL; + * nested sheets stack). Opt out with `closeOnHistoryBack={false}`; Back is trapped + * while `!isDismissable` or busy (`isLoading` / `isSubmitting` / `isCancelLoading`). + * - Legacy prop aliases (`accept*` / `reject*` / `onAccept` / `onReject`) exist for + * migrations — prefer `submit*` / `cancel*` / `onSubmit` / `onCancel` in new code. + * - Persian copy via `@/texts`, never hardcoded. + * + * Related + * - Footer buttons: `ConsumerActionButtons` + * - Sticky page CTAs that replace BottomNav: `ConsumerFormActionBar` (not a modal) + * - History Back: `hooks/useCloseOnHistoryBack` + * - Guidelines: `frontend/docs/consumer-ui-guidelines.md` + */ + +import { type FocusEvent, type PropsWithChildren, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Modal as HeroModal } from '@heroui/react' + +import ConsumerActionButtons from '@/components/consumer/ConsumerActionButtons' +import { useCloseOnHistoryBack } from '@/hooks/useCloseOnHistoryBack' +import { cn } from '@/lib/cn' +import { scrollElementIntoContainer } from '@/lib/scrollElementIntoContainer' +import { texts } from '@/texts' + +type ConsumerModalSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | 'full' + +/** Keep close callbacks aligned with the CSS exit animation. */ +const EXIT_DURATION_MS = 220 + +export interface ConsumerModalProps { + /** Compatibility aliases for consumer flows migrated from the legacy Modal. */ + acceptDanger?: boolean + acceptBtnDisabled?: boolean + acceptBtnText?: string + backdrop?: 'transparent' | 'opaque' | 'blur' + bodyClassName?: string + cancelDisabled?: boolean + cancelIcon?: ReactNode + cancelLabel?: string + className?: string + /** + * When true (default), opening the sheet pushes a history entry so browser/Android + * Back closes it without leaving the page. Nested modals stack correctly. + */ + closeOnHistoryBack?: boolean + containerClassName?: string + footerChildren?: ReactNode + hideCloseButton?: boolean + hideFooter?: boolean + hideHeader?: boolean + isLoading?: boolean + isCancelLoading?: boolean + isDismissable?: boolean + isOpen: boolean + isSubmitting?: boolean + onAccept?: () => void + onCancel?: () => void + onClose?: () => void + onOpenChange: (isOpen: boolean) => void + onReject?: () => void + onSubmit?: () => void + rejectBtnDisabled?: boolean + rejectBtnText?: string + radius?: 'none' | 'sm' | 'md' | 'lg' + size?: ConsumerModalSize + /** + * Stacking tier for nested sheets (auth profile → city/birthdate pickers). + * Level 0 = z-60 (default). Each step adds 10 so the child sits above its parent. + */ + stackLevel?: 0 | 1 | 2 + submitDisabled?: boolean + submitIcon?: ReactNode + submitLabel?: string + title?: string + titleIcon?: ReactNode +} + +export default function ConsumerModal({ + acceptDanger = false, + acceptBtnDisabled, + acceptBtnText, + backdrop = 'opaque', + bodyClassName, + cancelDisabled = false, + cancelIcon, + cancelLabel = texts.common.cancel, + children, + className, + closeOnHistoryBack = true, + containerClassName, + footerChildren, + hideCloseButton = true, + hideFooter = false, + hideHeader = false, + isLoading = false, + isCancelLoading = false, + isDismissable = true, + isOpen, + isSubmitting = false, + onAccept, + onCancel, + onClose, + onOpenChange, + onReject, + onSubmit, + radius = 'lg', + rejectBtnDisabled, + rejectBtnText, + submitDisabled = false, + submitIcon, + submitLabel = texts.common.select, + size = 'md', + stackLevel = 0, + title = '', + titleIcon, +}: PropsWithChildren) { + const [renderedOpen, setRenderedOpen] = useState(isOpen) + const [contentSnapshot, setContentSnapshot] = useState({ children, footerChildren, title, titleIcon }) + const closeRequestedRef = useRef(false) + const closeFallbackRef = useRef | null>(null) + + if ( + isOpen && + (contentSnapshot.children !== children || + contentSnapshot.footerChildren !== footerChildren || + contentSnapshot.title !== title || + contentSnapshot.titleIcon !== titleIcon) + ) { + setContentSnapshot({ children, footerChildren, title, titleIcon }) + } + + const visualContent = isOpen ? { children, footerChildren, title, titleIcon } : contentSnapshot + const resolvedSubmitting = isSubmitting || isLoading + const resolvedCancelLabel = rejectBtnText ?? cancelLabel + const resolvedSubmitLabel = acceptBtnText ?? submitLabel + const resolvedSubmitDisabled = acceptBtnDisabled ?? submitDisabled + const isBusy = resolvedSubmitting || isCancelLoading + const normalizedSize: 'xs' | 'sm' | 'md' | 'lg' | 'full' = + size === 'full' ? 'full' : size === 'xs' || size === 'sm' || size === 'md' || size === 'lg' ? size : 'lg' + const appMaxWidth = 'var(--consumer-app-max-width)' + const maxWidthClass = { + xs: `max-w-[min(320px,${appMaxWidth})]`, + sm: `max-w-[min(384px,${appMaxWidth})]`, + md: `max-w-[${appMaxWidth}]`, + lg: `max-w-[min(512px,${appMaxWidth})]`, + xl: `max-w-[min(640px,${appMaxWidth})]`, + '2xl': `max-w-[min(768px,${appMaxWidth})]`, + '3xl': `max-w-[min(896px,${appMaxWidth})]`, + '4xl': `max-w-[min(1024px,${appMaxWidth})]`, + '5xl': `max-w-[min(1280px,${appMaxWidth})]`, + full: `max-w-[${appMaxWidth}]`, + }[size] + const topRadiusClass = + radius === 'none' + ? 'rounded-t-none' + : radius === 'sm' + ? 'rounded-t-consumer-control' + : radius === 'md' + ? 'rounded-t-consumer-card' + : 'rounded-t-consumer-modal' + + const stackZClass = stackLevel === 2 ? 'z-[80]' : stackLevel === 1 ? 'z-[70]' : 'z-[60]' + + const completeRequestedClose = useCallback(() => { + if (!closeRequestedRef.current) return + + closeRequestedRef.current = false + if (closeFallbackRef.current) { + clearTimeout(closeFallbackRef.current) + closeFallbackRef.current = null + } + + onOpenChange(false) + onCancel?.() + onReject?.() + onClose?.() + }, [onCancel, onClose, onOpenChange, onReject]) + + const requestClose = useCallback(() => { + if (closeRequestedRef.current) return + + closeRequestedRef.current = true + setRenderedOpen(false) + }, []) + + useCloseOnHistoryBack({ + // Drop the history entry as soon as the exit animation starts so Back mid-close + // does not leave the page or dismiss a parent sheet. + isOpen: isOpen && renderedOpen, + enabled: closeOnHistoryBack, + allowClose: isDismissable && !isBusy, + onRequestClose: requestClose, + }) + + useLayoutEffect(() => { + if (isOpen) { + closeRequestedRef.current = false + setRenderedOpen(true) + + return + } + + setRenderedOpen(false) + }, [isOpen]) + + useEffect(() => { + if (renderedOpen || !closeRequestedRef.current) return + + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false + + closeFallbackRef.current = setTimeout(completeRequestedClose, reduceMotion ? 0 : EXIT_DURATION_MS) + + return () => { + if (closeFallbackRef.current) { + clearTimeout(closeFallbackRef.current) + closeFallbackRef.current = null + } + } + }, [completeRequestedClose, renderedOpen]) + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + requestClose() + + return + } + + onOpenChange(true) + } + + /** + * After the keyboard settles, keep the focused control visible inside the + * modal body only. Do not call `scrollIntoView` — on mobile it can shift + * `visualViewport.offsetTop` and open a hole through the fixed backdrop. + */ + const handleFocusCapture = (event: FocusEvent) => { + const target = event.target + + if (!(target instanceof HTMLElement) || !target.matches('input, textarea, select, [contenteditable="true"]')) return + + const body = event.currentTarget + + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + scrollElementIntoContainer(body, target) + }) + }) + } + + return ( + + + + + {!hideCloseButton && !isBusy ? : null} + {!hideHeader ? ( + + {visualContent.titleIcon} + {visualContent.title} + + ) : null} + + + {visualContent.children} + + + {!hideFooter ? ( + + {visualContent.footerChildren ?? ( + + )} + + ) : null} + + + + + ) +} diff --git a/components/consumer/UserAvatar.tsx b/components/consumer/UserAvatar.tsx new file mode 100644 index 0000000..4bf0280 --- /dev/null +++ b/components/consumer/UserAvatar.tsx @@ -0,0 +1,62 @@ +'use client' + +/** + * UserAvatar — circular or square avatar with UserFillIcon fallback. + * + * Purpose + * - Shared avatar for host cards, chat, profile, and follow lists. + * - Empty/missing `src` shows a neutral background + UserFillIcon (decorative). + * + * Agent notes + * - Pass explicit `size` in pixels; Next/Image uses `fill` inside that box. + * - Prefer this over ad-hoc Image + fallback icon pairs in consumer UI. + */ + +import Image from 'next/image' + +import UserFillIcon from '@/components/icons/UserFillIcon' +import { cn } from '@/lib/cn' + +export type UserAvatarVariant = 'circle' | 'square' + +interface UserAvatarProps { + src?: string | null + alt: string + variant: UserAvatarVariant + /** Pixel size (width and height). */ + size: number + className?: string + /** Extra class on the fallback UserFillIcon. */ + iconClassName?: string +} + +const UserAvatar = ({ src, alt, variant, size, className, iconClassName }: UserAvatarProps) => { + const trimmed = src?.trim() || null + const radiusClass = variant === 'circle' ? 'rounded-full' : 'rounded-[12px]' + + return ( + + {trimmed ? ( + {alt} + ) : ( + = 64 ? 'size-8' : 'size-5', iconClassName)} /> + )} + + ) +} + +export default UserAvatar diff --git a/components/consumer/UserIdentity.tsx b/components/consumer/UserIdentity.tsx new file mode 100644 index 0000000..03381d2 --- /dev/null +++ b/components/consumer/UserIdentity.tsx @@ -0,0 +1,105 @@ +'use client' + +/** + * UserIdentity — avatar + display name for any user (host or guest). + * + * Purpose + * - Shared “who is this person?” row: wraps `UserAvatar` and a formatted name. + * - Optional `userId` turns the block into a link to `/u/:id`. + * - Works for hosts and guests; pass `nameFallback` for role-specific empty names. + * + * Related + * - Avatar only: `UserAvatar` + * - Name formatting: `formatPersonName` + */ + +import type { ReactNode } from 'react' + +import Link from 'next/link' + +import UserAvatar, { type UserAvatarVariant } from '@/components/consumer/UserAvatar' +import { SEO_ROUTES } from '@/constants/routes' +import { formatPersonName } from '@/helpers' +import { cn } from '@/lib/cn' +import { texts, format } from '@/texts' + +export type { UserAvatarVariant } + +export interface UserIdentityProps { + /** Public profile id — when set, avatar + name link to `/u/:id`. */ + userId?: string | null + firstName?: string | null + lastName?: string | null + /** Pre-formatted name; wins over first/last when provided. */ + name?: string | null + /** Fallback when name parts are empty (e.g. guest vs host label). */ + nameFallback?: string + src?: string | null + variant?: UserAvatarVariant + /** Pixel size for the avatar (width and height). */ + size?: number + avatarClassName?: string + iconClassName?: string + /** Optional line under the name (e.g. relative time). */ + subtitle?: ReactNode + /** Extra content beside the name (e.g. verified badge). */ + nameExtra?: ReactNode + nameClassName?: string + subtitleClassName?: string + className?: string +} + +const UserIdentity = ({ + userId, + firstName, + lastName, + name, + nameFallback = texts.events.guest, + src, + variant = 'circle', + size = 36, + avatarClassName, + iconClassName, + subtitle, + nameExtra, + nameClassName, + subtitleClassName, + className, +}: UserIdentityProps) => { + const displayName = name?.trim() || formatPersonName(firstName, lastName, nameFallback) + const content = ( + <> + +
+
+

{displayName}

+ {nameExtra} +
+ {subtitle ?
{subtitle}
: null} +
+ + ) + + if (userId) { + return ( + + {content} + + ) + } + + return
{content}
+} + +export default UserIdentity diff --git a/components/consumer/consumerButtonStyles.ts b/components/consumer/consumerButtonStyles.ts new file mode 100644 index 0000000..faaa4a4 --- /dev/null +++ b/components/consumer/consumerButtonStyles.ts @@ -0,0 +1,137 @@ +import type { + ConsumerButtonAlign, + ConsumerButtonFill, + ConsumerButtonRadius, + ConsumerButtonSize, + ResolvedConsumerButtonStyle, +} from '@/components/consumer/consumerButtonTypes' +import { cn } from '@/lib/cn' + +/** xs/sm/md/lg/xl → px heights (Figma). */ +export const CONSUMER_BUTTON_HEIGHT_PX: Record = { + xs: 40, + sm: 44, + md: 48, + lg: 52, + xl: 60, +} + +const HEIGHT_CLASS: Record = { + xs: 'h-10 min-h-10', + sm: 'h-11 min-h-11', + md: 'h-12 min-h-12', + lg: 'h-[52px] min-h-[52px]', + xl: 'h-[60px] min-h-[60px]', +} + +const ICON_ONLY_CLASS: Record = { + xs: 'h-10 w-10 min-h-10 min-w-10', + sm: 'h-11 w-11 min-h-11 min-w-11', + md: 'h-12 w-12 min-h-12 min-w-12', + lg: 'h-[52px] w-[52px] min-h-[52px] min-w-[52px]', + xl: 'h-[60px] w-[60px] min-h-[60px] min-w-[60px]', +} + +const RADIUS_CLASS: Record = { + full: 'rounded-full', + control: 'rounded-[10px]', +} + +const FILL_CLASS: Record = { + orange: 'bg-primary hover:bg-primary-600 active:bg-primary-700', + navy: 'bg-tertiary-900 hover:bg-tertiary-800 active:bg-tertiary-900', + fourth: 'bg-fourth-900 hover:bg-fourth-700 active:bg-fourth-900', + fifth: 'bg-fifth-900 hover:bg-fifth-700 active:bg-fifth-900', + gray: 'bg-[#F5F5F5] hover:bg-secondary-40 active:bg-secondary-200', + none: 'bg-transparent', +} + +const DEFAULT_TEXT_CLASS: Record = { + orange: 'text-white', + navy: 'text-white', + fourth: 'text-white', + fifth: 'text-white', + gray: 'text-consumer-text', + none: undefined, +} + +const DEFAULT_SIZE: Record<'default' | 'icon', ConsumerButtonSize> = { + default: 'lg', + icon: 'sm', +} + +function resolveTextColorClass(textColor: string | undefined, fill: ConsumerButtonFill): string | undefined { + if (!textColor) return DEFAULT_TEXT_CLASS[fill] + if (textColor === 'white') return 'text-white' + if (textColor.startsWith('text-') || textColor.startsWith('!text-')) return textColor + + return undefined +} + +function resolveTextColorStyle(textColor: string | undefined): string | undefined { + if (!textColor || textColor === 'white' || textColor.startsWith('text-') || textColor.startsWith('!text-')) { + return undefined + } + + if (textColor.startsWith('#')) return textColor + + return undefined +} + +/** وقتی از بیرون `px-*` داده شده، padding افقی پیش‌فرض اعمال نمی‌شود. */ +function hasHorizontalPaddingOverride(className?: string): boolean { + return className != null && /\b!?px-/.test(className) +} + +function resolveHorizontalPaddingClass(iconOnly: boolean, className?: string): string | undefined { + if (hasHorizontalPaddingOverride(className)) return undefined + + return iconOnly ? 'px-0' : 'px-4' +} + +export function buildConsumerButtonStyle({ + align = 'center', + className, + radius = 'full', + fill = 'orange', + fontSize = 14, + fullWidth = false, + iconOnly = false, + size, + textColor, +}: { + align?: ConsumerButtonAlign + className?: string + radius?: ConsumerButtonRadius + fill?: ConsumerButtonFill + fontSize?: number + fullWidth?: boolean + iconOnly?: boolean + size?: ConsumerButtonSize + textColor?: string +}): ResolvedConsumerButtonStyle { + const resolvedSize = size ?? (iconOnly ? DEFAULT_SIZE.icon : DEFAULT_SIZE.default) + const textClass = resolveTextColorClass(textColor, fill) + const inlineColor = resolveTextColorStyle(textColor) + + return { + className: cn( + 'consumer-focus-ring inline-flex shrink-0 items-center gap-2 font-medium transition-colors', + 'disabled:pointer-events-none disabled:opacity-50', + resolveHorizontalPaddingClass(iconOnly, className), + iconOnly + ? cn(ICON_ONLY_CLASS[resolvedSize], 'justify-center') + : cn(HEIGHT_CLASS[resolvedSize], align === 'center' ? 'justify-center' : 'justify-start'), + !iconOnly && 'min-w-0', + RADIUS_CLASS[radius], + FILL_CLASS[fill], + textClass, + fullWidth && 'w-full', + className + ), + style: { + fontSize, + ...(inlineColor ? { color: inlineColor } : {}), + }, + } +} diff --git a/components/consumer/consumerButtonTypes.ts b/components/consumer/consumerButtonTypes.ts new file mode 100644 index 0000000..ee4ffe8 --- /dev/null +++ b/components/consumer/consumerButtonTypes.ts @@ -0,0 +1,51 @@ +import type { CSSProperties, ReactNode } from 'react' + +/** Figma fill: #F68B1F | #1C2473 | #ED1C24 | #007839 | #F5F5F5 | transparent */ +export type ConsumerButtonFill = 'orange' | 'navy' | 'fourth' | 'fifth' | 'gray' | 'none' + +/** Figma radius: pill or 10px control */ +export type ConsumerButtonRadius = 'full' | 'control' + +/** Button height tokens — see `consumer-ui-guidelines.md` (scale differs from ConsumerInput). */ +export type ConsumerButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' + +/** Content alignment inside the button (RTL: `start` = right) */ +export type ConsumerButtonAlign = 'center' | 'start' + +export type ConsumerButtonIconPosition = 'start' | 'end' + +export interface ConsumerButtonProps { + 'aria-controls'?: string + 'aria-expanded'?: boolean | 'true' | 'false' + 'aria-label'?: string + 'aria-pressed'?: boolean | 'true' | 'false' + 'aria-checked'?: boolean | 'true' | 'false' + 'aria-selected'?: boolean | 'true' | 'false' + align?: ConsumerButtonAlign + children?: ReactNode + className?: string + radius?: ConsumerButtonRadius + disabled?: boolean + fill?: ConsumerButtonFill + fontSize?: number + fullWidth?: boolean + icon?: ReactNode + iconEnd?: ReactNode + iconOnly?: boolean + iconPosition?: ConsumerButtonIconPosition + iconStart?: ReactNode + isLoading?: boolean + role?: React.AriaRole + size?: ConsumerButtonSize + target?: '_blank' | '_self' + /** Preset white, Tailwind class (e.g. `text-primary`), or hex (e.g. `#333A44`). */ + textColor?: 'white' | (string & {}) + to?: string + type?: 'button' | 'submit' | 'reset' + onClick?: () => void +} + +export interface ResolvedConsumerButtonStyle { + className: string + style: CSSProperties +} diff --git a/components/consumer/input/ConsumerChoiceField.tsx b/components/consumer/input/ConsumerChoiceField.tsx new file mode 100644 index 0000000..15e301a --- /dev/null +++ b/components/consumer/input/ConsumerChoiceField.tsx @@ -0,0 +1,183 @@ +'use client' + +import type { ReactNode } from 'react' + +import { Controller, type Control } from 'react-hook-form' + +import ConsumerFieldShell from '@/components/consumer/input/ConsumerFieldShell' +import type { ConsumerInputRadius, ConsumerInputSize } from '@/components/consumer/input/consumerInputTypes' +import { + CONSUMER_CHECKBOX_CLASS_NAMES, + CONSUMER_FIELD_DESCRIPTION_CLASS, + CONSUMER_FIELD_LABEL_CLASS, + CONSUMER_RADIO_CLASS_NAMES, + buildConsumerOtpSegmentClass, + withConsumerFieldError, +} from '@/components/consumer/input/consumerInputStyles' +import { Checkbox, Radio, RadioGroup, Switch } from '@/components/heroui/SelectionControls' +import { InputOtp } from '@/components/heroui/InputOtp' +import { getOptionValue, type SelectOptionItem } from '@/components/formElements/input/inputUtils' +import { coerceToString } from '@/helpers' +import { cn } from '@/lib/cn' + +interface Props { + autoFocus?: boolean + className?: string + control: Control + description?: ReactNode + disabled?: boolean + kind: 'radio' | 'checkbox' | 'otp' | 'switch' + label?: string + name: string + options: SelectOptionItem[] + orientation: 'horizontal' | 'vertical' + otpClassNames?: { segment?: string; segmentWrapper?: string } + otpLength: number + placeholder?: string + radius: ConsumerInputRadius + readonly?: boolean + required?: boolean + selectKey: string + size: ConsumerInputSize +} + +export default function ConsumerChoiceField({ + autoFocus, + className, + control, + description, + disabled, + kind, + label, + name, + options, + orientation, + otpClassNames, + otpLength, + placeholder, + radius, + readonly, + required, + selectKey, + size, +}: Props) { + const otpSegmentClass = otpClassNames?.segment ?? buildConsumerOtpSegmentClass({ radius, size }) + + return ( + { + const invalid = !!error + const otpSegment = invalid ? withConsumerFieldError(otpSegmentClass) : otpSegmentClass + + if (kind === 'otp') { + return ( + + + + ) + } + + if (kind === 'radio') { + return ( + + {options.map((item) => ( + + {coerceToString(getOptionValue(item, 'name'))} + + ))} + + ) + } + + if (kind === 'checkbox') { + return ( +
+ + {label} + + {description ?

{description}

: null} + {invalid && error?.message ?

{error.message}

: null} +
+ ) + } + + return ( +
+ + {label} + + {description ?

{description}

: null} +
+ ) + }} + /> + ) +} diff --git a/components/consumer/input/ConsumerFieldShell.tsx b/components/consumer/input/ConsumerFieldShell.tsx new file mode 100644 index 0000000..fcfe0ff --- /dev/null +++ b/components/consumer/input/ConsumerFieldShell.tsx @@ -0,0 +1,42 @@ +'use client' + +import type { ReactNode } from 'react' + +import { Description, FieldError, Label } from '@heroui/react' + +import { CONSUMER_FIELD_DESCRIPTION_CLASS, CONSUMER_FIELD_LABEL_CLASS } from '@/components/consumer/input/consumerInputStyles' +import { buildOutsideLabel } from '@/components/formElements/input/inputUtils' +import { cn } from '@/lib/cn' + +interface Props { + children: ReactNode + className?: string + description?: ReactNode + errorMessage?: string + invalid?: boolean + label?: string + labelIcon?: ReactNode + required?: boolean +} + +export default function ConsumerFieldShell({ + children, + className, + description, + errorMessage, + invalid, + label, + labelIcon = null, + required, +}: Props) { + return ( +
+ {label ? ( + + ) : null} + {children} + {description ? {description} : null} + {invalid && errorMessage ? {errorMessage} : null} +
+ ) +} diff --git a/components/consumer/input/ConsumerSelectField.tsx b/components/consumer/input/ConsumerSelectField.tsx new file mode 100644 index 0000000..9b499c0 --- /dev/null +++ b/components/consumer/input/ConsumerSelectField.tsx @@ -0,0 +1,170 @@ +'use client' + +import type { ReactNode } from 'react' + +import { Controller, type Control } from 'react-hook-form' + +import ConsumerFieldShell from '@/components/consumer/input/ConsumerFieldShell' +import type { ConsumerInputRadius, ConsumerInputSize, ConsumerInputTone } from '@/components/consumer/input/consumerInputTypes' +import { + CONSUMER_FIELD_SELECT_PLACEHOLDER_CLASS, + CONSUMER_FIELD_SELECT_POPOVER_CLASS, + CONSUMER_FIELD_SELECT_VALUE_CLASS, + buildConsumerFieldWrapper, + resolveConsumerTone, + withConsumerFieldError, +} from '@/components/consumer/input/consumerInputStyles' +import { Select, SelectItem } from '@/components/heroui/Select' +import { getOptionValue, type SelectOptionItem } from '@/components/formElements/input/inputUtils' +import { useDialogPortalContainer } from '@/components/formElements/input/useDialogPortalContainer' +import { coerceToString } from '@/helpers' +import { cn } from '@/lib/cn' + +interface Props { + ariaLabel?: string + autoFocus?: boolean + className?: string + radius: ConsumerInputRadius + size: ConsumerInputSize + control: Control + description?: ReactNode + disabled?: boolean + iconEnd?: ReactNode + iconStart?: ReactNode + inputWrapper?: string + label?: string + multiple?: boolean + name: string + options: SelectOptionItem[] + placeholder?: string + readonly?: boolean + required?: boolean + selectKey: string + selectValue: string + tone?: ConsumerInputTone +} + +export default function ConsumerSelectField({ + ariaLabel, + autoFocus, + className, + radius, + size, + control, + description, + disabled, + iconEnd, + iconStart, + label, + multiple, + name, + options, + placeholder, + readonly: _readonly, + required, + selectKey, + selectValue, + tone, + inputWrapper, +}: Props) { + const { portalContainer, setHostEl } = useDialogPortalContainer() + const resolvedTone = resolveConsumerTone('select', tone) + const wrapper = cn( + buildConsumerFieldWrapper({ + radius: radius, + size: size, + tone: resolvedTone, + }), + inputWrapper + ) + + return ( + { + const invalid = !!error + const triggerWrapper = invalid ? withConsumerFieldError(wrapper) : wrapper + const selectedKeys = multiple + ? field.value + : field.value !== undefined && field.value !== null && field.value !== '' + ? [String(field.value)] + : [] + + return ( +
+ + + +
+ ) + }} + /> + ) +} diff --git a/components/consumer/input/ConsumerTextField.tsx b/components/consumer/input/ConsumerTextField.tsx new file mode 100644 index 0000000..ae271f3 --- /dev/null +++ b/components/consumer/input/ConsumerTextField.tsx @@ -0,0 +1,235 @@ +'use client' + +import type { KeyboardEvent } from 'react' + +import { Controller, type Control } from 'react-hook-form' + +import ConsumerFieldShell from '@/components/consumer/input/ConsumerFieldShell' +import type { ConsumerInputRadius, ConsumerInputSize, ConsumerInputTone } from '@/components/consumer/input/consumerInputTypes' +import { + CONSUMER_FIELD_INPUT_CLASS, + buildConsumerFieldWrapper, + resolveConsumerTone, + withConsumerFieldError, +} from '@/components/consumer/input/consumerInputStyles' +import { buildConsumerAmountDescription } from '@/components/consumer/input/consumerInputUtils' +import { Input } from '@/components/heroui/Input' +import { NumberInput } from '@/components/heroui/NumberInput' +import { formatNumberInputDisplayValue } from '@/components/formElements/input/inputUtils' +import { convertPersianToEnglish } from '@/helpers' +import { cn } from '@/lib/cn' + +interface SharedProps { + ariaLabel?: string + autoFocus?: boolean + className?: string + radius: ConsumerInputRadius + size: ConsumerInputSize + control: Control + description?: React.ReactNode + direction: 'rtl' | 'ltr' + disabled?: boolean + englishDigitsOnly: boolean + formatOptions?: Intl.NumberFormatOptions + iconEnd?: React.ReactNode + iconStart?: React.ReactNode + inputWrapper?: string + inputType?: 'text' | 'email' | 'number' | 'password' | 'tel' + isClearable: boolean + label?: string + labelIcon?: React.ReactNode + maxValue?: number + minValue?: number + name: string + placeholder?: string + readonly?: boolean + required?: boolean + tone?: ConsumerInputTone + onBlur?: () => void + onClear?: () => void + onKeyDown?: (event: KeyboardEvent) => void +} + +export function ConsumerTextField(props: SharedProps) { + const tone = resolveConsumerTone('input', props.tone) + const wrapper = cn( + buildConsumerFieldWrapper({ + radius: props.radius, + size: props.size, + tone, + }), + props.inputWrapper + ) + + return ( + { + const invalid = !!error + const inputWrapper = invalid ? withConsumerFieldError(wrapper) : wrapper + const description = buildConsumerAmountDescription(field.value, props.label, props.description) + const isTel = props.inputType === 'tel' + const canClear = props.isClearable && !props.readonly && props.inputType !== 'password' && !isTel + const isRtlLayout = props.direction === 'rtl' && !isTel + + return ( + + { + field.onBlur() + props.onBlur?.() + }} + onChange={(event) => { + let value = event.target.value + + if (props.englishDigitsOnly || isTel) value = convertPersianToEnglish(value).replace(/\D/g, '') + else if (props.inputType === 'number') value = String(Number(value)) + field.onChange(value) + }} + onKeyDown={props.onKeyDown} + {...(canClear + ? { + onClear: () => { + if (props.onClear) props.onClear() + else field.onChange('') + }, + } + : {})} + /> + + ) + }} + /> + ) +} + +export function ConsumerNumberField(props: SharedProps) { + const tone = resolveConsumerTone('numberInput', props.tone) + const wrapper = cn( + buildConsumerFieldWrapper({ + radius: props.radius, + size: props.size, + tone, + }), + props.inputWrapper + ) + + return ( + { + const invalid = !!error + const inputWrapper = invalid ? withConsumerFieldError(wrapper) : wrapper + const description = buildConsumerAmountDescription(field.value, props.label, props.description) + const { onChange, value, ...fieldRest } = field + + if (props.readonly) { + return ( + + + + ) + } + + return ( + + { + if (props.onClear) props.onClear() + else onChange(undefined) + }, + } + : {})} + onValueChange={onChange} + /> + + ) + }} + /> + ) +} diff --git a/components/consumer/input/ConsumerTextareaField.tsx b/components/consumer/input/ConsumerTextareaField.tsx new file mode 100644 index 0000000..929b937 --- /dev/null +++ b/components/consumer/input/ConsumerTextareaField.tsx @@ -0,0 +1,103 @@ +'use client' + +import { Controller, type Control } from 'react-hook-form' + +import ConsumerFieldShell from '@/components/consumer/input/ConsumerFieldShell' +import type { ConsumerInputRadius, ConsumerInputTone } from '@/components/consumer/input/consumerInputTypes' +import { + CONSUMER_FIELD_INPUT_CLASS, + buildConsumerTextareaWrapper, + resolveConsumerTone, + withConsumerFieldError, +} from '@/components/consumer/input/consumerInputStyles' +import { Textarea } from '@/components/heroui/Input' +import { cn } from '@/lib/cn' + +interface Props { + autoFocus?: boolean + className?: string + radius: ConsumerInputRadius + control: Control + description?: React.ReactNode + disabled?: boolean + isClearable: boolean + inputWrapper?: string + label?: string + name: string + placeholder?: string + readonly?: boolean + required?: boolean + textAreaMinRows: number + tone?: ConsumerInputTone + onBlur?: () => void +} + +export default function ConsumerTextareaField({ + autoFocus, + className, + radius, + control, + description, + disabled, + isClearable, + label, + name, + placeholder, + readonly, + required, + textAreaMinRows, + tone, + inputWrapper, + onBlur, +}: Props) { + const resolvedTone = resolveConsumerTone('textarea', tone) + const wrapper = cn(buildConsumerTextareaWrapper({ radius: radius, tone: resolvedTone }), inputWrapper) + + return ( + { + const invalid = !!error + const inputWrapper = invalid ? withConsumerFieldError(wrapper) : wrapper + const canClear = isClearable && !readonly + + return ( + +