From 76ef4e32a9b38c1975c670d0727084c010f35b64 Mon Sep 17 00:00:00 2001 From: payacom <59262811+payacom@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:51:50 +0330 Subject: [PATCH] order --- package-lock.json | 966 ++++++- package.json | 1 + public/service-worker.js | 45 +- src/app/(auth)/verify/avatar/page.tsx | 22 +- src/app/(auth)/verify/confirm/page.tsx | 334 +-- .../academy/[id]/[title]/CourseClient.tsx | 2106 +++++++------- .../academy/payment/failed/page.tsx | 5 + .../academy/payment/success/page.tsx | 5 + .../[academyId]/AcademyProfileClient.tsx | 4 + src/app/(projects)/new-project/page.tsx | 17 +- src/app/billboards/payment/failed/page.tsx | 28 +- src/app/billboards/payment/success/page.tsx | 28 +- src/app/booking/[userId]/page.tsx | 4 +- src/app/gift/payment/failed/page.tsx | 5 + src/app/gift/payment/success/page.tsx | 5 + src/app/global-error.tsx | 2 +- src/app/layout.tsx | 2 + src/app/offer/payment/failed/page.tsx | 5 + src/app/offer/payment/success/page.tsx | 5 + src/app/settings/academy/Components/Box.tsx | 38 +- .../academy/Components/OrdersCart.tsx | 206 +- src/app/settings/academy/my-courses/page.tsx | 892 +++--- .../settings/academy/storeProfile/page.tsx | 1022 +++---- src/app/settings/academy/wallet/page.tsx | 854 +++--- .../settings/booking/new/schedule/page.tsx | 6 +- .../settings/chats/[username]/[id]/page.tsx | 1 + .../settings/chats/rooms/[roomId]/page.tsx | 4 +- src/app/settings/edit/License/page.tsx | 15 +- .../edit/activity/academy-comments/page.tsx | 12 +- .../edit/activity/academy-likes/page.tsx | 12 +- .../edit/activity/billboard-comments/page.tsx | 12 +- .../edit/activity/billboard-likes/page.tsx | 12 +- .../edit/analytics/posts/[postId]/page.tsx | 7 +- .../settings/edit/analytics/posts/page.tsx | 7 +- .../settings/edit/analytics/region/page.tsx | 11 +- src/app/settings/edit/avatar/page.tsx | 7 + src/app/settings/edit/bio/page.tsx | 5 + src/app/settings/edit/blocked-users/page.tsx | 18 +- src/app/settings/edit/colors/page.tsx | 7 + .../settings/edit/cooperation-type/page.tsx | 5 + src/app/settings/edit/expertise/page.tsx | 10 + src/app/settings/edit/google-account/page.tsx | 3 +- .../settings/edit/instagram-import/page.tsx | 3 +- .../edit/instagram-import/select/page.tsx | 5 +- .../edit/link-device/approve/page.tsx | 5 +- src/app/settings/edit/link-device/page.tsx | 5 +- src/app/settings/edit/location/page.tsx | 5 + .../settings/edit/personal-details/page.tsx | 7 + .../settings/edit/public-relations/page.tsx | 5 + src/app/settings/edit/services/page.tsx | 10 + src/app/settings/edit/shaba/page.tsx | 5 + src/app/settings/edit/sizes/page.tsx | 7 + src/app/settings/edit/two-factor/page.tsx | 3 +- .../settings/edit/verification-guide/page.tsx | 57 + src/app/settings/notifications/page.tsx | 4 + src/app/settings/profile/language/page.tsx | 2 +- .../settings/profile/user-settings/page.tsx | 220 +- src/app/settings/workroom/edit/[id]/page.tsx | 11 +- src/app/shops/checkout/[orderId]/page.tsx | 2 +- src/app/shops/new/preview/page.tsx | 22 +- src/app/shops/orders/[orderId]/page.tsx | 63 +- src/app/shops/product/[catalogId]/page.tsx | 2 +- .../profile/[shopId]/ShopProfileClient.tsx | 2 +- src/components/ClientErrorBoundary.tsx | 2 +- src/components/PushNotificationListener.tsx | 57 + src/components/academy/AcademyPackageItem.tsx | 2 +- src/components/academy/AcademySkeletons.tsx | 18 +- src/components/academy/MainModelCard.tsx | 1036 +++---- src/components/auth/OtherLanguagesModal.tsx | 2 +- .../billboards/BillboardDetailContent.tsx | 2 +- .../BillboardPage/BillboardDetails.tsx | 324 +-- .../BillboardPage/BillboardImageSlider.tsx | 86 +- .../BillboardPage/BillboardsFilter.tsx | 62 +- .../billboards/Profile/AdProfileContent.tsx | 3 +- .../Profile/AdProfileContentPosts.tsx | 158 +- .../billboards/Profile/AdProfileHead.tsx | 230 +- src/components/chart-area-interactive.tsx | 902 +++--- src/components/chat/AttachmentMenu.tsx | 182 +- src/components/chat/ChatRoomMemberPicker.tsx | 2 +- src/components/chat/FileMessageBubble.tsx | 222 +- src/components/chat/ForwardMessageModal.tsx | 408 +-- src/components/chat/MessageInput.tsx | 5 +- src/components/chat/SharedPostBubble.tsx | 2 +- src/components/chat/SharedProductBubble.tsx | 2 +- src/components/chat/SharedStoryBubble.tsx | 2 +- src/components/chat/TimedMessagePicker.tsx | 2 +- src/components/course/CourseForm.tsx | 2434 ++++++++--------- src/components/main/ProfileAvatar.tsx | 2 +- .../models/AppearanceColorSelect.tsx | 4 +- src/components/models/FilterModal.tsx | 5 +- .../models/MainModelCard/MainModelCard.tsx | 1003 +++---- .../MainModelCard/MainModelCardActions.tsx | 29 +- .../models/MainModelCard/SwipeImageSlider.tsx | 376 +-- .../models/ModelPage/LocationModal.tsx | 9 +- .../models/ModelPage/MainModelCardPost.tsx | 892 +++--- .../models/ModelPage/ModelContent.tsx | 6 +- .../models/ModelPage/ModelContentPosts.tsx | 2 +- .../models/ModelPage/ProfileUserListModal.tsx | 2 +- src/components/posts/PostLinkPicker.tsx | 8 +- .../posts/ReelsPostAnalyticsModal.tsx | 2 +- src/components/posts/ReelsPostOptionsMenu.tsx | 4 +- src/components/posts/SendPostModal.tsx | 4 +- src/components/posts/TagUsersPicker.tsx | 402 +-- src/components/posts/VideoCoverPicker.tsx | 4 +- src/components/profile/ShareProfileModal.tsx | 2 +- src/components/projects/MainProjectCard.tsx | 5 +- .../projects/NewProject/MultiStepForm.tsx | 4 + .../Workroom/ProjectRequestsAction.tsx | 484 ++-- src/components/pwa/PwaInstallPrompt.tsx | 39 +- .../settings/AccountAnalyticsWidget.tsx | 2 +- src/components/settings/EditPageSkeletons.tsx | 30 + .../settings/LanguageSettingRow.tsx | 2 +- .../settings/SectionAnalyticsWidget.tsx | 4 +- .../settings/profile/AccountActionModals.tsx | 2 +- .../settings/profile/ProfileContent.tsx | 13 +- src/components/shops/SendProductModal.tsx | 4 +- src/components/shops/ShareProductModal.tsx | 2 +- src/components/shops/ShareShopModal.tsx | 2 +- src/components/shops/ShopCartTab.tsx | 2 +- src/components/shops/TimeSelect.tsx | 2 +- .../shops/orders/BuyerInfoModal.tsx | 9 +- .../shops/orders/BuyerOrderItem.tsx | 4 +- .../shops/orders/SellerOrderItem.tsx | 4 +- src/components/shops/orders/ShopInfoModal.tsx | 9 +- .../shops/wallet/ShopPerformanceWidget.tsx | 4 +- src/components/stories/SendStoryModal.tsx | 4 +- src/components/stories/StoryEditor.tsx | 4 +- src/components/stories/StoryRing.tsx | 2 +- src/components/stories/StoryViewer.tsx | 2 +- src/components/ui/BannerComponent.tsx | 110 +- .../ui/CountryProvinceCitySelect.tsx | 26 +- src/components/ui/LanguageSwitcher.tsx | 106 +- src/components/ui/ProductCart.tsx | 246 +- src/components/ui/ReadBox.tsx | 60 +- src/components/ui/card.tsx | 184 +- src/components/ui/sidebar.tsx | 1452 +++++----- src/constants/exploreFilters.ts | 6 +- src/constants/notificationFilters.ts | 7 +- src/data/geo/provinces.json | 93 +- src/hooks/usePwaInstall.ts | 8 +- src/lib/auth/session.ts | 8 + src/lib/e2ee/keys.ts | 44 +- src/lib/explore/buildPostFilters.ts | 7 + src/lib/firebase.ts | 47 + src/lib/pushNotifications.ts | 60 + src/lib/pwa/install.ts | 18 +- src/lib/pwaConfig.ts | 8 +- src/lib/ui/buttonStyles.ts | 10 +- src/locales/ar/common.json | 41 +- src/locales/en/common.json | 41 +- src/locales/fa/common.json | 41 +- src/types/types.ts | 7 +- src/utils/geoName.ts | 10 + 153 files changed, 10612 insertions(+), 8768 deletions(-) create mode 100644 src/app/settings/edit/verification-guide/page.tsx create mode 100644 src/components/PushNotificationListener.tsx create mode 100644 src/components/settings/EditPageSkeletons.tsx create mode 100644 src/lib/firebase.ts create mode 100644 src/lib/pushNotifications.ts create mode 100644 src/utils/geoName.ts diff --git a/package-lock.json b/package-lock.json index 8bcfe0b..02ac110 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "embla-carousel-react": "^8.6.0", + "firebase": "^12.18.0", "formik": "^2.4.6", "framer-motion": "^12.23.12", "i18next": "^25.3.6", @@ -453,6 +454,640 @@ "node": ">=18.x" } }, + "node_modules/@firebase/ai": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.15.0.tgz", + "integrity": "sha512-Aj7TbFdAIWZdkX8JfdDStERpR35g6WNs+7XhNPtFLFOizUotj6k4N/D8HJkR45165HhnMJBe4hjOeeaxnnn56Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.5", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.24", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.24.tgz", + "integrity": "sha512-OfIAcIIwoqXjBzS+DnUQpOZ7i3ePaNFg83KPpDWjBZUKJmqMwzzAtLJqmKZOkQnW8im6vFR6f2I3M/9hxVd+QA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.30", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.30.tgz", + "integrity": "sha512-uVZEKlLaW4AHAhv8zoN9cTmveX6v86AVqcJ4LCaRCMTChTH8/NsjbisTq1lpBfWCLWS1spqwSHB4vol/YSCdMA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.24", + "@firebase/analytics-types": "0.8.5", + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.5.tgz", + "integrity": "sha512-kdnooE7Bis2jEnsqcerRwn/UQpH5D3uvHpku7OdUM9TJN3omlu6iYtbyAQ6XkAJRgJtO0aNf9OOkW8J6D59oCA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.16.1.tgz", + "integrity": "sha512-tjUEorFyKrurH7PbLWv9zDHRuU4mLefgD/yY38D584ziorIRlQz/PVjx4c/SCGyCuUlmyzt72mK+Lh+COAywNQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.13.1.tgz", + "integrity": "sha512-l8y3dmnhodXks/APAwx4tWqRl3tk8b9874KF1FJyKg1DIU+kMD0l52iz7S9/MW+eK8k6cjJg0G0iJ5KQAsQpow==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.7.tgz", + "integrity": "sha512-fBb/xSMyIKv1nDmccfzz3IAGBzx/cQOxmZai66rJzifb1hMMkztf824Xx7LhpTUe7HNUbXwY90IvJ66fi8q6yg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.13.1", + "@firebase/app-check-types": "0.5.5", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.5.tgz", + "integrity": "sha512-qId34pVZ2CXTmtu4ofW5leiI93DvnOvIcr/5GT7MZPw5WXAi6nZoU+g9cNRgl7K90UJSbK0cXxten4meYMPyZg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.5.tgz", + "integrity": "sha512-+DF4gzFrlwGFyky38o4T/YN/r51l70yCtJ2HTkwmRj8FCMwPjm4hLH4fQbj6BUvzkiFqliBkitFsRpf1/YZkWA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.17.tgz", + "integrity": "sha512-5GdJWobqs6jbNYOnaQiSa4Ng8gnFerhqr7nY+v5jlnT7o9QbEeIZRLPQpnLe0TxYSWfRV2lAMbyR0kbXeIos9Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.16.1", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.6.tgz", + "integrity": "sha512-yPLahy7Esfu2w/yme3msVK4xTkDXQqq6szfQn8yVOQpCKiT5GVFjqNpLbuz6NkX0WuTwUidkmPewW3r4xkvpeg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/logger": "0.5.2" + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.10.tgz", + "integrity": "sha512-Bvklg2nL7BrBFCAqdsleW8fse9Jh0fARqJPlJmA40uermfWx1bJiO7dnKyZN3J39d2TFVd4VJXtg3i6Wg92/YQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.13.5", + "@firebase/auth-types": "0.13.2", + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-compat/node_modules/@firebase/auth": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.13.5.tgz", + "integrity": "sha512-1AXoBJqBVD8WL8FZYo3S2GmJF9YUoom6Y6ngMxOSkzzhW5sT83pLchb6TGFgxes91dfXx8s/VYc5VrLDNqpLog==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.6.tgz", + "integrity": "sha512-FgwZqDrBqgK0BHI70QTv1v/5wmuDF9f8fsJFvKSxoRlK2PPfSQtZAk2jhXu4pe9BZzFi/e4UYusU/bEQHdf6Qg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.2.tgz", + "integrity": "sha512-OU+miuoSxIWYN7GT291d2ylVJBL3k/OePo7/JDSoQtkFQoEiGBc4AHuMjj/seqFIsHrqnjrAfRCk4pfufoJolQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.5.tgz", + "integrity": "sha512-vuFDcL91Q+2ZuBJkyOh86T4q0B4ffNTDjc/A38tybO56odQABxRTFLTIowCWqAKeIcgo37GowWMVgFF73gD8Qw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.7.4.tgz", + "integrity": "sha512-su1aGWlzhxb+xtggCUSsufJn1FDa06SBDK71y+fpQ+g2zMhMExik6FUv/odkKzOF/xfWVjabCbWBcjJY3MceDA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.6", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.5.tgz", + "integrity": "sha512-/JGpvszLoNXNgzilRXocigGfFF4hbcPA9wN1i1kjJx6oKkXgkHteZYl3lQs1lJX3ETDf6bv7zI15lPMVUp4sAQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.5", + "@firebase/auth-interop-types": "0.2.6", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.7.tgz", + "integrity": "sha512-lBq9sJm8MnJINKJnkAKSOj2MbC66xGoSVCcGkPtstl+lkoKEBtD46qHLbuDgW/WbLPoKVm17RIN8BxHj+Z2F2w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/database": "1.1.5", + "@firebase/database-types": "1.0.22", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + }, + "peerDependenciesMeta": { + "@firebase/app": { + "optional": true + }, + "@firebase/app-compat": { + "optional": true + } + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.22.tgz", + "integrity": "sha512-YAZNXsjY9EQQ+pKw/3ax8n5FgolHC7Qew7EY5RceYdl0R2ZP+kCv1O0DkKlcceue8uQCOreHCNN7PWVFpY1Nug==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.6", + "@firebase/util": "1.15.3" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.17.1.tgz", + "integrity": "sha512-8lqPNf2w10CtYG+tayVjZO1pSyQpnhztQRudeD109VtXDzNbASTaYdO43sj5PMsDcWq0aOYY3RmJOUlXu9++jw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "@firebase/webchannel-wrapper": "1.0.7", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "re2js": "^2.8.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.13.tgz", + "integrity": "sha512-l9dCewxMzzLOIhcwTjERCKxrWOn1kZ9JvwOQq9zZNq4I/Nbvb/B9njT230V61uvQ9qZ3/qpUG758D8DDTEFXnw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/firestore": "4.17.1", + "@firebase/firestore-types": "3.0.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.5.tgz", + "integrity": "sha512-dbdMAQkMd5dwWc48eupz/Y6/E9ruat3+gY5lhVKscvvT/HnDBEEMzJW38zdKhgdnglZHGk2vUsJMOHAphMHGMA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.14.0.tgz", + "integrity": "sha512-DhuYFr0eMhp+s/PNEk6SiMsYkc00+XVOUeKbrl8MOzQNZI3SKQpqoDR1d+keNDAutwmHRWTUAdXBoVPD+xxVUw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.5", + "@firebase/auth-interop-types": "0.2.6", + "@firebase/component": "0.7.5", + "@firebase/messaging-interop-types": "0.2.6", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.5.0.tgz", + "integrity": "sha512-T3BDIToESZUHt7438wyKYMkRjKg3m1xAIux5fVpNvTRQlDOFLukWniQWBDhJ2znpU9kxMTR6+e31TVo8P15PZw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/functions": "0.14.0", + "@firebase/functions-types": "0.6.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.5.tgz", + "integrity": "sha512-Zc0pURjthHXzSj54ZivCkzKDSV1r/wIpnmHdhq82q2yFFPVoncG/ZJjnVMiANvQfeww/QnElhzODpfwUucVwdA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.24", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.24.tgz", + "integrity": "sha512-Ui52ey8wHoWqkBbXRKJEKYWylI0JZogZmoLS+o8Anh1bxWtK27ZYjLla1UJAAdC5DCKLJ2qAp0VpJOjg8VOX1g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.24.tgz", + "integrity": "sha512-8M5nlcWwYt881x83COP2odq5vgf+NgwJh+RMd4LRSv8JI1pxwDfgrDJOERrjTgfC9J5Z0vnQX4b1pdN914e0Zw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/installations-types": "0.5.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.5.tgz", + "integrity": "sha512-e9UYcju3puDl1vdrcKIi5dExzHLameOT/Tc61Q48PYwxtsM1NzZh/ikGbdBQTsbRgg0EMZqdPr0/m5ODUBobrg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.2.tgz", + "integrity": "sha512-J2VO4NFTc0xQFrxV1B/lm5balicm9cwuX2acR9Yn41fN8KgUeQFo+VJV222IqW2FPSXKDu9uo5WdQFWf9TPbYg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.13.2.tgz", + "integrity": "sha512-KcZoqUu2ih4sLH91dW9tmyHjCR0IQyNzSLZunX5uq7cLeImXb4uO2I0wq5FACduO2I+FoTeWa2BtUv7Jpowqdw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/messaging-interop-types": "0.2.6", + "@firebase/util": "1.15.3", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.29", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.29.tgz", + "integrity": "sha512-8Twe4CeYvAx8AzjBxyyQFKzinaMGGt13hPQBqaARQ2QZjagrEaqSVBe+Zy6F4L/vT8DV168yRgRu7W/RK7p+4w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/messaging": "0.13.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.6.tgz", + "integrity": "sha512-MVzvkKe2V4H2dHu5oOxRfeKQcfTwWmCgnzsC4V1q3ixun5iiL8riCZ2qI35rDhCL4glPGiu0jHxDbpVNuTKfow==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.14", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.14.tgz", + "integrity": "sha512-9PH1XEZVHErxGdbXluvGz4Uyjw4W955H8v7Mv9rHIybRrg5F2Ac2tvf5+mSf5EtnxWNmxrD2WLnvMFaZP4sMrQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.27", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.27.tgz", + "integrity": "sha512-O/ozTf/EbChN94Pk7bd1eUC0PAC36726AwsaiJyC0bZzSBWfLGSWASGPH5pebUWXQPJtGxIJYRkkbX5l0Qa+Hw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/performance": "0.7.14", + "@firebase/performance-types": "0.2.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.5.tgz", + "integrity": "sha512-PRzOgB+/M+6AKlEkY8a9xy5Ff5SfZPIh4iShXhn2WAcL/euSbK7bdLqWysHduunNJhGFsXa22Ag3ixqL6rQtYg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.9.2.tgz", + "integrity": "sha512-Rii93DkXjM+RE/ytHdYa7EIiQLJbdBr+W8EEiqd/vbLCOC+1FdkDuRiumJBp6t3yewHGbdqbpjB3fk3z0cZ+8g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.29", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.29.tgz", + "integrity": "sha512-mY7JtTISK6F4g4T0x3kVepr5cp0NXL7f5yjIUXXGaTrg4qUlFBWRbENaHaqZ78dwmNcLm24h/yPsOVRlDXEXhA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/remote-config": "0.9.2", + "@firebase/remote-config-types": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.2.tgz", + "integrity": "sha512-i8k1omVfoAnaT1ZPv2FFjxMZrATYsO/GnFgHEj5+7fAJLzi8wLnGGv1WK06oEAD6ltrWXSO1HzeNyajn/NjMWw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.5.tgz", + "integrity": "sha512-r2tozN/BlEewLi70tJNUQPzWwbex9GM7NgXZsamHKQrjKEfcR0i4jGgHBKAKA4hbwiPE9eNMb3hcxWnDpeJZwg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.5.tgz", + "integrity": "sha512-vO0tFPxXbKDKdlTu8tYT08S9t9ezUTJYEdLCULpWxWq2aq6zojwN1QmAB+1a50AI/g47GlWUJn1XPncm/PDZsw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/storage": "0.14.5", + "@firebase/storage-types": "0.8.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.5.tgz", + "integrity": "sha512-GEDs5P+rNUfNcS+wxIdOLAHficife2YXtvTJnyi3ssrX11AAtBg+nDUdbYh8vuBzWehVQZPmztGUUnud4L+4yg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.3.tgz", + "integrity": "sha512-c/z/gaIlaaLZEuGbE6sLUuJ61tskg1JghvhcNQzW948ASBinbVBBRnZTC4b4yt4LaEtJQkYlyzqLHcutFwEIvA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.7.tgz", + "integrity": "sha512-phBFwieDLvkZGYN9CE9ZFNEIoBVksprzsnCzQejCmCHtgwCXReeuRpoEGN9C4EbhONztv8NRV1tau6Rb9pONwQ==", + "license": "Apache-2.0" + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", @@ -506,6 +1141,37 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@headlessui/react": { "version": "2.2.10", "resolved": "https://registry.npmmirror.com/@headlessui/react/-/react-2.2.10.tgz", @@ -1413,6 +2079,63 @@ "node": ">=12.4.0" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@radix-ui/colors": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/@radix-ui/colors/-/colors-3.0.0.tgz", @@ -5175,6 +5898,37 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clone": { "version": "1.0.4", "resolved": "https://registry.npmmirror.com/clone/-/clone-1.0.4.tgz", @@ -6105,7 +6859,6 @@ "version": "3.2.0", "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6672,6 +7425,18 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -6761,6 +7526,66 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase": { + "version": "12.18.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.18.0.tgz", + "integrity": "sha512-XaL6tlE5Xd20ZDhckqOMIw+JJTET+wTdeZPxQ7ihc42oxRb7kWUyn/j1LO5V9dH1xq8Rv5R71Pv1fBCdIkt9Rw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "2.15.0", + "@firebase/analytics": "0.10.24", + "@firebase/analytics-compat": "0.2.30", + "@firebase/app": "0.16.1", + "@firebase/app-check": "0.13.1", + "@firebase/app-check-compat": "0.4.7", + "@firebase/app-compat": "0.5.17", + "@firebase/app-types": "0.9.6", + "@firebase/auth": "1.13.5", + "@firebase/auth-compat": "0.6.10", + "@firebase/data-connect": "0.7.4", + "@firebase/database": "1.1.5", + "@firebase/database-compat": "2.1.7", + "@firebase/firestore": "4.17.1", + "@firebase/firestore-compat": "0.4.13", + "@firebase/functions": "0.14.0", + "@firebase/functions-compat": "0.5.0", + "@firebase/installations": "0.6.24", + "@firebase/installations-compat": "0.2.24", + "@firebase/messaging": "0.13.2", + "@firebase/messaging-compat": "0.2.29", + "@firebase/performance": "0.7.14", + "@firebase/performance-compat": "0.2.27", + "@firebase/remote-config": "0.9.2", + "@firebase/remote-config-compat": "0.2.29", + "@firebase/storage": "0.14.5", + "@firebase/storage-compat": "0.4.5", + "@firebase/util": "1.15.3" + } + }, + "node_modules/firebase/node_modules/@firebase/auth": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.13.5.tgz", + "integrity": "sha512-1AXoBJqBVD8WL8FZYo3S2GmJF9YUoom6Y6ngMxOSkzzhW5sT83pLchb6TGFgxes91dfXx8s/VYc5VrLDNqpLog==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", @@ -6979,6 +7804,15 @@ "node": ">= 0.4" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -7306,6 +8140,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -7418,6 +8258,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", @@ -8739,6 +9585,12 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -8768,6 +9620,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", @@ -9846,6 +10704,29 @@ "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", "license": "MIT" }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -10022,6 +10903,15 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/re2js": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-2.8.6.tgz", + "integrity": "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", @@ -10552,6 +11442,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -12308,12 +13207,41 @@ "node": ">= 8" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -12518,6 +13446,42 @@ "node": ">=0.4.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yet-another-react-lightbox": { "version": "3.32.0", "resolved": "https://registry.npmmirror.com/yet-another-react-lightbox/-/yet-another-react-lightbox-3.32.0.tgz", diff --git a/package.json b/package.json index dc67ee2..ab80f45 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "embla-carousel-react": "^8.6.0", + "firebase": "^12.18.0", "formik": "^2.4.6", "framer-motion": "^12.23.12", "i18next": "^25.3.6", diff --git a/public/service-worker.js b/public/service-worker.js index e4af100..ff777cb 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,6 +1,49 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -const CACHE_NAME = "pwa-cache-v10"; +// عین google-services.json/pushToken.ts اندروید — این مقادیر عمومی و امن برای +// مرورگرند. سرویس‌ورکرِ استاتیک نمی‌تونه process.env بخونه، پس مستقیم اینجا نوشته +// شدن (دقیقاً همون چیزی که NEXT_PUBLIC_FIREBASE_* توی باندلِ کلاینت هم inline می‌شه) +importScripts("https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js"); +importScripts("https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js"); + +firebase.initializeApp({ + apiKey: "AIzaSyDpUh8AlafwyM931o519oX3z7JYnXZJyBg", + authDomain: "modstagram-com.firebaseapp.com", + projectId: "modstagram-com", + storageBucket: "modstagram-com.firebasestorage.app", + messagingSenderId: "767614062479", + appId: "1:767614062479:web:ec2aea23cc8ce17dfe45fa", +}); + +// پیامِ پوش وقتی تب بسته/پس‌زمینه‌ست — عین رفتار پیش‌فرضِ اندروید (نوتیفِ سیستمی). +// وقتی تب باز و فعاله، Firebase این رویداد رو صدا نمی‌زنه — اونجا onMessage +// (pushNotifications.ts سمتِ کلاینت) با toast جایگزینش می‌کنه +const messaging = firebase.messaging.isSupported() ? firebase.messaging() : null; +if (messaging) { + messaging.onBackgroundMessage((payload) => { + const title = payload.notification?.title || "modstagram"; + self.registration.showNotification(title, { + body: payload.notification?.body, + icon: "/images/icons/192x192.png", + badge: "/images/icons/192x192.png", + data: payload.data || {}, + }); + }); +} + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const url = "/settings/notifications"; + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientsArr) => { + const existing = clientsArr.find((c) => c.url.includes(url)); + if (existing) return existing.focus(); + return self.clients.openWindow(url); + }) + ); +}); + +const CACHE_NAME = "pwa-cache-v11"; const PRECACHE_URLS = [ "/", diff --git a/src/app/(auth)/verify/avatar/page.tsx b/src/app/(auth)/verify/avatar/page.tsx index 0d0f3e7..a56f073 100644 --- a/src/app/(auth)/verify/avatar/page.tsx +++ b/src/app/(auth)/verify/avatar/page.tsx @@ -93,7 +93,7 @@ function AvatarPage() { headers: { "Content-Type": "multipart/form-data" }, }); toast.success(t("auth.imageSavedSuccess")); - navigateHandler(); + await navigateHandler(); } catch (err) { console.log("Upload error:", err); toast.error(t("auth.imageUploadError")); @@ -101,12 +101,22 @@ function AvatarPage() { } }; - const navigateHandler = () => { - if (userDetail?.user_type === "user") { - router.push("/verify/expertise"); - } else { - router.push("/verify/gender"); + // userDetail از یه fetchData جدا (useEffect موقع mount) پر می‌شه، بدون هیچ + // تضمینی که قبل از اینکه کاربر آپلود آواتار رو تموم کنه resolve بشه — اگه کاربر + // سریع‌تر از اون GET /profile عکس رو آپلود کنه، userDetail هنوز null بود، چک + // user_type==="user" شکست می‌خورد و مستقیم می‌رفت verify/gender، از verify/expertise + // کامل رد می‌شد. اینجا مستقل از اون state، مستقیم و تازه دوباره می‌گیریم + const navigateHandler = async () => { + try { + const response = await request<{ user: IProfileData }>("GET", "/profile"); + if (response?.user?.user_type === "user") { + router.push("/verify/expertise"); + return; + } + } catch (err) { + console.log(err); } + router.push("/verify/gender"); }; return ( diff --git a/src/app/(auth)/verify/confirm/page.tsx b/src/app/(auth)/verify/confirm/page.tsx index cc7dc66..2b606cd 100644 --- a/src/app/(auth)/verify/confirm/page.tsx +++ b/src/app/(auth)/verify/confirm/page.tsx @@ -1,167 +1,167 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -"use client"; - -import Container from "@/components/elements/Container"; -import React, { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import AuthNextButton from "@/components/auth/AuthNextButton"; -import Modal from "@/components/elements/Modal"; -import Link from "next/link"; -import ProfileAvatar from "@/components/main/ProfileAvatar"; -import VerificationBadge from "@/components/main/VerificationBadge"; -import { useUser } from "@/hooks/useUser"; -import { formatFullName } from "@/lib/formatFullName"; -import useAxios from "@/hooks/useAxios"; -import { useTranslation } from "react-i18next"; -import LocalePageShell from "@/components/i18n/LocalePageShell"; - -function Confirm() { - const { t } = useTranslation("common"); - const user = useUser(); - const { request } = useAxios(); - const router = useRouter(); - const [showModal, setShowModal] = useState(false); - const [navigating, setNavigating] = useState(false); - const [verifiedStatus, setVerifiedStatus] = useState(null); - - useEffect(() => { - const markComplete = async () => { - try { - const response = await request<{ verify_badge?: string }>( - "POST", - "/verify/complete", - {} - ); - setVerifiedStatus(response?.verify_badge ?? "none"); - } catch { - setVerifiedStatus(user?.verify_badge ?? "none"); - } - }; - markComplete(); - }, []); - - const confirmHandler = () => { - setNavigating(true); - setShowModal(true); - setNavigating(false); - }; - - return ( - - -
- - {t("auth.registrationComplete")} - - - {t("auth.welcomeMessage")} - -
- -
- {formatFullName(user?.first_name, user?.last_name)} -
- - {user?.user_name} - - -
-
-
- - {t("auth.verificationBadgeHint")} - - -
-
- - - - -
-
- - - - -
-
- - {t("auth.saveAndContinue")} - - - {t("auth.verificationTimeOffice")} - - - {t("auth.verificationTimeOffHours")} - - setShowModal(false)} - height="200px" - > -

- {t("auth.shareWorkHint")} -

-
- - {t("auth.createPost")} - - - {t("auth.later")} - -
-
-
-
-
- ); -} - -export default Confirm; +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +import Container from "@/components/elements/Container"; +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import AuthNextButton from "@/components/auth/AuthNextButton"; +import Modal from "@/components/elements/Modal"; +import Link from "next/link"; +import ProfileAvatar from "@/components/main/ProfileAvatar"; +import VerificationBadge from "@/components/main/VerificationBadge"; +import { useUser } from "@/hooks/useUser"; +import { formatFullName } from "@/lib/formatFullName"; +import useAxios from "@/hooks/useAxios"; +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; + +function Confirm() { + const { t } = useTranslation("common"); + const user = useUser(); + const { request } = useAxios(); + const router = useRouter(); + const [showModal, setShowModal] = useState(false); + const [navigating, setNavigating] = useState(false); + const [verifiedStatus, setVerifiedStatus] = useState(null); + + useEffect(() => { + const markComplete = async () => { + try { + const response = await request<{ verify_badge?: string }>( + "POST", + "/verify/complete", + {} + ); + setVerifiedStatus(response?.verify_badge ?? "none"); + } catch { + setVerifiedStatus(user?.verify_badge ?? "none"); + } + }; + markComplete(); + }, []); + + const confirmHandler = () => { + setNavigating(true); + setShowModal(true); + setNavigating(false); + }; + + return ( + + +
+ + {t("auth.registrationComplete")} + + + {t("auth.welcomeMessage")} + +
+ +
+ {formatFullName(user?.first_name, user?.last_name)} +
+ + {user?.user_name} + + +
+
+
+ + {t("auth.verificationBadgeHint")} + + +
+
+ + + + +
+
+ + + + +
+
+ + {t("auth.saveAndContinue")} + + + {t("auth.verificationTimeOffice")} + + + {t("auth.verificationTimeOffHours")} + + setShowModal(false)} + height="200px" + > +

+ {t("auth.shareWorkHint")} +

+
+ + {t("auth.createPost")} + + + {t("auth.later")} + +
+
+
+
+
+ ); +} + +export default Confirm; diff --git a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx index 0a99f9c..ac39a17 100644 --- a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx +++ b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx @@ -1,1052 +1,1056 @@ -"use client"; - -import React, { useCallback, useEffect, useRef, useState } from "react"; -import Image from "next/image"; -import { motion } from "framer-motion"; -import { toast } from "react-hot-toast"; -import { - Lock, - Play, - CheckCircle, - ShoppingCart, - Clock, - User, - BookOpen, - Percent, - Tag, - FileVideo, - Star, - Phone, -} from "lucide-react"; -import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles"; -import { cn } from "@/lib/utils"; -import { Course } from "@/types/types"; -import useAxios from "@/hooks/useAxios"; -import { Comments } from "@/components/academy/CommentsModal"; -import { buildStorageUrl } from "@/components/main/BaseUrl"; -import VerificationBadge from "@/components/main/VerificationBadge"; -import ProfileAvatar from "@/components/main/ProfileAvatar"; -import { usePathname } from "next/navigation"; -import Cookies from "js-cookie"; -import { trackExploreInteraction } from "@/api/trackExploreInteraction"; -import BillboardContactInfoModal from "@/components/billboards/BillboardPage/BillboardContactInfoModal"; -import { AcademyCourseDetailSkeleton } from "@/components/academy/AcademySkeletons"; -import { useTranslation } from "react-i18next"; - -// ==================== دیتای فیک ==================== -const MOCK_COURSE = { - _id: "course_123", - cuorse_name: "", - price: 0, - category: "", - offer: 0, - caption: "", - course_time: "", - teacher_name: "", - course_image: "", - number_of_course_content: "0", - averageRate: 0, -}; - -const MOCK_VIDEOS = [ - { - _id: "video_1", - file_name: "", - course_video: "/videos/sample1.mp4", - is_free: false, - type: "video", - duration: "", - } - -]; - -// کامپوننت نمایش ریتینگ -const RatingStars = ({ rate }: { rate: number }) => { - return ( -
- {[...Array(5)].map((_, i) => ( - - ))} - ({rate.toFixed(1)}) -
- ); -}; - -interface CourseDetailProps { - courseId?: string; - initialIsPurchased?: boolean; -} - -interface MediaFile { - id: string; - title: string; - file: File | null; - preview?: string; - course_video: string; - type: "image" | "video"; - isFree?: boolean; - is_free?: boolean; -} - -export default function CourseDetail({ - courseId, - initialIsPurchased = false, -}: CourseDetailProps) { - const { t } = useTranslation("common"); - const [selectedVideo, setSelectedVideo] = useState(MOCK_VIDEOS[0]); - const [isPurchased, setIsPurchased] = useState(initialIsPurchased); - const [purchasing, setPurchasing] = useState(false); - const [showContactInfoModal, setShowContactInfoModal] = useState(false); - const [videos] = useState(MOCK_VIDEOS); - const [course] = useState(MOCK_COURSE); - - const pathname = usePathname(); - const match = pathname.match(/\/academy\/([^\/]+)/); - const id = match ? match[1] : null; - - const [courses, setCourses] = useState([]); - const [courseLoading, setCourseLoading] = useState(true); - const { request } = useAxios(); - - const _id = courses[0]?._id; - - const [comments, setComments] = useState([]); - const [totalComments, setTotalComments] = useState(0); - const [averageRate, setAverageRate] = useState(0); - const [loading, setLoading] = useState(false); - const [page, setPage] = useState(1); - const [hasMore, setHasMore] = useState(true); - const [statusFilter, setStatusFilter] = useState("accepted"); - const [sortBy, setSortBy] = useState("createdAt"); - const [sortOrder, setSortOrder] = useState("desc"); - const [usersCache, setUsersCache] = useState>({}); - const [videoFields, setVideoFields] = useState([]); - - const [newScore, setNewScore] = useState(0); - const [rating, setRating] = useState(0); - - const containerRef = useRef(null); - - - useEffect(() => { - const getAcademyCourses = async () => { - - - try { - const getAcademyCourses = (await request( - "GET", - `/academy/academy/course/content?courseId=${id}&page=1&limit=20`, - {} - )) as MediaFile; - - setVideoFields(getAcademyCourses.data.courses); - setSelectedVideo(getAcademyCourses.data.courses[0]) - } catch (err) { - console.error("get error:", err); - toast.error(t("academy.course.loadError")); - } finally { - } - }; - - getAcademyCourses(); - }, []); - useEffect(() => { - const getAcademyCourses = async () => { - try { - const getCoursespayment = await request( - "GET", // تغییر به GET - `/academy/course/checkCoursePurchase/${id}` // ارسال ID در مسیر - ); - - if (getCoursespayment?.success) { - if (getCoursespayment.isPurchased) { - setIsPurchased(true) - } else { - setIsPurchased(false) - } - } - - } catch (err) { - console.error("get error:", err); - toast.error(t("academy.course.loadError")); - } - }; - - getAcademyCourses(); - }, [id]); - - // تابع دریافت اطلاعات کاربر با caching - const fetchUserInfo = async (userId: string) => { - if (usersCache[userId]) { - return usersCache[userId]; - } - - try { - const response = await request("GET", `/profile/${userId}`); - if (response?.user) { - const userData = response.user; - setUsersCache((prev) => ({ ...prev, [userId]: userData })); - return userData; - } - } catch (error) { - console.error(`Error fetching user ${userId}:`, error); - } - return null; - }; - - // دریافت تعداد کل کامنت‌ها و آمار - const fetchCommentsStats = async () => { - if (!_id) return; - - try { - const response = await request( - "GET", - `/academy/course/${_id}/comments/count` - ); - if (response?.success) { - setTotalComments(response.data.totalComments || 0); - setAverageRate(response.data.averageRate || 0); - } - } catch (error) { - console.error("Error fetching stats:", error); - } - }; - - // دریافت لیست کامنت‌ها - const fetchComments = async (pageNum: number, append = false) => { - if (loading || !_id) return; - - setLoading(true); - try { - const response = await request( - "GET", - `/academy/course/${_id}/comments?page=${pageNum}&limit=20&status=${statusFilter}&sortBy=${sortBy}&sortOrder=${sortOrder}` - ); - - let sum = 0; - let count = 0; - - response.data.stats.rateDistribution.forEach(element => { - sum += Number(element); - count++; - }); - - const average = sum / count; - const averagesum = sum *1; - - const roundedRating = Math.round(average * 10) / 10 - setRating(roundedRating); - setNewScore(averagesum); - - if (response?.success) { - let newComments = response.data.comments; - const pagination = response.data.pagination; - - if (newComments && newComments.length > 0) { - // بررسی اینکه آیا اطلاعات کاربر در کامنت‌ها وجود دارد - if (newComments[0]?.user_id?.user_name) { - // اطلاعات کاربر قبلاً داخل کامنت است - setComments((prev) => - append ? [...prev, ...newComments] : newComments - ); - } else { - // اگر بک‌اند فقط user_id را برگردانده، اطلاعات کاربر را بگیر - const uniqueUserIds = [...new Set(newComments.map((c) => c.user_id))]; - - // دریافت اطلاعات همه کاربران به صورت همزمان - const usersData = await Promise.all( - uniqueUserIds.map((id) => fetchUserInfo(id)) - ); - - // ساخت mapping از userId به اطلاعات کاربر - const userMap: Record = {}; - uniqueUserIds.forEach((userId, index) => { - userMap[userId] = usersData[index] || { - _id: userId, - user_name: t("common.user"), - profile_image: null, - is_verified: "unverified" - }; - }); - - // ترکیب کامنت‌ها با اطلاعات کاربران - const commentsWithUsers = newComments.map((comment) => ({ - ...comment, - user_id: userMap[comment.user_id], - })); - - setComments((prev) => - append ? [...prev, ...commentsWithUsers] : commentsWithUsers - ); - } - } else { - if (!append) setComments([]); - } - - setHasMore(pagination?.hasNextPage || false); - if (pagination?.totalItems) { - setTotalComments(pagination.totalItems); - } - - // به‌روزرسانی averageRate از اطلاعات بازگشتی - if (response.data.stats?.averageRate) { - setAverageRate(response.data.stats.averageRate); - } - } - } catch (error) { - console.error("Error fetching comments:", error); - toast.error(t("academy.commentsError")); - } finally { - setLoading(false); - } - }; - - // لود اولیه و هنگام تغییر فیلترها - useEffect(() => { - if (_id) { - setPage(1); - setComments([]); - fetchComments(1, false); - fetchCommentsStats(); - } - }, [_id, statusFilter, sortBy, sortOrder]); - - // اینفینیت اسکرول - const observer = useRef(null); - const lastCommentRef = useCallback( - (node: HTMLDivElement) => { - if (loading) return; - if (observer.current) observer.current.disconnect(); - observer.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && hasMore) { - setPage((prev) => prev + 1); - } - }); - if (node) observer.current.observe(node); - }, - [loading, hasMore] - ); - - useEffect(() => { - if (page > 1) { - fetchComments(page, true); - } - }, [page]); - - useEffect(() => { - const getAcademyCourses = async () => { - setCourseLoading(true); - try { - const response = await request( - "GET", - `/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}` - ); - - if (response?.success && response?.data?.courses) { - setCourses(response.data.courses); - } else if (response?.data && Array.isArray(response.data)) { - setCourses(response.data); - } else if (Array.isArray(response)) { - setCourses(response); - } else { - console.error("Unexpected response structure:", response); - setCourses([]); - } - } catch (err) { - console.error("get error:", err); - toast.error(t("academy.course.loadError")); - setCourses([]); - } finally { - setCourseLoading(false); - } - }; - - if (id) { - getAcademyCourses(); - } - }, [id, request]); - - useEffect(() => { - const course = courses[0]; - if (!course?._id) return; - const token = Cookies.get("token") || ""; - if (!token) return; - trackExploreInteraction( - { - targetType: "academy_course", - targetId: course._id, - authorId: course.user_id || course.academyId, - contentType: "academy", - action: "view", - }, - token - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [courses[0]?._id]); - - // خرید دوره - // const handlePurchase = async () => { - // setPurchasing(true); - - // try { - // const response = await request( - // "POST", - // "/academy/academy/course/payment-web", - // { - // courseId: id, // یا _id که از courses[0]?._id می‌آید - // } - // ); - - // console.log("پاسخ کامل سرور:", response); - - // // بررسی موفقیت آمیز بودن درخواست - // if (response?.success && response?.paymentUrl) { - // // هدایت کاربر به درگاه پرداخت زرین‌پال - // window.location.href = response.paymentUrl; - // } else { - // // نمایش خطا به کاربر - // toast.error(response?.message || "خطا در اتصال به درگاه پرداخت"); - // setPurchasing(false); - // } - // } catch (error) { - // console.error("خطا در خرید:", error); - // toast.error("خطا در ارتباط با سرور"); - // setPurchasing(false); - // } - // }; - - - const handleBuyCourse = async () => { - const token = Cookies.get("token"); - - if (!token) { - toast.error(t("academy.course.loginToBuy")); - return; - } - - setPurchasing(true); - const loadingToastId = toast.loading( - courses[0]?.is_free - ? t("academy.course.registeringFree") - : t("academy.course.connectingPayment") - ); - - try { - const response = await request("POST", "/academy/academy/course/payment-web", { - courseId: id, - }); - - toast.dismiss(loadingToastId); - - if (response?.free) { - setIsPurchased(true); - toast.success(response?.message || t("academy.course.freeRegistered")); - return; - } - - const paymentUrl = - response?.paymentUrl || - response?.data?.paymentUrl || - (response?.authority - ? `https://www.zarinpal.com/pg/StartPay/${response.authority}` - : response?.data?.authority - ? `https://www.zarinpal.com/pg/StartPay/${response.data.authority}` - : null); - - if (paymentUrl) { - window.location.href = paymentUrl; - return; - } - - toast.error( - response?.message || - response?.data?.message || - t("academy.course.paymentError") - ); - } catch (err: unknown) { - toast.dismiss(loadingToastId); - console.error("خطا در خرید:", err); - toast.error(t("academy.course.paymentStartError")); - } finally { - setPurchasing(false); - } - }; - - const handlePurchase = async (plan: { - duration: string; - label: string; - price: number; - discount?: number; - }) => { - let loadingToastId = null; - - try { - - - const token = Cookies.get("token"); - - if (!token) { - toast.error(t("academy.course.loginToBuy")); - return - } - - // نمایش لودینگ - loadingToastId = toast.loading(t("academy.course.connectingPayment")); - - const response = await request( - "POST", - "/academy/academy/course/payment-web", - { - courseId: id, // یا _id که از courses[0]?._id می‌آید - } - ); - - // بستن لودینگ - toast.dismiss(loadingToastId); - - // پردازش پاسخ (ساختارهای مختلف احتمالی) - let authority = null; - let paymentUrl = null; - - if (response?.data?.authority) { - authority = response.data.authority; - paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`; - } else if (response?.authority) { - authority = response.authority; - paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`; - } else if (response?.data?.paymentUrl) { - paymentUrl = response.data.paymentUrl; - } else if (response?.paymentUrl) { - paymentUrl = response.paymentUrl; - } - - if (paymentUrl) { - window.open(paymentUrl, "_blank", "noopener,noreferrer"); - toast.success(t("academy.course.redirectedToPayment")); - - } else { - console.error("ساختار پاسخ نامعتبر:", response); - toast.error( - response?.data?.message || - response?.message || - t("academy.course.paymentStartError") - ); - } - - } catch (err: any) { - // بستن لودینگ در صورت خطا - if (loadingToastId) { - toast.dismiss(loadingToastId); - } - - console.error("خطا در شروع پرداخت:", err); - - // استخراج پیام خطا - let errorMessage = t("academy.course.paymentStartError"); - - if (err?.response?.data?.message) { - errorMessage = err.response.data.message; - } else if (err?.response?.data?.error) { - errorMessage = err.response.data.error; - } else if (err?.message) { - errorMessage = err.message; - } - - if (errorMessage.includes("amount") || errorMessage.includes("مبلغ")) { - errorMessage = t("academy.course.invalidAmount"); - } else if (errorMessage.includes("merchant")) { - errorMessage = t("academy.course.gatewayConfigError"); - } - - toast.error(errorMessage); - } - }; - - - // پخش ویدیو - const handlePlayVideo = (video: (typeof MOCK_VIDEOS)[0]) => { - if (!video.is_free && !isPurchased) { - toast.error(t("academy.course.mustPurchaseVideo")); - return; - } - setSelectedVideo(video); - toast.success(t("academy.course.playing", { name: video.file_name })); - }; - - // جلوگیری از دانلود ویدیو - const preventDownload = ( - e: - | React.MouseEvent - | React.KeyboardEvent - ) => { - e.preventDefault(); - toast.error(t("academy.course.downloadBlocked")); - }; - - const finalPrice = courses[0]?.is_free - ? 0 - : courses[0]?.offerNumber > 0 - ? courses[0]?.price * (1 - courses[0]?.offerNumber / 100) - : course.price; - - const courseContactInfo = courses[0]?.contactInfo; - const hasContactInfo = Boolean( - courseContactInfo?.mobile || - courseContactInfo?.telegramLink || - courseContactInfo?.whatsappNumber || - courseContactInfo?.instagramLink - ); - - const freeVideosCount = videos.filter((v) => v.is_free).length; - const paidVideosCount = videos.filter((v) => !v.is_free).length; - - // کامپوننت نمایش ستاره‌های کوچک برای کامنت‌ها - const renderSmallStars = (rate: number) => { - return ( -
- {[1, 2, 3, 4, 5].map((star) => ( - - ))} -
- ); - }; - - // در صفحه موفقیت پرداخت -useEffect(() => { - const params = new URLSearchParams(window.location.search); - const paymentStatus = params.get('payment'); - - if (paymentStatus === 'success') { - toast.success(t("academy.course.paymentSuccess")); - setIsPurchased(true); - } -}, []); - - if (courseLoading) { - return ; - } - - return ( -
-
-
- {/* سمت راست - پخش ویدیو و اطلاعات */} -
- {/* پلیر ویدیو */} -
-
- - - {!selectedVideo?.is_free && !isPurchased && ( -
- -

- {t("academy.course.videoNotFree")} -

-

- {t("academy.course.purchaseToWatch")} -

- -
- )} -
- -
-

{selectedVideo?.file_name || ""}

-
- {selectedVideo?.duration || ""} - {selectedVideo?.is_free && ( - - ✓ {t("academy.course.freeVideo")} - - )} -
-
-
- - {/* توضیحات دوره */} -
-

- - {t("academy.course.about")} -

-
- {courses[0]?.caption || course.caption} -
-
- - {/* نظرات */} -
-

- - {t("academy.course.studentComments")} - {totalComments > 0 && ( - - {t("academy.course.commentCount", { count: totalComments })} - - )} -

- - {/* نمایش میانگین ریتینگ */} -
-
-
-

- {t("academy.course.averageRating")} -

- -
-
- {(rating || course.averageRate).toFixed(1)} -
-
-
- -
- {!loading && comments.length === 0 ? ( -

- {t("academy.course.noReviewsYet")} -

- ) : ( - comments.map((item, index) => ( -
-
- {/* آواتار کاربر */} -
- -
- -
- {/* اطلاعات کاربر و امتیاز */} -
- - {item?.user_id?.user_name || t("common.user")} - - - - {item?.rate > 0 && ( -
- {renderSmallStars(item.rate)} - - ({item.rate}) - -
- )} -
- - {/* متن کامنت */} -

- {item.comment} -

- - {/* متادیتا */} -
- - {item.createdAt - ? new Date(item.createdAt).toLocaleDateString("fa-IR") - : t("academy.course.unknownDate")} - - - ❤️ {item.likes || 0} - -
-
-
-
- )) - )} - - {loading && ( -
-
-

- {t("academy.course.loadingComments")} -

-
- )} -
-
-
- - {/* سمت چپ - اطلاعات دوره و لیست ویدیوها */} -
- {/* تصویر و اطلاعات اصلی */} -
-
- {courses[0]?.course_image && ( - {courses[0]?.cuorse_name - )} -
- -
-

- {courses[0]?.cuorse_name || course.cuorse_name} -

- - {/* نمایش امتیاز دوره */} -
- -
- - {/* قیمت */} -
- {courses[0]?.is_free ? ( - - {t("academy.course.free")} - - ) : courses[0]?.offerNumber > 0 ? ( -
-
- - {Math.round( - courses[0]?.price * (1 - courses[0]?.offerNumber / 100) - ).toLocaleString()}{" "} - {t("settings.toman")} - - - {Math.round(courses[0]?.price).toLocaleString()}{" "} - {t("settings.toman")} - - - - {t("academy.course.discount", { - percent: courses[0]?.offerNumber, - })} - -
-
- ) : ( - - {Math.round(courses[0]?.price || course.price).toLocaleString()}{" "} - {t("settings.toman")} - - )} -
- - {/* دکمه خرید */} - {isPurchased ? ( -
- - {t("academy.course.purchased")} -
- ) : ( - - )} - - {hasContactInfo && courseContactInfo && ( - - )} - - {/* مشخصات دوره */} -
-
- - - {t("academy.course.teacher")}{" "} - {courses[0]?.teacher_name || course.teacher_name} - -
-
- - - {t("academy.course.duration")}{" "} - {courses[0]?.course_time || course.course_time} - -
-
- - - {t("academy.course.videoCountLabel")}{" "} - {courses[0]?.number_of_course_content || - course.number_of_course_content} - -
-
- - - {t("academy.course.category")}{" "} - {courses[0]?.category || course.category} - -
-
- - -
-
- - {/* لیست ویدیوها */} -
-

- - {t("academy.course.syllabus")} - - {t("academy.course.videoCountShort", { - count: courses[0]?.number_of_course_content, - })} - -

- -
- {videoFields.map((video, index) => { - const isLocked = !video.is_free && !isPurchased; - const isActive = selectedVideo?._id === video._id; - - return ( - handlePlayVideo(video)} - whileHover={{ scale: 1.01 }} - whileTap={{ scale: 0.99 }} - className={`w-full text-right p-3 rounded-lg flex items-center justify-between transition-all ${ - isActive - ? "bg-[#FF107D20] border-r-4 border-[#FF107D]" - : "hover:bg-gray-100 dark:hover:bg-gray-700" - }`} - > -
- {isLocked ? ( - - ) : ( -
- -
- )} -
-

- {index + 1}. {video.file_name} -

- - {video.duration} - -
-
-
- {video.is_free && !isPurchased && ( - - {t("academy.course.free")} - - )} - {isLocked && } -
-
- ); - })} -
- - -
-
-
-
- - {hasContactInfo && courseContactInfo && ( - - )} -
- ); +"use client"; + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import { motion } from "framer-motion"; +import { toast } from "react-hot-toast"; +import { + Lock, + Play, + CheckCircle, + ShoppingCart, + Clock, + User, + BookOpen, + Percent, + Tag, + FileVideo, + Star, + Phone, +} from "lucide-react"; +import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles"; +import { cn } from "@/lib/utils"; +import { Course } from "@/types/types"; +import useAxios from "@/hooks/useAxios"; +import { Comments } from "@/components/academy/CommentsModal"; +import { buildStorageUrl } from "@/components/main/BaseUrl"; +import VerificationBadge from "@/components/main/VerificationBadge"; +import ProfileAvatar from "@/components/main/ProfileAvatar"; +import { usePathname } from "next/navigation"; +import Cookies from "js-cookie"; +import { trackExploreInteraction } from "@/api/trackExploreInteraction"; +import BillboardContactInfoModal from "@/components/billboards/BillboardPage/BillboardContactInfoModal"; +import { AcademyCourseDetailSkeleton } from "@/components/academy/AcademySkeletons"; +import { useTranslation } from "react-i18next"; +import BackButton from "@/components/ui/BackButton"; + +// ==================== دیتای فیک ==================== +const MOCK_COURSE = { + _id: "course_123", + cuorse_name: "", + price: 0, + category: "", + offer: 0, + caption: "", + course_time: "", + teacher_name: "", + course_image: "", + number_of_course_content: "0", + averageRate: 0, +}; + +const MOCK_VIDEOS = [ + { + _id: "video_1", + file_name: "", + course_video: "/videos/sample1.mp4", + is_free: false, + type: "video", + duration: "", + } + +]; + +// کامپوننت نمایش ریتینگ +const RatingStars = ({ rate }: { rate: number }) => { + return ( +
+ {[...Array(5)].map((_, i) => ( + + ))} + ({rate.toFixed(1)}) +
+ ); +}; + +interface CourseDetailProps { + courseId?: string; + initialIsPurchased?: boolean; +} + +interface MediaFile { + id: string; + title: string; + file: File | null; + preview?: string; + course_video: string; + type: "image" | "video"; + isFree?: boolean; + is_free?: boolean; +} + +export default function CourseDetail({ + courseId, + initialIsPurchased = false, +}: CourseDetailProps) { + const { t } = useTranslation("common"); + const [selectedVideo, setSelectedVideo] = useState(MOCK_VIDEOS[0]); + const [isPurchased, setIsPurchased] = useState(initialIsPurchased); + const [purchasing, setPurchasing] = useState(false); + const [showContactInfoModal, setShowContactInfoModal] = useState(false); + const [videos] = useState(MOCK_VIDEOS); + const [course] = useState(MOCK_COURSE); + + const pathname = usePathname(); + const match = pathname.match(/\/academy\/([^\/]+)/); + const id = match ? match[1] : null; + + const [courses, setCourses] = useState([]); + const [courseLoading, setCourseLoading] = useState(true); + const { request } = useAxios(); + + const _id = courses[0]?._id; + + const [comments, setComments] = useState([]); + const [totalComments, setTotalComments] = useState(0); + const [averageRate, setAverageRate] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [statusFilter, setStatusFilter] = useState("accepted"); + const [sortBy, setSortBy] = useState("createdAt"); + const [sortOrder, setSortOrder] = useState("desc"); + const [usersCache, setUsersCache] = useState>({}); + const [videoFields, setVideoFields] = useState([]); + + const [newScore, setNewScore] = useState(0); + const [rating, setRating] = useState(0); + + const containerRef = useRef(null); + + + useEffect(() => { + const getAcademyCourses = async () => { + + + try { + const getAcademyCourses = (await request( + "GET", + `/academy/academy/course/content?courseId=${id}&page=1&limit=20`, + {} + )) as MediaFile; + + setVideoFields(getAcademyCourses.data.courses); + setSelectedVideo(getAcademyCourses.data.courses[0]) + } catch (err) { + console.error("get error:", err); + toast.error(t("academy.course.loadError")); + } finally { + } + }; + + getAcademyCourses(); + }, []); + useEffect(() => { + const getAcademyCourses = async () => { + try { + const getCoursespayment = await request( + "GET", // تغییر به GET + `/academy/course/checkCoursePurchase/${id}` // ارسال ID در مسیر + ); + + if (getCoursespayment?.success) { + if (getCoursespayment.isPurchased) { + setIsPurchased(true) + } else { + setIsPurchased(false) + } + } + + } catch (err) { + console.error("get error:", err); + toast.error(t("academy.course.loadError")); + } + }; + + getAcademyCourses(); + }, [id]); + + // تابع دریافت اطلاعات کاربر با caching + const fetchUserInfo = async (userId: string) => { + if (usersCache[userId]) { + return usersCache[userId]; + } + + try { + const response = await request("GET", `/profile/${userId}`); + if (response?.user) { + const userData = response.user; + setUsersCache((prev) => ({ ...prev, [userId]: userData })); + return userData; + } + } catch (error) { + console.error(`Error fetching user ${userId}:`, error); + } + return null; + }; + + // دریافت تعداد کل کامنت‌ها و آمار + const fetchCommentsStats = async () => { + if (!_id) return; + + try { + const response = await request( + "GET", + `/academy/course/${_id}/comments/count` + ); + if (response?.success) { + setTotalComments(response.data.totalComments || 0); + setAverageRate(response.data.averageRate || 0); + } + } catch (error) { + console.error("Error fetching stats:", error); + } + }; + + // دریافت لیست کامنت‌ها + const fetchComments = async (pageNum: number, append = false) => { + if (loading || !_id) return; + + setLoading(true); + try { + const response = await request( + "GET", + `/academy/course/${_id}/comments?page=${pageNum}&limit=20&status=${statusFilter}&sortBy=${sortBy}&sortOrder=${sortOrder}` + ); + + let sum = 0; + let count = 0; + + response.data.stats.rateDistribution.forEach(element => { + sum += Number(element); + count++; + }); + + const average = sum / count; + const averagesum = sum *1; + + const roundedRating = Math.round(average * 10) / 10 + setRating(roundedRating); + setNewScore(averagesum); + + if (response?.success) { + let newComments = response.data.comments; + const pagination = response.data.pagination; + + if (newComments && newComments.length > 0) { + // بررسی اینکه آیا اطلاعات کاربر در کامنت‌ها وجود دارد + if (newComments[0]?.user_id?.user_name) { + // اطلاعات کاربر قبلاً داخل کامنت است + setComments((prev) => + append ? [...prev, ...newComments] : newComments + ); + } else { + // اگر بک‌اند فقط user_id را برگردانده، اطلاعات کاربر را بگیر + const uniqueUserIds = [...new Set(newComments.map((c) => c.user_id))]; + + // دریافت اطلاعات همه کاربران به صورت همزمان + const usersData = await Promise.all( + uniqueUserIds.map((id) => fetchUserInfo(id)) + ); + + // ساخت mapping از userId به اطلاعات کاربر + const userMap: Record = {}; + uniqueUserIds.forEach((userId, index) => { + userMap[userId] = usersData[index] || { + _id: userId, + user_name: t("common.user"), + profile_image: null, + is_verified: "unverified" + }; + }); + + // ترکیب کامنت‌ها با اطلاعات کاربران + const commentsWithUsers = newComments.map((comment) => ({ + ...comment, + user_id: userMap[comment.user_id], + })); + + setComments((prev) => + append ? [...prev, ...commentsWithUsers] : commentsWithUsers + ); + } + } else { + if (!append) setComments([]); + } + + setHasMore(pagination?.hasNextPage || false); + if (pagination?.totalItems) { + setTotalComments(pagination.totalItems); + } + + // به‌روزرسانی averageRate از اطلاعات بازگشتی + if (response.data.stats?.averageRate) { + setAverageRate(response.data.stats.averageRate); + } + } + } catch (error) { + console.error("Error fetching comments:", error); + toast.error(t("academy.commentsError")); + } finally { + setLoading(false); + } + }; + + // لود اولیه و هنگام تغییر فیلترها + useEffect(() => { + if (_id) { + setPage(1); + setComments([]); + fetchComments(1, false); + fetchCommentsStats(); + } + }, [_id, statusFilter, sortBy, sortOrder]); + + // اینفینیت اسکرول + const observer = useRef(null); + const lastCommentRef = useCallback( + (node: HTMLDivElement) => { + if (loading) return; + if (observer.current) observer.current.disconnect(); + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore) { + setPage((prev) => prev + 1); + } + }); + if (node) observer.current.observe(node); + }, + [loading, hasMore] + ); + + useEffect(() => { + if (page > 1) { + fetchComments(page, true); + } + }, [page]); + + useEffect(() => { + const getAcademyCourses = async () => { + setCourseLoading(true); + try { + const response = await request( + "GET", + `/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}` + ); + + if (response?.success && response?.data?.courses) { + setCourses(response.data.courses); + } else if (response?.data && Array.isArray(response.data)) { + setCourses(response.data); + } else if (Array.isArray(response)) { + setCourses(response); + } else { + console.error("Unexpected response structure:", response); + setCourses([]); + } + } catch (err) { + console.error("get error:", err); + toast.error(t("academy.course.loadError")); + setCourses([]); + } finally { + setCourseLoading(false); + } + }; + + if (id) { + getAcademyCourses(); + } + }, [id, request]); + + useEffect(() => { + const course = courses[0]; + if (!course?._id) return; + const token = Cookies.get("token") || ""; + if (!token) return; + trackExploreInteraction( + { + targetType: "academy_course", + targetId: course._id, + authorId: course.user_id || course.academyId, + contentType: "academy", + action: "view", + }, + token + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [courses[0]?._id]); + + // خرید دوره + // const handlePurchase = async () => { + // setPurchasing(true); + + // try { + // const response = await request( + // "POST", + // "/academy/academy/course/payment-web", + // { + // courseId: id, // یا _id که از courses[0]?._id می‌آید + // } + // ); + + // console.log("پاسخ کامل سرور:", response); + + // // بررسی موفقیت آمیز بودن درخواست + // if (response?.success && response?.paymentUrl) { + // // هدایت کاربر به درگاه پرداخت زرین‌پال + // window.location.href = response.paymentUrl; + // } else { + // // نمایش خطا به کاربر + // toast.error(response?.message || "خطا در اتصال به درگاه پرداخت"); + // setPurchasing(false); + // } + // } catch (error) { + // console.error("خطا در خرید:", error); + // toast.error("خطا در ارتباط با سرور"); + // setPurchasing(false); + // } + // }; + + + const handleBuyCourse = async () => { + const token = Cookies.get("token"); + + if (!token) { + toast.error(t("academy.course.loginToBuy")); + return; + } + + setPurchasing(true); + const loadingToastId = toast.loading( + courses[0]?.is_free + ? t("academy.course.registeringFree") + : t("academy.course.connectingPayment") + ); + + try { + const response = await request("POST", "/academy/academy/course/payment-web", { + courseId: id, + }); + + toast.dismiss(loadingToastId); + + if (response?.free) { + setIsPurchased(true); + toast.success(response?.message || t("academy.course.freeRegistered")); + return; + } + + const paymentUrl = + response?.paymentUrl || + response?.data?.paymentUrl || + (response?.authority + ? `https://www.zarinpal.com/pg/StartPay/${response.authority}` + : response?.data?.authority + ? `https://www.zarinpal.com/pg/StartPay/${response.data.authority}` + : null); + + if (paymentUrl) { + window.location.href = paymentUrl; + return; + } + + toast.error( + response?.message || + response?.data?.message || + t("academy.course.paymentError") + ); + } catch (err: unknown) { + toast.dismiss(loadingToastId); + console.error("خطا در خرید:", err); + toast.error(t("academy.course.paymentStartError")); + } finally { + setPurchasing(false); + } + }; + + const handlePurchase = async (plan: { + duration: string; + label: string; + price: number; + discount?: number; + }) => { + let loadingToastId = null; + + try { + + + const token = Cookies.get("token"); + + if (!token) { + toast.error(t("academy.course.loginToBuy")); + return + } + + // نمایش لودینگ + loadingToastId = toast.loading(t("academy.course.connectingPayment")); + + const response = await request( + "POST", + "/academy/academy/course/payment-web", + { + courseId: id, // یا _id که از courses[0]?._id می‌آید + } + ); + + // بستن لودینگ + toast.dismiss(loadingToastId); + + // پردازش پاسخ (ساختارهای مختلف احتمالی) + let authority = null; + let paymentUrl = null; + + if (response?.data?.authority) { + authority = response.data.authority; + paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`; + } else if (response?.authority) { + authority = response.authority; + paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`; + } else if (response?.data?.paymentUrl) { + paymentUrl = response.data.paymentUrl; + } else if (response?.paymentUrl) { + paymentUrl = response.paymentUrl; + } + + if (paymentUrl) { + window.open(paymentUrl, "_blank", "noopener,noreferrer"); + toast.success(t("academy.course.redirectedToPayment")); + + } else { + console.error("ساختار پاسخ نامعتبر:", response); + toast.error( + response?.data?.message || + response?.message || + t("academy.course.paymentStartError") + ); + } + + } catch (err: any) { + // بستن لودینگ در صورت خطا + if (loadingToastId) { + toast.dismiss(loadingToastId); + } + + console.error("خطا در شروع پرداخت:", err); + + // استخراج پیام خطا + let errorMessage = t("academy.course.paymentStartError"); + + if (err?.response?.data?.message) { + errorMessage = err.response.data.message; + } else if (err?.response?.data?.error) { + errorMessage = err.response.data.error; + } else if (err?.message) { + errorMessage = err.message; + } + + if (errorMessage.includes("amount") || errorMessage.includes("مبلغ")) { + errorMessage = t("academy.course.invalidAmount"); + } else if (errorMessage.includes("merchant")) { + errorMessage = t("academy.course.gatewayConfigError"); + } + + toast.error(errorMessage); + } + }; + + + // پخش ویدیو + const handlePlayVideo = (video: (typeof MOCK_VIDEOS)[0]) => { + if (!video.is_free && !isPurchased) { + toast.error(t("academy.course.mustPurchaseVideo")); + return; + } + setSelectedVideo(video); + toast.success(t("academy.course.playing", { name: video.file_name })); + }; + + // جلوگیری از دانلود ویدیو + const preventDownload = ( + e: + | React.MouseEvent + | React.KeyboardEvent + ) => { + e.preventDefault(); + toast.error(t("academy.course.downloadBlocked")); + }; + + const finalPrice = courses[0]?.is_free + ? 0 + : courses[0]?.offerNumber > 0 + ? courses[0]?.price * (1 - courses[0]?.offerNumber / 100) + : course.price; + + const courseContactInfo = courses[0]?.contactInfo; + const hasContactInfo = Boolean( + courseContactInfo?.mobile || + courseContactInfo?.telegramLink || + courseContactInfo?.whatsappNumber || + courseContactInfo?.instagramLink + ); + + const freeVideosCount = videos.filter((v) => v.is_free).length; + const paidVideosCount = videos.filter((v) => !v.is_free).length; + + // کامپوننت نمایش ستاره‌های کوچک برای کامنت‌ها + const renderSmallStars = (rate: number) => { + return ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ ); + }; + + // در صفحه موفقیت پرداخت +useEffect(() => { + const params = new URLSearchParams(window.location.search); + const paymentStatus = params.get('payment'); + + if (paymentStatus === 'success') { + toast.success(t("academy.course.paymentSuccess")); + setIsPurchased(true); + } +}, []); + + if (courseLoading) { + return ; + } + + return ( +
+
+
+ +
+
+ {/* سمت راست - پخش ویدیو و اطلاعات */} +
+ {/* پلیر ویدیو */} +
+
+ + + {!selectedVideo?.is_free && !isPurchased && ( +
+ +

+ {t("academy.course.videoNotFree")} +

+

+ {t("academy.course.purchaseToWatch")} +

+ +
+ )} +
+ +
+

{selectedVideo?.file_name || ""}

+
+ {selectedVideo?.duration || ""} + {selectedVideo?.is_free && ( + + ✓ {t("academy.course.freeVideo")} + + )} +
+
+
+ + {/* توضیحات دوره */} +
+

+ + {t("academy.course.about")} +

+
+ {courses[0]?.caption || course.caption} +
+
+ + {/* نظرات */} +
+

+ + {t("academy.course.studentComments")} + {totalComments > 0 && ( + + {t("academy.course.commentCount", { count: totalComments })} + + )} +

+ + {/* نمایش میانگین ریتینگ */} +
+
+
+

+ {t("academy.course.averageRating")} +

+ +
+
+ {(rating || course.averageRate).toFixed(1)} +
+
+
+ +
+ {!loading && comments.length === 0 ? ( +

+ {t("academy.course.noReviewsYet")} +

+ ) : ( + comments.map((item, index) => ( +
+
+ {/* آواتار کاربر */} +
+ +
+ +
+ {/* اطلاعات کاربر و امتیاز */} +
+ + {item?.user_id?.user_name || t("common.user")} + + + + {item?.rate > 0 && ( +
+ {renderSmallStars(item.rate)} + + ({item.rate}) + +
+ )} +
+ + {/* متن کامنت */} +

+ {item.comment} +

+ + {/* متادیتا */} +
+ + {item.createdAt + ? new Date(item.createdAt).toLocaleDateString("fa-IR") + : t("academy.course.unknownDate")} + + + ❤️ {item.likes || 0} + +
+
+
+
+ )) + )} + + {loading && ( +
+
+

+ {t("academy.course.loadingComments")} +

+
+ )} +
+
+
+ + {/* سمت چپ - اطلاعات دوره و لیست ویدیوها */} +
+ {/* تصویر و اطلاعات اصلی */} +
+
+ {courses[0]?.course_image && ( + {courses[0]?.cuorse_name + )} +
+ +
+

+ {courses[0]?.cuorse_name || course.cuorse_name} +

+ + {/* نمایش امتیاز دوره */} +
+ +
+ + {/* قیمت */} +
+ {courses[0]?.is_free ? ( + + {t("academy.course.free")} + + ) : courses[0]?.offerNumber > 0 ? ( +
+
+ + {Math.round( + courses[0]?.price * (1 - courses[0]?.offerNumber / 100) + ).toLocaleString()}{" "} + {t("settings.toman")} + + + {Math.round(courses[0]?.price).toLocaleString()}{" "} + {t("settings.toman")} + + + + {t("academy.course.discount", { + percent: courses[0]?.offerNumber, + })} + +
+
+ ) : ( + + {Math.round(courses[0]?.price || course.price).toLocaleString()}{" "} + {t("settings.toman")} + + )} +
+ + {/* دکمه خرید */} + {isPurchased ? ( +
+ + {t("academy.course.purchased")} +
+ ) : ( + + )} + + {hasContactInfo && courseContactInfo && ( + + )} + + {/* مشخصات دوره */} +
+
+ + + {t("academy.course.teacher")}{" "} + {courses[0]?.teacher_name || course.teacher_name} + +
+
+ + + {t("academy.course.duration")}{" "} + {courses[0]?.course_time || course.course_time} + +
+
+ + + {t("academy.course.videoCountLabel")}{" "} + {courses[0]?.number_of_course_content || + course.number_of_course_content} + +
+
+ + + {t("academy.course.category")}{" "} + {courses[0]?.category || course.category} + +
+
+ + +
+
+ + {/* لیست ویدیوها */} +
+

+ + {t("academy.course.syllabus")} + + {t("academy.course.videoCountShort", { + count: courses[0]?.number_of_course_content, + })} + +

+ +
+ {videoFields.map((video, index) => { + const isLocked = !video.is_free && !isPurchased; + const isActive = selectedVideo?._id === video._id; + + return ( + handlePlayVideo(video)} + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} + className={`w-full text-right p-3 rounded-lg flex items-center justify-between transition-all ${ + isActive + ? "bg-[#FF107D20] border-r-4 border-[#FF107D]" + : "hover:bg-gray-100 dark:hover:bg-gray-700" + }`} + > +
+ {isLocked ? ( + + ) : ( +
+ +
+ )} +
+

+ {index + 1}. {video.file_name} +

+ + {video.duration} + +
+
+
+ {video.is_free && !isPurchased && ( + + {t("academy.course.free")} + + )} + {isLocked && } +
+
+ ); + })} +
+ + +
+
+
+
+ + {hasContactInfo && courseContactInfo && ( + + )} +
+ ); } \ No newline at end of file diff --git a/src/app/(projects)/academy/payment/failed/page.tsx b/src/app/(projects)/academy/payment/failed/page.tsx index fab8883..bb311a1 100644 --- a/src/app/(projects)/academy/payment/failed/page.tsx +++ b/src/app/(projects)/academy/payment/failed/page.tsx @@ -6,6 +6,8 @@ import useAxios from "@/hooks/useAxios"; import React, { useEffect, useState } from "react"; import RoundedDiv from "@/components/elements/RoundedDiv"; import RoundedButton from "@/components/elements/RoundedButton"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import BackButton from "@/components/ui/BackButton"; import { useRouter, useSearchParams } from "next/navigation"; import Image from "next/image"; import MainProjectCard from "@/components/projects/MainProjectCard"; @@ -88,7 +90,9 @@ function FailedProject() { }; return ( + +
{t("academy.payment.failed")}
@@ -127,6 +131,7 @@ function FailedProject() {
+
); } diff --git a/src/app/(projects)/academy/payment/success/page.tsx b/src/app/(projects)/academy/payment/success/page.tsx index 94a3877..7fd0530 100644 --- a/src/app/(projects)/academy/payment/success/page.tsx +++ b/src/app/(projects)/academy/payment/success/page.tsx @@ -6,6 +6,8 @@ import useAxios from "@/hooks/useAxios"; import React, { useEffect, useState } from "react"; import RoundedDiv from "@/components/elements/RoundedDiv"; import RoundedButton from "@/components/elements/RoundedButton"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import BackButton from "@/components/ui/BackButton"; import { useSearchParams } from "next/navigation"; import Image from "next/image"; import Link from "next/link"; @@ -66,7 +68,9 @@ function SuccessProject() { }, [project, typeList]); return ( + +
{t("academy.payment.success")}
@@ -95,6 +99,7 @@ function SuccessProject() {
+
); } diff --git a/src/app/(projects)/academy/profile/[academyId]/AcademyProfileClient.tsx b/src/app/(projects)/academy/profile/[academyId]/AcademyProfileClient.tsx index 57aa8f8..5810c0f 100644 --- a/src/app/(projects)/academy/profile/[academyId]/AcademyProfileClient.tsx +++ b/src/app/(projects)/academy/profile/[academyId]/AcademyProfileClient.tsx @@ -8,6 +8,7 @@ import { AcademyProfileHeadSkeleton, } from "@/components/academy/AcademySkeletons"; import Container from "@/components/elements/Container"; +import BackButton from "@/components/ui/BackButton"; import { buildStorageUrl } from "@/components/main/BaseUrl"; import useAxios from "@/hooks/useAxios"; import { useUserById } from "@/hooks/getUserById"; @@ -179,6 +180,7 @@ export default function AcademyProfileClient() { if (isLoading) { return ( + @@ -188,6 +190,7 @@ export default function AcademyProfileClient() { if (!academy) { return ( +
{t("academy.profile.notFound")}
@@ -208,6 +211,7 @@ export default function AcademyProfileClient() { return ( +
+ ); } diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 5bbf697..34e5f4e 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -29,7 +29,7 @@ export default function GlobalError({ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8b22adb..5ec39ea 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,6 +9,7 @@ import Script from 'next/script'; import "./globals.css"; import Layout from "@/components/Layout"; import RegisterSW from "@/components/RegisterSW"; +import PushNotificationListener from "@/components/PushNotificationListener"; import ClientErrorBoundary from "@/components/ClientErrorBoundary"; const iranSansFont = localFont({ @@ -98,6 +99,7 @@ export default async function RootLayout({ + {children} diff --git a/src/app/offer/payment/failed/page.tsx b/src/app/offer/payment/failed/page.tsx index 082473d..3fa6246 100644 --- a/src/app/offer/payment/failed/page.tsx +++ b/src/app/offer/payment/failed/page.tsx @@ -3,6 +3,8 @@ import Container from "@/components/elements/Container"; import React from "react"; import RoundedButton from "@/components/elements/RoundedButton"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import BackButton from "@/components/ui/BackButton"; import { useSearchParams } from "next/navigation"; import Image from "next/image"; import Link from "next/link"; @@ -14,7 +16,9 @@ function FailedBillboard() { const userId = searchParams.get("userId"); return ( + +
{t("offerPage.paymentFailed.title")}
@@ -37,6 +41,7 @@ function FailedBillboard() {
+
); } diff --git a/src/app/offer/payment/success/page.tsx b/src/app/offer/payment/success/page.tsx index 0c5f526..3975071 100644 --- a/src/app/offer/payment/success/page.tsx +++ b/src/app/offer/payment/success/page.tsx @@ -5,6 +5,8 @@ import { User } from "@/types/types"; import useAxios from "@/hooks/useAxios"; import React, { useEffect, useState } from "react"; import RoundedButton from "@/components/elements/RoundedButton"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import BackButton from "@/components/ui/BackButton"; import { useSearchParams } from "next/navigation"; import Image from "next/image"; import Link from "next/link"; @@ -29,7 +31,9 @@ function SuccessBillboard() { }, []); return ( + +
{t("offerPage.paymentSuccess.title")}
@@ -64,6 +68,7 @@ function SuccessBillboard() {
+
); } diff --git a/src/app/settings/academy/Components/Box.tsx b/src/app/settings/academy/Components/Box.tsx index 0261705..ab38058 100644 --- a/src/app/settings/academy/Components/Box.tsx +++ b/src/app/settings/academy/Components/Box.tsx @@ -1,19 +1,19 @@ -import Title from "@/Components/ui/Title"; -import React, { ReactNode } from "react"; - -interface BoxProps { - children: ReactNode; - text: string; -} - -export default function Box({ children, text }: BoxProps) { - return ( -
-
- - - {children} - </div> - </div> - ); -} +import Title from "@/Components/ui/Title"; +import React, { ReactNode } from "react"; + +interface BoxProps { + children: ReactNode; + text: string; +} + +export default function Box({ children, text }: BoxProps) { + return ( + <div className="flex justify-center mt-5 items-center mx-2.5"> + <div className="px-3 py-7 rounded-2xl bg-white max-w-[1500px] dark:bg-neutral-900 w-full shadow-xl"> + <Title text={text} className="text-left mb-8 mx-3.5 " /> + + {children} + </div> + </div> + ); +} diff --git a/src/app/settings/academy/Components/OrdersCart.tsx b/src/app/settings/academy/Components/OrdersCart.tsx index 7d09caa..3a85a81 100644 --- a/src/app/settings/academy/Components/OrdersCart.tsx +++ b/src/app/settings/academy/Components/OrdersCart.tsx @@ -1,103 +1,103 @@ -"use client"; -import { Separator } from "@/Components/ui/separator"; -import React from "react"; -import BoldIcon from "@/components/ui/BoldIcon"; -import { useTranslation } from "react-i18next"; -import Image from "next/image"; - - -interface OrderItem { - id: string; - date: string; - orderCode: string; - amount: string; - statusLabel: string; - statusDetail?: string; - images: string[]; - rating: number; - invoiceUrl?: string; - }; - -export default function OrdersCart({ order }: { order: OrderItem }) { - const { t } = useTranslation("common"); - - - return ( - <div className="w-full border-2 gap-y-3.5 rounded-xl p-3.5 flex flex-col items-center"> - <div className="flex items-center justify-between w-full"> - <div className="flex justify-center items-center gap-2.5"> - <span className=""> - <BoldIcon name="task" size={28} tinted className="text-cyan-600 dark:text-neutral-600" /> - </span> - <span className="font-bold text-neutral-600 dark:text-neutral-300"> - {order.statusLabel} - </span> - </div> - <span className="ltr:rotate-180"> - <BoldIcon name="arrow-right-2" size={24} tinted className="text-neutral-600 dark:text-neutral-300" /> - </span> - </div> - <div className=" text-sm max-sm:text-xs w-full flex gap-3.5"> - <div className="flex gap-2 flex-wrap"> - <span className="font-medium text-neutral-400"> - {t("orderCart.date")}:{" "} - </span> - <span className="font-bold text-neutral-800 dark:text-neutral-200"> - {order.date} - </span> - </div> - <div className="flex gap-2 flex-wrap"> - <span className="font-medium text-neutral-400"> - {t("orderCart.Order_code")}:{" "} - </span> - <span className="font-bold text-neutral-800 dark:text-neutral-200"> - {order.orderCode} - </span> - </div> - <div className="flex gap-2 flex-wrap"> - <span className="font-medium text-neutral-400"> - {t("orderCart.Amount")}:{" "} - </span> - <span className="font-bold text-neutral-800 dark:text-neutral-200"> - {order.amount} - </span> - </div> - </div> - <div className=" max-sm:text-sm w-full flex gap-3.5"> - <div className="flex gap-2 flex-wrap"> - <span className="font-medium text-neutral-400"> - {t("orderCart.Order_status")}:{" "} - </span> - <span className="font-bold text-cyan-600 dark:text-neutral-600"> - {order.statusDetail} - </span> - </div> - </div> - <div className="w-full flex gap-2 flex-wrap"> - {order.images.map((src, idx) => ( - <Image key={idx} alt={`img-${idx}`} src={src} height={70} width={70} className="object-cover object-center" /> - ))} - </div> - <Separator /> - <div className="flex items-center justify-between w-full"> - <div className="flex justify-center items-center gap-2.5"> - <span className="font-medium max-sm:hidden text-neutral-400"> - {t("orderCart.Your_rating_for_this_order")}:{" "} - </span> - <span className="font-bold flex justify-center items-center gap-0.5 flex-row-reverse text-neutral-600 dark:text-neutral-300"> - {order.rating} <BoldIcon name="star" size={20} tinted className="text-amber-400" /> - </span> - </div> - <div className="flex cursor-pointer justify-center items-center gap-2.5"> - <span className=""> - <BoldIcon name="document-text" size={20} tinted className="text-cyan-800 dark:text-neutral-200" /> - </span> - <span className="font-bold flex justify-center items-center text-cyan-800 dark:text-neutral-200"> - {t("orderCart.View_invoice")} <BoldIcon name="arrow-right-2" size={20} tinted className="text-cyan-800 dark:text-neutral-200 rtl:rotate-180" /> - </span> - </div> - - </div> - </div> - ); -} +"use client"; +import { Separator } from "@/Components/ui/separator"; +import React from "react"; +import BoldIcon from "@/components/ui/BoldIcon"; +import { useTranslation } from "react-i18next"; +import Image from "next/image"; + + +interface OrderItem { + id: string; + date: string; + orderCode: string; + amount: string; + statusLabel: string; + statusDetail?: string; + images: string[]; + rating: number; + invoiceUrl?: string; + }; + +export default function OrdersCart({ order }: { order: OrderItem }) { + const { t } = useTranslation("common"); + + + return ( + <div className="w-full border-2 gap-y-3.5 rounded-2xl p-3.5 flex flex-col items-center"> + <div className="flex items-center justify-between w-full"> + <div className="flex justify-center items-center gap-2.5"> + <span className=""> + <BoldIcon name="task" size={28} tinted className="text-cyan-600 dark:text-neutral-600" /> + </span> + <span className="font-bold text-neutral-600 dark:text-neutral-300"> + {order.statusLabel} + </span> + </div> + <span className="ltr:rotate-180"> + <BoldIcon name="arrow-right-2" size={24} tinted className="text-neutral-600 dark:text-neutral-300" /> + </span> + </div> + <div className=" text-sm max-sm:text-xs w-full flex gap-3.5"> + <div className="flex gap-2 flex-wrap"> + <span className="font-medium text-neutral-400"> + {t("orderCart.date")}:{" "} + </span> + <span className="font-bold text-neutral-800 dark:text-neutral-200"> + {order.date} + </span> + </div> + <div className="flex gap-2 flex-wrap"> + <span className="font-medium text-neutral-400"> + {t("orderCart.Order_code")}:{" "} + </span> + <span className="font-bold text-neutral-800 dark:text-neutral-200"> + {order.orderCode} + </span> + </div> + <div className="flex gap-2 flex-wrap"> + <span className="font-medium text-neutral-400"> + {t("orderCart.Amount")}:{" "} + </span> + <span className="font-bold text-neutral-800 dark:text-neutral-200"> + {order.amount} + </span> + </div> + </div> + <div className=" max-sm:text-sm w-full flex gap-3.5"> + <div className="flex gap-2 flex-wrap"> + <span className="font-medium text-neutral-400"> + {t("orderCart.Order_status")}:{" "} + </span> + <span className="font-bold text-cyan-600 dark:text-neutral-600"> + {order.statusDetail} + </span> + </div> + </div> + <div className="w-full flex gap-2 flex-wrap"> + {order.images.map((src, idx) => ( + <Image key={idx} alt={`img-${idx}`} src={src} height={70} width={70} className="object-cover object-center" /> + ))} + </div> + <Separator /> + <div className="flex items-center justify-between w-full"> + <div className="flex justify-center items-center gap-2.5"> + <span className="font-medium max-sm:hidden text-neutral-400"> + {t("orderCart.Your_rating_for_this_order")}:{" "} + </span> + <span className="font-bold flex justify-center items-center gap-0.5 flex-row-reverse text-neutral-600 dark:text-neutral-300"> + {order.rating} <BoldIcon name="star" size={20} tinted className="text-amber-400" /> + </span> + </div> + <div className="flex cursor-pointer justify-center items-center gap-2.5"> + <span className=""> + <BoldIcon name="document-text" size={20} tinted className="text-cyan-800 dark:text-neutral-200" /> + </span> + <span className="font-bold flex justify-center items-center text-cyan-800 dark:text-neutral-200"> + {t("orderCart.View_invoice")} <BoldIcon name="arrow-right-2" size={20} tinted className="text-cyan-800 dark:text-neutral-200 rtl:rotate-180" /> + </span> + </div> + + </div> + </div> + ); +} diff --git a/src/app/settings/academy/my-courses/page.tsx b/src/app/settings/academy/my-courses/page.tsx index 952b897..06d2842 100644 --- a/src/app/settings/academy/my-courses/page.tsx +++ b/src/app/settings/academy/my-courses/page.tsx @@ -1,446 +1,446 @@ -"use client"; - -import { AcademyListSkeleton } from "@/components/academy/AcademySkeletons"; -import LocalePageShell from "@/components/i18n/LocalePageShell"; - -import useAxios from "@/hooks/useAxios"; -import React, { useEffect, useState } from "react"; -import MainModelCard from "@/components/academy/MainModelCard"; -import { Course } from "@/types/types"; -import Image from "next/image"; -import toast from "react-hot-toast"; -import { motion, AnimatePresence } from "framer-motion"; -import { useTranslation } from "react-i18next"; -import BackButton from "@/components/ui/BackButton"; - -interface PaginationData { - page: number; - limit: number; - total: number; - totalPages: number; - hasNextPage: boolean; - hasPrevPage: boolean; -} - -export default function PurchasedCoursesPage() { - const { request } = useAxios(); - const { t } = useTranslation("common"); - const [courses, setCourses] = useState<Course[]>([]); - const [isLoading, setIsLoading] = useState(true); - const [pagination, setPagination] = useState<PaginationData>({ - page: 1, - limit: 5, - total: 0, - totalPages: 1, - hasNextPage: false, - hasPrevPage: false, - }); - const [currentPage, setCurrentPage] = useState(1); - - const fetchCourses = async (page: number) => { - setIsLoading(true); - try { - const response = await request( - "GET", - `/academy/course/getUserPurchasedCoursesWithPopulate?page=${page}&limit=6` - ); - - console.log("Response:", response); - - if (response?.success && response?.data?.courses) { - setCourses(response.data.courses); - - if (response.data.pagination) { - setPagination({ - page: response.data.pagination.page, - limit: response.data.pagination.limit, - total: response.data.pagination.total, - totalPages: response.data.pagination.totalPages, - hasNextPage: response.data.pagination.hasNextPage, - hasPrevPage: response.data.pagination.hasPrevPage, - }); - } - } else { - setCourses([]); - toast.error(t("settings.academy.myCourses.fetchError")); - } - } catch (err) { - console.log("get error:", err); - toast.error(t("settings.academy.myCourses.fetchPurchasedError")); - setCourses([]); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - fetchCourses(currentPage); - }, [currentPage]); - - const handlePageChange = (newPage: number) => { - if (newPage >= 1 && newPage <= pagination.totalPages) { - setCurrentPage(newPage); - window.scrollTo({ top: 0, behavior: "smooth" }); - } - }; - - if (isLoading) { - return ( - <LocalePageShell> - <div className="min-h-dvh container mx-auto px-4 py-8 lg:px-6"> - <AcademyListSkeleton count={4} /> - </div> - </LocalePageShell> - ); - } - - return ( - <LocalePageShell> - <div className="min-h-dvh bg-white dark:bg-neutral-950"> - <div className="relative bg-gradient-to-r from-purple-400 via-purple-600 to-pink-600 dark:from-purple-600 dark:via-purple-800 dark:to-pink-800"> - <div className="absolute inset-0 bg-black/10"></div> - <div className="relative container mx-auto px-4 py-12 lg:px-6 md:py-16"> - <BackButton tinted className="absolute left-4 top-4 text-white sm:left-6 sm:top-6" /> - <motion.div - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.5 }} - className="text-center" - > - <h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-4"> - {t("settings.academy.myCourses.title")} - </h1> - <p className="text-lg text-white/90 max-w-2xl mx-auto"> - {t("settings.academy.myCourses.subtitle")} - </p> - <div className="inline-flex items-center gap-2 mt-6 px-4 py-2 bg-white/20 backdrop-blur-sm rounded-full"> - <svg - className="w-5 h-5 text-white" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" - /> - </svg> - <span className="text-white font-medium"> - {t("settings.academy.myCourses.purchasedCount", { - count: pagination.total, - })} - </span> - </div> - </motion.div> - </div> - <div className="absolute bottom-0 left-0 right-0"> - <svg - className="w-full h-12 text-white dark:text-neutral-950" - preserveAspectRatio="none" - viewBox="0 0 1440 54" - fill="currentColor" - > - <path d="M0 22L120 16.7C240 11 480 0 720 0C960 0 1200 11 1320 16.7L1440 22V54H1320C1200 54 960 54 720 54C480 54 240 54 120 54H0V22Z" /> - </svg> - </div> - </div> - - <div className="container mx-auto px-4 py-12 lg:px-6"> - {courses.length === 0 ? ( - <motion.div - initial={{ opacity: 0, scale: 0.9 }} - animate={{ opacity: 1, scale: 1 }} - transition={{ duration: 0.5 }} - className="text-center py-20" - > - <div className="relative w-48 h-48 mx-auto mb-8"> - <Image - src="/images/empty-courses.svg" - alt={t("settings.academy.myCourses.emptyAlt")} - fill - className="object-contain" - onError={(e) => { - (e.target as HTMLImageElement).src = "/images/empty-box.png"; - }} - /> - </div> - <h3 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-3"> - {t("settings.academy.myCourses.emptyTitle")} - </h3> - <p className="text-gray-600 dark:text-gray-400 mb-8"> - {t("settings.academy.myCourses.emptyDescription")} - </p> - <a - href="/academy" - className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-xl hover:shadow-lg transition-all duration-300 transform hover:scale-105" - > - <svg - className="w-5 h-5" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M12 6v6m0 0v6m0-6h6m-6 0H6" - /> - </svg> - {t("settings.academy.myCourses.browseCourses")} - </a> - </motion.div> - ) : ( - <> - <motion.div - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.5 }} - className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8" - > - <div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-md border border-gray-200 dark:border-gray-700"> - <div className="flex items-center justify-between"> - <div> - <p className="text-gray-500 dark:text-gray-400 text-sm"> - {t("settings.academy.myCourses.totalCourses")} - </p> - <p className="text-2xl font-bold text-gray-800 dark:text-gray-200"> - {pagination.total} - </p> - </div> - <div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center"> - <svg - className="w-6 h-6 text-blue-600 dark:text-blue-400" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" - /> - </svg> - </div> - </div> - </div> - - <div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-md border border-gray-200 dark:border-gray-700"> - <div className="flex items-center justify-between"> - <div> - <p className="text-gray-500 dark:text-gray-400 text-sm"> - {t("settings.academy.myCourses.currentPage")} - </p> - <p className="text-2xl font-bold text-gray-800 dark:text-gray-200"> - {pagination.page} / {pagination.totalPages} - </p> - </div> - <div className="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-lg flex items-center justify-center"> - <svg - className="w-6 h-6 text-purple-600 dark:text-purple-400" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - /> - </svg> - </div> - </div> - </div> - - <div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-md border border-gray-200 dark:border-gray-700"> - <div className="flex items-center justify-between"> - <div> - <p className="text-gray-500 dark:text-gray-400 text-sm"> - {t("settings.academy.myCourses.perPage")} - </p> - <p className="text-2xl font-bold text-gray-800 dark:text-gray-200"> - {pagination.limit} - </p> - </div> - <div className="w-12 h-12 bg-pink-100 dark:bg-pink-900 rounded-lg flex items-center justify-center"> - <svg - className="w-6 h-6 text-pink-600 dark:text-pink-400" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M4 6h16M4 12h16M4 18h16" - /> - </svg> - </div> - </div> - </div> - </motion.div> - - <AnimatePresence> - <motion.div - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - exit={{ opacity: 0 }} - className="space-y-6" - > - {courses.map((course, index) => ( - <motion.div - key={course._id} - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ delay: index * 0.1 }} - > - <MainModelCard postData={course} /> - </motion.div> - ))} - </motion.div> - </AnimatePresence> - - {pagination.totalPages > 1 && ( - <motion.div - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ delay: 0.3 }} - className="mt-12 flex justify-center" - > - <div className="flex items-center gap-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg p-2 border border-gray-200 dark:border-gray-700"> - <button - onClick={() => handlePageChange(currentPage - 1)} - disabled={!pagination.hasPrevPage} - className={` - px-4 py-2 rounded-lg transition-all duration-200 - ${ - pagination.hasPrevPage - ? "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300" - : "opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600" - } - `} - > - <svg - className="w-5 h-5" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M15 19l-7-7 7-7" - /> - </svg> - </button> - - <div className="flex items-center gap-1"> - {Array.from( - { length: pagination.totalPages }, - (_, i) => i + 1 - ).map((page) => { - if ( - page === 1 || - page === pagination.totalPages || - (page >= currentPage - 1 && page <= currentPage + 1) - ) { - return ( - <button - key={page} - onClick={() => handlePageChange(page)} - className={` - min-w-[40px] h-10 rounded-lg font-medium transition-all duration-200 - ${ - currentPage === page - ? "bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-md" - : "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300" - } - `} - > - {page} - </button> - ); - } - if ( - page === currentPage - 2 || - page === currentPage + 2 - ) { - return ( - <span - key={page} - className="w-10 h-10 flex items-center justify-center text-gray-400" - > - ... - </span> - ); - } - return null; - })} - </div> - - <button - onClick={() => handlePageChange(currentPage + 1)} - disabled={!pagination.hasNextPage} - className={` - px-4 py-2 rounded-lg transition-all duration-200 - ${ - pagination.hasNextPage - ? "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300" - : "opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600" - } - `} - > - <svg - className="w-5 h-5" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M9 5l7 7-7 7" - /> - </svg> - </button> - </div> - </motion.div> - )} - - {courses.length > 3 && ( - <button - onClick={() => - window.scrollTo({ top: 0, behavior: "smooth" }) - } - className="fixed bottom-8 right-8 bg-gradient-to-r from-blue-500 to-purple-600 text-white p-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-110 z-50" - aria-label={t("backToTop.aria")} - > - <svg - className="w-6 h-6" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M5 10l7-7m0 0l7 7m-7-7v18" - /> - </svg> - </button> - )} - </> - )} - </div> - </div> - </LocalePageShell> - ); -} +"use client"; + +import { AcademyListSkeleton } from "@/components/academy/AcademySkeletons"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; + +import useAxios from "@/hooks/useAxios"; +import React, { useEffect, useState } from "react"; +import MainModelCard from "@/components/academy/MainModelCard"; +import { Course } from "@/types/types"; +import Image from "next/image"; +import toast from "react-hot-toast"; +import { motion, AnimatePresence } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import BackButton from "@/components/ui/BackButton"; + +interface PaginationData { + page: number; + limit: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPrevPage: boolean; +} + +export default function PurchasedCoursesPage() { + const { request } = useAxios(); + const { t } = useTranslation("common"); + const [courses, setCourses] = useState<Course[]>([]); + const [isLoading, setIsLoading] = useState(true); + const [pagination, setPagination] = useState<PaginationData>({ + page: 1, + limit: 5, + total: 0, + totalPages: 1, + hasNextPage: false, + hasPrevPage: false, + }); + const [currentPage, setCurrentPage] = useState(1); + + const fetchCourses = async (page: number) => { + setIsLoading(true); + try { + const response = await request( + "GET", + `/academy/course/getUserPurchasedCoursesWithPopulate?page=${page}&limit=6` + ); + + console.log("Response:", response); + + if (response?.success && response?.data?.courses) { + setCourses(response.data.courses); + + if (response.data.pagination) { + setPagination({ + page: response.data.pagination.page, + limit: response.data.pagination.limit, + total: response.data.pagination.total, + totalPages: response.data.pagination.totalPages, + hasNextPage: response.data.pagination.hasNextPage, + hasPrevPage: response.data.pagination.hasPrevPage, + }); + } + } else { + setCourses([]); + toast.error(t("settings.academy.myCourses.fetchError")); + } + } catch (err) { + console.log("get error:", err); + toast.error(t("settings.academy.myCourses.fetchPurchasedError")); + setCourses([]); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchCourses(currentPage); + }, [currentPage]); + + const handlePageChange = (newPage: number) => { + if (newPage >= 1 && newPage <= pagination.totalPages) { + setCurrentPage(newPage); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + }; + + if (isLoading) { + return ( + <LocalePageShell> + <div className="min-h-dvh container mx-auto px-4 py-8 lg:px-6"> + <AcademyListSkeleton count={4} /> + </div> + </LocalePageShell> + ); + } + + return ( + <LocalePageShell> + <div className="min-h-dvh bg-white dark:bg-neutral-950"> + <div className="relative bg-gradient-to-r from-purple-400 via-purple-600 to-pink-600 dark:from-purple-600 dark:via-purple-800 dark:to-pink-800"> + <div className="absolute inset-0 bg-black/10"></div> + <div className="relative container mx-auto px-4 py-12 lg:px-6 md:py-16"> + <BackButton tinted className="absolute left-4 top-4 text-white sm:left-6 sm:top-6" /> + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.5 }} + className="text-center" + > + <h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-4"> + {t("settings.academy.myCourses.title")} + </h1> + <p className="text-lg text-white/90 max-w-2xl mx-auto"> + {t("settings.academy.myCourses.subtitle")} + </p> + <div className="inline-flex items-center gap-2 mt-6 px-4 py-2 bg-white/20 backdrop-blur-sm rounded-full"> + <svg + className="w-5 h-5 text-white" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" + /> + </svg> + <span className="text-white font-medium"> + {t("settings.academy.myCourses.purchasedCount", { + count: pagination.total, + })} + </span> + </div> + </motion.div> + </div> + <div className="absolute bottom-0 left-0 right-0"> + <svg + className="w-full h-12 text-white dark:text-neutral-950" + preserveAspectRatio="none" + viewBox="0 0 1440 54" + fill="currentColor" + > + <path d="M0 22L120 16.7C240 11 480 0 720 0C960 0 1200 11 1320 16.7L1440 22V54H1320C1200 54 960 54 720 54C480 54 240 54 120 54H0V22Z" /> + </svg> + </div> + </div> + + <div className="container mx-auto px-4 py-12 lg:px-6"> + {courses.length === 0 ? ( + <motion.div + initial={{ opacity: 0, scale: 0.9 }} + animate={{ opacity: 1, scale: 1 }} + transition={{ duration: 0.5 }} + className="text-center py-20" + > + <div className="relative w-48 h-48 mx-auto mb-8"> + <Image + src="/images/empty-courses.svg" + alt={t("settings.academy.myCourses.emptyAlt")} + fill + className="object-contain" + onError={(e) => { + (e.target as HTMLImageElement).src = "/images/empty-box.png"; + }} + /> + </div> + <h3 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-3"> + {t("settings.academy.myCourses.emptyTitle")} + </h3> + <p className="text-gray-600 dark:text-gray-400 mb-8"> + {t("settings.academy.myCourses.emptyDescription")} + </p> + <a + href="/academy" + className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-2xl hover:shadow-lg transition-all duration-300 transform hover:scale-105" + > + <svg + className="w-5 h-5" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M12 6v6m0 0v6m0-6h6m-6 0H6" + /> + </svg> + {t("settings.academy.myCourses.browseCourses")} + </a> + </motion.div> + ) : ( + <> + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.5 }} + className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8" + > + <div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-md border border-gray-200 dark:border-gray-700"> + <div className="flex items-center justify-between"> + <div> + <p className="text-gray-500 dark:text-gray-400 text-sm"> + {t("settings.academy.myCourses.totalCourses")} + </p> + <p className="text-2xl font-bold text-gray-800 dark:text-gray-200"> + {pagination.total} + </p> + </div> + <div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center"> + <svg + className="w-6 h-6 text-blue-600 dark:text-blue-400" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" + /> + </svg> + </div> + </div> + </div> + + <div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-md border border-gray-200 dark:border-gray-700"> + <div className="flex items-center justify-between"> + <div> + <p className="text-gray-500 dark:text-gray-400 text-sm"> + {t("settings.academy.myCourses.currentPage")} + </p> + <p className="text-2xl font-bold text-gray-800 dark:text-gray-200"> + {pagination.page} / {pagination.totalPages} + </p> + </div> + <div className="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-lg flex items-center justify-center"> + <svg + className="w-6 h-6 text-purple-600 dark:text-purple-400" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + /> + </svg> + </div> + </div> + </div> + + <div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-md border border-gray-200 dark:border-gray-700"> + <div className="flex items-center justify-between"> + <div> + <p className="text-gray-500 dark:text-gray-400 text-sm"> + {t("settings.academy.myCourses.perPage")} + </p> + <p className="text-2xl font-bold text-gray-800 dark:text-gray-200"> + {pagination.limit} + </p> + </div> + <div className="w-12 h-12 bg-pink-100 dark:bg-pink-900 rounded-lg flex items-center justify-center"> + <svg + className="w-6 h-6 text-pink-600 dark:text-pink-400" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M4 6h16M4 12h16M4 18h16" + /> + </svg> + </div> + </div> + </div> + </motion.div> + + <AnimatePresence> + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + className="space-y-6" + > + {courses.map((course, index) => ( + <motion.div + key={course._id} + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: index * 0.1 }} + > + <MainModelCard postData={course} /> + </motion.div> + ))} + </motion.div> + </AnimatePresence> + + {pagination.totalPages > 1 && ( + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.3 }} + className="mt-12 flex justify-center" + > + <div className="flex items-center gap-2 bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-2 border border-gray-200 dark:border-gray-700"> + <button + onClick={() => handlePageChange(currentPage - 1)} + disabled={!pagination.hasPrevPage} + className={` + px-4 py-2 rounded-lg transition-all duration-200 + ${ + pagination.hasPrevPage + ? "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300" + : "opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600" + } + `} + > + <svg + className="w-5 h-5" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M15 19l-7-7 7-7" + /> + </svg> + </button> + + <div className="flex items-center gap-1"> + {Array.from( + { length: pagination.totalPages }, + (_, i) => i + 1 + ).map((page) => { + if ( + page === 1 || + page === pagination.totalPages || + (page >= currentPage - 1 && page <= currentPage + 1) + ) { + return ( + <button + key={page} + onClick={() => handlePageChange(page)} + className={` + min-w-[40px] h-10 rounded-lg font-medium transition-all duration-200 + ${ + currentPage === page + ? "bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-md" + : "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300" + } + `} + > + {page} + </button> + ); + } + if ( + page === currentPage - 2 || + page === currentPage + 2 + ) { + return ( + <span + key={page} + className="w-10 h-10 flex items-center justify-center text-gray-400" + > + ... + </span> + ); + } + return null; + })} + </div> + + <button + onClick={() => handlePageChange(currentPage + 1)} + disabled={!pagination.hasNextPage} + className={` + px-4 py-2 rounded-lg transition-all duration-200 + ${ + pagination.hasNextPage + ? "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300" + : "opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600" + } + `} + > + <svg + className="w-5 h-5" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M9 5l7 7-7 7" + /> + </svg> + </button> + </div> + </motion.div> + )} + + {courses.length > 3 && ( + <button + onClick={() => + window.scrollTo({ top: 0, behavior: "smooth" }) + } + className="fixed bottom-8 right-8 bg-gradient-to-r from-blue-500 to-purple-600 text-white p-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-110 z-50" + aria-label={t("backToTop.aria")} + > + <svg + className="w-6 h-6" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M5 10l7-7m0 0l7 7m-7-7v18" + /> + </svg> + </button> + )} + </> + )} + </div> + </div> + </LocalePageShell> + ); +} diff --git a/src/app/settings/academy/storeProfile/page.tsx b/src/app/settings/academy/storeProfile/page.tsx index 18a3db0..2ad6dc1 100644 --- a/src/app/settings/academy/storeProfile/page.tsx +++ b/src/app/settings/academy/storeProfile/page.tsx @@ -1,511 +1,511 @@ -"use client"; - -import * as React from "react"; -import { useState, useEffect } from "react"; -import { useForm, useFieldArray } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import * as z from "zod"; -import { motion } from "framer-motion"; -import { toast } from "react-hot-toast"; -import { useTranslation } from "react-i18next"; -import BackButton from "@/components/ui/BackButton"; - -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/Textarea"; - -import { Separator } from "@/components/ui/separator"; -import { Badge } from "@/components/ui/badge"; -import "leaflet/dist/leaflet.css"; -import { useRouter } from "next/navigation"; -import useAxios from "@/hooks/useAxios"; -import { Academy } from "@/types/types"; -import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; -import { AcademyProfileHeadSkeleton } from "@/components/academy/AcademySkeletons"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - getIranBankName, - isValidIranSheba, - stripShebaPrefix, -} from "@/lib/iranSheba"; -import LocalePageShell from "@/components/i18n/LocalePageShell"; -import { useMemo } from "react"; -import { cn } from "@/lib/utils"; -import { btnPrimary } from "@/lib/ui/buttonStyles"; - -function buildStoreSchema(t: (key: string) => string) { - return z.object({ - name: z.string(), - sheba: z - .string() - .regex(/^\d{24}$/, t("settings.academy.storeProfile.shebaInvalid")), - bio: z.string().optional(), - profileImage: z.string().optional(), - tags: z.array(z.object({ value: z.string() })).optional(), - contact_mobile: z.string().optional(), - contact_telegram: z.string().optional(), - contact_whatsapp: z.string().optional(), - contact_instagram: z.string().optional(), - }); -} - -type StoreFormValues = z.infer<ReturnType<typeof buildStoreSchema>>; - - - -interface AcademyResponse { - academy: Academy & { - contactInfo?: { - mobile?: string; - phone?: string; - telegramLink?: string; - whatsappNumber?: string; - instagramLink?: string; - }; - }; -} - -// -type TagType = { value: string }; - -export default function StoreSettings() { - const { request, loading, error } = useAxios(); - const router = useRouter(); - const { t } = useTranslation("common"); - const storeSchema = useMemo(() => buildStoreSchema(t), [t]); - const tagPlaceholder = t("settings.academy.storeProfile.tagPlaceholder"); - const [previewProfile, setPreviewProfile] = useState<string>(""); - const [avatar, setAvatar] = useState<string | null>(null); - const [isLoading, setIsLoading] = useState(false); - const [isFetching, setIsFetching] = useState(true); // برای نمایش لودینگ اولیه - - const mobile = - typeof window !== "undefined" ? localStorage.getItem("mobile") : null; - - const form = useForm<StoreFormValues>({ - resolver: zodResolver(storeSchema), - defaultValues: { - name: "", - bio: "", - profileImage: "", - sheba: "", - tags: [], - contact_mobile: "", - contact_telegram: "", - contact_whatsapp: "", - contact_instagram: "", - }, - }); - - const shebaValue = form.watch("sheba"); - const shebaValid = isValidIranSheba(shebaValue || ""); - const bankName = shebaValid ? getIranBankName(shebaValue || "") : null; - - const parseTags = (jsonString: string): TagType[] => { - try { - const parsed = JSON.parse(jsonString); - if (Array.isArray(parsed)) { - const validTags = parsed.filter( - (item) => - item && typeof item === "object" && typeof item.value === "string" - ); - return validTags as TagType[]; - } - return []; - } catch (error) { - console.error("خطا در parse تگ‌ها:", error); - return []; - } - }; - - // دریافت دیتا از سرور - useEffect(() => { - const fetchData = async () => { - try { - setIsFetching(true); - const response = (await request("GET", `/academy`)) as AcademyResponse; - - if (response?.academy) { - const tagsArray = parseTags(response.academy.tag || ""); - const contact = response.academy.contactInfo; - - // تنظیم مقادیر فرم - form.reset({ - name: response.academy.academy_name || "", - bio: response.academy.bio || "", - profileImage: response.academy.academy_image || "", - sheba: stripShebaPrefix(response.academy.sheba || ""), - tags: tagsArray, - contact_mobile: contact?.mobile || contact?.phone || "", - contact_telegram: contact?.telegramLink || "", - contact_whatsapp: contact?.whatsappNumber || "", - contact_instagram: contact?.instagramLink || "", - }); - - // تنظیم پیش‌نمایش عکس - if (response.academy.academy_image) { - setPreviewProfile(response.academy.academy_image); - setAvatar(response.academy.academy_image); - } - } - } catch (error) { - console.error("خطا در دریافت اطلاعات:", error); - toast.error(t("settings.academy.storeProfile.fetchError")); - } finally { - setIsFetching(false); - } - }; - - fetchData(); - }, []); - - const { - fields: tagFields, - append: appendTag, - remove: removeTag, - } = useFieldArray({ - control: form.control, - name: "tags", - }); - - const selectImage = (event: React.ChangeEvent<HTMLInputElement>) => { - if (event.target.files && event.target.files[0]) { - const file = event.target.files[0]; - const previewUrl = URL.createObjectURL(file); - setPreviewProfile(previewUrl); - setAvatar(previewUrl); - } - }; - - const onSubmit = async (data: StoreFormValues) => { - setIsLoading(true); - - if (!data.name) { - toast.error(t("settings.academy.storeProfile.nameRequired")); - setIsLoading(false); - return; - } - if (!data.sheba || !isValidIranSheba(data.sheba)) { - toast.error(t("settings.academy.storeProfile.shebaRequired")); - setIsLoading(false); - return; - } - if (!data.bio) { - toast.error(t("settings.academy.storeProfile.bioRequired")); - setIsLoading(false); - return; - } - - const fileInput = document.getElementById("fileInput") as HTMLInputElement; - if (!fileInput?.files?.[0]) { - toast.error(t("settings.academy.storeProfile.imageRequired")); - setIsLoading(false); - return; - } - - const formData = new FormData(); - formData.append("profile_image", fileInput.files[0]); - formData.append("mobile", mobile as string); - formData.append("name", data.name); - formData.append("sheba", data.sheba); - formData.append("bio", data.bio); - formData.append("tag", JSON.stringify(data.tags || [])); - formData.append("contact_mobile", data.contact_mobile || ""); - formData.append("contact_telegram", data.contact_telegram || ""); - formData.append("contact_whatsapp", data.contact_whatsapp || ""); - formData.append("contact_instagram", data.contact_instagram || ""); - - try { - await request("POST", "/academy/academy/profile", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - toast.success(t("settings.academy.storeProfile.saved")); - router.push("/settings/academy/Dashboard"); - } catch (err) { - console.log("Upload error:", err); - toast.error(t("settings.academy.storeProfile.saveError")); - } finally { - setIsLoading(false); - } - }; - - // نمایش لودینگ در حین دریافت دیتا - if (isFetching) { - return ( - <div className="w-full max-w-4xl mx-auto mb-32 px-4 py-6 lg:px-6"> - <AcademyProfileHeadSkeleton /> - <div className="space-y-4 mt-6"> - <Skeleton className="h-10 w-full" /> - <Skeleton className="h-10 w-full" /> - <Skeleton className="h-32 w-full" /> - </div> - </div> - ); - } - - return ( - <LocalePageShell> - <motion.div - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.5 }} - className="w-full max-w-4xl mx-auto px-4 pt-6 lg:px-6 pb-[calc(6rem+env(safe-area-inset-bottom))]" - dir={t("dir") || "rtl"} - > - <BackButton className="mb-4" /> - <h2 className="text-3xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent mb-6"> - {t("settings.academy.storeProfile.title")} - </h2> - <Form {...form}> - <form - dir="rtl" - onSubmit={form.handleSubmit(onSubmit)} - className="space-y-8" - > - <div dir="rtl" className="flex flex-col border p-4"> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <FormField - control={form.control} - name="name" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("settings.academy.storeProfile.name")}</FormLabel> - <FormControl> - <Input placeholder={t("settings.academy.storeProfile.name")} {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="sheba" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("settings.academy.storeProfile.sheba")}</FormLabel> - <div className="flex flex-col gap-1.5"> - <div className="flex items-center gap-2"> - <FormControl> - <div className="relative flex-1"> - <span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm font-semibold text-muted-foreground"> - IR - </span> - <Input - placeholder={t("settings.academy.storeProfile.shebaPlaceholder")} - maxLength={24} - inputMode="numeric" - className={cn( - "pl-10 dir-ltr", - shebaValid && - "border-green-500 focus-visible:ring-green-500" - )} - {...field} - onChange={(e) => { - const digits = e.target.value - .replace(/\D/g, "") - .slice(0, 24); - field.onChange(digits); - }} - /> - </div> - </FormControl> - {bankName ? ( - <span className="shrink-0 text-xs font-semibold text-green-600 dark:text-green-400"> - {bankName} - </span> - ) : null} - </div> - </div> - <FormMessage /> - </FormItem> - )} - /> - </div> - <Separator className="mb-3" /> - <FormField - control={form.control} - name="bio" - render={({ field }) => ( - <FormItem> - <FormLabel className="mt-1.5">{t("settings.academy.storeProfile.bio")}</FormLabel> - <FormControl> - <Textarea - placeholder={t("settings.academy.storeProfile.bioPlaceholder")} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <Separator className="mb-3" /> - - <div className="space-y-3 rounded-xl border border-border-primary-light p-4"> - <FormLabel className="text-base">{t("settings.academy.storeProfile.contactOptional")}</FormLabel> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <FormField - control={form.control} - name="contact_mobile" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input placeholder={t("settings.billboards.mobile")} maxLength={11} {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="contact_telegram" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input placeholder={t("settings.billboards.telegram")} {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="contact_whatsapp" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input placeholder={t("settings.billboards.whatsapp")} maxLength={11} {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="contact_instagram" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input placeholder={t("settings.billboards.instagram")} {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </div> - - <Separator className="mb-3" /> - <FormItem> - <FormLabel className="mt-1.5">{t("settings.academy.storeProfile.profileImage")}</FormLabel> - <FormControl> - <Input - id="fileInput" - type="file" - accept="image/*" - onChange={selectImage} - /> - </FormControl> - {previewProfile && ( - <motion.img - src={IMAGE_BASE_URL + previewProfile} - alt="Profile Preview" - className="w-24 h-24 rounded-xl object-cover mt-2" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - /> - )} - <FormMessage /> - </FormItem> - <Separator className="mb-3" /> - <FormItem> - <FormLabel className="mt-1.5">{t("settings.academy.storeProfile.storeTags")}</FormLabel> - <div className="flex flex-wrap gap-2 mb-2"> - {tagFields.map((tag, index) => ( - <Badge key={tag.id}> - {tag.value} - <Button type="button" onClick={() => removeTag(index)}> - X - </Button> - </Badge> - ))} - </div> - <div className="flex gap-2 mb-2"> - <Input - placeholder={tagPlaceholder} - onKeyDown={(e) => { - if (e.key === "Enter" && e.currentTarget.value) { - appendTag({ value: e.currentTarget.value }); - e.currentTarget.value = ""; - } - }} - /> - <Button - type="button" - onClick={() => { - const input = document.querySelector( - `input[placeholder="${tagPlaceholder}"]` - ) as HTMLInputElement; - if (input.value) { - appendTag({ value: input.value }); - input.value = ""; - } - }} - > - {t("settings.academy.storeProfile.submit")} - </Button> - </div> - <FormMessage /> - </FormItem> - <Separator className="mb-3" /> - - <div className="flex w-full justify-center pt-4"> - <Button - type="submit" - disabled={isLoading} - className={cn(btnPrimary, "h-10 w-11/12")} - > - {isLoading ? ( - <> - <svg - className="animate-spin h-5 w-5 ml-2" - viewBox="0 0 24 24" - > - <circle - className="opacity-25" - cx="12" - cy="12" - r="10" - stroke="currentColor" - strokeWidth="4" - fill="none" - /> - <path - className="opacity-75" - fill="currentColor" - d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" - /> - </svg> - {t("settings.academy.storeProfile.submitting")} - </> - ) : ( - t("settings.academy.storeProfile.submit") - )} - </Button> - </div> - </div> - </form> - </Form> - </motion.div> - </LocalePageShell> - ); -} +"use client"; + +import * as React from "react"; +import { useState, useEffect } from "react"; +import { useForm, useFieldArray } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { motion } from "framer-motion"; +import { toast } from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import BackButton from "@/components/ui/BackButton"; + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/Textarea"; + +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import "leaflet/dist/leaflet.css"; +import { useRouter } from "next/navigation"; +import useAxios from "@/hooks/useAxios"; +import { Academy } from "@/types/types"; +import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; +import { AcademyProfileHeadSkeleton } from "@/components/academy/AcademySkeletons"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + getIranBankName, + isValidIranSheba, + stripShebaPrefix, +} from "@/lib/iranSheba"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { btnPrimary } from "@/lib/ui/buttonStyles"; + +function buildStoreSchema(t: (key: string) => string) { + return z.object({ + name: z.string(), + sheba: z + .string() + .regex(/^\d{24}$/, t("settings.academy.storeProfile.shebaInvalid")), + bio: z.string().optional(), + profileImage: z.string().optional(), + tags: z.array(z.object({ value: z.string() })).optional(), + contact_mobile: z.string().optional(), + contact_telegram: z.string().optional(), + contact_whatsapp: z.string().optional(), + contact_instagram: z.string().optional(), + }); +} + +type StoreFormValues = z.infer<ReturnType<typeof buildStoreSchema>>; + + + +interface AcademyResponse { + academy: Academy & { + contactInfo?: { + mobile?: string; + phone?: string; + telegramLink?: string; + whatsappNumber?: string; + instagramLink?: string; + }; + }; +} + +// +type TagType = { value: string }; + +export default function StoreSettings() { + const { request, loading, error } = useAxios(); + const router = useRouter(); + const { t } = useTranslation("common"); + const storeSchema = useMemo(() => buildStoreSchema(t), [t]); + const tagPlaceholder = t("settings.academy.storeProfile.tagPlaceholder"); + const [previewProfile, setPreviewProfile] = useState<string>(""); + const [avatar, setAvatar] = useState<string | null>(null); + const [isLoading, setIsLoading] = useState(false); + const [isFetching, setIsFetching] = useState(true); // برای نمایش لودینگ اولیه + + const mobile = + typeof window !== "undefined" ? localStorage.getItem("mobile") : null; + + const form = useForm<StoreFormValues>({ + resolver: zodResolver(storeSchema), + defaultValues: { + name: "", + bio: "", + profileImage: "", + sheba: "", + tags: [], + contact_mobile: "", + contact_telegram: "", + contact_whatsapp: "", + contact_instagram: "", + }, + }); + + const shebaValue = form.watch("sheba"); + const shebaValid = isValidIranSheba(shebaValue || ""); + const bankName = shebaValid ? getIranBankName(shebaValue || "") : null; + + const parseTags = (jsonString: string): TagType[] => { + try { + const parsed = JSON.parse(jsonString); + if (Array.isArray(parsed)) { + const validTags = parsed.filter( + (item) => + item && typeof item === "object" && typeof item.value === "string" + ); + return validTags as TagType[]; + } + return []; + } catch (error) { + console.error("خطا در parse تگ‌ها:", error); + return []; + } + }; + + // دریافت دیتا از سرور + useEffect(() => { + const fetchData = async () => { + try { + setIsFetching(true); + const response = (await request("GET", `/academy`)) as AcademyResponse; + + if (response?.academy) { + const tagsArray = parseTags(response.academy.tag || ""); + const contact = response.academy.contactInfo; + + // تنظیم مقادیر فرم + form.reset({ + name: response.academy.academy_name || "", + bio: response.academy.bio || "", + profileImage: response.academy.academy_image || "", + sheba: stripShebaPrefix(response.academy.sheba || ""), + tags: tagsArray, + contact_mobile: contact?.mobile || contact?.phone || "", + contact_telegram: contact?.telegramLink || "", + contact_whatsapp: contact?.whatsappNumber || "", + contact_instagram: contact?.instagramLink || "", + }); + + // تنظیم پیش‌نمایش عکس + if (response.academy.academy_image) { + setPreviewProfile(response.academy.academy_image); + setAvatar(response.academy.academy_image); + } + } + } catch (error) { + console.error("خطا در دریافت اطلاعات:", error); + toast.error(t("settings.academy.storeProfile.fetchError")); + } finally { + setIsFetching(false); + } + }; + + fetchData(); + }, []); + + const { + fields: tagFields, + append: appendTag, + remove: removeTag, + } = useFieldArray({ + control: form.control, + name: "tags", + }); + + const selectImage = (event: React.ChangeEvent<HTMLInputElement>) => { + if (event.target.files && event.target.files[0]) { + const file = event.target.files[0]; + const previewUrl = URL.createObjectURL(file); + setPreviewProfile(previewUrl); + setAvatar(previewUrl); + } + }; + + const onSubmit = async (data: StoreFormValues) => { + setIsLoading(true); + + if (!data.name) { + toast.error(t("settings.academy.storeProfile.nameRequired")); + setIsLoading(false); + return; + } + if (!data.sheba || !isValidIranSheba(data.sheba)) { + toast.error(t("settings.academy.storeProfile.shebaRequired")); + setIsLoading(false); + return; + } + if (!data.bio) { + toast.error(t("settings.academy.storeProfile.bioRequired")); + setIsLoading(false); + return; + } + + const fileInput = document.getElementById("fileInput") as HTMLInputElement; + if (!fileInput?.files?.[0]) { + toast.error(t("settings.academy.storeProfile.imageRequired")); + setIsLoading(false); + return; + } + + const formData = new FormData(); + formData.append("profile_image", fileInput.files[0]); + formData.append("mobile", mobile as string); + formData.append("name", data.name); + formData.append("sheba", data.sheba); + formData.append("bio", data.bio); + formData.append("tag", JSON.stringify(data.tags || [])); + formData.append("contact_mobile", data.contact_mobile || ""); + formData.append("contact_telegram", data.contact_telegram || ""); + formData.append("contact_whatsapp", data.contact_whatsapp || ""); + formData.append("contact_instagram", data.contact_instagram || ""); + + try { + await request("POST", "/academy/academy/profile", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + toast.success(t("settings.academy.storeProfile.saved")); + router.push("/settings/academy/Dashboard"); + } catch (err) { + console.log("Upload error:", err); + toast.error(t("settings.academy.storeProfile.saveError")); + } finally { + setIsLoading(false); + } + }; + + // نمایش لودینگ در حین دریافت دیتا + if (isFetching) { + return ( + <div className="w-full max-w-4xl mx-auto mb-32 px-4 py-6 lg:px-6"> + <AcademyProfileHeadSkeleton /> + <div className="space-y-4 mt-6"> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-32 w-full" /> + </div> + </div> + ); + } + + return ( + <LocalePageShell> + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.5 }} + className="w-full max-w-4xl mx-auto px-4 pt-6 lg:px-6 pb-[calc(6rem+env(safe-area-inset-bottom))]" + dir={t("dir") || "rtl"} + > + <BackButton className="mb-4" /> + <h2 className="text-3xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent mb-6"> + {t("settings.academy.storeProfile.title")} + </h2> + <Form {...form}> + <form + dir="rtl" + onSubmit={form.handleSubmit(onSubmit)} + className="space-y-8" + > + <div dir="rtl" className="flex flex-col border p-4"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("settings.academy.storeProfile.name")}</FormLabel> + <FormControl> + <Input placeholder={t("settings.academy.storeProfile.name")} {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="sheba" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("settings.academy.storeProfile.sheba")}</FormLabel> + <div className="flex flex-col gap-1.5"> + <div className="flex items-center gap-2"> + <FormControl> + <div className="relative flex-1"> + <span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm font-semibold text-muted-foreground"> + IR + </span> + <Input + placeholder={t("settings.academy.storeProfile.shebaPlaceholder")} + maxLength={24} + inputMode="numeric" + className={cn( + "pl-10 dir-ltr", + shebaValid && + "border-green-500 focus-visible:ring-green-500" + )} + {...field} + onChange={(e) => { + const digits = e.target.value + .replace(/\D/g, "") + .slice(0, 24); + field.onChange(digits); + }} + /> + </div> + </FormControl> + {bankName ? ( + <span className="shrink-0 text-xs font-semibold text-green-600 dark:text-green-400"> + {bankName} + </span> + ) : null} + </div> + </div> + <FormMessage /> + </FormItem> + )} + /> + </div> + <Separator className="mb-3" /> + <FormField + control={form.control} + name="bio" + render={({ field }) => ( + <FormItem> + <FormLabel className="mt-1.5">{t("settings.academy.storeProfile.bio")}</FormLabel> + <FormControl> + <Textarea + placeholder={t("settings.academy.storeProfile.bioPlaceholder")} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <Separator className="mb-3" /> + + <div className="space-y-3 rounded-2xl border border-border-primary-light p-4"> + <FormLabel className="text-base">{t("settings.academy.storeProfile.contactOptional")}</FormLabel> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <FormField + control={form.control} + name="contact_mobile" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input placeholder={t("settings.billboards.mobile")} maxLength={11} {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="contact_telegram" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input placeholder={t("settings.billboards.telegram")} {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="contact_whatsapp" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input placeholder={t("settings.billboards.whatsapp")} maxLength={11} {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="contact_instagram" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input placeholder={t("settings.billboards.instagram")} {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + <Separator className="mb-3" /> + <FormItem> + <FormLabel className="mt-1.5">{t("settings.academy.storeProfile.profileImage")}</FormLabel> + <FormControl> + <Input + id="fileInput" + type="file" + accept="image/*" + onChange={selectImage} + /> + </FormControl> + {previewProfile && ( + <motion.img + src={IMAGE_BASE_URL + previewProfile} + alt="Profile Preview" + className="w-24 h-24 rounded-2xl object-cover mt-2" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + /> + )} + <FormMessage /> + </FormItem> + <Separator className="mb-3" /> + <FormItem> + <FormLabel className="mt-1.5">{t("settings.academy.storeProfile.storeTags")}</FormLabel> + <div className="flex flex-wrap gap-2 mb-2"> + {tagFields.map((tag, index) => ( + <Badge key={tag.id}> + {tag.value} + <Button type="button" onClick={() => removeTag(index)}> + X + </Button> + </Badge> + ))} + </div> + <div className="flex gap-2 mb-2"> + <Input + placeholder={tagPlaceholder} + onKeyDown={(e) => { + if (e.key === "Enter" && e.currentTarget.value) { + appendTag({ value: e.currentTarget.value }); + e.currentTarget.value = ""; + } + }} + /> + <Button + type="button" + onClick={() => { + const input = document.querySelector( + `input[placeholder="${tagPlaceholder}"]` + ) as HTMLInputElement; + if (input.value) { + appendTag({ value: input.value }); + input.value = ""; + } + }} + > + {t("settings.academy.storeProfile.submit")} + </Button> + </div> + <FormMessage /> + </FormItem> + <Separator className="mb-3" /> + + <div className="flex w-full justify-center pt-4"> + <Button + type="submit" + disabled={isLoading} + className={cn(btnPrimary, "h-10 w-11/12")} + > + {isLoading ? ( + <> + <svg + className="animate-spin h-5 w-5 ml-2" + viewBox="0 0 24 24" + > + <circle + className="opacity-25" + cx="12" + cy="12" + r="10" + stroke="currentColor" + strokeWidth="4" + fill="none" + /> + <path + className="opacity-75" + fill="currentColor" + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" + /> + </svg> + {t("settings.academy.storeProfile.submitting")} + </> + ) : ( + t("settings.academy.storeProfile.submit") + )} + </Button> + </div> + </div> + </form> + </Form> + </motion.div> + </LocalePageShell> + ); +} diff --git a/src/app/settings/academy/wallet/page.tsx b/src/app/settings/academy/wallet/page.tsx index bac0339..b1c3b9d 100644 --- a/src/app/settings/academy/wallet/page.tsx +++ b/src/app/settings/academy/wallet/page.tsx @@ -1,428 +1,428 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { ThemeProvider, useTheme } from "next-themes"; -import { Skeleton } from "@/components/ui/skeleton"; - -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; - -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Badge } from "@/components/ui/badge"; - -import { motion } from "framer-motion"; - -import toast from "react-hot-toast"; -import { useTranslation } from "react-i18next"; -import useAxios from "@/hooks/useAxios"; -import LocalePageShell from "@/components/i18n/LocalePageShell"; -import { useAppLanguage } from "@/contexts/LanguageProvider"; -import EditPageHeader from "@/components/settings/EditPageHeader"; - -interface Payment { - _id: string; - price: number; - course_name: string; - course_id: any; - academy_id: string; - status: string; - createdAt: string; - updatedAt: string; - payment_authority: string; - payment_ref_id: string; - discountAmount: number; - taxAmount: number; - taxRate: number; - user_id: { - _id: string; - user_name: string; - profile_image: string; - }; -} - -interface Transaction { - id: string; - type: "deposit" | "withdraw" | "expense"; - currency: string; - amount: number; - date: string; - description: string; - status: string; - refId: string; -} - -const WalletDashboard = () => { - const [transactions, setTransactions] = useState<Transaction[]>([]); - const [filterType, setFilterType] = useState<"all" | "deposit" | "withdraw" | "expense">("all"); - const [totalBalance, setTotalBalance] = useState(0); - const [totalSettled, setTotalSettled] = useState(0); - const [totalPending, setTotalPending] = useState(0); - const [isLoading, setIsLoading] = useState(false); - - const { request } = useAxios(); - const { t } = useTranslation("common"); - const { language } = useAppLanguage(); - const dateLocale = language === "fa" ? "fa-IR" : "en-US"; - - // دریافت پرداخت‌های موفق (فروش‌های انجام شده) - const fetchSuccessfulPayments = async () => { - try { - const response = await request( - "GET", - `/academy/course/getAllAcademyPayments?status=success` - ); - - if (response?.data?.payments && Array.isArray(response.data.payments)) { - const payments: Payment[] = response.data.payments; - - // محاسبه مجموع فروش - const total = payments.reduce((sum, p) => sum + p.taxAmount, 0); - setTotalPending(total); - - // تبدیل به تراکنش‌های فروش ( expense type ) - const salesTransactions: Transaction[] = payments.map((payment) => ({ - id: payment._id, - type: "expense", - currency: t("settings.toman"), - amount: payment.taxAmount, - date: new Date(payment.createdAt).toLocaleDateString(dateLocale), - description: payment.course_name, - status: "pending", - refId: payment.payment_ref_id, - })); - - return salesTransactions; - } - return []; - } catch (err) { - console.error("error fetching successful payments:", err); - toast.error(t("settings.academy.wallet.fetchSalesError")); - return []; - } - }; - - // دریافت پرداخت‌های تسویه شده - const fetchSettledPayments = async () => { - try { - const response = await request( - "GET", - `/academy/course/getAllAcademyPayments?status=settled` - ); - - if (response?.data?.payments && Array.isArray(response.data.payments)) { - const payments: Payment[] = response.data.payments; - - // محاسبه مجموع تسویه شده - const total = payments.reduce((sum, p) => sum + p.taxAmount, 0); - setTotalSettled(total); - - // تبدیل به تراکنش‌های پرداخت شده ( withdraw type ) - const settledTransactions: Transaction[] = payments.map((payment) => ({ - id: payment._id, - type: "withdraw", - currency: t("settings.toman"), - amount: payment.taxAmount, - date: new Date(payment.updatedAt).toLocaleDateString(dateLocale), - description: t("settings.academy.wallet.settlementDescription", { - course: payment.course_name, - }), - status: "settled", - refId: payment.payment_ref_id, - })); - - return settledTransactions; - } - return []; - } catch (err) { - console.error("error fetching settled payments:", err); - toast.error(t("settings.academy.wallet.fetchSettledError")); - return []; - } - }; - - // محاسبه موجودی کل (فروش - تسویه شده) - const calculateBalance = (sales: number, settled: number) => { - return sales - settled; - }; - - // بارگذاری تمام داده‌ها - const fetchAllData = async () => { - setIsLoading(true); - try { - const [salesTransactions, settledTransactions] = await Promise.all([ - fetchSuccessfulPayments(), - fetchSettledPayments(), - ]); - - // ترکیب تراکنش‌ها - const allTransactions = [...salesTransactions, ...settledTransactions]; - - // مرتب‌سازی بر اساس تاریخ (جدیدترین اول) - allTransactions.sort((a, b) => { - const dateA = new Date(a.date.split("/").reverse().join("/")); - const dateB = new Date(b.date.split("/").reverse().join("/")); - return dateB.getTime() - dateA.getTime(); - }); - - setTransactions(allTransactions); - - // محاسبه موجودی - const balance = calculateBalance(totalPending, totalSettled); - setTotalBalance(balance); - - } catch (err) { - console.error("error fetching all data:", err); - toast.error(t("settings.academy.wallet.fetchError")); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - fetchAllData(); - }, []); - - // فیلتر تراکنش‌ها - const filteredTransactions = transactions.filter( - (t) => filterType === "all" || t.type === filterType - ); - - // آمار کلی - const stats = { - sales: transactions.filter(t => t.type === "expense").reduce((sum, t) => sum + t.amount, 0), - settled: transactions.filter(t => t.type === "withdraw").reduce((sum, t) => sum + t.amount, 0), - pending: transactions.filter(t => t.type === "expense" && t.status === "pending").reduce((sum, t) => sum + t.amount, 0), - }; - - if (isLoading) { - return ( - <div className="container mx-auto space-y-4 px-4 py-6 lg:px-6"> - <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> - <Skeleton className="h-28 w-full rounded-xl" /> - <Skeleton className="h-28 w-full rounded-xl" /> - <Skeleton className="h-28 w-full rounded-xl" /> - </div> - <Skeleton className="h-64 w-full rounded-xl" /> - </div> - ); - } - - return ( - <LocalePageShell> - <ThemeProvider attribute="class" defaultTheme="system" enableSystem> - <div className="min-h-dvh text-foreground"> - <div className="container mx-auto space-y-8 px-4 py-6 lg:px-6"> - <EditPageHeader title={t("settings.nav.wallet")} className="my-0" /> - {/* کارت‌های آمار - 3 کارت */} - <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> - - - {/* کل فروش */} - <motion.div - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.5, delay: 0.1 }} - > - <Card className="text-white"> - <CardHeader> - <CardTitle className="text-lg">{t("settings.academy.wallet.totalSales")}</CardTitle> - <CardDescription className="dark:text-green-100 text-green-500"> - {t("settings.academy.wallet.totalSalesDesc")} - </CardDescription> - </CardHeader> - <CardContent > - <div className="text-3xl font-bold dark:text-white text-neutral-700"> - {(stats.sales + stats.settled).toLocaleString(dateLocale)} {t("settings.toman")} - </div> - </CardContent> - </Card> - </motion.div> - - {/* تسویه شده */} - <motion.div - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.5, delay: 0.2 }} - > - <Card className="text-white"> - <CardHeader> - <CardTitle className="text-lg">{t("settings.academy.wallet.settled")}</CardTitle> - <CardDescription className="dark:text-purple-100 text-purple-500"> - {t("settings.academy.wallet.settledDesc")} - </CardDescription> - </CardHeader> - <CardContent> - <div className="text-3xl font-bold dark:text-white text-neutral-700"> - {stats.settled.toLocaleString(dateLocale)} {t("settings.toman")} - </div> - </CardContent> - </Card> - </motion.div> - </div> - - {/* جداول تراکنش‌ها */} - <Card> - <CardHeader className="flex flex-row items-center justify-between flex-wrap gap-4"> - <CardTitle>{t("settings.academy.wallet.transactionsReport")}</CardTitle> - <div className="flex gap-2"> - <Select - value={filterType} - onValueChange={(value) => - setFilterType(value as "all" | "deposit" | "withdraw" | "expense") - } - > - <SelectTrigger className="w-[180px]"> - <SelectValue placeholder={t("settings.academy.wallet.filterByType")} /> - </SelectTrigger> - <SelectContent> - <SelectItem value="all">{t("settings.academy.wallet.all")}</SelectItem> - <SelectItem value="expense">{t("settings.academy.wallet.sales")}</SelectItem> - <SelectItem value="withdraw">{t("settings.academy.wallet.paidOut")}</SelectItem> - </SelectContent> - </Select> - </div> - </CardHeader> - <CardContent> - {filteredTransactions.length === 0 ? ( - <div className="text-center py-8 text-muted-foreground"> - {t("settings.academy.wallet.noTransactions")} - </div> - ) : ( - <div className="w-full"> - {/* نمای دسکتاپ - جدول */} - <div className="hidden md:block overflow-x-auto rounded-md border"> - <Table> - <TableHeader> - <TableRow className="bg-muted/50"> - <TableHead className="text-right">{t("settings.academy.wallet.description")}</TableHead> - <TableHead className="text-right">{t("settings.academy.wallet.amount")}</TableHead> - <TableHead className="text-right">{t("settings.academy.wallet.date")}</TableHead> - <TableHead className="text-right">{t("settings.status")}</TableHead> - - </TableRow> - </TableHeader> - <TableBody> - {filteredTransactions.length === 0 ? ( - <TableRow> - <TableCell colSpan={5} className="text-center py-8"> - {t("settings.academy.wallet.noTransactions")} - </TableCell> - </TableRow> - ) : ( - filteredTransactions.map((transaction) => ( - <TableRow key={transaction.id} className="border-b"> - <TableCell className="font-medium text-right"> - {transaction.description} - </TableCell> - <TableCell className="text-right"> - <span - className={`font-bold ${ - transaction.type === "expense" - ? "text-green-600" - : "text-red-600" - }`} - > - {transaction.type === "expense" ? "+" : "-"} - {transaction.amount.toLocaleString()} - </span> - </TableCell> - <TableCell className="text-right">{transaction.date}</TableCell> - <TableCell className="text-right"> - <Badge - variant="outline" - className={`${ - transaction.type === "expense" - ? "bg-yellow-500/10 text-yellow-600 border-yellow-500/30" - : "bg-green-500/10 text-green-600 border-green-500/30" - }`} - > - {transaction.type === "expense" - ? t("settings.academy.wallet.pendingSettlement") - : t("settings.academy.wallet.settlementDone")} - </Badge> - </TableCell> - - </TableRow> - )) - )} - </TableBody> - </Table> - </div> - - {/* نمای موبایل - کارت‌ها */} - <div className="md:hidden space-y-3"> - {filteredTransactions.length === 0 ? ( - <div className="text-center py-8 text-muted-foreground"> - {t("settings.academy.wallet.noTransactions")} - </div> - ) : ( - filteredTransactions.map((transaction) => ( - <Card key={transaction.id} className="p-4"> - <div className="flex justify-between items-start mb-2"> - <div className="flex-1"> - <p className="font-medium text-sm">{transaction.description}</p> - <p className="text-xs text-muted-foreground mt-1"> - {transaction.date} - </p> - </div> - <div className="text-right"> - <p - className={`font-bold text-lg ${ - transaction.type === "expense" - ? "text-green-600" - : "text-red-600" - }`} - > - {transaction.type === "expense" ? "+" : "-"} - {transaction.amount.toLocaleString()} - </p> - <Badge - variant="outline" - className={`mt-1 text-xs ${ - transaction.type === "expense" - ? "bg-yellow-500/10 text-yellow-600" - : "bg-green-500/10 text-green-600" - }`} - > - {transaction.type === "expense" - ? t("settings.academy.wallet.pendingSettlement") - : t("settings.academy.wallet.settlementDone")} - </Badge> - </div> - </div> - - </Card> - )) - )} - </div> - </div> - )} - </CardContent> - </Card> - </div> - </div> - </ThemeProvider> - </LocalePageShell> - ); -}; - +"use client"; + +import React, { useState, useEffect } from "react"; +import { ThemeProvider, useTheme } from "next-themes"; +import { Skeleton } from "@/components/ui/skeleton"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; + +import { motion } from "framer-motion"; + +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import useAxios from "@/hooks/useAxios"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import { useAppLanguage } from "@/contexts/LanguageProvider"; +import EditPageHeader from "@/components/settings/EditPageHeader"; + +interface Payment { + _id: string; + price: number; + course_name: string; + course_id: any; + academy_id: string; + status: string; + createdAt: string; + updatedAt: string; + payment_authority: string; + payment_ref_id: string; + discountAmount: number; + taxAmount: number; + taxRate: number; + user_id: { + _id: string; + user_name: string; + profile_image: string; + }; +} + +interface Transaction { + id: string; + type: "deposit" | "withdraw" | "expense"; + currency: string; + amount: number; + date: string; + description: string; + status: string; + refId: string; +} + +const WalletDashboard = () => { + const [transactions, setTransactions] = useState<Transaction[]>([]); + const [filterType, setFilterType] = useState<"all" | "deposit" | "withdraw" | "expense">("all"); + const [totalBalance, setTotalBalance] = useState(0); + const [totalSettled, setTotalSettled] = useState(0); + const [totalPending, setTotalPending] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + const { request } = useAxios(); + const { t } = useTranslation("common"); + const { language } = useAppLanguage(); + const dateLocale = language === "fa" ? "fa-IR" : "en-US"; + + // دریافت پرداخت‌های موفق (فروش‌های انجام شده) + const fetchSuccessfulPayments = async () => { + try { + const response = await request( + "GET", + `/academy/course/getAllAcademyPayments?status=success` + ); + + if (response?.data?.payments && Array.isArray(response.data.payments)) { + const payments: Payment[] = response.data.payments; + + // محاسبه مجموع فروش + const total = payments.reduce((sum, p) => sum + p.taxAmount, 0); + setTotalPending(total); + + // تبدیل به تراکنش‌های فروش ( expense type ) + const salesTransactions: Transaction[] = payments.map((payment) => ({ + id: payment._id, + type: "expense", + currency: t("settings.toman"), + amount: payment.taxAmount, + date: new Date(payment.createdAt).toLocaleDateString(dateLocale), + description: payment.course_name, + status: "pending", + refId: payment.payment_ref_id, + })); + + return salesTransactions; + } + return []; + } catch (err) { + console.error("error fetching successful payments:", err); + toast.error(t("settings.academy.wallet.fetchSalesError")); + return []; + } + }; + + // دریافت پرداخت‌های تسویه شده + const fetchSettledPayments = async () => { + try { + const response = await request( + "GET", + `/academy/course/getAllAcademyPayments?status=settled` + ); + + if (response?.data?.payments && Array.isArray(response.data.payments)) { + const payments: Payment[] = response.data.payments; + + // محاسبه مجموع تسویه شده + const total = payments.reduce((sum, p) => sum + p.taxAmount, 0); + setTotalSettled(total); + + // تبدیل به تراکنش‌های پرداخت شده ( withdraw type ) + const settledTransactions: Transaction[] = payments.map((payment) => ({ + id: payment._id, + type: "withdraw", + currency: t("settings.toman"), + amount: payment.taxAmount, + date: new Date(payment.updatedAt).toLocaleDateString(dateLocale), + description: t("settings.academy.wallet.settlementDescription", { + course: payment.course_name, + }), + status: "settled", + refId: payment.payment_ref_id, + })); + + return settledTransactions; + } + return []; + } catch (err) { + console.error("error fetching settled payments:", err); + toast.error(t("settings.academy.wallet.fetchSettledError")); + return []; + } + }; + + // محاسبه موجودی کل (فروش - تسویه شده) + const calculateBalance = (sales: number, settled: number) => { + return sales - settled; + }; + + // بارگذاری تمام داده‌ها + const fetchAllData = async () => { + setIsLoading(true); + try { + const [salesTransactions, settledTransactions] = await Promise.all([ + fetchSuccessfulPayments(), + fetchSettledPayments(), + ]); + + // ترکیب تراکنش‌ها + const allTransactions = [...salesTransactions, ...settledTransactions]; + + // مرتب‌سازی بر اساس تاریخ (جدیدترین اول) + allTransactions.sort((a, b) => { + const dateA = new Date(a.date.split("/").reverse().join("/")); + const dateB = new Date(b.date.split("/").reverse().join("/")); + return dateB.getTime() - dateA.getTime(); + }); + + setTransactions(allTransactions); + + // محاسبه موجودی + const balance = calculateBalance(totalPending, totalSettled); + setTotalBalance(balance); + + } catch (err) { + console.error("error fetching all data:", err); + toast.error(t("settings.academy.wallet.fetchError")); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchAllData(); + }, []); + + // فیلتر تراکنش‌ها + const filteredTransactions = transactions.filter( + (t) => filterType === "all" || t.type === filterType + ); + + // آمار کلی + const stats = { + sales: transactions.filter(t => t.type === "expense").reduce((sum, t) => sum + t.amount, 0), + settled: transactions.filter(t => t.type === "withdraw").reduce((sum, t) => sum + t.amount, 0), + pending: transactions.filter(t => t.type === "expense" && t.status === "pending").reduce((sum, t) => sum + t.amount, 0), + }; + + if (isLoading) { + return ( + <div className="container mx-auto space-y-4 px-4 py-6 lg:px-6"> + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> + <Skeleton className="h-28 w-full rounded-2xl" /> + <Skeleton className="h-28 w-full rounded-2xl" /> + <Skeleton className="h-28 w-full rounded-2xl" /> + </div> + <Skeleton className="h-64 w-full rounded-2xl" /> + </div> + ); + } + + return ( + <LocalePageShell> + <ThemeProvider attribute="class" defaultTheme="system" enableSystem> + <div className="min-h-dvh text-foreground"> + <div className="container mx-auto space-y-8 px-4 py-6 lg:px-6"> + <EditPageHeader title={t("settings.nav.wallet")} className="my-0" /> + {/* کارت‌های آمار - 3 کارت */} + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> + + + {/* کل فروش */} + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.5, delay: 0.1 }} + > + <Card className="text-white"> + <CardHeader> + <CardTitle className="text-lg">{t("settings.academy.wallet.totalSales")}</CardTitle> + <CardDescription className="dark:text-green-100 text-green-500"> + {t("settings.academy.wallet.totalSalesDesc")} + </CardDescription> + </CardHeader> + <CardContent > + <div className="text-3xl font-bold dark:text-white text-neutral-700"> + {(stats.sales + stats.settled).toLocaleString(dateLocale)} {t("settings.toman")} + </div> + </CardContent> + </Card> + </motion.div> + + {/* تسویه شده */} + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.5, delay: 0.2 }} + > + <Card className="text-white"> + <CardHeader> + <CardTitle className="text-lg">{t("settings.academy.wallet.settled")}</CardTitle> + <CardDescription className="dark:text-purple-100 text-purple-500"> + {t("settings.academy.wallet.settledDesc")} + </CardDescription> + </CardHeader> + <CardContent> + <div className="text-3xl font-bold dark:text-white text-neutral-700"> + {stats.settled.toLocaleString(dateLocale)} {t("settings.toman")} + </div> + </CardContent> + </Card> + </motion.div> + </div> + + {/* جداول تراکنش‌ها */} + <Card> + <CardHeader className="flex flex-row items-center justify-between flex-wrap gap-4"> + <CardTitle>{t("settings.academy.wallet.transactionsReport")}</CardTitle> + <div className="flex gap-2"> + <Select + value={filterType} + onValueChange={(value) => + setFilterType(value as "all" | "deposit" | "withdraw" | "expense") + } + > + <SelectTrigger className="w-[180px]"> + <SelectValue placeholder={t("settings.academy.wallet.filterByType")} /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">{t("settings.academy.wallet.all")}</SelectItem> + <SelectItem value="expense">{t("settings.academy.wallet.sales")}</SelectItem> + <SelectItem value="withdraw">{t("settings.academy.wallet.paidOut")}</SelectItem> + </SelectContent> + </Select> + </div> + </CardHeader> + <CardContent> + {filteredTransactions.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + {t("settings.academy.wallet.noTransactions")} + </div> + ) : ( + <div className="w-full"> + {/* نمای دسکتاپ - جدول */} + <div className="hidden md:block overflow-x-auto rounded-md border"> + <Table> + <TableHeader> + <TableRow className="bg-muted/50"> + <TableHead className="text-right">{t("settings.academy.wallet.description")}</TableHead> + <TableHead className="text-right">{t("settings.academy.wallet.amount")}</TableHead> + <TableHead className="text-right">{t("settings.academy.wallet.date")}</TableHead> + <TableHead className="text-right">{t("settings.status")}</TableHead> + + </TableRow> + </TableHeader> + <TableBody> + {filteredTransactions.length === 0 ? ( + <TableRow> + <TableCell colSpan={5} className="text-center py-8"> + {t("settings.academy.wallet.noTransactions")} + </TableCell> + </TableRow> + ) : ( + filteredTransactions.map((transaction) => ( + <TableRow key={transaction.id} className="border-b"> + <TableCell className="font-medium text-right"> + {transaction.description} + </TableCell> + <TableCell className="text-right"> + <span + className={`font-bold ${ + transaction.type === "expense" + ? "text-green-600" + : "text-red-600" + }`} + > + {transaction.type === "expense" ? "+" : "-"} + {transaction.amount.toLocaleString()} + </span> + </TableCell> + <TableCell className="text-right">{transaction.date}</TableCell> + <TableCell className="text-right"> + <Badge + variant="outline" + className={`${ + transaction.type === "expense" + ? "bg-yellow-500/10 text-yellow-600 border-yellow-500/30" + : "bg-green-500/10 text-green-600 border-green-500/30" + }`} + > + {transaction.type === "expense" + ? t("settings.academy.wallet.pendingSettlement") + : t("settings.academy.wallet.settlementDone")} + </Badge> + </TableCell> + + </TableRow> + )) + )} + </TableBody> + </Table> + </div> + + {/* نمای موبایل - کارت‌ها */} + <div className="md:hidden space-y-3"> + {filteredTransactions.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + {t("settings.academy.wallet.noTransactions")} + </div> + ) : ( + filteredTransactions.map((transaction) => ( + <Card key={transaction.id} className="p-4"> + <div className="flex justify-between items-start mb-2"> + <div className="flex-1"> + <p className="font-medium text-sm">{transaction.description}</p> + <p className="text-xs text-muted-foreground mt-1"> + {transaction.date} + </p> + </div> + <div className="text-right"> + <p + className={`font-bold text-lg ${ + transaction.type === "expense" + ? "text-green-600" + : "text-red-600" + }`} + > + {transaction.type === "expense" ? "+" : "-"} + {transaction.amount.toLocaleString()} + </p> + <Badge + variant="outline" + className={`mt-1 text-xs ${ + transaction.type === "expense" + ? "bg-yellow-500/10 text-yellow-600" + : "bg-green-500/10 text-green-600" + }`} + > + {transaction.type === "expense" + ? t("settings.academy.wallet.pendingSettlement") + : t("settings.academy.wallet.settlementDone")} + </Badge> + </div> + </div> + + </Card> + )) + )} + </div> + </div> + )} + </CardContent> + </Card> + </div> + </div> + </ThemeProvider> + </LocalePageShell> + ); +}; + export default WalletDashboard; \ No newline at end of file diff --git a/src/app/settings/booking/new/schedule/page.tsx b/src/app/settings/booking/new/schedule/page.tsx index db5e804..7d195dd 100644 --- a/src/app/settings/booking/new/schedule/page.tsx +++ b/src/app/settings/booking/new/schedule/page.tsx @@ -234,7 +234,7 @@ function BookingSchedulePage() { value={weeklyRepeatCount} onChange={(e) => setWeeklyRepeatCount(e.target.value)} placeholder={t("booking.weeklyRepeatCountPlaceholder")} - className="mt-2 w-24 rounded-xl border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50" + className="mt-2 w-24 rounded-2xl border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50" /> </div> @@ -247,12 +247,12 @@ function BookingSchedulePage() { type="date" value={newHoliday} onChange={(e) => setNewHoliday(e.target.value)} - className="flex-1 rounded-xl border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50" + className="flex-1 rounded-2xl border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50" /> <button type="button" onClick={addHoliday} - className="rounded-xl bg-neutral-100 px-3 py-2 text-sm font-semibold dark:bg-neutral-800" + className="rounded-2xl bg-neutral-100 px-3 py-2 text-sm font-semibold dark:bg-neutral-800" > {t("common.add")} </button> diff --git a/src/app/settings/chats/[username]/[id]/page.tsx b/src/app/settings/chats/[username]/[id]/page.tsx index f219a89..52fba64 100644 --- a/src/app/settings/chats/[username]/[id]/page.tsx +++ b/src/app/settings/chats/[username]/[id]/page.tsx @@ -728,6 +728,7 @@ function TicketChat({ params }: ITicketChatProps) { viewOnceMedia={viewOnceMedia} onViewOnceChange={setViewOnceMedia} onGiftClick={() => setShowGiftModal(true)} + giftDisabled={userTwoDetail?.gift_enabled === false} /> )} </AnimatePresence> diff --git a/src/app/settings/chats/rooms/[roomId]/page.tsx b/src/app/settings/chats/rooms/[roomId]/page.tsx index 3aff562..f1d0bca 100644 --- a/src/app/settings/chats/rooms/[roomId]/page.tsx +++ b/src/app/settings/chats/rooms/[roomId]/page.tsx @@ -244,7 +244,7 @@ function RoomMessageRow({ : buildStorageUrl(msg.file) } alt="" - className="mb-1 max-h-64 w-full rounded-xl object-cover" + className="mb-1 max-h-64 w-full rounded-2xl object-cover" /> ) : null} {msg.file && msg.fileType === "video" ? ( @@ -256,7 +256,7 @@ function RoomMessageRow({ } controls playsInline - className="mb-1 max-h-64 w-full rounded-xl" + className="mb-1 max-h-64 w-full rounded-2xl" /> ) : null} {msg.file && msg.fileType === "voice" ? ( diff --git a/src/app/settings/edit/License/page.tsx b/src/app/settings/edit/License/page.tsx index b3118ea..853ab53 100644 --- a/src/app/settings/edit/License/page.tsx +++ b/src/app/settings/edit/License/page.tsx @@ -16,6 +16,7 @@ import Cookies from "js-cookie"; import axios from "axios"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import EditPageHeader from "@/components/settings/EditPageHeader"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import { useTranslation } from "react-i18next"; function LicensePage() { @@ -27,6 +28,7 @@ function LicensePage() { const [avatar, setAvatar] = useState<string | null>(null); const [license, setLicense] = useState<any>(null); const [loadingUpload, setLoadingUpload] = useState(false); + const [loadingLicense, setLoadingLicense] = useState(true); // دریافت اطلاعات License هنگام لود صفحه useEffect(() => { @@ -37,6 +39,7 @@ function LicensePage() { const fetchLicense = async () => { + setLoadingLicense(true); try { // گرفتن توکن از کوکی const token = Cookies.get("token"); @@ -57,9 +60,11 @@ function LicensePage() { setAvatar(response.data.licenseImg); } } catch (error) { - + console.error(error); - + + } finally { + setLoadingLicense(false); } }; @@ -184,6 +189,10 @@ function LicensePage() { <div className="p-4 flex flex-col items-center justify-center address-page"> <EditPageHeader title={t("settings.edit.license.title")} className="mb-9" /> + {(!user || loadingLicense) ? ( + <EditFormSkeleton fields={2} /> + ) : ( + <> <div className="flex flex-col justify-center items-center"> <ProfileAvatar src={user?.profile_image} @@ -282,6 +291,8 @@ function LicensePage() { <small className="font-bold mt-10 mb-1 text-center"> {t("settings.edit.license.reviewTime")} </small> + </> + )} </div> </Container> </LocalePageShell> diff --git a/src/app/settings/edit/activity/academy-comments/page.tsx b/src/app/settings/edit/activity/academy-comments/page.tsx index c32a0b3..cd9d8fd 100644 --- a/src/app/settings/edit/activity/academy-comments/page.tsx +++ b/src/app/settings/edit/activity/academy-comments/page.tsx @@ -5,6 +5,7 @@ import Container from "@/components/elements/Container"; import PageTitle from "@/components/settings/PageTitle"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import useAxios from "@/hooks/useAxios"; +import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons"; import Link from "next/link"; import { useTranslation } from "react-i18next"; @@ -38,14 +39,7 @@ function AcademyCommentsPage() { <PageTitle>{t("settings.activity.items.academyComments")}</PageTitle> <div className="my-6"> {loading ? ( - <div className="space-y-2"> - {Array.from({ length: 5 }).map((_, i) => ( - <div - key={i} - className="h-14 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800" - /> - ))} - </div> + <SettingsListSkeleton count={5} /> ) : items.length === 0 ? ( <p className="py-10 text-center text-sm text-neutral-500"> {t("models.nothingFound")} @@ -58,7 +52,7 @@ function AcademyCommentsPage() { <li key={item._id}> <Link href={`/explore/${item.course_id?._id}`} - className="block rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800" + className="block rounded-2xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800" > <p className="truncate text-sm font-bold"> {item.course_id?.cuorse_name} diff --git a/src/app/settings/edit/activity/academy-likes/page.tsx b/src/app/settings/edit/activity/academy-likes/page.tsx index f42f217..b407f51 100644 --- a/src/app/settings/edit/activity/academy-likes/page.tsx +++ b/src/app/settings/edit/activity/academy-likes/page.tsx @@ -5,6 +5,7 @@ import Container from "@/components/elements/Container"; import PageTitle from "@/components/settings/PageTitle"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import useAxios from "@/hooks/useAxios"; +import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons"; import Link from "next/link"; import { useTranslation } from "react-i18next"; @@ -37,14 +38,7 @@ function AcademyLikesPage() { <PageTitle>{t("settings.activity.items.academyLikes")}</PageTitle> <div className="my-6"> {loading ? ( - <div className="space-y-2"> - {Array.from({ length: 5 }).map((_, i) => ( - <div - key={i} - className="h-12 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800" - /> - ))} - </div> + <SettingsListSkeleton count={5} /> ) : items.length === 0 ? ( <p className="py-10 text-center text-sm text-neutral-500"> {t("models.nothingFound")} @@ -57,7 +51,7 @@ function AcademyLikesPage() { <li key={item._id}> <Link href={`/explore/${item.course_id?._id}`} - className="flex items-center justify-between rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800" + className="flex items-center justify-between rounded-2xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800" > <span className="truncate text-sm font-bold"> {item.course_id?.cuorse_name} diff --git a/src/app/settings/edit/activity/billboard-comments/page.tsx b/src/app/settings/edit/activity/billboard-comments/page.tsx index bad2f33..8d1353a 100644 --- a/src/app/settings/edit/activity/billboard-comments/page.tsx +++ b/src/app/settings/edit/activity/billboard-comments/page.tsx @@ -5,6 +5,7 @@ import Container from "@/components/elements/Container"; import PageTitle from "@/components/settings/PageTitle"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import useAxios from "@/hooks/useAxios"; +import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons"; import Link from "next/link"; import { useTranslation } from "react-i18next"; @@ -38,14 +39,7 @@ function BillboardCommentsPage() { <PageTitle>{t("settings.activity.items.billboardComments")}</PageTitle> <div className="my-6"> {loading ? ( - <div className="space-y-2"> - {Array.from({ length: 5 }).map((_, i) => ( - <div - key={i} - className="h-14 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800" - /> - ))} - </div> + <SettingsListSkeleton count={5} /> ) : items.length === 0 ? ( <p className="py-10 text-center text-sm text-neutral-500"> {t("models.nothingFound")} @@ -58,7 +52,7 @@ function BillboardCommentsPage() { <li key={item._id}> <Link href={`/billboards/${item.advertisingId?._id}/x`} - className="block rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800" + className="block rounded-2xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800" > <p className="truncate text-sm font-bold"> {item.advertisingId?.title} diff --git a/src/app/settings/edit/activity/billboard-likes/page.tsx b/src/app/settings/edit/activity/billboard-likes/page.tsx index efd39bd..5a0842d 100644 --- a/src/app/settings/edit/activity/billboard-likes/page.tsx +++ b/src/app/settings/edit/activity/billboard-likes/page.tsx @@ -5,6 +5,7 @@ import Container from "@/components/elements/Container"; import PageTitle from "@/components/settings/PageTitle"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import useAxios from "@/hooks/useAxios"; +import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons"; import Link from "next/link"; import { useTranslation } from "react-i18next"; @@ -37,14 +38,7 @@ function BillboardLikesPage() { <PageTitle>{t("settings.activity.items.billboardLikes")}</PageTitle> <div className="my-6"> {loading ? ( - <div className="space-y-2"> - {Array.from({ length: 5 }).map((_, i) => ( - <div - key={i} - className="h-12 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800" - /> - ))} - </div> + <SettingsListSkeleton count={5} /> ) : items.length === 0 ? ( <p className="py-10 text-center text-sm text-neutral-500"> {t("models.nothingFound")} @@ -57,7 +51,7 @@ function BillboardLikesPage() { <li key={item._id}> <Link href={`/billboards/${item.advertisingId?._id}/x`} - className="flex items-center justify-between rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800" + className="flex items-center justify-between rounded-2xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800" > <span className="truncate text-sm font-bold"> {item.advertisingId?.title} diff --git a/src/app/settings/edit/analytics/posts/[postId]/page.tsx b/src/app/settings/edit/analytics/posts/[postId]/page.tsx index 62c4744..dc0d213 100644 --- a/src/app/settings/edit/analytics/posts/[postId]/page.tsx +++ b/src/app/settings/edit/analytics/posts/[postId]/page.tsx @@ -3,6 +3,7 @@ import Container from "@/components/elements/Container"; import PageTitle from "@/components/settings/PageTitle"; import LocalePageShell from "@/components/i18n/LocalePageShell"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import { IMAGE_BASE_URL } from "@/components/main/BaseUrl"; import useAxios from "@/hooks/useAxios"; import { useParams } from "next/navigation"; @@ -79,9 +80,8 @@ export default function PostAnalyticsDetailPage() { return ( <LocalePageShell> <Container> - <p className="py-16 text-center text-sm text-neutral-500"> - {t("common.loading")} - </p> + <PageTitle>{t("settings.edit.postsAnalytics.detailTitle")}</PageTitle> + <EditFormSkeleton fields={3} /> </Container> </LocalePageShell> ); @@ -91,6 +91,7 @@ export default function PostAnalyticsDetailPage() { return ( <LocalePageShell> <Container> + <PageTitle>{t("settings.edit.postsAnalytics.detailTitle")}</PageTitle> <p className="py-16 text-center text-sm text-neutral-500"> {t("settings.edit.postsAnalytics.notFound")} </p> diff --git a/src/app/settings/edit/analytics/posts/page.tsx b/src/app/settings/edit/analytics/posts/page.tsx index 4565e7a..21adcf8 100644 --- a/src/app/settings/edit/analytics/posts/page.tsx +++ b/src/app/settings/edit/analytics/posts/page.tsx @@ -4,6 +4,7 @@ import Container from "@/components/elements/Container"; import PageTitle from "@/components/settings/PageTitle"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import AccountAnalyticsWidget from "@/components/settings/AccountAnalyticsWidget"; +import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons"; import { IMAGE_BASE_URL } from "@/components/main/BaseUrl"; import useAxios from "@/hooks/useAxios"; import Link from "next/link"; @@ -50,9 +51,7 @@ export default function PostsAnalyticsPage() { </h3> {posts === null ? ( - <p className="py-8 text-center text-sm text-neutral-500"> - {t("common.loading")} - </p> + <SettingsListSkeleton count={6} /> ) : posts.length === 0 ? ( <p className="py-8 text-center text-sm text-neutral-500"> {t("settings.edit.postsAnalytics.empty")} @@ -65,7 +64,7 @@ export default function PostsAnalyticsPage() { href={`/settings/edit/analytics/posts/${post._id}`} className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700" > - <span className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-neutral-100 dark:bg-neutral-800"> + <span className="h-14 w-14 shrink-0 overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-800"> {post.files?.[0] && ( // eslint-disable-next-line @next/next/no-img-element <img diff --git a/src/app/settings/edit/analytics/region/page.tsx b/src/app/settings/edit/analytics/region/page.tsx index 4935b28..aad8b41 100644 --- a/src/app/settings/edit/analytics/region/page.tsx +++ b/src/app/settings/edit/analytics/region/page.tsx @@ -9,6 +9,7 @@ import CountryProvinceCitySelect, { findCountry, } from "@/components/ui/CountryProvinceCitySelect"; import useAxios from "@/hooks/useAxios"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; @@ -29,6 +30,7 @@ export default function RegionPage() { provinceId: null, cityId: null, }); + const [pageLoading, setPageLoading] = useState(true); useEffect(() => { request<ProfileResponse>("GET", "/profile", null, { noToast: true }) @@ -42,7 +44,8 @@ export default function RegionPage() { }); } }) - .catch(() => {}); + .catch(() => {}) + .finally(() => setPageLoading(false)); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -70,6 +73,10 @@ export default function RegionPage() { {t("settings.edit.region.hint")} </p> + {pageLoading ? ( + <EditFormSkeleton fields={1} /> + ) : ( + <> <CountryProvinceCitySelect value={selection} onChange={setSelection} /> {selectedCountry && ( @@ -90,6 +97,8 @@ export default function RegionPage() { {t("settings.edit.save")} </AuthNextButton> </div> + </> + )} </div> </Container> </LocalePageShell> diff --git a/src/app/settings/edit/avatar/page.tsx b/src/app/settings/edit/avatar/page.tsx index aca7043..f6edb3e 100644 --- a/src/app/settings/edit/avatar/page.tsx +++ b/src/app/settings/edit/avatar/page.tsx @@ -5,6 +5,7 @@ import Container from "@/components/elements/Container"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import { IMAGE_BASE_URL } from "@/components/main/BaseUrl"; import EditPageHeader from "@/components/settings/EditPageHeader"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import useAxios from "@/hooks/useAxios"; import { useUser } from "@/hooks/useUser"; import Image from "next/image"; @@ -70,6 +71,10 @@ function AvatarPage() { <Container> <div className="p-4 flex flex-col items-center h-dvh justify-center"> <EditPageHeader title={t("settings.edit.nav.avatar")} /> + {!user ? ( + <EditFormSkeleton fields={1} /> + ) : ( + <> <div className="flex flex-col items-center mb-4"> <div className="relative aspect-square w-[250px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-10"> {avatar ? ( @@ -107,6 +112,8 @@ function AvatarPage() { > {t("settings.edit.save")} </AuthNextButton> + </> + )} </div> </Container> </LocalePageShell> diff --git a/src/app/settings/edit/bio/page.tsx b/src/app/settings/edit/bio/page.tsx index c97c7cc..38f4297 100644 --- a/src/app/settings/edit/bio/page.tsx +++ b/src/app/settings/edit/bio/page.tsx @@ -10,6 +10,7 @@ import * as yup from "yup"; import { useRouter } from "next/navigation"; import AuthNextButton from "@/components/auth/AuthNextButton"; import EditPageHeader from "@/components/settings/EditPageHeader"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import toast from "react-hot-toast"; import { useUser } from "@/hooks/useUser"; import { useTranslation } from "react-i18next"; @@ -60,6 +61,9 @@ function BioPage() { <div className="p-4 flex flex-col items-center h-dvh justify-center"> <EditPageHeader title={t("settings.edit.nav.bio")} /> <div className="mt-5"></div> + {!user ? ( + <EditFormSkeleton fields={1} /> + ) : ( <form onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center" @@ -90,6 +94,7 @@ function BioPage() { {t("settings.edit.save")} </AuthNextButton> </form> + )} </div> </Container> </LocalePageShell> diff --git a/src/app/settings/edit/blocked-users/page.tsx b/src/app/settings/edit/blocked-users/page.tsx index 8ef927e..9f13734 100644 --- a/src/app/settings/edit/blocked-users/page.tsx +++ b/src/app/settings/edit/blocked-users/page.tsx @@ -5,6 +5,7 @@ import Container from "@/components/elements/Container"; import PageTitle from "@/components/settings/PageTitle"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import useAxios from "@/hooks/useAxios"; +import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons"; import { buildStorageUrl } from "@/components/main/BaseUrl"; import Image from "next/image"; import Link from "next/link"; @@ -39,20 +40,7 @@ function BlockedUsersPage() { <PageTitle>{t("settings.edit.nav.blockedUsers")}</PageTitle> <div className="my-6"> {loading ? ( - <div className="space-y-2"> - {Array.from({ length: 5 }).map((_, i) => ( - <div - key={i} - className="flex animate-pulse items-center gap-3 rounded-xl px-2 py-2" - > - <div className="h-11 w-11 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800" /> - <div className="flex-1 space-y-2"> - <div className="h-3 w-1/3 rounded bg-neutral-200 dark:bg-neutral-800" /> - <div className="h-3 w-1/4 rounded bg-neutral-200 dark:bg-neutral-800" /> - </div> - </div> - ))} - </div> + <SettingsListSkeleton count={5} /> ) : users.length === 0 ? ( <p className="py-10 text-center text-sm text-neutral-500"> {t("models.nothingFound")} @@ -67,7 +55,7 @@ function BlockedUsersPage() { <li key={u._id}> <Link href={`/users/${u.user_name}`} - className="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800" + className="flex items-center gap-3 rounded-2xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800" > <div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full bg-neutral-200"> <Image diff --git a/src/app/settings/edit/colors/page.tsx b/src/app/settings/edit/colors/page.tsx index f2cfe9d..21daae0 100644 --- a/src/app/settings/edit/colors/page.tsx +++ b/src/app/settings/edit/colors/page.tsx @@ -8,6 +8,7 @@ import * as yup from "yup"; import { useRouter } from "next/navigation"; import AuthNextButton from "@/components/auth/AuthNextButton"; import EditPageHeader from "@/components/settings/EditPageHeader"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import toast from "react-hot-toast"; import Image from "next/image"; import { useUser } from "@/hooks/useUser"; @@ -95,6 +96,10 @@ function Colors() { <EditPageHeader title={t("settings.edit.nav.colors")} /> <p className="mt-8 text-center text-sm font-bold">{t("settings.edit.sizes.appearance")}</p> + {!user ? ( + <EditFormSkeleton fields={2} /> + ) : ( + <> {/* Buttons for categories */} <div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]"> <button @@ -170,6 +175,8 @@ function Colors() { > {t("settings.edit.save")} </AuthNextButton> + </> + )} </div> </Container> </LocalePageShell> diff --git a/src/app/settings/edit/cooperation-type/page.tsx b/src/app/settings/edit/cooperation-type/page.tsx index 07b3339..66ff1ac 100644 --- a/src/app/settings/edit/cooperation-type/page.tsx +++ b/src/app/settings/edit/cooperation-type/page.tsx @@ -10,6 +10,7 @@ import * as yup from "yup"; import { useRouter } from "next/navigation"; import AuthNextButton from "@/components/auth/AuthNextButton"; import EditPageHeader from "@/components/settings/EditPageHeader"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import toast from "react-hot-toast"; import { useUser } from "@/hooks/useUser"; import { useTranslation } from "react-i18next"; @@ -58,6 +59,9 @@ function CooperationType() { <p className="my-10 text-sm font-bold"> {t("settings.edit.cooperationType.question")} </p> + {!user ? ( + <EditFormSkeleton fields={2} /> + ) : ( <form onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center"> <div className="flex w-full gap-2"> <AuthNextButton @@ -86,6 +90,7 @@ function CooperationType() { {t("settings.edit.save")} </AuthNextButton> </form> + )} </div> </Container> </LocalePageShell> diff --git a/src/app/settings/edit/expertise/page.tsx b/src/app/settings/edit/expertise/page.tsx index ccd39cc..7813a8b 100644 --- a/src/app/settings/edit/expertise/page.tsx +++ b/src/app/settings/edit/expertise/page.tsx @@ -17,6 +17,7 @@ import { } from "@/lib/subExpertiseDisplay"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import EditPageHeader from "@/components/settings/EditPageHeader"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import { useTranslation } from "react-i18next"; function Expertise() { @@ -42,6 +43,7 @@ function Expertise() { const [displaySubExpertise, setDisplaySubExpertise] = useState<string | null>( null ); + const [loadingList, setLoadingList] = useState(true); const user = useUser(); const hydratedFromProfile = useRef(false); @@ -71,6 +73,8 @@ function Expertise() { setExpertiseList(response?.expertises ?? []); } catch (err) { console.log(err); + } finally { + setLoadingList(false); } }; @@ -156,6 +160,10 @@ function Expertise() { <p className="mt-8 text-center">{t("settings.edit.expertise.question")}</p> + {!user || loadingList ? ( + <EditFormSkeleton fields={3} /> + ) : ( + <> <ExpertisePicker expertiseList={expertiseList} expertise={expertise} @@ -179,6 +187,8 @@ function Expertise() { > {t("settings.edit.save")} </AuthNextButton> + </> + )} </div> </Container> </LocalePageShell> diff --git a/src/app/settings/edit/google-account/page.tsx b/src/app/settings/edit/google-account/page.tsx index 151ba69..7055b87 100644 --- a/src/app/settings/edit/google-account/page.tsx +++ b/src/app/settings/edit/google-account/page.tsx @@ -4,6 +4,7 @@ import Container from "@/components/elements/Container"; import PageTitle from "@/components/settings/PageTitle"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import { GoogleSignInButton } from "@/app/(auth)/AuthProviders"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import useAxios from "@/hooks/useAxios"; import React, { useCallback, useEffect, useState } from "react"; import toast from "react-hot-toast"; @@ -43,7 +44,7 @@ export default function GoogleAccountSettingsPage() { <PageTitle>{t("settings.edit.nav.googleAccount")}</PageTitle> <div className="mx-auto flex max-w-md flex-col items-center py-8 text-center"> {loading ? ( - <p className="text-sm text-neutral-500">{t("common.loading")}</p> + <EditFormSkeleton fields={1} /> ) : status?.linked ? ( <> <p className="text-sm text-neutral-600 dark:text-neutral-300"> diff --git a/src/app/settings/edit/instagram-import/page.tsx b/src/app/settings/edit/instagram-import/page.tsx index 657e741..539908f 100644 --- a/src/app/settings/edit/instagram-import/page.tsx +++ b/src/app/settings/edit/instagram-import/page.tsx @@ -4,6 +4,7 @@ import Container from "@/components/elements/Container"; import PageTitle from "@/components/settings/PageTitle"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import InstagramSignInButton from "@/components/auth/InstagramSignInButton"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import useAxios from "@/hooks/useAxios"; import Link from "next/link"; import React, { useCallback, useEffect, useState } from "react"; @@ -110,7 +111,7 @@ export default function InstagramImportSettingsPage() { <PageTitle>{t("settings.edit.nav.instagramImport")}</PageTitle> <div className="mx-auto flex max-w-md flex-col items-center py-8 text-center"> {loading ? ( - <p className="text-sm text-neutral-500">{t("common.loading")}</p> + <EditFormSkeleton fields={1} /> ) : status?.connected ? ( <> <p className="text-sm text-neutral-600 dark:text-neutral-300"> diff --git a/src/app/settings/edit/instagram-import/select/page.tsx b/src/app/settings/edit/instagram-import/select/page.tsx index b7394a1..f5d7ed8 100644 --- a/src/app/settings/edit/instagram-import/select/page.tsx +++ b/src/app/settings/edit/instagram-import/select/page.tsx @@ -3,6 +3,7 @@ import Container from "@/components/elements/Container"; import PageTitle from "@/components/settings/PageTitle"; import LocalePageShell from "@/components/i18n/LocalePageShell"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import useAxios from "@/hooks/useAxios"; import { useRouter } from "next/navigation"; import React, { useEffect, useState } from "react"; @@ -88,9 +89,7 @@ export default function InstagramImportSelectPage() { <PageTitle>{t("settings.edit.instagramImport.selectPageTitle")}</PageTitle> {loading ? ( - <p className="py-8 text-center text-sm text-neutral-500"> - {t("common.loading")} - </p> + <EditFormSkeleton fields={1} /> ) : media.length === 0 ? ( <p className="py-8 text-center text-sm text-neutral-500"> {t("settings.edit.instagramImport.noMediaFound")} diff --git a/src/app/settings/edit/link-device/approve/page.tsx b/src/app/settings/edit/link-device/approve/page.tsx index debdfc2..7e551db 100644 --- a/src/app/settings/edit/link-device/approve/page.tsx +++ b/src/app/settings/edit/link-device/approve/page.tsx @@ -3,6 +3,7 @@ import Container from "@/components/elements/Container"; import PageTitle from "@/components/settings/PageTitle"; import LocalePageShell from "@/components/i18n/LocalePageShell"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import { getStoredUserId, isAuthenticated } from "@/lib/auth/session"; import { ensureIdentityKeys } from "@/lib/e2ee/keys"; import { @@ -66,9 +67,7 @@ export default function ApproveLinkDevicePage() { <Container> <PageTitle>{t("settings.edit.linkDevice.approveTitle")}</PageTitle> <div className="mx-auto flex max-w-md flex-col items-center py-8 text-center"> - {phase === "loading" && ( - <p className="text-sm text-neutral-500">{t("common.loading")}</p> - )} + {phase === "loading" && <EditFormSkeleton fields={1} />} {phase === "invalid" && ( <p className="text-sm text-red-500"> diff --git a/src/app/settings/edit/link-device/page.tsx b/src/app/settings/edit/link-device/page.tsx index 531490a..9aabd6d 100644 --- a/src/app/settings/edit/link-device/page.tsx +++ b/src/app/settings/edit/link-device/page.tsx @@ -3,6 +3,7 @@ import Container from "@/components/elements/Container"; import PageTitle from "@/components/settings/PageTitle"; import LocalePageShell from "@/components/i18n/LocalePageShell"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import { getStoredUserId } from "@/lib/auth/session"; import { startDeviceLinkSession, @@ -108,9 +109,7 @@ export default function LinkDevicePage() { <Container> <PageTitle>{t("settings.edit.nav.linkDevice")}</PageTitle> <div className="mx-auto flex max-w-md flex-col items-center py-8 text-center"> - {phase === "loading" && ( - <p className="text-sm text-neutral-500">{t("common.loading")}</p> - )} + {phase === "loading" && <EditFormSkeleton fields={1} />} {phase === "error" && ( <> diff --git a/src/app/settings/edit/location/page.tsx b/src/app/settings/edit/location/page.tsx index fbb0ab1..bf34110 100644 --- a/src/app/settings/edit/location/page.tsx +++ b/src/app/settings/edit/location/page.tsx @@ -14,6 +14,7 @@ import CountryProvinceCitySelect, { countryHasProvinces, } from "@/components/ui/CountryProvinceCitySelect"; import EditPageHeader from "@/components/settings/EditPageHeader"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import Map, { GeolocateControl, Marker } from "react-map-gl"; import "mapbox-gl/dist/mapbox-gl.css"; @@ -137,6 +138,9 @@ function LocationPage() { <small className="font-bold mt-10 mb-4"> {t("settings.edit.location.question")} </small> + {!user ? ( + <EditFormSkeleton fields={2} /> + ) : ( <form onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center" @@ -206,6 +210,7 @@ function LocationPage() { {t("settings.edit.save")} </AuthNextButton> </form> + )} </div> </Container> </LocalePageShell> diff --git a/src/app/settings/edit/personal-details/page.tsx b/src/app/settings/edit/personal-details/page.tsx index c7a95ee..fcefe19 100644 --- a/src/app/settings/edit/personal-details/page.tsx +++ b/src/app/settings/edit/personal-details/page.tsx @@ -5,6 +5,7 @@ import LocalePageShell from "@/components/i18n/LocalePageShell"; import RoundedInput from "@/components/elements/RoundedInput"; import AuthNextButton from "@/components/auth/AuthNextButton"; import EditPageHeader from "@/components/settings/EditPageHeader"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import { toggleBtnClass } from "@/lib/ui/buttonStyles"; import useAxios from "@/hooks/useAxios"; import { useUser } from "@/hooks/useUser"; @@ -79,6 +80,10 @@ function PersonalDetailsPage() { {t("settings.edit.personalDetails.hint")} </p> + {!user ? ( + <EditFormSkeleton fields={6} /> + ) : ( + <> <div className="flex w-full max-w-sm flex-col gap-6"> {/* وضعیت تاهل */} <div> @@ -277,6 +282,8 @@ function PersonalDetailsPage() { > {t("settings.edit.save")} </AuthNextButton> + </> + )} </div> </Container> </LocalePageShell> diff --git a/src/app/settings/edit/public-relations/page.tsx b/src/app/settings/edit/public-relations/page.tsx index f9ee3fa..0998888 100644 --- a/src/app/settings/edit/public-relations/page.tsx +++ b/src/app/settings/edit/public-relations/page.tsx @@ -10,6 +10,7 @@ import * as yup from "yup"; import { useRouter } from "next/navigation"; import AuthNextButton from "@/components/auth/AuthNextButton"; import EditPageHeader from "@/components/settings/EditPageHeader"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import toast from "react-hot-toast"; import { useUser } from "@/hooks/useUser"; import { useTranslation } from "react-i18next"; @@ -61,6 +62,9 @@ function PublicRelations() { <Container> <div className="p-4 flex flex-col items-center h-dvh justify-center"> <EditPageHeader title={t("settings.edit.nav.publicRelations")} /> + {!user ? ( + <EditFormSkeleton fields={1} /> + ) : ( <form onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center"> <small className="font-bold mt-10">{t("settings.edit.bio.aboutYou")}</small> <textarea @@ -89,6 +93,7 @@ function PublicRelations() { {t("settings.edit.save")} </AuthNextButton> </form> + )} </div> </Container> </LocalePageShell> diff --git a/src/app/settings/edit/services/page.tsx b/src/app/settings/edit/services/page.tsx index c8f0c46..2eb2be7 100644 --- a/src/app/settings/edit/services/page.tsx +++ b/src/app/settings/edit/services/page.tsx @@ -9,6 +9,7 @@ import Container from "@/components/elements/Container"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import AuthNextButton from "@/components/auth/AuthNextButton"; import EditPageHeader from "@/components/settings/EditPageHeader"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import useAxios from "@/hooks/useAxios"; import toast from "react-hot-toast"; import { dataURLtoBlob } from "@/helpers/helpers"; @@ -22,6 +23,7 @@ const ServicesPage: React.FC = () => { const [services, setServices] = useState<Service[]>([]); const [isModalOpen, setModalOpen] = useState(false); const [user, setUser] = useState<User>(); + const [loadingUser, setLoadingUser] = useState(true); const fetchUser = async () => { try { @@ -29,6 +31,8 @@ const ServicesPage: React.FC = () => { setUser(response?.user); } catch (error) { console.error("Failed to fetch user:", error); + } finally { + setLoadingUser(false); } }; @@ -81,6 +85,10 @@ const ServicesPage: React.FC = () => { <Container> <div className="p-4 flex flex-col items-center h-dvh justify-center"> <EditPageHeader title={t("settings.edit.nav.services")} /> + {loadingUser ? ( + <EditFormSkeleton fields={2} /> + ) : ( + <> <div className="mt-5 w-full max-w-md"> <AuthUserDetails /> </div> @@ -113,6 +121,8 @@ const ServicesPage: React.FC = () => { isModalOpen={isModalOpen} /> )} + </> + )} </div> </Container> </LocalePageShell> diff --git a/src/app/settings/edit/shaba/page.tsx b/src/app/settings/edit/shaba/page.tsx index e4d9fd3..33a4dc3 100644 --- a/src/app/settings/edit/shaba/page.tsx +++ b/src/app/settings/edit/shaba/page.tsx @@ -10,6 +10,7 @@ import * as yup from "yup"; import { useRouter } from "next/navigation"; import AuthNextButton from "@/components/auth/AuthNextButton"; import EditPageHeader from "@/components/settings/EditPageHeader"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import toast from "react-hot-toast"; import AuthInput from "@/components/auth/AuthInput"; import DatePicker from "react-multi-date-picker"; @@ -131,6 +132,9 @@ function ShabaPage() { <Container> <div className="p-4 flex flex-col items-center min-h-dvh justify-center auth-page"> <EditPageHeader title={t("settings.edit.nav.shaba")} /> + {!user ? ( + <EditFormSkeleton fields={3} /> + ) : ( <form onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center" @@ -254,6 +258,7 @@ function ShabaPage() { {t("settings.edit.shaba.saveButton")} </AuthNextButton> </form> + )} </div> </Container> </LocalePageShell> diff --git a/src/app/settings/edit/sizes/page.tsx b/src/app/settings/edit/sizes/page.tsx index c0c84a2..863ab45 100644 --- a/src/app/settings/edit/sizes/page.tsx +++ b/src/app/settings/edit/sizes/page.tsx @@ -9,6 +9,7 @@ import * as yup from "yup"; import { useRouter } from "next/navigation"; import AuthNextButton from "@/components/auth/AuthNextButton"; import EditPageHeader from "@/components/settings/EditPageHeader"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import toast from "react-hot-toast"; import Image from "next/image"; import { useUser } from "@/hooks/useUser"; @@ -71,6 +72,10 @@ function Sizes() { <p className="mt-8 text-center text-sm font-bold mb-4"> {t("settings.edit.sizes.appearance")} </p> + {!user ? ( + <EditFormSkeleton fields={2} /> + ) : ( + <> <div className="flex gap-2 mt-4 w-full max-w-[320px]"> <button className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${ @@ -148,6 +153,8 @@ function Sizes() { <AuthNextButton type="button" onClick={handleCheck} className="mt-10" loading={loading} disabled={loading}> {t("settings.edit.save")} </AuthNextButton> + </> + )} </div> </Container> </LocalePageShell> diff --git a/src/app/settings/edit/two-factor/page.tsx b/src/app/settings/edit/two-factor/page.tsx index f7a5767..d6474f7 100644 --- a/src/app/settings/edit/two-factor/page.tsx +++ b/src/app/settings/edit/two-factor/page.tsx @@ -5,6 +5,7 @@ import PageTitle from "@/components/settings/PageTitle"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import AuthOtpInput from "@/components/auth/AuthOtpInput"; import AuthNextButton from "@/components/auth/AuthNextButton"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; import useAxios from "@/hooks/useAxios"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; @@ -127,7 +128,7 @@ export default function TwoFactorSettingsPage() { <PageTitle>{t("settings.edit.nav.twoFactor")}</PageTitle> <div className="mx-auto flex max-w-md flex-col items-center py-8"> {pageLoading ? ( - <p className="text-sm text-neutral-500">{t("common.loading")}</p> + <EditFormSkeleton fields={1} /> ) : status?.enabled ? ( <> <p className="mb-6 text-center text-sm text-neutral-600 dark:text-neutral-300"> diff --git a/src/app/settings/edit/verification-guide/page.tsx b/src/app/settings/edit/verification-guide/page.tsx new file mode 100644 index 0000000..0979567 --- /dev/null +++ b/src/app/settings/edit/verification-guide/page.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Container from "@/components/elements/Container"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import EditPageHeader from "@/components/settings/EditPageHeader"; +import RoundedButton from "@/components/elements/RoundedButton"; +import VerificationBadge, { + type VerifyBadgeTier, +} from "@/components/main/VerificationBadge"; +import Link from "next/link"; +import { useTranslation } from "react-i18next"; + +const TIERS: { key: Exclude<VerifyBadgeTier, "none">; href: string }[] = [ + { key: "gray", href: "/settings/edit/Authentication" }, + { key: "blue", href: "/settings/edit/Authentication" }, + { key: "green", href: "/settings/edit/License" }, + { key: "gold", href: "/settings/tickets/new" }, +]; + +function VerificationGuidePage() { + const { t } = useTranslation("common"); + + return ( + <LocalePageShell> + <Container> + <div className="flex flex-col items-center p-4"> + <EditPageHeader title={t("settings.edit.nav.verificationGuide")} /> + <div className="mt-6 flex w-full max-w-sm flex-col gap-6 pb-10"> + {TIERS.map((tier) => ( + <div + key={tier.key} + className="flex flex-col gap-3 rounded-3xl border border-border-primary-light p-4 dark:border-border-primary-dark" + > + <div className="flex items-center gap-2"> + <VerificationBadge verifyBadge={tier.key} size={22} /> + <span className="font-bold"> + {t(`settings.verificationGuide.${tier.key}.name`)} + </span> + </div> + <p className="text-xs leading-6 text-neutral-600 dark:text-neutral-300"> + {t(`settings.verificationGuide.${tier.key}.description`)} + </p> + <Link href={tier.href} className="self-center"> + <RoundedButton variant="primary" className="h-10 w-full max-w-[200px]"> + {t("settings.verificationGuide.buttonLabel")} + </RoundedButton> + </Link> + </div> + ))} + </div> + </div> + </Container> + </LocalePageShell> + ); +} + +export default VerificationGuidePage; diff --git a/src/app/settings/notifications/page.tsx b/src/app/settings/notifications/page.tsx index 41636e5..9b1a7ef 100644 --- a/src/app/settings/notifications/page.tsx +++ b/src/app/settings/notifications/page.tsx @@ -133,6 +133,10 @@ function Notifications() { post_comment: `/posts/${item?.project_post_id}`, post_rating: `/posts/${item?.project_post_id}`, post_tag: `/posts/${item?.project_post_id}`, + // نوتیف‌های هوشمند (SmartNotificationsCron.js) — همیشه project_post_id یه پست معموله + trending_post_3day: `/posts/${item?.project_post_id}`, + weekly_new_trend: `/posts/${item?.project_post_id}`, + inactivity_reengagement: `/posts/${item?.project_post_id}`, profile_comment: "/settings/profile", profile_rating: "/settings/profile", user_comment: "/settings/profile", diff --git a/src/app/settings/profile/language/page.tsx b/src/app/settings/profile/language/page.tsx index 4985369..57d788e 100644 --- a/src/app/settings/profile/language/page.tsx +++ b/src/app/settings/profile/language/page.tsx @@ -90,7 +90,7 @@ export default function SelectLanguagePage() { type="button" dir={def.direction} onClick={() => void handleSelect(def.code as AppLanguage)} - className={`flex items-center justify-between rounded-xl border px-4 py-3 text-right transition-colors ${ + className={`flex items-center justify-between rounded-2xl border px-4 py-3 text-right transition-colors ${ active ? "border-[#0095f6] bg-[#0095f6]/10" : "border-neutral-200 dark:border-neutral-800" diff --git a/src/app/settings/profile/user-settings/page.tsx b/src/app/settings/profile/user-settings/page.tsx index 2261d5f..eb4e479 100644 --- a/src/app/settings/profile/user-settings/page.tsx +++ b/src/app/settings/profile/user-settings/page.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useState } from "react"; import Container from "@/components/elements/Container"; +import Modal from "@/components/elements/Modal"; +import RoundedButton from "@/components/elements/RoundedButton"; import PageTitle from "@/components/settings/PageTitle"; import LanguageSettingRow from "@/components/settings/LanguageSettingRow"; import LocalePageShell from "@/components/i18n/LocalePageShell"; @@ -11,12 +13,17 @@ import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { useAppLanguage } from "@/contexts/LanguageProvider"; import { isAppLanguage, type AppLanguage } from "@/lib/i18n/constants"; +// زبان‌هایی که آیتم‌های آنالیتیکس/کاربران مسدود/بایگانی/فعالیت شما فقط توی اونا نمایش داده می‌شن +const SETTINGS_ITEMS_VISIBLE_LANGUAGES: AppLanguage[] = ["fa", "ar", "en"]; + type PrivacyPatch = { allow_save_posts?: boolean; ghost_mode?: boolean; show_location?: boolean; post_location_enabled?: boolean; is_private?: boolean; + gift_enabled?: boolean; + offer_payment_enabled?: boolean; preferred_language?: AppLanguage; }; @@ -26,6 +33,8 @@ type PrivacyResponse = { show_location: boolean; post_location_enabled: boolean; is_private: boolean; + gift_enabled: boolean; + offer_payment_enabled: boolean; preferred_language?: AppLanguage; }; @@ -38,10 +47,15 @@ export default function UserSettingsPage() { const [showLocation, setShowLocation] = useState(false); const [postLocationEnabled, setPostLocationEnabled] = useState(false); const [isPrivate, setIsPrivate] = useState(false); + const [giftEnabled, setGiftEnabled] = useState(true); + const [offerPaymentEnabled, setOfferPaymentEnabled] = useState(true); const [loading, setLoading] = useState(true); const [accountStatus, setAccountStatus] = useState<string>("active"); const [reactivationLockedUntil, setReactivationLockedUntil] = useState<string | null>(null); const [reactivating, setReactivating] = useState(false); + const [blockedByCount, setBlockedByCount] = useState(0); + const [resetConfirmOpen, setResetConfirmOpen] = useState(false); + const [resettingAlgorithm, setResettingAlgorithm] = useState(false); const applyPrivacyState = (data: Partial<PrivacyResponse>) => { if (typeof data.allow_save_posts === "boolean") { @@ -59,6 +73,12 @@ export default function UserSettingsPage() { if (typeof data.is_private === "boolean") { setIsPrivate(data.is_private); } + if (typeof data.gift_enabled === "boolean") { + setGiftEnabled(data.gift_enabled); + } + if (typeof data.offer_payment_enabled === "boolean") { + setOfferPaymentEnabled(data.offer_payment_enabled); + } if (isAppLanguage(data.preferred_language)) { void setLanguage(data.preferred_language); } @@ -79,16 +99,36 @@ export default function UserSettingsPage() { show_location: res?.account?.show_location === true, post_location_enabled: res?.account?.post_location_enabled === true, is_private: res?.account?.is_private === true, + gift_enabled: res?.account?.gift_enabled !== false, + offer_payment_enabled: res?.account?.offer_payment_enabled !== false, preferred_language: res?.account?.preferred_language, }); setAccountStatus(res?.account?.account_status || "active"); setReactivationLockedUntil(res?.account?.reactivation_locked_until || null); + setBlockedByCount( + (res?.account as { blocked_by_count?: number })?.blocked_by_count ?? 0 + ); } finally { setLoading(false); } })(); }, [request]); // eslint-disable-line react-hooks/exhaustive-deps + const resetAlgorithm = async () => { + setResettingAlgorithm(true); + try { + await request("POST", "/account/reset-algorithm", {}, { noToast: true }); + toast.success(t("settings.resetAlgorithmDone")); + setResetConfirmOpen(false); + } catch (err) { + toast.error( + err instanceof Error ? err.message : t("settings.resetAlgorithmError") + ); + } finally { + setResettingAlgorithm(false); + } + }; + const savePrivacy = async (patch: PrivacyPatch) => { try { const res = await request<PrivacyResponse>( @@ -132,7 +172,7 @@ export default function UserSettingsPage() { <PageTitle>{t("settings.userSettingsTitle")}</PageTitle> <div className="space-y-4 pb-8 text-sm"> {accountStatus === "deactivated" && ( - <div className="rounded-xl border border-amber-300 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30"> + <div className="rounded-2xl border border-amber-300 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30"> <p className="font-semibold text-amber-800 dark:text-amber-200"> {t("settings.accountDeactivated")} </p> @@ -161,7 +201,7 @@ export default function UserSettingsPage() { <Link href="/settings/edit/analytics/region" - className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" + className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" > <span className="font-semibold">{t("settings.edit.region.title")}</span> <span className="text-neutral-400">›</span> @@ -169,7 +209,7 @@ export default function UserSettingsPage() { <Link href="/settings/profile/visitors" - className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" + className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" > <span className="font-semibold">{t("settings.profileVisitors")}</span> <span className="text-neutral-400">›</span> @@ -177,46 +217,74 @@ export default function UserSettingsPage() { <Link href="/settings/booking" - className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" + className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" > <span className="font-semibold">{t("settings.nav.booking")}</span> <span className="text-neutral-400">›</span> </Link> + {/* آنالیتیکس/کاربران مسدود/بایگانی/فعالیت شما و محتوای این آیتم‌ها فقط توی + فارسی/عربی/انگلیسی نمایش داده می‌شن */} + {SETTINGS_ITEMS_VISIBLE_LANGUAGES.includes(language) ? ( + <> + <Link + href="/settings/edit/analytics" + className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" + > + <span className="font-semibold">{t("settings.edit.nav.analytics")}</span> + <span className="text-neutral-400">›</span> + </Link> + + <Link + href="/settings/edit/blocked-users" + className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" + > + <span className="font-semibold">{t("settings.edit.nav.blockedUsers")}</span> + <span className="text-neutral-400">›</span> + </Link> + + <div className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> + <span className="font-semibold">{t("settings.blockedByCount")}</span> + <span className="text-neutral-400">{blockedByCount}</span> + </div> + + <Link + href="/settings/edit/archive" + className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" + > + <span className="font-semibold">{t("settings.edit.nav.archive")}</span> + <span className="text-neutral-400">›</span> + </Link> + + <Link + href="/settings/edit/activity" + className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" + > + <span className="font-semibold">{t("settings.edit.nav.activity")}</span> + <span className="text-neutral-400">›</span> + </Link> + </> + ) : null} + <Link - href="/settings/edit/analytics" - className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" + href="/settings/edit/verification-guide" + className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" > - <span className="font-semibold">{t("settings.edit.nav.analytics")}</span> + <span className="font-semibold">{t("settings.edit.nav.verificationGuide")}</span> <span className="text-neutral-400">›</span> </Link> - <Link - href="/settings/edit/blocked-users" - className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" + <button + type="button" + onClick={() => setResetConfirmOpen(true)} + className="flex w-full items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 text-start dark:border-neutral-800" > - <span className="font-semibold">{t("settings.edit.nav.blockedUsers")}</span> + <span className="font-semibold">{t("settings.resetAlgorithm")}</span> <span className="text-neutral-400">›</span> - </Link> + </button> - <Link - href="/settings/edit/archive" - className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" - > - <span className="font-semibold">{t("settings.edit.nav.archive")}</span> - <span className="text-neutral-400">›</span> - </Link> - - <Link - href="/settings/edit/activity" - className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800" - > - <span className="font-semibold">{t("settings.edit.nav.activity")}</span> - <span className="text-neutral-400">›</span> - </Link> - - <label className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> - <div> + <label className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> + <div className="min-w-0 pe-3"> <p className="font-semibold">{t("settings.allowSavePosts")}</p> <p className="mt-0.5 text-xs text-neutral-500"> {t("settings.allowSavePostsDesc")} @@ -227,12 +295,12 @@ export default function UserSettingsPage() { checked={allowSave} disabled={loading} onChange={(e) => savePrivacy({ allow_save_posts: e.target.checked })} - className="h-5 w-5 accent-[#0095f6]" + className="h-5 w-5 shrink-0 accent-[#0095f6]" /> </label> - <label className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> - <div> + <label className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> + <div className="min-w-0 pe-3"> <p className="font-semibold">{t("settings.postLocation")}</p> <p className="mt-0.5 text-xs text-neutral-500"> {t("settings.postLocationDesc")} @@ -245,12 +313,12 @@ export default function UserSettingsPage() { onChange={(e) => savePrivacy({ post_location_enabled: e.target.checked }) } - className="h-5 w-5 accent-[#0095f6]" + className="h-5 w-5 shrink-0 accent-[#0095f6]" /> </label> - <label className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> - <div> + <label className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> + <div className="min-w-0 pe-3"> <p className="font-semibold">{t("settings.showLocation")}</p> <p className="mt-0.5 text-xs text-neutral-500"> {t("settings.showLocationDesc")} @@ -261,12 +329,12 @@ export default function UserSettingsPage() { checked={showLocation} disabled={loading} onChange={(e) => savePrivacy({ show_location: e.target.checked })} - className="h-5 w-5 accent-[#0095f6]" + className="h-5 w-5 shrink-0 accent-[#0095f6]" /> </label> - <label className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> - <div> + <label className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> + <div className="min-w-0 pe-3"> <p className="font-semibold">{t("settings.privateProfile")}</p> <p className="mt-0.5 text-xs text-neutral-500"> {t("settings.privateProfileDesc")} @@ -277,12 +345,12 @@ export default function UserSettingsPage() { checked={isPrivate} disabled={loading} onChange={(e) => savePrivacy({ is_private: e.target.checked })} - className="h-5 w-5 accent-[#0095f6]" + className="h-5 w-5 shrink-0 accent-[#0095f6]" /> </label> - <label className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> - <div> + <label className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> + <div className="min-w-0 pe-3"> <p className="font-semibold">{t("settings.ghostMode")}</p> <p className="mt-0.5 text-xs text-neutral-500"> {t("settings.ghostModeDesc")} @@ -293,11 +361,77 @@ export default function UserSettingsPage() { checked={ghostMode} disabled={loading} onChange={(e) => savePrivacy({ ghost_mode: e.target.checked })} - className="h-5 w-5 accent-[#0095f6]" + className="h-5 w-5 shrink-0 accent-[#0095f6]" + /> + </label> + + <p className="pt-2 font-bold">{t("settings.walletSettingsTitle")}</p> + + <label className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> + <div className="min-w-0 pe-3"> + <p className="font-semibold">{t("settings.giftEnabled")}</p> + <p className="mt-0.5 text-xs text-neutral-500"> + {t("settings.giftEnabledDesc")} + </p> + </div> + <input + type="checkbox" + checked={giftEnabled} + disabled={loading} + onChange={(e) => savePrivacy({ gift_enabled: e.target.checked })} + className="h-5 w-5 shrink-0 accent-[#0095f6]" + /> + </label> + + <label className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> + <div className="min-w-0 pe-3"> + <p className="font-semibold">{t("settings.offerPaymentEnabled")}</p> + <p className="mt-0.5 text-xs text-neutral-500"> + {t("settings.offerPaymentEnabledDesc")} + </p> + </div> + <input + type="checkbox" + checked={offerPaymentEnabled} + disabled={loading} + onChange={(e) => + savePrivacy({ offer_payment_enabled: e.target.checked }) + } + className="h-5 w-5 shrink-0 accent-[#0095f6]" /> </label> </div> + + <Modal + isOpen={resetConfirmOpen} + onClose={() => setResetConfirmOpen(false)} + height="fit" + > + <div className="flex flex-col items-center gap-4 py-2 text-center"> + <p className="font-bold">{t("settings.resetAlgorithm")}</p> + <p className="text-sm text-neutral-500"> + {t("settings.resetAlgorithmWarning")} + </p> + <div className="grid w-full grid-cols-2 gap-4"> + <RoundedButton + className="h-10 w-full" + disabled={resettingAlgorithm} + onClick={() => setResetConfirmOpen(false)} + > + {t("common.no")} + </RoundedButton> + <RoundedButton + variant="primary" + className="h-10 w-full" + disabled={resettingAlgorithm} + onClick={resetAlgorithm} + > + {resettingAlgorithm ? "…" : t("common.yes")} + </RoundedButton> + </div> + </div> + </Modal> </Container> </LocalePageShell> ); diff --git a/src/app/settings/workroom/edit/[id]/page.tsx b/src/app/settings/workroom/edit/[id]/page.tsx index aed2964..e535263 100644 --- a/src/app/settings/workroom/edit/[id]/page.tsx +++ b/src/app/settings/workroom/edit/[id]/page.tsx @@ -1,6 +1,7 @@ "use client"; import Container from "@/components/elements/Container"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; import MultiStepForm from "@/components/projects/NewProject/MultiStepForm"; import { useProjectForm } from "@/contexts/ProjectFormContext"; import useAxios from "@/hooks/useAxios"; @@ -83,9 +84,11 @@ export default function EditProjectWrapper({ params }: IEditProps) { }, [id]); return ( - <Container> - {fetched && <MultiStepForm />} - <div></div> - </Container> + <LocalePageShell> + <Container> + {fetched && <MultiStepForm />} + <div></div> + </Container> + </LocalePageShell> ); } diff --git a/src/app/shops/checkout/[orderId]/page.tsx b/src/app/shops/checkout/[orderId]/page.tsx index b8cc7eb..4d4fbfe 100644 --- a/src/app/shops/checkout/[orderId]/page.tsx +++ b/src/app/shops/checkout/[orderId]/page.tsx @@ -87,7 +87,7 @@ export default function CheckoutPage() { <div className="mx-auto flex max-w-md flex-col gap-4"> <div className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700"> {primaryImage && ( - <div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-200 dark:bg-neutral-800"> + <div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800"> <Image src={IMAGE_BASE_URL + primaryImage} alt={order.listing.title} diff --git a/src/app/shops/new/preview/page.tsx b/src/app/shops/new/preview/page.tsx index abcbaf0..08fb990 100644 --- a/src/app/shops/new/preview/page.tsx +++ b/src/app/shops/new/preview/page.tsx @@ -10,6 +10,7 @@ import { IMAGE_BASE_URL } from "@/components/main/BaseUrl"; import { cn } from "@/lib/utils"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; +import Image from "next/image"; import Map, { Marker } from "react-map-gl"; import "mapbox-gl/dist/mapbox-gl.css"; import toast from "react-hot-toast"; @@ -24,15 +25,18 @@ const METHOD_ICON: Record<string, string> = { own_vehicle: "smart-car", }; +// آیکون‌ها عین همون‌هایی که توی اپ اندروید برای اطلاعات تماس فروشگاه استفاده می‌شن +// (ShopProfileScreen.tsx وب: call/call/send/message-text/instagram) — برای یکسان بودن +// ظاهر بین وب و اپ، نه آیکون‌های برندیِ قبلی هر کانال const CONTACT_CHANNELS: { key: "mobile" | "landline" | "telegram" | "whatsapp" | "instagram"; icon: string; color: string; }[] = [ - { key: "mobile", icon: "/images/icons/vuesax/bold/mobile.svg", color: "bg-green-500" }, + { key: "mobile", icon: "/images/icons/vuesax/bold/call.svg", color: "bg-green-500" }, { key: "landline", icon: "/images/icons/vuesax/bold/call.svg", color: "bg-orange-500" }, - { key: "telegram", icon: "/images/icons/telegram.svg", color: "bg-sky-500" }, - { key: "whatsapp", icon: "/images/icons/vuesax/bold/whatsapp.svg", color: "bg-emerald-600" }, + { key: "telegram", icon: "/images/icons/vuesax/bold/send.svg", color: "bg-sky-500" }, + { key: "whatsapp", icon: "/images/icons/vuesax/bold/message-text.svg", color: "bg-emerald-600" }, { key: "instagram", icon: "/images/icons/vuesax/bold/instagram.svg", @@ -134,7 +138,7 @@ function ShopPreviewPage() { <img src={IMAGE_BASE_URL + shop.logo} alt={shop.name} - className="h-32 w-32 rounded-2xl object-cover shadow-sm" + className="h-44 w-44 rounded-2xl object-cover shadow-sm" /> )} @@ -211,10 +215,10 @@ function ShopPreviewPage() { <div className="flex flex-col gap-1.5 rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700"> {shop.response_schedule.map((item) => ( <div key={item.day} className="flex items-center justify-between text-xs"> + <span className="font-medium">{t(`shops.days.${item.day}`)}</span> <span className="text-neutral-500" dir="ltr"> {item.start_time || "--:--"} - {item.end_time || "--:--"} </span> - <span className="font-medium">{t(`shops.days.${item.day}`)}</span> </div> ))} </div> @@ -243,9 +247,11 @@ function ShopPreviewPage() { </div> )} {hasMap && ( + // ارتفاع و پین سفارشی عین نقشه‌ی لوکیشن پروفایل کاربر + // (settings/edit/location/page.tsx) — برای یکدستی ظاهر نقشه‌ها توی اپ <div className="w-full overflow-hidden rounded-2xl"> <Map - style={{ height: "150px" }} + style={{ height: "220px" }} initialViewState={{ longitude: Number(shop.lng), latitude: Number(shop.lat), @@ -255,7 +261,9 @@ function ShopPreviewPage() { mapStyle="mapbox://styles/mapbox/streets-v11" interactive={false} > - <Marker latitude={Number(shop.lat)} longitude={Number(shop.lng)} /> + <Marker latitude={Number(shop.lat)} longitude={Number(shop.lng)}> + <Image alt="location icon" width={25} height={25} src="/images/icons/location.svg" /> + </Marker> </Map> </div> )} diff --git a/src/app/shops/orders/[orderId]/page.tsx b/src/app/shops/orders/[orderId]/page.tsx index 621d62d..a8eed30 100644 --- a/src/app/shops/orders/[orderId]/page.tsx +++ b/src/app/shops/orders/[orderId]/page.tsx @@ -6,6 +6,8 @@ import Header from "@/components/main/Header"; import TabNavigation from "@/components/TabNavigation"; import PageTitle from "@/components/settings/PageTitle"; import AuthNextButton from "@/components/auth/AuthNextButton"; +import Modal from "@/components/elements/Modal"; +import RoundedButton from "@/components/elements/RoundedButton"; import RoundedInput from "@/components/elements/RoundedInput"; import BuyerInfoModal from "@/components/shops/orders/BuyerInfoModal"; import ShopInfoModal from "@/components/shops/orders/ShopInfoModal"; @@ -84,6 +86,8 @@ export default function OrderDetailPage() { const { request, loading } = useAxios(); const user = useUser(); const [repurchasing, setRepurchasing] = useState(false); + const [cancelling, setCancelling] = useState(false); + const [showCancelConfirm, setShowCancelConfirm] = useState(false); const [order, setOrder] = useState<OrderDetail | null>(null); const [report, setReport] = useState<OrderReport | null>(null); @@ -186,6 +190,24 @@ export default function OrderDetailPage() { } }; + const handleCancelOrder = async () => { + if (!order) return; + setCancelling(true); + try { + await request("PATCH", `/orders/${order._id}/cancel`, {}); + toast.success(t("shops.orderCancelled")); + setShowCancelConfirm(false); + loadOrder(); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response + ?.data?.message || t("shops.unknownError"); + toast.error(message); + } finally { + setCancelling(false); + } + }; + const handleSubmitRating = async () => { if (!order || ratingScore < 1) return; try { @@ -313,7 +335,7 @@ export default function OrderDetailPage() { <div className="mx-auto flex max-w-md flex-col gap-4"> <div className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700"> {primaryImage && ( - <div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-200 dark:bg-neutral-800"> + <div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800"> <Image src={IMAGE_BASE_URL + primaryImage} alt={order.listing.title} @@ -514,6 +536,16 @@ export default function OrderDetailPage() { </AuthNextButton> )} + {isBuyer && order.status === "on_hold" && ( + <AuthNextButton + type="button" + onClick={() => setShowCancelConfirm(true)} + className="!bg-red-500 !border-red-500" + > + {t("shops.cancelOrder")} + </AuthNextButton> + )} + {isBuyer && order.status !== "pending_payment" && ( <> {canConfirmReceipt && ( @@ -713,6 +745,35 @@ export default function OrderDetailPage() { onClose={() => setShowShopInfo(false)} shop={order.shop} /> + <Modal + isOpen={showCancelConfirm} + onClose={() => setShowCancelConfirm(false)} + height="fit" + > + <div className="flex flex-col items-center gap-4 py-2 text-center"> + <p className="font-bold">{t("shops.cancelOrderConfirmTitle")}</p> + <p className="text-sm text-neutral-500"> + {t("shops.cancelOrderConfirmDesc")} + </p> + <div className="grid w-full grid-cols-2 gap-4"> + <RoundedButton + className="h-10 w-full" + disabled={cancelling} + onClick={() => setShowCancelConfirm(false)} + > + {t("common.no")} + </RoundedButton> + <RoundedButton + variant="primary" + className="h-10 w-full" + disabled={cancelling} + onClick={handleCancelOrder} + > + {cancelling ? "…" : t("common.yes")} + </RoundedButton> + </div> + </div> + </Modal> </> ); } diff --git a/src/app/shops/product/[catalogId]/page.tsx b/src/app/shops/product/[catalogId]/page.tsx index 60da967..31766fc 100644 --- a/src/app/shops/product/[catalogId]/page.tsx +++ b/src/app/shops/product/[catalogId]/page.tsx @@ -78,7 +78,7 @@ export default function ProductComparisonPage() { className="flex w-full items-center gap-3 rounded-2xl border border-neutral-200 p-2 text-right active:scale-[0.98] dark:border-neutral-700" > {primaryImage && ( - <div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-200 dark:bg-neutral-800"> + <div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800"> <Image src={IMAGE_BASE_URL + primaryImage} alt={listing.title} diff --git a/src/app/shops/profile/[shopId]/ShopProfileClient.tsx b/src/app/shops/profile/[shopId]/ShopProfileClient.tsx index 81055df..a140de0 100644 --- a/src/app/shops/profile/[shopId]/ShopProfileClient.tsx +++ b/src/app/shops/profile/[shopId]/ShopProfileClient.tsx @@ -330,7 +330,7 @@ export default function ShopProfileClient() { onClick={() => router.push(`/shops/profile/${shopId}/reel/${tileId}`) } - className="relative aspect-square overflow-hidden rounded-md bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" + className="relative aspect-square overflow-hidden rounded-md bg-neutral-100 sm:rounded-2xl dark:bg-neutral-800" > {listing.isTaggedPost && ( <span className="absolute right-1 top-1 z-10 rounded-full bg-black/50 px-1.5 py-0.5 text-[9px] font-semibold text-white"> diff --git a/src/components/ClientErrorBoundary.tsx b/src/components/ClientErrorBoundary.tsx index 93dbe19..a159ac9 100644 --- a/src/components/ClientErrorBoundary.tsx +++ b/src/components/ClientErrorBoundary.tsx @@ -35,7 +35,7 @@ export default class ClientErrorBoundary extends React.Component<Props, State> { <button type="button" onClick={() => window.location.reload()} - className="rounded-xl bg-[#0095f6] px-5 py-2.5 text-sm font-bold text-white" + className="rounded-2xl bg-[#0095f6] px-5 py-2.5 text-sm font-bold text-white" > {t("errors.retry")} </button> diff --git a/src/components/PushNotificationListener.tsx b/src/components/PushNotificationListener.tsx new file mode 100644 index 0000000..64410ff --- /dev/null +++ b/src/components/PushNotificationListener.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import Cookies from "js-cookie"; +import toast from "react-hot-toast"; +import { registerPushToken, subscribeForegroundPush } from "@/lib/pushNotifications"; + +// عین PushNotificationListener.tsx اندروید: (۱) ثبت توکنِ پوش وقتی کاربر لاگینه، +// (۲) وقتی تب باز و فعاله و پیامی می‌رسه، خودِ مرورگر هیچ نوتیفِ سیستمی‌ای نشون +// نمی‌ده — با toast جایگزین می‌شه. ری‌لود کامل روی لاگین/خروج (window.location.href +// در useAxios.tsx) نیست، پس با تغییرِ pathname هم دوباره چک می‌شه تا لاگینِ بدون +// ری‌لودِ کامل هم توکن رو ثبت کنه +export default function PushNotificationListener() { + const pathname = usePathname(); + const router = useRouter(); + const registeredRef = useRef(false); + + useEffect(() => { + const hasToken = Boolean(Cookies.get("token")); + if (hasToken && !registeredRef.current) { + registeredRef.current = true; + void registerPushToken(); + } else if (!hasToken) { + registeredRef.current = false; + } + }, [pathname]); + + useEffect(() => { + const unsubscribe = subscribeForegroundPush((payload) => { + const title = payload.notification?.title; + if (!title) return; + const body = payload.notification?.body; + toast( + (t) => ( + <button + type="button" + onClick={() => { + toast.dismiss(t.id); + router.push("/settings/notifications"); + }} + className="flex flex-col items-start gap-0.5 text-start" + > + <span className="text-sm font-bold">{title}</span> + {body ? ( + <span className="text-xs text-neutral-500">{body}</span> + ) : null} + </button> + ), + { duration: 6000 }, + ); + }); + return unsubscribe; + }, [router]); + + return null; +} diff --git a/src/components/academy/AcademyPackageItem.tsx b/src/components/academy/AcademyPackageItem.tsx index 50ebb07..a97226f 100644 --- a/src/components/academy/AcademyPackageItem.tsx +++ b/src/components/academy/AcademyPackageItem.tsx @@ -26,7 +26,7 @@ function AcademyPackageItem({ > <div className="flex w-full items-center justify-between max-[350px]:flex-col max-sm:gap-5"> <div className="flex items-center gap-3 min-w-0 flex-1"> - <div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-xl shadow-md"> + <div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-2xl shadow-md"> <Image src={imageSrc} alt={course.cuorse_name} diff --git a/src/components/academy/AcademySkeletons.tsx b/src/components/academy/AcademySkeletons.tsx index beaf12a..bbecb27 100644 --- a/src/components/academy/AcademySkeletons.tsx +++ b/src/components/academy/AcademySkeletons.tsx @@ -34,7 +34,7 @@ export function AcademyProfileHeadSkeleton() { <Skeleton className="h-10 w-14" /> <Skeleton className="h-10 w-14" /> </div> - <Skeleton className="h-[120px] w-[120px] rounded-xl" /> + <Skeleton className="h-[120px] w-[120px] rounded-2xl" /> </div> <div className="grid grid-cols-3 gap-4"> <Skeleton className="h-6 w-full" /> @@ -43,10 +43,10 @@ export function AcademyProfileHeadSkeleton() { </div> <Skeleton className="h-16 w-full" /> <div className="grid grid-cols-2 md:grid-cols-4 gap-2"> - <Skeleton className="h-8 w-full rounded-xl" /> - <Skeleton className="h-8 w-full rounded-xl" /> - <Skeleton className="h-8 w-full rounded-xl" /> - <Skeleton className="h-8 w-full rounded-xl" /> + <Skeleton className="h-8 w-full rounded-2xl" /> + <Skeleton className="h-8 w-full rounded-2xl" /> + <Skeleton className="h-8 w-full rounded-2xl" /> + <Skeleton className="h-8 w-full rounded-2xl" /> </div> </div> ); @@ -62,7 +62,7 @@ export function AcademyPackageListSkeleton({ count = 4 }: { count?: number }) { > <div className="flex items-center justify-between gap-4"> <div className="flex items-center gap-3 flex-1"> - <Skeleton className="h-12 w-12 rounded-xl shrink-0" /> + <Skeleton className="h-12 w-12 rounded-2xl shrink-0" /> <div className="flex-1 space-y-2"> <Skeleton className="h-4 w-2/3" /> <Skeleton className="h-3 w-full" /> @@ -84,11 +84,11 @@ export function AcademyCourseDetailSkeleton() { <div className="max-w-7xl mx-auto px-4 py-6 space-y-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="lg:col-span-2 space-y-4"> - <Skeleton className="h-72 w-full rounded-xl" /> - <Skeleton className="h-32 w-full rounded-xl" /> + <Skeleton className="h-72 w-full rounded-2xl" /> + <Skeleton className="h-32 w-full rounded-2xl" /> </div> <div className="space-y-4"> - <Skeleton className="h-96 w-full rounded-xl" /> + <Skeleton className="h-96 w-full rounded-2xl" /> </div> </div> </div> diff --git a/src/components/academy/MainModelCard.tsx b/src/components/academy/MainModelCard.tsx index 0951224..a945580 100644 --- a/src/components/academy/MainModelCard.tsx +++ b/src/components/academy/MainModelCard.tsx @@ -1,518 +1,518 @@ -"use client"; - -import React, { - useState, - useRef, - useEffect, - useContext, - createContext, - useCallback, -} from "react"; -import { Academy, Course } from "@/types/types"; -import Image from "next/image"; -import MainModelCardActions from "./MainModelCardActions"; -import Link from "next/link"; -import ExpandableCaption from "@/components/posts/ExpandableCaption"; -import { - reorderCaptionLinksToEnd, - splitCaptionBodyAndLinks, -} from "@/lib/postLinkCaption"; -import { buildStorageUrl } from "@/components/main/BaseUrl"; -import VerificationBadge from "@/components/main/VerificationBadge"; -import ScoreRateStats from "@/components/main/ScoreRateStats"; -import DiscountBadge from "@/components/ui/DiscountBadge"; -import { useUserById } from "@/hooks/getUserById"; -import "slick-carousel/slick/slick.css"; -import "slick-carousel/slick/slick-theme.css"; -import useAxios from "@/hooks/useAxios"; -import { useRouter } from "next/navigation"; -import { navigateWithSavedScroll } from "@/lib/listScrollRestoration"; -import { forEach } from "lodash"; -import { formatCoursePriceLabel } from "@/lib/formatCoursePriceLabel"; -import toast from "react-hot-toast"; -import { useTranslation } from "react-i18next"; - -// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارت‌ها -const MutedContext = createContext<{ - muted: boolean; - setMuted: React.Dispatch<React.SetStateAction<boolean>>; -}>({ - muted: true, - setMuted: () => {}, -}); - -interface AcademyResponse { - academy: Academy; -} - -function MainModelCard({ - postData, - scrollKey, -}: { - postData: Course; - scrollKey?: string; -}) { - const { t, i18n } = useTranslation("common"); - const priceLang = i18n.language === "en" ? "en" : "fa"; - const { - user_id, - caption, - type, - price, - cuorse_name, - category, - offer, - academyId, - number_of_course_content, - course_image, - } = postData; - - // console.log(postData._id); - - - const user = useUserById(user_id); - const profile_image = user?.profile_image; - const verify_badge = user?.verify_badge; - const user_score = user?.user_score; - const rate = user?.rate; - - const { request } = useAxios(); - const router = useRouter() - const [academy, setAcademy] = useState<Academy | null>(null); - - - const { _id } = postData; - - const [newScore, setNewScore] = useState<number>(0); - const [rating, setRating] = useState<number>(0); - const [comments, setComments] = useState<Comments[]>([]); - const [totalComments, setTotalComments] = useState(0); - const [averageRate, setAverageRate] = useState(0); - const [loading, setLoading] = useState(false); - const [page, setPage] = useState(1); - const [hasMore, setHasMore] = useState(true); - const [statusFilter, setStatusFilter] = useState<string>("accepted"); - const [sortBy, setSortBy] = useState<string>("createdAt"); - const [sortOrder, setSortOrder] = useState<string>("desc"); - const [usersCache, setUsersCache] = useState<Record<string, any>>({}); - - - const containerRef = useRef<HTMLDivElement>(null); - - // تابع دریافت اطلاعات کاربر با caching - const fetchUserInfo = async (userId: string) => { - if (usersCache[userId]) { - return usersCache[userId]; - } - - try { - const response = await request("GET", `/profile/${userId}`); - if (response?.user) { - const userData = response.user; - setUsersCache((prev) => ({ ...prev, [userId]: userData })); - return userData; - } - } catch (error) { - console.error(`Error fetching user ${userId}:`, error); - } - return null; - }; - - // دریافت تعداد کل کامنت‌ها و آمار - const fetchCommentsStats = async () => { - if (!_id) return; - - try { - const response = await request( - "GET", - `/academy/course/${_id}/comments/count` - ); - if (response?.success) { - setTotalComments(response.data.totalComments); - setAverageRate(response.data.averageRate || 0); - } - } catch (error) { - console.error("Error fetching stats:", error); - } - }; - - // دریافت لیست کامنت‌ها - const fetchComments = async (pageNum: number, append = false) => { - if (loading || !_id) return; - - setLoading(true); - try { - const response = await request( - "GET", - `/academy/course/${_id}/comments?page=${pageNum}&limit=20&status=${statusFilter}&sortBy=${sortBy}&sortOrder=${sortOrder}` - ); - - let sum = 0; - let count = 0; - - response.data.stats.rateDistribution.forEach(element => { - sum += Number(element); - count++; - }); - - const average = sum / count; - const averagesum = sum *1; - - const roundedRating = Math.round(average * 10) / 10 - setRating(roundedRating); - setNewScore(averagesum); - - - - - - if (response?.success) { - let newComments = response.data.comments; - const pagination = response.data.pagination; - - if (newComments && newComments.length > 0) { - // بررسی اینکه آیا بک‌اند اطلاعات کاربر را populate کرده است - if (newComments[0]?.user_id?.user_name) { - // اطلاعات کاربر قبلاً داخل کامنت است - setComments((prev) => - append ? [...prev, ...newComments] : newComments - ); - } else { - // اگر بک‌اند فقط user_id را برگردانده، اطلاعات کاربر را بگیریم - const uniqueUserIds = [...new Set(newComments.map((c) => c.user_id))]; - - // دریافت اطلاعات همه کاربران به صورت همزمان - const usersData = await Promise.all( - uniqueUserIds.map((id) => fetchUserInfo(id)) - ); - - // ساخت mapping از userId به اطلاعات کاربر - const userMap: Record<string, any> = {}; - uniqueUserIds.forEach((id, index) => { - userMap[id] = usersData[index]; - }); - - // ترکیب کامنت‌ها با اطلاعات کاربران - const commentsWithUsers = newComments.map((comment) => ({ - ...comment, - user_id: userMap[comment.user_id] || { - _id: comment.user_id, - user_name: t("common.user"), - profile_image: null, - is_verified: "unverified" - }, - })); - - setComments((prev) => - append ? [...prev, ...commentsWithUsers] : commentsWithUsers - ); - } - } else { - setComments(append ? [...prev] : []); - } - - setHasMore(pagination?.hasNextPage || false); - if (pagination?.totalItems) { - setTotalComments(pagination.totalItems); - } - } - } catch (error) { - console.error("Error fetching comments:", error); - toast.error(t("academy.commentsError")); - } finally { - setLoading(false); - } - }; - - // لود اولیه و هنگام تغییر فیلترها - useEffect(() => { - if (_id) { - setPage(1); - setComments([]); - fetchComments(1, false); - fetchCommentsStats(); - } - }, [ _id, statusFilter, sortBy, sortOrder]); - - // اینفینیت اسکرول - const observer = useRef<IntersectionObserver | null>(null); - const lastCommentRef = useCallback( - (node: HTMLDivElement) => { - if (loading) return; - if (observer.current) observer.current.disconnect(); - observer.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && hasMore) { - setPage((prev) => prev + 1); - } - }); - if (node) observer.current.observe(node); - }, - [loading, hasMore] - ); - - useEffect(() => { - if (page > 1) { - fetchComments(page, true); - } - }, [page]); - useEffect(() => { - const fetchData = async () => { - try { - const response = (await request("POST", `/academy/findAcademyById`, { - _id: academyId, - })) as AcademyResponse; - setAcademy(response?.academy); - console.log(response); - } catch (error) { - console.error("خطا در دریافت اطلاعات:", error); - } - }; - - fetchData(); - }, []); - - const [showFullCaption, setShowFullCaption] = useState(false); - const orderedCaption = reorderCaptionLinksToEnd(caption || ""); - const { body: captionBody, links: captionLinks } = - splitCaptionBodyAndLinks(orderedCaption); - - useEffect(() => { - setShowFullCaption(false); - }, [postData._id]); - - const [imageLoading, setImageLoading] = useState(true); - const videoRef = useRef<HTMLVideoElement | null>(null); - const [ripple, setRipple] = useState<{ - x: number; - y: number; - key: number; - } | null>(null); - - // استفاده از Context برای وضعیت muted گلوبال - const { muted, setMuted } = useContext(MutedContext); - - const subExpertise = user?.sub_expertise || []; - const keywords = ["نمونه کار دارم", "آرایشگر سیار", "عکاس سیار"]; - const keywords2 = ["آموزشگاه دارم"]; - const keywords3 = ["سالن دارم", "آتیله دارم"]; - - const matchedExpertise = subExpertise.filter((item) => - keywords.some((keyword) => item.includes(keyword)) - ); - const matchedExpertise2 = subExpertise.filter((item) => - keywords2.some((keyword) => item.includes(keyword)) - ); - const matchedExpertise3 = subExpertise.filter((item) => - keywords3.some((keyword) => item.includes(keyword)) - ); - - // تابع برای ایجاد افکت ripple و تغییر وضعیت صدا - const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => { - const v = videoRef.current; - if (!v) return; - - const rect = v.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - setRipple({ x, y, key: Date.now() }); - - // --- مهمترین اصلاح --- - v.muted = !v.muted; // اول وضعیت ویدیو را تغییر بده - setMuted(v.muted); // بعد context را Sync کن - - if (!v.muted) { - v.play().catch(() => {}); - } - - setTimeout(() => setRipple(null), 600); - }; - - // استفاده از IntersectionObserver برای پخش/توقف ویدیو بر اساس ویوپورت - useEffect(() => { - const video = videoRef.current; - if (!video || type !== "video") return; - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - // ویدیو در ویوپورت است: پخش کن - video.play().catch(() => { - /* ignore: ممکن است به دلیل محدودیت‌های مرورگر */ - }); - video.muted = muted; // اعمال وضعیت گلوبال - } else { - // ویدیو خارج از ویوپورت: توقف کن - video.pause(); - } - }); - }, - { - threshold: 0.5, // حداقل 50% ویدیو باید در ویوپورت باشد - } - ); - - observer.observe(video); - - return () => { - observer.unobserve(video); - }; - }, [muted, type]); - - // اعمال وضعیت muted گلوبال هنگام mount یا تغییر muted - useEffect(() => { - if (videoRef.current) { - videoRef.current.muted = muted; - } - }, [muted]); - - return ( - <div - className={` - relative flex w-full flex-col mb-6 overflow-hidden rounded-2xl pb-2 - transition-all duration-300 - ${type === "pro" ? "shadow-[0_0px_30px_rgb(255,16,125,0.60)] p-3" : ""} - `} - > -{/* {type === "pro" && ( - <div className="absolute top-30 left-3 z-10 bg-gradient-to-r from-[#FF107D] to-[#FF6B9D] text-white text-xs font-bold px-3 py-1.5 rounded-full shadow-lg flex items-center gap-1.5 backdrop-blur-sm"> - <svg - width="14" - height="14" - viewBox="0 0 24 24" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > - <path - d="M12 2L15 8.5L22 9.5L17 14L18.5 21L12 17.5L5.5 21L7 14L2 9.5L9 8.5L12 2Z" - fill="currentColor" - stroke="currentColor" - strokeWidth="1.5" - strokeLinejoin="round" - /> - </svg> - <span>نسخه پرو</span> - </div> - )} */} - <Link - href={`/academy/profile/${academyId}`} - className="flex text-sm justify-between py-2" - > - <div className="flex items-center w-full"> - <div className="relative max-w-[72px] min-w-[72px] max-h-[72px] min-h-[72px] ml-2"> - <Image - src={ - academy?.academy_image - ? buildStorageUrl(academy.academy_image) - : "/images/fake-avatar.png" - } - alt={cuorse_name} - fill - className="rounded-xl object-cover" - priority - /> - </div> - <div className="font-semibold flex gap-2 flex-col ml-2 w-full"> - <div className="max-sm:text-xs items-center text-[#387E65] flex w-full justify-between"> - {t("academy.videoCount", { count: number_of_course_content })} - <div className="text-[#387E65] pt-1 flex gap-5 font-semibold"> - <h4>{category}</h4> - {matchedExpertise.map((item, index) => ( - <h4 key={index} className="text-[#387E65] font-semibold"> - {item} - </h4> - ))} - </div> - </div> - <div className="flex justify-between items-center"> - <span className="text-[#0090ff] text-xl font-semibold flex items-center"> - {formatCoursePriceLabel(postData, priceLang)} - {!postData.is_free && postData.offer && Number(postData.offer) > 0 ? ( - <DiscountBadge percent={Number(postData.offer)} className="mr-2" /> - ) : null} - </span> - </div> - <div className="flex items-center gap-1 mt-1"> - <h2 className="max-sm:text-xs inline-flex items-center gap-1"> - {cuorse_name} - <VerificationBadge verifyBadge={verify_badge} /> - </h2> - <ScoreRateStats score={newScore} rate={rating} /> - </div> - </div> - </div> - </Link> - - <div - onClick={() => { - const url = `/academy/${postData._id}/course`; - if (scrollKey) { - navigateWithSavedScroll(router, scrollKey, url); - } else { - router.push(url); - } - }} - className="overflow-hidden rounded-2xl" - > - <div className="w-full flex-shrink-0 flex justify-center items-center"> - <div className="w-full rounded-2xl overflow-hidden shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]"> - <Image - src={buildStorageUrl(course_image)} - alt={`media-${course_image}`} - width={1000} - height={1000} - loading={"lazy"} - className="object-contain w-full min-h-[30vh] rounded-xl shadow-lg transition-opacity duration-300" - onLoadingComplete={() => setImageLoading?.(false)} - /> - </div> - </div> - </div> - - <MainModelCardActions postData={postData} /> - {(captionBody || captionLinks) && ( - <div className="mx-2 mt-2"> - <ExpandableCaption - body={captionBody} - links={captionLinks} - expanded={showFullCaption} - onToggle={() => setShowFullCaption((v) => !v)} - /> - </div> - )} - - <style jsx>{` - .animate-ripple { - animation: ripple 600ms ease-out; - pointer-events: none; - } - @keyframes ripple { - 0% { - transform: scale(0); - opacity: 0.5; - } - 100% { - transform: scale(4); - opacity: 0; - } - } - `}</style> - </div> - ); -} - -// Provider برای به اشتراک گذاشتن وضعیت muted -export const MutedProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - const [muted, setMuted] = useState(true); - - return ( - <MutedContext.Provider value={{ muted, setMuted }}> - {children} - </MutedContext.Provider> - ); -}; - -export default MainModelCard; +"use client"; + +import React, { + useState, + useRef, + useEffect, + useContext, + createContext, + useCallback, +} from "react"; +import { Academy, Course } from "@/types/types"; +import Image from "next/image"; +import MainModelCardActions from "./MainModelCardActions"; +import Link from "next/link"; +import ExpandableCaption from "@/components/posts/ExpandableCaption"; +import { + reorderCaptionLinksToEnd, + splitCaptionBodyAndLinks, +} from "@/lib/postLinkCaption"; +import { buildStorageUrl } from "@/components/main/BaseUrl"; +import VerificationBadge from "@/components/main/VerificationBadge"; +import ScoreRateStats from "@/components/main/ScoreRateStats"; +import DiscountBadge from "@/components/ui/DiscountBadge"; +import { useUserById } from "@/hooks/getUserById"; +import "slick-carousel/slick/slick.css"; +import "slick-carousel/slick/slick-theme.css"; +import useAxios from "@/hooks/useAxios"; +import { useRouter } from "next/navigation"; +import { navigateWithSavedScroll } from "@/lib/listScrollRestoration"; +import { forEach } from "lodash"; +import { formatCoursePriceLabel } from "@/lib/formatCoursePriceLabel"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; + +// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارت‌ها +const MutedContext = createContext<{ + muted: boolean; + setMuted: React.Dispatch<React.SetStateAction<boolean>>; +}>({ + muted: true, + setMuted: () => {}, +}); + +interface AcademyResponse { + academy: Academy; +} + +function MainModelCard({ + postData, + scrollKey, +}: { + postData: Course; + scrollKey?: string; +}) { + const { t, i18n } = useTranslation("common"); + const priceLang = i18n.language === "en" ? "en" : "fa"; + const { + user_id, + caption, + type, + price, + cuorse_name, + category, + offer, + academyId, + number_of_course_content, + course_image, + } = postData; + + // console.log(postData._id); + + + const user = useUserById(user_id); + const profile_image = user?.profile_image; + const verify_badge = user?.verify_badge; + const user_score = user?.user_score; + const rate = user?.rate; + + const { request } = useAxios(); + const router = useRouter() + const [academy, setAcademy] = useState<Academy | null>(null); + + + const { _id } = postData; + + const [newScore, setNewScore] = useState<number>(0); + const [rating, setRating] = useState<number>(0); + const [comments, setComments] = useState<Comments[]>([]); + const [totalComments, setTotalComments] = useState(0); + const [averageRate, setAverageRate] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [statusFilter, setStatusFilter] = useState<string>("accepted"); + const [sortBy, setSortBy] = useState<string>("createdAt"); + const [sortOrder, setSortOrder] = useState<string>("desc"); + const [usersCache, setUsersCache] = useState<Record<string, any>>({}); + + + const containerRef = useRef<HTMLDivElement>(null); + + // تابع دریافت اطلاعات کاربر با caching + const fetchUserInfo = async (userId: string) => { + if (usersCache[userId]) { + return usersCache[userId]; + } + + try { + const response = await request("GET", `/profile/${userId}`); + if (response?.user) { + const userData = response.user; + setUsersCache((prev) => ({ ...prev, [userId]: userData })); + return userData; + } + } catch (error) { + console.error(`Error fetching user ${userId}:`, error); + } + return null; + }; + + // دریافت تعداد کل کامنت‌ها و آمار + const fetchCommentsStats = async () => { + if (!_id) return; + + try { + const response = await request( + "GET", + `/academy/course/${_id}/comments/count` + ); + if (response?.success) { + setTotalComments(response.data.totalComments); + setAverageRate(response.data.averageRate || 0); + } + } catch (error) { + console.error("Error fetching stats:", error); + } + }; + + // دریافت لیست کامنت‌ها + const fetchComments = async (pageNum: number, append = false) => { + if (loading || !_id) return; + + setLoading(true); + try { + const response = await request( + "GET", + `/academy/course/${_id}/comments?page=${pageNum}&limit=20&status=${statusFilter}&sortBy=${sortBy}&sortOrder=${sortOrder}` + ); + + let sum = 0; + let count = 0; + + response.data.stats.rateDistribution.forEach(element => { + sum += Number(element); + count++; + }); + + const average = sum / count; + const averagesum = sum *1; + + const roundedRating = Math.round(average * 10) / 10 + setRating(roundedRating); + setNewScore(averagesum); + + + + + + if (response?.success) { + let newComments = response.data.comments; + const pagination = response.data.pagination; + + if (newComments && newComments.length > 0) { + // بررسی اینکه آیا بک‌اند اطلاعات کاربر را populate کرده است + if (newComments[0]?.user_id?.user_name) { + // اطلاعات کاربر قبلاً داخل کامنت است + setComments((prev) => + append ? [...prev, ...newComments] : newComments + ); + } else { + // اگر بک‌اند فقط user_id را برگردانده، اطلاعات کاربر را بگیریم + const uniqueUserIds = [...new Set(newComments.map((c) => c.user_id))]; + + // دریافت اطلاعات همه کاربران به صورت همزمان + const usersData = await Promise.all( + uniqueUserIds.map((id) => fetchUserInfo(id)) + ); + + // ساخت mapping از userId به اطلاعات کاربر + const userMap: Record<string, any> = {}; + uniqueUserIds.forEach((id, index) => { + userMap[id] = usersData[index]; + }); + + // ترکیب کامنت‌ها با اطلاعات کاربران + const commentsWithUsers = newComments.map((comment) => ({ + ...comment, + user_id: userMap[comment.user_id] || { + _id: comment.user_id, + user_name: t("common.user"), + profile_image: null, + is_verified: "unverified" + }, + })); + + setComments((prev) => + append ? [...prev, ...commentsWithUsers] : commentsWithUsers + ); + } + } else { + setComments(append ? [...prev] : []); + } + + setHasMore(pagination?.hasNextPage || false); + if (pagination?.totalItems) { + setTotalComments(pagination.totalItems); + } + } + } catch (error) { + console.error("Error fetching comments:", error); + toast.error(t("academy.commentsError")); + } finally { + setLoading(false); + } + }; + + // لود اولیه و هنگام تغییر فیلترها + useEffect(() => { + if (_id) { + setPage(1); + setComments([]); + fetchComments(1, false); + fetchCommentsStats(); + } + }, [ _id, statusFilter, sortBy, sortOrder]); + + // اینفینیت اسکرول + const observer = useRef<IntersectionObserver | null>(null); + const lastCommentRef = useCallback( + (node: HTMLDivElement) => { + if (loading) return; + if (observer.current) observer.current.disconnect(); + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore) { + setPage((prev) => prev + 1); + } + }); + if (node) observer.current.observe(node); + }, + [loading, hasMore] + ); + + useEffect(() => { + if (page > 1) { + fetchComments(page, true); + } + }, [page]); + useEffect(() => { + const fetchData = async () => { + try { + const response = (await request("POST", `/academy/findAcademyById`, { + _id: academyId, + })) as AcademyResponse; + setAcademy(response?.academy); + console.log(response); + } catch (error) { + console.error("خطا در دریافت اطلاعات:", error); + } + }; + + fetchData(); + }, []); + + const [showFullCaption, setShowFullCaption] = useState(false); + const orderedCaption = reorderCaptionLinksToEnd(caption || ""); + const { body: captionBody, links: captionLinks } = + splitCaptionBodyAndLinks(orderedCaption); + + useEffect(() => { + setShowFullCaption(false); + }, [postData._id]); + + const [imageLoading, setImageLoading] = useState(true); + const videoRef = useRef<HTMLVideoElement | null>(null); + const [ripple, setRipple] = useState<{ + x: number; + y: number; + key: number; + } | null>(null); + + // استفاده از Context برای وضعیت muted گلوبال + const { muted, setMuted } = useContext(MutedContext); + + const subExpertise = user?.sub_expertise || []; + const keywords = ["نمونه کار دارم", "آرایشگر سیار", "عکاس سیار"]; + const keywords2 = ["آموزشگاه دارم"]; + const keywords3 = ["سالن دارم", "آتیله دارم"]; + + const matchedExpertise = subExpertise.filter((item) => + keywords.some((keyword) => item.includes(keyword)) + ); + const matchedExpertise2 = subExpertise.filter((item) => + keywords2.some((keyword) => item.includes(keyword)) + ); + const matchedExpertise3 = subExpertise.filter((item) => + keywords3.some((keyword) => item.includes(keyword)) + ); + + // تابع برای ایجاد افکت ripple و تغییر وضعیت صدا + const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => { + const v = videoRef.current; + if (!v) return; + + const rect = v.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + setRipple({ x, y, key: Date.now() }); + + // --- مهمترین اصلاح --- + v.muted = !v.muted; // اول وضعیت ویدیو را تغییر بده + setMuted(v.muted); // بعد context را Sync کن + + if (!v.muted) { + v.play().catch(() => {}); + } + + setTimeout(() => setRipple(null), 600); + }; + + // استفاده از IntersectionObserver برای پخش/توقف ویدیو بر اساس ویوپورت + useEffect(() => { + const video = videoRef.current; + if (!video || type !== "video") return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + // ویدیو در ویوپورت است: پخش کن + video.play().catch(() => { + /* ignore: ممکن است به دلیل محدودیت‌های مرورگر */ + }); + video.muted = muted; // اعمال وضعیت گلوبال + } else { + // ویدیو خارج از ویوپورت: توقف کن + video.pause(); + } + }); + }, + { + threshold: 0.5, // حداقل 50% ویدیو باید در ویوپورت باشد + } + ); + + observer.observe(video); + + return () => { + observer.unobserve(video); + }; + }, [muted, type]); + + // اعمال وضعیت muted گلوبال هنگام mount یا تغییر muted + useEffect(() => { + if (videoRef.current) { + videoRef.current.muted = muted; + } + }, [muted]); + + return ( + <div + className={` + relative flex w-full flex-col mb-6 overflow-hidden rounded-2xl pb-2 + transition-all duration-300 + ${type === "pro" ? "shadow-[0_0px_30px_rgb(255,16,125,0.60)] p-3" : ""} + `} + > +{/* {type === "pro" && ( + <div className="absolute top-30 left-3 z-10 bg-gradient-to-r from-[#FF107D] to-[#FF6B9D] text-white text-xs font-bold px-3 py-1.5 rounded-full shadow-lg flex items-center gap-1.5 backdrop-blur-sm"> + <svg + width="14" + height="14" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M12 2L15 8.5L22 9.5L17 14L18.5 21L12 17.5L5.5 21L7 14L2 9.5L9 8.5L12 2Z" + fill="currentColor" + stroke="currentColor" + strokeWidth="1.5" + strokeLinejoin="round" + /> + </svg> + <span>نسخه پرو</span> + </div> + )} */} + <Link + href={`/academy/profile/${academyId}`} + className="flex text-sm justify-between py-2" + > + <div className="flex items-center w-full"> + <div className="relative max-w-[72px] min-w-[72px] max-h-[72px] min-h-[72px] ml-2"> + <Image + src={ + academy?.academy_image + ? buildStorageUrl(academy.academy_image) + : "/images/fake-avatar.png" + } + alt={cuorse_name} + fill + className="rounded-2xl object-cover" + priority + /> + </div> + <div className="font-semibold flex gap-2 flex-col ml-2 w-full"> + <div className="max-sm:text-xs items-center text-[#387E65] flex w-full justify-between"> + {t("academy.videoCount", { count: number_of_course_content })} + <div className="text-[#387E65] pt-1 flex gap-5 font-semibold"> + <h4>{category}</h4> + {matchedExpertise.map((item, index) => ( + <h4 key={index} className="text-[#387E65] font-semibold"> + {item} + </h4> + ))} + </div> + </div> + <div className="flex justify-between items-center"> + <span className="text-[#0090ff] text-xl font-semibold flex items-center"> + {formatCoursePriceLabel(postData, priceLang)} + {!postData.is_free && postData.offer && Number(postData.offer) > 0 ? ( + <DiscountBadge percent={Number(postData.offer)} className="mr-2" /> + ) : null} + </span> + </div> + <div className="flex items-center gap-1 mt-1"> + <h2 className="max-sm:text-xs inline-flex items-center gap-1"> + {cuorse_name} + <VerificationBadge verifyBadge={verify_badge} /> + </h2> + <ScoreRateStats score={newScore} rate={rating} /> + </div> + </div> + </div> + </Link> + + <div + onClick={() => { + const url = `/academy/${postData._id}/course`; + if (scrollKey) { + navigateWithSavedScroll(router, scrollKey, url); + } else { + router.push(url); + } + }} + className="overflow-hidden rounded-2xl" + > + <div className="w-full flex-shrink-0 flex justify-center items-center"> + <div className="w-full rounded-2xl overflow-hidden shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]"> + <Image + src={buildStorageUrl(course_image)} + alt={`media-${course_image}`} + width={1000} + height={1000} + loading={"lazy"} + className="object-contain w-full min-h-[30vh] rounded-2xl shadow-lg transition-opacity duration-300" + onLoadingComplete={() => setImageLoading?.(false)} + /> + </div> + </div> + </div> + + <MainModelCardActions postData={postData} /> + {(captionBody || captionLinks) && ( + <div className="mx-2 mt-2"> + <ExpandableCaption + body={captionBody} + links={captionLinks} + expanded={showFullCaption} + onToggle={() => setShowFullCaption((v) => !v)} + /> + </div> + )} + + <style jsx>{` + .animate-ripple { + animation: ripple 600ms ease-out; + pointer-events: none; + } + @keyframes ripple { + 0% { + transform: scale(0); + opacity: 0.5; + } + 100% { + transform: scale(4); + opacity: 0; + } + } + `}</style> + </div> + ); +} + +// Provider برای به اشتراک گذاشتن وضعیت muted +export const MutedProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [muted, setMuted] = useState(true); + + return ( + <MutedContext.Provider value={{ muted, setMuted }}> + {children} + </MutedContext.Provider> + ); +}; + +export default MainModelCard; diff --git a/src/components/auth/OtherLanguagesModal.tsx b/src/components/auth/OtherLanguagesModal.tsx index 1df11d8..8f7f36e 100644 --- a/src/components/auth/OtherLanguagesModal.tsx +++ b/src/components/auth/OtherLanguagesModal.tsx @@ -80,7 +80,7 @@ export default function OtherLanguagesModal({ type="button" dir={def.direction} onClick={() => void handleSelect(def.code as AppLanguage)} - className={`flex items-center justify-between rounded-xl border px-4 py-3 text-right transition-colors ${ + className={`flex items-center justify-between rounded-2xl border px-4 py-3 text-right transition-colors ${ active ? "border-[#0095f6] bg-[#0095f6]/10" : "border-neutral-200 dark:border-neutral-800" diff --git a/src/components/billboards/BillboardDetailContent.tsx b/src/components/billboards/BillboardDetailContent.tsx index 4b652fd..78fbb44 100644 --- a/src/components/billboards/BillboardDetailContent.tsx +++ b/src/components/billboards/BillboardDetailContent.tsx @@ -110,7 +110,7 @@ export default function BillboardDetailContent({ trackView /> - <div className="flex flex-wrap gap-4 mt-6 p-4 bg-gray-50 dark:bg-zinc-900 rounded-xl"> + <div className="flex flex-wrap gap-4 mt-6 p-4 bg-gray-50 dark:bg-zinc-900 rounded-2xl"> <div className="flex gap-1 text-gray-500"> <span>{t("billboards.provinceLabel")}</span> <span className="text-black dark:text-white"> diff --git a/src/components/billboards/BillboardPage/BillboardDetails.tsx b/src/components/billboards/BillboardPage/BillboardDetails.tsx index 9844dd0..3a94c1e 100644 --- a/src/components/billboards/BillboardPage/BillboardDetails.tsx +++ b/src/components/billboards/BillboardPage/BillboardDetails.tsx @@ -1,162 +1,162 @@ -"use client"; - -import RoundedButton from "@/components/elements/RoundedButton"; -import { IContactInfo, IFeature, Service } from "@/types/types"; -import React, { useState } from "react"; -import Rate from "rc-rate"; -import "rc-rate/assets/index.css"; -import "@/styles/rc-rate-custom.css"; -import Image from "next/image"; -import useAxios from "@/hooks/useAxios"; -import { useTranslation } from "react-i18next"; -import BillboardServicesModal from "./BillboardServicesModal"; -import BillboardFeaturesModal from "./BillboardFeaturesModal"; -import BillboardContactInfoModal from "./BillboardContactInfoModal"; -import BillboardDescriptionModal from "./BillboardDescriptionModal"; -import ShowMap from "@/components/main/ShowMap"; -import DirectionMapModal from "@/components/main/DirectionMapModal"; - -function BillboardDetails({ - services, - features, - contactInfo, - description, - lat, - lng, - _id, - noDirection, -}: { - services?: Service[]; - features?: IFeature[]; - contactInfo?: IContactInfo; - description?: string | null; - lat?: string | number | undefined; - lng?: string | number | undefined; - _id: string; - noDirection?: boolean; -}) { - const { t } = useTranslation("common"); - const { request } = useAxios(); - const [rating, setRating] = useState<number>(0); - const [showServicesModal, setShowServicesModal] = useState<boolean>(false); - const [showFeaturesModal, setShowFeaturesModal] = useState<boolean>(false); - const [showContactInfoModal, setShowContactInfoModal] = - useState<boolean>(false); - const [showDescriptionModal, setShowDescriptionModal] = - useState<boolean>(false); - const [showNavigationModal, setShowNavigationModal] = - useState<boolean>(false); - - const submitRateHandler = async () => { - try { - await request("POST", "/advertising/rate", { - rating: rating, - advertisingId: _id, - }); - } catch (error: unknown) { - console.log(error); - } - }; - - const correctedLat = Number(lng); - const correctedLng = Number(lat); - - return ( - <div className="flex flex-col mt-6"> - <div className="flex items-center justify-center gap-4"> - <Rate - value={rating} - onChange={(value) => setRating(value)} - count={5} - style={{ fontSize: "28px" }} - /> - <div onClick={submitRateHandler}> - <Image - width={24} - height={24} - alt={"submit.svg"} - src={`/images/icons/submit.svg`} - className="dark:invert" - /> - </div> - </div> - <div className="grid grid-cols-2 gap-4 mt-4"> - <RoundedButton - onClick={() => setShowServicesModal(true)} - className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full" - > - {t("billboards.details.servicesList")} - </RoundedButton> - <RoundedButton - onClick={() => setShowFeaturesModal(true)} - className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full" - > - {t("billboards.details.features")} - </RoundedButton> - <RoundedButton - onClick={() => setShowContactInfoModal(true)} - className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full" - > - {t("billboards.details.showContact")} - </RoundedButton> - <RoundedButton - onClick={() => setShowDescriptionModal(true)} - className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full" - > - {t("billboards.details.description")} - </RoundedButton> - </div> - {showServicesModal && services && ( - <BillboardServicesModal - showServicesModal={showServicesModal} - setShowServicesModal={setShowServicesModal} - services={services} - /> - )} - {showFeaturesModal && features && ( - <BillboardFeaturesModal - showFeaturesModal={showFeaturesModal} - setShowFeaturesModal={setShowFeaturesModal} - features={features} - /> - )} - {showContactInfoModal && contactInfo && ( - <BillboardContactInfoModal - showContactInfoModal={showContactInfoModal} - setShowContactInfoModal={setShowContactInfoModal} - contactInfo={contactInfo} - /> - )} - {showDescriptionModal && ( - <BillboardDescriptionModal - showDescriptionModal={showDescriptionModal} - setShowDescriptionModal={setShowDescriptionModal} - description={description} - /> - )} - {lat && lng && lat !== "undefined" && lng !== "undefined" && ( - <div className="my-5 rounded-xl overflow-hidden"> - <ShowMap lat={lat} lng={lng} height="200px" /> - </div> - )} - {showNavigationModal && ( - <DirectionMapModal - showNavigationModal={showNavigationModal} - setShowNavigationModal={setShowNavigationModal} - correctedLat={correctedLat} - correctedLng={correctedLng} - /> - )} - {!noDirection && ( - <RoundedButton - onClick={() => setShowNavigationModal(true)} - className="h-9 mx-auto px-10" - > - {t("billboards.details.directions")} - </RoundedButton> - )} - </div> - ); -} - -export default BillboardDetails; +"use client"; + +import RoundedButton from "@/components/elements/RoundedButton"; +import { IContactInfo, IFeature, Service } from "@/types/types"; +import React, { useState } from "react"; +import Rate from "rc-rate"; +import "rc-rate/assets/index.css"; +import "@/styles/rc-rate-custom.css"; +import Image from "next/image"; +import useAxios from "@/hooks/useAxios"; +import { useTranslation } from "react-i18next"; +import BillboardServicesModal from "./BillboardServicesModal"; +import BillboardFeaturesModal from "./BillboardFeaturesModal"; +import BillboardContactInfoModal from "./BillboardContactInfoModal"; +import BillboardDescriptionModal from "./BillboardDescriptionModal"; +import ShowMap from "@/components/main/ShowMap"; +import DirectionMapModal from "@/components/main/DirectionMapModal"; + +function BillboardDetails({ + services, + features, + contactInfo, + description, + lat, + lng, + _id, + noDirection, +}: { + services?: Service[]; + features?: IFeature[]; + contactInfo?: IContactInfo; + description?: string | null; + lat?: string | number | undefined; + lng?: string | number | undefined; + _id: string; + noDirection?: boolean; +}) { + const { t } = useTranslation("common"); + const { request } = useAxios(); + const [rating, setRating] = useState<number>(0); + const [showServicesModal, setShowServicesModal] = useState<boolean>(false); + const [showFeaturesModal, setShowFeaturesModal] = useState<boolean>(false); + const [showContactInfoModal, setShowContactInfoModal] = + useState<boolean>(false); + const [showDescriptionModal, setShowDescriptionModal] = + useState<boolean>(false); + const [showNavigationModal, setShowNavigationModal] = + useState<boolean>(false); + + const submitRateHandler = async () => { + try { + await request("POST", "/advertising/rate", { + rating: rating, + advertisingId: _id, + }); + } catch (error: unknown) { + console.log(error); + } + }; + + const correctedLat = Number(lng); + const correctedLng = Number(lat); + + return ( + <div className="flex flex-col mt-6"> + <div className="flex items-center justify-center gap-4"> + <Rate + value={rating} + onChange={(value) => setRating(value)} + count={5} + style={{ fontSize: "28px" }} + /> + <div onClick={submitRateHandler}> + <Image + width={24} + height={24} + alt={"submit.svg"} + src={`/images/icons/submit.svg`} + className="dark:invert" + /> + </div> + </div> + <div className="grid grid-cols-2 gap-4 mt-4"> + <RoundedButton + onClick={() => setShowServicesModal(true)} + className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full" + > + {t("billboards.details.servicesList")} + </RoundedButton> + <RoundedButton + onClick={() => setShowFeaturesModal(true)} + className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full" + > + {t("billboards.details.features")} + </RoundedButton> + <RoundedButton + onClick={() => setShowContactInfoModal(true)} + className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full" + > + {t("billboards.details.showContact")} + </RoundedButton> + <RoundedButton + onClick={() => setShowDescriptionModal(true)} + className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full" + > + {t("billboards.details.description")} + </RoundedButton> + </div> + {showServicesModal && services && ( + <BillboardServicesModal + showServicesModal={showServicesModal} + setShowServicesModal={setShowServicesModal} + services={services} + /> + )} + {showFeaturesModal && features && ( + <BillboardFeaturesModal + showFeaturesModal={showFeaturesModal} + setShowFeaturesModal={setShowFeaturesModal} + features={features} + /> + )} + {showContactInfoModal && contactInfo && ( + <BillboardContactInfoModal + showContactInfoModal={showContactInfoModal} + setShowContactInfoModal={setShowContactInfoModal} + contactInfo={contactInfo} + /> + )} + {showDescriptionModal && ( + <BillboardDescriptionModal + showDescriptionModal={showDescriptionModal} + setShowDescriptionModal={setShowDescriptionModal} + description={description} + /> + )} + {lat && lng && lat !== "undefined" && lng !== "undefined" && ( + <div className="my-5 rounded-2xl overflow-hidden"> + <ShowMap lat={lat} lng={lng} height="200px" /> + </div> + )} + {showNavigationModal && ( + <DirectionMapModal + showNavigationModal={showNavigationModal} + setShowNavigationModal={setShowNavigationModal} + correctedLat={correctedLat} + correctedLng={correctedLng} + /> + )} + {!noDirection && ( + <RoundedButton + onClick={() => setShowNavigationModal(true)} + className="h-9 mx-auto px-10" + > + {t("billboards.details.directions")} + </RoundedButton> + )} + </div> + ); +} + +export default BillboardDetails; diff --git a/src/components/billboards/BillboardPage/BillboardImageSlider.tsx b/src/components/billboards/BillboardPage/BillboardImageSlider.tsx index 669ec59..0f21644 100644 --- a/src/components/billboards/BillboardPage/BillboardImageSlider.tsx +++ b/src/components/billboards/BillboardPage/BillboardImageSlider.tsx @@ -1,43 +1,43 @@ -"use client"; - -import React from "react"; -import { Swiper, SwiperSlide } from "swiper/react"; -import "swiper/css"; -import { Pagination } from "swiper/modules"; -import Image from "next/image"; -import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; - -interface BillboardImageSliderProps { - images: string[]; -} - -const BillboardImageSlider: React.FC<BillboardImageSliderProps> = ({ - images, -}) => { - return ( - <div className="my-8"> - <Swiper - modules={[Pagination]} - navigation - spaceBetween={30} - slidesPerView={1.3} - centeredSlides={true} - className="w-full max-w-lg overflow-visible" - > - {images.map((src, index) => ( - <SwiperSlide key={index} className="flex justify-center"> - <Image - src={IMAGE_BASE_URL + src} - alt={`Slide ${index + 1}`} - width={400} - height={400} - className="rounded-xl object-cover" - /> - </SwiperSlide> - ))} - </Swiper> - </div> - ); -}; - -export default BillboardImageSlider; +"use client"; + +import React from "react"; +import { Swiper, SwiperSlide } from "swiper/react"; +import "swiper/css"; +import { Pagination } from "swiper/modules"; +import Image from "next/image"; +import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; + +interface BillboardImageSliderProps { + images: string[]; +} + +const BillboardImageSlider: React.FC<BillboardImageSliderProps> = ({ + images, +}) => { + return ( + <div className="my-8"> + <Swiper + modules={[Pagination]} + navigation + spaceBetween={30} + slidesPerView={1.3} + centeredSlides={true} + className="w-full max-w-lg overflow-visible" + > + {images.map((src, index) => ( + <SwiperSlide key={index} className="flex justify-center"> + <Image + src={IMAGE_BASE_URL + src} + alt={`Slide ${index + 1}`} + width={400} + height={400} + className="rounded-2xl object-cover" + /> + </SwiperSlide> + ))} + </Swiper> + </div> + ); +}; + +export default BillboardImageSlider; diff --git a/src/components/billboards/BillboardPage/BillboardsFilter.tsx b/src/components/billboards/BillboardPage/BillboardsFilter.tsx index b1adac2..e65d9b5 100644 --- a/src/components/billboards/BillboardPage/BillboardsFilter.tsx +++ b/src/components/billboards/BillboardPage/BillboardsFilter.tsx @@ -17,6 +17,7 @@ function BillboardsFilter() { const searchParams = useSearchParams(); const [showFilterModal, setShowFilterModal] = useState<boolean>(false); + const [showSearchBox, setShowSearchBox] = useState<boolean>(false); const [stateId, setStateId] = useState<string>(""); const [cityId, setCityId] = useState<string>(""); const [category, setCategory] = useState<string>(""); @@ -57,7 +58,9 @@ function BillboardsFilter() { } } - setSearchText(searchParams.get("search") || ""); + const initialSearch = searchParams.get("search") || ""; + setSearchText(initialSearch); + if (initialSearch) setShowSearchBox(true); setSort(searchParams.get("sort") || ""); }, [ params, @@ -115,14 +118,44 @@ function BillboardsFilter() { return ( <div> - <div className="flex items-center justify-between relative text-sm gap-3"> - <div className="w-full relative"> + <div className="flex items-center justify-end gap-3 text-sm"> + <Link href="/billboards/new" aria-label={t("billboards.createAria")}> + <BoldIcon + name="add-square" + size={25} + tinted + className="text-neutral-700 dark:text-neutral-200 min-w-[25px]" + /> + </Link> + <button onClick={() => setShowSearchBox((v) => !v)} aria-label={t("billboards.search")}> + <Image + width={25} + height={25} + alt="search icon" + src="/images/icons/search-normal.svg" + className="dark:invert min-w-[25px]" + /> + </button> + <button onClick={() => setShowFilterModal(true)} aria-label={t("billboards.filter")}> + <Image + width={25} + height={25} + alt="filter icon" + src="/images/icons/candle.svg" + className="dark:invert min-w-[25px]" + /> + </button> + </div> + + {showSearchBox && ( + <div className="relative mt-3"> <RoundedInput value={searchText} onChange={(e) => setSearchText(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleFilterChange()} placeholder={t("billboards.search")} className="w-full" + autoFocus /> <Image onClick={() => handleFilterChange()} @@ -130,29 +163,10 @@ function BillboardsFilter() { height={25} alt="search icon" src="/images/icons/search-normal.svg" - className=" min-w-[25px] absolute left-3 top-[7px] cursor-pointer" + className="min-w-[25px] absolute left-3 top-[7px] cursor-pointer" /> </div> - <div className="flex items-center gap-3"> - <button onClick={() => setShowFilterModal(true)}> - <Image - width={25} - height={25} - alt="filter icon" - src="/images/icons/candle.svg" - className="dark:invert min-w-[25px]" - /> - </button> - <Link href="/billboards/new" aria-label={t("billboards.createAria")}> - <BoldIcon - name="add-square" - size={25} - tinted - className="text-neutral-700 dark:text-neutral-200 min-w-[25px]" - /> - </Link> - </div> - </div> + )} {showFilterModal && ( <FilterModal setShowFilterModal={setShowFilterModal} diff --git a/src/components/billboards/Profile/AdProfileContent.tsx b/src/components/billboards/Profile/AdProfileContent.tsx index 7fbd9a5..8e28d44 100644 --- a/src/components/billboards/Profile/AdProfileContent.tsx +++ b/src/components/billboards/Profile/AdProfileContent.tsx @@ -10,6 +10,7 @@ import BillboardFeaturesModal from "../BillboardPage/BillboardFeaturesModal"; import BillboardContactInfoModal from "../BillboardPage/BillboardContactInfoModal"; import BillboardLocationModal from "../MainBillboardCard/BillboardLocationModal"; import { useTranslation } from "react-i18next"; +import { localizedGeoName } from "@/utils/geoName"; function AdProfileContent({ profile }: { profile: IAdvertisingProfile }) { const { t } = useTranslation("common"); @@ -24,7 +25,7 @@ function AdProfileContent({ profile }: { profile: IAdvertisingProfile }) { <div className="px-4 py-2 w-full text-xs md:text-sm font-semibold"> <div className="grid grid-cols-3 gap-1 md:gap-4"> <RoundedDiv className="text-[12px] md:text-sm h-7 md:h-8"> - {profile?.city?.name ? profile?.city?.name : t("billboards.city")} + {profile?.city ? localizedGeoName(profile.city) : t("billboards.city")} </RoundedDiv> <RoundedDiv className="text-[12px] md:text-sm h-7 md:h-8"> {profile?.neighbourhood ? profile?.neighbourhood : t("billboards.neighbourhood")} diff --git a/src/components/billboards/Profile/AdProfileContentPosts.tsx b/src/components/billboards/Profile/AdProfileContentPosts.tsx index 985fe30..6e83d22 100644 --- a/src/components/billboards/Profile/AdProfileContentPosts.tsx +++ b/src/components/billboards/Profile/AdProfileContentPosts.tsx @@ -1,79 +1,79 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -"use client"; - -import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; -import React, { useState } from "react"; -import Modal from "react-modal"; -import { Swiper, SwiperSlide } from "swiper/react"; -import "swiper/css"; -import "swiper/css/navigation"; -import { Navigation } from "swiper/modules"; - -Modal.setAppElement("body"); - -function AdProfileContentPosts({ allImages }: { allImages: string[] }) { - const [modalIsOpen, setModalIsOpen] = useState(false); - const [selectedImageIndex, setSelectedImageIndex] = useState(0); - const images = allImages?.map((post: any) => post); - - const openModal = (index: number) => { - setSelectedImageIndex(index); - setModalIsOpen(true); - }; - - const closeModal = () => { - setModalIsOpen(false); - }; - - return ( - <div className="grid grid-cols-3 overflow-y-auto max-h-[500px] my-3 gap-2 no-scrollbar"> - {allImages.map((item: string, index: number) => ( - <div - key={index} - className="relative aspect-square w-full rounded-xl cursor-pointer overflow-hidden" - onClick={() => openModal(index)} - > - <img - alt="post" - src={`${IMAGE_BASE_URL}${item}`} - className="absolute inset-0 w-full h-full object-cover" - /> - </div> -))} - - - <Modal - isOpen={modalIsOpen} - onRequestClose={closeModal} - overlayClassName="glass-modal-overlay glass-modal-overlay--dark fixed inset-0 z-[9999]" - className="fixed inset-0 flex items-center justify-center px-4 outline-none" - > - <div className="w-full max-w-2xl"> - <Swiper - navigation - modules={[Navigation]} - initialSlide={selectedImageIndex} - > - {images.map((image, index) => ( - <SwiperSlide key={index}> - <img - className="w-full h-auto rounded-xl" - src={`${IMAGE_BASE_URL}${image}`} - alt="Selected post" - /> - </SwiperSlide> - ))} - </Swiper> - <button - className="absolute top-5 right-5 text-white mt-20 text-3xl" - onClick={closeModal} - > - × - </button> - </div> - </Modal> - </div> - ); -} - -export default AdProfileContentPosts; +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; +import React, { useState } from "react"; +import Modal from "react-modal"; +import { Swiper, SwiperSlide } from "swiper/react"; +import "swiper/css"; +import "swiper/css/navigation"; +import { Navigation } from "swiper/modules"; + +Modal.setAppElement("body"); + +function AdProfileContentPosts({ allImages }: { allImages: string[] }) { + const [modalIsOpen, setModalIsOpen] = useState(false); + const [selectedImageIndex, setSelectedImageIndex] = useState(0); + const images = allImages?.map((post: any) => post); + + const openModal = (index: number) => { + setSelectedImageIndex(index); + setModalIsOpen(true); + }; + + const closeModal = () => { + setModalIsOpen(false); + }; + + return ( + <div className="grid grid-cols-3 overflow-y-auto max-h-[500px] my-3 gap-2 no-scrollbar"> + {allImages.map((item: string, index: number) => ( + <div + key={index} + className="relative aspect-square w-full rounded-2xl cursor-pointer overflow-hidden" + onClick={() => openModal(index)} + > + <img + alt="post" + src={`${IMAGE_BASE_URL}${item}`} + className="absolute inset-0 w-full h-full object-cover" + /> + </div> +))} + + + <Modal + isOpen={modalIsOpen} + onRequestClose={closeModal} + overlayClassName="glass-modal-overlay glass-modal-overlay--dark fixed inset-0 z-[9999]" + className="fixed inset-0 flex items-center justify-center px-4 outline-none" + > + <div className="w-full max-w-2xl"> + <Swiper + navigation + modules={[Navigation]} + initialSlide={selectedImageIndex} + > + {images.map((image, index) => ( + <SwiperSlide key={index}> + <img + className="w-full h-auto rounded-2xl" + src={`${IMAGE_BASE_URL}${image}`} + alt="Selected post" + /> + </SwiperSlide> + ))} + </Swiper> + <button + className="absolute top-5 right-5 text-white mt-20 text-3xl" + onClick={closeModal} + > + × + </button> + </div> + </Modal> + </div> + ); +} + +export default AdProfileContentPosts; diff --git a/src/components/billboards/Profile/AdProfileHead.tsx b/src/components/billboards/Profile/AdProfileHead.tsx index b616c49..fbfa6fd 100644 --- a/src/components/billboards/Profile/AdProfileHead.tsx +++ b/src/components/billboards/Profile/AdProfileHead.tsx @@ -1,115 +1,115 @@ -"use client"; - -import ProfileAvatar from "@/components/main/ProfileAvatar"; -import { IAdvertisingProfile } from "@/types/types"; -import Image from "next/image"; -import React, { useState } from "react"; -import AdProfileHeadRowTwo from "@/components/billboards/Profile/AdProfileHeadRowTwo"; -import { useTranslation } from "react-i18next"; - -interface AdProfileHeadProps { - profile: IAdvertisingProfile; - id: string | null; -} - -function AdProfileHead({ profile, id }: AdProfileHeadProps) { - const { t } = useTranslation("common"); - const { profile_image, is_self, adTotalRatings, neighbourhood, adAverageRating, about_us } = - profile; - - const [expanded, setExpanded] = useState(false); - const maxLength = 100; - - const toggleExpanded = () => setExpanded(!expanded); - - const statsBlock = ( - <div className="flex gap-5 mt-2"> - <div className="font-semibold flex flex-col items-center"> - <span>{profile?.allImages?.length}</span> - <span>{t("billboards.profile.images")}</span> - </div> - <div className="font-semibold flex flex-col items-center text-[#0C8002]"> - <span>{profile?.totalLikesCount}</span> - <span>{t("billboards.profile.likes")}</span> - </div> - <div className="font-semibold flex flex-col items-center text-[#3A59A9]"> - <span>{profile?.totalCommentsCount}</span> - <span>{t("billboards.profile.comments")}</span> - </div> - </div> - ); - - const avatarBlock = profile_image ? ( - <ProfileAvatar - src={profile_image} - alt={profile?.vitrine_name || "Name"} - size="md" - rounded="xl" - className="md:h-[120px] md:w-[120px]" - /> - ) : ( - <div className="border dark:border-border-primary-dark rounded-3xl p-2"> - <Image - className="rounded-xl object-cover h-[65px] w-[65px] md:h-[120px] md:w-[120px] invert dark:invert-0" - width={100} - height={100} - alt={profile?.vitrine_name || "Name"} - src={`/images/icons/select-img.svg`} - /> - </div> - ); - - if (!about_us) - return ( - <div className="w-full"> - <div className="flex w-full items-center justify-between px-4 py-2"> - <div className="flex flex-col text-xs md:text-sm "> - <h1 className="text-xl font-bold ">{neighbourhood}</h1> - {statsBlock} - </div> - {avatarBlock} - </div> - <AdProfileHeadRowTwo - is_self={is_self} - adTotalRatings={adTotalRatings} - adAverageRating={adAverageRating} - id={id} - /> - </div> - ); - - return ( - <div className="w-full"> - <div className="flex w-full items-center justify-between px-4 py-2"> - <div className="flex flex-col text-xs md:text-sm "> - <h1 className="text-xl font-bold ">{neighbourhood}</h1> - {statsBlock} - </div> - {avatarBlock} - </div> - <AdProfileHeadRowTwo - is_self={is_self} - adTotalRatings={adTotalRatings} - adAverageRating={adAverageRating} - id={id} - /> - <div className="text-sm leading-relaxed mx-4"> - {about_us.length <= maxLength ? ( - about_us - ) : ( - <> - {expanded ? about_us : about_us.slice(0, maxLength) + "..."} - <button - onClick={toggleExpanded} - className="text-blue-500 hover:underline ml-2" - > - {expanded ? t("billboards.profile.less") : t("billboards.profile.more")} - </button> - </> - )} - </div> - </div> - ); -} - -export default AdProfileHead; +"use client"; + +import ProfileAvatar from "@/components/main/ProfileAvatar"; +import { IAdvertisingProfile } from "@/types/types"; +import Image from "next/image"; +import React, { useState } from "react"; +import AdProfileHeadRowTwo from "@/components/billboards/Profile/AdProfileHeadRowTwo"; +import { useTranslation } from "react-i18next"; + +interface AdProfileHeadProps { + profile: IAdvertisingProfile; + id: string | null; +} + +function AdProfileHead({ profile, id }: AdProfileHeadProps) { + const { t } = useTranslation("common"); + const { profile_image, is_self, adTotalRatings, neighbourhood, adAverageRating, about_us } = + profile; + + const [expanded, setExpanded] = useState(false); + const maxLength = 100; + + const toggleExpanded = () => setExpanded(!expanded); + + const statsBlock = ( + <div className="flex gap-5 mt-2"> + <div className="font-semibold flex flex-col items-center"> + <span>{profile?.allImages?.length}</span> + <span>{t("billboards.profile.images")}</span> + </div> + <div className="font-semibold flex flex-col items-center text-[#0C8002]"> + <span>{profile?.totalLikesCount}</span> + <span>{t("billboards.profile.likes")}</span> + </div> + <div className="font-semibold flex flex-col items-center text-[#3A59A9]"> + <span>{profile?.totalCommentsCount}</span> + <span>{t("billboards.profile.comments")}</span> + </div> + </div> + ); + + const avatarBlock = profile_image ? ( + <ProfileAvatar + src={profile_image} + alt={profile?.vitrine_name || "Name"} + size="md" + rounded="xl" + className="md:h-[120px] md:w-[120px]" + /> + ) : ( + <div className="border dark:border-border-primary-dark rounded-3xl p-2"> + <Image + className="rounded-2xl object-cover h-[65px] w-[65px] md:h-[120px] md:w-[120px] invert dark:invert-0" + width={100} + height={100} + alt={profile?.vitrine_name || "Name"} + src={`/images/icons/select-img.svg`} + /> + </div> + ); + + if (!about_us) + return ( + <div className="w-full"> + <div className="flex w-full items-center justify-between px-4 py-2"> + <div className="flex flex-col text-xs md:text-sm "> + <h1 className="text-xl font-bold ">{neighbourhood}</h1> + {statsBlock} + </div> + {avatarBlock} + </div> + <AdProfileHeadRowTwo + is_self={is_self} + adTotalRatings={adTotalRatings} + adAverageRating={adAverageRating} + id={id} + /> + </div> + ); + + return ( + <div className="w-full"> + <div className="flex w-full items-center justify-between px-4 py-2"> + <div className="flex flex-col text-xs md:text-sm "> + <h1 className="text-xl font-bold ">{neighbourhood}</h1> + {statsBlock} + </div> + {avatarBlock} + </div> + <AdProfileHeadRowTwo + is_self={is_self} + adTotalRatings={adTotalRatings} + adAverageRating={adAverageRating} + id={id} + /> + <div className="text-sm leading-relaxed mx-4"> + {about_us.length <= maxLength ? ( + about_us + ) : ( + <> + {expanded ? about_us : about_us.slice(0, maxLength) + "..."} + <button + onClick={toggleExpanded} + className="text-blue-500 hover:underline ml-2" + > + {expanded ? t("billboards.profile.less") : t("billboards.profile.more")} + </button> + </> + )} + </div> + </div> + ); +} + +export default AdProfileHead; diff --git a/src/components/chart-area-interactive.tsx b/src/components/chart-area-interactive.tsx index 0850e69..60b94a3 100644 --- a/src/components/chart-area-interactive.tsx +++ b/src/components/chart-area-interactive.tsx @@ -1,451 +1,451 @@ -"use client"; - -import * as React from "react"; -import { useState, useEffect } from "react"; -import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"; - -import { useIsMobile } from "@/hooks/use-mobile"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import useAxios from "@/hooks/useAxios"; -import toast from "react-hot-toast"; -import { useTranslation } from "react-i18next"; - -interface Payment { - _id: string; - price: number; - course_name: string; - course_id: any; - academy_id: string; - status: string; // "success" = پرداخت نشده, "settled" = پرداخت شده - createdAt: string; - updatedAt: string; - payment_authority: string; - payment_ref_id: string; - discountAmount: number; - taxAmount: number; - taxRate: number; - user_id: any; -} - -interface ChartDataItem { - date: string; - amount: number; - count: number; - formattedDate: string; - persianDate: string; - jalaliDate: string; -} - -const chartConfig = { - amount: { - label: "amount", - color: "#ff107d", - }, - count: { - label: "count", - color: "#10b981", - }, -} satisfies ChartConfig; - -// تبدیل تاریخ میلادی به شمسی -const toJalaliDate = (date: Date): string => { - return new Intl.DateTimeFormat("fa-IR", { - year: "numeric", - month: "numeric", - day: "numeric", - }).format(date); -}; - -// نمایش ساده برای محور X -const getJalaliDayMonth = (date: Date): string => { - return new Intl.DateTimeFormat("fa-IR", { - month: "numeric", - day: "numeric", - }).format(date); -}; - -// تابع برای تولید تمام روزهای بازه زمانی -const generateDateRange = (startDate: Date, endDate: Date): string[] => { - const dates: string[] = []; - const currentDate = new Date(startDate); - - while (currentDate <= endDate) { - dates.push(currentDate.toISOString().split("T")[0]); - currentDate.setDate(currentDate.getDate() + 1); - } - - return dates; -}; - -export function ChartAreaInteractive() { - const { t } = useTranslation("common"); - const isMobile = useIsMobile(); - const [timeRange, setTimeRange] = useState("90d"); - const [isLoading, setIsLoading] = useState(false); - const [chartData, setChartData] = useState<ChartDataItem[]>([]); - const [totalSales, setTotalSales] = useState(0); // مجموع فروش‌های موفق (تسویه نشده) - const [totalCount, setTotalCount] = useState(0); - const [averageAmount, setAverageAmount] = useState(0); - const [settledAmount, setSettledAmount] = useState(0); // مبلغ تسویه شده (پرداخت شده) - - const { request } = useAxios(); - - useEffect(() => { - if (isMobile) { - setTimeRange("30d"); - } - }, [isMobile]); - - // محاسبه تاریخ شروع بر اساس بازه زمانی - const getStartDate = (range: string, endDate: Date): Date => { - const start = new Date(endDate); - switch (range) { - case "7d": - start.setDate(start.getDate() - 6); - break; - case "30d": - start.setDate(start.getDate() - 29); - break; - case "90d": - start.setDate(start.getDate() - 89); - break; - default: - start.setDate(start.getDate() - 89); - } - start.setHours(0, 0, 0, 0); - return start; - }; - - // پردازش داده‌ها و تکمیل روزهای بدون فروش - const processPaymentsData = (payments: Payment[], range: string) => { - const now = new Date(); - now.setHours(23, 59, 59, 999); - const startDate = getStartDate(range, now); - - // گروه‌بندی پرداخت‌های موجود بر اساس تاریخ - const groupedByDate: { [key: string]: { amount: number; count: number } } = - {}; - let settledTotal = 0; // مجموع مبالغ تسویه شده (پرداخت شده) - - payments.forEach((payment) => { - const date = new Date(payment.createdAt); - const dateKey = date.toISOString().split("T")[0]; - - // مبلغ: price یا taxAmount - const amount = payment.taxAmount || 0; - - // محاسبه مبالغ تسویه شده (status = settled) - if (payment.status === "settled") { - settledTotal += amount; - } - - // فقط پرداخت‌های داخل بازه زمانی را برای چارت در نظر بگیر - // برای چارت، فقط فروش‌های موفق (success) را نشان می‌دهیم - if (payment.status === "success" && date >= startDate && date <= now) { - if (!groupedByDate[dateKey]) { - groupedByDate[dateKey] = { amount: 0, count: 0 }; - } - groupedByDate[dateKey].amount += amount; - groupedByDate[dateKey].count += 1; - } - if (payment.status === "settled" && date >= startDate && date <= now) { - if (!groupedByDate[dateKey]) { - groupedByDate[dateKey] = { amount: 0, count: 0 }; - } - groupedByDate[dateKey].amount += amount; - groupedByDate[dateKey].count += 1; - } - }); - - setSettledAmount(settledTotal); - - // تولید تمام روزهای بازه زمانی - const allDates = generateDateRange(startDate, now); - - // ساخت آرایه نهایی با تمام روزها (روزهای بدون فروش مقدار صفر دارند) - const result: ChartDataItem[] = allDates.map((date) => { - const existing = groupedByDate[date]; - const currentDate = new Date(date); - - return { - date: date, - amount: existing?.amount || 0, - count: existing?.count || 0, - formattedDate: currentDate.toLocaleDateString("fa-IR", { - month: "numeric", - day: "numeric", - }), - persianDate: toJalaliDate(currentDate), - jalaliDate: getJalaliDayMonth(currentDate), - }; - }); - - // محاسبه مجموع کل فروش در بازه زمانی (فقط success ها) - const total = result.reduce((sum, item) => sum + item.amount, 0); - const count = result.reduce((sum, item) => sum + item.count, 0); - const avg = count > 0 ? Math.round(total / count) : 0; - - setTotalSales(total); - setTotalCount(count); - setAverageAmount(avg); - - return result; - }; - - const fetchCoursesPayment = async () => { - setIsLoading(true); - try { - // دریافت تمام پرداخت‌ها (هم success و هم settled) - const response = await request( - "GET", - `/academy/course/getAllAcademyPayments` - ); - - console.log("تمامی پرداخت‌ها:", response?.data?.payments); - - if (response?.data?.payments && Array.isArray(response.data.payments)) { - const processedData = processPaymentsData( - response.data.payments, - timeRange - ); - setChartData(processedData); - } else { - // اگر داده‌ای نبود، یک بازه خالی با تمام روزها و مقدار صفر ایجاد کن - const now = new Date(); - const startDate = getStartDate(timeRange, now); - const allDates = generateDateRange(startDate, now); - const emptyData: ChartDataItem[] = allDates.map((date) => ({ - date: date, - amount: 0, - count: 0, - formattedDate: new Date(date).toLocaleDateString("fa-IR", { - month: "numeric", - day: "numeric", - }), - persianDate: toJalaliDate(new Date(date)), - jalaliDate: getJalaliDayMonth(new Date(date)), - })); - setChartData(emptyData); - setTotalSales(0); - setTotalCount(0); - setAverageAmount(0); - setSettledAmount(0); - } - } catch (err) { - console.log("get error:", err); - toast.error(t("settings.academy.chart.fetchError")); - setChartData([]); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - fetchCoursesPayment(); - }, []); - - useEffect(() => { - if (chartData.length > 0) { - fetchCoursesPayment(); - } - }, [timeRange]); - - if (isLoading) { - return ( - <Card className="@container/card"> - <CardHeader> - <CardTitle>{t("settings.academy.chart.salesAmount")}</CardTitle> - <CardDescription>{t("settings.academy.chart.loadingData")}</CardDescription> - </CardHeader> - <CardContent className="flex justify-center items-center h-[250px]"> - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-500"></div> - </CardContent> - </Card> - ); - } - - // فرمتر محور X با تاریخ شمسی - const formatXAxis = (value: string) => { - const date = new Date(value); - return getJalaliDayMonth(date); - }; - - return ( - <Card className="@container/card"> - <CardHeader> - <div className="flex justify-between items-start flex-wrap gap-4"> - <div> - <CardTitle className="text-xl font-bold"> - {t("settings.academy.chart.salesStats")} - </CardTitle> - </div> - <CardAction> - <ToggleGroup - type="single" - value={timeRange} - onValueChange={setTimeRange} - variant="outline" - className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex" - > - <ToggleGroupItem value="90d">{t("settings.academy.chart.days90")}</ToggleGroupItem> - <ToggleGroupItem value="30d">{t("settings.academy.chart.days30")}</ToggleGroupItem> - <ToggleGroupItem value="7d">{t("settings.academy.chart.days7")}</ToggleGroupItem> - </ToggleGroup> - <Select value={timeRange} onValueChange={setTimeRange}> - <SelectTrigger - className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden" - size="sm" - aria-label="Select a value" - > - <SelectValue placeholder={t("settings.academy.chart.selectTimeRange")} /> - </SelectTrigger> - <SelectContent className="rounded-xl"> - <SelectItem value="90d" className="rounded-lg"> - {t("settings.academy.chart.days90")} - </SelectItem> - <SelectItem value="30d" className="rounded-lg"> - {t("settings.academy.chart.days30")} - </SelectItem> - <SelectItem value="7d" className="rounded-lg"> - {t("settings.academy.chart.days7")} - </SelectItem> - </SelectContent> - </Select> - </CardAction> - </div> - </CardHeader> - <CardContent className="px-2 pt-4 sm:px-6 sm:pt-6"> - {chartData.length === 0 ? ( - <div className="flex justify-center items-center h-[250px] text-muted-foreground"> - <div className="text-center"> - <svg - className="w-16 h-16 mx-auto mb-4 text-gray-400" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={1.5} - d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" - /> - </svg> - <p>{t("settings.academy.chart.noData")}</p> - <p className="text-sm mt-2">{t("settings.academy.chart.noSalesYet")}</p> - </div> - </div> - ) : ( - <ChartContainer - config={chartConfig} - className="aspect-auto h-[250px] w-full" - > - <AreaChart data={chartData}> - <defs> - <linearGradient id="fillAmount" x1="0" y1="0" x2="0" y2="1"> - <stop - offset="5%" - stopColor="var(--color-amount)" - stopOpacity={1.0} - /> - <stop - offset="95%" - stopColor="var(--color-amount)" - stopOpacity={0.1} - /> - </linearGradient> - <linearGradient id="fillCount" x1="0" y1="0" x2="0" y2="1"> - <stop - offset="5%" - stopColor="var(--color-count)" - stopOpacity={0.8} - /> - <stop - offset="95%" - stopColor="var(--color-count)" - stopOpacity={0.1} - /> - </linearGradient> - </defs> - <CartesianGrid vertical={false} /> - <XAxis - dataKey="date" - tickLine={false} - axisLine={false} - tickMargin={8} - minTickGap={timeRange === "7d" ? 40 : 60} - tickFormatter={formatXAxis} - interval={ - timeRange === "7d" ? 0 : Math.floor(chartData.length / 10) - } - /> - <ChartTooltip - cursor={false} - content={ - <ChartTooltipContent - labelFormatter={(value) => { - const date = new Date(value); - return toJalaliDate(date); - }} - formatter={(value, name) => { - if (name === "amount") { - return [ - `${Number(value).toLocaleString("fa-IR")}`, - t("settings.academy.chart.salesAmountLabel"), - ]; - } - if (name === "count") { - return [`${value}`, t("settings.academy.chart.salesCountLabel")]; - } - return [value, name]; - }} - indicator="dot" - /> - } - /> - <Area - dataKey="count" - type="monotone" - fill="url(#fillCount)" - stroke="var(--color-count)" - strokeWidth={2} - name="count" - isAnimationActive={false} - /> - <Area - dataKey="amount" - type="monotone" - fill="url(#fillAmount)" - stroke="var(--color-amount)" - strokeWidth={2} - name="amount" - isAnimationActive={false} - /> - </AreaChart> - </ChartContainer> - )} - </CardContent> - </Card> - ); -} +"use client"; + +import * as React from "react"; +import { useState, useEffect } from "react"; +import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"; + +import { useIsMobile } from "@/hooks/use-mobile"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import useAxios from "@/hooks/useAxios"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; + +interface Payment { + _id: string; + price: number; + course_name: string; + course_id: any; + academy_id: string; + status: string; // "success" = پرداخت نشده, "settled" = پرداخت شده + createdAt: string; + updatedAt: string; + payment_authority: string; + payment_ref_id: string; + discountAmount: number; + taxAmount: number; + taxRate: number; + user_id: any; +} + +interface ChartDataItem { + date: string; + amount: number; + count: number; + formattedDate: string; + persianDate: string; + jalaliDate: string; +} + +const chartConfig = { + amount: { + label: "amount", + color: "#ff107d", + }, + count: { + label: "count", + color: "#10b981", + }, +} satisfies ChartConfig; + +// تبدیل تاریخ میلادی به شمسی +const toJalaliDate = (date: Date): string => { + return new Intl.DateTimeFormat("fa-IR", { + year: "numeric", + month: "numeric", + day: "numeric", + }).format(date); +}; + +// نمایش ساده برای محور X +const getJalaliDayMonth = (date: Date): string => { + return new Intl.DateTimeFormat("fa-IR", { + month: "numeric", + day: "numeric", + }).format(date); +}; + +// تابع برای تولید تمام روزهای بازه زمانی +const generateDateRange = (startDate: Date, endDate: Date): string[] => { + const dates: string[] = []; + const currentDate = new Date(startDate); + + while (currentDate <= endDate) { + dates.push(currentDate.toISOString().split("T")[0]); + currentDate.setDate(currentDate.getDate() + 1); + } + + return dates; +}; + +export function ChartAreaInteractive() { + const { t } = useTranslation("common"); + const isMobile = useIsMobile(); + const [timeRange, setTimeRange] = useState("90d"); + const [isLoading, setIsLoading] = useState(false); + const [chartData, setChartData] = useState<ChartDataItem[]>([]); + const [totalSales, setTotalSales] = useState(0); // مجموع فروش‌های موفق (تسویه نشده) + const [totalCount, setTotalCount] = useState(0); + const [averageAmount, setAverageAmount] = useState(0); + const [settledAmount, setSettledAmount] = useState(0); // مبلغ تسویه شده (پرداخت شده) + + const { request } = useAxios(); + + useEffect(() => { + if (isMobile) { + setTimeRange("30d"); + } + }, [isMobile]); + + // محاسبه تاریخ شروع بر اساس بازه زمانی + const getStartDate = (range: string, endDate: Date): Date => { + const start = new Date(endDate); + switch (range) { + case "7d": + start.setDate(start.getDate() - 6); + break; + case "30d": + start.setDate(start.getDate() - 29); + break; + case "90d": + start.setDate(start.getDate() - 89); + break; + default: + start.setDate(start.getDate() - 89); + } + start.setHours(0, 0, 0, 0); + return start; + }; + + // پردازش داده‌ها و تکمیل روزهای بدون فروش + const processPaymentsData = (payments: Payment[], range: string) => { + const now = new Date(); + now.setHours(23, 59, 59, 999); + const startDate = getStartDate(range, now); + + // گروه‌بندی پرداخت‌های موجود بر اساس تاریخ + const groupedByDate: { [key: string]: { amount: number; count: number } } = + {}; + let settledTotal = 0; // مجموع مبالغ تسویه شده (پرداخت شده) + + payments.forEach((payment) => { + const date = new Date(payment.createdAt); + const dateKey = date.toISOString().split("T")[0]; + + // مبلغ: price یا taxAmount + const amount = payment.taxAmount || 0; + + // محاسبه مبالغ تسویه شده (status = settled) + if (payment.status === "settled") { + settledTotal += amount; + } + + // فقط پرداخت‌های داخل بازه زمانی را برای چارت در نظر بگیر + // برای چارت، فقط فروش‌های موفق (success) را نشان می‌دهیم + if (payment.status === "success" && date >= startDate && date <= now) { + if (!groupedByDate[dateKey]) { + groupedByDate[dateKey] = { amount: 0, count: 0 }; + } + groupedByDate[dateKey].amount += amount; + groupedByDate[dateKey].count += 1; + } + if (payment.status === "settled" && date >= startDate && date <= now) { + if (!groupedByDate[dateKey]) { + groupedByDate[dateKey] = { amount: 0, count: 0 }; + } + groupedByDate[dateKey].amount += amount; + groupedByDate[dateKey].count += 1; + } + }); + + setSettledAmount(settledTotal); + + // تولید تمام روزهای بازه زمانی + const allDates = generateDateRange(startDate, now); + + // ساخت آرایه نهایی با تمام روزها (روزهای بدون فروش مقدار صفر دارند) + const result: ChartDataItem[] = allDates.map((date) => { + const existing = groupedByDate[date]; + const currentDate = new Date(date); + + return { + date: date, + amount: existing?.amount || 0, + count: existing?.count || 0, + formattedDate: currentDate.toLocaleDateString("fa-IR", { + month: "numeric", + day: "numeric", + }), + persianDate: toJalaliDate(currentDate), + jalaliDate: getJalaliDayMonth(currentDate), + }; + }); + + // محاسبه مجموع کل فروش در بازه زمانی (فقط success ها) + const total = result.reduce((sum, item) => sum + item.amount, 0); + const count = result.reduce((sum, item) => sum + item.count, 0); + const avg = count > 0 ? Math.round(total / count) : 0; + + setTotalSales(total); + setTotalCount(count); + setAverageAmount(avg); + + return result; + }; + + const fetchCoursesPayment = async () => { + setIsLoading(true); + try { + // دریافت تمام پرداخت‌ها (هم success و هم settled) + const response = await request( + "GET", + `/academy/course/getAllAcademyPayments` + ); + + console.log("تمامی پرداخت‌ها:", response?.data?.payments); + + if (response?.data?.payments && Array.isArray(response.data.payments)) { + const processedData = processPaymentsData( + response.data.payments, + timeRange + ); + setChartData(processedData); + } else { + // اگر داده‌ای نبود، یک بازه خالی با تمام روزها و مقدار صفر ایجاد کن + const now = new Date(); + const startDate = getStartDate(timeRange, now); + const allDates = generateDateRange(startDate, now); + const emptyData: ChartDataItem[] = allDates.map((date) => ({ + date: date, + amount: 0, + count: 0, + formattedDate: new Date(date).toLocaleDateString("fa-IR", { + month: "numeric", + day: "numeric", + }), + persianDate: toJalaliDate(new Date(date)), + jalaliDate: getJalaliDayMonth(new Date(date)), + })); + setChartData(emptyData); + setTotalSales(0); + setTotalCount(0); + setAverageAmount(0); + setSettledAmount(0); + } + } catch (err) { + console.log("get error:", err); + toast.error(t("settings.academy.chart.fetchError")); + setChartData([]); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchCoursesPayment(); + }, []); + + useEffect(() => { + if (chartData.length > 0) { + fetchCoursesPayment(); + } + }, [timeRange]); + + if (isLoading) { + return ( + <Card className="@container/card"> + <CardHeader> + <CardTitle>{t("settings.academy.chart.salesAmount")}</CardTitle> + <CardDescription>{t("settings.academy.chart.loadingData")}</CardDescription> + </CardHeader> + <CardContent className="flex justify-center items-center h-[250px]"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-500"></div> + </CardContent> + </Card> + ); + } + + // فرمتر محور X با تاریخ شمسی + const formatXAxis = (value: string) => { + const date = new Date(value); + return getJalaliDayMonth(date); + }; + + return ( + <Card className="@container/card"> + <CardHeader> + <div className="flex justify-between items-start flex-wrap gap-4"> + <div> + <CardTitle className="text-xl font-bold"> + {t("settings.academy.chart.salesStats")} + </CardTitle> + </div> + <CardAction> + <ToggleGroup + type="single" + value={timeRange} + onValueChange={setTimeRange} + variant="outline" + className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex" + > + <ToggleGroupItem value="90d">{t("settings.academy.chart.days90")}</ToggleGroupItem> + <ToggleGroupItem value="30d">{t("settings.academy.chart.days30")}</ToggleGroupItem> + <ToggleGroupItem value="7d">{t("settings.academy.chart.days7")}</ToggleGroupItem> + </ToggleGroup> + <Select value={timeRange} onValueChange={setTimeRange}> + <SelectTrigger + className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden" + size="sm" + aria-label="Select a value" + > + <SelectValue placeholder={t("settings.academy.chart.selectTimeRange")} /> + </SelectTrigger> + <SelectContent className="rounded-2xl"> + <SelectItem value="90d" className="rounded-lg"> + {t("settings.academy.chart.days90")} + </SelectItem> + <SelectItem value="30d" className="rounded-lg"> + {t("settings.academy.chart.days30")} + </SelectItem> + <SelectItem value="7d" className="rounded-lg"> + {t("settings.academy.chart.days7")} + </SelectItem> + </SelectContent> + </Select> + </CardAction> + </div> + </CardHeader> + <CardContent className="px-2 pt-4 sm:px-6 sm:pt-6"> + {chartData.length === 0 ? ( + <div className="flex justify-center items-center h-[250px] text-muted-foreground"> + <div className="text-center"> + <svg + className="w-16 h-16 mx-auto mb-4 text-gray-400" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={1.5} + d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" + /> + </svg> + <p>{t("settings.academy.chart.noData")}</p> + <p className="text-sm mt-2">{t("settings.academy.chart.noSalesYet")}</p> + </div> + </div> + ) : ( + <ChartContainer + config={chartConfig} + className="aspect-auto h-[250px] w-full" + > + <AreaChart data={chartData}> + <defs> + <linearGradient id="fillAmount" x1="0" y1="0" x2="0" y2="1"> + <stop + offset="5%" + stopColor="var(--color-amount)" + stopOpacity={1.0} + /> + <stop + offset="95%" + stopColor="var(--color-amount)" + stopOpacity={0.1} + /> + </linearGradient> + <linearGradient id="fillCount" x1="0" y1="0" x2="0" y2="1"> + <stop + offset="5%" + stopColor="var(--color-count)" + stopOpacity={0.8} + /> + <stop + offset="95%" + stopColor="var(--color-count)" + stopOpacity={0.1} + /> + </linearGradient> + </defs> + <CartesianGrid vertical={false} /> + <XAxis + dataKey="date" + tickLine={false} + axisLine={false} + tickMargin={8} + minTickGap={timeRange === "7d" ? 40 : 60} + tickFormatter={formatXAxis} + interval={ + timeRange === "7d" ? 0 : Math.floor(chartData.length / 10) + } + /> + <ChartTooltip + cursor={false} + content={ + <ChartTooltipContent + labelFormatter={(value) => { + const date = new Date(value); + return toJalaliDate(date); + }} + formatter={(value, name) => { + if (name === "amount") { + return [ + `${Number(value).toLocaleString("fa-IR")}`, + t("settings.academy.chart.salesAmountLabel"), + ]; + } + if (name === "count") { + return [`${value}`, t("settings.academy.chart.salesCountLabel")]; + } + return [value, name]; + }} + indicator="dot" + /> + } + /> + <Area + dataKey="count" + type="monotone" + fill="url(#fillCount)" + stroke="var(--color-count)" + strokeWidth={2} + name="count" + isAnimationActive={false} + /> + <Area + dataKey="amount" + type="monotone" + fill="url(#fillAmount)" + stroke="var(--color-amount)" + strokeWidth={2} + name="amount" + isAnimationActive={false} + /> + </AreaChart> + </ChartContainer> + )} + </CardContent> + </Card> + ); +} diff --git a/src/components/chat/AttachmentMenu.tsx b/src/components/chat/AttachmentMenu.tsx index 3081c4b..2a02494 100644 --- a/src/components/chat/AttachmentMenu.tsx +++ b/src/components/chat/AttachmentMenu.tsx @@ -1,91 +1,91 @@ -"use client"; - -import { useEffect, useRef } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import ChatBoldIcon, { type ChatBoldIconName } from "./ChatBoldIcon"; -import { useTranslation } from "react-i18next"; - -export type AttachmentType = "image" | "video" | "file" | "location" | "camera"; - -interface AttachmentMenuProps { - open: boolean; - onClose: () => void; - onSelect: (type: AttachmentType) => void; - anchorRef?: React.RefObject<HTMLElement | null>; -} - -const attachmentConfig: { - type: AttachmentType; - labelKey: string; - icon: ChatBoldIconName; - color: string; -}[] = [ - { type: "camera", labelKey: "chats.attachment.camera", icon: "camera", color: "bg-red-500" }, - { type: "image", labelKey: "chats.attachment.gallery", icon: "gallery", color: "bg-purple-500" }, - { type: "video", labelKey: "chats.attachment.video", icon: "video", color: "bg-blue-500" }, - { type: "file", labelKey: "chats.attachment.file", icon: "file", color: "bg-orange-500" }, - { type: "location", labelKey: "chats.attachment.location", icon: "location", color: "bg-green-500" }, -]; - -export default function AttachmentMenu({ - open, - onClose, - onSelect, - anchorRef, -}: AttachmentMenuProps) { - const { t } = useTranslation("common"); - const menuRef = useRef<HTMLDivElement>(null); - - useEffect(() => { - if (!open) return; - const handleClick = (e: MouseEvent) => { - const target = e.target as Node; - if ( - menuRef.current && - !menuRef.current.contains(target) && - !anchorRef?.current?.contains(target) - ) { - onClose(); - } - }; - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [open, onClose, anchorRef]); - - return ( - <AnimatePresence> - {open && ( - <motion.div - ref={menuRef} - initial={{ opacity: 0, y: 12, scale: 0.95 }} - animate={{ opacity: 1, y: 0, scale: 1 }} - exit={{ opacity: 0, y: 8, scale: 0.95 }} - transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }} - className="glass-panel absolute bottom-full left-0 z-[60] mb-3 min-w-[200px] rounded-2xl p-2 shadow-xl" - > - {attachmentConfig.map((item, i) => ( - <motion.button - key={item.type} - type="button" - initial={{ opacity: 0, x: -8 }} - animate={{ opacity: 1, x: 0 }} - transition={{ delay: i * 0.04 }} - onClick={() => { - onSelect(item.type); - onClose(); - }} - className="gentle-transition flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-right hover:bg-black/5 active:scale-[0.98] dark:hover:bg-white/10" - > - <span - className={`flex h-10 w-10 items-center justify-center rounded-full text-white ${item.color}`} - > - <ChatBoldIcon name={item.icon} size={22} className="text-white" /> - </span> - <span className="text-sm font-medium">{t(item.labelKey)}</span> - </motion.button> - ))} - </motion.div> - )} - </AnimatePresence> - ); -} +"use client"; + +import { useEffect, useRef } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import ChatBoldIcon, { type ChatBoldIconName } from "./ChatBoldIcon"; +import { useTranslation } from "react-i18next"; + +export type AttachmentType = "image" | "video" | "file" | "location" | "camera"; + +interface AttachmentMenuProps { + open: boolean; + onClose: () => void; + onSelect: (type: AttachmentType) => void; + anchorRef?: React.RefObject<HTMLElement | null>; +} + +const attachmentConfig: { + type: AttachmentType; + labelKey: string; + icon: ChatBoldIconName; + color: string; +}[] = [ + { type: "camera", labelKey: "chats.attachment.camera", icon: "camera", color: "bg-red-500" }, + { type: "image", labelKey: "chats.attachment.gallery", icon: "gallery", color: "bg-purple-500" }, + { type: "video", labelKey: "chats.attachment.video", icon: "video", color: "bg-blue-500" }, + { type: "file", labelKey: "chats.attachment.file", icon: "file", color: "bg-orange-500" }, + { type: "location", labelKey: "chats.attachment.location", icon: "location", color: "bg-green-500" }, +]; + +export default function AttachmentMenu({ + open, + onClose, + onSelect, + anchorRef, +}: AttachmentMenuProps) { + const { t } = useTranslation("common"); + const menuRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!open) return; + const handleClick = (e: MouseEvent) => { + const target = e.target as Node; + if ( + menuRef.current && + !menuRef.current.contains(target) && + !anchorRef?.current?.contains(target) + ) { + onClose(); + } + }; + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open, onClose, anchorRef]); + + return ( + <AnimatePresence> + {open && ( + <motion.div + ref={menuRef} + initial={{ opacity: 0, y: 12, scale: 0.95 }} + animate={{ opacity: 1, y: 0, scale: 1 }} + exit={{ opacity: 0, y: 8, scale: 0.95 }} + transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }} + className="glass-panel absolute bottom-full left-0 z-[60] mb-3 min-w-[200px] rounded-2xl p-2 shadow-xl" + > + {attachmentConfig.map((item, i) => ( + <motion.button + key={item.type} + type="button" + initial={{ opacity: 0, x: -8 }} + animate={{ opacity: 1, x: 0 }} + transition={{ delay: i * 0.04 }} + onClick={() => { + onSelect(item.type); + onClose(); + }} + className="gentle-transition flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-right hover:bg-black/5 active:scale-[0.98] dark:hover:bg-white/10" + > + <span + className={`flex h-10 w-10 items-center justify-center rounded-full text-white ${item.color}`} + > + <ChatBoldIcon name={item.icon} size={22} className="text-white" /> + </span> + <span className="text-sm font-medium">{t(item.labelKey)}</span> + </motion.button> + ))} + </motion.div> + )} + </AnimatePresence> + ); +} diff --git a/src/components/chat/ChatRoomMemberPicker.tsx b/src/components/chat/ChatRoomMemberPicker.tsx index bb2faf8..d32b7df 100644 --- a/src/components/chat/ChatRoomMemberPicker.tsx +++ b/src/components/chat/ChatRoomMemberPicker.tsx @@ -110,7 +110,7 @@ export default function ChatRoomMemberPicker({ ) : null} {results.length > 0 ? ( - <ul className="mt-2 max-h-40 overflow-y-auto rounded-xl border border-neutral-200 dark:border-neutral-700"> + <ul className="mt-2 max-h-40 overflow-y-auto rounded-2xl border border-neutral-200 dark:border-neutral-700"> {results.map((user) => ( <li key={user._id}> <button diff --git a/src/components/chat/FileMessageBubble.tsx b/src/components/chat/FileMessageBubble.tsx index 9a55bdc..f4626b6 100644 --- a/src/components/chat/FileMessageBubble.tsx +++ b/src/components/chat/FileMessageBubble.tsx @@ -1,111 +1,111 @@ -"use client"; - -import { useState } from "react"; -import BoldIcon from "@/components/ui/BoldIcon"; -import IOSSpinner from "@/components/ui/IOSSpinner"; -import { cn } from "@/lib/utils"; -import { useTranslation } from "react-i18next"; - -interface FileMessageBubbleProps { - url: string; - fileName?: string; - isOutgoing?: boolean; -} - -export default function FileMessageBubble({ - url, - fileName, - isOutgoing = false, -}: FileMessageBubbleProps) { - const { t } = useTranslation("common"); - const resolvedFileName = fileName ?? t("chats.fileAttachment"); - const [progress, setProgress] = useState(0); - const [downloading, setDownloading] = useState(false); - const isPending = url.startsWith("blob:"); - - const handleDownload = async () => { - if (downloading) return; - setDownloading(true); - setProgress(0); - try { - const xhr = new XMLHttpRequest(); - xhr.open("GET", url, true); - xhr.responseType = "blob"; - xhr.onprogress = (e) => { - if (e.lengthComputable) { - setProgress(Math.round((e.loaded / e.total) * 100)); - } - }; - xhr.onload = () => { - if (xhr.status === 200) { - const a = document.createElement("a"); - a.href = URL.createObjectURL(xhr.response); - a.download = resolvedFileName; - a.click(); - setProgress(100); - } - setDownloading(false); - }; - xhr.onerror = () => { - window.open(url, "_blank"); - setDownloading(false); - }; - xhr.send(); - } catch { - window.open(url, "_blank"); - setDownloading(false); - } - }; - - return ( - <div - className={cn( - "flex min-w-[200px] max-w-[260px] items-center gap-3 rounded-2xl p-3", - isOutgoing - ? "bg-[#2aabee]/10" - : "bg-neutral-50 dark:bg-neutral-800/80" - )} - > - <div - className={cn( - "flex h-11 w-11 shrink-0 items-center justify-center rounded-xl", - isOutgoing ? "bg-[#2aabee]/20 text-[#2aabee]" : "bg-blue-500/15 text-blue-500" - )} - > - {isPending || downloading ? ( - <IOSSpinner size={22} color={isOutgoing ? "#2aabee" : "#007aff"} /> - ) : ( - <BoldIcon name="document" size={22} tinted className="text-current" /> - )} - </div> - <div className="min-w-0 flex-1"> - <p className="truncate text-sm font-medium">{resolvedFileName}</p> - {(isPending || downloading) && ( - <div className="mt-1.5 h-1 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-700"> - <div - className="gentle-transition h-full rounded-full bg-[#2aabee]" - style={{ width: `${isPending ? 40 : progress}%` }} - /> - </div> - )} - <span className="text-[10px] text-neutral-500"> - {isPending - ? t("chats.fileStatus.uploading") - : downloading - ? `${progress}%` - : t("chats.file")} - </span> - </div> - {!isPending && ( - <button - type="button" - onClick={handleDownload} - className="gentle-transition rounded-full p-2 text-neutral-500 hover:bg-neutral-200/80 active:scale-90 dark:hover:bg-neutral-700" - aria-label={t("chats.aria.download")} - > - <BoldIcon name="document-download" size={20} tinted className="text-current" /> - </button> - )} - </div> - ); -} +"use client"; + +import { useState } from "react"; +import BoldIcon from "@/components/ui/BoldIcon"; +import IOSSpinner from "@/components/ui/IOSSpinner"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; + +interface FileMessageBubbleProps { + url: string; + fileName?: string; + isOutgoing?: boolean; +} + +export default function FileMessageBubble({ + url, + fileName, + isOutgoing = false, +}: FileMessageBubbleProps) { + const { t } = useTranslation("common"); + const resolvedFileName = fileName ?? t("chats.fileAttachment"); + const [progress, setProgress] = useState(0); + const [downloading, setDownloading] = useState(false); + const isPending = url.startsWith("blob:"); + + const handleDownload = async () => { + if (downloading) return; + setDownloading(true); + setProgress(0); + try { + const xhr = new XMLHttpRequest(); + xhr.open("GET", url, true); + xhr.responseType = "blob"; + xhr.onprogress = (e) => { + if (e.lengthComputable) { + setProgress(Math.round((e.loaded / e.total) * 100)); + } + }; + xhr.onload = () => { + if (xhr.status === 200) { + const a = document.createElement("a"); + a.href = URL.createObjectURL(xhr.response); + a.download = resolvedFileName; + a.click(); + setProgress(100); + } + setDownloading(false); + }; + xhr.onerror = () => { + window.open(url, "_blank"); + setDownloading(false); + }; + xhr.send(); + } catch { + window.open(url, "_blank"); + setDownloading(false); + } + }; + + return ( + <div + className={cn( + "flex min-w-[200px] max-w-[260px] items-center gap-3 rounded-2xl p-3", + isOutgoing + ? "bg-[#2aabee]/10" + : "bg-neutral-50 dark:bg-neutral-800/80" + )} + > + <div + className={cn( + "flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl", + isOutgoing ? "bg-[#2aabee]/20 text-[#2aabee]" : "bg-blue-500/15 text-blue-500" + )} + > + {isPending || downloading ? ( + <IOSSpinner size={22} color={isOutgoing ? "#2aabee" : "#007aff"} /> + ) : ( + <BoldIcon name="document" size={22} tinted className="text-current" /> + )} + </div> + <div className="min-w-0 flex-1"> + <p className="truncate text-sm font-medium">{resolvedFileName}</p> + {(isPending || downloading) && ( + <div className="mt-1.5 h-1 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-700"> + <div + className="gentle-transition h-full rounded-full bg-[#2aabee]" + style={{ width: `${isPending ? 40 : progress}%` }} + /> + </div> + )} + <span className="text-[10px] text-neutral-500"> + {isPending + ? t("chats.fileStatus.uploading") + : downloading + ? `${progress}%` + : t("chats.file")} + </span> + </div> + {!isPending && ( + <button + type="button" + onClick={handleDownload} + className="gentle-transition rounded-full p-2 text-neutral-500 hover:bg-neutral-200/80 active:scale-90 dark:hover:bg-neutral-700" + aria-label={t("chats.aria.download")} + > + <BoldIcon name="document-download" size={20} tinted className="text-current" /> + </button> + )} + </div> + ); +} diff --git a/src/components/chat/ForwardMessageModal.tsx b/src/components/chat/ForwardMessageModal.tsx index 49a9ea6..56b55c4 100644 --- a/src/components/chat/ForwardMessageModal.tsx +++ b/src/components/chat/ForwardMessageModal.tsx @@ -1,204 +1,204 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import Image from "next/image"; -import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; -import useAxios from "@/hooks/useAxios"; -import toast from "react-hot-toast"; -import IOSSpinner from "@/components/ui/IOSSpinner"; -import { useTranslation } from "react-i18next"; -import { encryptDmContent, ensureIdentityKeys } from "@/lib/e2ee"; - -export interface ChatUserItem { - _id: string; - user_name: string; - first_name: string; - last_name: string; - profile_image?: string; -} - -interface ForwardMessageModalProps { - open: boolean; - onClose: () => void; - message: { - content: string; - file?: string; - fileType?: string; - forwardedFrom?: { - userId: string; - userName: string; - displayName: string; - }; - }; - currentUserId: string; - currentReceiverId?: string; -} - -export default function ForwardMessageModal({ - open, - onClose, - message, - currentUserId, -}: ForwardMessageModalProps) { - const { t } = useTranslation("common"); - const { request } = useAxios(); - const [users, setUsers] = useState<ChatUserItem[]>([]); - const [selected, setSelected] = useState<Set<string>>(new Set()); - const [loading, setLoading] = useState(false); - const [sending, setSending] = useState(false); - - const loadUsers = async () => { - setLoading(true); - try { - const res = await request<{ filteredUsersData: ChatUserItem[] }>( - "GET", - "/messages?limit=100", - null, - { noToast: true } - ); - setUsers( - (res?.filteredUsersData || []).filter( - (u) => u._id !== currentUserId && Boolean(u.user_name) - ) - ); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - if (open) loadUsers(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); - - const toggle = (id: string) => { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else if (next.size < 50) next.add(id); - else toast.error(t("chats.toast.maxForwardUsers")); - return next; - }); - }; - - const forward = async () => { - if (selected.size === 0) return; - setSending(true); - const fwdMeta = message.forwardedFrom - ? JSON.stringify({ forwardedFrom: message.forwardedFrom }) - : ""; - const content = - (fwdMeta ? fwdMeta + "\n" : "") + - (message.content || t("chats.forwardedMessage")); - - try { - const targets = Array.from(selected); - await ensureIdentityKeys(currentUserId); - await Promise.all( - targets.map(async (receiverId) => { - let sendContent = content; - try { - const encrypted = await encryptDmContent( - currentUserId, - receiverId, - content - ); - if (encrypted) sendContent = encrypted; - } catch { - /* plaintext fallback */ - } - return request("POST", "/chat", { - content: sendContent, - receiverId, - senderId: currentUserId, - forwardedFrom: message.forwardedFrom, - }); - }) - ); - toast.success(t("chats.toast.forwardSent", { count: selected.size })); - onClose(); - setSelected(new Set()); - } catch { - toast.error(t("chats.toast.forwardFailed")); - } finally { - setSending(false); - } - }; - - return ( - <AnimatePresence> - {open && ( - <motion.div - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - exit={{ opacity: 0 }} - className="glass-modal-overlay fixed inset-0 z-[200] flex items-end justify-center sm:items-center" - onClick={onClose} - > - <motion.div - initial={{ y: 40 }} - animate={{ y: 0 }} - exit={{ y: 40 }} - onClick={(e) => e.stopPropagation()} - className="glass-modal-panel max-h-[70vh] w-full max-w-md overflow-hidden rounded-t-3xl sm:rounded-3xl" - > - <div className="border-b border-white/20 p-4 text-center font-semibold"> - {t("chats.modal.forwardTo", { selected: selected.size })} - </div> - <div className="max-h-[50vh] overflow-y-auto p-2"> - {loading ? ( - <div className="flex justify-center py-8"> - <IOSSpinner size={24} /> - </div> - ) : ( - users.map((u) => ( - <button - key={u._id} - type="button" - onClick={() => toggle(u._id)} - className={`gentle-transition mb-1 flex w-full items-center gap-3 rounded-xl p-3 ${ - selected.has(u._id) ? "bg-[#0095f6]/15 ring-1 ring-[#0095f6]/40" : "hover:bg-black/5" - }`} - > - {u.profile_image ? ( - <Image - src={buildStorageUrl(u.profile_image)} - width={40} - height={40} - alt="" - className="rounded-full" - /> - ) : ( - <div className="h-10 w-10 rounded-full bg-neutral-200" /> - )} - <span className="text-sm"> - {u.first_name} {u.last_name} - </span> - </button> - )) - )} - </div> - <div className="flex gap-2 border-t border-white/20 p-4"> - <button - type="button" - onClick={onClose} - className="flex-1 rounded-full py-2.5 text-sm text-neutral-500" - > - {t("chats.actions.cancel")} - </button> - <button - type="button" - disabled={selected.size === 0 || sending} - onClick={forward} - className="ig-dm-send-btn flex-1 rounded-full py-2.5 text-sm text-white disabled:opacity-50" - > - {sending ? t("chats.actions.sending") : t("chats.actions.send")} - </button> - </div> - </motion.div> - </motion.div> - )} - </AnimatePresence> - ); -} +"use client"; + +import { useEffect, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import Image from "next/image"; +import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; +import useAxios from "@/hooks/useAxios"; +import toast from "react-hot-toast"; +import IOSSpinner from "@/components/ui/IOSSpinner"; +import { useTranslation } from "react-i18next"; +import { encryptDmContent, ensureIdentityKeys } from "@/lib/e2ee"; + +export interface ChatUserItem { + _id: string; + user_name: string; + first_name: string; + last_name: string; + profile_image?: string; +} + +interface ForwardMessageModalProps { + open: boolean; + onClose: () => void; + message: { + content: string; + file?: string; + fileType?: string; + forwardedFrom?: { + userId: string; + userName: string; + displayName: string; + }; + }; + currentUserId: string; + currentReceiverId?: string; +} + +export default function ForwardMessageModal({ + open, + onClose, + message, + currentUserId, +}: ForwardMessageModalProps) { + const { t } = useTranslation("common"); + const { request } = useAxios(); + const [users, setUsers] = useState<ChatUserItem[]>([]); + const [selected, setSelected] = useState<Set<string>>(new Set()); + const [loading, setLoading] = useState(false); + const [sending, setSending] = useState(false); + + const loadUsers = async () => { + setLoading(true); + try { + const res = await request<{ filteredUsersData: ChatUserItem[] }>( + "GET", + "/messages?limit=100", + null, + { noToast: true } + ); + setUsers( + (res?.filteredUsersData || []).filter( + (u) => u._id !== currentUserId && Boolean(u.user_name) + ) + ); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (open) loadUsers(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const toggle = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else if (next.size < 50) next.add(id); + else toast.error(t("chats.toast.maxForwardUsers")); + return next; + }); + }; + + const forward = async () => { + if (selected.size === 0) return; + setSending(true); + const fwdMeta = message.forwardedFrom + ? JSON.stringify({ forwardedFrom: message.forwardedFrom }) + : ""; + const content = + (fwdMeta ? fwdMeta + "\n" : "") + + (message.content || t("chats.forwardedMessage")); + + try { + const targets = Array.from(selected); + await ensureIdentityKeys(currentUserId); + await Promise.all( + targets.map(async (receiverId) => { + let sendContent = content; + try { + const encrypted = await encryptDmContent( + currentUserId, + receiverId, + content + ); + if (encrypted) sendContent = encrypted; + } catch { + /* plaintext fallback */ + } + return request("POST", "/chat", { + content: sendContent, + receiverId, + senderId: currentUserId, + forwardedFrom: message.forwardedFrom, + }); + }) + ); + toast.success(t("chats.toast.forwardSent", { count: selected.size })); + onClose(); + setSelected(new Set()); + } catch { + toast.error(t("chats.toast.forwardFailed")); + } finally { + setSending(false); + } + }; + + return ( + <AnimatePresence> + {open && ( + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + className="glass-modal-overlay fixed inset-0 z-[200] flex items-end justify-center sm:items-center" + onClick={onClose} + > + <motion.div + initial={{ y: 40 }} + animate={{ y: 0 }} + exit={{ y: 40 }} + onClick={(e) => e.stopPropagation()} + className="glass-modal-panel max-h-[70vh] w-full max-w-md overflow-hidden rounded-t-3xl sm:rounded-3xl" + > + <div className="border-b border-white/20 p-4 text-center font-semibold"> + {t("chats.modal.forwardTo", { selected: selected.size })} + </div> + <div className="max-h-[50vh] overflow-y-auto p-2"> + {loading ? ( + <div className="flex justify-center py-8"> + <IOSSpinner size={24} /> + </div> + ) : ( + users.map((u) => ( + <button + key={u._id} + type="button" + onClick={() => toggle(u._id)} + className={`gentle-transition mb-1 flex w-full items-center gap-3 rounded-2xl p-3 ${ + selected.has(u._id) ? "bg-[#0095f6]/15 ring-1 ring-[#0095f6]/40" : "hover:bg-black/5" + }`} + > + {u.profile_image ? ( + <Image + src={buildStorageUrl(u.profile_image)} + width={40} + height={40} + alt="" + className="rounded-full" + /> + ) : ( + <div className="h-10 w-10 rounded-full bg-neutral-200" /> + )} + <span className="text-sm"> + {u.first_name} {u.last_name} + </span> + </button> + )) + )} + </div> + <div className="flex gap-2 border-t border-white/20 p-4"> + <button + type="button" + onClick={onClose} + className="flex-1 rounded-full py-2.5 text-sm text-neutral-500" + > + {t("chats.actions.cancel")} + </button> + <button + type="button" + disabled={selected.size === 0 || sending} + onClick={forward} + className="ig-dm-send-btn flex-1 rounded-full py-2.5 text-sm text-white disabled:opacity-50" + > + {sending ? t("chats.actions.sending") : t("chats.actions.send")} + </button> + </div> + </motion.div> + </motion.div> + )} + </AnimatePresence> + ); +} diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index ab84777..186188a 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -33,6 +33,8 @@ interface MessageInputProps { viewOnceMedia?: boolean; onViewOnceChange?: (value: boolean) => void; onGiftClick?: () => void; + /** طرف مقابل چت گزینه‌ی «دریافت هدیه» رو غیرفعال کرده — دکمه‌ی هدیه غیرفعال می‌شه */ + giftDisabled?: boolean; } const formatTime = (seconds: number) => { @@ -59,6 +61,7 @@ const MessageInput = ({ viewOnceMedia = false, onViewOnceChange, onGiftClick, + giftDisabled = false, }: MessageInputProps) => { const { t } = useTranslation("common"); const [isRecording, setIsRecording] = useState(false); @@ -328,7 +331,7 @@ const MessageInput = ({ {onGiftClick && ( <button type="button" - disabled={blocked_you || isRecording} + disabled={blocked_you || isRecording || giftDisabled} onClick={onGiftClick} className={`${chatHeaderCircleBtn} gentle-transition disabled:opacity-40`} style={chatHeaderGlassStyle} diff --git a/src/components/chat/SharedPostBubble.tsx b/src/components/chat/SharedPostBubble.tsx index acbb463..7b8fc49 100644 --- a/src/components/chat/SharedPostBubble.tsx +++ b/src/components/chat/SharedPostBubble.tsx @@ -35,7 +35,7 @@ export default function SharedPostBubble({ data }: { data: SharedPostPayload }) return ( <Link href={href} - className="block overflow-hidden rounded-xl border border-white/20 bg-black/10" + className="block overflow-hidden rounded-2xl border border-white/20 bg-black/10" > {preview && ( <div className="relative aspect-[4/3] w-full min-w-[200px] max-w-[240px] bg-neutral-900"> diff --git a/src/components/chat/SharedProductBubble.tsx b/src/components/chat/SharedProductBubble.tsx index 03020ab..87e5133 100644 --- a/src/components/chat/SharedProductBubble.tsx +++ b/src/components/chat/SharedProductBubble.tsx @@ -34,7 +34,7 @@ export default function SharedProductBubble({ data }: { data: SharedProductPaylo return ( <Link href={href} - className="block overflow-hidden rounded-xl border border-white/20 bg-black/10" + className="block overflow-hidden rounded-2xl border border-white/20 bg-black/10" > {preview && ( <div className="relative aspect-[4/3] w-full min-w-[200px] max-w-[240px] bg-neutral-900"> diff --git a/src/components/chat/SharedStoryBubble.tsx b/src/components/chat/SharedStoryBubble.tsx index 9df603e..0ff1368 100644 --- a/src/components/chat/SharedStoryBubble.tsx +++ b/src/components/chat/SharedStoryBubble.tsx @@ -46,7 +46,7 @@ export default function SharedStoryBubble({ const isLike = data.reactionType === "like"; return ( - <div className="block overflow-hidden rounded-xl border border-white/20 bg-black/10"> + <div className="block overflow-hidden rounded-2xl border border-white/20 bg-black/10"> <div className="flex items-center gap-1.5 px-2 pt-2 text-[10px] font-semibold opacity-80"> <BoldIcon name="video-vertical" size={14} tinted className="text-current" /> <span> diff --git a/src/components/chat/TimedMessagePicker.tsx b/src/components/chat/TimedMessagePicker.tsx index 82ede8a..41d605c 100644 --- a/src/components/chat/TimedMessagePicker.tsx +++ b/src/components/chat/TimedMessagePicker.tsx @@ -78,7 +78,7 @@ export default function TimedMessagePicker({ setOpen(false); }} className={cn( - "flex w-full items-center justify-between rounded-xl px-2.5 py-2 text-right text-sm transition-colors", + "flex w-full items-center justify-between rounded-2xl px-2.5 py-2 text-right text-sm transition-colors", value === opt.value ? "bg-[#2aabee]/12 font-semibold text-[#248cc8] dark:text-[#2aabee]" : "text-foreground hover:bg-black/5 dark:hover:bg-white/8" diff --git a/src/components/course/CourseForm.tsx b/src/components/course/CourseForm.tsx index afe3758..11d8e6b 100644 --- a/src/components/course/CourseForm.tsx +++ b/src/components/course/CourseForm.tsx @@ -1,1217 +1,1217 @@ -"use client"; - -import { useState, useRef, useEffect, useMemo } from "react"; -import { useForm, useFieldArray } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import * as z from "zod"; -import { motion } from "framer-motion"; -import { toast } from "react-hot-toast"; -import { ImageIcon } from "lucide-react"; -import { Image } from "lucide-react"; - -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Textarea } from "../ui/Textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Card, CardContent } from "@/components/ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Trash2, Video, Plus, Upload, FileVideo, Play } from "lucide-react"; -import useAxios from "@/hooks/useAxios"; -import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "../main/BaseUrl"; -import { useTranslation } from "react-i18next"; -import { useAppLanguage } from "@/contexts/LanguageProvider"; - -interface IAcademyCategory { - _id: string; - title: string; - slug: string; - description?: string; - icon?: string; - color?: string; - parent?: string | null; - level: number; - order: number; - course_count: number; - status: string; - is_featured: boolean; -} - -function buildCourseSchema(t: (key: string) => string) { - const videoSchema = z.object({ - title: z.string().min(1, t("academyCourse.validation.videoTitleRequired")), - file: z.any(), - url: z.string().optional(), - isFree: z.boolean().default(false), - }); - - return z.object({ - cuorse_name: z.string().min(1, t("academyCourse.nameRequired")), - price: z.number().min(0).optional(), - category: z.string().min(1, t("academyCourse.categoryRequired")), - offer: z.number().min(0).max(100).optional(), - caption: z.string().min(1, t("academyCourse.captionRequired")), - course_time: z.string().min(1, t("academyCourse.timeRequired")), - teacher_name: z.string().min(1, t("academyCourse.teacherRequired")), - packageImage: z.string().min(1, t("academyCourse.validation.packageImageRequired")), - is_free: z.boolean().default(false), - contact_mobile: z.string().optional(), - contact_telegram: z.string().optional(), - contact_whatsapp: z.string().optional(), - contact_instagram: z.string().optional(), - videos: z.array(videoSchema), - }); -} - -type CourseFormValues = z.infer<ReturnType<typeof buildCourseSchema>>; - -interface CourseFormProps { - initialData?: any; - initialData2?: any; - onSubmit: (data: CourseFormValues) => void; - isLoading?: boolean; -} - -interface MediaFile { - id: string; - title: string; - file: File | null; - preview?: string; - course_video: string; - type: "image" | "video"; - isFree?: boolean; - is_free?: boolean; -} - -export function CourseForm({ - initialData, - initialData2, - onSubmit, - isLoading, -}: CourseFormProps) { - const { t } = useTranslation("common"); - const { language } = useAppLanguage(); - const textDir = language === "fa" ? "rtl" : "ltr"; - const courseSchema = useMemo(() => buildCourseSchema(t), [t]); - const [previewImage, setPreviewImage] = useState<string>(""); - - const [previewProfile, setPreviewProfile] = useState<string>(""); - const [avatar, setAvatar] = useState<string | null>(null); - // State برای عکس پکیج - const [packageImageFile, setPackageImageFile] = useState<File | null>(null); - const [packageImagePreview, setPackageImagePreview] = useState<string>(""); - - // State برای عکس‌ها - const [imageFields, setImageFields] = useState<MediaFile[]>([]); - const [imageTitle, setImageTitle] = useState(""); - const [imageFile, setImageFile] = useState<File | null>(null); - const [imagePreview, setImagePreview] = useState(""); - const [imagePreviewUrls, setImagePreviewUrls] = useState<{ - [key: number]: string; - }>({}); - - // State برای ویدیوها (مثل قبل) - const [videoFields, setVideoFields] = useState<MediaFile[]>([]); - const [videoTitle, setVideoTitle] = useState(""); - const [videoFile, setVideoFile] = useState<File | null>(null); - const [videoPreview, setVideoPreview] = useState(""); - const [isFreeVideo, setIsFreeVideo] = useState(false); - const [videoPreviewUrls, setVideoPreviewUrls] = useState<{ - [key: number]: string; - }>({}); - - // ========================================== - // تغییر: استفاده از دسته‌بندی آکادمی به جای تبلیغات - // ========================================== - const [academyCategories, setAcademyCategories] = useState< - IAcademyCategory[] - >([]); - const [categoriesLoading, setCategoriesLoading] = useState(false); - - const [uploadProgress, setUploadProgress] = useState(0); - const [isUploading, setIsUploading] = useState(false); - const { request, loading, error } = useAxios(); - - // ========================================== - // دریافت دسته‌بندی‌های آکادمی از API جدید - // ========================================== - useEffect(() => { - const fetchAcademyCategories = async () => { - setCategoriesLoading(true); - try { - const response = await request<{ data: { docs: IAcademyCategory[] } }>( - "GET", - "/academy/categories?status=active&limit=100" - ); - // بررسی ساختار پاسخ - if (response?.data?.docs) { - setAcademyCategories(response.data.docs); - } else if (response?.data && Array.isArray(response.data)) { - setAcademyCategories(response.data); - } else if (Array.isArray(response)) { - setAcademyCategories(response); - } else { - setAcademyCategories([]); - } - console.log("دسته‌بندی‌های آکادمی:", response); - } catch (err) { - console.log("خطا در دریافت دسته‌بندی‌های آکادمی:", err); - toast.error(t("academyCourse.toast.fetchCategoriesError")); - } finally { - setCategoriesLoading(false); - } - }; - - fetchAcademyCategories(); - }, []); - - useEffect(() => { - const getAcademyCourses = async () => { - console.log(initialData2._id); - - try { - const getAcademyCourses = (await request( - "GET", - `/academy/academy/course/content?courseId=${initialData2._id}&page=1&limit=20`, - {} - )) as MediaFile; - - setVideoFields(getAcademyCourses.data.courses); - } catch (err) { - console.log("get error:", err); - toast.error(t("academyCourse.toast.fetchContentError")); - } finally { - } - }; - - getAcademyCourses(); - }, []); - - // تابع اضافه کردن عکس - const handleAddImage = () => { - if (!imageTitle.trim()) { - toast.error(t("academyCourse.toast.imageTitleRequired")); - return; - } - if (!imageFile) { - toast.error(t("academyCourse.toast.selectImageFile")); - return; - } - - const previewUrl = URL.createObjectURL(imageFile); - const newIndex = imageFields.length; - - setImageFields([ - ...imageFields, - { - id: Date.now().toString(), - title: imageTitle, - file: imageFile, - course_video: previewUrl, - type: "image", - }, - ]); - - setImagePreviewUrls((prev) => ({ ...prev, [newIndex]: previewUrl })); - - // Reset form - setImageTitle(""); - setImageFile(null); - setImagePreview(""); - const fileInput = document.getElementById( - "imageFileInput" - ) as HTMLInputElement; - if (fileInput) fileInput.value = ""; - - toast.success(t("academyCourse.toast.imageAdded")); - }; - - // تابع حذف عکس - const handleRemoveImage = (index: number) => { - if (imagePreviewUrls[index]) { - URL.revokeObjectURL(imagePreviewUrls[index]); - } - setImageFields(imageFields.filter((_, i) => i !== index)); - toast.success(t("academyCourse.toast.imageRemoved")); - }; - - const form = useForm<CourseFormValues>({ - resolver: zodResolver(courseSchema), - defaultValues: - initialData && typeof initialData === "object" - ? { - cuorse_name: initialData.cuorse_name || "", - price: initialData.is_free ? 0 : Number(initialData.price) || 0, - category: initialData.category || "", - offer: - initialData.offer && initialData.offer !== "0" - ? Number(initialData.offer) - : undefined, - caption: initialData.caption || "", - course_time: initialData.course_time || "", - teacher_name: initialData.teacher_name || "", - packageImage: initialData.course_image || "", - is_free: Boolean(initialData.is_free), - contact_mobile: initialData.contactInfo?.mobile || "", - contact_telegram: initialData.contactInfo?.telegramLink || "", - contact_whatsapp: initialData.contactInfo?.whatsappNumber || "", - contact_instagram: initialData.contactInfo?.instagramLink || "", - videos: initialData.videos || [], - } - : { - cuorse_name: "", - price: 0, - category: "", - offer: undefined, - caption: "", - course_time: "", - teacher_name: "", - packageImage: "", - is_free: false, - contact_mobile: "", - contact_telegram: "", - contact_whatsapp: "", - contact_instagram: "", - videos: [], - }, - }); - - const isFreePackage = form.watch("is_free"); - - useEffect(() => { - if (!initialData || typeof initialData !== "object") { - const preloadContactInfo = async () => { - try { - const response = await request<{ data: { contactInfo?: Record<string, string> } }>( - "GET", - "/users/contact-info" - ); - const contactInfo = response?.data?.contactInfo; - if (!contactInfo) return; - - if (contactInfo.mobile) form.setValue("contact_mobile", contactInfo.mobile); - if (contactInfo.telegramLink) - form.setValue("contact_telegram", contactInfo.telegramLink); - if (contactInfo.whatsappNumber) - form.setValue("contact_whatsapp", contactInfo.whatsappNumber); - if (contactInfo.instagramLink) - form.setValue("contact_instagram", contactInfo.instagramLink); - } catch { - // اطلاعات تماس اختیاری است - } - }; - - void preloadContactInfo(); - } - }, [initialData, request, form]); - - const { - // fields: videoFields, - append: appendVideo, - remove: removeVideo, - } = useFieldArray({ - control: form.control, - name: "videos", - }); - - // تابع برای انتخاب فایل ویدیو - const handleVideoFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { - const file = e.target.files?.[0]; - if (file) { - // چک کردن نوع فایل - if (!file.type.startsWith("video/")) { - toast.error(t("academyCourse.toast.invalidVideoFile")); - return; - } - - // چک کردن حجم فایل (حداکثر 500 مگابایت) - if (file.size > 1024 * 1024 * 1024) { - toast.error(t("academyCourse.toast.videoSizeLimitMb")); - return; - } - - setVideoFile(file); - const previewUrl = URL.createObjectURL(file); - setVideoPreview(previewUrl); - } - }; - - const fileToBase64 = (file: File): Promise<string> => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => resolve(reader.result as string); - reader.onerror = (error) => reject(error); - }); - }; - - // اضافه کردن state های جدید - const [uploadChunks, setUploadChunks] = useState([]); - const [currentChunk, setCurrentChunk] = useState(0); - const [totalChunks, setTotalChunks] = useState(0); - - // تابع تبدیل فایل به تکه‌های کوچک - const splitFileIntoChunks = (file, chunkSize = 10 * 1024 * 1024) => { - const chunks = []; - let start = 0; - - while (start < file.size) { - const end = Math.min(start + chunkSize, file.size); - const chunk = file.slice(start, end); - chunks.push({ - data: chunk, - index: chunks.length, - start: start, - end: end, - }); - start = end; - } - - return chunks; - }; - - // تابع تبدیل تکه به Base64 (برای هر تکه جداگانه) - const chunkToBase64 = (chunk) => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = (error) => reject(error); - reader.readAsDataURL(chunk); - }); - }; - - // تابع اصلی آپلود با روش تکه‌تکه - // اضافه کردن تابع برای بررسی تکه‌های آپلود شده قبلی - const checkUploadedChunks = async (courseId, fileName) => { - try { - const response = await request( - "GET", - `/academy/academy/course/upload-status?courseId=${courseId}&originalFileName=${fileName}`, - {}, - { timeout: 10000 } - ); - return response; - } catch (error) { - console.log("Error checking uploaded chunks:", error); - return { uploadedChunks: [], totalChunks: 0 }; - } - }; - - // تابع اصلی آپلود با قابلیت Resume - const uploadVideoInChunks = async (file, courseId, isFree, fileName) => { - const CHUNK_SIZE = 10 * 1024 * 1024; // 10 مگابایت - const chunks = splitFileIntoChunks(file, CHUNK_SIZE); - const totalChunksCount = chunks.length; - - setTotalChunks(totalChunksCount); - setCurrentChunk(0); - - // بررسی تکه‌های قبلی آپلود شده - const uploadStatus = await checkUploadedChunks(courseId, file.name); - const uploadedChunksSet = new Set(uploadStatus.uploadedChunks || []); - - let startFromChunk = 0; - if (uploadedChunksSet.size > 0) { - // پیدا کردن اولین تکه که آپلود نشده - for (let i = 0; i < totalChunksCount; i++) { - if (!uploadedChunksSet.has(i)) { - startFromChunk = i; - break; - } - } - - if (startFromChunk > 0) { - toast.success( - t("academyCourse.toast.resumeUploadChunk", { - chunk: startFromChunk + 1, - }) - ); - } - } - - for (let i = startFromChunk; i < chunks.length; i++) { - const chunk = chunks[i]; - - // تبدیل تکه فعلی به Base64 - const base64Chunk = await chunkToBase64(chunk.data); - - // ارسال تکه به سرور با قابلیت retry - let retryCount = 0; - let success = false; - - while (retryCount < 3 && !success) { - try { - const response = await request( - "POST", - "/academy/academy/course/creat/video-chunk", - { - courseId: courseId, - is_free: isFree, - file_name: fileName, - chunk: base64Chunk, - chunkIndex: i, - totalChunks: totalChunksCount, - originalFileName: file.name, - fileSize: file.size, - mime_type: file.type, - }, - { - timeout: 180000, - maxContentLength: Infinity, - maxBodyLength: Infinity, - } - ); - - if (response?.error) { - throw new Error(response.message); - } - - success = true; - console.log(`✅ Chunk ${i + 1}/${totalChunksCount} uploaded`); - } catch (err) { - retryCount++; - console.log(`❌ Chunk ${i + 1} failed, retry ${retryCount}/3`); - - if (retryCount === 3) { - throw new Error( - `Chunk ${i + 1} failed after 3 attempts: ${err.message}` - ); - } - - //等待 2 秒后再重试 - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - } - - // به روز رسانی پیشرفت - const progress = Math.round(((i + 1) / totalChunksCount) * 100); - setUploadProgress(progress); - setCurrentChunk(i + 1); - } - - // بعد از اتمام همه تکه‌ها، درخواست نهایی برای ترکیب - const finalResponse = await request( - "POST", - "/academy/academy/course/creat/video-chunk/merge", - { - courseId: courseId, - originalFileName: file.name, - totalChunks: totalChunksCount, - }, - { - timeout: 60000, - } - ); - - return finalResponse; - }; - - // تابع handleAddVideo جدید (جایگزین تابع قبلی) - const handleAddVideo = async () => { - if (!videoTitle.trim()) { - toast.error(t("academyCourse.toast.enterVideoTitle")); - return; - } - if (!videoFile) { - toast.error(t("academyCourse.toast.selectVideoFile")); - return; - } - - if (!initialData2?._id) { - toast.error(t("academyCourse.courseIdMissing")); - return; - } - - // بررسی حجم فایل - if (videoFile.size > 1024 * 1024 * 1024) { - toast.error(t("academyCourse.toast.videoSizeLimitGb")); - return; - } - - const previewUrl = URL.createObjectURL(videoFile); - setIsUploading(true); - setUploadProgress(0); - - try { - const loadingToast = toast.loading(t("academyCourse.toast.uploadingVideoChunks")); - - // آپلود با روش تکه‌تکه - const response = await uploadVideoInChunks( - videoFile, - initialData2._id, - isFreeVideo, - videoTitle - ); - - toast.dismiss(loadingToast); - console.log("پاسخ سرور:", response); - - if (response?.success) { - toast.success(t("academyCourse.toast.videoUploadSuccess")); - - setVideoFields([ - ...videoFields, - { - id: Date.now().toString(), - title: videoTitle, - file: videoFile, - course_video: previewUrl, - type: "video", - is_free: isFreeVideo, - _id: response.data?._id, - }, - ]); - - // پاک کردن فرم - setVideoTitle(""); - setVideoFile(null); - setVideoPreview(""); - setIsFreeVideo(false); - setUploadProgress(0); - setCurrentChunk(0); - setTotalChunks(0); - - const fileInput = document.getElementById( - "videoFileInput" - ) as HTMLInputElement; - if (fileInput) fileInput.value = ""; - } else { - toast.error(response?.message || t("academyCourse.toast.videoUploadError")); - } - } catch (err: any) { - console.error("Upload error:", err); - toast.error(err?.response?.data?.message || t("academyCourse.toast.videoUploadError")); - } finally { - setIsUploading(false); - } - }; - - const fileToBase64WithProgress = (file, onProgress) => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onprogress = (event) => { - if (event.lengthComputable && onProgress) { - const percentComplete = Math.round((event.loaded / event.total) * 50); // 50% برای تبدیل - onProgress(percentComplete); - } - }; - - reader.onload = () => resolve(reader.result); - reader.onerror = (error) => reject(error); - reader.readAsDataURL(file); - }); - }; - - const handleRemoveVideo = async (index: number, field) => { - console.log(field.courseId); - const deleteCourseContent = await request( - "DELETE", - `/academy/academy/course/content/video`, - { - courseContentId: field._id, - courseId: field.courseId, - } - ); - setVideoFields(videoFields.filter((_, i) => i !== index)); - toast.success(t("academyCourse.toast.videoRemoved")); - }; - - const selectImage = (event: React.ChangeEvent<HTMLInputElement>) => { - if (event.target.files && event.target.files[0]) { - const file = event.target.files[0]; - const previewUrl = URL.createObjectURL(file); - if (file) { - const url = URL.createObjectURL(file); - setPreviewImage(url); - form.setValue("packageImage", url); - } - setPreviewProfile(previewUrl); - setAvatar(previewUrl); - } - }; - - // تابع برای ارسال فرم (در CourseForm) - const onSubmitForm = (data: CourseFormValues) => { - // ارسال همه داده‌ها به پدر - const formDataToSend = { - ...data, - videos: videoFields.map((video) => ({ - title: video.title, - file: video.file, - isFree: video.isFree || false, - })), - imageFields: imageFields.map((image) => ({ - title: image.title, - file: image.file, - })), - }; - - onSubmit(formDataToSend); - }; - - return ( - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmitForm)} className="space-y-6"> - <Tabs defaultValue="basic" className="w-full"> - <TabsList className="grid w-full grid-cols-3"> - <TabsTrigger - className={`${initialData === "videos" ? "hidden" : ""}`} - value="basic" - > - {t("academyCourse.form.tabBasicInfo")} - </TabsTrigger> - <TabsTrigger - className={`${initialData === "videos" ? "" : "hidden"}`} - value="videos" - > - {t("academyCourse.form.tabVideosCount", { - count: videoFields.length, - })} - </TabsTrigger> - {/* <TabsTrigger - className={`${initialData === "videos" ? "hidden" : ""}`} - value="media" - > - رسانه - </TabsTrigger> */} - </TabsList> - - {/* تب اطلاعات پایه */} - <TabsContent dir={textDir} value="basic" className="space-y-4 mt-4"> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <FormField - control={form.control} - name="cuorse_name" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("academyCourse.form.packageName")}</FormLabel> - <FormControl> - <Input - placeholder={t("academyCourse.form.packageNamePlaceholder")} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="teacher_name" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("academyCourse.form.teacherName")}</FormLabel> - <FormControl> - <Input - placeholder={t("academyCourse.form.teacherNamePlaceholder")} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="price" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("academyCourse.form.price")}</FormLabel> - <FormControl> - <Input - type="number" - placeholder={t("academyCourse.form.pricePlaceholder")} - disabled={isFreePackage} - onChange={(e) => - field.onChange( - e.target.value === "" - ? undefined - : Number(e.target.value) - ) - } - value={isFreePackage ? 0 : field.value ?? ""} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="is_free" - render={({ field }) => ( - <FormItem className="flex items-center gap-2 space-y-0 md:col-span-2"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={(checked) => { - const isFree = checked === true; - field.onChange(isFree); - if (isFree) { - form.setValue("price", 0); - form.setValue("offer", undefined); - } - }} - /> - </FormControl> - <FormLabel className="!mt-0 cursor-pointer"> - {t("academyCourse.form.freePackage")} - </FormLabel> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="offer" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("academyCourse.form.discount")}</FormLabel> - <FormControl> - <Input - type="number" - placeholder={t("academyCourse.form.discountPlaceholder")} - disabled={isFreePackage} - onChange={(e) => - field.onChange( - e.target.value === "" - ? undefined - : Number(e.target.value) - ) - } - value={field.value ?? ""} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="category" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("academyCourse.form.academyCategory")}</FormLabel> - <Select - onValueChange={(selectedId) => { - // پیدا کردن دسته‌بندی انتخاب شده - const selectedCategory = academyCategories.find( - (cat) => cat._id === selectedId - ); - // ذخیره کردن title (اسم دسته‌بندی) به جای _id - field.onChange(selectedCategory?.title || selectedId); - }} - defaultValue={field.value} - > - <FormControl> - <SelectTrigger> - <SelectValue - placeholder={t("academyCourse.form.selectCategory")} - /> - </SelectTrigger> - </FormControl> - <SelectContent> - {categoriesLoading ? ( - <SelectItem value="loading" disabled> - {t("academyCourse.loading")} - </SelectItem> - ) : academyCategories.length === 0 ? ( - <SelectItem value="empty" disabled> - {t("academyCourse.form.noCategories")} - </SelectItem> - ) : ( - academyCategories.map((category) => ( - <SelectItem - dir={textDir} - key={category._id} - value={category._id} - > - <div className="flex items-center gap-2"> - {category.icon && ( - <span className="text-lg"> - {category.icon} - </span> - )} - <span style={{ color: category.color }}> - {category.title} - </span> - {category.course_count > 0 && ( - <span className="text-xs text-gray-400"> - ({category.course_count}) - </span> - )} - </div> - </SelectItem> - )) - )} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="course_time" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("academyCourse.form.courseTime")}</FormLabel> - <FormControl> - <Input - placeholder={t("academyCourse.form.courseTimePlaceholder")} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <div className="space-y-3 rounded-xl border border-border-primary-light p-4"> - <FormLabel className="text-base"> - {t("academyCourse.form.contactInfoOptional")} - </FormLabel> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <FormField - control={form.control} - name="contact_mobile" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - placeholder={t("academyCourse.form.mobilePlaceholder")} - maxLength={11} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="contact_telegram" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - placeholder={t("academyCourse.form.telegramPlaceholder")} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="contact_whatsapp" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - placeholder={t("academyCourse.form.whatsappPlaceholder")} - maxLength={11} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="contact_instagram" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - placeholder={t("academyCourse.form.instagramPlaceholder")} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </div> - - <FormField - control={form.control} - name="caption" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("academyCourse.form.caption")}</FormLabel> - <FormControl> - <Textarea - placeholder={t("academyCourse.form.captionPlaceholder")} - className="min-h-[120px]" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormItem> - <FormLabel>{t("academyCourse.form.packageImage")}</FormLabel> - <FormControl> - <Input - id="fileInput" - type="file" - accept="image/*" - onChange={selectImage} - /> - </FormControl> - {previewImage && ( - <motion.img - src={previewProfile} - alt="Preview" - className="w-48 h-48 object-cover rounded-lg mt-2 border" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - /> - )} - <FormMessage /> - </FormItem> - </TabsContent> - - {/* تب ویدیوها */} - <TabsContent dir={textDir} value="videos" className="space-y-4 mt-4"> - {/* بخش آپلود ویدیو */} - <Card className="border-2 border-dashed mt-6"> - <CardContent className="pt-6"> - <div className="flex items-center gap-2 mb-4"> - <Video className="w-5 h-5 text-primary" /> - <h3 className="text-lg font-semibold"> - {t("academyCourse.form.addNewVideo")} - </h3> - <span className="text-xs text-muted-foreground"> - {t("academyCourse.form.optional")} - </span> - </div> - - <div className="space-y-4"> - <div> - <label className="text-sm font-medium mb-2 block"> - {t("academyCourse.form.videoTitle")} - </label> - <Input - placeholder={t("academyCourse.form.videoTitlePlaceholder")} - value={videoTitle} - onChange={(e) => setVideoTitle(e.target.value)} - /> - </div> - - <div> - <label className="text-sm font-medium mb-2 block"> - {t("academyCourse.form.videoFile")} - </label> - <div className="flex items-center gap-2"> - <Input - id="videoFileInput" - type="file" - accept="video/*" - onChange={handleVideoFileSelect} - className="flex-1" - /> - {videoFile && ( - <span className="text-xs text-green-600"> - {videoFile.name} - </span> - )} - </div> - {videoPreview && ( - <div className="mt-3"> - <video - src={videoPreview} - controls - className="w-full max-h-48 rounded-lg border" - controlsList="nodownload" - /> - </div> - )} - </div> - - <div className="flex items-center gap-2"> - <Checkbox - id="isFree" - checked={isFreeVideo} - onCheckedChange={(checked) => - setIsFreeVideo(checked as boolean) - } - /> - <label htmlFor="isFree" className="text-sm cursor-pointer"> - {t("academyCourse.form.freeVideo")} - </label> - </div> - - <Button - type="button" - onClick={handleAddVideo} - className="w-full" - variant="outline" - disabled={!videoTitle.trim() || !videoFile || isUploading} - > - <Plus className="w-4 h-4 ml-2" /> - {isUploading - ? t("academyCourse.form.uploading") - : t("academyCourse.form.submitVideo")} - </Button> - - {isUploading && ( - <div className="mt-4 space-y-2"> - <div className="flex justify-between text-sm"> - <span className="text-gray-600"> - {t("academyCourse.form.uploading")} - </span> - <span className="text-gray-600 font-medium"> - {uploadProgress}% - </span> - </div> - <div className="w-full bg-gray-200 rounded-full h-2.5 overflow-hidden"> - <motion.div - className="bg-gradient-to-r from-pink-500 to-purple-500 h-2.5 rounded-full" - initial={{ width: 0 }} - animate={{ width: `${uploadProgress}%` }} - transition={{ duration: 0.3 }} - /> - </div> - <p className="text-xs text-gray-400 text-center"> - {t("academyCourse.form.uploadWaitHint")} - </p> - </div> - )} - </div> - </CardContent> - </Card> - - {/* لیست ویدیوهای اضافه شده */} - {videoFields.length > 0 && ( - <div className="space-y-3 mt-6"> - <h3 className="font-semibold flex items-center gap-2"> - <Video className="w-4 h-4" /> - {t("academyCourse.form.addedVideosCount", { - count: videoFields.length, - })} - </h3> - - {videoFields.map((field, index) => ( - <div - key={field.id} - className="border rounded-lg overflow-hidden bg-muted/30" - > - <div className="p-3"> - <div className="flex items-start justify-between"> - <div className="flex-1"> - <div className="flex items-center gap-2 mb-2"> - <Play className="w-4 h-4 text-primary" /> - <span className="font-medium">{field.title}</span> - {field.is_free && ( - <span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded"> - {t("academyCourse.form.freeBadge")} - </span> - )} - </div> - </div> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => handleRemoveVideo(index, field)} - className="text-red-500 hover:text-red-700" - > - <Trash2 className="w-4 h-4" /> - </Button> - </div> - - <div className="mt-2"> - <video - src={buildStorageUrl(field.course_video)} - controls - className="w-full max-h-64 rounded-lg" - controlsList="nodownload" - > - {t("academyCourse.form.videoUnsupported")} - </video> - </div> - </div> - </div> - ))} - </div> - )} - - {/* اخطار برای زمانی که چیزی اضافه نشده */} - {videoFields.length === 0 && ( - <div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg mt-6"> - <FileVideo className="w-12 h-12 mx-auto mb-3 opacity-50" /> - <p>{t("academyCourse.form.noVideos")}</p> - <p className="text-sm"> - {t("academyCourse.form.noVideosHint")} - </p> - </div> - )} - </TabsContent> - - {/* تب رسانه */} - {/* <TabsContent dir="rtl" value="media" className="space-y-4 mt-4"> - <FormItem> - <FormLabel>عکس پکیج</FormLabel> - <FormControl> - <Input - id="fileInput" - type="file" - accept="image/*" - onChange={selectImage} - /> - </FormControl> - {previewImage && ( - <motion.img - src={previewProfile} - alt="Preview" - className="w-48 h-48 object-cover rounded-lg mt-2 border" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - /> - )} - <FormMessage /> - </FormItem> - </TabsContent> */} - </Tabs> - - <div className="flex justify-end gap-3 pt-4 border-t"> - <Button type="submit" disabled={isLoading}> - {isLoading - ? t("academyCourse.submitting") - : initialData - ? t("academyCourse.editPackage") - : t("academyCourse.createPackageBtn")} - </Button> - </div> - </form> - </Form> - ); -} +"use client"; + +import { useState, useRef, useEffect, useMemo } from "react"; +import { useForm, useFieldArray } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { motion } from "framer-motion"; +import { toast } from "react-hot-toast"; +import { ImageIcon } from "lucide-react"; +import { Image } from "lucide-react"; + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "../ui/Textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Card, CardContent } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Trash2, Video, Plus, Upload, FileVideo, Play } from "lucide-react"; +import useAxios from "@/hooks/useAxios"; +import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "../main/BaseUrl"; +import { useTranslation } from "react-i18next"; +import { useAppLanguage } from "@/contexts/LanguageProvider"; + +interface IAcademyCategory { + _id: string; + title: string; + slug: string; + description?: string; + icon?: string; + color?: string; + parent?: string | null; + level: number; + order: number; + course_count: number; + status: string; + is_featured: boolean; +} + +function buildCourseSchema(t: (key: string) => string) { + const videoSchema = z.object({ + title: z.string().min(1, t("academyCourse.validation.videoTitleRequired")), + file: z.any(), + url: z.string().optional(), + isFree: z.boolean().default(false), + }); + + return z.object({ + cuorse_name: z.string().min(1, t("academyCourse.nameRequired")), + price: z.number().min(0).optional(), + category: z.string().min(1, t("academyCourse.categoryRequired")), + offer: z.number().min(0).max(100).optional(), + caption: z.string().min(1, t("academyCourse.captionRequired")), + course_time: z.string().min(1, t("academyCourse.timeRequired")), + teacher_name: z.string().min(1, t("academyCourse.teacherRequired")), + packageImage: z.string().min(1, t("academyCourse.validation.packageImageRequired")), + is_free: z.boolean().default(false), + contact_mobile: z.string().optional(), + contact_telegram: z.string().optional(), + contact_whatsapp: z.string().optional(), + contact_instagram: z.string().optional(), + videos: z.array(videoSchema), + }); +} + +type CourseFormValues = z.infer<ReturnType<typeof buildCourseSchema>>; + +interface CourseFormProps { + initialData?: any; + initialData2?: any; + onSubmit: (data: CourseFormValues) => void; + isLoading?: boolean; +} + +interface MediaFile { + id: string; + title: string; + file: File | null; + preview?: string; + course_video: string; + type: "image" | "video"; + isFree?: boolean; + is_free?: boolean; +} + +export function CourseForm({ + initialData, + initialData2, + onSubmit, + isLoading, +}: CourseFormProps) { + const { t } = useTranslation("common"); + const { language } = useAppLanguage(); + const textDir = language === "fa" ? "rtl" : "ltr"; + const courseSchema = useMemo(() => buildCourseSchema(t), [t]); + const [previewImage, setPreviewImage] = useState<string>(""); + + const [previewProfile, setPreviewProfile] = useState<string>(""); + const [avatar, setAvatar] = useState<string | null>(null); + // State برای عکس پکیج + const [packageImageFile, setPackageImageFile] = useState<File | null>(null); + const [packageImagePreview, setPackageImagePreview] = useState<string>(""); + + // State برای عکس‌ها + const [imageFields, setImageFields] = useState<MediaFile[]>([]); + const [imageTitle, setImageTitle] = useState(""); + const [imageFile, setImageFile] = useState<File | null>(null); + const [imagePreview, setImagePreview] = useState(""); + const [imagePreviewUrls, setImagePreviewUrls] = useState<{ + [key: number]: string; + }>({}); + + // State برای ویدیوها (مثل قبل) + const [videoFields, setVideoFields] = useState<MediaFile[]>([]); + const [videoTitle, setVideoTitle] = useState(""); + const [videoFile, setVideoFile] = useState<File | null>(null); + const [videoPreview, setVideoPreview] = useState(""); + const [isFreeVideo, setIsFreeVideo] = useState(false); + const [videoPreviewUrls, setVideoPreviewUrls] = useState<{ + [key: number]: string; + }>({}); + + // ========================================== + // تغییر: استفاده از دسته‌بندی آکادمی به جای تبلیغات + // ========================================== + const [academyCategories, setAcademyCategories] = useState< + IAcademyCategory[] + >([]); + const [categoriesLoading, setCategoriesLoading] = useState(false); + + const [uploadProgress, setUploadProgress] = useState(0); + const [isUploading, setIsUploading] = useState(false); + const { request, loading, error } = useAxios(); + + // ========================================== + // دریافت دسته‌بندی‌های آکادمی از API جدید + // ========================================== + useEffect(() => { + const fetchAcademyCategories = async () => { + setCategoriesLoading(true); + try { + const response = await request<{ data: { docs: IAcademyCategory[] } }>( + "GET", + "/academy/categories?status=active&limit=100" + ); + // بررسی ساختار پاسخ + if (response?.data?.docs) { + setAcademyCategories(response.data.docs); + } else if (response?.data && Array.isArray(response.data)) { + setAcademyCategories(response.data); + } else if (Array.isArray(response)) { + setAcademyCategories(response); + } else { + setAcademyCategories([]); + } + console.log("دسته‌بندی‌های آکادمی:", response); + } catch (err) { + console.log("خطا در دریافت دسته‌بندی‌های آکادمی:", err); + toast.error(t("academyCourse.toast.fetchCategoriesError")); + } finally { + setCategoriesLoading(false); + } + }; + + fetchAcademyCategories(); + }, []); + + useEffect(() => { + const getAcademyCourses = async () => { + console.log(initialData2._id); + + try { + const getAcademyCourses = (await request( + "GET", + `/academy/academy/course/content?courseId=${initialData2._id}&page=1&limit=20`, + {} + )) as MediaFile; + + setVideoFields(getAcademyCourses.data.courses); + } catch (err) { + console.log("get error:", err); + toast.error(t("academyCourse.toast.fetchContentError")); + } finally { + } + }; + + getAcademyCourses(); + }, []); + + // تابع اضافه کردن عکس + const handleAddImage = () => { + if (!imageTitle.trim()) { + toast.error(t("academyCourse.toast.imageTitleRequired")); + return; + } + if (!imageFile) { + toast.error(t("academyCourse.toast.selectImageFile")); + return; + } + + const previewUrl = URL.createObjectURL(imageFile); + const newIndex = imageFields.length; + + setImageFields([ + ...imageFields, + { + id: Date.now().toString(), + title: imageTitle, + file: imageFile, + course_video: previewUrl, + type: "image", + }, + ]); + + setImagePreviewUrls((prev) => ({ ...prev, [newIndex]: previewUrl })); + + // Reset form + setImageTitle(""); + setImageFile(null); + setImagePreview(""); + const fileInput = document.getElementById( + "imageFileInput" + ) as HTMLInputElement; + if (fileInput) fileInput.value = ""; + + toast.success(t("academyCourse.toast.imageAdded")); + }; + + // تابع حذف عکس + const handleRemoveImage = (index: number) => { + if (imagePreviewUrls[index]) { + URL.revokeObjectURL(imagePreviewUrls[index]); + } + setImageFields(imageFields.filter((_, i) => i !== index)); + toast.success(t("academyCourse.toast.imageRemoved")); + }; + + const form = useForm<CourseFormValues>({ + resolver: zodResolver(courseSchema), + defaultValues: + initialData && typeof initialData === "object" + ? { + cuorse_name: initialData.cuorse_name || "", + price: initialData.is_free ? 0 : Number(initialData.price) || 0, + category: initialData.category || "", + offer: + initialData.offer && initialData.offer !== "0" + ? Number(initialData.offer) + : undefined, + caption: initialData.caption || "", + course_time: initialData.course_time || "", + teacher_name: initialData.teacher_name || "", + packageImage: initialData.course_image || "", + is_free: Boolean(initialData.is_free), + contact_mobile: initialData.contactInfo?.mobile || "", + contact_telegram: initialData.contactInfo?.telegramLink || "", + contact_whatsapp: initialData.contactInfo?.whatsappNumber || "", + contact_instagram: initialData.contactInfo?.instagramLink || "", + videos: initialData.videos || [], + } + : { + cuorse_name: "", + price: 0, + category: "", + offer: undefined, + caption: "", + course_time: "", + teacher_name: "", + packageImage: "", + is_free: false, + contact_mobile: "", + contact_telegram: "", + contact_whatsapp: "", + contact_instagram: "", + videos: [], + }, + }); + + const isFreePackage = form.watch("is_free"); + + useEffect(() => { + if (!initialData || typeof initialData !== "object") { + const preloadContactInfo = async () => { + try { + const response = await request<{ data: { contactInfo?: Record<string, string> } }>( + "GET", + "/users/contact-info" + ); + const contactInfo = response?.data?.contactInfo; + if (!contactInfo) return; + + if (contactInfo.mobile) form.setValue("contact_mobile", contactInfo.mobile); + if (contactInfo.telegramLink) + form.setValue("contact_telegram", contactInfo.telegramLink); + if (contactInfo.whatsappNumber) + form.setValue("contact_whatsapp", contactInfo.whatsappNumber); + if (contactInfo.instagramLink) + form.setValue("contact_instagram", contactInfo.instagramLink); + } catch { + // اطلاعات تماس اختیاری است + } + }; + + void preloadContactInfo(); + } + }, [initialData, request, form]); + + const { + // fields: videoFields, + append: appendVideo, + remove: removeVideo, + } = useFieldArray({ + control: form.control, + name: "videos", + }); + + // تابع برای انتخاب فایل ویدیو + const handleVideoFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0]; + if (file) { + // چک کردن نوع فایل + if (!file.type.startsWith("video/")) { + toast.error(t("academyCourse.toast.invalidVideoFile")); + return; + } + + // چک کردن حجم فایل (حداکثر 500 مگابایت) + if (file.size > 1024 * 1024 * 1024) { + toast.error(t("academyCourse.toast.videoSizeLimitMb")); + return; + } + + setVideoFile(file); + const previewUrl = URL.createObjectURL(file); + setVideoPreview(previewUrl); + } + }; + + const fileToBase64 = (file: File): Promise<string> => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result as string); + reader.onerror = (error) => reject(error); + }); + }; + + // اضافه کردن state های جدید + const [uploadChunks, setUploadChunks] = useState([]); + const [currentChunk, setCurrentChunk] = useState(0); + const [totalChunks, setTotalChunks] = useState(0); + + // تابع تبدیل فایل به تکه‌های کوچک + const splitFileIntoChunks = (file, chunkSize = 10 * 1024 * 1024) => { + const chunks = []; + let start = 0; + + while (start < file.size) { + const end = Math.min(start + chunkSize, file.size); + const chunk = file.slice(start, end); + chunks.push({ + data: chunk, + index: chunks.length, + start: start, + end: end, + }); + start = end; + } + + return chunks; + }; + + // تابع تبدیل تکه به Base64 (برای هر تکه جداگانه) + const chunkToBase64 = (chunk) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = (error) => reject(error); + reader.readAsDataURL(chunk); + }); + }; + + // تابع اصلی آپلود با روش تکه‌تکه + // اضافه کردن تابع برای بررسی تکه‌های آپلود شده قبلی + const checkUploadedChunks = async (courseId, fileName) => { + try { + const response = await request( + "GET", + `/academy/academy/course/upload-status?courseId=${courseId}&originalFileName=${fileName}`, + {}, + { timeout: 10000 } + ); + return response; + } catch (error) { + console.log("Error checking uploaded chunks:", error); + return { uploadedChunks: [], totalChunks: 0 }; + } + }; + + // تابع اصلی آپلود با قابلیت Resume + const uploadVideoInChunks = async (file, courseId, isFree, fileName) => { + const CHUNK_SIZE = 10 * 1024 * 1024; // 10 مگابایت + const chunks = splitFileIntoChunks(file, CHUNK_SIZE); + const totalChunksCount = chunks.length; + + setTotalChunks(totalChunksCount); + setCurrentChunk(0); + + // بررسی تکه‌های قبلی آپلود شده + const uploadStatus = await checkUploadedChunks(courseId, file.name); + const uploadedChunksSet = new Set(uploadStatus.uploadedChunks || []); + + let startFromChunk = 0; + if (uploadedChunksSet.size > 0) { + // پیدا کردن اولین تکه که آپلود نشده + for (let i = 0; i < totalChunksCount; i++) { + if (!uploadedChunksSet.has(i)) { + startFromChunk = i; + break; + } + } + + if (startFromChunk > 0) { + toast.success( + t("academyCourse.toast.resumeUploadChunk", { + chunk: startFromChunk + 1, + }) + ); + } + } + + for (let i = startFromChunk; i < chunks.length; i++) { + const chunk = chunks[i]; + + // تبدیل تکه فعلی به Base64 + const base64Chunk = await chunkToBase64(chunk.data); + + // ارسال تکه به سرور با قابلیت retry + let retryCount = 0; + let success = false; + + while (retryCount < 3 && !success) { + try { + const response = await request( + "POST", + "/academy/academy/course/creat/video-chunk", + { + courseId: courseId, + is_free: isFree, + file_name: fileName, + chunk: base64Chunk, + chunkIndex: i, + totalChunks: totalChunksCount, + originalFileName: file.name, + fileSize: file.size, + mime_type: file.type, + }, + { + timeout: 180000, + maxContentLength: Infinity, + maxBodyLength: Infinity, + } + ); + + if (response?.error) { + throw new Error(response.message); + } + + success = true; + console.log(`✅ Chunk ${i + 1}/${totalChunksCount} uploaded`); + } catch (err) { + retryCount++; + console.log(`❌ Chunk ${i + 1} failed, retry ${retryCount}/3`); + + if (retryCount === 3) { + throw new Error( + `Chunk ${i + 1} failed after 3 attempts: ${err.message}` + ); + } + + //等待 2 秒后再重试 + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + + // به روز رسانی پیشرفت + const progress = Math.round(((i + 1) / totalChunksCount) * 100); + setUploadProgress(progress); + setCurrentChunk(i + 1); + } + + // بعد از اتمام همه تکه‌ها، درخواست نهایی برای ترکیب + const finalResponse = await request( + "POST", + "/academy/academy/course/creat/video-chunk/merge", + { + courseId: courseId, + originalFileName: file.name, + totalChunks: totalChunksCount, + }, + { + timeout: 60000, + } + ); + + return finalResponse; + }; + + // تابع handleAddVideo جدید (جایگزین تابع قبلی) + const handleAddVideo = async () => { + if (!videoTitle.trim()) { + toast.error(t("academyCourse.toast.enterVideoTitle")); + return; + } + if (!videoFile) { + toast.error(t("academyCourse.toast.selectVideoFile")); + return; + } + + if (!initialData2?._id) { + toast.error(t("academyCourse.courseIdMissing")); + return; + } + + // بررسی حجم فایل + if (videoFile.size > 1024 * 1024 * 1024) { + toast.error(t("academyCourse.toast.videoSizeLimitGb")); + return; + } + + const previewUrl = URL.createObjectURL(videoFile); + setIsUploading(true); + setUploadProgress(0); + + try { + const loadingToast = toast.loading(t("academyCourse.toast.uploadingVideoChunks")); + + // آپلود با روش تکه‌تکه + const response = await uploadVideoInChunks( + videoFile, + initialData2._id, + isFreeVideo, + videoTitle + ); + + toast.dismiss(loadingToast); + console.log("پاسخ سرور:", response); + + if (response?.success) { + toast.success(t("academyCourse.toast.videoUploadSuccess")); + + setVideoFields([ + ...videoFields, + { + id: Date.now().toString(), + title: videoTitle, + file: videoFile, + course_video: previewUrl, + type: "video", + is_free: isFreeVideo, + _id: response.data?._id, + }, + ]); + + // پاک کردن فرم + setVideoTitle(""); + setVideoFile(null); + setVideoPreview(""); + setIsFreeVideo(false); + setUploadProgress(0); + setCurrentChunk(0); + setTotalChunks(0); + + const fileInput = document.getElementById( + "videoFileInput" + ) as HTMLInputElement; + if (fileInput) fileInput.value = ""; + } else { + toast.error(response?.message || t("academyCourse.toast.videoUploadError")); + } + } catch (err: any) { + console.error("Upload error:", err); + toast.error(err?.response?.data?.message || t("academyCourse.toast.videoUploadError")); + } finally { + setIsUploading(false); + } + }; + + const fileToBase64WithProgress = (file, onProgress) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onprogress = (event) => { + if (event.lengthComputable && onProgress) { + const percentComplete = Math.round((event.loaded / event.total) * 50); // 50% برای تبدیل + onProgress(percentComplete); + } + }; + + reader.onload = () => resolve(reader.result); + reader.onerror = (error) => reject(error); + reader.readAsDataURL(file); + }); + }; + + const handleRemoveVideo = async (index: number, field) => { + console.log(field.courseId); + const deleteCourseContent = await request( + "DELETE", + `/academy/academy/course/content/video`, + { + courseContentId: field._id, + courseId: field.courseId, + } + ); + setVideoFields(videoFields.filter((_, i) => i !== index)); + toast.success(t("academyCourse.toast.videoRemoved")); + }; + + const selectImage = (event: React.ChangeEvent<HTMLInputElement>) => { + if (event.target.files && event.target.files[0]) { + const file = event.target.files[0]; + const previewUrl = URL.createObjectURL(file); + if (file) { + const url = URL.createObjectURL(file); + setPreviewImage(url); + form.setValue("packageImage", url); + } + setPreviewProfile(previewUrl); + setAvatar(previewUrl); + } + }; + + // تابع برای ارسال فرم (در CourseForm) + const onSubmitForm = (data: CourseFormValues) => { + // ارسال همه داده‌ها به پدر + const formDataToSend = { + ...data, + videos: videoFields.map((video) => ({ + title: video.title, + file: video.file, + isFree: video.isFree || false, + })), + imageFields: imageFields.map((image) => ({ + title: image.title, + file: image.file, + })), + }; + + onSubmit(formDataToSend); + }; + + return ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmitForm)} className="space-y-6"> + <Tabs defaultValue="basic" className="w-full"> + <TabsList className="grid w-full grid-cols-3"> + <TabsTrigger + className={`${initialData === "videos" ? "hidden" : ""}`} + value="basic" + > + {t("academyCourse.form.tabBasicInfo")} + </TabsTrigger> + <TabsTrigger + className={`${initialData === "videos" ? "" : "hidden"}`} + value="videos" + > + {t("academyCourse.form.tabVideosCount", { + count: videoFields.length, + })} + </TabsTrigger> + {/* <TabsTrigger + className={`${initialData === "videos" ? "hidden" : ""}`} + value="media" + > + رسانه + </TabsTrigger> */} + </TabsList> + + {/* تب اطلاعات پایه */} + <TabsContent dir={textDir} value="basic" className="space-y-4 mt-4"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <FormField + control={form.control} + name="cuorse_name" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("academyCourse.form.packageName")}</FormLabel> + <FormControl> + <Input + placeholder={t("academyCourse.form.packageNamePlaceholder")} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="teacher_name" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("academyCourse.form.teacherName")}</FormLabel> + <FormControl> + <Input + placeholder={t("academyCourse.form.teacherNamePlaceholder")} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="price" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("academyCourse.form.price")}</FormLabel> + <FormControl> + <Input + type="number" + placeholder={t("academyCourse.form.pricePlaceholder")} + disabled={isFreePackage} + onChange={(e) => + field.onChange( + e.target.value === "" + ? undefined + : Number(e.target.value) + ) + } + value={isFreePackage ? 0 : field.value ?? ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="is_free" + render={({ field }) => ( + <FormItem className="flex items-center gap-2 space-y-0 md:col-span-2"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={(checked) => { + const isFree = checked === true; + field.onChange(isFree); + if (isFree) { + form.setValue("price", 0); + form.setValue("offer", undefined); + } + }} + /> + </FormControl> + <FormLabel className="!mt-0 cursor-pointer"> + {t("academyCourse.form.freePackage")} + </FormLabel> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="offer" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("academyCourse.form.discount")}</FormLabel> + <FormControl> + <Input + type="number" + placeholder={t("academyCourse.form.discountPlaceholder")} + disabled={isFreePackage} + onChange={(e) => + field.onChange( + e.target.value === "" + ? undefined + : Number(e.target.value) + ) + } + value={field.value ?? ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="category" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("academyCourse.form.academyCategory")}</FormLabel> + <Select + onValueChange={(selectedId) => { + // پیدا کردن دسته‌بندی انتخاب شده + const selectedCategory = academyCategories.find( + (cat) => cat._id === selectedId + ); + // ذخیره کردن title (اسم دسته‌بندی) به جای _id + field.onChange(selectedCategory?.title || selectedId); + }} + defaultValue={field.value} + > + <FormControl> + <SelectTrigger> + <SelectValue + placeholder={t("academyCourse.form.selectCategory")} + /> + </SelectTrigger> + </FormControl> + <SelectContent> + {categoriesLoading ? ( + <SelectItem value="loading" disabled> + {t("academyCourse.loading")} + </SelectItem> + ) : academyCategories.length === 0 ? ( + <SelectItem value="empty" disabled> + {t("academyCourse.form.noCategories")} + </SelectItem> + ) : ( + academyCategories.map((category) => ( + <SelectItem + dir={textDir} + key={category._id} + value={category._id} + > + <div className="flex items-center gap-2"> + {category.icon && ( + <span className="text-lg"> + {category.icon} + </span> + )} + <span style={{ color: category.color }}> + {category.title} + </span> + {category.course_count > 0 && ( + <span className="text-xs text-gray-400"> + ({category.course_count}) + </span> + )} + </div> + </SelectItem> + )) + )} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="course_time" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("academyCourse.form.courseTime")}</FormLabel> + <FormControl> + <Input + placeholder={t("academyCourse.form.courseTimePlaceholder")} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className="space-y-3 rounded-2xl border border-border-primary-light p-4"> + <FormLabel className="text-base"> + {t("academyCourse.form.contactInfoOptional")} + </FormLabel> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <FormField + control={form.control} + name="contact_mobile" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder={t("academyCourse.form.mobilePlaceholder")} + maxLength={11} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="contact_telegram" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder={t("academyCourse.form.telegramPlaceholder")} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="contact_whatsapp" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder={t("academyCourse.form.whatsappPlaceholder")} + maxLength={11} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="contact_instagram" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder={t("academyCourse.form.instagramPlaceholder")} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + <FormField + control={form.control} + name="caption" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("academyCourse.form.caption")}</FormLabel> + <FormControl> + <Textarea + placeholder={t("academyCourse.form.captionPlaceholder")} + className="min-h-[120px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormItem> + <FormLabel>{t("academyCourse.form.packageImage")}</FormLabel> + <FormControl> + <Input + id="fileInput" + type="file" + accept="image/*" + onChange={selectImage} + /> + </FormControl> + {previewImage && ( + <motion.img + src={previewProfile} + alt="Preview" + className="w-48 h-48 object-cover rounded-lg mt-2 border" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + /> + )} + <FormMessage /> + </FormItem> + </TabsContent> + + {/* تب ویدیوها */} + <TabsContent dir={textDir} value="videos" className="space-y-4 mt-4"> + {/* بخش آپلود ویدیو */} + <Card className="border-2 border-dashed mt-6"> + <CardContent className="pt-6"> + <div className="flex items-center gap-2 mb-4"> + <Video className="w-5 h-5 text-primary" /> + <h3 className="text-lg font-semibold"> + {t("academyCourse.form.addNewVideo")} + </h3> + <span className="text-xs text-muted-foreground"> + {t("academyCourse.form.optional")} + </span> + </div> + + <div className="space-y-4"> + <div> + <label className="text-sm font-medium mb-2 block"> + {t("academyCourse.form.videoTitle")} + </label> + <Input + placeholder={t("academyCourse.form.videoTitlePlaceholder")} + value={videoTitle} + onChange={(e) => setVideoTitle(e.target.value)} + /> + </div> + + <div> + <label className="text-sm font-medium mb-2 block"> + {t("academyCourse.form.videoFile")} + </label> + <div className="flex items-center gap-2"> + <Input + id="videoFileInput" + type="file" + accept="video/*" + onChange={handleVideoFileSelect} + className="flex-1" + /> + {videoFile && ( + <span className="text-xs text-green-600"> + {videoFile.name} + </span> + )} + </div> + {videoPreview && ( + <div className="mt-3"> + <video + src={videoPreview} + controls + className="w-full max-h-48 rounded-lg border" + controlsList="nodownload" + /> + </div> + )} + </div> + + <div className="flex items-center gap-2"> + <Checkbox + id="isFree" + checked={isFreeVideo} + onCheckedChange={(checked) => + setIsFreeVideo(checked as boolean) + } + /> + <label htmlFor="isFree" className="text-sm cursor-pointer"> + {t("academyCourse.form.freeVideo")} + </label> + </div> + + <Button + type="button" + onClick={handleAddVideo} + className="w-full" + variant="outline" + disabled={!videoTitle.trim() || !videoFile || isUploading} + > + <Plus className="w-4 h-4 ml-2" /> + {isUploading + ? t("academyCourse.form.uploading") + : t("academyCourse.form.submitVideo")} + </Button> + + {isUploading && ( + <div className="mt-4 space-y-2"> + <div className="flex justify-between text-sm"> + <span className="text-gray-600"> + {t("academyCourse.form.uploading")} + </span> + <span className="text-gray-600 font-medium"> + {uploadProgress}% + </span> + </div> + <div className="w-full bg-gray-200 rounded-full h-2.5 overflow-hidden"> + <motion.div + className="bg-gradient-to-r from-pink-500 to-purple-500 h-2.5 rounded-full" + initial={{ width: 0 }} + animate={{ width: `${uploadProgress}%` }} + transition={{ duration: 0.3 }} + /> + </div> + <p className="text-xs text-gray-400 text-center"> + {t("academyCourse.form.uploadWaitHint")} + </p> + </div> + )} + </div> + </CardContent> + </Card> + + {/* لیست ویدیوهای اضافه شده */} + {videoFields.length > 0 && ( + <div className="space-y-3 mt-6"> + <h3 className="font-semibold flex items-center gap-2"> + <Video className="w-4 h-4" /> + {t("academyCourse.form.addedVideosCount", { + count: videoFields.length, + })} + </h3> + + {videoFields.map((field, index) => ( + <div + key={field.id} + className="border rounded-lg overflow-hidden bg-muted/30" + > + <div className="p-3"> + <div className="flex items-start justify-between"> + <div className="flex-1"> + <div className="flex items-center gap-2 mb-2"> + <Play className="w-4 h-4 text-primary" /> + <span className="font-medium">{field.title}</span> + {field.is_free && ( + <span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded"> + {t("academyCourse.form.freeBadge")} + </span> + )} + </div> + </div> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleRemoveVideo(index, field)} + className="text-red-500 hover:text-red-700" + > + <Trash2 className="w-4 h-4" /> + </Button> + </div> + + <div className="mt-2"> + <video + src={buildStorageUrl(field.course_video)} + controls + className="w-full max-h-64 rounded-lg" + controlsList="nodownload" + > + {t("academyCourse.form.videoUnsupported")} + </video> + </div> + </div> + </div> + ))} + </div> + )} + + {/* اخطار برای زمانی که چیزی اضافه نشده */} + {videoFields.length === 0 && ( + <div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg mt-6"> + <FileVideo className="w-12 h-12 mx-auto mb-3 opacity-50" /> + <p>{t("academyCourse.form.noVideos")}</p> + <p className="text-sm"> + {t("academyCourse.form.noVideosHint")} + </p> + </div> + )} + </TabsContent> + + {/* تب رسانه */} + {/* <TabsContent dir="rtl" value="media" className="space-y-4 mt-4"> + <FormItem> + <FormLabel>عکس پکیج</FormLabel> + <FormControl> + <Input + id="fileInput" + type="file" + accept="image/*" + onChange={selectImage} + /> + </FormControl> + {previewImage && ( + <motion.img + src={previewProfile} + alt="Preview" + className="w-48 h-48 object-cover rounded-lg mt-2 border" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + /> + )} + <FormMessage /> + </FormItem> + </TabsContent> */} + </Tabs> + + <div className="flex justify-end gap-3 pt-4 border-t"> + <Button type="submit" disabled={isLoading}> + {isLoading + ? t("academyCourse.submitting") + : initialData + ? t("academyCourse.editPackage") + : t("academyCourse.createPackageBtn")} + </Button> + </div> + </form> + </Form> + ); +} diff --git a/src/components/main/ProfileAvatar.tsx b/src/components/main/ProfileAvatar.tsx index e22a020..324ca61 100644 --- a/src/components/main/ProfileAvatar.tsx +++ b/src/components/main/ProfileAvatar.tsx @@ -52,7 +52,7 @@ function ProfileAvatar({ rounded === "full" ? "rounded-full" : rounded === "xl" - ? "rounded-xl" + ? "rounded-2xl" : "rounded-2xl"; const imageSrc = src ? buildStorageUrl(src) : fallback; diff --git a/src/components/models/AppearanceColorSelect.tsx b/src/components/models/AppearanceColorSelect.tsx index 9b108f9..69aa74c 100644 --- a/src/components/models/AppearanceColorSelect.tsx +++ b/src/components/models/AppearanceColorSelect.tsx @@ -64,7 +64,7 @@ export default function AppearanceColorSelect({ onChange(""); setOpen(false); }} - className="mb-2 w-full rounded-xl border border-dashed border-neutral-300 py-1.5 text-xs text-neutral-500 dark:border-neutral-600" + className="mb-2 w-full rounded-2xl border border-dashed border-neutral-300 py-1.5 text-xs text-neutral-500 dark:border-neutral-600" > {t("models.noFilter")} </button> @@ -83,7 +83,7 @@ export default function AppearanceColorSelect({ > <div className={cn( - "relative h-14 w-14 overflow-hidden rounded-xl border-4 transition-colors", + "relative h-14 w-14 overflow-hidden rounded-2xl border-4 transition-colors", isActive ? "border-[#FC8EAC]" : "border-transparent ring-1 ring-neutral-200 dark:ring-neutral-700" diff --git a/src/components/models/FilterModal.tsx b/src/components/models/FilterModal.tsx index 71ca979..fd4a3dc 100644 --- a/src/components/models/FilterModal.tsx +++ b/src/components/models/FilterModal.tsx @@ -16,6 +16,7 @@ import { isModelExpertise } from "@/lib/isModelExpertise"; import RoundedButton from "../elements/RoundedButton"; import { filterChipClass } from "@/lib/ui/buttonStyles"; import { useDefaultCountryId } from "@/hooks/useDefaultCountryId"; +import { localizedGeoName } from "@/utils/geoName"; interface IFilterProps { setShowFilterModal: (e: boolean) => void; @@ -319,7 +320,7 @@ function FilterModal({ </option> {allStates?.map((item) => ( <option key={item.id} value={item.id}> - {item.name} + {localizedGeoName(item)} </option> ))} </SelectBox> @@ -333,7 +334,7 @@ function FilterModal({ </option> {cities?.map((city) => ( <option key={city.id} value={city.id}> - {city.name} + {localizedGeoName(city)} </option> ))} </SelectBox> diff --git a/src/components/models/MainModelCard/MainModelCard.tsx b/src/components/models/MainModelCard/MainModelCard.tsx index eb83d7d..c2571b8 100644 --- a/src/components/models/MainModelCard/MainModelCard.tsx +++ b/src/components/models/MainModelCard/MainModelCard.tsx @@ -1,501 +1,502 @@ -"use client"; - -import React, { - useState, - useRef, - useEffect, - useContext, - createContext, -} from "react"; -import { Post } from "@/types/types"; -import Image from "next/image"; -import MainModelCardActions from "./MainModelCardActions"; -import Link from "next/link"; -import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; -import VerificationBadge from "@/components/main/VerificationBadge"; -import ScoreRateStats from "@/components/main/ScoreRateStats"; -import { useUserById } from "@/hooks/getUserById"; -import "slick-carousel/slick/slick.css"; -import "slick-carousel/slick/slick-theme.css"; -import BoldIcon from "@/components/ui/BoldIcon"; -import SwipeImageSlider from "./SwipeImageSlider"; -import ExpandableCaption from "@/components/posts/ExpandableCaption"; -import StoryRing, { verifyBadgeToTierColor } from "@/components/stories/StoryRing"; -import StoryAvatarOpen from "@/components/stories/StoryAvatarOpen"; -import { useStoryMetaForUser } from "@/hooks/useStoryMetaForUser"; -import { - reorderCaptionLinksToEnd, - splitCaptionBodyAndLinks, -} from "@/lib/postLinkCaption"; -import { cn } from "@/lib/utils"; -import { getPostSubExpertiseLabels } from "@/lib/subExpertiseDisplay"; -import { useTranslation } from "react-i18next"; -import { getPostVideoSrc } from "@/lib/videoPreload"; -import { getVideoPoster } from "@/lib/explore/postMedia"; -import { resolvePostMediaType } from "@/lib/reelsMediaType"; - -// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارت‌ها -const MutedContext = createContext<{ - muted: boolean; - setMuted: React.Dispatch<React.SetStateAction<boolean>>; -}>({ - muted: true, - setMuted: () => {}, -}); - -function MainModelCard({ - postData, - onMediaClick, -}: { - postData: Post; - onMediaClick?: () => void; -}) { - const { - user_id, - user_name, - first_name, - last_name, - expertise, - city, - user_level, - caption, - files, - type, - } = postData; - - const { t } = useTranslation("common"); - const user = useUserById(user_id); - const storyMeta = useStoryMetaForUser(user_id); - const profile_image = user?.profile_image; - const verify_badge = user?.verify_badge; - const user_score = user?.user_score; - const rate = user?.rate; - - const [showFullCaption, setShowFullCaption] = useState(false); - const orderedCaption = reorderCaptionLinksToEnd(caption || ""); - const { body: captionBody, links: captionLinks } = - splitCaptionBodyAndLinks(orderedCaption); - - useEffect(() => { - setShowFullCaption(false); - }, [postData._id]); - const [imageLoading, setImageLoading] = useState(true); - const videoRef = useRef<HTMLVideoElement | null>(null); - const [ripple, setRipple] = useState<{ - x: number; - y: number; - key: number; - } | null>(null); - - // استفاده از Context برای وضعیت muted گلوبال - const { muted, setMuted } = useContext(MutedContext); - const [videoMuted, setVideoMuted] = useState(true); - - const subExpertise = user?.sub_expertise || []; - const displaySubExpertise = - user?.display_sub_expertise || - getPostSubExpertiseLabels( - expertise, - subExpertise, - user?.display_sub_expertise - )[0] || - null; - const keywords2 = ["آموزشگاه دارم"]; - const keywords3 = ["سالن دارم", "آتیله دارم"]; - - const matchedExpertise2 = subExpertise.filter((item) => - keywords2.some((keyword) => item.includes(keyword)) - ); - const matchedExpertise3 = subExpertise.filter((item) => - keywords3.some((keyword) => item.includes(keyword)) - ); - - const mediaFiles = - files?.map((file) => ({ - ...file, - src: buildStorageUrl(file.path), - })) || []; - - const mediaType = resolvePostMediaType(postData) ?? type; - const videoSrc = - mediaType === "video" ? getPostVideoSrc(postData) : undefined; - const videoPoster = - mediaType === "video" ? getVideoPoster(postData) : undefined; - - // تابع برای ایجاد افکت ripple و تغییر وضعیت صدا - const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => { - const v = videoRef.current; - if (!v) return; - - const rect = v.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - setRipple({ x, y, key: Date.now() }); - - // --- مهمترین اصلاح --- - v.muted = !v.muted; - setVideoMuted(v.muted); - setMuted(v.muted); - - if (!v.muted) { - v.play().catch(() => {}); - } - - setTimeout(() => setRipple(null), 600); - }; - - // استفاده از IntersectionObserver برای پخش/توقف ویدیو بر اساس ویوپورت - useEffect(() => { - const video = videoRef.current; - if (!video || type !== "video") return; - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - // ویدیو در ویوپورت است: پخش کن - video.play().catch(() => { - /* ignore: ممکن است به دلیل محدودیت‌های مرورگر */ - }); - video.muted = muted; // اعمال وضعیت گلوبال - } else { - // ویدیو خارج از ویوپورت: توقف کن - video.pause(); - } - }); - }, - { - threshold: 0.5, // حداقل 50% ویدیو باید در ویوپورت باشد - } - ); - - observer.observe(video); - - return () => { - observer.unobserve(video); - }; - }, [muted, type]); - - // اعمال وضعیت muted گلوبال هنگام mount یا تغییر muted - useEffect(() => { - if (videoRef.current) { - videoRef.current.muted = muted; - setVideoMuted(muted); - } - }, [muted]); - - useEffect(() => { - const v = videoRef.current; - if (!v || type !== "video") return; - const sync = () => setVideoMuted(v.muted); - v.addEventListener("volumechange", sync); - sync(); - return () => v.removeEventListener("volumechange", sync); - }, [type, mediaFiles]); - - return ( - <div className="flex w-full flex-col overflow-hidden pb-2"> - <div className="flex text-sm justify-between py-2"> - <div className="flex items-center w-full"> - <div className="relative ml-2 flex h-[72px] w-[72px] shrink-0 items-center justify-center"> - <StoryAvatarOpen userId={user_id}> - <StoryRing - hasStory={storyMeta.hasStory} - hasUnviewed={storyMeta.hasUnviewed} - tierColor={verifyBadgeToTierColor(verify_badge)} - rounded="xl" - animated={false} - avatarSize={storyMeta.hasStory ? 67 : 72} - avatarRadius={12} - > - <div - className={`relative overflow-hidden rounded-xl ${ - storyMeta.hasStory ? "h-full w-full" : "h-[72px] w-[72px]" - }`} - > - <Image - src={ - profile_image - ? buildStorageUrl(profile_image) - : "/images/fake-avatar.png" - } - alt={user_name} - fill - className="aspect-square rounded-xl object-cover" - priority - unoptimized - /> - </div> - </StoryRing> - </StoryAvatarOpen> - </div> - <Link - href={`/users/${user_name}`} - className="font-semibold flex gap-2 flex-col-reverse ml-2 w-full min-w-0" - > - <div className="flex items-center gap-1 mt-1"> - <h2 className="max-sm:text-xs inline-flex items-center gap-1"> - {user_name} - <VerificationBadge verifyBadge={verify_badge} /> - </h2> - <ScoreRateStats score={user_score} rate={rate} /> - </div> - <div className="flex justify-between items-center"> - <div className="max-sm:text-xs"> - {first_name} {last_name} - </div> - <div className=""> - <div className="text-[#15a9d6] flex gap-5 font-semibold"> - {subExpertise && matchedExpertise2.length > 0 && ( - <span> - <svg - width="24" - height="24" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - xmlns="http://www.w3.org/2000/svg" - > - <path - d="M5.99953 5.17031L4.02953 6.46031C2.09953 7.72031 2.09953 10.5403 4.02953 11.8003L10.0495 15.7303C11.1295 16.4403 12.9095 16.4403 13.9895 15.7303L19.9795 11.8003C21.8995 10.5403 21.8995 7.73031 19.9795 6.47031L13.9895 2.54031C12.9095 1.83031 11.1295 1.83031 10.0495 2.54031" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M5.63012 13.0801L5.62012 17.7701C5.62012 19.0401 6.60012 20.4001 7.80012 20.8001L10.9901 21.8601C11.5401 22.0401 12.4501 22.0401 13.0101 21.8601L16.2001 20.8001C17.4001 20.4001 18.3801 19.0401 18.3801 17.7701V13.1301" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M21.4004 15V9" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - </svg> - </span> - )} - {subExpertise && matchedExpertise3.length > 0 && ( - <span> - <svg - width="24" - height="24" - viewBox="0 0 24 24" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > - <path - d="M2 22H22" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M12 2C13.6 2.64 15.4 2.64 17 2V5C15.4 5.64 13.6 5.64 12 5V2Z" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M12 5V8" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M4 15.91V22H20V11C20 9 19 8 17 8H7C5 8 4 9 4 11V12" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M4 12H19.42" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M7.98999 12V22" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinejoin="round" - /> - <path - d="M11.99 12V22" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinejoin="round" - /> - <path - d="M15.99 12V22" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinejoin="round" - /> - </svg> - </span> - )} - </div> - </div> - </div> - <div className="max-sm:text-xs flex w-full flex-col gap-0.5 text-[#387E65]"> - <div className="flex w-full items-center justify-between"> - <span>{user_level || t("models.newcomer")}</span> - <div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-0.5 pt-1 font-semibold"> - {city?.name ? <span>{city.name}</span> : null} - {expertise ? <span>{expertise}</span> : null} - {displaySubExpertise ? <span>{displaySubExpertise}</span> : null} - </div> - </div> - </div> - </Link> - </div> - </div> - - <div - className={`overflow-hidden rounded-2xl ${onMediaClick ? "cursor-pointer" : ""}`} - onClick={onMediaClick} - onKeyDown={ - onMediaClick - ? (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onMediaClick(); - } - } - : undefined - } - role={onMediaClick ? "button" : undefined} - tabIndex={onMediaClick ? 0 : undefined} - aria-label={onMediaClick ? t("models.fullscreen") : undefined} - > - {mediaType === "image" && mediaFiles.length > 0 && ( - <SwipeImageSlider - mediaFiles={mediaFiles} - setImageLoading={setImageLoading} - /> - )} - - {mediaType === "video" && videoSrc && ( - <div className="relative w-full rounded-2xl overflow-hidden bg-black"> - <video - onClick={(e) => { - if (onMediaClick) { - e.stopPropagation(); - onMediaClick(); - return; - } - handleVideoClick(e); - }} - ref={videoRef} - src={videoSrc} - poster={videoPoster} - loop - playsInline - className="w-full h-auto object-contain" - onLoadedData={() => setImageLoading(false)} - muted={muted} - /> - {ripple && ( - <span - className="absolute rounded-full bg-white/50 animate-ripple" - style={{ - left: ripple.x - 25, - top: ripple.y - 25, - width: 50, - height: 50, - }} - key={ripple.key} - /> - )} - {imageLoading && ( - <div className="absolute inset-0 flex justify-center items-center bg-black/30 z-10"> - <Image - height={40} - width={50} - src="/waiting.gif" - alt="waiting" - /> - </div> - )} - <div className="pointer-events-none absolute bottom-3 left-3 z-50 rounded-full bg-black/50 p-2 backdrop-blur-md"> - {videoMuted ? ( - <BoldIcon name="volume-slash" size={20} tinted className="text-white" aria-label={t("models.mute")} /> - ) : ( - <BoldIcon name="volume-high" size={20} tinted className="text-white" aria-label={t("models.unmute")} /> - )} - </div> - </div> - )} - </div> - - <MainModelCardActions postData={postData} /> - {caption?.includes("@") && ( - <div className="mx-2 mt-1 flex items-center gap-1 text-xs text-neutral-500"> - <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden> - <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" /> - </svg> - <span>{t("models.taggedPeople")}</span> - </div> - )} - {(captionBody || captionLinks) && ( - <div className="mx-2 mt-2"> - <ExpandableCaption - body={captionBody} - links={captionLinks} - expanded={showFullCaption} - onToggle={() => setShowFullCaption((v) => !v)} - /> - </div> - )} - - <style jsx>{` - .animate-ripple { - animation: ripple 600ms ease-out; - pointer-events: none; - } - @keyframes ripple { - 0% { - transform: scale(0); - opacity: 0.5; - } - 100% { - transform: scale(4); - opacity: 0; - } - } - `}</style> - </div> - ); -} - -// Provider برای به اشتراک گذاشتن وضعیت muted -export const MutedProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - const [muted, setMuted] = useState(true); - - return ( - <MutedContext.Provider value={{ muted, setMuted }}> - {children} - </MutedContext.Provider> - ); -}; - -export default MainModelCard; +"use client"; + +import React, { + useState, + useRef, + useEffect, + useContext, + createContext, +} from "react"; +import { Post } from "@/types/types"; +import Image from "next/image"; +import MainModelCardActions from "./MainModelCardActions"; +import Link from "next/link"; +import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; +import VerificationBadge from "@/components/main/VerificationBadge"; +import ScoreRateStats from "@/components/main/ScoreRateStats"; +import { useUserById } from "@/hooks/getUserById"; +import "slick-carousel/slick/slick.css"; +import "slick-carousel/slick/slick-theme.css"; +import BoldIcon from "@/components/ui/BoldIcon"; +import SwipeImageSlider from "./SwipeImageSlider"; +import ExpandableCaption from "@/components/posts/ExpandableCaption"; +import StoryRing, { verifyBadgeToTierColor } from "@/components/stories/StoryRing"; +import StoryAvatarOpen from "@/components/stories/StoryAvatarOpen"; +import { useStoryMetaForUser } from "@/hooks/useStoryMetaForUser"; +import { + reorderCaptionLinksToEnd, + splitCaptionBodyAndLinks, +} from "@/lib/postLinkCaption"; +import { cn } from "@/lib/utils"; +import { getPostSubExpertiseLabels } from "@/lib/subExpertiseDisplay"; +import { localizedGeoName } from "@/utils/geoName"; +import { useTranslation } from "react-i18next"; +import { getPostVideoSrc } from "@/lib/videoPreload"; +import { getVideoPoster } from "@/lib/explore/postMedia"; +import { resolvePostMediaType } from "@/lib/reelsMediaType"; + +// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارت‌ها +const MutedContext = createContext<{ + muted: boolean; + setMuted: React.Dispatch<React.SetStateAction<boolean>>; +}>({ + muted: true, + setMuted: () => {}, +}); + +function MainModelCard({ + postData, + onMediaClick, +}: { + postData: Post; + onMediaClick?: () => void; +}) { + const { + user_id, + user_name, + first_name, + last_name, + expertise, + city, + user_level, + caption, + files, + type, + } = postData; + + const { t } = useTranslation("common"); + const user = useUserById(user_id); + const storyMeta = useStoryMetaForUser(user_id); + const profile_image = user?.profile_image; + const verify_badge = user?.verify_badge; + const user_score = user?.user_score; + const rate = user?.rate; + + const [showFullCaption, setShowFullCaption] = useState(false); + const orderedCaption = reorderCaptionLinksToEnd(caption || ""); + const { body: captionBody, links: captionLinks } = + splitCaptionBodyAndLinks(orderedCaption); + + useEffect(() => { + setShowFullCaption(false); + }, [postData._id]); + const [imageLoading, setImageLoading] = useState(true); + const videoRef = useRef<HTMLVideoElement | null>(null); + const [ripple, setRipple] = useState<{ + x: number; + y: number; + key: number; + } | null>(null); + + // استفاده از Context برای وضعیت muted گلوبال + const { muted, setMuted } = useContext(MutedContext); + const [videoMuted, setVideoMuted] = useState(true); + + const subExpertise = user?.sub_expertise || []; + const displaySubExpertise = + user?.display_sub_expertise || + getPostSubExpertiseLabels( + expertise, + subExpertise, + user?.display_sub_expertise + )[0] || + null; + const keywords2 = ["آموزشگاه دارم"]; + const keywords3 = ["سالن دارم", "آتیله دارم"]; + + const matchedExpertise2 = subExpertise.filter((item) => + keywords2.some((keyword) => item.includes(keyword)) + ); + const matchedExpertise3 = subExpertise.filter((item) => + keywords3.some((keyword) => item.includes(keyword)) + ); + + const mediaFiles = + files?.map((file) => ({ + ...file, + src: buildStorageUrl(file.path), + })) || []; + + const mediaType = resolvePostMediaType(postData) ?? type; + const videoSrc = + mediaType === "video" ? getPostVideoSrc(postData) : undefined; + const videoPoster = + mediaType === "video" ? getVideoPoster(postData) : undefined; + + // تابع برای ایجاد افکت ripple و تغییر وضعیت صدا + const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => { + const v = videoRef.current; + if (!v) return; + + const rect = v.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + setRipple({ x, y, key: Date.now() }); + + // --- مهمترین اصلاح --- + v.muted = !v.muted; + setVideoMuted(v.muted); + setMuted(v.muted); + + if (!v.muted) { + v.play().catch(() => {}); + } + + setTimeout(() => setRipple(null), 600); + }; + + // استفاده از IntersectionObserver برای پخش/توقف ویدیو بر اساس ویوپورت + useEffect(() => { + const video = videoRef.current; + if (!video || type !== "video") return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + // ویدیو در ویوپورت است: پخش کن + video.play().catch(() => { + /* ignore: ممکن است به دلیل محدودیت‌های مرورگر */ + }); + video.muted = muted; // اعمال وضعیت گلوبال + } else { + // ویدیو خارج از ویوپورت: توقف کن + video.pause(); + } + }); + }, + { + threshold: 0.5, // حداقل 50% ویدیو باید در ویوپورت باشد + } + ); + + observer.observe(video); + + return () => { + observer.unobserve(video); + }; + }, [muted, type]); + + // اعمال وضعیت muted گلوبال هنگام mount یا تغییر muted + useEffect(() => { + if (videoRef.current) { + videoRef.current.muted = muted; + setVideoMuted(muted); + } + }, [muted]); + + useEffect(() => { + const v = videoRef.current; + if (!v || type !== "video") return; + const sync = () => setVideoMuted(v.muted); + v.addEventListener("volumechange", sync); + sync(); + return () => v.removeEventListener("volumechange", sync); + }, [type, mediaFiles]); + + return ( + <div className="flex w-full flex-col overflow-hidden pb-2"> + <div className="flex text-sm justify-between py-2"> + <div className="flex items-center w-full"> + <div className="relative ml-2 flex h-[72px] w-[72px] shrink-0 items-center justify-center"> + <StoryAvatarOpen userId={user_id}> + <StoryRing + hasStory={storyMeta.hasStory} + hasUnviewed={storyMeta.hasUnviewed} + tierColor={verifyBadgeToTierColor(verify_badge)} + rounded="xl" + animated={false} + avatarSize={storyMeta.hasStory ? 67 : 72} + avatarRadius={12} + > + <div + className={`relative overflow-hidden rounded-2xl ${ + storyMeta.hasStory ? "h-full w-full" : "h-[72px] w-[72px]" + }`} + > + <Image + src={ + profile_image + ? buildStorageUrl(profile_image) + : "/images/fake-avatar.png" + } + alt={user_name} + fill + className="aspect-square rounded-2xl object-cover" + priority + unoptimized + /> + </div> + </StoryRing> + </StoryAvatarOpen> + </div> + <Link + href={`/users/${user_name}`} + className="font-semibold flex gap-2 flex-col-reverse ml-2 w-full min-w-0" + > + <div className="flex items-center gap-1 mt-1"> + <h2 className="max-sm:text-xs inline-flex items-center gap-1"> + {user_name} + <VerificationBadge verifyBadge={verify_badge} /> + </h2> + <ScoreRateStats score={user_score} rate={rate} /> + </div> + <div className="flex justify-between items-center"> + <div className="max-sm:text-xs"> + {first_name} {last_name} + </div> + <div className=""> + <div className="text-[#15a9d6] flex gap-5 font-semibold"> + {subExpertise && matchedExpertise2.length > 0 && ( + <span> + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M5.99953 5.17031L4.02953 6.46031C2.09953 7.72031 2.09953 10.5403 4.02953 11.8003L10.0495 15.7303C11.1295 16.4403 12.9095 16.4403 13.9895 15.7303L19.9795 11.8003C21.8995 10.5403 21.8995 7.73031 19.9795 6.47031L13.9895 2.54031C12.9095 1.83031 11.1295 1.83031 10.0495 2.54031" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M5.63012 13.0801L5.62012 17.7701C5.62012 19.0401 6.60012 20.4001 7.80012 20.8001L10.9901 21.8601C11.5401 22.0401 12.4501 22.0401 13.0101 21.8601L16.2001 20.8001C17.4001 20.4001 18.3801 19.0401 18.3801 17.7701V13.1301" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M21.4004 15V9" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + </span> + )} + {subExpertise && matchedExpertise3.length > 0 && ( + <span> + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M2 22H22" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M12 2C13.6 2.64 15.4 2.64 17 2V5C15.4 5.64 13.6 5.64 12 5V2Z" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M12 5V8" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M4 15.91V22H20V11C20 9 19 8 17 8H7C5 8 4 9 4 11V12" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M4 12H19.42" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M7.98999 12V22" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinejoin="round" + /> + <path + d="M11.99 12V22" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinejoin="round" + /> + <path + d="M15.99 12V22" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinejoin="round" + /> + </svg> + </span> + )} + </div> + </div> + </div> + <div className="max-sm:text-xs flex w-full flex-col gap-0.5 text-[#387E65]"> + <div className="flex w-full items-center justify-between"> + <span>{user_level || t("models.newcomer")}</span> + <div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-0.5 pt-1 font-semibold"> + {city?.name ? <span>{localizedGeoName(city)}</span> : null} + {expertise ? <span>{expertise}</span> : null} + {displaySubExpertise ? <span>{displaySubExpertise}</span> : null} + </div> + </div> + </div> + </Link> + </div> + </div> + + <div + className={`overflow-hidden rounded-2xl ${onMediaClick ? "cursor-pointer" : ""}`} + onClick={onMediaClick} + onKeyDown={ + onMediaClick + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onMediaClick(); + } + } + : undefined + } + role={onMediaClick ? "button" : undefined} + tabIndex={onMediaClick ? 0 : undefined} + aria-label={onMediaClick ? t("models.fullscreen") : undefined} + > + {mediaType === "image" && mediaFiles.length > 0 && ( + <SwipeImageSlider + mediaFiles={mediaFiles} + setImageLoading={setImageLoading} + /> + )} + + {mediaType === "video" && videoSrc && ( + <div className="relative w-full rounded-2xl overflow-hidden bg-black"> + <video + onClick={(e) => { + if (onMediaClick) { + e.stopPropagation(); + onMediaClick(); + return; + } + handleVideoClick(e); + }} + ref={videoRef} + src={videoSrc} + poster={videoPoster} + loop + playsInline + className="w-full h-auto object-contain" + onLoadedData={() => setImageLoading(false)} + muted={muted} + /> + {ripple && ( + <span + className="absolute rounded-full bg-white/50 animate-ripple" + style={{ + left: ripple.x - 25, + top: ripple.y - 25, + width: 50, + height: 50, + }} + key={ripple.key} + /> + )} + {imageLoading && ( + <div className="absolute inset-0 flex justify-center items-center bg-black/30 z-10"> + <Image + height={40} + width={50} + src="/waiting.gif" + alt="waiting" + /> + </div> + )} + <div className="pointer-events-none absolute bottom-3 left-3 z-50 rounded-full bg-black/50 p-2 backdrop-blur-md"> + {videoMuted ? ( + <BoldIcon name="volume-slash" size={20} tinted className="text-white" aria-label={t("models.mute")} /> + ) : ( + <BoldIcon name="volume-high" size={20} tinted className="text-white" aria-label={t("models.unmute")} /> + )} + </div> + </div> + )} + </div> + + <MainModelCardActions postData={postData} /> + {caption?.includes("@") && ( + <div className="mx-2 mt-1 flex items-center gap-1 text-xs text-neutral-500"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden> + <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" /> + </svg> + <span>{t("models.taggedPeople")}</span> + </div> + )} + {(captionBody || captionLinks) && ( + <div className="mx-2 mt-2"> + <ExpandableCaption + body={captionBody} + links={captionLinks} + expanded={showFullCaption} + onToggle={() => setShowFullCaption((v) => !v)} + /> + </div> + )} + + <style jsx>{` + .animate-ripple { + animation: ripple 600ms ease-out; + pointer-events: none; + } + @keyframes ripple { + 0% { + transform: scale(0); + opacity: 0.5; + } + 100% { + transform: scale(4); + opacity: 0; + } + } + `}</style> + </div> + ); +} + +// Provider برای به اشتراک گذاشتن وضعیت muted +export const MutedProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [muted, setMuted] = useState(true); + + return ( + <MutedContext.Provider value={{ muted, setMuted }}> + {children} + </MutedContext.Provider> + ); +}; + +export default MainModelCard; diff --git a/src/components/models/MainModelCard/MainModelCardActions.tsx b/src/components/models/MainModelCard/MainModelCardActions.tsx index c6ad233..8c36294 100644 --- a/src/components/models/MainModelCard/MainModelCardActions.tsx +++ b/src/components/models/MainModelCard/MainModelCardActions.tsx @@ -38,6 +38,11 @@ function MainModelCardActions({ postData }: { postData: Post }) { const social = usePostSocialState(postData); const iconSize = "sm" as const; + // عین allowSave توی ReelsPostCard.tsx/ReelsPostActions.tsx — این کارت (فید خانه) این + // چک رو نداشت و دکمه‌ی ذخیره همیشه نمایش داده می‌شد، حتی وقتی نویسنده allow_save_posts + // رو غیرفعال کرده بود (بک‌اند خودِ toggleBookmark درخواست رو رد می‌کرد، ولی دکمه هنوز + // قابل‌لمس بود و کاربر فقط با خطا مواجه می‌شد) + const allowSave = postData.author_allow_save !== false; return ( <> @@ -132,17 +137,19 @@ function MainModelCardActions({ postData }: { postData: Post }) { <PostSocialIcon {...POST_SOCIAL_ICONS.share(iconSize)} /> } /> - <SocialStatButton - layout="horizontal" - count={social.savesCount} - onClick={social.toggleSave} - aria-label={social.saved ? t("models.actions.favoriteRemoveAria") : t("models.actions.favoriteAria")} - icon={ - <PostSocialIcon - {...POST_SOCIAL_ICONS.save(social.saved, iconSize)} - /> - } - /> + {allowSave ? ( + <SocialStatButton + layout="horizontal" + count={social.savesCount} + onClick={social.toggleSave} + aria-label={social.saved ? t("models.actions.favoriteRemoveAria") : t("models.actions.favoriteAria")} + icon={ + <PostSocialIcon + {...POST_SOCIAL_ICONS.save(social.saved, iconSize)} + /> + } + /> + ) : null} <SocialStatButton layout="horizontal" count={social.commentCount} diff --git a/src/components/models/MainModelCard/SwipeImageSlider.tsx b/src/components/models/MainModelCard/SwipeImageSlider.tsx index c4b0d9a..57dc79e 100644 --- a/src/components/models/MainModelCard/SwipeImageSlider.tsx +++ b/src/components/models/MainModelCard/SwipeImageSlider.tsx @@ -1,188 +1,188 @@ -"use client"; - -import React, { useState, useRef, useEffect } from "react"; -import Image from "next/image"; -import BoldIcon from "@/components/ui/BoldIcon"; -import { cn } from "@/lib/utils"; -import { useTranslation } from "react-i18next"; - -interface MediaFile { - src: string; - type?: "image" | "video"; -} - -interface SwipeImageSliderProps { - mediaFiles: MediaFile[]; - setImageLoading?: React.Dispatch<React.SetStateAction<boolean>>; -} - -const SwipeImageSlider: React.FC<SwipeImageSliderProps> = ({ - mediaFiles, - setImageLoading, -}) => { - const { t } = useTranslation("common"); - const [currentIndex, setCurrentIndex] = useState(0); - const [isDragging, setIsDragging] = useState(false); - const startX = useRef<number | null>(null); - const sliderRef = useRef<HTMLDivElement>(null); - - useEffect(() => { - setCurrentIndex(0); - }, [mediaFiles.length]); - - const goToPrev = () => { - setCurrentIndex((prev) => (prev > 0 ? prev - 1 : mediaFiles.length - 1)); - }; - - const goToNext = () => { - setCurrentIndex((prev) => (prev < mediaFiles.length - 1 ? prev + 1 : 0)); - }; - - const handleStart = (clientX: number) => { - startX.current = clientX; - setIsDragging(true); - }; - - const handleEnd = (clientX: number) => { - if (!startX.current || !isDragging) return; - - const diff = clientX - startX.current; - if (Math.abs(diff) > 50) { - if (diff > 0) { - goToPrev(); - } else { - goToNext(); - } - } - - startX.current = null; - setIsDragging(false); - }; - - const handleMouseDown = (e: React.MouseEvent) => handleStart(e.clientX); - const handleMouseUp = (e: React.MouseEvent) => handleEnd(e.clientX); - const handleTouchStart = (e: React.TouchEvent) => - handleStart(e.touches[0].clientX); - const handleTouchEnd = (e: React.TouchEvent) => - handleEnd(e.changedTouches[0].clientX); - - if (!mediaFiles || mediaFiles.length === 0) { - return ( - <div className="flex h-64 w-full items-center justify-center rounded-2xl bg-neutral-200 dark:bg-neutral-800"> - <p className="text-neutral-500">{t("models.noImage")}</p> - </div> - ); - } - - const navButtonClass = - "absolute top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center transition-transform active:scale-90 drop-shadow-[0_1px_4px_rgba(0,0,0,0.45)]"; - - return ( - <div className="group relative w-full select-none"> - <div - ref={sliderRef} - className="flex flex-row-reverse overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-900" - onMouseDown={handleMouseDown} - onMouseUp={handleMouseUp} - onMouseLeave={() => setIsDragging(false)} - onTouchStart={handleTouchStart} - onTouchEnd={handleTouchEnd} - style={{ cursor: isDragging ? "grabbing" : "grab" }} - > - <div - className="flex flex-row-reverse transition-transform duration-500 ease-out" - style={{ - transform: `translateX(-${currentIndex * 100}%)`, - width: `${mediaFiles.length * 100}%`, - }} - > - {mediaFiles.map((file, idx) => ( - <div - key={idx} - className="flex w-full shrink-0 items-center justify-center p-2" - style={{ width: "100%" }} - > - {file.type === "video" ? ( - <video - src={file.src} - controls - className="max-h-[60vh] w-auto rounded-xl shadow-lg" - onLoadedData={() => setImageLoading?.(false)} - /> - ) : ( - <Image - src={file.src} - alt={`media-${idx}`} - width={1000} - height={1000} - priority={idx === 0} - loading={idx === 0 ? "eager" : "lazy"} - className="min-h-[30vh] w-full rounded-xl object-contain shadow-lg transition-opacity duration-300" - onLoadingComplete={() => setImageLoading?.(false)} - unoptimized - /> - )} - </div> - ))} - </div> - </div> - - {mediaFiles.length > 1 && ( - <> - <button - type="button" - onClick={goToPrev} - className={cn( - navButtonClass, - "left-3 opacity-100 sm:opacity-0 sm:group-hover:opacity-100" - )} - aria-label={t("models.prevImage")} - > - <BoldIcon - name="arrow-circle-left" - size={36} - tinted - className="text-white" - /> - </button> - <button - type="button" - onClick={goToNext} - className={cn( - navButtonClass, - "right-3 opacity-100 sm:opacity-0 sm:group-hover:opacity-100" - )} - aria-label={t("models.nextImage")} - > - <BoldIcon - name="arrow-circle-right" - size={36} - tinted - className="text-white" - /> - </button> - </> - )} - - {mediaFiles.length > 1 && ( - <div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 flex-row-reverse gap-1"> - {mediaFiles.map((_, idx) => ( - <button - key={idx} - type="button" - onClick={() => setCurrentIndex(idx)} - className={`h-1.5 rounded-full transition-all duration-300 ${ - idx === currentIndex - ? "w-6 bg-white" - : "w-1.5 bg-white/50 hover:bg-white/80" - }`} - aria-label={t("models.goToSlide", { n: idx + 1 })} - /> - ))} - </div> - )} - </div> - ); -}; - -export default SwipeImageSlider; +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import Image from "next/image"; +import BoldIcon from "@/components/ui/BoldIcon"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; + +interface MediaFile { + src: string; + type?: "image" | "video"; +} + +interface SwipeImageSliderProps { + mediaFiles: MediaFile[]; + setImageLoading?: React.Dispatch<React.SetStateAction<boolean>>; +} + +const SwipeImageSlider: React.FC<SwipeImageSliderProps> = ({ + mediaFiles, + setImageLoading, +}) => { + const { t } = useTranslation("common"); + const [currentIndex, setCurrentIndex] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const startX = useRef<number | null>(null); + const sliderRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + setCurrentIndex(0); + }, [mediaFiles.length]); + + const goToPrev = () => { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : mediaFiles.length - 1)); + }; + + const goToNext = () => { + setCurrentIndex((prev) => (prev < mediaFiles.length - 1 ? prev + 1 : 0)); + }; + + const handleStart = (clientX: number) => { + startX.current = clientX; + setIsDragging(true); + }; + + const handleEnd = (clientX: number) => { + if (!startX.current || !isDragging) return; + + const diff = clientX - startX.current; + if (Math.abs(diff) > 50) { + if (diff > 0) { + goToPrev(); + } else { + goToNext(); + } + } + + startX.current = null; + setIsDragging(false); + }; + + const handleMouseDown = (e: React.MouseEvent) => handleStart(e.clientX); + const handleMouseUp = (e: React.MouseEvent) => handleEnd(e.clientX); + const handleTouchStart = (e: React.TouchEvent) => + handleStart(e.touches[0].clientX); + const handleTouchEnd = (e: React.TouchEvent) => + handleEnd(e.changedTouches[0].clientX); + + if (!mediaFiles || mediaFiles.length === 0) { + return ( + <div className="flex h-64 w-full items-center justify-center rounded-2xl bg-neutral-200 dark:bg-neutral-800"> + <p className="text-neutral-500">{t("models.noImage")}</p> + </div> + ); + } + + const navButtonClass = + "absolute top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center transition-transform active:scale-90 drop-shadow-[0_1px_4px_rgba(0,0,0,0.45)]"; + + return ( + <div className="group relative w-full select-none"> + <div + ref={sliderRef} + className="flex flex-row-reverse overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-900" + onMouseDown={handleMouseDown} + onMouseUp={handleMouseUp} + onMouseLeave={() => setIsDragging(false)} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + style={{ cursor: isDragging ? "grabbing" : "grab" }} + > + <div + className="flex flex-row-reverse transition-transform duration-500 ease-out" + style={{ + transform: `translateX(-${currentIndex * 100}%)`, + width: `${mediaFiles.length * 100}%`, + }} + > + {mediaFiles.map((file, idx) => ( + <div + key={idx} + className="flex w-full shrink-0 items-center justify-center p-2" + style={{ width: "100%" }} + > + {file.type === "video" ? ( + <video + src={file.src} + controls + className="max-h-[60vh] w-auto rounded-2xl shadow-lg" + onLoadedData={() => setImageLoading?.(false)} + /> + ) : ( + <Image + src={file.src} + alt={`media-${idx}`} + width={1000} + height={1000} + priority={idx === 0} + loading={idx === 0 ? "eager" : "lazy"} + className="min-h-[30vh] w-full rounded-2xl object-contain shadow-lg transition-opacity duration-300" + onLoadingComplete={() => setImageLoading?.(false)} + unoptimized + /> + )} + </div> + ))} + </div> + </div> + + {mediaFiles.length > 1 && ( + <> + <button + type="button" + onClick={goToPrev} + className={cn( + navButtonClass, + "left-3 opacity-100 sm:opacity-0 sm:group-hover:opacity-100" + )} + aria-label={t("models.prevImage")} + > + <BoldIcon + name="arrow-circle-left" + size={36} + tinted + className="text-white" + /> + </button> + <button + type="button" + onClick={goToNext} + className={cn( + navButtonClass, + "right-3 opacity-100 sm:opacity-0 sm:group-hover:opacity-100" + )} + aria-label={t("models.nextImage")} + > + <BoldIcon + name="arrow-circle-right" + size={36} + tinted + className="text-white" + /> + </button> + </> + )} + + {mediaFiles.length > 1 && ( + <div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 flex-row-reverse gap-1"> + {mediaFiles.map((_, idx) => ( + <button + key={idx} + type="button" + onClick={() => setCurrentIndex(idx)} + className={`h-1.5 rounded-full transition-all duration-300 ${ + idx === currentIndex + ? "w-6 bg-white" + : "w-1.5 bg-white/50 hover:bg-white/80" + }`} + aria-label={t("models.goToSlide", { n: idx + 1 })} + /> + ))} + </div> + )} + </div> + ); +}; + +export default SwipeImageSlider; diff --git a/src/components/models/ModelPage/LocationModal.tsx b/src/components/models/ModelPage/LocationModal.tsx index fb94027..c75aca4 100644 --- a/src/components/models/ModelPage/LocationModal.tsx +++ b/src/components/models/ModelPage/LocationModal.tsx @@ -5,6 +5,7 @@ import BoldIcon from "@/components/ui/BoldIcon"; import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; +import { localizedGeoName } from "@/utils/geoName"; interface LocationModalProps { isOpen: boolean; @@ -13,8 +14,8 @@ interface LocationModalProps { address?: string; lat?: string; lng?: string; - city?: { name: string }; - province?: { name: string }; + city?: { name: string; name_fa?: string }; + province?: { name: string; name_fa?: string }; }; } @@ -61,8 +62,8 @@ export default function LocationModal({ <div className="mb-6 text-sm dark:text-neutral-300 text-gray-600"> <p> <strong className="dark:text-neutral-100 text-gray-800">{t("models.location.city")} </strong> - {location?.city?.name || t("models.location.unknown")}،{" "} - {location?.province?.name || t("models.location.unknown")} + {localizedGeoName(location?.city) || t("models.location.unknown")}،{" "} + {localizedGeoName(location?.province) || t("models.location.unknown")} </p> {location?.address && location.address !== "hn y" && ( <p className="mt-3"> diff --git a/src/components/models/ModelPage/MainModelCardPost.tsx b/src/components/models/ModelPage/MainModelCardPost.tsx index 8c95058..f162722 100644 --- a/src/components/models/ModelPage/MainModelCardPost.tsx +++ b/src/components/models/ModelPage/MainModelCardPost.tsx @@ -1,446 +1,446 @@ -"use client"; - -import React, { useState, useRef, useEffect, useContext, createContext } from "react"; -import { Post } from "@/types/types"; -import Image from "next/image"; -import MainModelCardActions from "../MainModelCard/MainModelCardActions"; -import Link from "next/link"; -import CaptionWithMentions from "@/components/posts/CaptionWithMentions"; -import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; -import VerificationBadge from "@/components/main/VerificationBadge"; -import ScoreRateStats from "@/components/main/ScoreRateStats"; -import { useUserById } from "@/hooks/getUserById"; -import Slider from "react-slick"; -import "slick-carousel/slick/slick.css"; -import "slick-carousel/slick/slick-theme.css"; -import { useTranslation } from "react-i18next"; -import { getPostVideoSrc } from "@/lib/videoPreload"; -import { getVideoPoster } from "@/lib/explore/postMedia"; -import { resolvePostMediaType } from "@/lib/reelsMediaType"; - -// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارت‌ها -export const MutedContext = createContext<{ - muted: boolean; - setMuted: React.Dispatch<React.SetStateAction<boolean>>; -}>({ - muted: true, - setMuted: () => {}, -}); - -function MainModelCard({ postData }: { postData: Post }) { - const { - user_id, - user_name, - first_name, - last_name, - expertise, - city, - user_level, - caption, - files, - type, - } = postData; - - const { t } = useTranslation("common"); - const user = useUserById(user_id); - const profile_image = user?.profile_image; - const verify_badge = user?.verify_badge; - const user_score = user?.user_score; - const rate = user?.rate; -// console.log(user); - - const [showFullCaption, setShowFullCaption] = useState(false); - const [currentSlide, setCurrentSlide] = useState(0); - const [imageLoading, setImageLoading] = useState(true); - const videoRef = useRef<HTMLVideoElement | null>(null); - const [ripple, setRipple] = useState<{ x: number; y: number; key: number } | null>(null); - - // استفاده از Context برای وضعیت muted گلوبال - const { muted, setMuted } = useContext(MutedContext); - - const displayCaption = showFullCaption ? caption : caption?.slice(0, 40); - - const subExpertise = user?.sub_expertise || []; - const keywords = ["نمونه کار دارم", "آرایشگر سیار", "عکاس سیار"]; - const keywords2 = ["آموزشگاه دارم"]; - const keywords3 = ["سالن دارم", "آتیله دارم"]; - - const matchedExpertise = subExpertise.filter((item) => - keywords.some((keyword) => item.includes(keyword)) - ); - const matchedExpertise2 = subExpertise.filter((item) => - keywords2.some((keyword) => item.includes(keyword)) - ); - const matchedExpertise3 = subExpertise.filter((item) => - keywords3.some((keyword) => item.includes(keyword)) - ); - - const mediaFiles = - files?.map((file) => ({ - ...file, - src: buildStorageUrl(file.path), - })) || []; - - const mediaType = resolvePostMediaType(postData) ?? type; - const videoSrc = - mediaType === "video" ? getPostVideoSrc(postData) : undefined; - const videoPoster = - mediaType === "video" ? getVideoPoster(postData) : undefined; - - const sliderSettings = { - dots: mediaFiles.length > 1, - infinite: mediaFiles.length > 1, - speed: 500, - slidesToShow: 1, - slidesToScroll: 1, - arrows: true, - autoplay: false, - adaptiveHeight: false, - lazyLoad: "progressive" as const, - afterChange: (index: number) => setCurrentSlide(index), - }; - - // تابع برای ایجاد افکت ripple و تغییر وضعیت صدا - const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => { - const v = videoRef.current; - if (!v) return; - - // گرفتن مختصات کلیک نسبت به ویدیو - const rect = v.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - // تنظیم افکت ripple - setRipple({ x, y, key: Date.now() }); - - // تغییر وضعیت صدا (گلوبال) - const newMuted = !muted; - setMuted(newMuted); - v.muted = newMuted; - if (!newMuted) { - v.play().catch(() => { - /* ignore */ - }); - } - - // حذف افکت بعد از اتمام انیمیشن - setTimeout(() => { - setRipple(null); - }, 600); // مدت زمان انیمیشن (600ms) - }; - - // استفاده از IntersectionObserver برای پخش/توقف ویدیو بر اساس ویوپورت - useEffect(() => { - const video = videoRef.current; - if (!video || type !== "video") return; - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - // ویدیو در ویوپورت است: پخش کن - video.play().catch(() => { - /* ignore: ممکن است به دلیل محدودیت‌های مرورگر */ - }); - video.muted = muted; // اعمال وضعیت گلوبال - } else { - // ویدیو خارج از ویوپورت: توقف کن - video.pause(); - } - }); - }, - { - threshold: 0.5, // حداقل 50% ویدیو باید در ویوپورت باشد - } - ); - - observer.observe(video); - - return () => { - observer.unobserve(video); - }; - }, [muted, type]); - - // اعمال وضعیت muted گلوبال هنگام mount یا تغییر muted - useEffect(() => { - const video = videoRef.current; - if (video) { - video.muted = muted; - } - }, [muted]); - - return ( - <div className="w-full z-[999] min-h-[85vh] max-h-[90vh] flex flex-col px-4 mx-auto max-w-[992px] mb-6 overflow-hidden"> - <Link - href={`/users/${user_name}`} - className="flex w-full text-sm justify-between py-2" - > - <div className="flex items-center w-full"> - <div className="relative max-w-[72px] min-w-[72px] max-h-[72px] min-h-[72px] ml-2"> - <Image - src={ - profile_image - ? buildStorageUrl(profile_image) - : "/images/fake-avatar.png" - } - alt={user_name} - fill - className="rounded-xl object-cover aspect-square" - priority - /> - </div> - <div className="font-semibold flex gap-2 flex-col-reverse ml-2 w-full"> - <div className="flex items-center gap-1 mt-1"> - <span className="max-sm:text-xs inline-flex items-center gap-1"> - {user_name} - <VerificationBadge verifyBadge={verify_badge} /> - </span> - <ScoreRateStats score={user_score} rate={rate} /> - </div> - <div className="flex justify-between items-center"> - <div className="max-sm:text-xs"> - {first_name} {last_name} - </div> - <div className="hidden"> - <div className="text-[#15a9d6] pt-1 flex gap-5 mt-3 font-semibold"> - {matchedExpertise2.length > 0 && ( - <span> - <svg - width="24" - height="24" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - xmlns="http://www.w3.org/2000/svg" - > - <path - d="M5.99953 5.17031L4.02953 6.46031C2.09953 7.72031 2.09953 10.5403 4.02953 11.8003L10.0495 15.7303C11.1295 16.4403 12.9095 16.4403 13.9895 15.7303L19.9795 11.8003C21.8995 10.5403 21.8995 7.73031 19.9795 6.47031L13.9895 2.54031C12.9095 1.83031 11.1295 1.83031 10.0495 2.54031" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M5.63012 13.0801L5.62012 17.7701C5.62012 19.0401 6.60012 20.4001 7.80012 20.8001L10.9901 21.8601C11.5401 22.0401 12.4501 22.0401 13.0101 21.8601L16.2001 20.8001C17.4001 20.4001 18.3801 19.0401 18.3801 17.7701V13.1301" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M21.4004 15V9" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - </svg> - </span> - )} - {matchedExpertise3.length > 0 && ( - <span> - <svg - width="24" - height="24" - viewBox="0 0 24 24" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > - <path - d="M2 22H22" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M12 2C13.6 2.64 15.4 2.64 17 2V5C15.4 5.64 13.6 5.64 12 5V2Z" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M12 5V8" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M4 15.91V22H20V11C20 9 19 8 17 8H7C5 8 4 9 4 11V12" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M4 12H19.42" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M7.98999 12V22" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinejoin="round" - /> - <path - d="M11.99 12V22" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinejoin="round" - /> - <path - d="M15.99 12V22" - stroke="currentColor" - strokeWidth="1.5" - strokeMiterlimit="10" - strokeLinejoin="round" - /> - </svg> - </span> - )} - </div> - </div> - </div> - <div className="max-sm:text-xs items-center text-[#387E65] flex w-full justify-between"> - {user_level || t("models.newcomer")} - <div className="text-[#387E65] pt-1 flex gap-5 font-semibold"> - <span>{expertise}</span> - <span className="text-[#387E65] font-semibold"> - {city?.name} - </span> - {matchedExpertise.map((item, index) => ( - <span key={index} className="text-[#387E65] font-semibold"> - {item} - </span> - ))} - </div> - </div> - </div> - </div> - </Link> - - <div className="relative flex-grow overflow-hidden rounded-2xl"> - {type === "image" && mediaFiles.length > 0 && ( - <div className="relative w-full h-full"> - <Slider {...sliderSettings}> - {mediaFiles.map((file, idx) => ( - <div - key={idx} - className="relative w-full h-[calc(100dvh-290px)] overflow-hidden" - > - <Image - src={file.src} - alt={`post-image-${idx}`} - fill - className="object-contain h-full" - onLoadingComplete={() => setImageLoading(false)} - loading={idx === 0 ? "eager" : "lazy"} - /> - {imageLoading && idx === currentSlide && ( - <div className="absolute inset-0 flex justify-center items-center bg-black/30 z-10"> - <div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div> - </div> - )} - </div> - ))} - </Slider> - {mediaFiles.length > 1 && ( - <div className="absolute bottom-2 right-2 bg-black/40 text-white px-3 py-1 rounded-full text-sm"> - {currentSlide + 1} / {mediaFiles.length} - </div> - )} - </div> - )} - {mediaType === "video" && videoSrc && ( - <div className="relative w-full h-[calc(100dvh-150px)] overflow-hidden"> - <video - onClick={handleVideoClick} - ref={videoRef} - src={videoSrc} - poster={videoPoster} - loop - playsInline - className="w-full h-full object-contain" - onLoadedData={() => setImageLoading(false)} - muted={muted} // این مقدار همیشه از گلوبال گرفته می‌شود - // autoPlay حذف شده، چون حالا با observer مدیریت می‌شود - /> - {ripple && ( - <span - className="absolute rounded-full bg-white/50 animate-ripple" - style={{ - left: ripple.x - 25, // نصف عرض دایره - top: ripple.y - 25, // نصف ارتفاع دایره - width: 50, - height: 50, - }} - key={ripple.key} - /> - )} - {imageLoading && ( - <div className="absolute inset-0 flex justify-center items-center bg-black/30 z-10"> - <div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div> - </div> - )} - - </div> - )} - </div> - - <MainModelCardActions postData={postData} /> - <p className="text-sm font-medium mt-2 mx-2 leading-6 whitespace-pre-wrap"> - <CaptionWithMentions - text={displayCaption || ""} - fullText={showFullCaption ? undefined : caption} - /> - {caption?.length && caption.length > 40 && ( - <span - className="text-blue-500 hover:underline cursor-pointer ml-1 transition-colors duration-200" - onClick={() => setShowFullCaption(!showFullCaption)} - > - {showFullCaption ? t("posts.less") : t("posts.more")} - </span> - )} - </p> - - <style jsx>{` - .animate-ripple { - animation: ripple 600ms ease-out; - pointer-events: none; - } - @keyframes ripple { - 0% { - transform: scale(0); - opacity: 0.5; - } - 100% { - transform: scale(4); - opacity: 0; - } - } - `}</style> - </div> - ); -} - -// Provider برای به اشتراک گذاشتن وضعیت muted -export const MutedProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [muted, setMuted] = useState(true); - - return ( - <MutedContext.Provider value={{ muted, setMuted }}> - {children} - </MutedContext.Provider> - ); -}; - -export default MainModelCard; +"use client"; + +import React, { useState, useRef, useEffect, useContext, createContext } from "react"; +import { Post } from "@/types/types"; +import Image from "next/image"; +import MainModelCardActions from "../MainModelCard/MainModelCardActions"; +import Link from "next/link"; +import CaptionWithMentions from "@/components/posts/CaptionWithMentions"; +import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; +import VerificationBadge from "@/components/main/VerificationBadge"; +import ScoreRateStats from "@/components/main/ScoreRateStats"; +import { useUserById } from "@/hooks/getUserById"; +import Slider from "react-slick"; +import "slick-carousel/slick/slick.css"; +import "slick-carousel/slick/slick-theme.css"; +import { useTranslation } from "react-i18next"; +import { getPostVideoSrc } from "@/lib/videoPreload"; +import { getVideoPoster } from "@/lib/explore/postMedia"; +import { resolvePostMediaType } from "@/lib/reelsMediaType"; + +// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارت‌ها +export const MutedContext = createContext<{ + muted: boolean; + setMuted: React.Dispatch<React.SetStateAction<boolean>>; +}>({ + muted: true, + setMuted: () => {}, +}); + +function MainModelCard({ postData }: { postData: Post }) { + const { + user_id, + user_name, + first_name, + last_name, + expertise, + city, + user_level, + caption, + files, + type, + } = postData; + + const { t } = useTranslation("common"); + const user = useUserById(user_id); + const profile_image = user?.profile_image; + const verify_badge = user?.verify_badge; + const user_score = user?.user_score; + const rate = user?.rate; +// console.log(user); + + const [showFullCaption, setShowFullCaption] = useState(false); + const [currentSlide, setCurrentSlide] = useState(0); + const [imageLoading, setImageLoading] = useState(true); + const videoRef = useRef<HTMLVideoElement | null>(null); + const [ripple, setRipple] = useState<{ x: number; y: number; key: number } | null>(null); + + // استفاده از Context برای وضعیت muted گلوبال + const { muted, setMuted } = useContext(MutedContext); + + const displayCaption = showFullCaption ? caption : caption?.slice(0, 40); + + const subExpertise = user?.sub_expertise || []; + const keywords = ["نمونه کار دارم", "آرایشگر سیار", "عکاس سیار"]; + const keywords2 = ["آموزشگاه دارم"]; + const keywords3 = ["سالن دارم", "آتیله دارم"]; + + const matchedExpertise = subExpertise.filter((item) => + keywords.some((keyword) => item.includes(keyword)) + ); + const matchedExpertise2 = subExpertise.filter((item) => + keywords2.some((keyword) => item.includes(keyword)) + ); + const matchedExpertise3 = subExpertise.filter((item) => + keywords3.some((keyword) => item.includes(keyword)) + ); + + const mediaFiles = + files?.map((file) => ({ + ...file, + src: buildStorageUrl(file.path), + })) || []; + + const mediaType = resolvePostMediaType(postData) ?? type; + const videoSrc = + mediaType === "video" ? getPostVideoSrc(postData) : undefined; + const videoPoster = + mediaType === "video" ? getVideoPoster(postData) : undefined; + + const sliderSettings = { + dots: mediaFiles.length > 1, + infinite: mediaFiles.length > 1, + speed: 500, + slidesToShow: 1, + slidesToScroll: 1, + arrows: true, + autoplay: false, + adaptiveHeight: false, + lazyLoad: "progressive" as const, + afterChange: (index: number) => setCurrentSlide(index), + }; + + // تابع برای ایجاد افکت ripple و تغییر وضعیت صدا + const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => { + const v = videoRef.current; + if (!v) return; + + // گرفتن مختصات کلیک نسبت به ویدیو + const rect = v.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // تنظیم افکت ripple + setRipple({ x, y, key: Date.now() }); + + // تغییر وضعیت صدا (گلوبال) + const newMuted = !muted; + setMuted(newMuted); + v.muted = newMuted; + if (!newMuted) { + v.play().catch(() => { + /* ignore */ + }); + } + + // حذف افکت بعد از اتمام انیمیشن + setTimeout(() => { + setRipple(null); + }, 600); // مدت زمان انیمیشن (600ms) + }; + + // استفاده از IntersectionObserver برای پخش/توقف ویدیو بر اساس ویوپورت + useEffect(() => { + const video = videoRef.current; + if (!video || type !== "video") return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + // ویدیو در ویوپورت است: پخش کن + video.play().catch(() => { + /* ignore: ممکن است به دلیل محدودیت‌های مرورگر */ + }); + video.muted = muted; // اعمال وضعیت گلوبال + } else { + // ویدیو خارج از ویوپورت: توقف کن + video.pause(); + } + }); + }, + { + threshold: 0.5, // حداقل 50% ویدیو باید در ویوپورت باشد + } + ); + + observer.observe(video); + + return () => { + observer.unobserve(video); + }; + }, [muted, type]); + + // اعمال وضعیت muted گلوبال هنگام mount یا تغییر muted + useEffect(() => { + const video = videoRef.current; + if (video) { + video.muted = muted; + } + }, [muted]); + + return ( + <div className="w-full z-[999] min-h-[85vh] max-h-[90vh] flex flex-col px-4 mx-auto max-w-[992px] mb-6 overflow-hidden"> + <Link + href={`/users/${user_name}`} + className="flex w-full text-sm justify-between py-2" + > + <div className="flex items-center w-full"> + <div className="relative max-w-[72px] min-w-[72px] max-h-[72px] min-h-[72px] ml-2"> + <Image + src={ + profile_image + ? buildStorageUrl(profile_image) + : "/images/fake-avatar.png" + } + alt={user_name} + fill + className="rounded-2xl object-cover aspect-square" + priority + /> + </div> + <div className="font-semibold flex gap-2 flex-col-reverse ml-2 w-full"> + <div className="flex items-center gap-1 mt-1"> + <span className="max-sm:text-xs inline-flex items-center gap-1"> + {user_name} + <VerificationBadge verifyBadge={verify_badge} /> + </span> + <ScoreRateStats score={user_score} rate={rate} /> + </div> + <div className="flex justify-between items-center"> + <div className="max-sm:text-xs"> + {first_name} {last_name} + </div> + <div className="hidden"> + <div className="text-[#15a9d6] pt-1 flex gap-5 mt-3 font-semibold"> + {matchedExpertise2.length > 0 && ( + <span> + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M5.99953 5.17031L4.02953 6.46031C2.09953 7.72031 2.09953 10.5403 4.02953 11.8003L10.0495 15.7303C11.1295 16.4403 12.9095 16.4403 13.9895 15.7303L19.9795 11.8003C21.8995 10.5403 21.8995 7.73031 19.9795 6.47031L13.9895 2.54031C12.9095 1.83031 11.1295 1.83031 10.0495 2.54031" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M5.63012 13.0801L5.62012 17.7701C5.62012 19.0401 6.60012 20.4001 7.80012 20.8001L10.9901 21.8601C11.5401 22.0401 12.4501 22.0401 13.0101 21.8601L16.2001 20.8001C17.4001 20.4001 18.3801 19.0401 18.3801 17.7701V13.1301" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M21.4004 15V9" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + </span> + )} + {matchedExpertise3.length > 0 && ( + <span> + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M2 22H22" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M12 2C13.6 2.64 15.4 2.64 17 2V5C15.4 5.64 13.6 5.64 12 5V2Z" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M12 5V8" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M4 15.91V22H20V11C20 9 19 8 17 8H7C5 8 4 9 4 11V12" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M4 12H19.42" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M7.98999 12V22" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinejoin="round" + /> + <path + d="M11.99 12V22" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinejoin="round" + /> + <path + d="M15.99 12V22" + stroke="currentColor" + strokeWidth="1.5" + strokeMiterlimit="10" + strokeLinejoin="round" + /> + </svg> + </span> + )} + </div> + </div> + </div> + <div className="max-sm:text-xs items-center text-[#387E65] flex w-full justify-between"> + {user_level || t("models.newcomer")} + <div className="text-[#387E65] pt-1 flex gap-5 font-semibold"> + <span>{expertise}</span> + <span className="text-[#387E65] font-semibold"> + {city?.name} + </span> + {matchedExpertise.map((item, index) => ( + <span key={index} className="text-[#387E65] font-semibold"> + {item} + </span> + ))} + </div> + </div> + </div> + </div> + </Link> + + <div className="relative flex-grow overflow-hidden rounded-2xl"> + {type === "image" && mediaFiles.length > 0 && ( + <div className="relative w-full h-full"> + <Slider {...sliderSettings}> + {mediaFiles.map((file, idx) => ( + <div + key={idx} + className="relative w-full h-[calc(100dvh-290px)] overflow-hidden" + > + <Image + src={file.src} + alt={`post-image-${idx}`} + fill + className="object-contain h-full" + onLoadingComplete={() => setImageLoading(false)} + loading={idx === 0 ? "eager" : "lazy"} + /> + {imageLoading && idx === currentSlide && ( + <div className="absolute inset-0 flex justify-center items-center bg-black/30 z-10"> + <div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div> + </div> + )} + </div> + ))} + </Slider> + {mediaFiles.length > 1 && ( + <div className="absolute bottom-2 right-2 bg-black/40 text-white px-3 py-1 rounded-full text-sm"> + {currentSlide + 1} / {mediaFiles.length} + </div> + )} + </div> + )} + {mediaType === "video" && videoSrc && ( + <div className="relative w-full h-[calc(100dvh-150px)] overflow-hidden"> + <video + onClick={handleVideoClick} + ref={videoRef} + src={videoSrc} + poster={videoPoster} + loop + playsInline + className="w-full h-full object-contain" + onLoadedData={() => setImageLoading(false)} + muted={muted} // این مقدار همیشه از گلوبال گرفته می‌شود + // autoPlay حذف شده، چون حالا با observer مدیریت می‌شود + /> + {ripple && ( + <span + className="absolute rounded-full bg-white/50 animate-ripple" + style={{ + left: ripple.x - 25, // نصف عرض دایره + top: ripple.y - 25, // نصف ارتفاع دایره + width: 50, + height: 50, + }} + key={ripple.key} + /> + )} + {imageLoading && ( + <div className="absolute inset-0 flex justify-center items-center bg-black/30 z-10"> + <div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div> + </div> + )} + + </div> + )} + </div> + + <MainModelCardActions postData={postData} /> + <p className="text-sm font-medium mt-2 mx-2 leading-6 whitespace-pre-wrap"> + <CaptionWithMentions + text={displayCaption || ""} + fullText={showFullCaption ? undefined : caption} + /> + {caption?.length && caption.length > 40 && ( + <span + className="text-blue-500 hover:underline cursor-pointer ml-1 transition-colors duration-200" + onClick={() => setShowFullCaption(!showFullCaption)} + > + {showFullCaption ? t("posts.less") : t("posts.more")} + </span> + )} + </p> + + <style jsx>{` + .animate-ripple { + animation: ripple 600ms ease-out; + pointer-events: none; + } + @keyframes ripple { + 0% { + transform: scale(0); + opacity: 0.5; + } + 100% { + transform: scale(4); + opacity: 0; + } + } + `}</style> + </div> + ); +} + +// Provider برای به اشتراک گذاشتن وضعیت muted +export const MutedProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [muted, setMuted] = useState(true); + + return ( + <MutedContext.Provider value={{ muted, setMuted }}> + {children} + </MutedContext.Provider> + ); +}; + +export default MainModelCard; diff --git a/src/components/models/ModelPage/ModelContent.tsx b/src/components/models/ModelPage/ModelContent.tsx index 526cfea..cf14fa5 100644 --- a/src/components/models/ModelPage/ModelContent.tsx +++ b/src/components/models/ModelPage/ModelContent.tsx @@ -335,7 +335,11 @@ function ModelContent({ user, searchParams }: ModelContentProps) { <button type="button" onClick={goToGift} - className={profileActionBtnClass} + disabled={!isOwnProfile && user?.gift_enabled === false} + className={cn( + profileActionBtnClass, + !isOwnProfile && user?.gift_enabled === false && "cursor-not-allowed opacity-40" + )} > {t("models.actions.specialGift")} </button> diff --git a/src/components/models/ModelPage/ModelContentPosts.tsx b/src/components/models/ModelPage/ModelContentPosts.tsx index 81d416b..898d322 100644 --- a/src/components/models/ModelPage/ModelContentPosts.tsx +++ b/src/components/models/ModelPage/ModelContentPosts.tsx @@ -131,7 +131,7 @@ function ModelContentPosts({ <button type="button" key={post._id} - className="relative aspect-square cursor-pointer overflow-hidden rounded-md sm:rounded-xl group focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-500" + className="relative aspect-square cursor-pointer overflow-hidden rounded-2xl group focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-500" onClick={() => openPost(post)} aria-label={t("models.viewPost")} > diff --git a/src/components/models/ModelPage/ProfileUserListModal.tsx b/src/components/models/ModelPage/ProfileUserListModal.tsx index ee91464..359e2ec 100644 --- a/src/components/models/ModelPage/ProfileUserListModal.tsx +++ b/src/components/models/ModelPage/ProfileUserListModal.tsx @@ -99,7 +99,7 @@ export default function ProfileUserListModal({ <li key={u._id}> <Link href={`/users/${u.user_name}`} - className="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800" + className="flex items-center gap-3 rounded-2xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800" onClick={onClose} > <div className="relative h-10 w-10 overflow-hidden rounded-full bg-neutral-200"> diff --git a/src/components/posts/PostLinkPicker.tsx b/src/components/posts/PostLinkPicker.tsx index dbe66a1..4281a72 100644 --- a/src/components/posts/PostLinkPicker.tsx +++ b/src/components/posts/PostLinkPicker.tsx @@ -128,7 +128,7 @@ function LinkSection({ <p className="text-xs font-semibold text-neutral-600 dark:text-neutral-300"> {label} ({t("posts.optional")}) </p> - <p className="rounded-xl border border-dashed border-neutral-200 px-3 py-2 text-xs text-neutral-400 dark:border-neutral-700"> + <p className="rounded-2xl border border-dashed border-neutral-200 px-3 py-2 text-xs text-neutral-400 dark:border-neutral-700"> {emptyText} </p> </div> @@ -140,7 +140,7 @@ function LinkSection({ <p className="text-xs font-semibold text-neutral-600 dark:text-neutral-300"> {label} ({t("posts.optionalMulti")}) </p> - <ul className="max-h-36 space-y-1.5 overflow-y-auto rounded-xl border border-neutral-200 p-2 dark:border-neutral-700"> + <ul className="max-h-36 space-y-1.5 overflow-y-auto rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700"> {items.map((item) => ( <LinkListItem key={item._id} @@ -224,7 +224,7 @@ function ShopLinkSection({ <p className="text-xs font-semibold text-neutral-600 dark:text-neutral-300"> {t("posts.shop")} ({t("posts.optional")}) </p> - <p className="rounded-xl border border-dashed border-neutral-200 px-3 py-2 text-xs text-neutral-400 dark:border-neutral-700"> + <p className="rounded-2xl border border-dashed border-neutral-200 px-3 py-2 text-xs text-neutral-400 dark:border-neutral-700"> {t("posts.noShop")} </p> </div> @@ -236,7 +236,7 @@ function ShopLinkSection({ <p className="text-xs font-semibold text-neutral-600 dark:text-neutral-300"> {t("posts.shop")} ({t("posts.optionalMulti")}) </p> - <ul className="max-h-64 space-y-1.5 overflow-y-auto rounded-xl border border-neutral-200 p-2 dark:border-neutral-700"> + <ul className="max-h-64 space-y-1.5 overflow-y-auto rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700"> {shops.map((shop) => { const isExpanded = expandedShopId === shop._id; const products = productsByShop[shop._id] || []; diff --git a/src/components/posts/ReelsPostAnalyticsModal.tsx b/src/components/posts/ReelsPostAnalyticsModal.tsx index b5b6efb..b8607e9 100644 --- a/src/components/posts/ReelsPostAnalyticsModal.tsx +++ b/src/components/posts/ReelsPostAnalyticsModal.tsx @@ -141,7 +141,7 @@ export default function ReelsPostAnalyticsModal({ {Array.from({ length: 6 }).map((_, i) => ( <div key={i} - className="h-10 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" + className="h-10 animate-pulse rounded-2xl bg-neutral-100 dark:bg-neutral-800" /> ))} </div> diff --git a/src/components/posts/ReelsPostOptionsMenu.tsx b/src/components/posts/ReelsPostOptionsMenu.tsx index 01238c0..4bca92b 100644 --- a/src/components/posts/ReelsPostOptionsMenu.tsx +++ b/src/components/posts/ReelsPostOptionsMenu.tsx @@ -248,7 +248,7 @@ export default function ReelsPostOptionsMenu({ <select value={reportReason} onChange={(e) => setReportReason(e.target.value)} - className="mb-3 w-full rounded-xl border border-neutral-200 bg-transparent px-3 py-2 text-sm dark:border-neutral-700" + className="mb-3 w-full rounded-2xl border border-neutral-200 bg-transparent px-3 py-2 text-sm dark:border-neutral-700" > <option value="">{t("posts.selectReason")}</option> {REPORT_REASON_IDS.map((id) => ( @@ -262,7 +262,7 @@ export default function ReelsPostOptionsMenu({ onChange={(e) => setReportText(e.target.value)} placeholder={t("posts.reportNotesPlaceholder")} rows={3} - className="mb-4 w-full resize-none rounded-xl border border-neutral-200 bg-transparent px-3 py-2 text-sm dark:border-neutral-700" + className="mb-4 w-full resize-none rounded-2xl border border-neutral-200 bg-transparent px-3 py-2 text-sm dark:border-neutral-700" /> <RoundedButton type="button" diff --git a/src/components/posts/SendPostModal.tsx b/src/components/posts/SendPostModal.tsx index 5612b2e..51d5cf1 100644 --- a/src/components/posts/SendPostModal.tsx +++ b/src/components/posts/SendPostModal.tsx @@ -130,7 +130,7 @@ export default function SendPostModal({ > <h3 className="mb-3 text-center text-sm font-bold">{t("posts.sendPost")}</h3> - <div className="mb-4 flex items-center gap-3 rounded-xl bg-neutral-100 p-2 dark:bg-neutral-800"> + <div className="mb-4 flex items-center gap-3 rounded-2xl bg-neutral-100 p-2 dark:bg-neutral-800"> {preview && ( <div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg"> <Image @@ -167,7 +167,7 @@ export default function SendPostModal({ <button type="button" onClick={() => toggle(u._id)} - className={`flex w-full items-center gap-3 rounded-xl px-2 py-2 text-right transition ${ + className={`flex w-full items-center gap-3 rounded-2xl px-2 py-2 text-right transition ${ checked ? "bg-pink-50 dark:bg-pink-950/30" : "" }`} > diff --git a/src/components/posts/TagUsersPicker.tsx b/src/components/posts/TagUsersPicker.tsx index 2bc8a3c..6ae4b18 100644 --- a/src/components/posts/TagUsersPicker.tsx +++ b/src/components/posts/TagUsersPicker.tsx @@ -1,201 +1,201 @@ -"use client"; - -import { useEffect, useState } from "react"; -import Image from "next/image"; -import useAxios from "@/hooks/useAxios"; -import { buildStorageUrl } from "@/components/main/BaseUrl"; -import BoldIcon from "@/components/ui/BoldIcon"; -import toast from "react-hot-toast"; -import { useTranslation } from "react-i18next"; - -export interface TaggedUser { - _id: string; - user_name: string; - first_name?: string; - last_name?: string; - profile_image?: string; -} - -interface TagUsersPickerProps { - selected: TaggedUser[]; - onChange: (users: TaggedUser[]) => void; - max?: number; -} - -function avatarSrc(profileImage?: string) { - if (!profileImage) return null; - return profileImage.startsWith("http") - ? profileImage - : buildStorageUrl(profileImage); -} - -function displayName(u: TaggedUser) { - return ( - [u.first_name, u.last_name].filter(Boolean).join(" ") || u.user_name - ); -} - -export default function TagUsersPicker({ - selected, - onChange, - max = 50, -}: TagUsersPickerProps) { - const { t } = useTranslation("common"); - const { request } = useAxios(); - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const [results, setResults] = useState<TaggedUser[]>([]); - const [searching, setSearching] = useState(false); - - useEffect(() => { - if (!open || query.trim().length < 2) { - setResults([]); - return; - } - const t = setTimeout(async () => { - setSearching(true); - try { - const res = await request<{ result?: TaggedUser[] }>( - "GET", - `/search-web?search=${encodeURIComponent(query.trim())}&type=user&limit=20`, - null, - { noToast: true } - ); - const list = res?.result || []; - setResults( - list.filter((u) => !selected.some((s) => s._id === u._id)) - ); - } catch { - setResults([]); - } finally { - setSearching(false); - } - }, 300); - return () => clearTimeout(t); - }, [query, open, request, selected]); - - const addUser = (u: TaggedUser) => { - if (selected.some((s) => s._id === u._id)) return; - if (selected.length >= max) { - toast.error(t("posts.maxTagged", { max })); - return; - } - onChange([...selected, u]); - setQuery(""); - }; - - const removeUser = (id: string) => { - onChange(selected.filter((u) => u._id !== id)); - }; - - return ( - <div className="w-full"> - <button - type="button" - onClick={() => setOpen((o) => !o)} - className="flex w-full items-center justify-center gap-2 rounded-full border border-neutral-200 py-2.5 text-sm font-medium transition active:scale-[0.98] dark:border-neutral-700" - > - <BoldIcon name="user-add" size={20} tinted className="text-current" /> - {t("posts.tagPeople", { count: selected.length, max })} - </button> - - {selected.length > 0 && ( - <div className="mt-3 flex flex-wrap gap-2"> - {selected.map((u) => { - const src = avatarSrc(u.profile_image); - return ( - <span - key={u._id} - className="flex items-center gap-2 rounded-full bg-neutral-100 py-1 pe-2 ps-1 dark:bg-neutral-800" - > - <div className="relative h-7 w-7 shrink-0 overflow-hidden rounded-full bg-neutral-200"> - {src ? ( - <Image - src={src} - alt="" - fill - className="object-cover" - unoptimized - /> - ) : null} - </div> - <span className="text-xs font-medium"> - {displayName(u)} - <span className="mx-1 text-neutral-400">·</span> - @{u.user_name} - </span> - <button - type="button" - onClick={() => removeUser(u._id)} - aria-label={t("posts.remove")} - > - <BoldIcon - name="close-circle" - size={16} - tinted - className="text-neutral-400" - /> - </button> - </span> - ); - })} - </div> - )} - - {open && ( - <div className="mt-3 rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700"> - <input - value={query} - onChange={(e) => setQuery(e.target.value)} - placeholder={t("posts.searchUsersPlaceholder")} - className="w-full rounded-full bg-neutral-100 px-4 py-2.5 text-sm outline-none dark:bg-neutral-800" - autoFocus - /> - {searching && ( - <p className="mt-2 text-center text-xs text-neutral-400"> - {t("posts.searching")} - </p> - )} - {!searching && query.trim().length >= 2 && results.length === 0 && ( - <p className="mt-2 text-center text-xs text-neutral-400"> - {t("posts.noUsersFound")} - </p> - )} - <ul className="mt-2 max-h-52 space-y-1 overflow-y-auto"> - {results.map((u) => { - const src = avatarSrc(u.profile_image); - const name = displayName(u); - return ( - <li key={u._id}> - <button - type="button" - onClick={() => addUser(u)} - className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-right transition hover:bg-neutral-100 dark:hover:bg-neutral-800" - > - <div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200"> - {src ? ( - <Image - src={src} - alt="" - fill - className="object-cover" - unoptimized - /> - ) : null} - </div> - <div className="min-w-0 flex-1 text-right"> - <p className="truncate text-sm font-medium">{name}</p> - <p className="truncate text-xs text-neutral-500"> - @{u.user_name} - </p> - </div> - </button> - </li> - ); - })} - </ul> - </div> - )} - </div> - ); -} +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import useAxios from "@/hooks/useAxios"; +import { buildStorageUrl } from "@/components/main/BaseUrl"; +import BoldIcon from "@/components/ui/BoldIcon"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; + +export interface TaggedUser { + _id: string; + user_name: string; + first_name?: string; + last_name?: string; + profile_image?: string; +} + +interface TagUsersPickerProps { + selected: TaggedUser[]; + onChange: (users: TaggedUser[]) => void; + max?: number; +} + +function avatarSrc(profileImage?: string) { + if (!profileImage) return null; + return profileImage.startsWith("http") + ? profileImage + : buildStorageUrl(profileImage); +} + +function displayName(u: TaggedUser) { + return ( + [u.first_name, u.last_name].filter(Boolean).join(" ") || u.user_name + ); +} + +export default function TagUsersPicker({ + selected, + onChange, + max = 50, +}: TagUsersPickerProps) { + const { t } = useTranslation("common"); + const { request } = useAxios(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [results, setResults] = useState<TaggedUser[]>([]); + const [searching, setSearching] = useState(false); + + useEffect(() => { + if (!open || query.trim().length < 2) { + setResults([]); + return; + } + const t = setTimeout(async () => { + setSearching(true); + try { + const res = await request<{ result?: TaggedUser[] }>( + "GET", + `/search-web?search=${encodeURIComponent(query.trim())}&type=user&limit=20`, + null, + { noToast: true } + ); + const list = res?.result || []; + setResults( + list.filter((u) => !selected.some((s) => s._id === u._id)) + ); + } catch { + setResults([]); + } finally { + setSearching(false); + } + }, 300); + return () => clearTimeout(t); + }, [query, open, request, selected]); + + const addUser = (u: TaggedUser) => { + if (selected.some((s) => s._id === u._id)) return; + if (selected.length >= max) { + toast.error(t("posts.maxTagged", { max })); + return; + } + onChange([...selected, u]); + setQuery(""); + }; + + const removeUser = (id: string) => { + onChange(selected.filter((u) => u._id !== id)); + }; + + return ( + <div className="w-full"> + <button + type="button" + onClick={() => setOpen((o) => !o)} + className="flex w-full items-center justify-center gap-2 rounded-full border border-neutral-200 py-2.5 text-sm font-medium transition active:scale-[0.98] dark:border-neutral-700" + > + <BoldIcon name="user-add" size={20} tinted className="text-current" /> + {t("posts.tagPeople", { count: selected.length, max })} + </button> + + {selected.length > 0 && ( + <div className="mt-3 flex flex-wrap gap-2"> + {selected.map((u) => { + const src = avatarSrc(u.profile_image); + return ( + <span + key={u._id} + className="flex items-center gap-2 rounded-full bg-neutral-100 py-1 pe-2 ps-1 dark:bg-neutral-800" + > + <div className="relative h-7 w-7 shrink-0 overflow-hidden rounded-full bg-neutral-200"> + {src ? ( + <Image + src={src} + alt="" + fill + className="object-cover" + unoptimized + /> + ) : null} + </div> + <span className="text-xs font-medium"> + {displayName(u)} + <span className="mx-1 text-neutral-400">·</span> + @{u.user_name} + </span> + <button + type="button" + onClick={() => removeUser(u._id)} + aria-label={t("posts.remove")} + > + <BoldIcon + name="close-circle" + size={16} + tinted + className="text-neutral-400" + /> + </button> + </span> + ); + })} + </div> + )} + + {open && ( + <div className="mt-3 rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700"> + <input + value={query} + onChange={(e) => setQuery(e.target.value)} + placeholder={t("posts.searchUsersPlaceholder")} + className="w-full rounded-full bg-neutral-100 px-4 py-2.5 text-sm outline-none dark:bg-neutral-800" + autoFocus + /> + {searching && ( + <p className="mt-2 text-center text-xs text-neutral-400"> + {t("posts.searching")} + </p> + )} + {!searching && query.trim().length >= 2 && results.length === 0 && ( + <p className="mt-2 text-center text-xs text-neutral-400"> + {t("posts.noUsersFound")} + </p> + )} + <ul className="mt-2 max-h-52 space-y-1 overflow-y-auto"> + {results.map((u) => { + const src = avatarSrc(u.profile_image); + const name = displayName(u); + return ( + <li key={u._id}> + <button + type="button" + onClick={() => addUser(u)} + className="flex w-full items-center gap-3 rounded-2xl px-2 py-2 text-right transition hover:bg-neutral-100 dark:hover:bg-neutral-800" + > + <div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200"> + {src ? ( + <Image + src={src} + alt="" + fill + className="object-cover" + unoptimized + /> + ) : null} + </div> + <div className="min-w-0 flex-1 text-right"> + <p className="truncate text-sm font-medium">{name}</p> + <p className="truncate text-xs text-neutral-500"> + @{u.user_name} + </p> + </div> + </button> + </li> + ); + })} + </ul> + </div> + )} + </div> + ); +} diff --git a/src/components/posts/VideoCoverPicker.tsx b/src/components/posts/VideoCoverPicker.tsx index fc2d153..2088bee 100644 --- a/src/components/posts/VideoCoverPicker.tsx +++ b/src/components/posts/VideoCoverPicker.tsx @@ -109,7 +109,7 @@ export default function VideoCoverPicker({ </label> </div> - <div className="relative mb-3 aspect-video overflow-hidden rounded-xl bg-black"> + <div className="relative mb-3 aspect-video overflow-hidden rounded-2xl bg-black"> <video ref={videoRef} src={videoUrl} @@ -159,7 +159,7 @@ export default function VideoCoverPicker({ <button type="button" onClick={() => void applyFrameAt(time)} - className="flex w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 py-2.5 text-sm font-semibold dark:bg-neutral-800" + className="flex w-full items-center justify-center gap-2 rounded-2xl bg-neutral-100 py-2.5 text-sm font-semibold dark:bg-neutral-800" > <BoldIcon name="gallery" size={18} className="dark:invert" /> {t("posts.pickFrameAsCover")} diff --git a/src/components/profile/ShareProfileModal.tsx b/src/components/profile/ShareProfileModal.tsx index ec5b452..5192428 100644 --- a/src/components/profile/ShareProfileModal.tsx +++ b/src/components/profile/ShareProfileModal.tsx @@ -99,7 +99,7 @@ export default function ShareProfileModal({ <p className="mb-2 text-xs text-neutral-500 dark:text-neutral-400"> {t("models.shareProfile.linkLabel")} </p> - <div className="mb-4 break-all rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs dark:border-neutral-700 dark:bg-neutral-900"> + <div className="mb-4 break-all rounded-2xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs dark:border-neutral-700 dark:bg-neutral-900"> {profileUrl || "—"} </div> diff --git a/src/components/projects/MainProjectCard.tsx b/src/components/projects/MainProjectCard.tsx index e7239bc..4b72709 100644 --- a/src/components/projects/MainProjectCard.tsx +++ b/src/components/projects/MainProjectCard.tsx @@ -9,6 +9,7 @@ import Link from "next/link"; import ProjectCreator from "./ProjectPage/ProjectCreator"; import LocationModal from "@/components/models/ModelPage/LocationModal"; import { cn } from "@/lib/utils"; +import { localizedGeoName } from "@/utils/geoName"; function MainProjectCard({ project, @@ -148,7 +149,7 @@ function MainProjectCard({ className="dark:invert" /> <span>{t("projects.locationCity")} </span> - <span>{project?.city && project?.city?.name}</span> + <span>{project?.city && localizedGeoName(project.city)}</span> </button> ) : ( <div className="flex items-center gap-1 text-[.7rem] md:my-1 md:justify-center md:text-sm"> @@ -160,7 +161,7 @@ function MainProjectCard({ className="dark:invert" /> <span>{t("projects.locationCity")} </span> - <span>{project?.city && project?.city?.name}</span> + <span>{project?.city && localizedGeoName(project.city)}</span> </div> )} <div className="flex items-center gap-1 text-[.7rem] md:my-1 md:justify-center md:text-sm"> diff --git a/src/components/projects/NewProject/MultiStepForm.tsx b/src/components/projects/NewProject/MultiStepForm.tsx index a844448..ab8cf3e 100644 --- a/src/components/projects/NewProject/MultiStepForm.tsx +++ b/src/components/projects/NewProject/MultiStepForm.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; import { useSearchParams } from "next/navigation"; +import BackButton from "@/components/ui/BackButton"; import Step1 from "./Step1"; import { useProjectForm } from "@/contexts/ProjectFormContext"; import { getProjectDraft, saveProjectDraft } from "@/lib/projectDrafts"; @@ -54,6 +55,9 @@ const MultiStepForm = () => { return ( <div className="flex w-full flex-col items-center p-4"> + <div className="flex w-full max-w-sm justify-start"> + <BackButton onClick={() => (step > 1 ? setStep(step - 1) : window.history.back())} /> + </div> {step === 1 && ( <Step1 nextStep={(values) => goNext(1, values)} diff --git a/src/components/projects/Workroom/ProjectRequestsAction.tsx b/src/components/projects/Workroom/ProjectRequestsAction.tsx index 812e995..498822e 100644 --- a/src/components/projects/Workroom/ProjectRequestsAction.tsx +++ b/src/components/projects/Workroom/ProjectRequestsAction.tsx @@ -1,242 +1,242 @@ -"use client"; - -import Modal from "@/components/elements/Modal"; -import RoundedButton from "@/components/elements/RoundedButton"; -import RoundedDiv from "@/components/elements/RoundedDiv"; -import UserInfo from "@/components/main/UserInfo"; -import ScoreRateStats from "@/components/main/ScoreRateStats"; -import { buildStorageUrl } from "@/components/main/BaseUrl"; -import useAxios from "@/hooks/useAxios"; -import { IProjectRequest } from "@/types/types"; -import { useRouter } from "next/navigation"; -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; -import BoldIcon from "@/components/ui/BoldIcon"; - -function ProjectRequestsAction({ - projectRequests, - projectId, - status, -}: { - projectRequests: IProjectRequest[]; - projectId: string; - status: string; -}) { - const { t } = useTranslation("common"); - const { request } = useAxios(); - const router = useRouter(); - const [showConfirmModal, setShowConfirmModal] = useState(false); - const [itemToAccept, setItemToAccept] = useState<IProjectRequest>(); - const [viewerUrl, setViewerUrl] = useState<string | null>(null); - const [viewerType, setViewerType] = useState<"image" | "video">("image"); - - const canAccept = ![ - "ongoing", - "done", - "cancled", - "pre_payment", - ].includes(status); - - const acceptHandler = async () => { - try { - const response = await request<{ authority?: string; paymentUrl?: string }>( - "POST", - "/projects/payment-request-step-one/web", - { - requestId: itemToAccept?._id, - projectId, - } - ); - if (response?.paymentUrl) { - router.push(response.paymentUrl); - } else if (response?.authority) { - router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`); - } - } catch (error: unknown) { - console.log(error); - } - }; - - const openMedia = (path: string, type: "image" | "video") => { - setViewerUrl(buildStorageUrl(path)); - setViewerType(type); - }; - - return ( - <> - {projectRequests - ? projectRequests.map((item: IProjectRequest) => { - return ( - <div - key={item?._id} - className={`my-4 flex w-full max-w-sm cursor-pointer items-center justify-between rounded-3xl p-2 md:max-w-md ${ - item?.status === "accepted" ? "border border-[#0C8002]" : "" - }`} - onClick={() => { - setItemToAccept(item); - setShowConfirmModal(true); - }} - > - <div className="min-w-0 flex-1"> - <UserInfo - first_name={item?.user?.first_name} - is_verified={item?.user?.verify_badge} - userId={item?.user?._id} - profile_image={item?.user?.profile_image} - user_level={item?.user?.user_level} - last_name={item?.user?.last_name} - user_name={item?.user?.user_name} - trailingContent={ - <ScoreRateStats score={item?.user?.user_score} rate={item?.user?.rate} /> - } - /> - </div> - - <div className="flex shrink-0 flex-col items-start gap-1.5 text-[10px] sm:text-xs"> - <span> - {t("projects.workroom.offerPrice", { - amount: Number(item?.price).toLocaleString(), - })} - </span> - <span> - {t("projects.workroom.suggestedTime", { days: item?.time })} - </span> - {item?.portfolio?.length ? ( - <span className="text-[#FF107D]"> - {t("projects.portfolio.count", { - count: item.portfolio.length, - })} - </span> - ) : null} - </div> - </div> - ); - }) - : ""} - {showConfirmModal && ( - <Modal - isOpen={showConfirmModal} - onClose={() => setShowConfirmModal(false)} - height="fit" - elevated - panelClassName="max-h-[90vh] overflow-y-auto" - > - {itemToAccept && ( - <div className="mx-auto flex max-w-sm flex-col items-center gap-4 pt-4"> - <UserInfo - first_name={itemToAccept?.user?.first_name} - is_verified={itemToAccept?.user?.verify_badge} - userId={itemToAccept?.user?._id} - profile_image={itemToAccept?.user?.profile_image} - user_level={itemToAccept?.user?.user_level} - last_name={itemToAccept?.user?.last_name} - user_name={itemToAccept?.user?.user_name} - /> - <RoundedDiv className="mt-2 w-full p-2 text-xs"> - {t("projects.workroom.offerPrice", { - amount: Number(itemToAccept?.price).toLocaleString(), - })} - </RoundedDiv> - <RoundedDiv className="w-full p-2 text-xs"> - {t("projects.workroom.suggestedTime", { - days: itemToAccept?.time, - })} - </RoundedDiv> - - {itemToAccept.portfolio?.length ? ( - <div className="w-full"> - <p className="mb-2 text-center text-xs font-semibold"> - {t("projects.portfolio.uploadTitle")} - </p> - <div className="flex flex-wrap justify-center gap-2"> - {itemToAccept.portfolio.map((file, index) => { - const url = buildStorageUrl(file.path); - return ( - <button - key={`${file.path}-${index}`} - type="button" - className="relative h-20 w-20 overflow-hidden rounded-lg border" - onClick={(e) => { - e.stopPropagation(); - openMedia(file.path, file.type); - }} - > - {file.type === "video" ? ( - <video - src={url} - className="h-full w-full object-cover" - muted - /> - ) : ( - // eslint-disable-next-line @next/next/no-img-element - <img - src={url} - alt="" - className="h-full w-full object-cover" - /> - )} - </button> - ); - })} - </div> - </div> - ) : null} - - {canAccept ? ( - <RoundedButton - onClick={() => { - void acceptHandler(); - setShowConfirmModal(false); - }} - className="mt-3 w-full p-2 text-sm" - > - {t("projects.workroom.accept")} - </RoundedButton> - ) : null} - </div> - )} - </Modal> - )} - - {viewerUrl ? ( - <div - className="fixed inset-0 z-[300] flex items-center justify-center bg-black/90" - onClick={() => setViewerUrl(null)} - > - <button - type="button" - className="absolute left-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/15 text-white" - onClick={() => setViewerUrl(null)} - aria-label={t("projects.specialists.close")} - > - <BoldIcon name="close-circle" size={24} tinted className="text-white" /> - </button> - <div - className="relative max-h-[90vh] max-w-[92vw]" - onClick={(e) => e.stopPropagation()} - > - {viewerType === "video" ? ( - <video - src={viewerUrl} - controls - autoPlay - muted - playsInline - className="max-h-[90vh] max-w-[92vw] rounded-xl" - /> - ) : ( - // eslint-disable-next-line @next/next/no-img-element - <img - src={viewerUrl} - alt="" - className="max-h-[90vh] max-w-[92vw] rounded-xl object-contain" - /> - )} - </div> - </div> - ) : null} - </> - ); -} - -export default ProjectRequestsAction; +"use client"; + +import Modal from "@/components/elements/Modal"; +import RoundedButton from "@/components/elements/RoundedButton"; +import RoundedDiv from "@/components/elements/RoundedDiv"; +import UserInfo from "@/components/main/UserInfo"; +import ScoreRateStats from "@/components/main/ScoreRateStats"; +import { buildStorageUrl } from "@/components/main/BaseUrl"; +import useAxios from "@/hooks/useAxios"; +import { IProjectRequest } from "@/types/types"; +import { useRouter } from "next/navigation"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import BoldIcon from "@/components/ui/BoldIcon"; + +function ProjectRequestsAction({ + projectRequests, + projectId, + status, +}: { + projectRequests: IProjectRequest[]; + projectId: string; + status: string; +}) { + const { t } = useTranslation("common"); + const { request } = useAxios(); + const router = useRouter(); + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [itemToAccept, setItemToAccept] = useState<IProjectRequest>(); + const [viewerUrl, setViewerUrl] = useState<string | null>(null); + const [viewerType, setViewerType] = useState<"image" | "video">("image"); + + const canAccept = ![ + "ongoing", + "done", + "cancled", + "pre_payment", + ].includes(status); + + const acceptHandler = async () => { + try { + const response = await request<{ authority?: string; paymentUrl?: string }>( + "POST", + "/projects/payment-request-step-one/web", + { + requestId: itemToAccept?._id, + projectId, + } + ); + if (response?.paymentUrl) { + router.push(response.paymentUrl); + } else if (response?.authority) { + router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`); + } + } catch (error: unknown) { + console.log(error); + } + }; + + const openMedia = (path: string, type: "image" | "video") => { + setViewerUrl(buildStorageUrl(path)); + setViewerType(type); + }; + + return ( + <> + {projectRequests + ? projectRequests.map((item: IProjectRequest) => { + return ( + <div + key={item?._id} + className={`my-4 flex w-full max-w-sm cursor-pointer items-center justify-between rounded-3xl p-2 md:max-w-md ${ + item?.status === "accepted" ? "border border-[#0C8002]" : "" + }`} + onClick={() => { + setItemToAccept(item); + setShowConfirmModal(true); + }} + > + <div className="min-w-0 flex-1"> + <UserInfo + first_name={item?.user?.first_name} + is_verified={item?.user?.verify_badge} + userId={item?.user?._id} + profile_image={item?.user?.profile_image} + user_level={item?.user?.user_level} + last_name={item?.user?.last_name} + user_name={item?.user?.user_name} + trailingContent={ + <ScoreRateStats score={item?.user?.user_score} rate={item?.user?.rate} /> + } + /> + </div> + + <div className="flex shrink-0 flex-col items-start gap-1.5 text-[10px] sm:text-xs"> + <span> + {t("projects.workroom.offerPrice", { + amount: Number(item?.price).toLocaleString(), + })} + </span> + <span> + {t("projects.workroom.suggestedTime", { days: item?.time })} + </span> + {item?.portfolio?.length ? ( + <span className="text-[#FF107D]"> + {t("projects.portfolio.count", { + count: item.portfolio.length, + })} + </span> + ) : null} + </div> + </div> + ); + }) + : ""} + {showConfirmModal && ( + <Modal + isOpen={showConfirmModal} + onClose={() => setShowConfirmModal(false)} + height="fit" + elevated + panelClassName="max-h-[90vh] overflow-y-auto" + > + {itemToAccept && ( + <div className="mx-auto flex max-w-sm flex-col items-center gap-4 pt-4"> + <UserInfo + first_name={itemToAccept?.user?.first_name} + is_verified={itemToAccept?.user?.verify_badge} + userId={itemToAccept?.user?._id} + profile_image={itemToAccept?.user?.profile_image} + user_level={itemToAccept?.user?.user_level} + last_name={itemToAccept?.user?.last_name} + user_name={itemToAccept?.user?.user_name} + /> + <RoundedDiv className="mt-2 w-full p-2 text-xs"> + {t("projects.workroom.offerPrice", { + amount: Number(itemToAccept?.price).toLocaleString(), + })} + </RoundedDiv> + <RoundedDiv className="w-full p-2 text-xs"> + {t("projects.workroom.suggestedTime", { + days: itemToAccept?.time, + })} + </RoundedDiv> + + {itemToAccept.portfolio?.length ? ( + <div className="w-full"> + <p className="mb-2 text-center text-xs font-semibold"> + {t("projects.portfolio.uploadTitle")} + </p> + <div className="flex flex-wrap justify-center gap-2"> + {itemToAccept.portfolio.map((file, index) => { + const url = buildStorageUrl(file.path); + return ( + <button + key={`${file.path}-${index}`} + type="button" + className="relative h-20 w-20 overflow-hidden rounded-lg border" + onClick={(e) => { + e.stopPropagation(); + openMedia(file.path, file.type); + }} + > + {file.type === "video" ? ( + <video + src={url} + className="h-full w-full object-cover" + muted + /> + ) : ( + // eslint-disable-next-line @next/next/no-img-element + <img + src={url} + alt="" + className="h-full w-full object-cover" + /> + )} + </button> + ); + })} + </div> + </div> + ) : null} + + {canAccept ? ( + <RoundedButton + onClick={() => { + void acceptHandler(); + setShowConfirmModal(false); + }} + className="mt-3 w-full p-2 text-sm" + > + {t("projects.workroom.accept")} + </RoundedButton> + ) : null} + </div> + )} + </Modal> + )} + + {viewerUrl ? ( + <div + className="fixed inset-0 z-[300] flex items-center justify-center bg-black/90" + onClick={() => setViewerUrl(null)} + > + <button + type="button" + className="absolute left-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/15 text-white" + onClick={() => setViewerUrl(null)} + aria-label={t("projects.specialists.close")} + > + <BoldIcon name="close-circle" size={24} tinted className="text-white" /> + </button> + <div + className="relative max-h-[90vh] max-w-[92vw]" + onClick={(e) => e.stopPropagation()} + > + {viewerType === "video" ? ( + <video + src={viewerUrl} + controls + autoPlay + muted + playsInline + className="max-h-[90vh] max-w-[92vw] rounded-2xl" + /> + ) : ( + // eslint-disable-next-line @next/next/no-img-element + <img + src={viewerUrl} + alt="" + className="max-h-[90vh] max-w-[92vw] rounded-2xl object-contain" + /> + )} + </div> + </div> + ) : null} + </> + ); +} + +export default ProjectRequestsAction; diff --git a/src/components/pwa/PwaInstallPrompt.tsx b/src/components/pwa/PwaInstallPrompt.tsx index c306832..17397d6 100644 --- a/src/components/pwa/PwaInstallPrompt.tsx +++ b/src/components/pwa/PwaInstallPrompt.tsx @@ -6,10 +6,22 @@ import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles"; import { cn } from "@/lib/utils"; import { useAppLanguage } from "@/contexts/LanguageProvider"; import { getPwaLabels, toPwaLang } from "@/lib/pwaConfig"; +import { buildStorageUrl } from "@/components/main/BaseUrl"; + +// طبق خواسته‌ی صریح کاربر: اندروید مسیر مستقیمِ فایل APK رو سرو می‌کنه (همون الگوی +// عمومیِ /storage/<هرچیزی> که بک‌اند از قبل بدونِ نیاز به روتِ جدید سرو می‌کنه) +const ANDROID_APK_URL = buildStorageUrl("/downloads/modstagram.apk"); export default function PwaInstallPrompt() { - const { visible, installing, isIos, canNativeInstall, install, close } = - usePwaInstall(); + const { + visible, + installing, + isIos, + isAndroid, + canNativeInstall, + install, + close, + } = usePwaInstall(); const { language } = useAppLanguage(); const labels = getPwaLabels(toPwaLang(language)); @@ -82,23 +94,40 @@ export default function PwaInstallPrompt() { labels.dir === "rtl" ? "sm:flex-row-reverse" : "" )} > + {/* فقط اندروید: دانلود مستقیمِ اپلیکیشن، جدا از نصبِ نسخه‌ی وب — آیفون این + دکمه رو نداره چون اپِ نیتیوی iOS فعلاً منتشر نشده */} + {isAndroid ? ( + <a + href={ANDROID_APK_URL} + className={cn( + btnPrimary, + "flex w-full items-center justify-center px-4 py-3 text-sm font-bold" + )} + > + {labels.downloadAppButton} + </a> + ) : null} + {canNativeInstall ? ( <button type="button" disabled={installing} onClick={() => void install()} className={cn( - btnPrimary, + isAndroid ? btnDefault : btnPrimary, "w-full px-4 py-3 text-sm font-bold disabled:opacity-60" )} > - {installing ? labels.installing : labels.installButton} + {installing ? labels.installing : labels.installWebButton} </button> ) : ( <button type="button" onClick={() => close(false)} - className={cn(btnPrimary, "w-full px-4 py-3 text-sm font-bold")} + className={cn( + isAndroid ? btnDefault : btnPrimary, + "w-full px-4 py-3 text-sm font-bold" + )} > {isIos ? labels.gotIt : labels.ok} </button> diff --git a/src/components/settings/AccountAnalyticsWidget.tsx b/src/components/settings/AccountAnalyticsWidget.tsx index ae2f017..f4dc81c 100644 --- a/src/components/settings/AccountAnalyticsWidget.tsx +++ b/src/components/settings/AccountAnalyticsWidget.tsx @@ -109,7 +109,7 @@ export default function AccountAnalyticsWidget() { </div> {loading ? ( - <div className="h-56 w-full animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" /> + <div className="h-56 w-full animate-pulse rounded-2xl bg-neutral-100 dark:bg-neutral-800" /> ) : series.length === 0 ? ( <p className="py-10 text-center text-sm text-neutral-500"> {t("settings.analytics.empty")} diff --git a/src/components/settings/EditPageSkeletons.tsx b/src/components/settings/EditPageSkeletons.tsx new file mode 100644 index 0000000..94efc47 --- /dev/null +++ b/src/components/settings/EditPageSkeletons.tsx @@ -0,0 +1,30 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** برای صفحات ویرایش تک‌فرمی زیر settings/edit/** (تا وقتی دیتای کاربر/فرم لود بشه). */ +export function EditFormSkeleton({ fields = 2 }: { fields?: number }) { + return ( + <div className="mt-10 flex w-full max-w-sm flex-col items-center gap-4"> + {Array.from({ length: fields }).map((_, i) => ( + <Skeleton key={i} className="h-11 w-full rounded-full" /> + ))} + <Skeleton className="mt-16 h-10 w-40 rounded-full" /> + </div> + ); +} + +/** برای صفحات فهرستی زیر settings/edit/** (فعالیت، مسدودشده‌ها، آنالیتیکس پست‌ها و...). */ +export function SettingsListSkeleton({ count = 6 }: { count?: number }) { + return ( + <div className="mt-4 flex w-full flex-col gap-3"> + {Array.from({ length: count }).map((_, i) => ( + <div key={i} className="flex items-center gap-3"> + <Skeleton className="h-11 w-11 shrink-0 rounded-full" /> + <div className="flex flex-1 flex-col gap-2"> + <Skeleton className="h-3.5 w-1/3" /> + <Skeleton className="h-3 w-1/2" /> + </div> + </div> + ))} + </div> + ); +} diff --git a/src/components/settings/LanguageSettingRow.tsx b/src/components/settings/LanguageSettingRow.tsx index 3dfe2a0..25cc955 100644 --- a/src/components/settings/LanguageSettingRow.tsx +++ b/src/components/settings/LanguageSettingRow.tsx @@ -35,7 +35,7 @@ export default function LanguageSettingRow() { }; return ( - <div className="rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> + <div className="rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"> <div className="mb-3"> <p className="font-semibold">{t("language.title")}</p> <p className="mt-0.5 text-xs text-neutral-500"> diff --git a/src/components/settings/SectionAnalyticsWidget.tsx b/src/components/settings/SectionAnalyticsWidget.tsx index 83f9989..e382eeb 100644 --- a/src/components/settings/SectionAnalyticsWidget.tsx +++ b/src/components/settings/SectionAnalyticsWidget.tsx @@ -147,7 +147,7 @@ export default function SectionAnalyticsWidget({ {t("settings.edit.analyticsHub.metrics.revenueChartTitle")} </h3> {loading ? ( - <div className="h-48 w-full animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" /> + <div className="h-48 w-full animate-pulse rounded-2xl bg-neutral-100 dark:bg-neutral-800" /> ) : series.length === 0 ? ( <p className="py-10 text-center text-sm text-neutral-500"> {t("settings.edit.analyticsHub.metrics.empty")} @@ -188,7 +188,7 @@ export default function SectionAnalyticsWidget({ {data!.topItems.map((item) => ( <div key={item.id} - className="flex items-center gap-3 rounded-xl border border-neutral-100 p-2 dark:border-neutral-800" + className="flex items-center gap-3 rounded-2xl border border-neutral-100 p-2 dark:border-neutral-800" > <span className="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-neutral-100 dark:bg-neutral-800"> {item.image && ( diff --git a/src/components/settings/profile/AccountActionModals.tsx b/src/components/settings/profile/AccountActionModals.tsx index b524413..7e91431 100644 --- a/src/components/settings/profile/AccountActionModals.tsx +++ b/src/components/settings/profile/AccountActionModals.tsx @@ -126,7 +126,7 @@ export default function AccountActionModals({ <input value={typed} onChange={(e) => setTyped(e.target.value)} - className="mt-3 w-full rounded-xl border border-neutral-300 bg-transparent px-3 py-2.5 text-sm outline-none dark:border-neutral-700" + className="mt-3 w-full rounded-2xl border border-neutral-300 bg-transparent px-3 py-2.5 text-sm outline-none dark:border-neutral-700" autoFocus /> <div className="mt-4 flex gap-2"> diff --git a/src/components/settings/profile/ProfileContent.tsx b/src/components/settings/profile/ProfileContent.tsx index 6cea6eb..dfd0590 100644 --- a/src/components/settings/profile/ProfileContent.tsx +++ b/src/components/settings/profile/ProfileContent.tsx @@ -12,6 +12,7 @@ import ShowMap from "@/components/main/ShowMap"; import { isModelExpertise } from "@/lib/isModelExpertise"; import AccountActionModals from "./AccountActionModals"; import { useTranslation } from "react-i18next"; +import { localizedGeoName } from "@/utils/geoName"; interface ProfileProfileContentProps { user: User | undefined; @@ -80,29 +81,29 @@ function ProfileContent({ user, onRefresh }: ProfileProfileContentProps) { </div> <div className="grid grid-cols-3 gap-1 md:gap-4 mt-5"> <Link - className="w-full md:h-8 max-w-full" + className="w-full h-9 max-w-full" href={"/settings/profile/user-settings"} > - <RoundedButton className="text-xs h-7 md:h-8 max-w-full w-full"> + <RoundedButton className="text-xs h-9 max-w-full w-full"> {t("settings.userSettings")} </RoundedButton> </Link> <RoundedButton onClick={() => setActionKind("deactivate")} - className="text-xs h-7 md:h-8 max-w-full w-full" + className="text-xs h-9 max-w-full w-full" > {t("settings.profile.deactivate")} </RoundedButton> <RoundedButton onClick={() => setActionKind("delete")} - className="text-xs h-7 md:h-8 max-w-full w-full text-red-600" + className="text-xs h-9 max-w-full w-full text-red-600" > {t("settings.profile.deleteAccount")} </RoundedButton> </div> <div className="grid grid-cols-2 gap-2 md:gap-4 mt-5 justify-center max-w-xl mx-auto"> - <RoundedDiv className="h-9">{t("settings.profile.province")}: {user?.province?.name}</RoundedDiv> - <RoundedDiv className="h-9">{t("settings.profile.city")}: {user?.city?.name}</RoundedDiv> + <RoundedDiv className="h-9">{t("settings.profile.province")}: {localizedGeoName(user?.province)}</RoundedDiv> + <RoundedDiv className="h-9">{t("settings.profile.city")}: {localizedGeoName(user?.city)}</RoundedDiv> </div> <RoundedDiv className="w-full p-4 max-w-xl mx-auto mt-4 text-right !justify-start"> {t("settings.profile.address")}: {user?.address} diff --git a/src/components/shops/SendProductModal.tsx b/src/components/shops/SendProductModal.tsx index 1ee3b14..222dd66 100644 --- a/src/components/shops/SendProductModal.tsx +++ b/src/components/shops/SendProductModal.tsx @@ -118,7 +118,7 @@ export default function SendProductModal({ {t("shops.sendProductTitle")} </h3> - <div className="mb-4 flex items-center gap-3 rounded-xl bg-neutral-100 p-2 dark:bg-neutral-800"> + <div className="mb-4 flex items-center gap-3 rounded-2xl bg-neutral-100 p-2 dark:bg-neutral-800"> {preview && ( <div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg"> <Image @@ -155,7 +155,7 @@ export default function SendProductModal({ <button type="button" onClick={() => toggle(u._id)} - className={`flex w-full items-center gap-3 rounded-xl px-2 py-2 text-right transition ${ + className={`flex w-full items-center gap-3 rounded-2xl px-2 py-2 text-right transition ${ checked ? "bg-pink-50 dark:bg-pink-950/30" : "" }`} > diff --git a/src/components/shops/ShareProductModal.tsx b/src/components/shops/ShareProductModal.tsx index dc157b7..516a141 100644 --- a/src/components/shops/ShareProductModal.tsx +++ b/src/components/shops/ShareProductModal.tsx @@ -89,7 +89,7 @@ export default function ShareProductModal({ <p className="mb-2 text-xs text-neutral-500 dark:text-neutral-400"> {t("shops.share.linkLabel")} </p> - <div className="mb-4 break-all rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs dark:border-neutral-700 dark:bg-neutral-900"> + <div className="mb-4 break-all rounded-2xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs dark:border-neutral-700 dark:bg-neutral-900"> {productUrl || "—"} </div> diff --git a/src/components/shops/ShareShopModal.tsx b/src/components/shops/ShareShopModal.tsx index 5a1c6c3..7484e31 100644 --- a/src/components/shops/ShareShopModal.tsx +++ b/src/components/shops/ShareShopModal.tsx @@ -97,7 +97,7 @@ export default function ShareShopModal({ <p className="mb-2 text-xs text-neutral-500 dark:text-neutral-400"> {t("shops.share.linkLabel")} </p> - <div className="mb-4 break-all rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs dark:border-neutral-700 dark:bg-neutral-900"> + <div className="mb-4 break-all rounded-2xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs dark:border-neutral-700 dark:bg-neutral-900"> {shopUrl || "—"} </div> diff --git a/src/components/shops/ShopCartTab.tsx b/src/components/shops/ShopCartTab.tsx index 99a33d1..59ccb67 100644 --- a/src/components/shops/ShopCartTab.tsx +++ b/src/components/shops/ShopCartTab.tsx @@ -98,7 +98,7 @@ export default function ShopCartTab() { onClick={() => router.push(`/shops/product/${item.listing._id}`) } - className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-100 dark:bg-neutral-800" + className="relative h-16 w-16 shrink-0 overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-800" > {item.listing.images?.[item.listing.primaryImageIndex] && ( // eslint-disable-next-line @next/next/no-img-element diff --git a/src/components/shops/TimeSelect.tsx b/src/components/shops/TimeSelect.tsx index c701eeb..7f02773 100644 --- a/src/components/shops/TimeSelect.tsx +++ b/src/components/shops/TimeSelect.tsx @@ -18,7 +18,7 @@ export default function TimeSelect({ value, onChange, ariaLabel }: TimeSelectPro aria-label={ariaLabel} value={value} onChange={(e) => onChange(e.target.value)} - className="rounded-xl border border-neutral-300 bg-white px-2 py-1.5 text-xs dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50" + className="rounded-2xl border border-neutral-300 bg-white px-2 py-1.5 text-xs dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50" > <option value="">--:--</option> {TIME_OPTIONS.map((time) => ( diff --git a/src/components/shops/orders/BuyerInfoModal.tsx b/src/components/shops/orders/BuyerInfoModal.tsx index 96b9eb4..081d941 100644 --- a/src/components/shops/orders/BuyerInfoModal.tsx +++ b/src/components/shops/orders/BuyerInfoModal.tsx @@ -5,14 +5,15 @@ import BoldIcon from "@/components/ui/BoldIcon"; import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; +import { localizedGeoName } from "@/utils/geoName"; type BuyerAddressSnapshot = { first_name?: string | null; last_name?: string | null; mobile?: string | null; address?: string | null; - province?: { name?: string } | null; - city?: { name?: string } | null; + province?: { name?: string; name_fa?: string } | null; + city?: { name?: string; name_fa?: string } | null; lat?: string | null; lng?: string | null; }; @@ -76,8 +77,8 @@ export default function BuyerInfoModal({ isOpen, onClose, buyer }: BuyerInfoModa <strong className="text-gray-800 dark:text-neutral-100"> {t("models.location.city")}{" "} </strong> - {buyer?.city?.name || t("models.location.unknown")}،{" "} - {buyer?.province?.name || t("models.location.unknown")} + {localizedGeoName(buyer?.city) || t("models.location.unknown")}،{" "} + {localizedGeoName(buyer?.province) || t("models.location.unknown")} </p> {buyer?.address && ( <p className="mt-2"> diff --git a/src/components/shops/orders/BuyerOrderItem.tsx b/src/components/shops/orders/BuyerOrderItem.tsx index 4ecc959..538aefd 100644 --- a/src/components/shops/orders/BuyerOrderItem.tsx +++ b/src/components/shops/orders/BuyerOrderItem.tsx @@ -49,10 +49,10 @@ export default function BuyerOrderItem({ order }: { order: BuyerOrder }) { <img src={IMAGE_BASE_URL + primaryImage} alt={listing?.title || ""} - className="h-12 w-12 shrink-0 rounded-xl object-cover" + className="h-12 w-12 shrink-0 rounded-2xl object-cover" /> ) : ( - <span className="h-12 w-12 shrink-0 rounded-xl bg-neutral-200 dark:bg-neutral-800" /> + <span className="h-12 w-12 shrink-0 rounded-2xl bg-neutral-200 dark:bg-neutral-800" /> )} <div className="min-w-0"> <p className="truncate text-sm">{listing?.title}</p> diff --git a/src/components/shops/orders/SellerOrderItem.tsx b/src/components/shops/orders/SellerOrderItem.tsx index 3438956..9706144 100644 --- a/src/components/shops/orders/SellerOrderItem.tsx +++ b/src/components/shops/orders/SellerOrderItem.tsx @@ -56,10 +56,10 @@ export default function SellerOrderItem({ order }: { order: SellerOrder }) { <img src={IMAGE_BASE_URL + primaryImage} alt={listing?.title || ""} - className="h-12 w-12 shrink-0 rounded-xl object-cover" + className="h-12 w-12 shrink-0 rounded-2xl object-cover" /> ) : ( - <span className="h-12 w-12 shrink-0 rounded-xl bg-neutral-200 dark:bg-neutral-800" /> + <span className="h-12 w-12 shrink-0 rounded-2xl bg-neutral-200 dark:bg-neutral-800" /> )} <div className="min-w-0"> <p className="truncate text-sm">{listing?.title}</p> diff --git a/src/components/shops/orders/ShopInfoModal.tsx b/src/components/shops/orders/ShopInfoModal.tsx index 5cf6a08..e69b548 100644 --- a/src/components/shops/orders/ShopInfoModal.tsx +++ b/src/components/shops/orders/ShopInfoModal.tsx @@ -8,14 +8,15 @@ import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl"; import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; +import { localizedGeoName } from "@/utils/geoName"; type ShopInfo = { _id: string; name: string; logo?: string | null; has_physical_location?: boolean; - province?: { name?: string } | null; - city?: { name?: string } | null; + province?: { name?: string; name_fa?: string } | null; + city?: { name?: string; name_fa?: string } | null; address?: string | null; lat?: string | null; lng?: string | null; @@ -95,8 +96,8 @@ export default function ShopInfoModal({ isOpen, onClose, shop }: ShopInfoModalPr <strong className="text-gray-800 dark:text-neutral-100"> {t("models.location.city")}{" "} </strong> - {shop.city?.name || t("models.location.unknown")}،{" "} - {shop.province?.name || t("models.location.unknown")} + {localizedGeoName(shop.city) || t("models.location.unknown")}،{" "} + {localizedGeoName(shop.province) || t("models.location.unknown")} </p> {shop.address && ( <p className="mt-2"> diff --git a/src/components/shops/wallet/ShopPerformanceWidget.tsx b/src/components/shops/wallet/ShopPerformanceWidget.tsx index e07f87d..59260e1 100644 --- a/src/components/shops/wallet/ShopPerformanceWidget.tsx +++ b/src/components/shops/wallet/ShopPerformanceWidget.tsx @@ -128,7 +128,7 @@ export default function ShopPerformanceWidget() { {t("shops.performance.revenueChartTitle")} </h3> {loading ? ( - <div className="h-48 w-full animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" /> + <div className="h-48 w-full animate-pulse rounded-2xl bg-neutral-100 dark:bg-neutral-800" /> ) : series.length === 0 ? ( <p className="py-10 text-center text-sm text-neutral-500"> {t("shops.performance.empty")} @@ -177,7 +177,7 @@ export default function ShopPerformanceWidget() { {data.bestSellers.map((item) => ( <div key={item.listingId} - className="flex items-center gap-3 rounded-xl border border-neutral-100 p-2 dark:border-neutral-800" + className="flex items-center gap-3 rounded-2xl border border-neutral-100 p-2 dark:border-neutral-800" > <span className="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-neutral-100 dark:bg-neutral-800"> {item.image && ( diff --git a/src/components/stories/SendStoryModal.tsx b/src/components/stories/SendStoryModal.tsx index 93f8e36..6ffa11c 100644 --- a/src/components/stories/SendStoryModal.tsx +++ b/src/components/stories/SendStoryModal.tsx @@ -129,7 +129,7 @@ export default function SendStoryModal({ {t("stories.sendStory")} </h3> - <div className="mb-4 flex items-center gap-3 rounded-xl bg-neutral-100 p-2 dark:bg-neutral-800"> + <div className="mb-4 flex items-center gap-3 rounded-2xl bg-neutral-100 p-2 dark:bg-neutral-800"> {preview && ( <div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg"> {mediaType === "video" ? ( @@ -173,7 +173,7 @@ export default function SendStoryModal({ type="button" onClick={() => toggle(u._id)} className={cn( - "flex w-full items-center gap-3 rounded-xl px-2 py-2 text-right", + "flex w-full items-center gap-3 rounded-2xl px-2 py-2 text-right", checked ? "bg-[#FF107D]/10" : "hover:bg-neutral-100 dark:hover:bg-neutral-800" diff --git a/src/components/stories/StoryEditor.tsx b/src/components/stories/StoryEditor.tsx index 381d4f9..b4b4cd8 100644 --- a/src/components/stories/StoryEditor.tsx +++ b/src/components/stories/StoryEditor.tsx @@ -326,7 +326,7 @@ export default function StoryEditor({ {/* Toolbar for selected overlay */} {selectedOverlay && selectedOverlay.type === "link" && ( - <div className="mx-auto flex w-full max-w-[340px] items-center justify-between rounded-xl border border-neutral-200 p-3 dark:border-neutral-800"> + <div className="mx-auto flex w-full max-w-[340px] items-center justify-between rounded-2xl border border-neutral-200 p-3 dark:border-neutral-800"> <span className="text-xs font-semibold text-neutral-500"> {selectedOverlay.link_title} </span> @@ -341,7 +341,7 @@ export default function StoryEditor({ )} {selectedOverlay && selectedOverlay.type !== "link" && ( - <div className="mx-auto w-full max-w-[340px] space-y-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800"> + <div className="mx-auto w-full max-w-[340px] space-y-3 rounded-2xl border border-neutral-200 p-3 dark:border-neutral-800"> <div className="flex items-center justify-between"> <span className="text-xs font-semibold text-neutral-500"> {t("stories.editText")} diff --git a/src/components/stories/StoryRing.tsx b/src/components/stories/StoryRing.tsx index d8229cd..eb8496c 100644 --- a/src/components/stories/StoryRing.tsx +++ b/src/components/stories/StoryRing.tsx @@ -60,7 +60,7 @@ export default function StoryRing({ ? Math.max(0, cornerRadius - (hasStory ? STORY_RING_PX : 0)) : undefined; - const outerRounded = isXl ? "rounded-xl" : "rounded-full"; + const outerRounded = isXl ? "rounded-2xl" : "rounded-full"; const innerRounded = innerRadius != null ? undefined diff --git a/src/components/stories/StoryViewer.tsx b/src/components/stories/StoryViewer.tsx index 314a893..8a3d2f4 100644 --- a/src/components/stories/StoryViewer.tsx +++ b/src/components/stories/StoryViewer.tsx @@ -681,7 +681,7 @@ export default function StoryViewer({ <li key={v._id}> <Link href={`/users/${v.user_name}`} - className="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800" + className="flex items-center gap-3 rounded-2xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800" onClick={onClose} > <div className="relative h-10 w-10 overflow-hidden rounded-full bg-neutral-200"> diff --git a/src/components/ui/BannerComponent.tsx b/src/components/ui/BannerComponent.tsx index d10c974..c738c88 100644 --- a/src/components/ui/BannerComponent.tsx +++ b/src/components/ui/BannerComponent.tsx @@ -1,55 +1,55 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import Image from 'next/image'; - -interface BannerGridProps { - banners: string[]; -} - -export default function BannerGrid({ banners }: BannerGridProps) { - const [visibleBanners, setVisibleBanners] = useState(banners); - const [show, setShow] = useState(true); - - useEffect(() => { - const handleResize = () => { - const width = window.innerWidth; - - - - - const maxCount = Math.floor(width / 466); - setVisibleBanners(banners.slice(0, maxCount)); - }; - - handleResize(); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, [banners]); - - if (!show) return null; - - return ( -<div className="flex gap-4 justify-center items-center overflow-hidden w-full"> - {visibleBanners.map((src, i) => ( - <div - key={i} - className=" - relative rounded-xl - h-[130px] md:h-[110px] sm:h-[90px] - flex-1 min-w-[443px] - overflow-hidden - " - > - <Image - src={src} - alt={`Banner ${i + 1}`} - fill - className="object-cover shadow-lg object-center" - priority={i === 0} - /> - </div> - ))} -</div> - ); -} +'use client'; + +import { useEffect, useState } from 'react'; +import Image from 'next/image'; + +interface BannerGridProps { + banners: string[]; +} + +export default function BannerGrid({ banners }: BannerGridProps) { + const [visibleBanners, setVisibleBanners] = useState(banners); + const [show, setShow] = useState(true); + + useEffect(() => { + const handleResize = () => { + const width = window.innerWidth; + + + + + const maxCount = Math.floor(width / 466); + setVisibleBanners(banners.slice(0, maxCount)); + }; + + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [banners]); + + if (!show) return null; + + return ( +<div className="flex gap-4 justify-center items-center overflow-hidden w-full"> + {visibleBanners.map((src, i) => ( + <div + key={i} + className=" + relative rounded-2xl + h-[130px] md:h-[110px] sm:h-[90px] + flex-1 min-w-[443px] + overflow-hidden + " + > + <Image + src={src} + alt={`Banner ${i + 1}`} + fill + className="object-cover shadow-lg object-center" + priority={i === 0} + /> + </div> + ))} +</div> + ); +} diff --git a/src/components/ui/CountryProvinceCitySelect.tsx b/src/components/ui/CountryProvinceCitySelect.tsx index 8037767..d17ff33 100644 --- a/src/components/ui/CountryProvinceCitySelect.tsx +++ b/src/components/ui/CountryProvinceCitySelect.tsx @@ -8,13 +8,19 @@ import provincesData from "@/data/geo/provinces.json"; import citiesData from "@/data/geo/cities.json"; type Country = { id: number; iso2: string; name: string; name_fa: string; slug: string }; -type Province = { id: number; name: string; slug: string; country_id: number }; -type City = { id: number; name: string; slug: string; province_id: number; country_id: number }; +type Province = { id: number; name: string; name_fa?: string; slug: string; country_id: number }; +type City = { id: number; name: string; name_fa?: string; slug: string; province_id: number; country_id: number }; const countries = countriesData as Country[]; const provinces = provincesData as Province[]; const cities = citiesData as City[]; +// نام هر کشور/استان/شهر همیشه به زبان خودِ اون کشور نمایش داده می‌شه (مثلاً ایران همیشه فارسی)، +// نه بر اساس زبان فعلی رابط کاربری — و اگه ترجمه‌ای براش نداشته باشیم، انگلیسی fallback می‌شه. +function localizedGeoName(entity: { name: string; name_fa?: string }) { + return entity.name_fa || entity.name; +} + export type GeoSelection = { countryId: number | null; provinceId: number | null; @@ -53,9 +59,9 @@ export default function CountryProvinceCitySelect({ const sortedCountries = useMemo( () => [...countries].sort((a, b) => - (isFa ? a.name_fa : a.name).localeCompare(isFa ? b.name_fa : b.name) + localizedGeoName(a).localeCompare(localizedGeoName(b)) ), - [isFa] + [] ); const availableProvinces = useMemo( @@ -63,7 +69,7 @@ export default function CountryProvinceCitySelect({ value.countryId ? provinces .filter((p) => p.country_id === value.countryId) - .sort((a, b) => a.name.localeCompare(b.name)) + .sort((a, b) => localizedGeoName(a).localeCompare(localizedGeoName(b))) : [], [value.countryId] ); @@ -73,7 +79,7 @@ export default function CountryProvinceCitySelect({ value.provinceId ? cities .filter((c) => c.province_id === value.provinceId) - .sort((a, b) => a.name.localeCompare(b.name)) + .sort((a, b) => localizedGeoName(a).localeCompare(localizedGeoName(b))) : [], [value.provinceId] ); @@ -88,7 +94,7 @@ export default function CountryProvinceCitySelect({ lockedCountry ? ( <p className="text-center text-xs text-neutral-400"> {t("settings.edit.region.selectedLabel", { - country: isFa ? lockedCountry.name_fa : lockedCountry.name, + country: localizedGeoName(lockedCountry), })} </p> ) : ( @@ -108,7 +114,7 @@ export default function CountryProvinceCitySelect({ <option value="">{isFa ? "انتخاب کشور" : "Select country"}</option> {sortedCountries.map((c) => ( <option key={c.id} value={c.id}> - {isFa ? c.name_fa : c.name} + {localizedGeoName(c)} </option> ))} </select> @@ -126,7 +132,7 @@ export default function CountryProvinceCitySelect({ <option value="">{isFa ? "انتخاب استان" : "Select province"}</option> {availableProvinces.map((p) => ( <option key={p.id} value={p.id}> - {p.name} + {localizedGeoName(p)} </option> ))} </select> @@ -144,7 +150,7 @@ export default function CountryProvinceCitySelect({ <option value="">{isFa ? "انتخاب شهر" : "Select city"}</option> {availableCities.map((c) => ( <option key={c.id} value={c.id}> - {c.name} + {localizedGeoName(c)} </option> ))} </select> diff --git a/src/components/ui/LanguageSwitcher.tsx b/src/components/ui/LanguageSwitcher.tsx index f94014f..13f3cbe 100644 --- a/src/components/ui/LanguageSwitcher.tsx +++ b/src/components/ui/LanguageSwitcher.tsx @@ -1,54 +1,54 @@ -"use client"; - -import { useState } from "react"; -import BoldIcon from "@/components/ui/BoldIcon"; -import { useAppLanguage } from "@/contexts/LanguageProvider"; -import { LANGUAGE_LABELS, type AppLanguage } from "@/lib/i18n/constants"; - -const languages: Array<{ code: AppLanguage; label: string; flag: string }> = [ - { code: "fa", label: LANGUAGE_LABELS.fa.native, flag: "🇮🇷" }, - { code: "en", label: LANGUAGE_LABELS.en.native, flag: "🇺🇸" }, -]; - -export default function LanguageSwitcher() { - const [open, setOpen] = useState(false); - const { language, setLanguage } = useAppLanguage(); - - const toggleDropdown = () => setOpen(!open); - - const changeLanguage = async (langCode: AppLanguage) => { - await setLanguage(langCode); - setOpen(false); - }; - - const current = - languages.find((lang) => lang.code === language) || languages[0]; - - return ( - <div className="relative inline-block text-left"> - <button - onClick={toggleDropdown} - className="inline-flex max-Wide-mobile-s:text-xs rtl:flex-row-reverse items-center px-4 py-2 dark:border-white dark:text-white font-medium border rounded-xl shadow transition-all" - > - <BoldIcon name="global" size={18} tinted className="mr-2 text-current" /> - {current.flag} {current.label} - </button> - - {open && ( - <div className="absolute z-10 mt-2 rounded-md bg-white dark:bg-neutral-800 shadow-lg ring-1 ring-black ring-opacity-5"> - <div className="py-1"> - {languages.map((lang) => ( - <button - key={lang.code} - onClick={() => void changeLanguage(lang.code)} - className="w-full rtl:flex-row-reverse flex items-center px-4 py-2 text-sm text-gray-700 dark:text-white/60 dark:hover:bg-gray-600 hover:bg-gray-100" - > - <span className="mr-2 flex">{lang.flag}</span> {lang.label} - </button> - ))} - </div> - </div> - )} - </div> - ); +"use client"; + +import { useState } from "react"; +import BoldIcon from "@/components/ui/BoldIcon"; +import { useAppLanguage } from "@/contexts/LanguageProvider"; +import { LANGUAGE_LABELS, type AppLanguage } from "@/lib/i18n/constants"; + +const languages: Array<{ code: AppLanguage; label: string; flag: string }> = [ + { code: "fa", label: LANGUAGE_LABELS.fa.native, flag: "🇮🇷" }, + { code: "en", label: LANGUAGE_LABELS.en.native, flag: "🇺🇸" }, +]; + +export default function LanguageSwitcher() { + const [open, setOpen] = useState(false); + const { language, setLanguage } = useAppLanguage(); + + const toggleDropdown = () => setOpen(!open); + + const changeLanguage = async (langCode: AppLanguage) => { + await setLanguage(langCode); + setOpen(false); + }; + + const current = + languages.find((lang) => lang.code === language) || languages[0]; + + return ( + <div className="relative inline-block text-left"> + <button + onClick={toggleDropdown} + className="inline-flex max-Wide-mobile-s:text-xs rtl:flex-row-reverse items-center px-4 py-2 dark:border-white dark:text-white font-medium border rounded-2xl shadow transition-all" + > + <BoldIcon name="global" size={18} tinted className="mr-2 text-current" /> + {current.flag} {current.label} + </button> + + {open && ( + <div className="absolute z-10 mt-2 rounded-md bg-white dark:bg-neutral-800 shadow-lg ring-1 ring-black ring-opacity-5"> + <div className="py-1"> + {languages.map((lang) => ( + <button + key={lang.code} + onClick={() => void changeLanguage(lang.code)} + className="w-full rtl:flex-row-reverse flex items-center px-4 py-2 text-sm text-gray-700 dark:text-white/60 dark:hover:bg-gray-600 hover:bg-gray-100" + > + <span className="mr-2 flex">{lang.flag}</span> {lang.label} + </button> + ))} + </div> + </div> + )} + </div> + ); } \ No newline at end of file diff --git a/src/components/ui/ProductCart.tsx b/src/components/ui/ProductCart.tsx index 5a9b098..ee3f2d5 100644 --- a/src/components/ui/ProductCart.tsx +++ b/src/components/ui/ProductCart.tsx @@ -1,123 +1,123 @@ -"use client"; - -import React from "react"; -import { useTranslation } from "react-i18next"; -import BoldIcon from "@/components/ui/BoldIcon"; -import { - Card, - CardContent, - CardFooter, - CardHeader, -} from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; - -interface Cart { - image: string; - title: string; - price: number; - discountedPrice: number; - score: number; - monetaryUnit: string; - heartStyle?: string; - style?: string; -} - -export default function ProductCart(props: Cart) { - const { t } = useTranslation("common"); - - const discount = - props.discountedPrice > 0 - ? Math.floor(100 - props.discountedPrice / (props.price / 100)) - : 0; - - return ( - <Card - className={cn( - "group relative w-[240px] max-laptop-xl:w-[210px] max-Wide-mobile-2xl:w-[180px] dark:bg-neutral-800 overflow-hidden border hover:shadow-xl hover:-translate-y-1 transition-all duration-300 flex flex-col", - props.style - )} - > - {/* Image Section */} - <CardHeader className="p-0 relative"> - <img - src={props.image} - alt="product" - className="h-52 max-laptop-xl:h-40 max-Wide-mobile-2xl:h-36 w-full object-contain - bg-white dark:bg-neutral-800 - transition-transform duration-300 group-hover:scale-105 rounded-xl" - /> - - {/* Discount Badge */} - {discount > 0 && ( - <Badge className="absolute top-2 right-2 bg-cyan-500 text-white dark:bg-neutral-600 shadow-md"> - -{discount}% - </Badge> - )} - - <div className="absolute top-2 left-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition"> - <Button - size="icon" - variant="secondary" - className="rounded-full bg-white/80 dark:bg-neutral-800/80 backdrop-blur hover:bg-red-500 hover:text-white" - > - <BoldIcon name="heart" size={16} tinted className="text-red-500 dark:text-red-400" /> - </Button> - <Button - size="icon" - variant="secondary" - className="rounded-full bg-white/80 dark:bg-neutral-800/80 backdrop-blur hover:bg-cyan-500 hover:text-white" - > - <BoldIcon name="share" size={16} tinted className="text-cyan-500 dark:text-cyan-400" /> - </Button> - </div> - </CardHeader> - - {/* Product Info */} - <CardContent className="flex flex-col flex-1 justify-between p-3"> - <p className="line-clamp-2 text-sm font-semibold text-neutral-800 dark:text-neutral-200"> - {props.title} - </p> - - <div className="flex items-center justify-between mt-3"> - <div> - <div className="text-neutral-500 flex items-center gap-1 rtl:flex-row-reverse text-sm"> - <span>{props.monetaryUnit}</span> - <span - className={cn( - props.discountedPrice - ? "line-through text-neutral-400" - : "text-neutral-800 font-bold" - )} - > - {props.price.toLocaleString()} - </span> - </div> - {props.discountedPrice > 0 && ( - <div className="text-black dark:text-white font-bold flex items-center gap-1 rtl:flex-row-reverse text-sm"> - <span>{props.monetaryUnit}</span> - <span>{props.discountedPrice.toLocaleString()}</span> - </div> - )} - </div> - - {/* Rating */} - <div className="flex items-center gap-1 text-yellow-500 text-sm font-medium"> - <BoldIcon name="star" size={16} tinted className="text-yellow-500" /> - <span className="ml-1 text-sm text-neutral-600 dark:text-neutral-400"> - {props.score.toFixed(1)} - </span> - </div> - </div> - </CardContent> - - {/* Footer CTA */} - <CardFooter className="p-3 pt-0"> - <Button className="w-full bg-cyan-500 hover:bg-cyan-600 dark:bg-neutral-500 dark:hover:bg-neutral-600 text-white font-semibold"> - {t("Interest.productCartBtnValue")} - </Button> - </CardFooter> - </Card> - ); -} +"use client"; + +import React from "react"; +import { useTranslation } from "react-i18next"; +import BoldIcon from "@/components/ui/BoldIcon"; +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface Cart { + image: string; + title: string; + price: number; + discountedPrice: number; + score: number; + monetaryUnit: string; + heartStyle?: string; + style?: string; +} + +export default function ProductCart(props: Cart) { + const { t } = useTranslation("common"); + + const discount = + props.discountedPrice > 0 + ? Math.floor(100 - props.discountedPrice / (props.price / 100)) + : 0; + + return ( + <Card + className={cn( + "group relative w-[240px] max-laptop-xl:w-[210px] max-Wide-mobile-2xl:w-[180px] dark:bg-neutral-800 overflow-hidden border hover:shadow-xl hover:-translate-y-1 transition-all duration-300 flex flex-col", + props.style + )} + > + {/* Image Section */} + <CardHeader className="p-0 relative"> + <img + src={props.image} + alt="product" + className="h-52 max-laptop-xl:h-40 max-Wide-mobile-2xl:h-36 w-full object-contain + bg-white dark:bg-neutral-800 + transition-transform duration-300 group-hover:scale-105 rounded-2xl" + /> + + {/* Discount Badge */} + {discount > 0 && ( + <Badge className="absolute top-2 right-2 bg-cyan-500 text-white dark:bg-neutral-600 shadow-md"> + -{discount}% + </Badge> + )} + + <div className="absolute top-2 left-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition"> + <Button + size="icon" + variant="secondary" + className="rounded-full bg-white/80 dark:bg-neutral-800/80 backdrop-blur hover:bg-red-500 hover:text-white" + > + <BoldIcon name="heart" size={16} tinted className="text-red-500 dark:text-red-400" /> + </Button> + <Button + size="icon" + variant="secondary" + className="rounded-full bg-white/80 dark:bg-neutral-800/80 backdrop-blur hover:bg-cyan-500 hover:text-white" + > + <BoldIcon name="share" size={16} tinted className="text-cyan-500 dark:text-cyan-400" /> + </Button> + </div> + </CardHeader> + + {/* Product Info */} + <CardContent className="flex flex-col flex-1 justify-between p-3"> + <p className="line-clamp-2 text-sm font-semibold text-neutral-800 dark:text-neutral-200"> + {props.title} + </p> + + <div className="flex items-center justify-between mt-3"> + <div> + <div className="text-neutral-500 flex items-center gap-1 rtl:flex-row-reverse text-sm"> + <span>{props.monetaryUnit}</span> + <span + className={cn( + props.discountedPrice + ? "line-through text-neutral-400" + : "text-neutral-800 font-bold" + )} + > + {props.price.toLocaleString()} + </span> + </div> + {props.discountedPrice > 0 && ( + <div className="text-black dark:text-white font-bold flex items-center gap-1 rtl:flex-row-reverse text-sm"> + <span>{props.monetaryUnit}</span> + <span>{props.discountedPrice.toLocaleString()}</span> + </div> + )} + </div> + + {/* Rating */} + <div className="flex items-center gap-1 text-yellow-500 text-sm font-medium"> + <BoldIcon name="star" size={16} tinted className="text-yellow-500" /> + <span className="ml-1 text-sm text-neutral-600 dark:text-neutral-400"> + {props.score.toFixed(1)} + </span> + </div> + </div> + </CardContent> + + {/* Footer CTA */} + <CardFooter className="p-3 pt-0"> + <Button className="w-full bg-cyan-500 hover:bg-cyan-600 dark:bg-neutral-500 dark:hover:bg-neutral-600 text-white font-semibold"> + {t("Interest.productCartBtnValue")} + </Button> + </CardFooter> + </Card> + ); +} diff --git a/src/components/ui/ReadBox.tsx b/src/components/ui/ReadBox.tsx index 61562b0..1375b9f 100644 --- a/src/components/ui/ReadBox.tsx +++ b/src/components/ui/ReadBox.tsx @@ -1,30 +1,30 @@ -import React from "react"; - -interface readBox { - src: string; - title: string; - text: string; -} - -export default function ReadBox(props: readBox) { - return ( - <div className="max-Wide-mobile-2xl:min-w-[230px] max-Wide-mobile-2xl:h-[340px] cursor-pointer h-auto pb-2.5 w-full max-w-[372px] max-Wide-mobile-4xl:min-w-[270px] border border-neutral-300 dark:border-neutral-500 rounded-xl overflow-hidden shadow-md transition-transform duration-300 hover:scale-[1.03] hover:shadow-xl bg-white dark:bg-neutral-800"> - {/* تصویر */} - <img - src={props.src} - alt="blog image" - className="h-[250px] max-Wide-mobile-2xl:h-[180px] w-full object-cover border-b border-neutral-300 dark:border-neutral-600 rounded-t-xl" - /> - - {/* متن */} - <div className="w-full px-4 mt-3 font-bold"> - <p className="line-clamp-1 dark:text-neutral-200 mb-2 text-neutral-900 text-[16px]"> - {props.title} - </p> - <p className="line-clamp-5 font-medium text-neutral-500 dark:text-neutral-400 text-[13px]"> - {props.text} - </p> - </div> - </div> - ); -} +import React from "react"; + +interface readBox { + src: string; + title: string; + text: string; +} + +export default function ReadBox(props: readBox) { + return ( + <div className="max-Wide-mobile-2xl:min-w-[230px] max-Wide-mobile-2xl:h-[340px] cursor-pointer h-auto pb-2.5 w-full max-w-[372px] max-Wide-mobile-4xl:min-w-[270px] border border-neutral-300 dark:border-neutral-500 rounded-2xl overflow-hidden shadow-md transition-transform duration-300 hover:scale-[1.03] hover:shadow-xl bg-white dark:bg-neutral-800"> + {/* تصویر */} + <img + src={props.src} + alt="blog image" + className="h-[250px] max-Wide-mobile-2xl:h-[180px] w-full object-cover border-b border-neutral-300 dark:border-neutral-600 rounded-t-xl" + /> + + {/* متن */} + <div className="w-full px-4 mt-3 font-bold"> + <p className="line-clamp-1 dark:text-neutral-200 mb-2 text-neutral-900 text-[16px]"> + {props.title} + </p> + <p className="line-clamp-5 font-medium text-neutral-500 dark:text-neutral-400 text-[13px]"> + {props.text} + </p> + </div> + </div> + ); +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 3d5c61a..69b4b67 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,92 +1,92 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -function Card({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="card" - className={cn( - "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", - className - )} - {...props} - /> - ) -} - -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="card-header" - className={cn( - "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", - className - )} - {...props} - /> - ) -} - -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="card-title" - className={cn("text-[#ff107d] leading-none font-semibold", className)} - {...props} - /> - ) -} - -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="card-description" - className={cn("text-muted-foreground text-sm", className)} - {...props} - /> - ) -} - -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="card-action" - className={cn( - "col-start-2 row-span-2 row-start-1 self-start justify-self-end", - className - )} - {...props} - /> - ) -} - -function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="card-content" - className={cn("px-6", className)} - {...props} - /> - ) -} - -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="card-footer" - className={cn("flex items-center px-6 [.border-t]:pt-6", className)} - {...props} - /> - ) -} - -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardAction, - CardDescription, - CardContent, -} +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card" + className={cn( + "bg-card text-card-foreground flex flex-col gap-6 rounded-2xl border py-6 shadow-sm", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-header" + className={cn( + "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", + className + )} + {...props} + /> + ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-title" + className={cn("text-[#ff107d] leading-none font-semibold", className)} + {...props} + /> + ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-description" + className={cn("text-muted-foreground text-sm", className)} + {...props} + /> + ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-action" + className={cn( + "col-start-2 row-span-2 row-start-1 self-start justify-self-end", + className + )} + {...props} + /> + ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-content" + className={cn("px-6", className)} + {...props} + /> + ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-footer" + className={cn("flex items-center px-6 [.border-t]:pt-6", className)} + {...props} + /> + ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 8d5044b..8587348 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -1,726 +1,726 @@ -"use client"; - -import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; -import { cva, VariantProps } from "class-variance-authority"; -import { PanelLeftIcon } from "lucide-react"; - -import { useIsMobile } from "@/hooks/use-mobile"; -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; - -const SIDEBAR_COOKIE_NAME = "sidebar_state"; -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; -const SIDEBAR_WIDTH = "16rem"; -const SIDEBAR_WIDTH_MOBILE = "18rem"; -const SIDEBAR_WIDTH_ICON = "3rem"; -const SIDEBAR_KEYBOARD_SHORTCUT = "b"; - -type SidebarContextProps = { - state: "expanded" | "collapsed"; - open: boolean; - setOpen: (open: boolean) => void; - openMobile: boolean; - setOpenMobile: (open: boolean) => void; - isMobile: boolean; - toggleSidebar: () => void; -}; - -const SidebarContext = React.createContext<SidebarContextProps | null>(null); - -function useSidebar() { - const context = React.useContext(SidebarContext); - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider."); - } - - return context; -} - -function SidebarProvider({ - defaultOpen = true, - open: openProp, - onOpenChange: setOpenProp, - className, - style, - children, - ...props -}: React.ComponentProps<"div"> & { - defaultOpen?: boolean; - open?: boolean; - onOpenChange?: (open: boolean) => void; -}) { - const isMobile = useIsMobile(); - const [openMobile, setOpenMobile] = React.useState(false); - - // This is the internal state of the sidebar. - // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen); - const open = openProp ?? _open; - const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === "function" ? value(open) : value; - if (setOpenProp) { - setOpenProp(openState); - } else { - _setOpen(openState); - } - - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; - }, - [setOpenProp, open] - ); - - // Helper to toggle the sidebar. - const toggleSidebar = React.useCallback(() => { - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); - }, [isMobile, setOpen, setOpenMobile]); - - // Adds a keyboard shortcut to toggle the sidebar. - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key === SIDEBAR_KEYBOARD_SHORTCUT && - (event.metaKey || event.ctrlKey) - ) { - event.preventDefault(); - toggleSidebar(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [toggleSidebar]); - - // We add a state so that we can do data-state="expanded" or "collapsed". - // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed"; - - const contextValue = React.useMemo<SidebarContextProps>( - () => ({ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] - ); - - return ( - <SidebarContext.Provider value={contextValue}> - <TooltipProvider delayDuration={0}> - <div - data-slot="sidebar-wrapper" - style={ - { - "--sidebar-width": SIDEBAR_WIDTH, - "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, - ...style, - } as React.CSSProperties - } - className={cn( - "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", - className - )} - {...props} - > - {children} - </div> - </TooltipProvider> - </SidebarContext.Provider> - ); -} - -function Sidebar({ - side = "left", - variant = "sidebar", - collapsible = "offcanvas", - className, - children, - ...props -}: React.ComponentProps<"div"> & { - side?: "left" | "right"; - variant?: "sidebar" | "floating" | "inset"; - collapsible?: "offcanvas" | "icon" | "none"; -}) { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); - - if (collapsible === "none") { - return ( - <div - data-slot="sidebar" - className={cn( - "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", - className - )} - {...props} - > - {children} - </div> - ); - } - - if (isMobile) { - return ( - <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> - <SheetContent - data-sidebar="sidebar" - data-slot="sidebar" - data-mobile="true" - className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" - style={ - { - "--sidebar-width": SIDEBAR_WIDTH_MOBILE, - } as React.CSSProperties - } - side={side} - > - <SheetHeader className="sr-only"> - <SheetTitle>Sidebar</SheetTitle> - <SheetDescription>Displays the mobile sidebar.</SheetDescription> - </SheetHeader> - <div className="flex h-full w-full flex-col">{children}</div> - </SheetContent> - </Sheet> - ); - } - - return ( - <div - className="group peer text-sidebar-foreground hidden md:block" - data-state={state} - data-collapsible={state === "collapsed" ? collapsible : ""} - data-variant={variant} - data-side={side} - data-slot="sidebar" - > - {/* This is what handles the sidebar gap on desktop */} - <div - data-slot="sidebar-gap" - className={cn( - "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear", - "group-data-[collapsible=offcanvas]:w-0", - "group-data-[side=right]:rotate-180", - variant === "floating" || variant === "inset" - ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" - : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)" - )} - /> - <div - data-slot="sidebar-container" - className={cn( - "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex", - side === "left" - ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" - : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", - // Adjust the padding for floating and inset variants. - variant === "floating" || variant === "inset" - ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" - : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", - className - )} - {...props} - > - <div - data-sidebar="sidebar" - data-slot="sidebar-inner" - className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" - > - {children} - </div> - </div> - </div> - ); -} - -function SidebarTrigger({ - className, - onClick, - ...props -}: React.ComponentProps<typeof Button>) { - const { toggleSidebar } = useSidebar(); - - return ( - <Button - data-sidebar="trigger" - data-slot="sidebar-trigger" - variant="ghost" - size="icon" - className={cn("size-7", className)} - onClick={(event) => { - onClick?.(event); - toggleSidebar(); - }} - {...props} - > - <PanelLeftIcon /> - <span className="sr-only">Toggle Sidebar</span> - </Button> - ); -} - -function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { - const { toggleSidebar } = useSidebar(); - - return ( - <button - data-sidebar="rail" - data-slot="sidebar-rail" - aria-label="Toggle Sidebar" - tabIndex={-1} - onClick={toggleSidebar} - title="Toggle Sidebar" - className={cn( - "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex", - "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", - "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", - "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", - "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", - "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", - className - )} - {...props} - /> - ); -} - -function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { - return ( - <main - data-slot="sidebar-inset" - className={cn( - "bg-background relative flex w-full flex-1 flex-col", - "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", - className - )} - {...props} - /> - ); -} - -function SidebarInput({ - className, - ...props -}: React.ComponentProps<typeof Input>) { - return ( - <Input - data-slot="sidebar-input" - data-sidebar="input" - className={cn("bg-background h-8 w-full shadow-none", className)} - {...props} - /> - ); -} - -function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-header" - data-sidebar="header" - className={cn("flex flex-col gap-2 p-2", className)} - {...props} - /> - ); -} - -function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-footer" - data-sidebar="footer" - className={cn("flex flex-col gap-2 p-2", className)} - {...props} - /> - ); -} - -function SidebarSeparator({ - className, - ...props -}: React.ComponentProps<typeof Separator>) { - return ( - <Separator - data-slot="sidebar-separator" - data-sidebar="separator" - className={cn("bg-sidebar-border mx-2 w-auto", className)} - {...props} - /> - ); -} - -function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-content" - data-sidebar="content" - className={cn( - "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", - className - )} - {...props} - /> - ); -} - -function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-group" - data-sidebar="group" - className={cn("relative flex w-full min-w-0 flex-col p-2", className)} - {...props} - /> - ); -} - -function SidebarGroupLabel({ - className, - asChild = false, - ...props -}: React.ComponentProps<"div"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "div"; - - return ( - <Comp - data-slot="sidebar-group-label" - data-sidebar="group-label" - className={cn( - "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", - className - )} - {...props} - /> - ); -} - -function SidebarGroupAction({ - className, - asChild = false, - ...props -}: React.ComponentProps<"button"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "button"; - - return ( - <Comp - data-slot="sidebar-group-action" - data-sidebar="group-action" - className={cn( - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 md:after:hidden", - "group-data-[collapsible=icon]:hidden", - className - )} - {...props} - /> - ); -} - -function SidebarGroupContent({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-group-content" - data-sidebar="group-content" - className={cn("w-full text-sm", className)} - {...props} - /> - ); -} - -function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { - return ( - <ul - data-slot="sidebar-menu" - data-sidebar="menu" - className={cn("flex w-full min-w-0 flex-col gap-1", className)} - {...props} - /> - ); -} - -function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { - return ( - <li - data-slot="sidebar-menu-item" - data-sidebar="menu-item" - className={cn("group/menu-item relative", className)} - {...props} - /> - ); -} - -const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", - outline: - "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", - }, - size: { - default: "h-8 text-sm", - sm: "h-7 text-xs", - lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -); - -function SidebarMenuButton({ - asChild = false, - isActive = false, - variant = "default", - size = "default", - tooltip, - className, - ...props -}: React.ComponentProps<"button"> & { - asChild?: boolean; - isActive?: boolean; - tooltip?: string | React.ComponentProps<typeof TooltipContent>; -} & VariantProps<typeof sidebarMenuButtonVariants>) { - const Comp = asChild ? Slot : "button"; - const { isMobile, state } = useSidebar(); - - const button = ( - <Comp - data-slot="sidebar-menu-button" - data-sidebar="menu-button" - data-size={size} - data-active={isActive} - className={cn(sidebarMenuButtonVariants({ variant, size }), className)} - {...props} - /> - ); - - if (!tooltip) { - return button; - } - - if (typeof tooltip === "string") { - tooltip = { - children: tooltip, - }; - } - - return ( - <Tooltip> - <TooltipTrigger asChild>{button}</TooltipTrigger> - <TooltipContent - side="right" - align="center" - hidden={state !== "collapsed" || isMobile} - {...tooltip} - /> - </Tooltip> - ); -} - -function SidebarMenuAction({ - className, - asChild = false, - showOnHover = false, - ...props -}: React.ComponentProps<"button"> & { - asChild?: boolean; - showOnHover?: boolean; -}) { - const Comp = asChild ? Slot : "button"; - - return ( - <Comp - data-slot="sidebar-menu-action" - data-sidebar="menu-action" - className={cn( - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 md:after:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - showOnHover && - "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", - className - )} - {...props} - /> - ); -} - -function SidebarMenuBadge({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-menu-badge" - data-sidebar="menu-badge" - className={cn( - "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none", - "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - className - )} - {...props} - /> - ); -} - -function SidebarMenuSkeleton({ - className, - showIcon = false, - ...props -}: React.ComponentProps<"div"> & { - showIcon?: boolean; -}) { - // Random width between 50 to 90%. - const width = React.useMemo(() => { - return `${Math.floor(Math.random() * 40) + 50}%`; - }, []); - - return ( - <div - data-slot="sidebar-menu-skeleton" - data-sidebar="menu-skeleton" - className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)} - {...props} - > - {showIcon && ( - <Skeleton - className="size-4 rounded-md" - data-sidebar="menu-skeleton-icon" - /> - )} - <Skeleton - className="h-4 max-w-(--skeleton-width) flex-1" - data-sidebar="menu-skeleton-text" - style={ - { - "--skeleton-width": width, - } as React.CSSProperties - } - /> - </div> - ); -} - -function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { - return ( - <ul - data-slot="sidebar-menu-sub" - data-sidebar="menu-sub" - className={cn( - "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", - "group-data-[collapsible=icon]:hidden", - className - )} - {...props} - /> - ); -} - -function SidebarMenuSubItem({ - className, - ...props -}: React.ComponentProps<"li">) { - return ( - <li - data-slot="sidebar-menu-sub-item" - data-sidebar="menu-sub-item" - className={cn("group/menu-sub-item relative", className)} - {...props} - /> - ); -} - -function SidebarMenuSubButton({ - asChild = false, - size = "md", - isActive = false, - className, - ...props -}: React.ComponentProps<"a"> & { - asChild?: boolean; - size?: "sm" | "md"; - isActive?: boolean; -}) { - const Comp = asChild ? Slot : "a"; - - return ( - <Comp - data-slot="sidebar-menu-sub-button" - data-sidebar="menu-sub-button" - data-size={size} - data-active={isActive} - className={cn( - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", - "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", - size === "sm" && "text-xs", - size === "md" && "text-sm", - "group-data-[collapsible=icon]:hidden", - className - )} - {...props} - /> - ); -} - -export { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupAction, - SidebarGroupContent, - SidebarGroupLabel, - SidebarHeader, - SidebarInput, - SidebarInset, - SidebarMenu, - SidebarMenuAction, - SidebarMenuBadge, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSkeleton, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - SidebarProvider, - SidebarRail, - SidebarSeparator, - SidebarTrigger, - useSidebar, -}; +"use client"; + +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, VariantProps } from "class-variance-authority"; +import { PanelLeftIcon } from "lucide-react"; + +import { useIsMobile } from "@/hooks/use-mobile"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +const SIDEBAR_COOKIE_NAME = "sidebar_state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +type SidebarContextProps = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext<SidebarContextProps | null>(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open] + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo<SidebarContextProps>( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ); + + return ( + <SidebarContext.Provider value={contextValue}> + <TooltipProvider delayDuration={0}> + <div + data-slot="sidebar-wrapper" + style={ + { + "--sidebar-width": SIDEBAR_WIDTH, + "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, + ...style, + } as React.CSSProperties + } + className={cn( + "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", + className + )} + {...props} + > + {children} + </div> + </TooltipProvider> + </SidebarContext.Provider> + ); +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( + <div + data-slot="sidebar" + className={cn( + "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", + className + )} + {...props} + > + {children} + </div> + ); + } + + if (isMobile) { + return ( + <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> + <SheetContent + data-sidebar="sidebar" + data-slot="sidebar" + data-mobile="true" + className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" + style={ + { + "--sidebar-width": SIDEBAR_WIDTH_MOBILE, + } as React.CSSProperties + } + side={side} + > + <SheetHeader className="sr-only"> + <SheetTitle>Sidebar</SheetTitle> + <SheetDescription>Displays the mobile sidebar.</SheetDescription> + </SheetHeader> + <div className="flex h-full w-full flex-col">{children}</div> + </SheetContent> + </Sheet> + ); + } + + return ( + <div + className="group peer text-sidebar-foreground hidden md:block" + data-state={state} + data-collapsible={state === "collapsed" ? collapsible : ""} + data-variant={variant} + data-side={side} + data-slot="sidebar" + > + {/* This is what handles the sidebar gap on desktop */} + <div + data-slot="sidebar-gap" + className={cn( + "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear", + "group-data-[collapsible=offcanvas]:w-0", + "group-data-[side=right]:rotate-180", + variant === "floating" || variant === "inset" + ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" + : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)" + )} + /> + <div + data-slot="sidebar-container" + className={cn( + "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex", + side === "left" + ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" + : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", + // Adjust the padding for floating and inset variants. + variant === "floating" || variant === "inset" + ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" + : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", + className + )} + {...props} + > + <div + data-sidebar="sidebar" + data-slot="sidebar-inner" + className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" + > + {children} + </div> + </div> + </div> + ); +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps<typeof Button>) { + const { toggleSidebar } = useSidebar(); + + return ( + <Button + data-sidebar="trigger" + data-slot="sidebar-trigger" + variant="ghost" + size="icon" + className={cn("size-7", className)} + onClick={(event) => { + onClick?.(event); + toggleSidebar(); + }} + {...props} + > + <PanelLeftIcon /> + <span className="sr-only">Toggle Sidebar</span> + </Button> + ); +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar(); + + return ( + <button + data-sidebar="rail" + data-slot="sidebar-rail" + aria-label="Toggle Sidebar" + tabIndex={-1} + onClick={toggleSidebar} + title="Toggle Sidebar" + className={cn( + "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex", + "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", + "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", + "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", + "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", + "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", + className + )} + {...props} + /> + ); +} + +function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { + return ( + <main + data-slot="sidebar-inset" + className={cn( + "bg-background relative flex w-full flex-1 flex-col", + "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-2xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", + className + )} + {...props} + /> + ); +} + +function SidebarInput({ + className, + ...props +}: React.ComponentProps<typeof Input>) { + return ( + <Input + data-slot="sidebar-input" + data-sidebar="input" + className={cn("bg-background h-8 w-full shadow-none", className)} + {...props} + /> + ); +} + +function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="sidebar-header" + data-sidebar="header" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} + /> + ); +} + +function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="sidebar-footer" + data-sidebar="footer" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} + /> + ); +} + +function SidebarSeparator({ + className, + ...props +}: React.ComponentProps<typeof Separator>) { + return ( + <Separator + data-slot="sidebar-separator" + data-sidebar="separator" + className={cn("bg-sidebar-border mx-2 w-auto", className)} + {...props} + /> + ); +} + +function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="sidebar-content" + data-sidebar="content" + className={cn( + "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", + className + )} + {...props} + /> + ); +} + +function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="sidebar-group" + data-sidebar="group" + className={cn("relative flex w-full min-w-0 flex-col p-2", className)} + {...props} + /> + ); +} + +function SidebarGroupLabel({ + className, + asChild = false, + ...props +}: React.ComponentProps<"div"> & { asChild?: boolean }) { + const Comp = asChild ? Slot : "div"; + + return ( + <Comp + data-slot="sidebar-group-label" + data-sidebar="group-label" + className={cn( + "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", + className + )} + {...props} + /> + ); +} + +function SidebarGroupAction({ + className, + asChild = false, + ...props +}: React.ComponentProps<"button"> & { asChild?: boolean }) { + const Comp = asChild ? Slot : "button"; + + return ( + <Comp + data-slot="sidebar-group-action" + data-sidebar="group-action" + className={cn( + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 md:after:hidden", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> + ); +} + +function SidebarGroupContent({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + <div + data-slot="sidebar-group-content" + data-sidebar="group-content" + className={cn("w-full text-sm", className)} + {...props} + /> + ); +} + +function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { + return ( + <ul + data-slot="sidebar-menu" + data-sidebar="menu" + className={cn("flex w-full min-w-0 flex-col gap-1", className)} + {...props} + /> + ); +} + +function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { + return ( + <li + data-slot="sidebar-menu-item" + data-sidebar="menu-item" + className={cn("group/menu-item relative", className)} + {...props} + /> + ); +} + +const sidebarMenuButtonVariants = cva( + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + outline: + "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", + }, + size: { + default: "h-8 text-sm", + sm: "h-7 text-xs", + lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +function SidebarMenuButton({ + asChild = false, + isActive = false, + variant = "default", + size = "default", + tooltip, + className, + ...props +}: React.ComponentProps<"button"> & { + asChild?: boolean; + isActive?: boolean; + tooltip?: string | React.ComponentProps<typeof TooltipContent>; +} & VariantProps<typeof sidebarMenuButtonVariants>) { + const Comp = asChild ? Slot : "button"; + const { isMobile, state } = useSidebar(); + + const button = ( + <Comp + data-slot="sidebar-menu-button" + data-sidebar="menu-button" + data-size={size} + data-active={isActive} + className={cn(sidebarMenuButtonVariants({ variant, size }), className)} + {...props} + /> + ); + + if (!tooltip) { + return button; + } + + if (typeof tooltip === "string") { + tooltip = { + children: tooltip, + }; + } + + return ( + <Tooltip> + <TooltipTrigger asChild>{button}</TooltipTrigger> + <TooltipContent + side="right" + align="center" + hidden={state !== "collapsed" || isMobile} + {...tooltip} + /> + </Tooltip> + ); +} + +function SidebarMenuAction({ + className, + asChild = false, + showOnHover = false, + ...props +}: React.ComponentProps<"button"> & { + asChild?: boolean; + showOnHover?: boolean; +}) { + const Comp = asChild ? Slot : "button"; + + return ( + <Comp + data-slot="sidebar-menu-action" + data-sidebar="menu-action" + className={cn( + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 md:after:hidden", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + showOnHover && + "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", + className + )} + {...props} + /> + ); +} + +function SidebarMenuBadge({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + <div + data-slot="sidebar-menu-badge" + data-sidebar="menu-badge" + className={cn( + "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none", + "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> + ); +} + +function SidebarMenuSkeleton({ + className, + showIcon = false, + ...props +}: React.ComponentProps<"div"> & { + showIcon?: boolean; +}) { + // Random width between 50 to 90%. + const width = React.useMemo(() => { + return `${Math.floor(Math.random() * 40) + 50}%`; + }, []); + + return ( + <div + data-slot="sidebar-menu-skeleton" + data-sidebar="menu-skeleton" + className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)} + {...props} + > + {showIcon && ( + <Skeleton + className="size-4 rounded-md" + data-sidebar="menu-skeleton-icon" + /> + )} + <Skeleton + className="h-4 max-w-(--skeleton-width) flex-1" + data-sidebar="menu-skeleton-text" + style={ + { + "--skeleton-width": width, + } as React.CSSProperties + } + /> + </div> + ); +} + +function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { + return ( + <ul + data-slot="sidebar-menu-sub" + data-sidebar="menu-sub" + className={cn( + "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> + ); +} + +function SidebarMenuSubItem({ + className, + ...props +}: React.ComponentProps<"li">) { + return ( + <li + data-slot="sidebar-menu-sub-item" + data-sidebar="menu-sub-item" + className={cn("group/menu-sub-item relative", className)} + {...props} + /> + ); +} + +function SidebarMenuSubButton({ + asChild = false, + size = "md", + isActive = false, + className, + ...props +}: React.ComponentProps<"a"> & { + asChild?: boolean; + size?: "sm" | "md"; + isActive?: boolean; +}) { + const Comp = asChild ? Slot : "a"; + + return ( + <Comp + data-slot="sidebar-menu-sub-button" + data-sidebar="menu-sub-button" + data-size={size} + data-active={isActive} + className={cn( + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", + size === "sm" && "text-xs", + size === "md" && "text-sm", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> + ); +} + +export { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInput, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar, +}; diff --git a/src/constants/exploreFilters.ts b/src/constants/exploreFilters.ts index 3bc8388..bece430 100644 --- a/src/constants/exploreFilters.ts +++ b/src/constants/exploreFilters.ts @@ -9,7 +9,9 @@ export type ExploreFilterId = | "makeup" | "professional" | "best_month" - | "near_me"; + | "near_me" + | "girls" + | "boys"; export type ExploreFilterItem = { id: ExploreFilterId; @@ -28,4 +30,6 @@ export const EXPLORE_FILTERS: ExploreFilterItem[] = [ { id: "professional", color: "#a78bfa" }, { id: "best_month", color: "#fbbf24" }, { id: "near_me", color: "#34d399" }, + { id: "girls", color: "#f472b6" }, + { id: "boys", color: "#3b82f6" }, ]; diff --git a/src/constants/notificationFilters.ts b/src/constants/notificationFilters.ts index 2afecdb..ea25d0c 100644 --- a/src/constants/notificationFilters.ts +++ b/src/constants/notificationFilters.ts @@ -49,6 +49,11 @@ const POST_TYPES = new Set([ "post_tag", "accept-post", "reject-post", + // نوتیفیکیشن‌های هوشمند (SmartNotificationsCron.js) — همیشه درباره‌ی یه پست معمولی‌ان + "trending_post_3day", + "weekly_new_trend", + "inactivity_reengagement", + "new_post_from_followed", ]); const ACADEMY_TYPES = new Set([ @@ -157,7 +162,7 @@ export function getNotificationActionMeta(type: string): { export function getNotificationVisualKind( type: string ): "user" | "post" | "project" | "billboard" | "academy" { - if (type === "accept-post" || type === "reject-post" || type === "post_tag") { + if (POST_TYPES.has(type)) { return "post"; } if ( diff --git a/src/data/geo/provinces.json b/src/data/geo/provinces.json index e24be4f..7f78022 100644 --- a/src/data/geo/provinces.json +++ b/src/data/geo/provinces.json @@ -893,217 +893,248 @@ "source_id": 3929, "country_id": 103, "name": "Alborz", - "slug": "ir-alborz" + "slug": "ir-alborz", + "name_fa": "البرز" }, { "id": 129, "source_id": 3934, "country_id": 103, "name": "Ardabil", - "slug": "ir-ardabil" + "slug": "ir-ardabil", + "name_fa": "اردبیل" }, { "id": 130, "source_id": 3932, "country_id": 103, "name": "Bushehr", - "slug": "ir-bushehr" + "slug": "ir-bushehr", + "name_fa": "بوشهر" }, { "id": 131, "source_id": 3921, "country_id": 103, "name": "Chaharmahal and Bakhtiari", - "slug": "ir-chaharmahal-and-bakhtiari" + "slug": "ir-chaharmahal-and-bakhtiari", + "name_fa": "چهارمحال و بختیاری" }, { "id": 132, "source_id": 3944, "country_id": 103, "name": "East Azerbaijan", - "slug": "ir-east-azerbaijan" + "slug": "ir-east-azerbaijan", + "name_fa": "آذربایجان شرقی" }, { "id": 133, "source_id": 3939, "country_id": 103, "name": "Fars", - "slug": "ir-fars" + "slug": "ir-fars", + "name_fa": "فارس" }, { "id": 134, "source_id": 3920, "country_id": 103, "name": "Gilan", - "slug": "ir-gilan" + "slug": "ir-gilan", + "name_fa": "گیلان" }, { "id": 135, "source_id": 3933, "country_id": 103, "name": "Golestan", - "slug": "ir-golestan" + "slug": "ir-golestan", + "name_fa": "گلستان" }, { "id": 136, "source_id": 4920, "country_id": 103, "name": "Hamadan", - "slug": "ir-hamadan" + "slug": "ir-hamadan", + "name_fa": "همدان" }, { "id": 137, "source_id": 3937, "country_id": 103, "name": "Hormozgan", - "slug": "ir-hormozgan" + "slug": "ir-hormozgan", + "name_fa": "هرمزگان" }, { "id": 138, "source_id": 3918, "country_id": 103, "name": "Ilam", - "slug": "ir-ilam" + "slug": "ir-ilam", + "name_fa": "ایلام" }, { "id": 139, "source_id": 3923, "country_id": 103, "name": "Isfahan", - "slug": "ir-isfahan" + "slug": "ir-isfahan", + "name_fa": "اصفهان" }, { "id": 140, "source_id": 3943, "country_id": 103, "name": "Kerman", - "slug": "ir-kerman" + "slug": "ir-kerman", + "name_fa": "کرمان" }, { "id": 141, "source_id": 3919, "country_id": 103, "name": "Kermanshah", - "slug": "ir-kermanshah" + "slug": "ir-kermanshah", + "name_fa": "کرمانشاه" }, { "id": 142, "source_id": 3917, "country_id": 103, "name": "Khuzestan", - "slug": "ir-khuzestan" + "slug": "ir-khuzestan", + "name_fa": "خوزستان" }, { "id": 143, "source_id": 3926, "country_id": 103, "name": "Kohgiluyeh and Boyer-Ahmad", - "slug": "ir-kohgiluyeh-and-boyer-ahmad" + "slug": "ir-kohgiluyeh-and-boyer-ahmad", + "name_fa": "کهگیلویه و بویراحمد" }, { "id": 144, "source_id": 3935, "country_id": 103, "name": "Kurdistan", - "slug": "ir-kurdistan" + "slug": "ir-kurdistan", + "name_fa": "کردستان" }, { "id": 145, "source_id": 3928, "country_id": 103, "name": "Lorestan", - "slug": "ir-lorestan" + "slug": "ir-lorestan", + "name_fa": "لرستان" }, { "id": 146, "source_id": 3916, "country_id": 103, "name": "Markazi", - "slug": "ir-markazi" + "slug": "ir-markazi", + "name_fa": "مرکزی" }, { "id": 147, "source_id": 3938, "country_id": 103, "name": "Mazandaran", - "slug": "ir-mazandaran" + "slug": "ir-mazandaran", + "name_fa": "مازندران" }, { "id": 148, "source_id": 3942, "country_id": 103, "name": "North Khorasan", - "slug": "ir-north-khorasan" + "slug": "ir-north-khorasan", + "name_fa": "خراسان شمالی" }, { "id": 149, "source_id": 3941, "country_id": 103, "name": "Qazvin", - "slug": "ir-qazvin" + "slug": "ir-qazvin", + "name_fa": "قزوین" }, { "id": 150, "source_id": 3922, "country_id": 103, "name": "Qom", - "slug": "ir-qom" + "slug": "ir-qom", + "name_fa": "قم" }, { "id": 151, "source_id": 3927, "country_id": 103, "name": "Razavi Khorasan", - "slug": "ir-razavi-khorasan" + "slug": "ir-razavi-khorasan", + "name_fa": "خراسان رضوی" }, { "id": 152, "source_id": 3940, "country_id": 103, "name": "Semnan", - "slug": "ir-semnan" + "slug": "ir-semnan", + "name_fa": "سمنان" }, { "id": 153, "source_id": 3931, "country_id": 103, "name": "Sistan and Baluchestan", - "slug": "ir-sistan-and-baluchestan" + "slug": "ir-sistan-and-baluchestan", + "name_fa": "سیستان و بلوچستان" }, { "id": 154, "source_id": 3930, "country_id": 103, "name": "South Khorasan", - "slug": "ir-south-khorasan" + "slug": "ir-south-khorasan", + "name_fa": "خراسان جنوبی" }, { "id": 155, "source_id": 3945, "country_id": 103, "name": "Tehran", - "slug": "ir-tehran" + "slug": "ir-tehran", + "name_fa": "تهران" }, { "id": 156, "source_id": 3924, "country_id": 103, "name": "West Azarbaijan", - "slug": "ir-west-azarbaijan" + "slug": "ir-west-azarbaijan", + "name_fa": "آذربایجان غربی" }, { "id": 157, "source_id": 3936, "country_id": 103, "name": "Yazd", - "slug": "ir-yazd" + "slug": "ir-yazd", + "name_fa": "یزد" }, { "id": 158, "source_id": 3925, "country_id": 103, "name": "Zanjan", - "slug": "ir-zanjan" + "slug": "ir-zanjan", + "name_fa": "زنجان" }, { "id": 159, diff --git a/src/hooks/usePwaInstall.ts b/src/hooks/usePwaInstall.ts index f6e6147..4c5fa55 100644 --- a/src/hooks/usePwaInstall.ts +++ b/src/hooks/usePwaInstall.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { BeforeInstallPromptEvent, dismissInstallPrompt, + isAndroidDevice, isDismissed, isIosDevice, isStandaloneMode, @@ -15,6 +16,7 @@ export function usePwaInstall() { const [visible, setVisible] = useState(false); const [installing, setInstalling] = useState(false); const isIos = isIosDevice(); + const isAndroid = isAndroidDevice(); useEffect(() => { if (isStandaloneMode() || isDismissed()) return; @@ -35,7 +37,10 @@ export function usePwaInstall() { const timer = window.setTimeout(() => { if (isStandaloneMode() || isDismissed()) return; - if (isIosDevice()) { + // آیفون: هیچ‌وقت beforeinstallprompt نداره، پس همیشه با تایمر نشون داده می‌شه. + // اندروید: حتی اگه beforeinstallprompt (نصبِ PWA) در دسترس نباشه (مرورگرهای غیرِ + // کروم)، بازم باید بنر دیده بشه چون دکمه‌ی «دانلود اپلیکیشن» وابسته به اون API نیست + if (isIosDevice() || isAndroidDevice()) { setVisible(true); } }, 2000); @@ -74,6 +79,7 @@ export function usePwaInstall() { visible, installing, isIos, + isAndroid, canNativeInstall: Boolean(deferredPrompt), install, close, diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index c31cebb..ea2041b 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -1,4 +1,5 @@ import Cookies from "js-cookie"; +import { clearDmKeyCache } from "@/lib/e2ee/dm"; import { readLocalStorage, writeLocalStorage, @@ -65,6 +66,12 @@ export async function setAuthSession( ): Promise<void> { if (!token) return; + // کلید AES مشترکِ هر مکالمه بر اساس جفت‌آیدی کش می‌شه — بین سوییچ‌کردنِ اکانت‌ها + // روی یه مرورگر، اگه یه نسخه‌ی «بازنده»‌ی race (نگاه کن به keys.ts) قبلاً این کش رو + // پر کرده باشه، پاکش می‌کنیم تا هر پیام تازه با کلید هویتِ واقعاً فعلی رمزنگاری/ + // رمزگشایی بشه + clearDmKeyCache(); + Cookies.set(TOKEN_KEY, token, cookieOptions()); let synced = await syncServerCookie(token); @@ -89,6 +96,7 @@ export async function setAuthSession( /** پاک کردن کامل session */ export async function clearAuthSession(): Promise<void> { + clearDmKeyCache(); Cookies.remove(TOKEN_KEY, { path: "/" }); if (typeof window !== "undefined") { diff --git a/src/lib/e2ee/keys.ts b/src/lib/e2ee/keys.ts index 7a8876c..df697cc 100644 --- a/src/lib/e2ee/keys.ts +++ b/src/lib/e2ee/keys.ts @@ -18,6 +18,18 @@ type StoredIdentity = { let memoryPair: { userId: string; privateKey: CryptoKey; publicKeyB64: string } | null = null; +// وقتی کلید هویتِ یه کاربر هنوز نه توی حافظه نه توی IndexedDB موجود نیست (اولین‌بار +// روی این مرورگر)، چند نقطه‌ی مختلف اپ (مثلاً ChatSocketProvider + بارگذاری لیست +// پیام‌های یه ترد) تقریباً هم‌زمان ensureIdentityKeys رو صدا می‌زنن. بدون این قفل، هر +// کدوم جداگانه یه جفت کلید تازه می‌ساختن و جدا-جدا با سرور ثبت می‌کردن (race) — +// برنده‌ی آخر که به سرور می‌رسید، کلید عمومیِ ثبت‌شده رو عوض می‌کرد و پیام‌هایی که با +// کلید «بازنده» رمزنگاری/رمزگشایی شده بودن دیگه با هم جور درنمی‌اومدن (حتی برای خودِ +// فرستنده). این Map تضمین می‌کنه برای هر کاربر فقط یه عملیاتِ ensure هم‌زمان اجرا بشه +const inFlight = new Map< + string, + Promise<{ privateKey: CryptoKey; publicKeyB64: string }> +>(); + function authHeaders(): HeadersInit { const token = Cookies.get("token"); return { @@ -35,18 +47,10 @@ async function publishPublicKey(publicKeyB64: string): Promise<void> { }); } -export async function ensureIdentityKeys(userId: string): Promise<{ +async function loadOrCreateIdentityKeys(uid: string): Promise<{ privateKey: CryptoKey; publicKeyB64: string; }> { - const uid = String(userId); - if (memoryPair?.userId === uid) { - return { - privateKey: memoryPair.privateKey, - publicKeyB64: memoryPair.publicKeyB64, - }; - } - const storeKey = `identity:${uid}`; const stored = await idbGet<StoredIdentity>(storeKey); @@ -83,6 +87,28 @@ export async function ensureIdentityKeys(userId: string): Promise<{ return { privateKey: pair.privateKey, publicKeyB64 }; } +export async function ensureIdentityKeys(userId: string): Promise<{ + privateKey: CryptoKey; + publicKeyB64: string; +}> { + const uid = String(userId); + if (memoryPair?.userId === uid) { + return { + privateKey: memoryPair.privateKey, + publicKeyB64: memoryPair.publicKeyB64, + }; + } + + const existing = inFlight.get(uid); + if (existing) return existing; + + const task = loadOrCreateIdentityKeys(uid).finally(() => { + inFlight.delete(uid); + }); + inFlight.set(uid, task); + return task; +} + export async function fetchPeerPublicKey( peerUserId: string ): Promise<string | null> { diff --git a/src/lib/explore/buildPostFilters.ts b/src/lib/explore/buildPostFilters.ts index 0a2cae1..fd13e8d 100644 --- a/src/lib/explore/buildPostFilters.ts +++ b/src/lib/explore/buildPostFilters.ts @@ -33,6 +33,13 @@ export function buildExplorePostFilters( return { exploreFilter: "predict_trends" }; case "best_month": return { exploreFilter: "best_month" }; + case "girls": + // عمداً exploreFilter ست نمی‌شه — همون مسیر جستجوی هشتگ (hashtag) موجوده + // استفاده می‌شه تا از فید شخصی‌سازی‌شده‌ی معمولی رد بشه، نه مسیر جدای + // فیلتر بر مبنای کاربر (مثل model/photographer/...) — عین RN + return { hashtag: "دخترانه" }; + case "boys": + return { hashtag: "پسرانه" }; case "near_me": if (coords) { return { diff --git a/src/lib/firebase.ts b/src/lib/firebase.ts new file mode 100644 index 0000000..d332731 --- /dev/null +++ b/src/lib/firebase.ts @@ -0,0 +1,47 @@ +import { initializeApp, getApps, type FirebaseApp } from "firebase/app"; +import { getMessaging, isSupported, type Messaging } from "firebase/messaging"; + +// این مقادیر عمومی و امن برای مرورگرند (بر خلاف کلید سرویس‌اکانت بک‌اند که فقط +// سمت سرور استفاده می‌شه) — از NEXT_PUBLIC_FIREBASE_* در .env.local میان +const firebaseConfig = { + apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, + authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, + projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, + storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, +}; + +let app: FirebaseApp | null = null; + +function getFirebaseApp(): FirebaseApp | null { + if (typeof window === "undefined") return null; + if (!firebaseConfig.apiKey) return null; + if (!app) { + app = getApps().length ? getApps()[0] : initializeApp(firebaseConfig); + } + return app; +} + +let messagingPromise: Promise<Messaging | null> | null = null; + +/** Messaging فقط توی مرورگرهایی که Service Worker/Push API دارن پشتیبانی می‌شه + * (سافاری قدیمی، حالت خصوصیِ بعضی مرورگرها و... نه) — isSupported این رو چک می‌کنه */ +export function getFirebaseMessaging(): Promise<Messaging | null> { + if (!messagingPromise) { + messagingPromise = (async () => { + const fbApp = getFirebaseApp(); + if (!fbApp) return null; + try { + const supported = await isSupported(); + if (!supported) return null; + return getMessaging(fbApp); + } catch { + return null; + } + })(); + } + return messagingPromise; +} + +export { firebaseConfig }; diff --git a/src/lib/pushNotifications.ts b/src/lib/pushNotifications.ts new file mode 100644 index 0000000..1b06b78 --- /dev/null +++ b/src/lib/pushNotifications.ts @@ -0,0 +1,60 @@ +import { getToken, onMessage, type MessagePayload } from "firebase/messaging"; +import { apiClient } from "@/hooks/useAxios"; +import { getApiBaseUrl } from "@/components/main/BaseUrl"; +import { getFirebaseMessaging } from "./firebase"; + +/** درخواست مجوز نوتیفیکیشن مرورگر و ثبت توکن FCM وب برای کاربر لاگین‌شده — + * عین registerPushToken (pushToken.ts) اندروید، با platform:"web" که بک‌اند + * (notificationController.js) جدا توی fcm_web_tokens ذخیره‌ش می‌کنه */ +export async function registerPushToken(): Promise<void> { + try { + if (typeof window === "undefined" || !("Notification" in window)) return; + if (!("serviceWorker" in navigator)) return; + + const messaging = await getFirebaseMessaging(); + if (!messaging) return; + + let permission = Notification.permission; + if (permission === "default") { + permission = await Notification.requestPermission(); + } + if (permission !== "granted") return; + + // sw همون service-worker.js موجوده (RegisterSW.tsx) — پیام‌های پوشِ پس‌زمینه + // مستقیم داخلش هندل می‌شن (importScripts فایربیس)، پس سرویس‌ورکرِ جدا لازم نیست + const registration = await navigator.serviceWorker.ready; + const vapidKey = process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY; + if (!vapidKey) return; + + const fcmToken = await getToken(messaging, { + vapidKey, + serviceWorkerRegistration: registration, + }); + if (!fcmToken) return; + + await apiClient.post( + "/notifications/register-token", + { fcmToken, platform: "web" }, + { baseURL: getApiBaseUrl() }, + ); + } catch { + // نبود مجوز/توکن نباید هیچ جریان دیگه‌ای رو بشکنه + } +} + +/** پیامِ دریافت‌شده وقتی تب باز و فعاله — عین onMessage در PushNotificationListener.tsx + * اندروید؛ خودِ مرورگر برای این حالت هیچ نوتیفِ سیستمی نشون نمی‌ده */ +export function subscribeForegroundPush( + onPush: (payload: MessagePayload) => void, +): () => void { + let unsubscribe: (() => void) | null = null; + let cancelled = false; + void getFirebaseMessaging().then((messaging) => { + if (!messaging || cancelled) return; + unsubscribe = onMessage(messaging, onPush); + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; +} diff --git a/src/lib/pwa/install.ts b/src/lib/pwa/install.ts index b1a89e4..f31eef0 100644 --- a/src/lib/pwa/install.ts +++ b/src/lib/pwa/install.ts @@ -22,15 +22,19 @@ export function isIosDevice(): boolean { return /iphone|ipad|ipod/i.test(window.navigator.userAgent); } -export function isDismissed(): boolean { - const until = Number(readLocalStorage(DISMISS_KEY) || 0); - return Date.now() < until; +export function isAndroidDevice(): boolean { + if (typeof window === "undefined") return false; + return /android/i.test(window.navigator.userAgent); } -/** عدم نمایش تا ۷ روز */ -export function dismissInstallPrompt(days = 7): void { - const until = Date.now() + days * 24 * 60 * 60 * 1000; - writeLocalStorage(DISMISS_KEY, String(until)); +// قبلاً ۷ روز بود؛ طبق خواسته‌ی صریح کاربر الان دائمیه — با زدنِ «بعداً» دیگه هیچ‌وقت +// دوباره نمایش داده نمی‌شه (نه فقط یه هفته) +export function isDismissed(): boolean { + return readLocalStorage(DISMISS_KEY) === "1"; +} + +export function dismissInstallPrompt(): void { + writeLocalStorage(DISMISS_KEY, "1"); } export function clearInstallDismissal(): void { diff --git a/src/lib/pwaConfig.ts b/src/lib/pwaConfig.ts index 1cbb371..11bd4a5 100644 --- a/src/lib/pwaConfig.ts +++ b/src/lib/pwaConfig.ts @@ -7,7 +7,7 @@ export type PwaLang = AppLanguage; export const PWA_THEME_COLOR = "#FF107D"; export const PWA_BACKGROUND_COLOR = "#FF107D"; export const PWA_ID = "modstagram-pwa"; -export const PWA_VERSION = "11"; +export const PWA_VERSION = "12"; export { resolvePwaLang, toPwaLang } from "@/lib/i18n/pwaLang"; @@ -20,6 +20,8 @@ const COPY: Partial<Record<AppLanguage, { installTitle: string; installDescription: string; installButton: string; + installWebButton: string; + downloadAppButton: string; installing: string; later: string; gotIt: string; @@ -37,6 +39,8 @@ const COPY: Partial<Record<AppLanguage, { installDescription: "برای دسترسی سریع‌تر و تجربه شبیه اپ موبایل، مدستاگرام را روی گوشی یا سیستم خود نصب کنید.", installButton: "نصب اپلیکیشن", + installWebButton: "نصب نسخه وب", + downloadAppButton: "دانلود اپلیکیشن", installing: "در حال نصب...", later: "بعداً", gotIt: "متوجه شدم", @@ -58,6 +62,8 @@ const COPY: Partial<Record<AppLanguage, { installDescription: "Install Modstagram on your device for faster access and an app-like experience.", installButton: "Install app", + installWebButton: "Install web app", + downloadAppButton: "Download app", installing: "Installing...", later: "Later", gotIt: "Got it", diff --git a/src/lib/ui/buttonStyles.ts b/src/lib/ui/buttonStyles.ts index d0e6c5b..4160360 100644 --- a/src/lib/ui/buttonStyles.ts +++ b/src/lib/ui/buttonStyles.ts @@ -8,15 +8,19 @@ export const btnBase = export const btnDefault = cn( btnBase, - "rounded-3xl bg-white/90 px-4 text-foreground", + "rounded-2xl bg-white/90 px-4 text-foreground", BTN_BORDER, "hover:bg-white dark:bg-neutral-900/90 dark:hover:bg-neutral-900" ); +// عین Button.tsx اندروید: variant="primary" یه تینتِ کم‌رنگِ رنگِ اصلی (نه پرشدنِ +// توپر) با متن/بوردرِ رنگِ اصلیه، نه پس‌زمینه‌ی توپرِ صورتی + متنِ سفید — عمداً از +// کلاسِ مشترکِ btn-modern--primary استفاده نمی‌کنه چون اون کلاس رو چیپ‌های فیلتر/ +// toggle هم استفاده می‌کنن که قرار نیست عوض بشن export const btnPrimary = cn( btnBase, - "btn-modern--primary rounded-3xl px-4 text-white", - BTN_BORDER + "rounded-2xl bg-[rgba(252,142,172,0.1)] px-4 text-[#FC8EAC] border-[#FC8EAC]", + "hover:bg-[rgba(252,142,172,0.16)]" ); export const btnSecondary = cn(btnDefault, "font-medium text-foreground/90"); diff --git a/src/locales/ar/common.json b/src/locales/ar/common.json index 13eab4c..98cb0d0 100644 --- a/src/locales/ar/common.json +++ b/src/locales/ar/common.json @@ -112,6 +112,11 @@ "userSettings": "إعدادات المستخدم", "userSettingsTitle": "إعدادات المستخدم", "profileVisitors": "زوار الملف الشخصي", + "blockedByCount": "المستخدمون الذين حظروك", + "resetAlgorithm": "إعادة ضبط الخوارزمية", + "resetAlgorithmWarning": "سيؤدي هذا إلى مسح سجل السلوك الذي يخصص اقتراحاتك (المنشورات والتطبيق بأكمله)، وستبدأ الاقتراحات من جديد كأول يوم. هل أنت متأكد؟", + "resetAlgorithmDone": "تمت إعادة ضبط الخوارزمية", + "resetAlgorithmError": "خطأ في إعادة ضبط الخوارزمية", "allowSavePosts": "حفظ المنشورات", "allowSavePostsDesc": "السماح للآخرين بحفظ وتنزيل منشوراتك", "postLocation": "موقع المنشور", @@ -122,6 +127,30 @@ "privateProfileDesc": "تظهر المنشورات والقصص والريلز للمتابعين فقط", "ghostMode": "وضع التخفي", "ghostModeDesc": "إخفاء حالة الاتصال وزيارات الملف الشخصي", + "walletSettingsTitle": "إعدادات المحفظة", + "giftEnabled": "استقبال الهدايا", + "giftEnabledDesc": "إمكانية استقبال الهدايا في ملفك الشخصي وفي الدردشة — عند التعطيل يتم إيقاف زر الهدية المميزة في ملفك الشخصي وزر الهدية في الدردشة بالنسبة لك", + "offerPaymentEnabled": "استقبال طلبات التعاون المدفوعة", + "offerPaymentEnabledDesc": "عند التعطيل، يصبح إرسال طلب تعاون إليك مجانيًا للمستخدمين الآخرين", + "verificationGuide": { + "buttonLabel": "الحصول على علامة التوثيق", + "gray": { + "name": "العلامة الرمادية", + "description": "تشير العلامة الرمادية إلى أنك أرسلت مستندات إثبات الهوية للمراجعة، وتبقى بجانب اسم المستخدم حتى يتم التحقق من هويتك. هذا لا يعني أن المستندات موافق عليها بعد — تتحول إلى علامة زرقاء فقط بعد تأكيد فريقنا للمستندات. في حال رفض المستندات، تتم إزالة العلامة الرمادية من بجانب اسم المستخدم." + }, + "blue": { + "name": "العلامة الزرقاء", + "description": "تؤكد العلامة الزرقاء أنه تم التحقق من مستندات هوية المستخدم. أي مستخدم لديه علامة زرقاء بجانب اسم المستخدم تم تأكيد مستنداته من قبل فريق مدستاگرام، مما يعني أنه شخص حقيقي تم التحقق منه." + }, + "green": { + "name": "العلامة الخضراء", + "description": "العلامة الخضراء تخص المستخدمين الذين قاموا بتوثيق أعمالهم التجارية ويمتلكون ترخيصًا رسميًا. تؤكد العلامة الخضراء بجانب اسم المستخدم أو اسم المتجر أن المتجر موثّق قانونيًا ويمتلك رخصة عمل سارية." + }, + "gold": { + "name": "العلامة الذهبية", + "description": "العلامة الذهبية مخصصة لأفضل المبدعين والمؤثرين الذين يمتلكون أكثر من مليون متابع. للحصول عليها، يجب أولاً الحصول على العلامة الزرقاء، ثم إرسال طلب إلى فريق الدعم. سيقوم خبراؤنا بتفعيل علامتك الذهبية بعد مراجعة مستنداتك والتأكد من استيفائك للشروط." + } + }, "accountDeactivated": "حسابك معطَّل", "reactivateLocked": "إعادة التفعيل مقفلة حتى {{date}}.", "reactivate": "إعادة تفعيل الحساب", @@ -386,7 +415,8 @@ "blockedUsers": "المستخدمون المحظورون", "archive": "الأرشيف", "activity": "نشاطك", - "bio": "النبذة التعريفية" + "bio": "النبذة التعريفية", + "verificationGuide": "الحصول على علامة التوثيق" }, "bio": { "aboutYou": "أخبرنا عن نفسك", @@ -1522,7 +1552,9 @@ "makeup": "مكياج", "professional": "محترف", "best_month": "الأفضل هذا الشهر", - "near_me": "بالقرب مني" + "near_me": "بالقرب مني", + "girls": "بناتي", + "boys": "ولادي" }, "empty": { "default": "لا يوجد شيء لعرضه بعد.", @@ -2021,6 +2053,7 @@ }, "billboards": { "search": "بحث", + "filter": "تصفية", "createAria": "إنشاء لوحة إعلانية", "loading": "جارٍ تحميل اللوحات الإعلانية…", "notFound": "اللوحة الإعلانية غير موجودة!", @@ -2739,6 +2772,10 @@ "confirmReceipt": "استلمت المنتج", "completePurchase": "إتمام الشراء", "repurchase": "الشراء مرة أخرى", + "cancelOrder": "إلغاء الطلب", + "cancelOrderConfirmTitle": "هل ترغب في إلغاء فاتورتك؟", + "cancelOrderConfirmDesc": "في حال الإلغاء، سيتم تحويل المبلغ المدفوع إلى محفظتك", + "orderCancelled": "تم إلغاء الطلب وإعادة المبلغ إلى محفظتك", "receiptConfirmed": "تم تأكيد الاستلام", "rateShop": "تقييم ومراجعة", "ratingCommentPlaceholder": "رأيك في المتجر (اختياري)", diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 43f726d..fcb2bfd 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -112,6 +112,11 @@ "userSettings": "User settings", "userSettingsTitle": "User settings", "profileVisitors": "Profile visitors", + "blockedByCount": "Users who have blocked you", + "resetAlgorithm": "Reset algorithm", + "resetAlgorithmWarning": "This will erase the behavior history that personalizes your suggestions (posts and the whole app), and suggestions will start over from scratch, like day one. Are you sure?", + "resetAlgorithmDone": "Algorithm has been reset", + "resetAlgorithmError": "Failed to reset the algorithm", "allowSavePosts": "Save posts", "allowSavePostsDesc": "Allow others to save and download your posts", "postLocation": "Post location", @@ -122,6 +127,30 @@ "privateProfileDesc": "Posts, stories and reels are visible to followers only", "ghostMode": "Ghost mode", "ghostModeDesc": "Hide online status and profile visits", + "walletSettingsTitle": "Wallet settings", + "giftEnabled": "Receive gifts", + "giftEnabledDesc": "Allow receiving gifts on your profile and in chat — disabling this turns off the special-gift button on your profile and the gift button in chat for you", + "offerPaymentEnabled": "Receive paid collaboration requests", + "offerPaymentEnabledDesc": "When disabled, sending you a collaboration request becomes free for other users", + "verificationGuide": { + "buttonLabel": "Get verified", + "gray": { + "name": "Gray badge", + "description": "The gray badge shows that you've submitted your identity documents for verification, and it stays next to your username until your identity is verified. It does not mean your documents are approved yet — it only turns into a blue badge once our team confirms your documents. If your documents are rejected, the gray badge is removed from your username." + }, + "blue": { + "name": "Blue badge", + "description": "The blue badge confirms that a user's identity documents have been verified. Any user with a blue badge next to their username has had their documents confirmed by the Modstagram team, meaning they are a real, verified individual." + }, + "green": { + "name": "Green badge", + "description": "The green badge belongs to users who have verified their business and hold an official license. A green badge next to a username or shop name confirms the shop is legally verified and holds a valid business license." + }, + "gold": { + "name": "Gold badge", + "description": "The gold badge is for top creators and influencers with over 1 million followers. To get it, you must first obtain the blue badge, then submit a request to our support team. Our specialists will activate your gold badge after reviewing your documents and confirming you meet the requirements." + } + }, "accountDeactivated": "Your account is deactivated", "reactivateLocked": "Reactivation is locked until {{date}}.", "reactivate": "Reactivate account", @@ -387,7 +416,8 @@ "blockedUsers": "Blocked users", "archive": "Archive", "activity": "Your activity", - "bio": "Bio" + "bio": "Bio", + "verificationGuide": "Get verified" }, "bio": { "aboutYou": "Tell us about yourself", @@ -1556,7 +1586,9 @@ "makeup": "Makeup", "professional": "Professional", "best_month": "Best this month", - "near_me": "Near me" + "near_me": "Near me", + "girls": "Girls", + "boys": "Boys" }, "empty": { "default": "Nothing to show yet.", @@ -2080,6 +2112,7 @@ }, "billboards": { "search": "Search", + "filter": "Filter", "createAria": "Create billboard", "loading": "Loading billboards…", "notFound": "Billboard not found!", @@ -2798,6 +2831,10 @@ "confirmReceipt": "I received the item", "completePurchase": "Complete purchase", "repurchase": "Buy again", + "cancelOrder": "Cancel order", + "cancelOrderConfirmTitle": "Do you want to cancel your invoice?", + "cancelOrderConfirmDesc": "If you cancel, the amount you paid will be transferred to your wallet", + "orderCancelled": "Order cancelled and the amount was refunded to your wallet", "receiptConfirmed": "Receipt confirmed", "rateShop": "Rate & review", "ratingCommentPlaceholder": "Your review of the shop (optional)", diff --git a/src/locales/fa/common.json b/src/locales/fa/common.json index 80ab0ed..a1b2e19 100644 --- a/src/locales/fa/common.json +++ b/src/locales/fa/common.json @@ -112,6 +112,11 @@ "userSettings": "تنظیمات کاربر", "userSettingsTitle": "تنظیمات کاربر", "profileVisitors": "بازدیدکنندگان پروفایل", + "blockedByCount": "کاربرانی که شما را مسدود کرده‌اند", + "resetAlgorithm": "ریست الگوریتم", + "resetAlgorithmWarning": "با این کار، سابقه‌ی رفتاری‌ای که پیشنهادهای شما (پست‌ها و کل اپ) رو شخصی‌سازی می‌کنه پاک می‌شود و پیشنهادها دوباره از ابتدا، مثل روز اول، ساخته می‌شوند. آیا مطمئن هستید؟", + "resetAlgorithmDone": "الگوریتم بازنشانی شد", + "resetAlgorithmError": "خطا در بازنشانی الگوریتم", "allowSavePosts": "ذخیره پست‌ها", "allowSavePostsDesc": "اجازه ذخیره و دانلود پست‌های شما برای دیگران", "postLocation": "لوکیشن پست‌ها", @@ -122,6 +127,30 @@ "privateProfileDesc": "پست‌ها، استوری‌ها و ریلز فقط برای دنبال‌کنندگان نمایش داده می‌شوند", "ghostMode": "حالت روح", "ghostModeDesc": "پنهان کردن وضعیت آنلاین و بازدید پروفایل", + "walletSettingsTitle": "تنظیمات کیف پول", + "giftEnabled": "دریافت هدیه", + "giftEnabledDesc": "امکان دریافت هدیه در پروفایل و در چت — با غیرفعال کردن، دکمه‌ی هدیه ویژه در پروفایل و دکمه‌ی هدیه در چت برای شما غیرفعال می‌شود", + "offerPaymentEnabled": "دریافت پرداختی از چت", + "offerPaymentEnabledDesc": "با غیرفعال کردن، ارسال درخواست همکاری به شما برای کاربران رایگان می‌شود", + "verificationGuide": { + "buttonLabel": "دریافت تیک وریفای", + "gray": { + "name": "تیک خاکستری", + "description": "تیک خاکستری نشانگر ارسال مدارک شناسایی کاربر برای تایید هویت می‌باشد و تا زمان تایید هویت، تیک خاکستری کنار نام کاربری کاربر نمایان می‌شود. تیک خاکستری به منزله‌ی تایید مدارک نیست و تا تایید مدارک توسط کارشناسان پلتفرم مدستاگرام، تیک خاکستری به تیک آبی تغییر نمی‌کند و در صورتی که مدارک تایید هویت توسط کارشناسان رد شوند، تیک خاکستری از کنار نام کاربری کاربر برداشته می‌شود." + }, + "blue": { + "name": "تیک آبی", + "description": "تیک آبی به منزله‌ی تایید مدارک هویتی کاربران می‌باشد و هر کاربری که تیک آبی کنار نام کاربری خود دارد، مدارک وی توسط کارشناسان مدستاگرام تایید شده و کاربر مورد نظر یک فرد حقیقی واقعی می‌باشد." + }, + "green": { + "name": "تیک سبز", + "description": "تیک سبز مربوط به کاربرانی می‌باشد که کسب‌وکار خود را تایید نموده‌اند و دارای مجوز رسمی می‌باشند. کاربرانی که تیک سبز کنار نام کاربری یا نام فروشگاه خود دارند، به منزله‌ی تایید قانونی فروشگاه و داشتن جواز کسب هستند." + }, + "gold": { + "name": "تیک طلایی", + "description": "تیک طلایی برای کاربران طلایی و اینفلوئنسرهایی است که بالای ۱ میلیون فالوئر دارند. برای دریافت این تیک لازم است ابتدا تیک آبی را کسب کرده باشید و سپس با ارسال درخواست به تیم پشتیبانی برای دریافت تیک طلایی اقدام کنید. کارشناسان ما پس از بررسی مدارک و احراز شرایط مورد نظر، تیک طلایی شما را فعال می‌کنند." + } + }, "accountDeactivated": "حساب شما غیرفعال است", "reactivateLocked": "تا {{date}} امکان فعال‌سازی مجدد وجود ندارد.", "reactivate": "فعال‌سازی مجدد حساب", @@ -387,7 +416,8 @@ "blockedUsers": "لیست کاربران مسدود", "archive": "بایگانی", "activity": "فعالیت شما", - "bio": "بیو" + "bio": "بیو", + "verificationGuide": "دریافت تیک وریفای" }, "bio": { "aboutYou": "در مورد خودتان چیزی بگویید", @@ -1556,7 +1586,9 @@ "makeup": "میکاپ", "professional": "متخصص حرفه‌ای", "best_month": "بهترین‌های این ماه", - "near_me": "نزدیک من" + "near_me": "نزدیک من", + "girls": "دخترانه", + "boys": "پسرانه" }, "empty": { "default": "محتوایی برای نمایش وجود ندارد.", @@ -2085,6 +2117,7 @@ }, "billboards": { "search": "جستجو", + "filter": "فیلتر", "createAria": "ثبت بیلبورد", "loading": "در حال بارگذاری بیلبوردها…", "notFound": "بیلبورد یافت نشد!", @@ -2803,6 +2836,10 @@ "confirmReceipt": "کالا را دریافت کردم", "completePurchase": "تکمیل خرید", "repurchase": "خرید مجدد", + "cancelOrder": "کنسل کردن سفارش", + "cancelOrderConfirmTitle": "آیا مایلید فاکتور خود را کنسل کنید؟", + "cancelOrderConfirmDesc": "در صورت کنسل کردن، مبلغ پرداختی شما به کیف پول شما منتقل خواهد شد", + "orderCancelled": "سفارش کنسل شد و مبلغ به کیف پول شما بازگشت داده شد", "receiptConfirmed": "دریافت کالا ثبت شد", "rateShop": "ثبت نظر و امتیاز", "ratingCommentPlaceholder": "نظر شما درباره فروشگاه (اختیاری)", diff --git a/src/types/types.ts b/src/types/types.ts index 817e4e7..a4e49dd 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -197,6 +197,7 @@ export interface IProvince { _id: string; id: number; name: string; + name_fa?: string; slug: string; } @@ -204,6 +205,7 @@ export interface ICity { _id: string; id: number; name: string; + name_fa?: string; slug: string; province_id: number; } @@ -255,6 +257,7 @@ export interface User { post_location_enabled?: boolean; is_private?: boolean; private_content_locked?: boolean; + gift_enabled?: boolean; bio?: string; lat?: string; lng?: string; @@ -546,8 +549,8 @@ export interface Post { first_name: string; last_name: string; expertise?: string; - province?: { id: string; name: string; slug: string; _id?: string }; - city?: { id: string; name: string; slug: string; province_id: number; _id?: string }; + province?: { id: string; name: string; name_fa?: string; slug: string; _id?: string }; + city?: { id: string; name: string; name_fa?: string; slug: string; province_id: number; _id?: string }; user_level?: string; post_image?: string; post_images?: string[]; diff --git a/src/utils/geoName.ts b/src/utils/geoName.ts new file mode 100644 index 0000000..e0410a7 --- /dev/null +++ b/src/utils/geoName.ts @@ -0,0 +1,10 @@ +/** + * نام کشور/استان/شهر همیشه به زبان خودِ اون کشور نمایش داده می‌شه (مثلاً ایران همیشه فارسی)، + * نه بر اساس زبان فعلی رابط کاربری — و اگه ترجمه‌ای براش نداشته باشیم، انگلیسی fallback می‌شه. + */ +export function localizedGeoName( + entity?: { name?: string; name_fa?: string } | null +): string { + if (!entity) return ""; + return entity.name_fa || entity.name || ""; +}