From 3906667a9ad58f25ec1eb279a2ba0b4c724e854d Mon Sep 17 00:00:00 2001 From: Rex Date: Thu, 17 Sep 2026 14:15:00 +0700 Subject: [PATCH 1/6] asset title --- src/models/asset.model.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/asset.model.ts b/src/models/asset.model.ts index e4342b76..e3dd331c 100644 --- a/src/models/asset.model.ts +++ b/src/models/asset.model.ts @@ -10,7 +10,7 @@ export interface IChildAsset { export interface IAsset { id: string; name: string; - display_name?: string | null; + title?: string | null; description?: string | null; metadata?: Record; url: string; @@ -36,7 +36,7 @@ export interface IAsset { export interface IAssetChild { id: IAsset["id"]; name: IAsset["name"]; - display_name?: string | null; + title?: string | null; description?: string | null; metadata?: Record; url: IAsset["url"]; From a649c54c09b5fd8c96c1dcd254f9eda9f57bd2eb Mon Sep 17 00:00:00 2001 From: Rex Date: Thu, 17 Sep 2026 17:27:31 +0700 Subject: [PATCH 2/6] coupon --- ECOSYSTEM.md | 73 +- README.md | 109 +-- src/AppRoutes.ts | 34 +- src/design/components/form/TextInput.tsx | 60 +- src/locales/app_locales.ts | 163 ++++ src/locales/en.json | 163 ++++ src/locales/my.json | 163 ++++ .../admin/components/AdminSidebarNav.tsx | 19 +- src/modules/admin/constants.ts | 6 + src/modules/admin/notification/constants.ts | 1 + .../pages/AdminNotificationsPage.tsx | 36 +- .../pages/AdminUserNotificationDetailPage.tsx | 29 +- .../pages/AdminUserNotificationsPage.tsx | 19 +- .../components/AdminRedemptionsTable.tsx | 335 ++++++++ src/modules/admin/payment/components/index.ts | 2 + src/modules/admin/payment/constants.ts | 20 + .../payment/pages/AdminCouponCreatePage.tsx | 501 ++++++++++++ .../payment/pages/AdminCouponDetailPage.tsx | 370 +++++++++ .../admin/payment/pages/AdminCouponsPage.tsx | 724 ++++++++++++++++++ .../payment/pages/AdminUserCouponsPage.tsx | 40 + src/modules/admin/payment/pages/index.ts | 4 + .../admin/payment/payment.controller.test.ts | 75 ++ .../admin/payment/payment.controller.ts | 189 ++++- src/modules/admin/payment/payment.service.ts | 141 ++++ src/modules/admin/payment/types.ts | 46 ++ src/modules/admin/version/constants.ts | 9 + .../admin/version/pages/AdminVersionsPage.tsx | 283 ++++--- .../payment/components/CheckoutDialog.tsx | 388 ++++++++++ src/modules/payment/components/index.ts | 1 + src/modules/payment/constants.ts | 25 + src/modules/payment/index.ts | 1 + src/modules/payment/pages/PaymentPage.tsx | 17 +- .../payment/payment.controller.test.ts | 83 ++ src/modules/payment/payment.controller.ts | 59 +- src/modules/payment/payment.service.ts | 39 +- src/modules/payment/types.ts | 63 ++ src/routes/RouteManager.tsx | 78 +- 37 files changed, 4108 insertions(+), 260 deletions(-) create mode 100644 src/modules/admin/payment/components/AdminRedemptionsTable.tsx create mode 100644 src/modules/admin/payment/components/index.ts create mode 100644 src/modules/admin/payment/pages/AdminCouponCreatePage.tsx create mode 100644 src/modules/admin/payment/pages/AdminCouponDetailPage.tsx create mode 100644 src/modules/admin/payment/pages/AdminCouponsPage.tsx create mode 100644 src/modules/admin/payment/pages/AdminUserCouponsPage.tsx create mode 100644 src/modules/payment/components/CheckoutDialog.tsx create mode 100644 src/modules/payment/components/index.ts diff --git a/ECOSYSTEM.md b/ECOSYSTEM.md index fe2dde43..37066bfb 100644 --- a/ECOSYSTEM.md +++ b/ECOSYSTEM.md @@ -106,18 +106,18 @@ flowchart TB All tables use **UUID** primary keys (`gen_random_uuid()`), utilize **Discard** for soft deletes (`discarded_at`, `undiscarded_at`), and include the **Auditable** concern (`Current.auditor`) tracking `created_by_id`, `updated_by_id`, `discarded_by_id`, and `undiscarded_by_id`. -| Domain | Models | Key Responsibilities | -| -------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Identity & Users** | `User` | Devise authentication, JWT JTI revocation strategy, 6-digit confirmation codes, 6-digit password reset codes, Google account linking, Stripe customer generation, profile pictures via Assets. | -| **IAM (RBAC)** | `Iam::Role`, `Iam::Permission`, `Iam::UserRole`, `Iam::RolePermission` | Granular resource-action permissions (`user.can?(action, resource)`). System roles (`super_admin`, `admin`, default `user`). Auto-assigned default role on signup. | -| **Commerce** | `Payment::Product`, `Payment::Subscription`, `Payment::Transaction`, `Payment::WebhookEvent` | Stripe synced products and prices; Stripe-version-aligned subscription item snapshots (`unit_amount`, currency, quantity, interval, and billing periods); cancellation/resumption; transactions with payment method details; and durable webhook processing. | -| **Entitlements** | `Access` | Granted/revoked/expired access records tied to `User` and `Product`. | -| **AI / Chat** | `Chat::Room`, `Chat::Message`, `Ai::Profile`, `Ai::Run` | `V1::ChatController` owns open, flexible room/message endpoints (supporting 1-on-1 direct user chats, group chats, single-user/single-AI chats, or multi-user/multi-AI hybrid rooms without rigid type constraints) and delegates queueing, message processing, and notifications to `ChatMessageService` / `Chat::ProcessMessageJob`. Multi-message chunking (`Chat::TextService`) splits prompts and assistant outputs exceeding 2,000 characters into sequential messages sharing `split_id` with `chunk_index`/`total_chunks`, stitching user chunks for LLM execution. `V1::Admin::AiController` owns AI profiles and run telemetry. `Ai::Providers::Client` stays the provider boundary. Core owns provider/model/prompt/output limits/timeouts; clients request profile keys, not raw provider parameters. | -| **Media** | `Asset` | Unified media metadata (`name`, human-friendly `display_name`, optional `description`, `storage_key` for Garage/S3/Cloudinary/Local — user objects under `user/{user_id}/`, platform objects under `admin/`; format, size_bytes, original_size_bytes, compressed_size_bytes, compression_ratio, compression_passes, status enum: `pending`/`processing`/`ready`/`optimal`, duration_secs, type, polymorphic `assetable_type`/`assetable_id`), and `parent_asset_id` for generated video thumbnails, uploaded covers, and `.srt` subtitle children on compressible video or audio parents. | -| **Telemetry** | `Client::Log` | Frontend error ingest (stack traces, device, OS, browser, URL, severity, occurrences, local/session storage keys, cookies, resolution status). Ingest still sends `app_version`; Core stores nullable `version_id`. | -| **Feedback** | `Feedback` | Intelligent in-place feedback (1-10 rating, auto-inferred category: `bug`/`feature_request`/`improvement`/`general`, priority: `low`/`normal`/`high`/`urgent`, status, automated device/route telemetry). Ingest still sends `app_version`; Core stores nullable `version_id`. | -| **Notifications** | `Notification`, `UserNotification` | Multi-channel notification repository (In-App, Push, Email) with dynamic variable interpolation; persistent user in-app inbox receipts with immutable snapshots, read tracking, and Pagy pagination. | -| **App versions** | `Client::Version`, `Client::UserVersion` | Global marketing versions (`draft` / `published` / `yanked`; publishing yanks every other kept published row) and one current user-version snapshot per user per platform. Public `GET /v1/client/versions/current` computes `update_required` (client behind the live version) and `must_update` (live version is force and greater than the client). Signed-in `POST /v1/client/versions/user-version` records the device. JSON admin `/v1/admin/client/versions` is super-admin only (discard/undiscard, `install_count`). `GET /v1/admin/client/versions/user_versions` lists all current snapshots (not nested under a version id). Administrate `/admin/client/versions` is super-admin only; user versions are `/admin/client/user_versions`. The user show page lists only `latest_user_version` (newest `last_seen_at`). | +| Domain | Models | Key Responsibilities | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Identity & Users** | `User` | Devise authentication, JWT JTI revocation strategy, 6-digit confirmation codes, 6-digit password reset codes, Google account linking, Stripe customer generation, profile pictures via Assets. | +| **IAM (RBAC)** | `Iam::Role`, `Iam::Permission`, `Iam::UserRole`, `Iam::RolePermission` | Granular resource-action permissions (`user.can?(action, resource)`). System roles (`super_admin`, `admin`, default `user`). Auto-assigned default role on signup. | +| **Commerce** | `Payment::Product`, `Payment::Subscription`, `Payment::Transaction`, `Payment::Coupon`, `Payment::UserCoupon`, `Payment::WebhookEvent` | Stripe synced products and prices; Stripe-version-aligned subscription item snapshots (`unit_amount`, currency, quantity, interval, and billing periods); percentage and fixed-amount coupons with referral generation, targeting restrictions, usage limits, and Stripe coupon synchronization; immutable redemption audit ledger; cancellation/resumption; transactions with payment method details; and durable webhook processing. | +| **Entitlements** | `Access` | Granted/revoked/expired access records tied to `User` and `Product`. | +| **AI / Chat** | `Chat::Room`, `Chat::Message`, `Ai::Profile`, `Ai::Run` | `V1::ChatController` owns open, flexible room/message endpoints (supporting 1-on-1 direct user chats, group chats, single-user/single-AI chats, or multi-user/multi-AI hybrid rooms without rigid type constraints) and delegates queueing, message processing, and notifications to `ChatMessageService` / `Chat::ProcessMessageJob`. Multi-message chunking (`Chat::TextService`) splits prompts and assistant outputs exceeding 2,000 characters into sequential messages sharing `split_id` with `chunk_index`/`total_chunks`, stitching user chunks for LLM execution. `V1::Admin::AiController` owns AI profiles and run telemetry. `Ai::Providers::Client` stays the provider boundary. Core owns provider/model/prompt/output limits/timeouts; clients request profile keys, not raw provider parameters. | +| **Media** | `Asset` | Unified media metadata (`name`, human-friendly `title`, optional `description`, `storage_key` for Garage/S3/Cloudinary/Local — user objects under `user/{user_id}/`, platform objects under `admin/`; format, size_bytes, original_size_bytes, compressed_size_bytes, compression_ratio, compression_passes, status enum: `pending`/`processing`/`ready`/`optimal`, duration_secs, type, polymorphic `assetable_type`/`assetable_id`), and `parent_asset_id` for generated video thumbnails, uploaded covers, and `.srt` subtitle children on compressible video or audio parents. | +| **Telemetry** | `Client::Log` | Frontend error ingest (stack traces, device, OS, browser, URL, severity, occurrences, local/session storage keys, cookies, resolution status). Ingest still sends `app_version`; Core stores nullable `version_id`. | +| **Feedback** | `Feedback` | Intelligent in-place feedback (1-10 rating, auto-inferred category: `bug`/`feature_request`/`improvement`/`general`, priority: `low`/`normal`/`high`/`urgent`, status, automated device/route telemetry). Ingest still sends `app_version`; Core stores nullable `version_id`. | +| **Notifications** | `Notification`, `UserNotification` | Multi-channel notification repository (In-App, Push, Email) with dynamic variable interpolation; persistent user in-app inbox receipts with immutable snapshots, read tracking, and Pagy pagination. | +| **App versions** | `Client::Version`, `Client::UserVersion` | Global marketing versions (`draft` / `published` / `yanked`; publishing yanks every other kept published row) and one current user-version snapshot per user per platform. Public `GET /v1/client/versions/current` computes `update_required` (client behind the live version) and `must_update` (live version is force and greater than the client). Signed-in `POST /v1/client/versions/user-version` records the device. JSON admin `/v1/admin/client/versions` is super-admin only (discard/undiscard, `install_count`). `GET /v1/admin/client/versions/user_versions` lists all current snapshots (not nested under a version id). Administrate `/admin/client/versions` is super-admin only; user versions are `/admin/client/user_versions`. The user show page lists only `latest_user_version` (newest `last_seen_at`). | ### ⚙️ Services & Background Jobs (Solid Queue / Waka / Media) @@ -212,7 +212,7 @@ Defined under `src/design/`: ### 🧩 Domain Modules & Flows - **Auth**: URL-driven dialog navigation (`?dialog=auth&step=...`). Passwords are held purely in memory and never leaked into URL params or persistent storage. -- **Commerce & Stripe**: Fetches products, triggers Checkout Session (`/v1/payment/session`), redirects to Stripe, handles success/cancel redirects, manages active subscriptions and transactions, and provides modal confirmation for cancellations. +- **Commerce & Stripe**: Fetches products, validates promo/referral coupons (`/v1/payment/coupons/validate`), applies discount to Stripe Checkout Session (`/v1/payment/session`), bypasses payment gateway for 100% free coupons with immediate entitlement provisioning, handles success/cancel redirects, manages active subscriptions and transactions, and provides modal confirmation for cancellations. Admin portal provides complete Coupon CRUD, batch code generator (`/v1/admin/payment/coupons/batch`), and redemptions audit ledger. - **AI Workspace**: Non-blocking queued chat. Submits message, displays thinking state, receives completion or event over WebSocket (`useAiSocket`), auto-refreshes room history. Includes utilities for translation, summarization, and sentiment analysis. - **Speech & Audio**: Plays raw binary MP3 audio streams directly from `/v1/speech/tts` without base64 wrapper overhead, handles chat message TTS audio playback, and integrates live audio recognition. - **Asset Control Center**: Dedicated operational asset management under `/admin/assets`. Features a live Storage & VPS Capacity dashboard (`AdminAssetStorageStats`) showing real-time Garage bucket occupied space, object count, and host VPS disk capacity with low-disk alerts; a multi-file batch upload dialog with optimistic row prepending; out-of-order socket reconciliation (`pendingSocketUpdates`); real-time compression badges (`optimal`, `ready`, `processing`, `pending`); disabled action buttons during in-flight processing; and manual secondary compression pass triggers. @@ -288,6 +288,8 @@ All three pillars of the RexOne platform are fully aligned at **100% feature par | **Media: Real-Time Cable Compression Updates** | ✅ | ✅ | N/A | | **Media: Batch Upload & Optimal-First Pipeline** | ✅ | ✅ | N/A | | **Media: Multi-Select Batch Actions & Empty Recycle Bin** | ✅ | ✅ | N/A | +| **Media: Audio/Video Playlist Playback & Subtitle (SRT) Child Assets** | ✅ | N/A | ✅ | +| **Media: Signed Playback URL Resolution (`GET /v1/assets/:id/playback`)** | ✅ | N/A | ✅ | | **Push Notifications (OneSignal)** | ✅ | N/A | ✅ | | **Product Analytics (Firebase)** | Constants | ✅ | ✅ | | **Client Admin Panel: User, IAM, Product, Chat, AI, Asset, Notification** | ✅ | ✅ | N/A | @@ -415,6 +417,51 @@ Clients keep sending `"app_version": "1.0.0"`. Core looks up a kept `Client::Ver - Client Telemetry (`Client::Log` capturing browser/mobile JS crashes that never touch Rails RED). - **Strict Non-Duplication Rule**: Never duplicate server CPU/memory, queue depths, or database query telemetry inside the Client Admin Panel. Prioritize business domain operations and client-side observability. +### 6. Coupon & Referral System Protocol + +- **Validation Contract (`POST /v1/payment/coupons/validate`)**: + - Request: `{ "code": "SAVE20", "product_id": "UUID" }` (Bearer auth required). + - Response (200): + ```json + { + "code": 200, + "message": "Coupon applied successfully.", + "data": { + "valid": true, + "coupon": { + "id": "UUID", + "code": "SAVE20", + "title": "20% Off", + "coupon_type": "percentage", + "amount": 20, + "currency": "usd" + }, + "original_amount": 1000, + "discount_amount": 200, + "final_amount": 800, + "currency": "usd" + } + } + ``` +- **Checkout Integration (`POST /v1/payment/session`)**: + - Request: `{ "product_id": "UUID", "coupon_code": "SAVE20", "success_url": "...", "cancel_url": "..." }` + - Flow: + 1. If `final_amount > 0`: Creates Stripe Checkout Session with `discounts: [{ coupon: stripe_coupon_id }]`, returns `{ "checkout_url": "...", "session_id": "..." }`. + 2. If `final_amount == 0`: Bypasses Stripe gateway, creates `Payment::Transaction` (with `unit_amount: product.unit_amount, amount_received: 0`), records `Payment::UserCoupon`, and calls `AccessService.grant(...)` returning `{ "free_access_granted": true, "product_id": "...", "coupon_code": "..." }`. +- **Referral Generation**: + - On every successful user signup, `User` model automatically creates a referral coupon (`REF`, uppercase alphanumeric only, minimum 6 characters padded, 20% discount, `max_usage_per_user: 1`, `referrer_id: user.id`). +- **Coupon Code Rules & Sanitization**: + - Minimum 6 characters required (`length: { minimum: 6 }`). + - Alphanumeric characters only (`A-Z` and `0-9`), auto-normalized to uppercase with all hyphens, underscores, spaces, or special characters strictly rejected. + - Batch coupon generation (`POST /v1/admin/payment/coupons/batch`) automatically sanitizes prefixes to alphanumeric and generates unique random codes satisfying the constraint. +- **Targeting Restrictions & User Email Resolution**: + - Admin coupon creation accepts customer emails under `target_user_emails: []` (or comma-separated emails from web UI). + - Backend resolves `target_user_emails` to registered user UUIDs (`User.where("LOWER(email) IN (?)", emails)`), rejecting any unknown email with a descriptive 422 error, and persists the UUID array in PostgreSQL `target_user_ids: uuid[]`. + - Serializer returns `target_user_emails` resolved from `target_user_ids` for admin inspection and badge rendering. +- **Atomic Concurrency & Reconciliation**: + - Increments `used_count` atomically via PostgreSQL `UPDATE coupons SET used_count = used_count + 1 WHERE id = :id AND (max_usage = 0 OR used_count < max_usage)`. + - Weekly `DataSyncService.sync_all!` job recalculates `used_count` against `user_coupons` to guarantee reconciliation. + ---
diff --git a/README.md b/README.md index b2cdedb5..5fadab53 100644 --- a/README.md +++ b/README.md @@ -197,10 +197,10 @@ The client admin panel architecture provides a protected workspace for managing - **Users**: User management (`/admin/users`), user creation (`/admin/users/create`), and user edit (`/admin/users/:id/edit`) powered by `AdminUserForm`. - **Roles**: Role and permission management (`/admin/roles`), role creation (`/admin/roles/create`), and role edit (`/admin/roles/:id/edit`) powered by `AdminRoleForm`. - **Products**: Product and pricing management (`/admin/products`), product creation (`/admin/products/create`), and product edit (`/admin/products/:id/edit`) powered by `AdminProductForm`. - - **Notifications**: Broadcast dispatch and templates (`/admin/notifications`), template creation (`/admin/notifications/create`), template edit (`/admin/notifications/:id/edit`), and dedicated user notifications management with recycle bin and detail page (`/admin/user-notifications`, `/admin/user-notifications/:id`, `/admin/user-notifications/bin`). + - **Notifications**: Broadcast dispatch, templates, and consolidated user notifications tab (`/admin/notifications?tab=user_notifications`), template creation (`/admin/notifications/create`), template edit (`/admin/notifications/:id/edit`), and user notification detail page (`/admin/user-notifications/:id`). Standalone `/admin/user-notifications` and `/admin/user-notifications/bin` routes automatically redirect to their respective parent tabs. - **Accesses**: Entitlements and access management (`/admin/accesses`), access grant console (`/admin/accesses/create`), and validity extension console (`/admin/accesses/:id/edit`) powered by `AdminAccessForm`. - **Assets**: Asset control center (`/admin/assets`), batch upload console (`/admin/assets/create`), asset detail (`/admin/assets/:id`), and asset edit console (`/admin/assets/:id/edit`) powered by `AdminAssetForm`. Canonical server-generated video thumbnails are consumed from the asset response and updated live through `asset_thumbnail_generated`; clients only show placeholders while generation is pending. Video and audio edit also support replacing a thumbnail and uploading an `.srt` subtitle (`POST /v1/admin/assets/:id/subtitle/upload`, same multipart `file` contract as thumbnail upload, non-video 10 MB limit). Nested thumbnail and subtitle use the shared `IChildAsset` shape (`id`, `url`, `status`, `size_bytes`) and are shown on detail and edit. List filters include `subtitle` type and format. Super admins additionally receive Garage and VPS capacity telemetry with separate object and byte usage for the `dev/`, `uat/`, and `prod/` storage partitions; regular admins never request or render these statistics. - - **Versions**: Super-admin-only version catalogue (`/admin/versions`), create (`/admin/versions/create`), edit (`/admin/versions/:id/edit`), recycle bin (`/admin/versions/bin`), and user versions (`/admin/user-versions`, `GET /v1/admin/client/versions/user_versions`). Nested user versions for one version remain at `/admin/versions/:id/user-versions` (`GET /v1/admin/client/versions/:id/user_versions`). Updates use PUT. There is no hard delete. + - **Versions**: Super-admin-only version catalogue (`/admin/versions`), create (`/admin/versions/create`), edit (`/admin/versions/:id/edit`), recycle bin (`/admin/versions/bin`), and consolidated user versions tab (`/admin/versions?tab=user_versions`, `GET /v1/admin/client/versions/user_versions`). Standalone `/admin/user-versions` route automatically redirects to the parent tab. Nested user versions for one version remain at `/admin/versions/:id/user-versions` (`GET /v1/admin/client/versions/:id/user_versions`). Updates use PUT. There is no hard delete. - **Chat**: Moderation tools for chat rooms and messages. - **Feedbacks & Logs**: User feedback review and client runtime error telemetry. - **Form Component Unification**: Every admin module featuring Create and Edit shares a single, reusable `Admin[Entity]Form` component (`mode: CREATE | EDIT`) between its dedicated create and edit route pages. Modals and dialogs are retired in favor of full pages. @@ -231,13 +231,16 @@ Browser persistence is used selectively. For example, sign-in cooldown timing su The payment module communicates with RexOne Core for: - Available product retrieval with pagination. -- Stripe Checkout Session creation. +- Promo & referral coupon validation (`POST /v1/payment/coupons/validate`) with real-time discount calculations and dynamic order summary in `CheckoutDialog`. +- Stripe Checkout Session creation (`POST /v1/payment/session`) with attached coupon discounts. +- 100% discount free access bypass: zero-amount checkouts bypass Stripe, provision immediate product access via Core `AccessService`, and transition UI smoothly without redirecting. - Payment success and cancellation routes. - Subscription listing, cancellation, and resumption contracts. - Transaction retrieval with pagination. - Access listing and entitlement checks. +- Admin Coupon Management (`/admin/coupons` and `/admin/user-coupons`): full CRUD, batch coupon generator, targeting filters (roles, users, products), usage limit enforcement, and redemption audit ledger. -Stripe secrets and webhook processing remain on the backend. The browser owns product presentation and secure checkout handoff, not payment authority. +Stripe secrets and webhook processing remain on the backend. The browser owns product presentation, promo validation, and secure checkout handoff, not payment authority. ### Real-time delivery @@ -464,55 +467,55 @@ All frontend environment variables are centralized through [`src/AppConfig.tsx`] ## Client route surface -| Access | Route | Purpose | -| --------- | ----------------------------------- | --------------------------------------------- | -| Public | `/` | Root experience | -| Public | `/signin` | Open the authentication dialog | -| Public | `/signup` | Enter the account creation flow | -| Public | `/email/confirm` | Handle confirmation links or code entry | -| Public | `/password/forgot` | Request account recovery | -| Public | `/password/reset` | Complete password reset links | -| Public | `/anapana` | Anapana interval reminder | -| Protected | `/home` | Authenticated home | -| Protected | `/profile` | Current-user profile | -| Protected | `/payment` | Products and checkout | -| Protected | `/payment/success` | Checkout success return | -| Protected | `/payment/cancel` | Checkout cancellation return | -| Protected | `/ai` | AI workspace | -| Protected | `/signout` | Sign out and provider cleanup | -| Protected | `/admin` | Admin panel entry with smart redirect | -| Protected | `/admin/users` | User management (super admin only) | -| Protected | `/admin/users/create` | User creation console | -| Protected | `/admin/users/:id/edit` | User edit console | -| Protected | `/admin/roles` | Role and permission management | -| Protected | `/admin/roles/create` | Role creation console | -| Protected | `/admin/roles/:id/edit` | Role edit console | -| Protected | `/admin/products` | Product and pricing management | -| Protected | `/admin/products/create` | Product creation console | -| Protected | `/admin/products/:id/edit` | Product edit console | -| Protected | `/admin/accesses` | Entitlements and user access management | -| Protected | `/admin/accesses/create` | Access grant console | -| Protected | `/admin/accesses/:id/edit` | Access validity extension console | -| Protected | `/admin/assets` | Asset control center & storage overview | -| Protected | `/admin/assets/create` | Asset upload console | -| Protected | `/admin/assets/:id` | Asset detail (metadata, thumbnail, subtitle) | -| Protected | `/admin/assets/:id/edit` | Asset edit, compression, thumbnail, subtitle | -| Protected | `/admin/notifications` | Broadcast notification dispatch and templates | -| Protected | `/admin/notifications/create` | Notification template creation console | -| Protected | `/admin/notifications/:id/edit` | Notification template edit console | -| Protected | `/admin/user-notifications` | User notifications inspection & lifecycle | -| Protected | `/admin/user-notifications/:id` | User notification detail & lifecycle actions | -| Protected | `/admin/user-notifications/bin` | User notifications recycle bin | -| Protected | `/admin/chat/rooms` | Chat room moderation | -| Protected | `/admin/chat/messages` | Chat message moderation | -| Protected | `/admin/feedback` | User feedback management | -| Protected | `/admin/logs` | Client error and telemetry logs | -| Protected | `/admin/versions` | App versions (super admin only) | -| Protected | `/admin/versions/create` | App version create console | -| Protected | `/admin/versions/:id/edit` | App version edit console | -| Protected | `/admin/versions/bin` | Discarded app versions | -| Protected | `/admin/user-versions` | All current user versions (super admin) | -| Protected | `/admin/versions/:id/user-versions` | User versions for one version | +| Access | Route | Purpose | +| --------- | ----------------------------------- | -------------------------------------------------------------------------------------------------- | +| Public | `/` | Root experience | +| Public | `/signin` | Open the authentication dialog | +| Public | `/signup` | Enter the account creation flow | +| Public | `/email/confirm` | Handle confirmation links or code entry | +| Public | `/password/forgot` | Request account recovery | +| Public | `/password/reset` | Complete password reset links | +| Public | `/anapana` | Anapana interval reminder | +| Protected | `/home` | Authenticated home | +| Protected | `/profile` | Current-user profile | +| Protected | `/payment` | Products and checkout | +| Protected | `/payment/success` | Checkout success return | +| Protected | `/payment/cancel` | Checkout cancellation return | +| Protected | `/ai` | AI workspace | +| Protected | `/signout` | Sign out and provider cleanup | +| Protected | `/admin` | Admin panel entry with smart redirect | +| Protected | `/admin/users` | User management (super admin only) | +| Protected | `/admin/users/create` | User creation console | +| Protected | `/admin/users/:id/edit` | User edit console | +| Protected | `/admin/roles` | Role and permission management | +| Protected | `/admin/roles/create` | Role creation console | +| Protected | `/admin/roles/:id/edit` | Role edit console | +| Protected | `/admin/products` | Product and pricing management | +| Protected | `/admin/products/create` | Product creation console | +| Protected | `/admin/products/:id/edit` | Product edit console | +| Protected | `/admin/accesses` | Entitlements and user access management | +| Protected | `/admin/accesses/create` | Access grant console | +| Protected | `/admin/accesses/:id/edit` | Access validity extension console | +| Protected | `/admin/assets` | Asset control center & storage overview | +| Protected | `/admin/assets/create` | Asset upload console | +| Protected | `/admin/assets/:id` | Asset detail (metadata, thumbnail, subtitle) | +| Protected | `/admin/assets/:id/edit` | Asset edit, compression, thumbnail, subtitle | +| Protected | `/admin/notifications` | Broadcast notification dispatch and templates | +| Protected | `/admin/notifications/create` | Notification template creation console | +| Protected | `/admin/notifications/:id/edit` | Notification template edit console | +| Protected | `/admin/user-notifications` | User notifications (redirects to `/admin/notifications?tab=user_notifications`) | +| Protected | `/admin/user-notifications/:id` | User notification detail & lifecycle actions | +| Protected | `/admin/user-notifications/bin` | User notifications bin (redirects to `/admin/notifications?tab=user_notifications&view=discarded`) | +| Protected | `/admin/chat/rooms` | Chat room moderation | +| Protected | `/admin/chat/messages` | Chat message moderation | +| Protected | `/admin/feedback` | User feedback management | +| Protected | `/admin/logs` | Client error and telemetry logs | +| Protected | `/admin/versions` | App versions (super admin only; active, user versions, bin tabs) | +| Protected | `/admin/versions/create` | App version create console | +| Protected | `/admin/versions/:id/edit` | App version edit console | +| Protected | `/admin/versions/bin` | Discarded app versions | +| Protected | `/admin/user-versions` | User versions (redirects to `/admin/versions?tab=user_versions`) | +| Protected | `/admin/versions/:id/user-versions` | User versions for one version | [`src/AppRoutes.ts`](src/AppRoutes.ts) is the client-side source of truth. RexOne Core's OpenAPI page at `/api-docs` and its `config/routes.rb` remain authoritative for server contracts. diff --git a/src/AppRoutes.ts b/src/AppRoutes.ts index ef9fdbec..103899b3 100644 --- a/src/AppRoutes.ts +++ b/src/AppRoutes.ts @@ -59,7 +59,9 @@ class AppRoutes { NOTIFICATION_EDIT: AppRoutes.admin("/notifications/:id/edit"), USER_NOTIFICATIONS: AppRoutes.admin("/user-notifications"), USER_NOTIFICATION_DETAIL: AppRoutes.admin("/user-notifications/:id"), - USER_NOTIFICATIONS_RECYCLE_BIN: AppRoutes.admin("/user-notifications/bin"), + USER_NOTIFICATIONS_RECYCLE_BIN: AppRoutes.admin( + "/user-notifications/bin", + ), PRODUCTS: AppRoutes.admin("/products"), PRODUCT_DETAIL: AppRoutes.admin("/products/:id"), PRODUCTS_RECYCLE_BIN: AppRoutes.admin("/products/bin"), @@ -69,6 +71,12 @@ class AppRoutes { TRANSACTION_DETAIL: AppRoutes.admin("/transactions/:id"), SUBSCRIPTIONS: AppRoutes.admin("/subscriptions"), SUBSCRIPTION_DETAIL: AppRoutes.admin("/subscriptions/:id"), + COUPONS: AppRoutes.admin("/coupons"), + COUPON_CREATE: AppRoutes.admin("/coupons/create"), + COUPON_EDIT: AppRoutes.admin("/coupons/:id/edit"), + COUPON_DETAIL: AppRoutes.admin("/coupons/:id"), + COUPONS_RECYCLE_BIN: AppRoutes.admin("/coupons/bin"), + USER_COUPONS: AppRoutes.admin("/user-coupons"), ASSETS: AppRoutes.admin("/assets"), ASSET_DETAIL: AppRoutes.admin("/assets/:id"), ASSETS_RECYCLE_BIN: AppRoutes.admin("/assets/bin"), @@ -162,6 +170,7 @@ class AppRoutes { "/payment/subscriptions/:id/resume", ), // POST PAYMENT_TRANSACTIONS: AppRoutes.api("/payment/transactions"), // GET + PAYMENT_COUPONS_VALIDATE: AppRoutes.api("/payment/coupons/validate"), // POST // Chat AI_CHAT: AppRoutes.api("/chat/messages"), // POST @@ -249,6 +258,29 @@ class AppRoutes { PAYMENT_SUBSCRIPTION_DETAIL: AppRoutes.adminApi( "/payment/subscriptions/:id", ), // GET + PAYMENT_COUPONS: AppRoutes.adminApi("/payment/coupons"), // GET, POST + PAYMENT_COUPON_DETAIL: AppRoutes.adminApi("/payment/coupons/:id"), // GET, PUT, DELETE + PAYMENT_COUPON_DISCARD: AppRoutes.adminApi( + "/payment/coupons/:id/discard", + ), // POST + PAYMENT_COUPON_UNDISCARD: AppRoutes.adminApi( + "/payment/coupons/:id/undiscard", + ), // POST + PAYMENT_COUPONS_BATCH: AppRoutes.adminApi("/payment/coupons/batch"), // POST + PAYMENT_COUPONS_BIN: AppRoutes.adminApi("/payment/coupons/bin"), // DELETE + PAYMENT_COUPONS_DISCARD_BATCH: AppRoutes.adminApi( + "/payment/coupons/discard_batch", + ), // POST + PAYMENT_COUPONS_UNDISCARD_BATCH: AppRoutes.adminApi( + "/payment/coupons/undiscard_batch", + ), // POST + PAYMENT_COUPONS_DESTROY_BATCH: AppRoutes.adminApi( + "/payment/coupons/destroy_batch", + ), // POST + PAYMENT_COUPON_REDEMPTIONS: AppRoutes.adminApi( + "/payment/coupons/:id/redemptions", + ), // GET + PAYMENT_USER_COUPONS: AppRoutes.adminApi("/payment/user_coupons"), // GET ASSETS: AppRoutes.adminApi("/assets"), // GET ASSET_DETAIL: AppRoutes.adminApi("/assets/:id"), // GET, PUT, DELETE ASSET_UPLOAD: AppRoutes.adminApi("/assets/upload"), // POST diff --git a/src/design/components/form/TextInput.tsx b/src/design/components/form/TextInput.tsx index 7f86d9bf..5d0a6793 100644 --- a/src/design/components/form/TextInput.tsx +++ b/src/design/components/form/TextInput.tsx @@ -5,23 +5,28 @@ */ import React, { useId } from "react"; +import { iconsLib } from "../../../assets"; import { cn } from "../../helpers"; import { InputVariant, InputVariants } from "../../constants"; export interface ITextInputProps extends React.InputHTMLAttributes { label?: string; + labelClassName?: string; helperText?: string; error?: string; fullWidth?: boolean; variant?: InputVariant; + tooltip?: string; } export const TextInput: React.FC = ({ label, + labelClassName, helperText, error, fullWidth = true, variant = InputVariants.DEFAULT, + tooltip, className, id, disabled, @@ -41,33 +46,48 @@ export const TextInput: React.FC = ({ className={cn( "text-body-s font-medium mb-1", isGlass ? "text-glow-white" : "text-base-content", + labelClassName, )} > {label} )} - + + + {tooltip && ( +
+
+ +
+
)} - /> +
{displayText && ( { const { can, isLoading: permissionsLoading } = usePermissions(); const canReadRoles = can(ADMIN_ACTIONS.READ, ADMIN_RESOURCES.ROLES); const canReadUsers = can(ADMIN_ACTIONS.READ, ADMIN_RESOURCES.USERS); + const canReadUserNotifications = can( + ADMIN_ACTIONS.READ, + ADMIN_RESOURCES.USER_NOTIFICATIONS, + ); const canCreateNotifications = can( ADMIN_ACTIONS.CREATE, ADMIN_RESOURCES.NOTIFICATIONS, @@ -103,6 +108,14 @@ export const AdminNotificationsPage: React.FC = () => { : NOTIFICATION_ADMIN_TABS.BROADCAST, ); + useEffect(() => { + if (tabParam && Object.values(NOTIFICATION_ADMIN_TABS).includes(tabParam)) { + setActiveTab(tabParam); + } else if (!tabParam) { + setActiveTab(NOTIFICATION_ADMIN_TABS.BROADCAST); + } + }, [tabParam]); + const handleTabChange = useCallback( (tab: TNotificationAdminTab) => { setActiveTab(tab); @@ -122,21 +135,30 @@ export const AdminNotificationsPage: React.FC = () => { [setSearchParams], ); - const tabItems = useMemo[]>( - () => [ + const tabItems = useMemo[]>(() => { + const tabs: ITabItem[] = [ { value: NOTIFICATION_ADMIN_TABS.BROADCAST, label: t(AppLocales.Admin.Notifications.Tabs.Broadcast), - icon: iconsLib.bell, + icon: iconsLib.bellAlert, }, { value: NOTIFICATION_ADMIN_TABS.TEMPLATES, label: t(AppLocales.Admin.Notifications.Tabs.Templates), icon: iconsLib.document, }, - ], - [t], - ); + ]; + + if (canReadUserNotifications) { + tabs.push({ + value: NOTIFICATION_ADMIN_TABS.USER_NOTIFICATIONS, + label: t(AppLocales.Admin.Notifications.UserNotifications.Title), + icon: iconsLib.bell, + }); + } + + return tabs; + }, [canReadUserNotifications, t]); useEffect(() => { if (permissionsLoading) return; @@ -455,6 +477,8 @@ export const AdminNotificationsPage: React.FC = () => { {activeTab === NOTIFICATION_ADMIN_TABS.TEMPLATES ? ( + ) : activeTab === NOTIFICATION_ADMIN_TABS.USER_NOTIFICATIONS ? ( + ) : error && templates.length === 0 ? ( { @@ -64,8 +65,8 @@ export const AdminUserNotificationDetailPage: React.FC = () => { const isDiscarded = Boolean(notification?.discarded_at); const listPath = isDiscarded - ? AppRoutes.client.protected.admin.USER_NOTIFICATIONS_RECYCLE_BIN - : AppRoutes.client.protected.admin.USER_NOTIFICATIONS; + ? `${AppRoutes.client.protected.admin.NOTIFICATIONS}?tab=${NOTIFICATION_ADMIN_TABS.USER_NOTIFICATIONS}&view=discarded` + : `${AppRoutes.client.protected.admin.NOTIFICATIONS}?tab=${NOTIFICATION_ADMIN_TABS.USER_NOTIFICATIONS}`; const handleDiscard = async () => { if (!notification) return; @@ -82,7 +83,7 @@ export const AdminUserNotificationDetailPage: React.FC = () => { ), ); setIsDiscardOpen(false); - navigate(AppRoutes.client.protected.admin.USER_NOTIFICATIONS); + navigate(listPath); } else { toast.error(result.error || "Failed to discard user notification"); } @@ -132,9 +133,7 @@ export const AdminUserNotificationDetailPage: React.FC = () => { ), ); setIsDestroyOpen(false); - navigate( - AppRoutes.client.protected.admin.USER_NOTIFICATIONS_RECYCLE_BIN, - ); + navigate(listPath); } else { toast.error( result.error || "Failed to permanently delete user notification", @@ -156,22 +155,14 @@ export const AdminUserNotificationDetailPage: React.FC = () => { label: t(AppLocales.Admin.Common.Detail.Admin), to: AppRoutes.client.protected.admin.HOME, }, + { + label: t(AppLocales.Admin.Notifications.Title), + to: AppRoutes.client.protected.admin.NOTIFICATIONS, + }, { label: t(AppLocales.Admin.Notifications.UserNotifications.Title), - to: AppRoutes.client.protected.admin.USER_NOTIFICATIONS, + to: listPath, }, - ...(isDiscarded - ? [ - { - label: t( - AppLocales.Admin.Notifications.UserNotifications.Tabs - .RecycleBin, - ), - to: AppRoutes.client.protected.admin - .USER_NOTIFICATIONS_RECYCLE_BIN, - }, - ] - : []), { label: notification?.title || t(AppLocales.Admin.Common.Detail.Details), diff --git a/src/modules/admin/notification/pages/AdminUserNotificationsPage.tsx b/src/modules/admin/notification/pages/AdminUserNotificationsPage.tsx index efc84321..a02ca31f 100644 --- a/src/modules/admin/notification/pages/AdminUserNotificationsPage.tsx +++ b/src/modules/admin/notification/pages/AdminUserNotificationsPage.tsx @@ -68,10 +68,21 @@ export const AdminUserNotificationsPage: React.FC< const { isLoading, setLoading } = useLoading(); const { can } = usePermissions(); - const [localView, setLocalView] = useState(initialView); + const viewParam = searchParams.get("view") as TAdminViewMode | null; + const [localView, setLocalView] = useState( + viewParam === ADMIN_VIEW_MODES.DISCARDED + ? ADMIN_VIEW_MODES.DISCARDED + : initialView, + ); const currentView = embedded ? localView : initialView; const isActive = currentView === ADMIN_VIEW_MODES.ACTIVE; + useEffect(() => { + if (embedded && viewParam) { + setLocalView(viewParam); + } + }, [embedded, viewParam]); + useDocumentTitle( embedded ? `${t(AppLocales.Admin.Notifications.Title)} | Admin` @@ -557,6 +568,10 @@ export const AdminUserNotificationsPage: React.FC< setSelectedIds([]); if (embedded) { setLocalView(tab as TAdminViewMode); + updateSearchParams({ + view: tab === ADMIN_VIEW_MODES.DISCARDED ? tab : "", + page: "1", + }); } else { navigate( tab === ADMIN_VIEW_MODES.ACTIVE @@ -564,8 +579,8 @@ export const AdminUserNotificationsPage: React.FC< : AppRoutes.client.protected.admin .USER_NOTIFICATIONS_RECYCLE_BIN, ); + updateSearchParams({ page: "1" }); } - updateSearchParams({ page: "1" }); }} items={[ { diff --git a/src/modules/admin/payment/components/AdminRedemptionsTable.tsx b/src/modules/admin/payment/components/AdminRedemptionsTable.tsx new file mode 100644 index 00000000..4c639540 --- /dev/null +++ b/src/modules/admin/payment/components/AdminRedemptionsTable.tsx @@ -0,0 +1,335 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import AppRoutes from "../../../../AppRoutes"; +import { iconsLib } from "../../../../assets"; +import { useLoading } from "../../../../contexts"; +import { + Badge, + DateTime, + DateTimeFormats, + Dropdown, + SearchInput, +} from "../../../../design"; +import { + BadgeVariants, + ComponentSizes, +} from "../../../../design/constants"; +import { + SORT_ORDERS, + usePermissions, + useSort, +} from "../../../../hooks"; +import { AppLocales } from "../../../../locales/app_locales"; +import { + AdminPagination, + AdminState, + AdminTable, + type IAdminTableColumn, +} from "../../components"; +import { + ADMIN_ACTIONS, + ADMIN_PAGE_SIZE, + ADMIN_RESOURCES, +} from "../../constants"; +import { ADMIN_USER_COUPON_SORT_KEYS } from "../constants"; +import PaymentController from "../payment.controller"; +import type { IUserCoupon } from "../../../payment/types"; +import type { IApiPagination } from "../../../../models"; + +const money = (amount: number, currency: string = "usd") => + new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency.toUpperCase(), + }).format(amount / 100); + +export interface IAdminRedemptionsTableProps { + couponId?: string; + hideCouponColumn?: boolean; +} + +export const AdminRedemptionsTable: React.FC = ({ + couponId, + hideCouponColumn = false, +}) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [params, setParams] = useSearchParams(); + const { isLoading, setLoading } = useLoading(); + + const page = Number(params.get("page") || 1); + const purchaseType = params.get("purchase_type") || ""; + const search = params.get("search") || ""; + + const [searchInput, setSearchInput] = useState(search); + const [records, setRecords] = useState([]); + const [pagination, setPagination] = useState(null); + const [error, setError] = useState(""); + + const { can, isLoading: permissionsLoading } = usePermissions(); + + const { sortBy, sortOrder, handleSort } = useSort({ + defaultSortBy: ADMIN_USER_COUPON_SORT_KEYS.CREATED_AT, + defaultSortOrder: SORT_ORDERS.DESC, + }); + + const update = useCallback( + (values: Record) => + setParams( + (previous) => { + const next = new URLSearchParams(previous); + Object.entries(values).forEach(([key, value]) => + value && value !== 1 + ? next.set(key, String(value)) + : next.delete(key), + ); + return next; + }, + { replace: true }, + ), + [setParams], + ); + + useEffect(() => { + const timer = window.setTimeout( + () => + searchInput !== search && + update({ search: searchInput.trim(), page: 1 }), + 300, + ); + return () => window.clearTimeout(timer); + }, [search, searchInput, update]); + + const loadRedemptions = useCallback(async () => { + const requiredResource = couponId + ? ADMIN_RESOURCES.PAYMENT_COUPONS + : ADMIN_RESOURCES.PAYMENT_USER_COUPONS; + + if (permissionsLoading || !can(ADMIN_ACTIONS.READ, requiredResource)) { + return; + } + + setLoading(true); + setError(""); + + const filterParams = { + page, + limit: ADMIN_PAGE_SIZE, + purchase_type: purchaseType || undefined, + search: search || undefined, + sort_by: sortBy, + sort_order: sortOrder, + }; + + const result = couponId + ? await PaymentController.getCouponRedemptions(couponId, filterParams) + : await PaymentController.getUserCoupons(filterParams); + + if (result.success) { + const loadedRecords = couponId + ? (result as { redemptions: IUserCoupon[] }).redemptions + : (result as { userCoupons: IUserCoupon[] }).userCoupons; + setRecords(loadedRecords); + setPagination(result.pagination); + } else { + setError(result.error || t(AppLocales.Admin.UserCoupons.Errors.Load)); + } + + setLoading(false); + }, [ + couponId, + page, + purchaseType, + search, + sortBy, + sortOrder, + permissionsLoading, + can, + setLoading, + t, + ]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + void loadRedemptions(); + }, [loadRedemptions]); + + const columns: IAdminTableColumn[] = [ + ...(!hideCouponColumn + ? [ + { + key: "coupon_code", + header: t(AppLocales.Admin.UserCoupons.Table.Code), + sortKey: ADMIN_USER_COUPON_SORT_KEYS.COUPON_CODE, + render: (item: IUserCoupon) => ( +
+ + navigate( + AppRoutes.withId( + AppRoutes.client.protected.admin.COUPON_DETAIL, + item.coupon_id, + ), + ) + } + > + {item.coupon_code || "—"} + + {item.coupon_title && ( + + {item.coupon_title} + + )} +
+ ), + }, + ] + : []), + { + key: "user_email", + header: t(AppLocales.Admin.UserCoupons.Table.User), + sortKey: ADMIN_USER_COUPON_SORT_KEYS.USER_EMAIL, + render: (item) => ( +
+ + {item.user_email || "—"} + + {item.user_name && item.user_name !== item.user_email && ( + + {item.user_name} + + )} +
+ ), + }, + { + key: "product_name", + header: t(AppLocales.Admin.UserCoupons.Table.Product), + sortKey: ADMIN_USER_COUPON_SORT_KEYS.PRODUCT_NAME, + render: (item) => ( +
+ + {item.product_name || "—"} + +
+ ), + }, + { + key: "purchase_type", + header: t(AppLocales.Admin.UserCoupons.Table.Type), + sortKey: ADMIN_USER_COUPON_SORT_KEYS.PURCHASE_TYPE, + render: (item) => ( + + {item.purchase_type === "sbs" + ? t(AppLocales.Admin.UserCoupons.Types.Subscription) + : t(AppLocales.Admin.UserCoupons.Types.Transaction)} + + ), + }, + { + key: "discount", + header: t(AppLocales.Admin.UserCoupons.Table.Discount), + sortKey: ADMIN_USER_COUPON_SORT_KEYS.DISCOUNT_AMOUNT, + render: (item) => ( +
+
+ + {money(item.original_amount, item.currency)} + + + -{money(item.discount_amount, item.currency)} + +
+ + {item.final_amount === 0 + ? t(AppLocales.Admin.UserCoupons.Free) + : money(item.final_amount, item.currency)} + +
+ ), + }, + { + key: "created_at", + header: t(AppLocales.Admin.UserCoupons.Table.Date), + sortKey: ADMIN_USER_COUPON_SORT_KEYS.CREATED_AT, + render: (item) => ( + + ), + }, + ]; + + return ( +
+ {/* Filter and Search Bar */} +
+
+ setSearchInput(e.target.value)} + onClear={() => setSearchInput("")} + placeholder={t(AppLocales.Admin.UserCoupons.SearchPlaceholder)} + /> +
+ +
+ update({ purchase_type: val, page: 1 })} + placeholder={t(AppLocales.Admin.UserCoupons.FilterAll)} + options={[ + { value: "", label: t(AppLocales.Admin.UserCoupons.FilterAll) }, + { value: "trx", label: t(AppLocales.Admin.UserCoupons.FilterTrx) }, + { value: "sbs", label: t(AppLocales.Admin.UserCoupons.FilterSbs) }, + ]} + /> +
+
+ + {/* Table Content */} + {error ? ( + + ) : !isLoading && records.length === 0 ? ( + + ) : ( + <> + item.id} + sortBy={sortBy} + sortOrder={sortOrder} + onSort={handleSort} + /> + + update({ page: nextPage })} + /> + + )} +
+ ); +}; +export default AdminRedemptionsTable; diff --git a/src/modules/admin/payment/components/index.ts b/src/modules/admin/payment/components/index.ts new file mode 100644 index 00000000..b680b0bf --- /dev/null +++ b/src/modules/admin/payment/components/index.ts @@ -0,0 +1,2 @@ +export { AdminRedemptionsTable } from "./AdminRedemptionsTable"; +export type { IAdminRedemptionsTableProps } from "./AdminRedemptionsTable"; diff --git a/src/modules/admin/payment/constants.ts b/src/modules/admin/payment/constants.ts index f9bc4a9f..bc24c68d 100644 --- a/src/modules/admin/payment/constants.ts +++ b/src/modules/admin/payment/constants.ts @@ -14,3 +14,23 @@ export const ADMIN_SUBSCRIPTION_SORT_KEYS = { STATUS: "status", INTERVAL: "interval", } as const; + +export const ADMIN_COUPON_SORT_KEYS = { + CREATED_AT: "created_at", + CODE: "code", + TITLE: "title", + AMOUNT: "amount", + USED_COUNT: "used_count", + EXPIRES_AT: "expires_at", +} as const; + +export const ADMIN_USER_COUPON_SORT_KEYS = { + CREATED_AT: "created_at", + DISCOUNT_AMOUNT: "discount_amount", + FINAL_AMOUNT: "final_amount", + PURCHASE_TYPE: "purchase_type", + USER_EMAIL: "user_email", + PRODUCT_NAME: "product_name", + COUPON_CODE: "coupon_code", +} as const; + diff --git a/src/modules/admin/payment/pages/AdminCouponCreatePage.tsx b/src/modules/admin/payment/pages/AdminCouponCreatePage.tsx new file mode 100644 index 00000000..0279cc1e --- /dev/null +++ b/src/modules/admin/payment/pages/AdminCouponCreatePage.tsx @@ -0,0 +1,501 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import AppRoutes from "../../../../AppRoutes"; +import { iconsLib } from "../../../../assets"; +import { + Button, + Dropdown, +} from "../../../../design"; +import { + ButtonVariants, + ComponentSizes, +} from "../../../../design/constants"; +import { TextInput, TextArea, Checkbox } from "../../../../design/components/form"; +import { useLoading } from "../../../../contexts"; +import { useDocumentTitle, usePermissions } from "../../../../hooks"; +import { AppLocales } from "../../../../locales/app_locales"; +import { PageHeader } from "../../components"; +import { ADMIN_ACTIONS, ADMIN_RESOURCES } from "../../constants"; +import PaymentController from "../payment.controller"; +import ClientPaymentController from "../../../payment/payment.controller"; +import RoleController from "../../role/role.controller"; +import type { IProduct } from "../../../payment/types"; +import type { IAdminRole } from "../../role/types"; +import { useToast } from "../../../../contexts/ToastContext"; +import { + COUPON_TYPES, + MAX_PERCENTAGE_DISCOUNT, + PAYMENT_CURRENCIES, + PAYMENT_CURRENCY_OPTIONS, + type TCouponType, +} from "../../../payment/constants"; + +export const AdminCouponCreatePage: React.FC = () => { + const { t } = useTranslation(); + useDocumentTitle(t(AppLocales.Admin.Coupons.CreateTitle)); + const navigate = useNavigate(); + const { success, error: toastError } = useToast(); + const { isLoading, setLoading } = useLoading(); + const { can, isLoading: permissionsLoading } = usePermissions(); + + const canReadRoles = can(ADMIN_ACTIONS.READ, ADMIN_RESOURCES.ROLES); + + const [isBatch, setIsBatch] = useState(false); + + // Form Fields + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [code, setCode] = useState(""); + const [batchCount, setBatchCount] = useState(10); + const [batchPrefix, setBatchPrefix] = useState("PROMO"); + const [couponType, setCouponType] = useState(COUPON_TYPES.PERCENTAGE); + const [amount, setAmount] = useState(20); + const [currency, setCurrency] = useState(PAYMENT_CURRENCIES.USD); + const [maxUsage, setMaxUsage] = useState(0); + const [maxUsagePerUser, setMaxUsagePerUser] = useState(1); + const [expiresAt, setExpiresAt] = useState(""); + + // Targeting + const [roles, setRoles] = useState([]); + const [products, setProducts] = useState([]); + const [selectedRoleIds, setSelectedRoleIds] = useState([]); + const [selectedProductIds, setSelectedProductIds] = useState([]); + const [targetUserEmailsInput, setTargetUserEmailsInput] = useState(""); + + useEffect(() => { + const fetchDependencies = async () => { + const rolePromise = canReadRoles + ? RoleController.getRoles({ limit: 100 }) + : Promise.resolve({ success: false, roles: [] }); + const prodPromise = ClientPaymentController.getProducts({ limit: 100 }); + + const [roleRes, prodRes] = await Promise.all([rolePromise, prodPromise]); + if (roleRes.success) setRoles(roleRes.roles); + if (prodRes.success) setProducts(prodRes.products); + }; + void fetchDependencies(); + }, [canReadRoles]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!title.trim()) { + toastError(t(AppLocales.Admin.Coupons.Form.TitleLabel)); + return; + } + + if (!isBatch) { + const cleanCode = code.trim().toUpperCase().replace(/[^A-Z0-9]/g, ""); + if (!cleanCode) { + toastError(t(AppLocales.Admin.Coupons.Form.CodeLabel)); + return; + } + if (cleanCode.length < 6) { + toastError(t(AppLocales.Admin.Coupons.Form.CodeInvalid)); + return; + } + } + + if (amount <= 0) { + toastError(t(AppLocales.Admin.Coupons.Form.AmountLabel)); + return; + } + + if (couponType === COUPON_TYPES.PERCENTAGE && amount > MAX_PERCENTAGE_DISCOUNT) { + toastError(t(AppLocales.Admin.Coupons.Form.PercentagePlaceholder)); + return; + } + + setLoading(true); + + const parsedTargetUsers = targetUserEmailsInput + .split(/[,;\n\s]+/) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + const targetUserEmails = parsedTargetUsers.filter((entry) => entry.includes("@")); + const targetUserIds = parsedTargetUsers.filter((entry) => !entry.includes("@")); + + // Minor units for fixed discounts (e.g. $10 -> 1000 cents), or raw integer percentage + const finalAmount = couponType === COUPON_TYPES.PERCENTAGE ? Math.round(amount) : Math.round(amount * 100); + + const basePayload = { + title: title.trim(), + description: description.trim() || undefined, + coupon_type: couponType, + amount: finalAmount, + currency: currency || undefined, + max_usage: maxUsage >= 0 ? maxUsage : 0, + max_usage_per_user: maxUsagePerUser > 0 ? maxUsagePerUser : 1, + expires_at: expiresAt ? new Date(expiresAt).toISOString() : undefined, + target_role_ids: canReadRoles ? selectedRoleIds : [], + target_product_ids: selectedProductIds, + target_user_ids: targetUserIds.length > 0 ? targetUserIds : undefined, + target_user_emails: targetUserEmails.length > 0 ? targetUserEmails : undefined, + }; + + if (isBatch) { + const cleanPrefix = batchPrefix.trim().toUpperCase().replace(/[^A-Z0-9]/g, ""); + const result = await PaymentController.createBatchCoupons({ + count: batchCount, + prefix: cleanPrefix, + coupon: basePayload, + }); + setLoading(false); + + if (result.success) { + success(t(AppLocales.Admin.Coupons.Toasts.BatchCreateSuccess)); + navigate(AppRoutes.client.protected.admin.COUPONS); + } else { + toastError(result.error || t(AppLocales.Admin.Coupons.Errors.Create)); + } + } else { + const cleanCode = code.trim().toUpperCase().replace(/[^A-Z0-9]/g, ""); + const result = await PaymentController.createCoupon({ + ...basePayload, + code: cleanCode, + }); + setLoading(false); + + if (result.success) { + success(t(AppLocales.Admin.Coupons.Toasts.CreateSuccess)); + navigate(AppRoutes.client.protected.admin.COUPONS); + } else { + toastError(result.error || t(AppLocales.Admin.Coupons.Errors.Create)); + } + } + }; + + const toggleRoleSelection = (roleId: string) => { + setSelectedRoleIds((prev) => + prev.includes(roleId) ? prev.filter((id) => id !== roleId) : [...prev, roleId], + ); + }; + + const toggleProductSelection = (productId: string) => { + setSelectedProductIds((prev) => + prev.includes(productId) + ? prev.filter((id) => id !== productId) + : [...prev, productId], + ); + }; + + if (!permissionsLoading && !can(ADMIN_ACTIONS.CREATE, ADMIN_RESOURCES.PAYMENT_COUPONS)) { + return ( +
+ {t(AppLocales.Admin.Coupons.Errors.Create)} +
+ ); + } + + return ( +
+ navigate(AppRoutes.client.protected.admin.COUPONS)} + > + + {t(AppLocales.Admin.Coupons.Title)} + + } + /> + +
+ {/* Creation Mode Toggle */} +
+
+ + {t(AppLocales.Admin.Coupons.Form.TypeLabel)} + + + {t(AppLocales.Admin.Coupons.Form.BatchHelper)} + +
+
+ + +
+
+ + {/* Basic Information */} +
+

+ {t(AppLocales.Admin.Coupons.DetailTitle)} +

+ +
+ setTitle(e.target.value)} + required + /> +
+ +
+