diff --git a/ECOSYSTEM.md b/ECOSYSTEM.md index fe2dde43..f808d720 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 | @@ -398,6 +400,10 @@ Clients keep sending `"app_version": "1.0.0"`. Core looks up a kept `Client::Ver - 9 failures $\rightarrow$ 120s cooldown - 12+ failures $\rightarrow$ 300s cooldown - Clients only consume `data.remaining_attempts` and `data.cooldown_remaining` from the API response to drive UI timers. +- **Password Reset Request Cooldown (`POST /password/forgot`)**: + - `password_reset:cooldown:{user_id}`: 60s cooldown between consecutive password reset email requests. + - On 429 Too Many Requests: returns `data.cooldown_remaining` and error message `"We've sent an email. Please wait %{seconds}s before requesting again."`. + - Frontend dialogs (Web & Mobile) catch 429 and start their local countdown timers directly from `data.cooldown_remaining`. ### 5. Dashboard Separation & Priority Protocol @@ -415,6 +421,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..03356efa 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Built under the same creed as RexOne Core: **Start from One. Not from Zero. Clea **Typed · Modular · Localized · Observable · API-driven · Fully Tested** -[Live Demo ↗](https://rexone.rex9.me) · [Explore the client](#feature-map) · [Who it is for](#who-rexone-web-is-for) · [Ecosystem Architecture](ECOSYSTEM.md) · [Development Law](LAW.md) · [Production Deployment](docs/DEPLOYMENT.md) · [Run it locally](#getting-started) · [Meet the architecture](#architecture) · [Connect the API](#configuration) +[Live Demo ↗](https://rexone.rex9.me) · [Explore the client](#feature-map) · [Who it is for](#who-rexone-web-is-for) · [Ecosystem Architecture](ECOSYSTEM.md) · [Development Law](LAW.md) · [Design System](docs/DESIGN_SYSTEM.md) · [Production Deployment](docs/DEPLOYMENT.md) · [Run it locally](#getting-started) · [Meet the architecture](#architecture) · [Connect the API](#configuration)
@@ -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. @@ -208,7 +208,7 @@ The client admin panel architecture provides a protected workspace for managing - **Data Handling**: Standardized data tables, forms, search filters, and recycle bins for discarded records. ### Design system - + The design layer provides reusable: - Buttons, Google authentication actions, and text links. @@ -218,7 +218,7 @@ The design layer provides reusable: - Color, typography, spacing, radius, shadow, and motion primitives. - Theme and language controls. -Tailwind CSS, DaisyUI, Headless UI, Heroicons, and `tailwind-merge` provide the implementation substrate without owning the application architecture. +Tailwind CSS v4, DaisyUI v5, and `tailwind-merge` provide the implementation substrate without owning the application architecture. For the complete token contracts, typography families (including monospace), color contrast matrices, and constitutional design laws, see the **[RexOne Design System Guide](docs/DESIGN_SYSTEM.md)**. ### State & application flow @@ -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 @@ -333,10 +336,12 @@ The web client includes a dedicated Client Admin Portal (`/admin/*`) providing o ## Quality toolchain -- **TypeScript Project Builds** for strict compile-time type safety. -- **ESLint** with React Hooks and React Refresh rules. -- **Vitest** for automated unit and component tests. -- **Playwright** for end-to-end user journey verification. +- **TypeScript Project Builds** for strict compile-time type safety (`npm run build`). +- **ESLint** with React Hooks and React Refresh rules (`npm run lint`). +- **Architecture Validation** (`npm run check:architecture`) to enforce LAW.md invariants (centralized storage keys, direct cookie disallowance, etc.). +- **Locales Parity & Fallback Check** (`npm run check:locales`) to verify zero loose strings or missing keys across i18n dictionaries. +- **Vitest** for automated unit and component tests (`npm run test:unit`). +- **Playwright** for end-to-end user journey verification (`npm run test:e2e`). - **Continuous Integration (GitHub Actions)** for automated build validation (`npm run build`) and Vitest test execution on PR branch pushes. - **Vite** production builds and local production preview. - Dependency and browser-baseline checks through the npm toolchain. @@ -464,55 +469,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/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md new file mode 100644 index 00000000..41a0ad18 --- /dev/null +++ b/docs/DESIGN_SYSTEM.md @@ -0,0 +1,297 @@ +# RexOne High-Contrast Design System + +> **A sovereign, high-contrast design system built for speed, precision, and longevity.** +> Built with **Tailwind CSS v4** + **DaisyUI v5** on **React 19**. +> Aesthetic Creed: High contrast, unapologetic, and stylish—Red & Black in daylight, Red & White in night, glowing neon on glass. + +--- + +## 1. Architectural Overview & Philosophy + +The RexOne Design System enforces absolute centralization and zero redundancy. It solves theme management, typography scaling, and brand consistency at the root layer rather than distributing styling debt into hundreds of individual React components. + +### Core Principles +1. **Single Point of Truth**: Every color, font, border radius, and glow effect originates strictly from `src/design/elements/`. +2. **Zero Hardcoded Colors**: No component or controller may contain hex codes (`#hex`), raw RGBA strings, or hardcoded utility colors (`text-white`, `text-black`, `bg-white`, `bg-black`). +3. **Flicker-Free Theme Toggling**: Themes (`day` and `night`) switch instantaneously via CSS variables bound to the root HTML attribute (``). Zero React component re-renders. Zero `dark:` prefix clutter. +4. **No Dumb / Redundant Code**: DaisyUI 5 natively produces semantic utility classes (`bg-base-100`, `text-base-content`, `bg-primary`, `text-primary-content`, `border-base-300`, `bg-secondary`, `bg-accent`, `bg-neutral`). We do not re-map or duplicate these inside `tailwind.config.js`. + +--- + +## 2. Technology Stack + +| Layer | Technology | Purpose | +| :--- | :--- | :--- | +| **Framework** | React 19 + TypeScript | Component runtime & strict contract typing | +| **CSS Engine** | Tailwind CSS v4 (`@tailwindcss/vite`) | Utility-first styling with native CSS-variable discovery | +| **Token & Component Primitives** | DaisyUI v5 | Semantic color tokens, badges, buttons, modals, form controls | +| **State & Theme Management** | Jotai (`atoms.themeAtom`) + DOM hook | Root theme attribute binding (`document.documentElement.setAttribute('data-theme', theme)`) | +| **Class Merging** | `clsx` + `tailwind-merge` (`cn` helper) | Conflict-free className merging and conditional class composition | + +--- + +## 3. Typography Engine + +All typography tokens are centralized in [`src/design/elements/font.ts`](file:///Users/rex/Desktop/Dev/rexone/rexone-web/src/design/elements/font.ts) and imported in [`src/design/styles/fonts.css`](file:///Users/rex/Desktop/Dev/rexone/rexone-web/src/design/styles/fonts.css). + +``` +src/design/elements/font.ts (Tokens) + │ + ▼ +tailwind.config.js (addBase :root injection) + │ + ▼ +src/design/styles/index.css (Applied globally to html, body) +``` + +### Font Families + +| Token | Family Name | Fallbacks | Tailwind Utility | Primary Usage | +| :--- | :--- | :--- | :--- | :--- | +| `primary` | `"Quicksand"` | `sans-serif` | `font-primary`, default `sans` | General UI, body text, form fields, buttons, navigation, headers | +| `display` | `"Clip"` | `cursive, sans-serif` | `font-display` | Landing hero titles, high-impact section headlines, neon signs | +| `handwritten`| `"Storytime"` | `cursive` | `font-handwritten` | Signature notes, quotes, bespoke editorial accents | +| `mono` | *System Monospace* | `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace` | `font-mono` | IDs, UUIDs, coupon codes, hashes, tokens, timestamps, JSON telemetry, data tables | + +### Global Typography Inheritance +- `html` and `body` automatically inherit `var(--font-primary)`. +- Default Tailwind `font-sans` is mapped to `[font.fontFamily.primary]`. +- Default Tailwind `font-mono` is mapped to `[font.fontFamily.mono]`. +- **To swap fonts globally**: Update [`src/design/styles/fonts.css`](file:///Users/rex/Desktop/Dev/rexone/rexone-web/src/design/styles/fonts.css) and [`src/design/elements/font.ts`](file:///Users/rex/Desktop/Dev/rexone/rexone-web/src/design/elements/font.ts). The entire application updates automatically without touching any UI components. + +--- + +## 4. Color & Theme Architecture + +All colors are centralized in [`src/design/elements/colors.ts`](file:///Users/rex/Desktop/Dev/rexone/rexone-web/src/design/elements/colors.ts). + +### Brand & Accent Colors +- **Rex9 Scarlet Red (Primary)**: `#FF2238` +- **Primary Light**: `#FF5263` +- **Primary Dark**: `#CC1125` +- **Glow Outer Core**: `#5C0916` (RGB: `92, 9, 22`) +- **Glow White Core**: `#FFF2F4` + +### Theme Contrast Matrix + +| Semantic Token | Day Mode (`day`) | Night Mode (`night`) | Description | +| :--- | :--- | :--- | :--- | +| `primary` | `#FF2238` | `#FF2238` | Iconic Rex9 scarlet red brand identity | +| `primary-content` | `#FFFFFF` | `#FFFFFF` | Crisp high-contrast white text on red buttons | +| `base-100` | `#FFFFFF` | `#160B11` | Main page background (pure white vs deep wine noir) | +| `base-200` | `#FFFFFF` | `#1C0D16` | Surface containers, card backgrounds | +| `base-300` | `#F8F8FA` | `#2A1420` | Secondary cards, subtle panels, dividers, borders | +| `base-content` | `#000000` | `#FFFFFF` | Main body text (**pure black in day, pure white in night**) | +| `secondary` | `#FF4D2E` | `#FF5263` | Secondary action highlights | +| `accent` | `#FF0D2D` | `#FF2238` | Tertiary accents and dynamic indicators | +| `neutral` | `#F8F8FA` | `#2A1420` | Neutral surfaces | +| `neutral-content` | `#000000` | `#FFFFFF` | High-contrast text on neutral surfaces | + +### Semantic Status Indicators +All semantic indicators enforce `#FFFFFF` foreground text (`*-content`) for AAA contrast: +- **Info**: `#38BDF8` (`text-info`, `bg-info/10`, `border-info/30`) +- **Success**: `#10B981` (`text-success`, `bg-success/10`, `border-success/30`) +- **Warning**: `#F59E0B` (`text-warning`, `bg-warning/10`, `border-warning/30`) +- **Error**: `#EF4444` (`text-error`, `bg-error/10`, `border-error/30`) + +### Glassmorphism & Neon Effects +Centralized in `colors.glass` and `colors.effects`: +- **Glass Nav**: `rgba(22, 7, 13, 0.75)` +- **Glass Card**: `rgba(35, 12, 20, 0.38)` with `border: rgba(255, 34, 56, 0.22)` +- **Glass Card Hover**: `rgba(50, 16, 28, 0.55)` with `border: rgba(255, 34, 56, 0.55)` +- **Neon Sign**: Multi-layer box/text shadows radiating from `var(--color-glow-white)` through `var(--color-primary)` to `var(--color-glow-outer)`. + +--- + +## 5. Clean Tailwind Configuration (`tailwind.config.js`) + +In accordance with Dante's principle of zero redundancy, `theme.extend.colors` only declares custom Rex9 extensions that DaisyUI does not natively supply: + +```javascript +// tailwind.config.js +export default { + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + plugins: [ + daisyui, + plugin(({ addBase }) => { + addBase({ + ':root': { + '--font-primary': font.fontFamily.primary, + '--font-display': font.fontFamily.display, + '--font-handwritten': font.fontFamily.handwritten, + '--font-mono': font.fontFamily.mono, + }, + ':root, [data-theme="day"]': { + '--color-primary': colors.day.primary, + '--color-primary-rgb': colors.day.primaryRgb, + '--color-primary-light': colors.day.primaryLight, + '--color-primary-dark': colors.day.primaryDark, + '--color-glow-white': colors.day.glowWhite, + '--color-glow-outer': colors.day.glowOuter, + '--color-glow-outer-rgb': colors.day.glowOuterRgb, + '--color-primary-content': colors.text.night.primary, + '--color-secondary': colors.secondary, + '--color-secondary-content': colors.text.night.primary, + '--color-accent': colors.accent, + '--color-accent-content': colors.text.night.primary, + '--color-base-100': colors.day.background, + '--color-base-200': colors.day.surface, + '--color-base-300': colors.day.card, + '--color-base-content': colors.text.day.primary, + '--color-base-content-rgb': colors.text.day.primaryRgb, + '--color-neutral': colors.day.card, + '--color-neutral-content': colors.text.day.primary, + '--color-border': colors.day.border, + '--color-divider': colors.day.divider, + '--color-info': colors.semantic.info, + '--color-info-content': colors.text.night.primary, + '--color-success': colors.semantic.success, + '--color-success-content': colors.text.night.primary, + '--color-warning': colors.semantic.warning, + '--color-warning-content': colors.text.night.primary, + '--color-error': colors.semantic.error, + '--color-error-content': colors.text.night.primary, + }, + '[data-theme="night"]': { + '--color-primary': colors.night.primary, + '--color-primary-rgb': colors.night.primaryRgb, + '--color-primary-light': colors.night.primaryLight, + '--color-primary-dark': colors.night.primaryDark, + '--color-glow-white': colors.night.glowWhite, + '--color-glow-outer': colors.night.glowOuter, + '--color-glow-outer-rgb': colors.night.glowOuterRgb, + '--color-primary-content': colors.text.night.primary, + '--color-secondary': colors.night.primaryLight, + '--color-secondary-content': colors.text.night.primary, + '--color-accent': colors.night.primary, + '--color-accent-content': colors.text.night.primary, + '--color-base-100': colors.night.background, + '--color-base-200': colors.night.surface, + '--color-base-300': colors.night.card, + '--color-base-content': colors.text.night.primary, + '--color-base-content-rgb': colors.text.night.primaryRgb, + '--color-neutral': colors.night.card, + '--color-neutral-content': colors.text.night.primary, + '--color-border': colors.night.border, + '--color-divider': colors.night.divider, + '--color-info': colors.semantic.info, + '--color-info-content': colors.text.night.primary, + '--color-success': colors.semantic.success, + '--color-success-content': colors.text.night.primary, + '--color-warning': colors.semantic.warning, + '--color-warning-content': colors.text.night.primary, + '--color-error': colors.semantic.error, + '--color-error-content': colors.text.night.primary, + }, + }); + }), + ], + theme: { + extend: { + colors: { + primary: { + DEFAULT: 'var(--color-primary)', + light: 'var(--color-primary-light)', + dark: 'var(--color-primary-dark)', + content: 'var(--color-primary-content)', + }, + glow: { + white: 'var(--color-glow-white)', + outer: 'var(--color-glow-outer)', + }, + glass: colors.glass, + }, + fontFamily: { + ...font.fontFamily, + sans: [font.fontFamily.primary], + mono: [font.fontFamily.mono], + }, + }, + }, + daisyui: { + themes: ["day --default", "night --prefersdark"], + base: true, + styled: true, + utils: true, + }, +}; +``` + +--- + +## 6. How Theme Switching Works + +The theme system operates with zero latency and zero React re-render cascades: + +``` +[User clicks ThemeToggle.tsx] + │ + ▼ +atoms.themeAtom updated in Jotai + │ + ▼ +useTheme() hook executes: +document.documentElement.setAttribute('data-theme', theme) + │ + ▼ +Browser activates matching CSS variables on :root: +--color-base-100, --color-base-content, --color-primary, etc. + │ + ▼ +Entire DOM updates styles natively in CSS in < 1ms +``` + +--- + +## 7. Component Library Catalog (`src/design/components/`) + +### Action Controls +- **`Button`**: Primary (Rex9 red), Secondary, Tertiary, Danger, Glass. Natively applies `text-primary-content` on solid red buttons. +- **`AdminTableActions`**: Compact square icon buttons for high-density administrative tables. + +### Form Inputs +- **`TextInput`**: Underline-glass or boxed-bordered variant with theme-adaptive label and error states. +- **`TextArea`**: Auto-resizing or fixed multi-line input with interactive `hover:border-primary`. +- **`PasswordInput`**: High-security 6-digit segmented passcode control with auto-advance, backspace handling, paste distribution, masked characters, and `font-mono`/`font-primary`. +- **`SearchInput`**: Debounced search field with search-by metadata tooltips and clear button. +- **`Dropdown`**: Single and multi-select select input with optgroup support. +- **`Checkbox` & `Radio`**: High-contrast styled checkboxes and radios with `checked:bg-primary` and `hover:border-primary`. +- **`Toggle`**: Accessible toggle switch with smooth slide animation. +- **`FileInput`**: Stylized file upload with file type constraints and trigger buttons. + +### Overlays & Dialogs +- **`Dialog`**: Accessible modal with focus containment, ESC key dismissal, click-outside backdrop (`.modal-backdrop`), and smooth transitions. +- **`ConfirmDialog`**: Standalone destructive / confirmation action prompt. +- **`LoadingOverlay`**: Non-blocking global spinner with subtle dimming scrim (`bg-black/20`). + +### Detail & Presentation Primitives +- **`DetailHeader`**: Resource detail view header with title, back navigation, status badge, and action slots. +- **`DetailSection`**: Grouped card section with optional accent line and `hover:border-primary`. +- **`DetailGrid`**: Responsive 1-to-4 column grid layout for key-value resource attributes. +- **`DetailField`**: Individual label/value pair supporting text, badges, code (`font-mono`), copy buttons, and links. +- **`CopyButton`**: Clipboard copy button with tactile visual feedback. + +--- + +## 8. Constitutional Rules for Developers + +When contributing code to `rexone-web`, adhere strictly to these rules: + +1. **NO Hardcoded Hex Codes**: + - ❌ `
` + - ✅ `
` or `
` + +2. **NO Hardcoded `text-white` or `text-black`**: + - ❌ `` + - ❌ `` (on default background) + - ✅ `` + - ✅ `` (on `bg-primary` buttons/badges) + +3. **NO Redundant Tailwind Color Definitions**: + - Do not add `base`, `neutral`, `secondary`, or `accent` to `theme.extend.colors`. DaisyUI provides them natively. + +4. **Monospace for Code, Tokens & Telemetry**: + - Any UUID, hash, coupon code, date/time timestamp, or technical token MUST use `font-mono`. + +5. **Interactive Borders Must Use `hover:border-primary`**: + - All interactive cards, tiles, and form inputs must use `hover:border-primary` to produce the signature Rex9 red glow on interaction. diff --git a/scripts/check_architecture.mjs b/scripts/check_architecture.mjs index 098f88f5..1a92af0e 100755 --- a/scripts/check_architecture.mjs +++ b/scripts/check_architecture.mjs @@ -34,9 +34,12 @@ for (const file of sourceFiles) { const name = relative(file); const content = fs.readFileSync(file, "utf8"); - if (/\b(?:localStorage|sessionStorage)\s*\./.test(content) && !exceptions.storage.has(name)) { + if (/\b(?:localStorage|sessionStorage)\s*(?:\.|\[)/.test(content) && !exceptions.storage.has(name)) { failures.push(`Direct browser storage access: ${name}`); } + if (/\bdocument\.cookie\b/.test(content) && !exceptions.storage.has(name)) { + failures.push(`Direct document.cookie access: ${name}`); + } if (/\bfetch\s*\(/.test(content) && !name.endsWith(".service.ts") && !exceptions.fetch.has(name)) { failures.push(`Direct fetch outside a service: ${name}`); } diff --git a/scripts/check_locales.mjs b/scripts/check_locales.mjs index b821c609..7a12bd1c 100755 --- a/scripts/check_locales.mjs +++ b/scripts/check_locales.mjs @@ -7,19 +7,39 @@ import ts from "typescript"; const root = process.cwd(); const sourceRoot = path.join(root, "src"); +// ANSI color helpers +const colors = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + cyan: "\x1b[36m", +}; + +const args = process.argv.slice(2); +const showUnused = args.includes("--unused") || args.includes("-u") || args.includes("--verbose"); +const showHardcoded = args.includes("--hardcoded") || args.includes("-h") || args.includes("--verbose"); + const walk = (directory) => fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const target = path.join(directory, entry.name); return entry.isDirectory() ? walk(target) : [target]; }); + const sourceFiles = walk(sourceRoot).filter( (file) => /\.(ts|tsx)$/.test(file) && !/\.(test|spec)\.(ts|tsx)$/.test(file), ); + const developerUiFiles = new Set([ path.join(sourceRoot, "design/examples.tsx"), path.join(sourceRoot, "modules/log/components/DevTestButtons.tsx"), ]); + const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/"); + const flatten = (value, prefix = "") => Object.entries(value).flatMap(([key, child]) => { const next = prefix ? `${prefix}.${key}` : key; @@ -28,16 +48,46 @@ const flatten = (value, prefix = "") => : [[next, child]]; }); -const en = JSON.parse(fs.readFileSync(path.join(sourceRoot, "locales/en.json"))); -const my = JSON.parse(fs.readFileSync(path.join(sourceRoot, "locales/my.json"))); +console.log(`${colors.bold}${colors.cyan}🌐 RexOne Locales Diagnostic & Integrity Checker${colors.reset}\n`); + +// --------------------------------------------------------------------------- +// 1. Language Parity: en.json <-> my.json +// --------------------------------------------------------------------------- +const enPath = path.join(sourceRoot, "locales/en.json"); +const myPath = path.join(sourceRoot, "locales/my.json"); + +if (!fs.existsSync(enPath) || !fs.existsSync(myPath)) { + console.error(`${colors.red}❌ Critical: en.json or my.json is missing from src/locales!${colors.reset}`); + process.exit(1); +} + +const en = JSON.parse(fs.readFileSync(enPath, "utf8")); +const my = JSON.parse(fs.readFileSync(myPath, "utf8")); + const enKeys = new Set(flatten(en).map(([key]) => key)); const myKeys = new Set(flatten(my).map(([key]) => key)); -const errors = []; -for (const key of enKeys) if (!myKeys.has(key)) errors.push(`missing-my:${key}`); -for (const key of myKeys) if (!enKeys.has(key)) errors.push(`missing-en:${key}`); +const parityErrors = []; +for (const key of enKeys) { + if (!myKeys.has(key)) { + parityErrors.push({ type: "missing-in-my", key, message: `Key in en.json missing in my.json: "${key}"` }); + } +} +for (const key of myKeys) { + if (!enKeys.has(key)) { + parityErrors.push({ type: "missing-in-en", key, message: `Key in my.json missing in en.json: "${key}"` }); + } +} +// --------------------------------------------------------------------------- +// 2. AppLocales TypeScript Constants <-> JSON Parity +// --------------------------------------------------------------------------- const constantsPath = path.join(sourceRoot, "locales/app_locales.ts"); +if (!fs.existsSync(constantsPath)) { + console.error(`${colors.red}❌ Critical: src/locales/app_locales.ts is missing!${colors.reset}`); + process.exit(1); +} + const constantsSource = fs.readFileSync(constantsPath, "utf8"); const constantsFile = ts.createSourceFile( constantsPath, @@ -46,44 +96,77 @@ const constantsFile = ts.createSourceFile( true, ts.ScriptKind.TS, ); -const constants = new Map(); + +const constants = new Map(); // "AppLocales.Auth.Shared.EmailLabel" -> "auth.shared.email_label" const propertyName = (node) => ts.isIdentifier(node) || ts.isStringLiteral(node) ? node.text : null; +const unwrap = (node) => (ts.isAsExpression(node) ? node.expression : node); + const collectConstants = (object, prefix = []) => { for (const property of object.properties) { if (!ts.isPropertyAssignment(property)) continue; const name = propertyName(property.name); if (!name) continue; const next = [...prefix, name]; - if (ts.isObjectLiteralExpression(property.initializer)) { - collectConstants(property.initializer, next); - } else if (ts.isStringLiteral(property.initializer)) { - constants.set(`AppLocales.${next.join(".")}`, property.initializer.text); + const init = unwrap(property.initializer); + if (ts.isObjectLiteralExpression(init)) { + collectConstants(init, next); + } else if (ts.isStringLiteral(init)) { + constants.set(`AppLocales.${next.join(".")}`, init.text); } } }; + constantsFile.forEachChild((node) => { if (!ts.isVariableStatement(node)) return; for (const declaration of node.declarationList.declarations) { if ( ts.isIdentifier(declaration.name) && declaration.name.text === "AppLocales" && - declaration.initializer && - ts.isObjectLiteralExpression(declaration.initializer) + declaration.initializer ) { - collectConstants(declaration.initializer); + const init = unwrap(declaration.initializer); + if (ts.isObjectLiteralExpression(init)) { + collectConstants(init); + } } } }); +const constantErrors = []; +const mappedKeys = new Set(constants.values()); + +// Check if AppLocales constants point to non-existent JSON keys for (const [constant, key] of constants) { - if (!enKeys.has(key)) errors.push(`unknown-key:${constant}:${key}`); + if (!enKeys.has(key)) { + constantErrors.push({ + type: "unknown-constant-target", + constant, + key, + message: `Constant ${constant} targets "${key}" which does not exist in en.json`, + }); + } } +// Check if en.json keys are missing from AppLocales +for (const key of enKeys) { + if (!mappedKeys.has(key)) { + constantErrors.push({ + type: "missing-in-app-locales", + key, + message: `Key "${key}" exists in en.json but is missing from AppLocales in app_locales.ts`, + }); + } +} + +// --------------------------------------------------------------------------- +// 3. Scan Source Code for Usages, Loose Fallbacks, Raw Keys, and Hardcoded Text +// --------------------------------------------------------------------------- const usedConstants = new Set(); -const hardcoded = new Set(); -const rawKeys = new Set(); +const looseFallbacks = []; +const rawKeys = []; +const hardcoded = []; const visibleAttributes = new Set([ "aria-label", "label", @@ -95,12 +178,11 @@ for (const file of sourceFiles) { if (file === constantsPath) continue; const content = fs.readFileSync(file, "utf8"); const name = relative(file); + + // Fast scan for referenced AppLocales constants for (const match of content.matchAll(/AppLocales(?:\.[A-Za-z_$][\w$]*)+/g)) { usedConstants.add(match[0]); } - for (const match of content.matchAll(/\b(?:t|translate)\s*\(\s*["']([a-z][a-z0-9_.-]+)["']/g)) { - rawKeys.add(`${name}:${match[1]}`); - } if (developerUiFiles.has(file)) continue; @@ -111,11 +193,83 @@ for (const file of sourceFiles) { true, file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, ); + const inspect = (node) => { + // Detect t() or translate() calls + if (ts.isCallExpression(node)) { + const expr = node.expression; + const fnName = ts.isIdentifier(expr) + ? expr.text + : ts.isPropertyAccessExpression(expr) + ? expr.name.text + : null; + + if (fnName === "t" || fnName === "translate") { + const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source)); + const lineNum = line + 1; + const colNum = character + 1; + const loc = `${name}:${lineNum}:${colNum}`; + + // 1. Raw string keys: t("some.key") + const firstArg = node.arguments[0]; + if (firstArg && ts.isStringLiteral(firstArg)) { + rawKeys.push({ + loc, + key: firstArg.text, + snippet: node.getText(source), + }); + } + + // 2. Loose fallback: second argument string literal t(key, "fallback") + if (node.arguments.length > 1) { + const secondArg = node.arguments[1]; + if (ts.isStringLiteral(secondArg)) { + looseFallbacks.push({ + loc, + type: "argument-fallback", + snippet: node.getText(source), + detail: `Second argument fallback string: "${secondArg.text}"`, + }); + } else if (ts.isObjectLiteralExpression(secondArg)) { + // Check defaultValue in options object + for (const prop of secondArg.properties) { + if (ts.isPropertyAssignment(prop) && propertyName(prop.name) === "defaultValue") { + looseFallbacks.push({ + loc, + type: "default-value-fallback", + snippet: node.getText(source), + detail: `defaultValue option fallback: ${prop.initializer.getText(source)}`, + }); + } + } + } + } + + // 3. Loose fallback: binary operator t(key) || "fallback" or ?? "fallback" + const parent = node.parent; + if ( + parent && + ts.isBinaryExpression(parent) && + (parent.operatorToken.kind === ts.SyntaxKind.BarBarToken || + parent.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) && + parent.left === node + ) { + looseFallbacks.push({ + loc, + type: "binary-fallback", + snippet: parent.getText(source), + detail: `Binary fallback operator (${parent.operatorToken.getText(source)}): ${parent.right.getText(source)}`, + }); + } + } + } + + // Detect hardcoded visible text in JSX if (ts.isJsxText(node)) { const text = node.text.replace(/\s+/g, " ").trim(); if (/[A-Za-z]{2}/.test(text)) { - hardcoded.add(`${name}:${text}`); + const { line } = source.getLineAndCharacterOfPosition(node.getStart(source)); + hardcoded.push({ loc: `${name}:${line + 1}`, text }); } } if ( @@ -125,39 +279,110 @@ for (const file of sourceFiles) { ts.isStringLiteral(node.initializer) && /[A-Za-z]{2}/.test(node.initializer.text) ) { - hardcoded.add(`${name}:${node.name.getText(source)}=${node.initializer.text}`); + const { line } = source.getLineAndCharacterOfPosition(node.getStart(source)); + hardcoded.push({ + loc: `${name}:${line + 1}`, + text: `${node.name.getText(source)}="${node.initializer.text}"`, + }); } + ts.forEachChild(node, inspect); }; inspect(source); } -const unused = new Set( - [...constants.keys()].filter((constant) => !usedConstants.has(constant)), +const unusedConstants = [...constants.keys()].filter( + (constant) => !usedConstants.has(constant), ); -const findings = { - hardcoded: [...hardcoded].sort(), - rawKeys: [...rawKeys].sort(), - unused: [...unused].sort(), -}; -for (const rawKey of findings.rawKeys) errors.push(`raw-key:${rawKey}`); +// --------------------------------------------------------------------------- +// 4. Formatted Diagnostic Reporting +// --------------------------------------------------------------------------- -console.log( - `Locale report: ${enKeys.size} keys, ${findings.unused.length} unused constants, ` + - `${findings.rawKeys.length} raw keys, ${findings.hardcoded.length} hardcoded strings.`, -); -if (errors.length) { - console.error(`Locale checks failed (${errors.length}):`); - for (const error of errors) console.error(`- ${error}`); - process.exit(1); +let hasFailures = false; + +// Section 1: Language Parity +console.log(`${colors.bold}${colors.blue}━━━ [1/4] Language Parity (${enKeys.size} keys en.json <-> ${myKeys.size} keys my.json) ━━━${colors.reset}`); +if (parityErrors.length > 0) { + hasFailures = true; + console.log(`${colors.red}${colors.bold}FAILED: ${parityErrors.length} parity mismatch(es) found:${colors.reset}`); + for (const err of parityErrors) { + console.log(` ${colors.red}✗${colors.reset} ${err.message}`); + } +} else { + console.log(` ${colors.green}✓${colors.reset} 100% key parity between en.json and my.json (${enKeys.size} keys each).`); } -if (findings.unused.length) { - console.warn("Unused AppLocales constants:"); - for (const finding of findings.unused) console.warn(`- ${finding}`); +console.log(); + +// Section 2: AppLocales Constant Parity +console.log(`${colors.bold}${colors.blue}━━━ [2/4] AppLocales Constant Mapping (${constants.size} constants) ━━━${colors.reset}`); +if (constantErrors.length > 0) { + hasFailures = true; + console.log(`${colors.red}${colors.bold}FAILED: ${constantErrors.length} mapping issue(s) found:${colors.reset}`); + for (const err of constantErrors) { + console.log(` ${colors.red}✗${colors.reset} ${err.message}`); + } +} else { + console.log(` ${colors.green}✓${colors.reset} 100% 1-to-1 sync between AppLocales constants and en.json keys.`); } -if (findings.hardcoded.length) { - console.warn("User-visible strings to localize:"); - for (const finding of findings.hardcoded) console.warn(`- ${finding}`); +console.log(); + +// Section 3: Loose Fallbacks & Raw Translation Keys +console.log(`${colors.bold}${colors.blue}━━━ [3/4] Translation Call Integrity & Loose Fallbacks ━━━${colors.reset}`); +if (rawKeys.length > 0) { + hasFailures = true; + console.log(`${colors.red}${colors.bold}FAILED: ${rawKeys.length} raw string key(s) detected (must use AppLocales.*):${colors.reset}`); + for (const r of rawKeys) { + console.log(` ${colors.red}✗${colors.reset} ${r.loc} -> t("${r.key}")`); + } +} else { + console.log(` ${colors.green}✓${colors.reset} Zero raw translation strings (all calls use canonical AppLocales).`); +} + +if (looseFallbacks.length > 0) { + hasFailures = true; + console.log(`${colors.red}${colors.bold}FAILED: ${looseFallbacks.length} loose fallback(s) detected:${colors.reset}`); + for (const lf of looseFallbacks) { + console.log(` ${colors.red}✗${colors.reset} ${colors.bold}${lf.loc}${colors.reset}`); + console.log(` ${colors.yellow}${lf.detail}${colors.reset}`); + console.log(` ${colors.dim}${lf.snippet}${colors.reset}`); + } +} else { + console.log(` ${colors.green}✓${colors.reset} Zero loose fallbacks (no ||, ??, second argument, or defaultValue masking translations).`); +} +console.log(); + +// Section 4: Diagnostics & Warnings (Unused Constants, Unlocalized UI Text) +console.log(`${colors.bold}${colors.blue}━━━ [4/4] Usage Diagnostics & Visibility ━━━${colors.reset}`); +console.log(` • AppLocales constants: ${colors.bold}${constants.size}${colors.reset} total, ${colors.bold}${usedConstants.size}${colors.reset} directly referenced, ${colors.yellow}${unusedConstants.length}${colors.reset} unreferenced directly.`); + +if (showUnused && unusedConstants.length > 0) { + console.log(`\n ${colors.yellow}Unreferenced AppLocales Constants (${unusedConstants.length}):${colors.reset}`); + for (const c of unusedConstants) { + console.log(` - ${c}`); + } +} else if (unusedConstants.length > 0) { + console.log(` ${colors.dim}(Run with --unused to list all unreferenced constants)${colors.reset}`); +} + +console.log(` • Unlocalized JSX text: ${colors.yellow}${hardcoded.length}${colors.reset} elements detected across UI files.`); +if (showHardcoded && hardcoded.length > 0) { + console.log(`\n ${colors.yellow}Unlocalized JSX Strings (${hardcoded.length}):${colors.reset}`); + for (const h of hardcoded) { + console.log(` - ${h.loc}: ${h.text}`); + } +} else if (hardcoded.length > 0) { + console.log(` ${colors.dim}(Run with --hardcoded to list all unlocalized strings)${colors.reset}`); +} +console.log(); + +// --------------------------------------------------------------------------- +// 5. Final Result Banner & Exit Code +// --------------------------------------------------------------------------- +if (hasFailures) { + console.error(`${colors.bold}${colors.red}❌ Locale integrity check FAILED. Please resolve the errors above.${colors.reset}\n`); + process.exit(1); +} else { + console.log(`${colors.bold}${colors.green}✅ All locale integrity checks PASSED successfully!${colors.reset}\n`); + process.exit(0); } -console.log("Locale checks passed."); diff --git a/scripts/dev.sh b/scripts/dev.sh index 92ac114b..f15cf079 100644 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -1,10 +1,19 @@ -#!/bin/bash +#!/usr/bin/env bash +# ============================================================================== +# RexOne Web — Development Container Runner +# ============================================================================== -# Check if the containers are already running -if [ "$(docker-compose ps -q)" ]; then +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." + +COMPOSE_FILE="docker-compose.dev.yaml" + +if [ -n "$(docker compose -f "$COMPOSE_FILE" ps -q)" ]; then echo "Containers are already running. Starting without rebuild..." - docker-compose -f docker-compose.dev.yaml up + docker compose -f "$COMPOSE_FILE" up "$@" else echo "Containers are not running. Building and starting..." - docker-compose -f docker-compose.dev.yaml up --build + docker compose -f "$COMPOSE_FILE" up --build "$@" fi diff --git a/src/AppConfig.tsx b/src/AppConfig.tsx index 2052627d..650098a3 100644 --- a/src/AppConfig.tsx +++ b/src/AppConfig.tsx @@ -1,4 +1,5 @@ class AppConfig { + static readonly IS_DEV = Boolean(import.meta.env.DEV); static readonly NODE_ENV = import.meta.env.NODE_ENV; static readonly APP_NAME = import.meta.env.VITE_REACT_APP_NAME || "rexone.me"; static readonly GOOGLE_CLIENT_ID = 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/assets/index.ts b/src/assets/index.ts index 717de037..b52f1eaf 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -56,6 +56,7 @@ import { KeyIcon, UserGroupIcon, ArchiveBoxIcon, + DocumentDuplicateIcon, ChatBubbleBottomCenterTextIcon, ChevronDownIcon, ChevronUpIcon, @@ -226,6 +227,7 @@ export const iconsLib = { play: PlayIcon, photo: PhotoIcon, tag: TagIcon, + copy: DocumentDuplicateIcon, devicePhoneMobile: DevicePhoneMobileIcon, externalLink: ArrowTopRightOnSquareIcon, } as const; diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 0cd7598f..ded62758 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -17,6 +17,7 @@ import { isTokenExpired } from "../helpers"; import { useLoading } from "./LoadingContext"; import UserController from "../modules/user/user.controller"; import { AnalyticsService } from "../services"; +import AppConfig from "../AppConfig"; interface IAuthContextType { isAuthenticated: boolean; @@ -67,7 +68,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ if (!token || !isTokenExpired(token)) return; const timeoutId = window.setTimeout(() => { - console.log("🔐 Token expired, signing out..."); + if (AppConfig.IS_DEV) { + console.log("🔐 Token expired, signing out..."); + } signout(); }, 0); @@ -80,7 +83,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ const interval = setInterval(() => { if (isTokenExpired(token)) { - console.log("Token expired during session, signing out..."); + if (AppConfig.IS_DEV) { + console.log("Token expired during session, signing out..."); + } signout(); } }, 3600000); diff --git a/src/design/components/button/Button.tsx b/src/design/components/button/Button.tsx index 984046a3..ffc6edce 100644 --- a/src/design/components/button/Button.tsx +++ b/src/design/components/button/Button.tsx @@ -38,11 +38,11 @@ export const Button: React.FC = ({ const variants: Record = { // Primary: Normal state has border with coral text -> Hover turns solid coral with bright neon box shadow (Pic 1) [ButtonVariants.PRIMARY]: - "border border-glass-border bg-glass-tag-bg text-primary font-bold tracking-wider hover:bg-primary hover:text-white hover:border-primary hover:shadow-neon-lg active:scale-95 transition-all duration-300", + "border border-glass-border bg-glass-tag-bg text-primary font-bold tracking-wider hover:bg-primary hover:text-primary-content hover:border-primary hover:shadow-neon-lg active:scale-95 transition-all duration-300", // Neon: Normal state has frosted glass + running animated laser borders + glowing text (Pic 2) -> Hover turns solid coral with large neon aura (Pic 3) [ButtonVariants.NEON]: - "relative overflow-hidden border border-glass-border bg-glass-card backdrop-blur-md text-glow-white font-bold tracking-wider [text-shadow:0_0_6px_var(--color-glow-white),0_0_12px_var(--color-primary),0_0_20px_var(--color-primary-dark)] hover:bg-primary hover:text-white hover:border-primary hover:shadow-neon-lg hover:[text-shadow:0_0_8px_#fff2f4,0_0_16px_var(--color-primary)] active:scale-95 transition-all duration-400", + "relative overflow-hidden border border-glass-border bg-glass-card backdrop-blur-md text-glow-white font-bold tracking-wider [text-shadow:0_0_6px_var(--color-glow-white),0_0_12px_var(--color-primary),0_0_20px_var(--color-primary-dark)] hover:bg-primary hover:text-primary-content hover:border-primary hover:shadow-neon-lg hover:[text-shadow:0_0_8px_var(--color-glow-white),0_0_16px_var(--color-primary)] active:scale-95 transition-all duration-400", [ButtonVariants.SECONDARY]: "border-2 border-primary text-primary hover:bg-primary/10 active:bg-primary/20 transition-all duration-300", @@ -73,10 +73,10 @@ export const Button: React.FC = ({ const neonBorders = isNeon && ( <> - - - - + + + + ); @@ -94,7 +94,9 @@ export const Button: React.FC = ({ target={target} rel={rel || (isBlank ? "noopener noreferrer" : undefined)} className={buttonClasses} - onClick={props.onClick as unknown as React.MouseEventHandler} + onClick={ + props.onClick as unknown as React.MouseEventHandler + } > {neonBorders} {innerContent} diff --git a/src/design/components/common/Badge.tsx b/src/design/components/common/Badge.tsx index d1814455..75b84a5d 100644 --- a/src/design/components/common/Badge.tsx +++ b/src/design/components/common/Badge.tsx @@ -31,9 +31,9 @@ export const Badge: React.FC = ({ }) => { const variants: Record = { [BadgeVariants.DEFAULT]: - "bg-base-200 text-base-content border border-border", + "bg-base-200 text-base-content border border-base-300", [BadgeVariants.NEON]: - "border border-glass-tag bg-glass-tag-bg text-white font-semibold font-primary transition-all duration-200 hover:bg-glass-tag-bg-hover hover:border-primary hover:text-white hover:shadow-neon", + "border border-glass-tag bg-glass-tag-bg text-base-content font-semibold font-primary transition-all duration-200 hover:bg-glass-tag-bg-hover hover:border-primary hover:text-primary-content hover:shadow-neon", [BadgeVariants.PRIMARY]: "bg-primary/10 text-primary border border-primary/30", [BadgeVariants.SECONDARY]: diff --git a/src/design/components/common/HeadNavbar.tsx b/src/design/components/common/HeadNavbar.tsx index 7af1cbab..b21cd66a 100644 --- a/src/design/components/common/HeadNavbar.tsx +++ b/src/design/components/common/HeadNavbar.tsx @@ -55,7 +55,7 @@ export const HeadNavbarBrand: React.FC = ({ asset={icons.logo} className={cn( "h-9 w-9 shrink-0 select-none transition-transform duration-300 hover:scale-105", - isAdmin && "drop-shadow-[0_0_10px_rgba(225,29,72,0.5)]", + isAdmin && "drop-shadow-[0_0_10px_rgba(var(--color-primary-rgb),0.5)]", )} /> {showText && ( diff --git a/src/design/components/common/NotificationCenter.tsx b/src/design/components/common/NotificationCenter.tsx index dbd1d8c5..27552884 100644 --- a/src/design/components/common/NotificationCenter.tsx +++ b/src/design/components/common/NotificationCenter.tsx @@ -341,7 +341,7 @@ export const NotificationCenter: React.FC = ({ {/* Unread Count Badge */} {unreadCount > 0 && ( - + {unreadCount > 99 ? "99+" : unreadCount} )} diff --git a/src/design/components/detail/CopyButton.tsx b/src/design/components/detail/CopyButton.tsx new file mode 100644 index 00000000..bd5026a5 --- /dev/null +++ b/src/design/components/detail/CopyButton.tsx @@ -0,0 +1,64 @@ +// src/design/components/detail/CopyButton.tsx +import React, { useState } from "react"; +import { iconsLib } from "../../../assets"; +import { cn } from "../../helpers"; + +export interface ICopyButtonProps { + text: string; + className?: string; + iconClassName?: string; + title?: string; + copiedTitle?: string; +} + +export const CopyButton: React.FC = ({ + text, + className, + iconClassName, + title = "Copy to clipboard", + copiedTitle = "Copied!", +}) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback for older environments + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( + + ); +}; diff --git a/src/design/components/detail/DetailField.tsx b/src/design/components/detail/DetailField.tsx new file mode 100644 index 00000000..f3fbe3e2 --- /dev/null +++ b/src/design/components/detail/DetailField.tsx @@ -0,0 +1,84 @@ +// src/design/components/detail/DetailField.tsx +import React from "react"; +import { cn } from "../../helpers"; +import { CopyButton } from "./CopyButton"; + +export interface IDetailFieldProps { + label: React.ReactNode; + value?: React.ReactNode; + className?: string; + valueClassName?: string; + copyable?: boolean; + copyValue?: string; + mono?: boolean; + icon?: React.ComponentType<{ className?: string }>; + badge?: React.ReactNode; + helperText?: React.ReactNode; + fullWidth?: boolean; +} + +export const DetailField: React.FC = ({ + label, + value, + className, + valueClassName, + copyable, + copyValue, + mono, + icon: Icon, + badge, + helperText, + fullWidth, +}) => { + const isEmpty = + value === undefined || + value === null || + (typeof value === "string" && value.trim() === ""); + + const stringValueToCopy = + copyValue ?? + (typeof value === "string" || typeof value === "number" + ? String(value) + : undefined); + + return ( +
+
+ + {Icon && } + {label} + +
+ {badge} + {copyable && stringValueToCopy && ( + + )} +
+
+ +
+ {isEmpty ? "—" : value} +
+ + {helperText && ( +
+ {helperText} +
+ )} +
+ ); +}; diff --git a/src/design/components/detail/DetailGrid.tsx b/src/design/components/detail/DetailGrid.tsx new file mode 100644 index 00000000..a303ec27 --- /dev/null +++ b/src/design/components/detail/DetailGrid.tsx @@ -0,0 +1,34 @@ +// src/design/components/detail/DetailGrid.tsx +import React from "react"; +import { cn } from "../../helpers"; + +export interface IDetailGridProps { + children: React.ReactNode; + className?: string; + columns?: 1 | 2 | 3 | 4; +} + +export const DetailGrid: React.FC = ({ + children, + className, + columns = 3, +}) => { + const columnClasses = { + 1: "grid-cols-1", + 2: "grid-cols-1 sm:grid-cols-2", + 3: "grid-cols-1 sm:grid-cols-2 xl:grid-cols-3", + 4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4", + }[columns]; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/design/components/detail/DetailHeader.tsx b/src/design/components/detail/DetailHeader.tsx new file mode 100644 index 00000000..8023c5e9 --- /dev/null +++ b/src/design/components/detail/DetailHeader.tsx @@ -0,0 +1,156 @@ +// src/design/components/detail/DetailHeader.tsx +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { iconsLib } from "../../../assets"; +import { Button } from "../button"; +import { ButtonSizes, ButtonVariants } from "../../constants"; +import { Breadcrumbs, type IBreadcrumbItem } from "../common/Breadcrumbs"; +import { DateTime, DateTimeFormats } from "../common/DateTime"; +import { CopyButton } from "./CopyButton"; +import { cn } from "../../helpers"; + +export interface IDetailHeaderProps { + breadcrumbs: IBreadcrumbItem[]; + title: React.ReactNode; + description?: React.ReactNode; + backTo: string; + backLabel?: React.ReactNode; + action?: React.ReactNode; + icon?: React.ComponentType<{ className?: string }>; + badge?: React.ReactNode; + statusBadge?: React.ReactNode; + entityId?: string; + timestamps?: { + createdAt?: string | Date; + updatedAt?: string | Date; + }; + extraMeta?: React.ReactNode; + className?: string; +} + +export const DetailHeader: React.FC = ({ + breadcrumbs, + title, + description, + backTo, + backLabel, + action, + icon: Icon, + badge, + statusBadge, + entityId, + timestamps, + extraMeta, + className, +}) => { + const navigate = useNavigate(); + + return ( +
+ {/* Top Navigation Row */} +
+ + +
+ + {/* Hero Card Banner */} +
+ {/* Top Scarlet Neon Glow Line */} +
+
+ +
+
+ {Icon && ( +
+ +
+ )} + +
+
+ {typeof title === "string" ? ( +

+ {title} +

+ ) : ( + title + )} + {statusBadge} + {badge} +
+ + {description && ( +
+ {description} +
+ )} + + {/* Entity Meta Strip */} + {(entityId || timestamps || extraMeta) && ( +
+ {entityId && ( +
+ + ID: + + + {entityId} + + +
+ )} + + {timestamps?.createdAt && ( +
+ + + Created:{" "} + + +
+ )} + + {timestamps?.updatedAt && ( +
+ + Updated:{" "} + + +
+ )} + + {extraMeta} +
+ )} +
+
+ + {action && ( +
+ {action} +
+ )} +
+
+
+ ); +}; diff --git a/src/design/components/detail/DetailSection.tsx b/src/design/components/detail/DetailSection.tsx new file mode 100644 index 00000000..7ec29821 --- /dev/null +++ b/src/design/components/detail/DetailSection.tsx @@ -0,0 +1,67 @@ +// src/design/components/detail/DetailSection.tsx +import React from "react"; +import { cn } from "../../helpers"; + +export interface IDetailSectionProps { + title: React.ReactNode; + description?: React.ReactNode; + icon?: React.ComponentType<{ className?: string }>; + badge?: React.ReactNode; + actions?: React.ReactNode; + children: React.ReactNode; + className?: string; + contentClassName?: string; + accent?: boolean; +} + +export const DetailSection: React.FC = ({ + title, + description, + icon: Icon, + badge, + actions, + children, + className, + contentClassName, + accent = false, +}) => ( +
+ {accent && ( +
+ )} + +
+
+ {Icon && ( +
+ +
+ )} +
+
+

+ {title} +

+ {badge} +
+ {description && ( +

+ {description} +

+ )} +
+
+ + {actions && ( +
{actions}
+ )} +
+ +
{children}
+
+); diff --git a/src/design/components/detail/index.ts b/src/design/components/detail/index.ts new file mode 100644 index 00000000..13e057ad --- /dev/null +++ b/src/design/components/detail/index.ts @@ -0,0 +1,6 @@ +// src/design/components/detail/index.ts +export * from "./CopyButton"; +export * from "./DetailField"; +export * from "./DetailGrid"; +export * from "./DetailHeader"; +export * from "./DetailSection"; diff --git a/src/design/components/form/FormContainer.tsx b/src/design/components/form/FormContainer.tsx index e072e513..becae10e 100644 --- a/src/design/components/form/FormContainer.tsx +++ b/src/design/components/form/FormContainer.tsx @@ -36,7 +36,7 @@ export const FormContainer = React.forwardRef< className={cn( "flex flex-col", isGlass - ? "bg-glass-form border border-glass-border backdrop-blur-xl shadow-[0_15px_35px_rgba(0,0,0,0.8),0_0_25px_rgba(107,20,38,0.35)] rounded-2xl p-6 sm:p-8 w-full max-w-md" + ? "bg-glass-form border border-glass-border backdrop-blur-xl shadow-[0_15px_35px_rgba(0,0,0,0.8),0_0_25px_rgba(var(--color-glow-outer-rgb),0.35)] rounded-2xl p-6 sm:p-8 w-full max-w-md" : "bg-base-100 p-4 rounded", className, )} diff --git a/src/design/components/form/PasswordInput.tsx b/src/design/components/form/PasswordInput.tsx index 1523da1b..af1ec3cf 100644 --- a/src/design/components/form/PasswordInput.tsx +++ b/src/design/components/form/PasswordInput.tsx @@ -152,13 +152,13 @@ export const PasswordInput: React.FC = ({ focus:outline-none focus:ring-2 focus:ring-primary/25 focus:border-primary ${ mask && digit - ? "font-sans font-bold text-2xl sm:text-3xl text-white tracking-normal pt-1" + ? "font-primary font-bold text-2xl sm:text-3xl text-base-content tracking-normal pt-1" : "font-primary font-semibold text-xl text-base-content tracking-wider pt-0" } ${ hasError ? "border-error focus:border-error focus:ring-error" - : "border-base-300 hover:border-base-content/30 focus:border-primary" + : "border-base-300 hover:border-primary focus:border-primary" } ${ disabled diff --git a/src/design/components/form/TextArea.tsx b/src/design/components/form/TextArea.tsx index f75008e3..38e480e7 100644 --- a/src/design/components/form/TextArea.tsx +++ b/src/design/components/form/TextArea.tsx @@ -89,10 +89,7 @@ export const TextArea: React.FC = ({ {label && ( @@ -110,7 +107,7 @@ export const TextArea: React.FC = ({ className={cn( "w-full transition-all duration-200 ease-out focus:outline-none", isGlass - ? "bg-transparent text-white border-0 border-b border-glass-border rounded-none px-0 py-3 text-base font-primary placeholder:text-white/50 focus:border-b-primary focus:ring-0 focus:shadow-[0_1px_0_0_var(--color-primary)]" + ? "bg-transparent text-base-content border-0 border-b border-glass-border rounded-none px-0 py-3 text-base font-primary placeholder:text-base-content/50 focus:border-b-primary focus:ring-0 focus:shadow-[0_1px_0_0_var(--color-primary)]" : "rounded-md px-4 py-3 text-base border bg-base-100 text-base-content placeholder:text-base-content/40 focus:ring-2 focus:ring-primary focus:border-transparent", "disabled:opacity-50 disabled:cursor-not-allowed", disabled && !isGlass && "disabled:bg-base-200", @@ -118,7 +115,7 @@ export const TextArea: React.FC = ({ ? isGlass ? "border-b-error focus:border-b-error" : "border-error focus:ring-error" - : !isGlass && "border-base-300 hover:border-base-400", + : !isGlass && "border-base-300 hover:border-primary", className, )} /> diff --git a/src/design/components/form/TextInput.tsx b/src/design/components/form/TextInput.tsx index 7f86d9bf..d0746940 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, @@ -39,35 +44,49 @@ export const TextInput: React.FC = ({ )} - + + + {tooltip && ( +
+
+ +
+
)} - /> +
{displayText && ( = ({ } className={ isDestructive - ? "!bg-error !text-white hover:!bg-error/90" + ? "bg-error! text-primary-content! hover:bg-error/90!" : "" } onClick={onConfirm} diff --git a/src/design/elements/colors.ts b/src/design/elements/colors.ts index 940b89bd..dd698ab9 100644 --- a/src/design/elements/colors.ts +++ b/src/design/elements/colors.ts @@ -31,28 +31,58 @@ export const colors = { info: "#38BDF8", }, - // Day Theme (Light Mode) + // Centralized Text Tokens (Single Source of Truth) + text: { + day: { + primary: "#000000", + primaryRgb: "0, 0, 0", + secondary: "#000000", + muted: "#111827", + }, + night: { + primary: "#FFFFFF", + primaryRgb: "255, 255, 255", + secondary: "#FFFFFF", + muted: "#F8FAFC", + }, + }, + + // Day Theme (White / Light Mode - Red & Black, Industrial Standard High Contrast) day: { - background: "#FAFAF8", + primary: "#FF2238", + primaryRgb: "255, 34, 56", + primaryLight: "#FF5263", + primaryDark: "#CC1125", + background: "#FFFFFF", surface: "#FFFFFF", - card: "#F5F5F3", + card: "#F8F8FA", border: "#E5E7EB", divider: "#F3F4F6", - textPrimary: "#111827", - textSecondary: "#4B5563", - textMuted: "#9CA3AF", + textPrimary: "#000000", + textSecondary: "#000000", + textMuted: "#111827", + glowWhite: "#FFF2F4", + glowOuter: "#5C0916", + glowOuterRgb: "92, 9, 22", }, - // Night Theme (Dark Mode - Rich Rex9 Aesthetic with clear brick visibility) + // Night Theme (Dark Mode - Rex9 Scarlet Red & Pure White, High Contrast) night: { + primary: "#FF2238", + primaryRgb: "255, 34, 56", + primaryLight: "#FF5263", + primaryDark: "#CC1125", background: "#160b11", surface: "#1f1018", card: "#26131e", border: "#3d1b28", divider: "#2c111c", textPrimary: "#FFFFFF", - textSecondary: "#E2D4D8", - textMuted: "#A39298", + textSecondary: "#FFFFFF", + textMuted: "#F8FAFC", + glowWhite: "#FFF2F4", + glowOuter: "#5C0916", + glowOuterRgb: "92, 9, 22", }, // Rex9 Glassmorphism Tokens diff --git a/src/design/elements/font.ts b/src/design/elements/font.ts index 18976d41..cf0f70a7 100644 --- a/src/design/elements/font.ts +++ b/src/design/elements/font.ts @@ -13,6 +13,7 @@ export const font = { primary: '"Quicksand", sans-serif', display: '"Clip", cursive, sans-serif', handwritten: '"Storytime", cursive', + mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', }, fontWeight: { diff --git a/src/design/elements/shadows.ts b/src/design/elements/shadows.ts index feec8c4e..e906c83b 100644 --- a/src/design/elements/shadows.ts +++ b/src/design/elements/shadows.ts @@ -24,7 +24,7 @@ export const dropShadows = { "0 0 14px var(--color-primary-dark)", ], "neon-hover": [ - "0 0 4px #ffffff", + "0 0 4px var(--color-glow-white)", "0 0 10px var(--color-primary-light)", "0 0 22px var(--color-primary)", "0 0 34px var(--color-primary-dark)", diff --git a/src/design/examples.tsx b/src/design/examples.tsx index 661a7a6a..ac8bd25f 100644 --- a/src/design/examples.tsx +++ b/src/design/examples.tsx @@ -42,13 +42,13 @@ export const ColorExamples = () => { Brand Colors
-
+
Primary (Sunset Coral)
-
+
Secondary (Coral Peach)
-
+
Accent (Crimson)
diff --git a/src/design/pages/TestPage.tsx b/src/design/pages/TestPage.tsx index 2c837355..f315828d 100644 --- a/src/design/pages/TestPage.tsx +++ b/src/design/pages/TestPage.tsx @@ -92,7 +92,7 @@ export const TestPage: React.FC = () => { src={videoPlayback.delivery.url} type={videoPlayback.media.content_type} alt="Test video playback" - className="aspect-video w-full rounded-lg border border-base-300 bg-black object-contain" + className="aspect-video w-full rounded-lg border border-base-300 bg-base-300 object-contain" tracks={videoPlayback.media.subtitles .filter((s) => Boolean(s.url)) .map((s, i) => ({ diff --git a/src/design/styles/index.css b/src/design/styles/index.css index e5431776..0399fd48 100644 --- a/src/design/styles/index.css +++ b/src/design/styles/index.css @@ -8,11 +8,15 @@ html { scrollbar-gutter: stable; scrollbar-width: thin; - scrollbar-color: rgba(255, 34, 56, 0.35) rgba(15, 5, 8, 0.6); + scrollbar-color: rgba(var(--color-primary-rgb), 0.35) var(--color-base-300); + font-family: var(--font-primary); } body { overflow-x: hidden; + font-family: var(--font-primary); + color: var(--color-base-content); + background-color: var(--color-base-100); } /* Sleek Thin Scrollbar matching Rex9 */ @@ -22,24 +26,24 @@ } ::-webkit-scrollbar-track { - background: rgba(15, 5, 8, 0.6); + background: var(--color-base-300); border-radius: 10px; } ::-webkit-scrollbar-thumb { - background: rgba(255, 34, 56, 0.35); + background: rgba(var(--color-primary-rgb), 0.35); border-radius: 10px; transition: background 0.3s ease; } ::-webkit-scrollbar-thumb:hover { - background: rgba(255, 34, 56, 0.75); + background: rgba(var(--color-primary-rgb), 0.75); } /* Thin internal scrollbars for cards, carousels, and scroll containers */ .scrollbar-thin { scrollbar-width: thin; - scrollbar-color: rgba(255, 34, 56, 0.35) transparent; + scrollbar-color: rgba(var(--color-primary-rgb), 0.35) transparent; } .scrollbar-thin::-webkit-scrollbar { @@ -52,13 +56,62 @@ } .scrollbar-thin::-webkit-scrollbar-thumb { - background: rgba(255, 34, 56, 0.35); + background: rgba(var(--color-primary-rgb), 0.35); border-radius: 10px; transition: background 0.3s ease; } .scrollbar-thin::-webkit-scrollbar-thumb:hover { - background: rgba(255, 34, 56, 0.75); + background: rgba(var(--color-primary-rgb), 0.75); } } +/* ========================================================================== + Rexone High-Contrast Design System Theme Engine + - White / Light Theme (Day): Red & Black (Rex9 Scarlet Red & Pure Black) + - Dark Theme (Night): Red & White (Rex9 Scarlet Red & Pure White) + - Centralized: Eliminates all washed-out grey text globally across components + ========================================================================== */ + +/* Universal text and background inheritance for any active or future theme */ +:root, +[data-theme] { + color: var(--color-base-content); + background-color: var(--color-base-100); +} + +[data-theme] body { + color: var(--color-base-content); + background-color: var(--color-base-100); +} + +/* Ensure all base-content text variations render crisp at high-contrast, never washed-out grey */ +.text-base-content, +[class*="text-base-content/"]:not(:hover) { + color: var(--color-base-content); +} + +/* Prevent opacity utilities on text elements from washing out to low-contrast grey */ +p.opacity-60, +span.opacity-60, +div.opacity-60, +label.opacity-60, +p.opacity-70, +span.opacity-70, +div.opacity-70, +label.opacity-70, +p.opacity-50, +span.opacity-50, +div.opacity-50, +label.opacity-50, +.text-caption.opacity-60, +.text-body-s.opacity-60 { + opacity: 1; + color: var(--color-base-content); +} + +/* Interactive hover borders on cards, tiles, and fields follow centralized --color-primary */ +[class*="hover:border-primary"]:hover { + border-color: var(--color-primary) !important; +} + diff --git a/src/locales/app_locales.ts b/src/locales/app_locales.ts index c9b2f066..f3cfda77 100644 --- a/src/locales/app_locales.ts +++ b/src/locales/app_locales.ts @@ -148,6 +148,7 @@ export const AppLocales = { Products: "admin.nav.items.products", Transactions: "admin.nav.items.transactions", Subscriptions: "admin.nav.items.subscriptions", + Coupons: "admin.nav.items.coupons", Accesses: "admin.nav.items.accesses", Notifications: "admin.nav.items.notifications", UserNotifications: "admin.nav.items.user_notifications", @@ -186,6 +187,7 @@ export const AppLocales = { DeselectAll: "admin.common.actions.deselect_all", ClearAll: "admin.common.actions.clear_all", ApplyFilter: "admin.common.actions.apply_filter", + ResetFilter: "admin.common.actions.reset_filter", OpenRecycleBin: "admin.common.actions.open_recycle_bin", }, RecycleBin: { @@ -470,6 +472,141 @@ export const AppLocales = { }, Errors: { Load: "admin.subscriptions.errors.load" }, }, + Coupons: { + Title: "admin.coupons.title", + Description: "admin.coupons.description", + RecycleTitle: "admin.coupons.recycle_title", + RecycleDescription: "admin.coupons.recycle_description", + CreateTitle: "admin.coupons.create_title", + CreateDescription: "admin.coupons.create_description", + DetailTitle: "admin.coupons.detail_title", + DetailDescription: "admin.coupons.detail_description", + LedgerButton: "admin.coupons.ledger_button", + CreateButton: "admin.coupons.create_button", + Tabs: { + Active: "admin.coupons.tabs.active", + RecycleBin: "admin.coupons.tabs.recycle_bin", + }, + SearchPlaceholder: "admin.coupons.search_placeholder", + FilterAll: "admin.coupons.filter_all", + FilterPercentage: "admin.coupons.filter_percentage", + FilterFixed: "admin.coupons.filter_fixed", + Table: { + Code: "admin.coupons.table.code", + Title: "admin.coupons.table.title", + Discount: "admin.coupons.table.discount", + Usage: "admin.coupons.table.usage", + Expires: "admin.coupons.table.expires", + Created: "admin.coupons.table.created", + Actions: "admin.coupons.table.actions", + Referral: "admin.coupons.table.referral", + Never: "admin.coupons.table.never", + MaxPerUser: "admin.coupons.table.max_per_user", + Off: "admin.coupons.table.off", + }, + Detail: { + TotalRedemptions: "admin.coupons.detail.total_redemptions", + TotalDiscounted: "admin.coupons.detail.total_discounted", + RemainingUsage: "admin.coupons.detail.remaining_usage", + TargetingRestrictions: "admin.coupons.detail.targeting_restrictions", + TargetingAllRoles: "admin.coupons.detail.targeting_all_roles", + TargetingAllUsers: "admin.coupons.detail.targeting_all_users", + TargetingAllProducts: "admin.coupons.detail.targeting_all_products", + TargetRoles: "admin.coupons.detail.target_roles", + TargetUsers: "admin.coupons.detail.target_users", + TargetProducts: "admin.coupons.detail.target_products", + RedemptionHistory: "admin.coupons.detail.redemption_history", + NoRedemptions: "admin.coupons.detail.no_redemptions", + RestrictedCount: "admin.coupons.detail.restricted_count", + }, + Form: { + SingleMode: "admin.coupons.form.single_mode", + BatchMode: "admin.coupons.form.batch_mode", + TitleLabel: "admin.coupons.form.title_label", + TitlePlaceholder: "admin.coupons.form.title_placeholder", + DescriptionLabel: "admin.coupons.form.description_label", + DescriptionPlaceholder: "admin.coupons.form.description_placeholder", + CodeLabel: "admin.coupons.form.code_label", + CodePlaceholder: "admin.coupons.form.code_placeholder", + CodeHelper: "admin.coupons.form.code_helper", + CodeInvalid: "admin.coupons.form.code_invalid", + TypeLabel: "admin.coupons.form.type_label", + TypePercentage: "admin.coupons.form.type_percentage", + TypeFixed: "admin.coupons.form.type_fixed", + AmountLabel: "admin.coupons.form.amount_label", + PercentagePlaceholder: "admin.coupons.form.percentage_placeholder", + FixedPlaceholder: "admin.coupons.form.fixed_placeholder", + CurrencyLabel: "admin.coupons.form.currency_label", + MaxUsageLabel: "admin.coupons.form.max_usage_label", + MaxUsagePlaceholder: "admin.coupons.form.max_usage_placeholder", + MaxUsageTooltip: "admin.coupons.form.max_usage_tooltip", + MaxUsageHelper: "admin.coupons.form.max_usage_helper", + MaxPerUserLabel: "admin.coupons.form.max_per_user_label", + MaxPerUserPlaceholder: "admin.coupons.form.max_per_user_placeholder", + MaxPerUserHelper: "admin.coupons.form.max_per_user_helper", + MaxPerUserExceedsMax: "admin.coupons.form.max_per_user_exceeds_max", + ExpiresLabel: "admin.coupons.form.expires_label", + ExpiresHelper: "admin.coupons.form.expires_helper", + BatchCountLabel: "admin.coupons.form.batch_count_label", + BatchPrefixLabel: "admin.coupons.form.batch_prefix_label", + BatchPrefixPlaceholder: "admin.coupons.form.batch_prefix_placeholder", + BatchHelper: "admin.coupons.form.batch_helper", + TargetUsersPlaceholder: "admin.coupons.form.target_users_placeholder", + TargetUsersHelper: "admin.coupons.form.target_users_helper", + SubmitSingle: "admin.coupons.form.submit_single", + SubmitBatch: "admin.coupons.form.submit_batch", + }, + Toasts: { + CreateSuccess: "admin.coupons.toasts.create_success", + BatchCreateSuccess: "admin.coupons.toasts.batch_create_success", + DiscardSuccess: "admin.coupons.toasts.discard_success", + RestoreSuccess: "admin.coupons.toasts.restore_success", + DestroySuccess: "admin.coupons.toasts.destroy_success", + BatchDiscardSuccess: "admin.coupons.toasts.batch_discard_success", + BatchRestoreSuccess: "admin.coupons.toasts.batch_restore_success", + BatchDestroySuccess: "admin.coupons.toasts.batch_destroy_success", + RecycleBinEmptied: "admin.coupons.toasts.recycle_bin_emptied", + }, + Errors: { + LoadList: "admin.coupons.errors.load_list", + LoadOne: "admin.coupons.errors.load_one", + Create: "admin.coupons.errors.create", + Discard: "admin.coupons.errors.discard", + Restore: "admin.coupons.errors.restore", + Destroy: "admin.coupons.errors.destroy", + BatchDiscard: "admin.coupons.errors.batch_discard", + BatchRestore: "admin.coupons.errors.batch_restore", + BatchDestroy: "admin.coupons.errors.batch_destroy", + EmptyRecycleBin: "admin.coupons.errors.empty_recycle_bin", + }, + }, + UserCoupons: { + Title: "admin.user_coupons.title", + Description: "admin.user_coupons.description", + SearchPlaceholder: "admin.user_coupons.search_placeholder", + FilterAll: "admin.user_coupons.filter_all", + FilterTrx: "admin.user_coupons.filter_trx", + FilterSbs: "admin.user_coupons.filter_sbs", + Free: "admin.user_coupons.free", + Types: { + Transaction: "admin.user_coupons.types.transaction", + Subscription: "admin.user_coupons.types.subscription", + }, + Table: { + Code: "admin.user_coupons.table.code", + User: "admin.user_coupons.table.user", + Product: "admin.user_coupons.table.product", + Type: "admin.user_coupons.table.type", + Discount: "admin.user_coupons.table.discount", + Final: "admin.user_coupons.table.final", + Date: "admin.user_coupons.table.date", + }, + Empty: "admin.user_coupons.empty", + EmptyDescription: "admin.user_coupons.empty_description", + Errors: { + Load: "admin.user_coupons.errors.load", + }, + }, Products: { Title: "admin.products.title", Description: "admin.products.description", @@ -1268,6 +1405,8 @@ export const AppLocales = { Table: { Preview: "admin.assets.table.preview", Name: "admin.assets.table.name", + Title: "admin.assets.table.title", + Description: "admin.assets.table.description", Type: "admin.assets.table.type", Format: "admin.assets.table.format", Size: "admin.assets.table.size", @@ -1279,6 +1418,8 @@ export const AppLocales = { UploadDialog: { Title: "admin.assets.upload_dialog.title", TypeLabel: "admin.assets.upload_dialog.type_label", + TitleLabel: "admin.assets.upload_dialog.title_label", + DescriptionLabel: "admin.assets.upload_dialog.description_label", FileLabel: "admin.assets.upload_dialog.file_label", ChooseFiles: "admin.assets.upload_dialog.choose_files", UploadButton: "admin.assets.upload_dialog.upload_button", @@ -1606,6 +1747,33 @@ export const AppLocales = { LoadTransactions: "payment.errors.load_transactions", CreateCheckout: "payment.errors.create_checkout", }, + CheckoutDialog: { + Title: "payment.checkout_dialog.title", + PromoCode: "payment.checkout_dialog.promo_code", + PromoCodePlaceholder: "payment.checkout_dialog.promo_code_placeholder", + Apply: "payment.checkout_dialog.apply", + Remove: "payment.checkout_dialog.remove", + Applied: "payment.checkout_dialog.applied", + OrderSummary: "payment.checkout_dialog.order_summary", + OriginalPrice: "payment.checkout_dialog.original_price", + Discount: "payment.checkout_dialog.discount", + TotalDue: "payment.checkout_dialog.total_due", + Free: "payment.checkout_dialog.free", + Proceed: "payment.checkout_dialog.proceed", + ClaimFree: "payment.checkout_dialog.claim_free", + FreeClaimed: "payment.checkout_dialog.free_claimed", + Redirecting: "payment.checkout_dialog.redirecting", + InvalidCode: "payment.checkout_dialog.invalid_code", + Checking: "payment.checkout_dialog.checking", + Processing: "payment.checkout_dialog.processing", + OneTime: "payment.checkout_dialog.one_time", + Per: "payment.checkout_dialog.per", + Off: "payment.checkout_dialog.off", + Subtotal: "payment.checkout_dialog.subtotal", + TotalDueToday: "payment.checkout_dialog.total_due_today", + TooManyAttempts: "payment.checkout_dialog.too_many_attempts", + AttemptsRemaining: "payment.checkout_dialog.attempts_remaining", + }, }, Anapana: { TimerTitle: "anapana.timer_title", diff --git a/src/locales/en.json b/src/locales/en.json index 55c1e7af..c8d3c306 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -47,10 +47,7 @@ "submitting": "Submitting...", "success_message": "Thank you for your feedback!", "rating_label": "How was your experience? (1 - 10)", - "worst": "1 - Needs Work", - "best": "10 - Excellent", "content_label": "What's on your mind?", - "success": "Thank you for your feedback!", "error_empty": "Please enter your feedback.", "error_submit": "Failed to submit feedback. Please try again." }, @@ -119,6 +116,33 @@ "resume_subscription": "Failed to resume subscription", "load_transactions": "Failed to fetch transactions", "create_checkout": "Failed to create checkout session" + }, + "checkout_dialog": { + "title": "Checkout & Activation", + "promo_code": "Promo Code", + "promo_code_placeholder": "ENTER CODE", + "apply": "Apply", + "remove": "Remove", + "applied": "Coupon Applied", + "order_summary": "Order Summary", + "original_price": "Original Price", + "discount": "Discount", + "total_due": "Total Due", + "free": "FREE", + "proceed": "Proceed to Checkout", + "claim_free": "Claim Free Access 🎉", + "free_claimed": "Free access claimed successfully! 🎉", + "redirecting": "Redirecting to checkout...", + "invalid_code": "Invalid coupon code", + "checking": "Checking...", + "processing": "Processing...", + "one_time": "One-time", + "per": "per", + "off": "OFF", + "subtotal": "Subtotal", + "total_due_today": "Total Due Today", + "too_many_attempts": "Too many attempts. Please wait {{seconds}}s before trying again.", + "attempts_remaining": "{{attempts}} attempt(s) remaining" } }, @@ -317,6 +341,7 @@ "products": "Products", "transactions": "Transactions", "subscriptions": "Subscriptions", + "coupons": "Coupons", "accesses": "Access", "notifications": "Notifications", "user_notifications": "User Notifications", @@ -639,6 +664,141 @@ }, "errors": { "load": "Failed to load subscriptions" } }, + "coupons": { + "title": "Promotions & Coupons", + "description": "Manage promotional codes, referral campaigns, and usage limits", + "recycle_title": "Discarded Coupons", + "recycle_description": "Inspect or restore archived promotional coupons.", + "create_title": "Create Promotion Coupon", + "create_description": "Configure promotional discounts, usage allowances, targeting restrictions, or batch generation.", + "detail_title": "Coupon Details", + "detail_description": "Inspect coupon configuration, redemption analytics, and targeted allowances.", + "ledger_button": "Redemption Ledger", + "create_button": "Create Coupon", + "tabs": { + "active": "Active Coupons", + "recycle_bin": "Recycle Bin" + }, + "search_placeholder": "Search coupons by code, title, or referrer...", + "filter_all": "All Types", + "filter_percentage": "Percentage", + "filter_fixed": "Fixed Amount", + "table": { + "code": "Code", + "title": "Title & Details", + "discount": "Discount", + "usage": "Usage / Limit", + "expires": "Expires At", + "created": "Created", + "actions": "Actions", + "referral": "Referral", + "never": "Never", + "max_per_user": "Max {{count}}/user", + "off": "OFF" + }, + "detail": { + "total_redemptions": "Total Redemptions", + "total_discounted": "Total Discounts", + "remaining_usage": "Remaining Usage", + "targeting_restrictions": "Targeting & Eligibility Restrictions", + "targeting_all_roles": "Applicable to all customer roles", + "targeting_all_users": "Applicable to all registered users", + "targeting_all_products": "Applicable to all product plans", + "target_roles": "Target Roles", + "target_users": "Target User Emails", + "target_products": "Target Product IDs", + "redemption_history": "Redemption History", + "no_redemptions": "No redemptions recorded yet", + "restricted_count": "{{count}} restricted" + }, + "form": { + "single_mode": "Single Coupon", + "batch_mode": "Batch Generator", + "title_label": "Coupon Title", + "title_placeholder": "e.g. Summer Promo 2026", + "description_label": "Description", + "description_placeholder": "Internal notes or customer-facing terms...", + "code_label": "Coupon Code", + "code_placeholder": "e.g. SUMMER20", + "code_helper": "Must be at least 6 characters (A-Z and 0-9 only, no hyphens or special characters).", + "code_invalid": "Coupon code must be at least 6 alphanumeric characters (A-Z and 0-9 only)", + "type_label": "Discount Type", + "type_percentage": "Percentage (%)", + "type_fixed": "Fixed Amount (Currency)", + "amount_label": "Discount Amount", + "percentage_placeholder": "20", + "fixed_placeholder": "10.00", + "currency_label": "Currency", + "max_usage_label": "Global Usage Limit", + "max_usage_placeholder": "0", + "max_usage_tooltip": "0 = Unlimited", + "max_usage_helper": "Set 0 for unlimited total redemptions.", + "max_per_user_label": "Usage Limit Per User", + "max_per_user_placeholder": "1", + "max_per_user_helper": "How many times each customer can redeem.", + "max_per_user_exceeds_max": "Usage limit per user cannot exceed global usage limit.", + "expires_label": "Expiration Date (Optional)", + "expires_helper": "Leave empty if this coupon never expires.", + "batch_count_label": "Number of Coupons to Generate", + "batch_prefix_label": "Code Prefix", + "batch_prefix_placeholder": "e.g. VIP", + "batch_helper": "Generates unique uppercase alphanumeric codes with the prefix (no hyphens or special characters).", + "target_users_placeholder": "e.g. user1@example.com, user2@example.com", + "target_users_helper": "Enter customer emails separated by commas, spaces, or newlines.", + "submit_single": "Create Coupon", + "submit_batch": "Generate Coupons" + }, + "toasts": { + "create_success": "Coupon created successfully", + "batch_create_success": "Batch coupons generated successfully", + "discard_success": "Coupon moved to recycle bin", + "restore_success": "Coupon restored successfully", + "destroy_success": "Coupon permanently deleted successfully", + "batch_discard_success": "Selected coupons moved to recycle bin", + "batch_restore_success": "Selected coupons restored successfully", + "batch_destroy_success": "Selected coupons permanently deleted", + "recycle_bin_emptied": "Recycle bin emptied successfully" + }, + "errors": { + "load_list": "Failed to load coupons", + "load_one": "Failed to load coupon details", + "create": "Failed to create coupon", + "discard": "Failed to discard coupon", + "restore": "Failed to restore coupon", + "destroy": "Failed to delete coupon", + "batch_discard": "Failed to batch discard coupons", + "batch_restore": "Failed to batch restore coupons", + "batch_destroy": "Failed to batch delete coupons", + "empty_recycle_bin": "Failed to empty recycle bin" + } + }, + "user_coupons": { + "title": "Redemption Ledger", + "description": "Global audit log of coupon redemptions and applied discounts across all customers", + "search_placeholder": "Search by email, coupon code, or product name...", + "filter_all": "All Purchase Types", + "filter_trx": "One-Time Purchase (trx)", + "filter_sbs": "Subscription (sbs)", + "free": "FREE", + "types": { + "transaction": "TRANSACTION", + "subscription": "SUBSCRIPTION" + }, + "table": { + "code": "Coupon Code", + "user": "Customer", + "product": "Product", + "type": "Purchase Type", + "discount": "Discount Given", + "final": "Final Paid", + "date": "Redeemed At" + }, + "empty": "No redemptions found", + "empty_description": "No coupon redemptions recorded yet.", + "errors": { + "load": "Failed to load redemptions ledger" + } + }, "products": { "detail": { "title": "Product Details", @@ -1385,6 +1545,8 @@ "table": { "preview": "Preview", "name": "Name", + "title": "Title", + "description": "Description", "type": "Type", "format": "Format", "size": "Size", @@ -1396,6 +1558,8 @@ "upload_dialog": { "title": "Upload Assets", "type_label": "Asset Type", + "title_label": "Title", + "description_label": "Description", "file_label": "Select Files", "choose_files": "Choose Files (Up to {{count}})", "upload_button": "Upload", diff --git a/src/locales/my.json b/src/locales/my.json index 293c5b52..655638ec 100644 --- a/src/locales/my.json +++ b/src/locales/my.json @@ -47,10 +47,7 @@ "submitting": "ပို့နေဆဲ...", "success_message": "အကြံပြုချက်အတွက် ကျေးဇူးတင်ပါသည်!", "rating_label": "အဆင့်သတ်မှတ်ပါ (၁ - ၁၀)", - "worst": "၁ - အားနည်း", - "best": "၁၀ - အထူးကောင်း", "content_label": "အကြံပြုချက် ရေးပါ", - "success": "အကြံပြုချက်အတွက် ကျေးဇူးတင်ပါသည်!", "error_empty": "အကြံပြုချက် ရေးသားပေးပါ။", "error_submit": "အကြံပြုချက် ပို့၍ မရပါ။ ထပ်စမ်းပါ။" }, @@ -119,6 +116,33 @@ "resume_subscription": "စာရင်းသွင်းမှု ပြန်စ၍ မရပါ", "load_transactions": "ငွေလွှဲမှတ်တမ်း မရပါ", "create_checkout": "ငွေပေးချေရန် စ၍ မရပါ" + }, + "checkout_dialog": { + "title": "ငွေပေးချေခြင်းနှင့် အသုံးပြုခွင့် ရယူခြင်း", + "promo_code": "ပရိုမိုကုဒ်", + "promo_code_placeholder": "ကုဒ်ရိုက်ထည့်ပါ", + "apply": "အသုံးပြုမည်", + "remove": "ဖယ်ရှားမည်", + "applied": "ကူပွန် ထည့်သွင်းပြီးပါပြီ", + "order_summary": "အော်ဒါ အကျဉ်းချုပ်", + "original_price": "မူရင်းစျေးနှုန်း", + "discount": "လျှော့စျေး", + "total_due": "ကျသင့်ငွေ", + "free": "အခမဲ့", + "proceed": "ငွေပေးချေမှုသို့ ဆက်သွားမည်", + "claim_free": "အခမဲ့ ရယူမည် 🎉", + "free_claimed": "အခမဲ့ အသုံးပြုခွင့်ကို အောင်မြင်စွာ ရယူပြီးပါပြီ။ 🎉", + "redirecting": "ငွေပေးချေမှု စာမျက်နှာသို့ ကူးပြောင်းနေသည်...", + "invalid_code": "ကူပွန်ကုဒ် မမှန်ကန်ပါ", + "checking": "စစ်ဆေးနေသည်...", + "processing": "ဆောင်ရွက်နေသည်...", + "one_time": "တစ်ကြိမ်သာ", + "per": "အလိုက်", + "off": "လျှော့စျေး", + "subtotal": "ကျသင့်ငွေခွဲ", + "total_due_today": "ယနေ့ ပေးချေရမည့် စုစုပေါင်း", + "too_many_attempts": "အကြိမ်ကြိမ် မှားယွင်းထည့်သွင်းထားပါသည်။ ကျေးဇူးပြု၍ {{seconds}} စက္ကန့် စောင့်ဆိုင်းပြီးမှ ပြန်လည်ကြိုးစားပါ။", + "attempts_remaining": "{{attempts}} ကြိမ် ကျန်ရှိပါသည်" } }, @@ -316,6 +340,7 @@ "products": "ထုတ်ကုန်များ", "transactions": "ငွေပေးချေမှုများ", "subscriptions": "စာရင်းသွင်းမှုများ", + "coupons": "ကူပွန်များ", "accesses": "အသုံးပြုခွင့်များ", "notifications": "အသိပေးချက်များ", "user_notifications": "အသုံးပြုသူ အသိပေးချက်များ", @@ -638,6 +663,141 @@ }, "errors": { "load": "စာရင်းသွင်းမှုများကို မတင်နိုင်ပါ" } }, + "coupons": { + "title": "ပရိုမိုးရှင်းနှင့် ကူပွန်များ", + "description": "ပရိုမိုးရှင်းကုဒ်များ၊ လွှဲပြောင်းညွှန်းဆိုမှုများနှင့် အသုံးပြုမှုကန့်သတ်ချက်များကို စီမံခန့်ခွဲပါ", + "recycle_title": "ပယ်ဖျက်ထားသော ကူပွန်များ", + "recycle_description": "ပယ်ဖျက်ထားသော ပရိုမိုးရှင်းကူပွန်များကို ပြန်လည်ရယူပါ သို့မဟုတ် စစ်ဆေးပါ။", + "create_title": "ပရိုမိုးရှင်းကူပွန် ဖန်တီးမည်", + "create_description": "လျှော့စျေးနှုန်းထား၊ အသုံးပြုမှုခွင့်ပြုချက်များနှင့် သတ်မှတ်ချက်များကို သတ်မှတ်ပါ။", + "detail_title": "ကူပွန် အသေးစိတ်", + "detail_description": "ကူပွန်ဖွဲ့စည်းမှု၊ အသုံးပြုမှုစာရင်းနှင့် ကန့်သတ်ချက်များကို စစ်ဆေးပါ။", + "ledger_button": "ကူပွန်အသုံးပြုမှု မှတ်တမ်း", + "create_button": "ကူပွန် ဖန်တီးမည်", + "tabs": { + "active": "အသုံးပြုနေဆဲ ကူပွန်များ", + "recycle_bin": "အမှိုက်ပုံး" + }, + "search_placeholder": "ကုဒ်၊ ခေါင်းစဉ် သို့မဟုတ် လွှဲပြောင်းသူဖြင့် ရှာဖွေပါ...", + "filter_all": "အမျိုးအစား အားလုံး", + "filter_percentage": "ရာခိုင်နှုန်း (%)", + "filter_fixed": "ပမာဏ ပုံသေ", + "table": { + "code": "ကုဒ်", + "title": "ခေါင်းစဉ်နှင့် အသေးစိတ်", + "discount": "လျှော့စျေး", + "usage": "အသုံးပြုမှု / ကန့်သတ်ချက်", + "expires": "သက်တမ်းကုန်ဆုံးချိန်", + "created": "ဖန်တီးချိန်", + "actions": "လုပ်ဆောင်ချက်များ", + "referral": "လွှဲပြောင်းညွှန်းဆိုမှု", + "never": "သတ်မှတ်မထားပါ", + "max_per_user": "တစ်ဦးလျှင် အများဆုံး {{count}} ကြိမ်", + "off": "လျှော့စျေး" + }, + "detail": { + "total_redemptions": "စုစုပေါင်း အသုံးပြုမှု", + "total_discounted": "စုစုပေါင်း လျှော့စျေး", + "remaining_usage": "ကျန်ရှိသော အကြိမ်အရေအတွက်", + "targeting_restrictions": "အသုံးပြုခွင့် ကန့်သတ်ချက်များ", + "targeting_all_roles": "သုံးစွဲသူ အဆင့်အတန်း အားလုံး အကျုံးဝင်ပါသည်", + "targeting_all_users": "သုံးစွဲသူ အားလုံး အကျုံးဝင်ပါသည်", + "targeting_all_products": "ထုတ်ကုန် အားလုံး အကျုံးဝင်ပါသည်", + "target_roles": "သတ်မှတ် အဆင့်အတန်းများ", + "target_users": "သတ်မှတ်သုံးစွဲသူ အီးမေးလ်များ", + "target_products": "သတ်မှတ် ထုတ်ကုန် ID များ", + "redemption_history": "ကူပွန် အသုံးပြုမှု မှတ်တမ်း", + "no_redemptions": "အသုံးပြုမှု မှတ်တမ်း မရှိသေးပါ", + "restricted_count": "{{count}} ခု သတ်မှတ်ထားသည်" + }, + "form": { + "single_mode": "ကူပွန်တစ်ခုတည်း", + "batch_mode": "အသုတ်လိုက် ထုတ်လုပ်မည်", + "title_label": "ကူပွန် ခေါင်းစဉ်", + "title_placeholder": "ဥပမာ - နွေရာသီ ပရိုမိုးရှင်း ၂၀၂၆", + "description_label": "ဖော်ပြချက်", + "description_placeholder": "အတွင်းပိုင်းမှတ်စု သို့မဟုတ် စည်းကမ်းချက်များ...", + "code_label": "ကူပွန်ကုဒ်", + "code_placeholder": "ဥပမာ - SUMMER20", + "code_helper": "အနည်းဆုံး စာလုံး ၆ လုံးရှိရမည်ဖြစ်ပြီး အင်္ဂလိပ်စာလုံးကြီးနှင့် ဂဏန်းများသာ ခွင့်ပြုပါသည် (အထူးသင်္ကေတများ မပါဝင်ရ)။", + "code_invalid": "ကူပွန်ကုဒ်သည် အနည်းဆုံး စာလုံး ၆ လုံးရှိရမည်ဖြစ်ပြီး စာလုံးကြီးနှင့် ဂဏန်းများသာ ဖြစ်ရပါမည်။", + "type_label": "လျှော့စျေး အမျိုးအစား", + "type_percentage": "ရာခိုင်နှုန်း (%)", + "type_fixed": "ငွေပမာဏ ပုံသေ", + "amount_label": "လျှော့စျေး ပမာဏ", + "percentage_placeholder": "20", + "fixed_placeholder": "10.00", + "currency_label": "ငွေကြေး", + "max_usage_label": "စုစုပေါင်း အသုံးပြုနိုင်သည့် အကြိမ်", + "max_usage_placeholder": "0", + "max_usage_tooltip": "0 = အကန့်အသတ်မရှိ", + "max_usage_helper": "အကန့်အသတ်မရှိ အသုံးပြုနိုင်ရန် 0 ဟု ထားပါ", + "max_per_user_label": "သုံးစွဲသူတစ်ဦးလျှင် အသုံးပြုနိုင်သည့် အကြိမ်", + "max_per_user_placeholder": "1", + "max_per_user_helper": "သုံးစွဲသူတစ်ဦးလျှင် အများဆုံး အသုံးပြုနိုင်သည့် အကြိမ်", + "max_per_user_exceeds_max": "သုံးစွဲသူတစ်ဦးလျှင် အသုံးပြုနိုင်သည့် အကြိမ်အရေအတွက်သည် စုစုပေါင်းကန့်သတ်ချက်ထက် မကျော်လွန်ရပါ။", + "expires_label": "သက်တမ်းကုန်ဆုံးရက် (ရွေးချယ်ရန်)", + "expires_helper": "သက်တမ်းမကုန်ဆုံးစေလိုပါက ကွက်လပ်ထားပါ", + "batch_count_label": "ထုတ်လုပ်မည့် ကူပွန်အရေအတွက်", + "batch_prefix_label": "ကုဒ် ရှေ့ဆက်စကားလုံး", + "batch_prefix_placeholder": "ဥပမာ - VIP", + "batch_helper": "သတ်မှတ်ရှေ့ဆက်စာလုံးဖြင့် အင်္ဂလိပ်စာလုံးကြီးနှင့် ဂဏန်းများသာပါဝင်သော ထူးခြားသည့်ကုဒ်များကို ထုတ်လုပ်ပေးပါမည်။", + "target_users_placeholder": "ဥပမာ user1@example.com, user2@example.com", + "target_users_helper": "သုံးစွဲသူ အီးမေးလ်များကို ကော်မာ၊ နေရာလွတ် သို့မဟုတ် စာကြောင်းအသစ်ဖြင့် ခွဲခြားထည့်သွင်းပါ", + "submit_single": "ကူပွန် ဖန်တီးမည်", + "submit_batch": "ကူပွန်များ ထုတ်လုပ်မည်" + }, + "toasts": { + "create_success": "ကူပွန် ဖန်တီးပြီးပါပြီ", + "batch_create_success": "ကူပွန်များ အသုတ်လိုက် ဖန်တီးပြီးပါပြီ", + "discard_success": "ကူပွန်ကို အမှိုက်ပုံးသို့ ရွှေ့ပြောင်းပြီးပါပြီ", + "restore_success": "ကူပွန်ကို ပြန်လည်ရယူပြီးပါပြီ", + "destroy_success": "ကူပွန်ကို အပြီးတိုင် ဖျက်ပြီးပါပြီ", + "batch_discard_success": "ရွေးချယ်ထားသော ကူပွန်များကို အမှိုက်ပုံးသို့ ရွှေ့ပြောင်းပြီးပါပြီ", + "batch_restore_success": "ရွေးချယ်ထားသော ကူပွန်များကို ပြန်လည်ရယူပြီးပါပြီ", + "batch_destroy_success": "ရွေးချယ်ထားသော ကူပွန်များကို အပြီးတိုင် ဖျက်ပြီးပါပြီ", + "recycle_bin_emptied": "အမှိုက်ပုံးကို အောင်မြင်စွာ ရှင်းလင်းပြီးပါပြီ" + }, + "errors": { + "load_list": "ကူပွန်များကို မတင်နိုင်ပါ", + "load_one": "ကူပွန် အသေးစိတ်ကို မတင်နိုင်ပါ", + "create": "ကူပွန် ဖန်တီး၍ မရပါ", + "discard": "ကူပွန်ကို ဖယ်ရှား၍ မရပါ", + "restore": "ကူပွန်ကို ပြန်လည်ရယူ၍ မရပါ", + "destroy": "ကူပွန်ကို ဖျက်၍ မရပါ", + "batch_discard": "ကူပွန်များကို အသုတ်လိုက် ဖယ်ရှား၍ မရပါ", + "batch_restore": "ကူပွန်များကို အသုတ်လိုက် ပြန်လည်ရယူ၍ မရပါ", + "batch_destroy": "ကူပွန်များကို အသုတ်လိုက် ဖျက်၍ မရပါ", + "empty_recycle_bin": "အမှိုက်ပုံးကို ရှင်းလင်း၍ မရပါ" + } + }, + "user_coupons": { + "title": "ကူပွန်အသုံးပြုမှု မှတ်တမ်း", + "description": "သုံးစွဲသူများ၏ ကူပွန်အသုံးပြုမှုနှင့် လျှော့စျေး မှတ်တမ်းများ", + "search_placeholder": "အီးမေးလ်၊ ကူပွန်ကုဒ် သို့မဟုတ် ထုတ်ကုန်ဖြင့် ရှာဖွေပါ...", + "filter_all": "အမျိုးအစား အားလုံး", + "filter_trx": "တစ်ကြိမ်တည်း ဝယ်ယူမှု (trx)", + "filter_sbs": "စာရင်းသွင်းမှု (sbs)", + "free": "အခမဲ့", + "types": { + "transaction": "TRANSACTION", + "subscription": "SUBSCRIPTION" + }, + "table": { + "code": "ကူပွန်ကုဒ်", + "user": "သုံးစွဲသူ", + "product": "ထုတ်ကုန်", + "type": "ဝယ်ယူမှု အမျိုးအစား", + "discount": "လျှော့စျေး", + "final": "ပေးချေငွေ", + "date": "အသုံးပြုသည့် ရက်စွဲ" + }, + "empty": "အသုံးပြုမှု မှတ်တမ်း မရှိပါ", + "empty_description": "ကူပွန်အသုံးပြုထားသည့် မှတ်တမ်း မရှိသေးပါ။", + "errors": { + "load": "ကူပွန်မှတ်တမ်းများ ရယူခြင်း မအောင်မြင်ပါ" + } + }, "products": { "detail": { "title": "ထုတ်ကုန် အသေးစိတ်", @@ -1384,6 +1544,8 @@ "table": { "preview": "နမူနာ", "name": "အမည်", + "title": "ခေါင်းစဉ်", + "description": "ဖော်ပြချက်", "type": "အမျိုးအစား", "format": "ဖော်မတ်", "size": "အရွယ်အစား", @@ -1395,6 +1557,8 @@ "upload_dialog": { "title": "ဖိုင်များ တင်ရန်", "type_label": "ဖိုင် အမျိုးအစား", + "title_label": "ခေါင်းစဉ်", + "description_label": "ဖော်ပြချက်", "file_label": "ဖိုင်များ ရွေးချယ်ရန်", "choose_files": "ဖိုင်များ ရွေးချယ်ရန် (အများဆုံး {{count}} ခု)", "upload_button": "တင်မည်", diff --git a/src/models/asset.model.ts b/src/models/asset.model.ts index e4342b76..a047a077 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"]; @@ -47,6 +47,8 @@ export interface IAssetChild { size_bytes?: IAsset["size_bytes"]; duration_secs?: IAsset["duration_secs"]; parent_asset_id?: IAsset["parent_asset_id"]; + discarded_at?: string | null; + undiscarded_at?: string | null; created_at: IAsset["created_at"]; updated_at: IAsset["updated_at"]; } diff --git a/src/modules/admin/access/pages/AdminAccessDetailPage.tsx b/src/modules/admin/access/pages/AdminAccessDetailPage.tsx index 4283ae73..99145c89 100644 --- a/src/modules/admin/access/pages/AdminAccessDetailPage.tsx +++ b/src/modules/admin/access/pages/AdminAccessDetailPage.tsx @@ -2,15 +2,16 @@ import React from "react"; import { useParams } from "react-router-dom"; import AppRoutes from "../../../../AppRoutes"; import { iconsLib } from "../../../../assets"; -import { StatusBadge } from "../../../../design"; -import { DateTime, DateTimeFormats } from "../../../../design"; import { - AdminDetailField, - AdminDetailGrid, - AdminDetailHeader, - AdminDetailSection, - AdminState, -} from "../../components"; + DateTime, + DateTimeFormats, + DetailField, + DetailGrid, + DetailHeader, + DetailSection, + StatusBadge, +} from "../../../../design"; +import { AdminState } from "../../components"; import { useAdminDetail } from "../../hooks/useAdminDetail"; import { AppLocales, useTranslate } from "../../../../locales"; import AccessController from "../access.controller"; @@ -30,51 +31,61 @@ export const AdminAccessDetailPage: React.FC = () => { const listPath = AppRoutes.client.protected.admin.ACCESSES; return (
- : undefined} + entityId={access?.id} /> {error ? ( ) : access ? (
- - - + + - - + - - - - - + + + + - } /> - : "—" } /> - { : t(AppLocales.Admin.Accesses.Detail.Never) } /> - - { /> } /> - - + +
) : null}
diff --git a/src/modules/admin/ai/pages/AdminAiProfileCreatePage.tsx b/src/modules/admin/ai/pages/AdminAiProfileCreatePage.tsx index 303f96ae..19c1aa39 100644 --- a/src/modules/admin/ai/pages/AdminAiProfileCreatePage.tsx +++ b/src/modules/admin/ai/pages/AdminAiProfileCreatePage.tsx @@ -6,7 +6,7 @@ import AppRoutes from "../../../../AppRoutes"; import { useLoading } from "../../../../contexts/LoadingContext"; import { useToast } from "../../../../contexts/ToastContext"; import { useDocumentTitle } from "../../../../hooks"; -import { Dropdown } from "../../../../design"; +import { DetailHeader, Dropdown } from "../../../../design"; import AiController from "../ai.controller"; import type { IAdminAiProfileFormValues } from "../types"; import { @@ -16,7 +16,6 @@ import { TextInput, TextArea, Toggle, - AdminDetailHeader, } from "../../components"; import { AI_PROVIDER_OPTIONS, @@ -54,7 +53,7 @@ export const AdminAiProfileCreatePage: React.FC = () => { ...list.map((m) => ({ value: m.value, label: m.label })), { value: "__custom__", - label: t(AppLocales.Admin.Ai.CustomModel) || "Custom Model...", + label: t(AppLocales.Admin.Ai.CustomModel), }, ]; }, [provider, t]); @@ -139,7 +138,7 @@ export const AdminAiProfileCreatePage: React.FC = () => { onClose={() => setAlertMessage("")} /> - { }} className={`px-2 py-0.5 rounded text-caption font-mono transition-colors cursor-pointer ${ key === preset.value - ? "bg-primary text-white font-semibold shadow-xs" + ? "bg-primary text-primary-content font-semibold shadow-xs" : "bg-base-200 text-base-content/70 hover:bg-base-300" }`} > diff --git a/src/modules/admin/ai/pages/AdminAiProfileDetailPage.tsx b/src/modules/admin/ai/pages/AdminAiProfileDetailPage.tsx index 9cd41b9a..689b9af8 100644 --- a/src/modules/admin/ai/pages/AdminAiProfileDetailPage.tsx +++ b/src/modules/admin/ai/pages/AdminAiProfileDetailPage.tsx @@ -5,18 +5,18 @@ import { useNavigate, useParams } from "react-router-dom"; import AppRoutes from "../../../../AppRoutes"; import { iconsLib } from "../../../../assets"; import { - StatusBadge, - DateTime, - DateTimeFormats, Button, ButtonSizes, ButtonVariants, + DateTime, + DateTimeFormats, + DetailField, + DetailGrid, + DetailHeader, + DetailSection, + StatusBadge, } from "../../../../design"; import { - AdminDetailField, - AdminDetailGrid, - AdminDetailHeader, - AdminDetailSection, AdminState, } from "../../components"; import { useAdminDetail } from "../../hooks/useAdminDetail"; @@ -50,7 +50,7 @@ export const AdminAiProfileDetailPage: React.FC = () => { return (
- { title={profile?.name || t(AppLocales.Admin.Ai.ProfileDetail.Title)} description={t(AppLocales.Admin.Ai.ProfileDetail.Description)} backTo={listPath} + icon={iconsLib.cube} + statusBadge={ + profile ? ( + + ) : undefined + } + entityId={profile?.id} + timestamps={ + profile + ? { + createdAt: profile.created_at, + updatedAt: profile.updated_at, + } + : undefined + } action={ profile && can(ADMIN_ACTIONS.UPDATE, ADMIN_RESOURCES.AI_PROFILES) ? (
) : null} diff --git a/src/modules/admin/ai/pages/AdminAiProfileEditPage.tsx b/src/modules/admin/ai/pages/AdminAiProfileEditPage.tsx index 54602ca9..ff289d9a 100644 --- a/src/modules/admin/ai/pages/AdminAiProfileEditPage.tsx +++ b/src/modules/admin/ai/pages/AdminAiProfileEditPage.tsx @@ -6,7 +6,7 @@ import AppRoutes from "../../../../AppRoutes"; import { useLoading } from "../../../../contexts/LoadingContext"; import { useToast } from "../../../../contexts/ToastContext"; import { useDocumentTitle } from "../../../../hooks"; -import { Dropdown } from "../../../../design"; +import { DetailHeader, Dropdown } from "../../../../design"; import AiController from "../ai.controller"; import type { IAdminAiProfile, @@ -20,7 +20,6 @@ import { TextInput, TextArea, Toggle, - AdminDetailHeader, } from "../../components"; import { AI_PROVIDER_OPTIONS, @@ -59,7 +58,7 @@ export const AdminAiProfileEditPage: React.FC = () => { ...list.map((m) => ({ value: m.value, label: m.label })), { value: "__custom__", - label: t(AppLocales.Admin.Ai.CustomModel) || "Custom Model...", + label: t(AppLocales.Admin.Ai.CustomModel), }, ]; }, [provider, t]); @@ -182,7 +181,7 @@ export const AdminAiProfileEditPage: React.FC = () => { onClose={() => setAlertMessage("")} /> - { () => [ { value: "", - label: t(AppLocales.Admin.Ai.Filters.AllProviders) || "All Providers", + label: t(AppLocales.Admin.Ai.Filters.AllProviders), }, ...AI_PROVIDER_OPTIONS.map((o) => ({ value: o.value, label: o.label })), ], @@ -137,7 +137,7 @@ export const AdminAiProfilesPage: React.FC = () => { return [ { value: "", - label: t(AppLocales.Admin.Ai.Filters.AllModels) || "All Models", + label: t(AppLocales.Admin.Ai.Filters.AllModels), }, ...models, ]; @@ -147,7 +147,7 @@ export const AdminAiProfilesPage: React.FC = () => { () => [ { value: "", - label: t(AppLocales.Admin.Ai.Filters.AllStatuses) || "All Statuses", + label: t(AppLocales.Admin.Ai.Filters.AllStatuses), }, { value: "active", label: ADMIN_COMMON_LABELS.ACTIVE }, { value: "inactive", label: ADMIN_COMMON_LABELS.INACTIVE }, @@ -339,10 +339,7 @@ export const AdminAiProfilesPage: React.FC = () => { value={searchInput} onChange={(e) => setSearchInput(e.target.value)} onClear={() => setSearchInput("")} - placeholder={ - t(AppLocales.Admin.Ai.Filters.SearchProfiles) || - "Search by name or key..." - } + placeholder={t(AppLocales.Admin.Ai.Filters.SearchProfiles)} searchableKeys={[ t(AppLocales.Admin.Ai.ProfileForm.KeyLabel), t(AppLocales.Admin.Ai.ProfileForm.NameLabel), diff --git a/src/modules/admin/ai/pages/AdminAiRunDetailPage.tsx b/src/modules/admin/ai/pages/AdminAiRunDetailPage.tsx index 9f6aef27..9d59b38d 100644 --- a/src/modules/admin/ai/pages/AdminAiRunDetailPage.tsx +++ b/src/modules/admin/ai/pages/AdminAiRunDetailPage.tsx @@ -4,14 +4,16 @@ import React from "react"; import { useParams } from "react-router-dom"; import AppRoutes from "../../../../AppRoutes"; import { iconsLib } from "../../../../assets"; -import { StatusBadge, DateTime, DateTimeFormats } from "../../../../design"; import { - AdminDetailField, - AdminDetailGrid, - AdminDetailHeader, - AdminDetailSection, - AdminState, -} from "../../components"; + DateTime, + DateTimeFormats, + DetailField, + DetailGrid, + DetailHeader, + DetailSection, + StatusBadge, +} from "../../../../design"; +import { AdminState } from "../../components"; import { useAdminDetail } from "../../hooks/useAdminDetail"; import { AppLocales, useTranslate } from "../../../../locales"; import AiController from "../ai.controller"; @@ -32,7 +34,7 @@ export const AdminAiRunDetailPage: React.FC = () => { return (
- { : t(AppLocales.Admin.Common.Detail.Details), }, ]} - title={t(AppLocales.Admin.Ai.RunDetail.Title)} - description={t(AppLocales.Admin.Ai.RunDetail.Description)} + title={run?.model ? `${run.model} (${run.feature || "run"})` : t(AppLocales.Admin.Ai.RunDetail.Title)} + description={run?.feature ? `Feature: ${run.feature}` : t(AppLocales.Admin.Ai.RunDetail.Description)} backTo={listPath} + icon={iconsLib.cube} + statusBadge={run ? : undefined} + entityId={run?.id} + timestamps={ + run + ? { + createdAt: run.created_at, + } + : undefined + } /> {error ? ( @@ -58,12 +70,12 @@ export const AdminAiRunDetailPage: React.FC = () => { ) : run ? (
{/* Top Card: Panoramic Run Overview (Full Width) */} - - - + { /> } /> - @@ -80,7 +92,7 @@ export const AdminAiRunDetailPage: React.FC = () => { } /> - @@ -88,7 +100,7 @@ export const AdminAiRunDetailPage: React.FC = () => { } /> - @@ -96,7 +108,7 @@ export const AdminAiRunDetailPage: React.FC = () => { } /> - @@ -104,7 +116,7 @@ export const AdminAiRunDetailPage: React.FC = () => { } /> - @@ -112,7 +124,7 @@ export const AdminAiRunDetailPage: React.FC = () => { } /> - @@ -122,7 +134,7 @@ export const AdminAiRunDetailPage: React.FC = () => { } /> - { ) } /> - { ) } /> - - + + {/* 2 Equal Columns: Performance Telemetry & Context Identifiers */}
- - - + { : "-" } /> - { : "-" } /> - { : "-" } /> - { : "-" } /> - { : "-" } /> - - + + - - - + @@ -216,7 +228,7 @@ export const AdminAiRunDetailPage: React.FC = () => {
} /> - { ) } /> - { ) } /> - - + +
{run.error && ( -
{run.error}
-
+ )} {run.request_metadata && Object.keys(run.request_metadata).length > 0 && ( -
                   {JSON.stringify(run.request_metadata, null, 2)}
                 
-
+ )}
) : null} diff --git a/src/modules/admin/ai/pages/AdminAiRunsPage.tsx b/src/modules/admin/ai/pages/AdminAiRunsPage.tsx index 634d7b6a..d529f361 100644 --- a/src/modules/admin/ai/pages/AdminAiRunsPage.tsx +++ b/src/modules/admin/ai/pages/AdminAiRunsPage.tsx @@ -110,7 +110,7 @@ export const AdminAiRunsPage: React.FC = () => { () => [ { value: "", - label: t(AppLocales.Admin.Ai.Filters.AllProviders) || "All Providers", + label: t(AppLocales.Admin.Ai.Filters.AllProviders), }, ...AI_PROVIDER_OPTIONS.map((o) => ({ value: o.value, label: o.label })), ], @@ -135,7 +135,7 @@ export const AdminAiRunsPage: React.FC = () => { return [ { value: "", - label: t(AppLocales.Admin.Ai.Filters.AllModels) || "All Models", + label: t(AppLocales.Admin.Ai.Filters.AllModels), }, ...models, ]; @@ -147,7 +147,7 @@ export const AdminAiRunsPage: React.FC = () => { value: o.value, label: o.value === "" - ? t(AppLocales.Admin.Ai.Filters.AllStatuses) || o.label + ? t(AppLocales.Admin.Ai.Filters.AllStatuses) : o.label, })), [t], @@ -159,7 +159,7 @@ export const AdminAiRunsPage: React.FC = () => { value: o.value, label: o.value === "" - ? t(AppLocales.Admin.Ai.Filters.AllFeatures) || o.label + ? t(AppLocales.Admin.Ai.Filters.AllFeatures) : o.label, })), [t], @@ -324,10 +324,7 @@ export const AdminAiRunsPage: React.FC = () => { value={searchInput} onChange={(e) => setSearchInput(e.target.value)} onClear={() => setSearchInput("")} - placeholder={ - t(AppLocales.Admin.Ai.Filters.SearchRuns) || - "Search runs..." - } + placeholder={t(AppLocales.Admin.Ai.Filters.SearchRuns)} searchableKeys={[ t(AppLocales.Admin.Ai.RunsTable.Feature), t(AppLocales.Admin.Ai.RunsTable.Provider), diff --git a/src/modules/admin/analytics/components/ChatActivityChart.tsx b/src/modules/admin/analytics/components/ChatActivityChart.tsx index ee08ba86..d7e197d3 100644 --- a/src/modules/admin/analytics/components/ChatActivityChart.tsx +++ b/src/modules/admin/analytics/components/ChatActivityChart.tsx @@ -13,6 +13,7 @@ import { import { IAnalyticsTimeSeriesPoint } from "../types"; import { formatUtcToLocalLabel } from "../helpers/analyticsDate.helper"; import { ANALYTICS_GRAINS, type TAnalyticsGrain } from "../../constants"; +import { ANALYTICS_COLORS } from "../constants"; interface IChatActivityChartProps { data: IAnalyticsTimeSeriesPoint[]; @@ -74,10 +75,10 @@ export const ChatActivityChart: React.FC = ({

{formattedLabel}

-

+

User Messages: {item.user_messages}

-

+

AI Responses: {item.ai_messages}

@@ -95,13 +96,13 @@ export const ChatActivityChart: React.FC = ({ diff --git a/src/modules/admin/analytics/components/NotificationChannelsChart.tsx b/src/modules/admin/analytics/components/NotificationChannelsChart.tsx index 6216c6f5..027b5b39 100644 --- a/src/modules/admin/analytics/components/NotificationChannelsChart.tsx +++ b/src/modules/admin/analytics/components/NotificationChannelsChart.tsx @@ -9,12 +9,19 @@ import { Tooltip, } from "recharts"; import { IAnalyticsBreakdowns } from "../types"; +import { ANALYTICS_COLORS } from "../constants"; interface INotificationChannelsChartProps { breakdowns: IAnalyticsBreakdowns; } -const COLORS = ["#ff5757", "#38bdf8", "#34d399", "#fbbf24", "#a855f7"]; +const COLORS = [ + ANALYTICS_COLORS.PRIMARY, + ANALYTICS_COLORS.ACCENT, + ANALYTICS_COLORS.SUCCESS, + ANALYTICS_COLORS.WARNING, + ANALYTICS_COLORS.PURPLE, +]; export const NotificationChannelsChart: React.FC< INotificationChannelsChartProps diff --git a/src/modules/admin/analytics/components/RevenueChart.tsx b/src/modules/admin/analytics/components/RevenueChart.tsx index 1f97685e..d6f7b296 100644 --- a/src/modules/admin/analytics/components/RevenueChart.tsx +++ b/src/modules/admin/analytics/components/RevenueChart.tsx @@ -12,6 +12,7 @@ import { import { IAnalyticsTimeSeriesPoint } from "../types"; import { formatUtcToLocalLabel } from "../helpers/analyticsDate.helper"; import { ANALYTICS_GRAINS, type TAnalyticsGrain } from "../../constants"; +import { ANALYTICS_COLORS } from "../constants"; interface IRevenueChartProps { data: IAnalyticsTimeSeriesPoint[]; @@ -43,8 +44,8 @@ export const RevenueChart: React.FC = ({ > - - + + = ({ = ({ x2="0" y2="1" > - - + + = ({

{formattedLabel}

-

+

{item.new_users} new user {item.new_users === 1 ? "" : "s"}

@@ -99,7 +100,7 @@ export const UserGrowthChart: React.FC = ({ + Pick< + IAsset, + | "name" + | "title" + | "description" + | "type" + | "assetable_type" + | "assetable_id" + > >, ): Promise<{ success: boolean; diff --git a/src/modules/admin/asset/asset.service.ts b/src/modules/admin/asset/asset.service.ts index 1af8e93e..e5abbc1e 100644 --- a/src/modules/admin/asset/asset.service.ts +++ b/src/modules/admin/asset/asset.service.ts @@ -39,6 +39,8 @@ class AssetService { file: File, options?: { type?: string; + title?: string; + description?: string; assetable_type?: string; assetable_id?: string; folder?: string; @@ -47,6 +49,8 @@ class AssetService { const formData = new FormData(); formData.append("file", file); if (options?.type) formData.append("type", options.type); + if (options?.title) formData.append("title", options.title); + if (options?.description) formData.append("description", options.description); if (options?.assetable_type) formData.append("assetable_type", options.assetable_type); if (options?.assetable_id) @@ -63,7 +67,15 @@ class AssetService { async updateAsset( id: string, data: Partial< - Pick + Pick< + IAsset, + | "name" + | "title" + | "description" + | "type" + | "assetable_type" + | "assetable_id" + > >, ): Promise>> { return api.put<{ asset: IAdminAsset }>( diff --git a/src/modules/admin/asset/components/AdminAssetChildrenTable.tsx b/src/modules/admin/asset/components/AdminAssetChildrenTable.tsx index 4bd64798..1fd0c70f 100644 --- a/src/modules/admin/asset/components/AdminAssetChildrenTable.tsx +++ b/src/modules/admin/asset/components/AdminAssetChildrenTable.tsx @@ -22,12 +22,22 @@ export interface IAdminAssetChildrenTableProps { assets?: IAssetChild[]; onDownload?: (asset: IAssetChild) => void | Promise; onEdit?: (asset: IAssetChild) => void; + onDiscard?: (asset: IAssetChild) => void; + onUndiscard?: (asset: IAssetChild) => void; + onDestroy?: (asset: IAssetChild) => void; + onRowClick?: (asset: IAssetChild) => void; } -export const AdminAssetChildrenTable: React.FC = ({ +export const AdminAssetChildrenTable: React.FC< + IAdminAssetChildrenTableProps +> = ({ assets = [], onDownload, onEdit, + onDiscard, + onUndiscard, + onDestroy, + onRowClick, }) => { const t = useTranslate(); @@ -58,17 +68,25 @@ export const AdminAssetChildrenTable: React.FC = }, }, { - key: ADMIN_ASSET_COLUMNS.NAME, - header: t(AppLocales.Admin.Assets.Table.Name), + key: ADMIN_ASSET_COLUMNS.TITLE, + header: t(AppLocales.Admin.Assets.Table.Title), className: "min-w-56 max-w-72", render: (asset) => (
- - {asset.name} - - - {asset.id} + + {asset.title || asset.name} + {asset.title && ( + + {asset.name} + + )}
), }, @@ -89,7 +107,9 @@ export const AdminAssetChildrenTable: React.FC = { key: ADMIN_ASSET_COLUMNS.STATUS, header: t(AppLocales.Admin.Assets.Detail.Status), - render: (asset) => , + render: (asset) => ( + + ), }, { key: ADMIN_ASSET_COLUMNS.SIZE, @@ -110,37 +130,64 @@ export const AdminAssetChildrenTable: React.FC = }, ]; - if (onDownload || onEdit) { + if (onDownload || onEdit || onDiscard || onUndiscard || onDestroy) { columns.push({ key: ADMIN_ASSET_COLUMNS.ACTIONS, header: t(AppLocales.Admin.Assets.Table.Actions), className: "text-right", - render: (asset) => ( -
- {onDownload && ( - - )} - {onEdit && ( - onEdit(asset), - }, - ]} - /> - )} -
- ), + render: (asset) => { + const isDiscarded = Boolean(asset.discarded_at); + const actions = []; + if (onEdit && !isDiscarded) { + actions.push({ + type: ADMIN_ACTIONS.EDIT, + onClick: () => onEdit(asset), + }); + } + if (onDiscard && !isDiscarded) { + actions.push({ + type: ADMIN_ACTIONS.DISCARD, + onClick: () => onDiscard(asset), + }); + } + if (onUndiscard && isDiscarded) { + actions.push({ + type: ADMIN_ACTIONS.UNDISCARD, + onClick: () => onUndiscard(asset), + }); + } + if (onDestroy && isDiscarded) { + actions.push({ + type: ADMIN_ACTIONS.DESTROY, + onClick: () => onDestroy(asset), + }); + } + + return ( +
+ {onDownload && ( + + )} + {actions.length > 0 && ( + + )} +
+ ); + }, }); } @@ -157,6 +204,7 @@ export const AdminAssetChildrenTable: React.FC = columns={columns} records={assets} getRowKey={(asset) => asset.id} + onRowClick={onRowClick} /> ); }; diff --git a/src/modules/admin/asset/components/AdminAssetSelectDialog.tsx b/src/modules/admin/asset/components/AdminAssetSelectDialog.tsx index f3140057..f9076e04 100644 --- a/src/modules/admin/asset/components/AdminAssetSelectDialog.tsx +++ b/src/modules/admin/asset/components/AdminAssetSelectDialog.tsx @@ -98,7 +98,7 @@ export const AdminAssetSelectDialog: React.FC = ({
@@ -107,10 +107,7 @@ export const AdminAssetSelectDialog: React.FC = ({
= ({
{assetType && (
- {t(AppLocales.Admin.Assets.Table.Type, "Type")}: + {t(AppLocales.Admin.Assets.Table.Type)}: {assetType} @@ -142,17 +139,14 @@ export const AdminAssetSelectDialog: React.FC = ({
- {t(AppLocales.Common.Loading, "Loading...")} + {t(AppLocales.Common.Loading)}
) : assets.length === 0 ? (

- {t( - AppLocales.Admin.Assets.Picker.NoAssets, - "No assets found matching criteria.", - )} + {t(AppLocales.Admin.Assets.Picker.NoAssets)}

) : ( @@ -169,7 +163,7 @@ export const AdminAssetSelectDialog: React.FC = ({ onClick={() => setSelectedAsset(asset)} className={`flex flex-col text-left p-2 h-auto rounded-xl border transition-all duration-200 ${ isSelected - ? "border-primary bg-primary/10 shadow-[0_0_12px_rgba(255,94,98,0.25)] ring-2 ring-primary" + ? "border-primary bg-primary/10 shadow-[0_0_12px_rgba(var(--color-primary-rgb),0.25)] ring-2 ring-primary" : "border-base-200 bg-base-100 hover:border-primary/50 hover:bg-base-200/50" }`} > @@ -180,7 +174,7 @@ export const AdminAssetSelectDialog: React.FC = ({ className="w-full h-full object-cover" /> {isSelected && ( -
+
)} @@ -188,10 +182,18 @@ export const AdminAssetSelectDialog: React.FC = ({
- {asset.name} + {asset.title || asset.name} + {asset.title && ( + + {asset.name} + + )}
{asset.format || "Media"} @@ -242,14 +244,14 @@ export const AdminAssetSelectDialog: React.FC = ({ {/* Dialog Actions */}
diff --git a/src/modules/admin/asset/constants.ts b/src/modules/admin/asset/constants.ts index 7620fb34..aebbd99e 100644 --- a/src/modules/admin/asset/constants.ts +++ b/src/modules/admin/asset/constants.ts @@ -5,6 +5,7 @@ export const ADMIN_ASSET_COLUMNS = { ACTIONS: "actions", PREVIEW: "preview", NAME: "name", + TITLE: "title", TYPE: "type", FORMAT: "format", STATUS: "status", diff --git a/src/modules/admin/asset/pages/AdminAssetCreatePage.tsx b/src/modules/admin/asset/pages/AdminAssetCreatePage.tsx index b9d1a7b8..1952edba 100644 --- a/src/modules/admin/asset/pages/AdminAssetCreatePage.tsx +++ b/src/modules/admin/asset/pages/AdminAssetCreatePage.tsx @@ -17,7 +17,7 @@ import { Admin } from "../.."; export const AdminAssetCreatePage: React.FC = () => { const t = useTranslate(); useDocumentTitle( - `${t(AppLocales.Admin.Assets.UploadDialog.Title, "Upload Assets")} | Admin`, + `${t(AppLocales.Admin.Assets.UploadDialog.Title)} | Admin`, ); const navigate = useNavigate(); @@ -28,6 +28,7 @@ export const AdminAssetCreatePage: React.FC = () => { files: File[], type: string, onProgress: (percent: number, msg: string) => void, + meta?: { title?: string; description?: string }, ) => { const total = files.length; @@ -36,14 +37,17 @@ export const AdminAssetCreatePage: React.FC = () => { onProgress( Math.round((i / total) * 100), t(AppLocales.Admin.Assets.UploadDialog.UploadingStatus, { - defaultValue: `Uploading file {{current}} of {{total}}: {{name}}`, current: i + 1, total, name: file.name, }), ); - const response = await Admin.AssetController.uploadAsset(file, { type }); + const response = await Admin.AssetController.uploadAsset(file, { + type, + title: meta?.title, + description: meta?.description, + }); if (!response.success) { throw new Error( @@ -55,7 +59,6 @@ export const AdminAssetCreatePage: React.FC = () => { onProgress( Math.round(((i + 1) / total) * 100), t(AppLocales.Admin.Assets.UploadDialog.UploadingStatus, { - defaultValue: `Uploading file {{current}} of {{total}}: {{name}}`, current: i + 1, total, name: file.name, @@ -66,7 +69,6 @@ export const AdminAssetCreatePage: React.FC = () => { if (total > 1) { toast.success( t(AppLocales.Admin.Assets.Toasts.BulkUploadSuccess, { - defaultValue: `Successfully uploaded ${total} assets`, count: total, }), ); @@ -91,7 +93,7 @@ export const AdminAssetCreatePage: React.FC = () => {
{ }; export const AdminAssetDetailPage: React.FC = () => { const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const toast = useToast(); const t = useTranslate(); - const { record: asset, error } = useAdminDetail(id, loadAsset); + const { record: asset, error, reload } = useAdminDetail(id, loadAsset); const listPath = AppRoutes.client.protected.admin.ASSETS; + + const [childToDiscard, setChildToDiscard] = useState(null); + const [childToUndiscard, setChildToUndiscard] = useState(null); + const [childToDestroy, setChildToDestroy] = useState(null); + const preview = asset?.format?.toLowerCase() === "image" ? asset.url @@ -43,32 +56,85 @@ export const AdminAssetDetailPage: React.FC = () => { link.click(); }; + const handleDiscardChild = async () => { + if (!childToDiscard) return; + const result = await AssetController.discardAsset(childToDiscard.id); + if (result.success) { + toast.success(result.message || t(AppLocales.Admin.Assets.Toasts.DiscardSuccess)); + setChildToDiscard(null); + reload(); + } else { + toast.error(result.error || t(AppLocales.Admin.Assets.Errors.DiscardFailed)); + } + }; + + const handleUndiscardChild = async () => { + if (!childToUndiscard) return; + const result = await AssetController.undiscardAsset(childToUndiscard.id); + if (result.success) { + toast.success(result.message || t(AppLocales.Admin.Assets.Toasts.RestoreSuccess)); + setChildToUndiscard(null); + reload(); + } else { + toast.error(result.error || t(AppLocales.Admin.Assets.Errors.RestoreFailed)); + } + }; + + const handleDestroyChild = async () => { + if (!childToDestroy) return; + const result = await AssetController.destroyAsset(childToDestroy.id); + if (result.success) { + toast.success(result.message || t(AppLocales.Admin.Assets.Toasts.DestroySuccess)); + setChildToDestroy(null); + reload(); + } else { + toast.error(result.error || t(AppLocales.Admin.Assets.Errors.DestroyFailed)); + } + }; + return (
- : undefined} + entityId={asset?.id} + timestamps={ + asset + ? { + createdAt: asset.created_at, + updatedAt: asset.updated_at, + } + : undefined + } /> {error ? ( ) : asset ? (
- + {preview ? ( - {asset.name} +
+ {asset.title +
) : ( -
- +
+
)}
@@ -76,49 +142,68 @@ export const AdminAssetDetailPage: React.FC = () => { variant={ButtonVariants.SECONDARY} fullWidth onClick={() => void handleDownload(asset)} - className="gap-1.5" + className="gap-2 font-semibold" > - + {t(AppLocales.Admin.Assets.Download.Action)}
- - + - - + {asset.title && ( + + )} + - - - + )} + + + } /> - - - + - - - - + + { + navigate( + AppRoutes.client.protected.admin.ASSET_DETAIL.replace( + ":id", + child.id, + ), + ) + } + onEdit={(child) => + navigate( + AppRoutes.client.protected.admin.ASSET_EDIT.replace( + ":id", + child.id, + ), + ) + } + onDiscard={(child) => setChildToDiscard(child)} + onUndiscard={(child) => setChildToUndiscard(child)} + onDestroy={(child) => setChildToDestroy(child)} /> - +
) : null} + + setChildToDiscard(null)} + isDestructive + /> + + setChildToUndiscard(null)} + /> + + setChildToDestroy(null)} + isDestructive + />
); }; \ No newline at end of file diff --git a/src/modules/admin/asset/pages/AdminAssetEditPage.tsx b/src/modules/admin/asset/pages/AdminAssetEditPage.tsx index 9fbb1e7f..acac4da1 100644 --- a/src/modules/admin/asset/pages/AdminAssetEditPage.tsx +++ b/src/modules/admin/asset/pages/AdminAssetEditPage.tsx @@ -132,6 +132,9 @@ export const AdminAssetEditPage: React.FC = () => { useEffect(() => { if (!id) return; + setAsset(null); + setError(""); + const loadAsset = async () => { setLoading(true); const result = await Admin.AssetController.getAsset(id); @@ -156,6 +159,8 @@ export const AdminAssetEditPage: React.FC = () => { const result = await Admin.AssetController.updateAsset(id, { name: values.name, + title: values.title, + description: values.description, type: values.type, }); setLoading(false, { overlay: false }); @@ -276,6 +281,7 @@ export const AdminAssetEditPage: React.FC = () => { /> ) : asset ? ( void, + meta?: { title?: string; description?: string }, ) => Promise; onSubmitEdit?: (values: IAdminAssetEditFormValues) => Promise; onCompress?: () => Promise; @@ -93,15 +97,31 @@ export const AdminAssetForm: React.FC = ({ // Create Mode state const [fileItems, setFileItems] = useState([]); const [uploadType, setUploadType] = useState(ASSET_TYPES.GENERAL); + const [uploadTitle, setUploadTitle] = useState(""); + const [uploadDescription, setUploadDescription] = useState(""); const [uploadProgress, setUploadProgress] = useState(0); const [uploadStatusMessage, setUploadStatusMessage] = useState(""); const [hasOversizedFiles, setHasOversizedFiles] = useState(false); // Edit Mode state const [editName, setEditName] = useState(asset?.name ?? ""); + const [editTitle, setEditTitle] = useState(asset?.title ?? ""); + const [editDescription, setEditDescription] = useState( + asset?.description ?? "", + ); const [editType, setEditType] = useState(asset?.type ?? ASSET_TYPES.GENERAL); + useEffect(() => { + if (asset) { + setEditName(asset.name ?? ""); + setEditTitle(asset.title ?? ""); + setEditDescription(asset.description ?? ""); + setEditType(asset.type ?? ASSET_TYPES.GENERAL); + } + }, [asset?.id, asset?.name, asset?.title, asset?.description, asset?.type]); + const [alertMessage, setAlertMessage] = useState(""); + const isChildAsset = Boolean(asset?.parent_asset_id); const filteredTypeOptions = useMemo( () => ASSET_TYPE_OPTIONS.filter((opt) => opt.value !== ""), @@ -124,7 +144,6 @@ export const AdminAssetForm: React.FC = ({ setAlertMessage( t(AppLocales.Admin.Assets.UploadDialog.MaxFilesExceeded, { count: UPLOAD_SIZE_LIMITS.MAX_FILE_COUNT, - defaultValue: `Maximum ${UPLOAD_SIZE_LIMITS.MAX_FILE_COUNT} files allowed per batch upload.`, }), ); return; @@ -157,7 +176,6 @@ export const AdminAssetForm: React.FC = ({ setAlertMessage( t(AppLocales.Admin.Assets.UploadDialog.FileSizeExceeded, { names: oversizedFiles.join(", "), - defaultValue: `Some files exceed maximum size limit: ${oversizedFiles.join(", ")}`, }), ); } @@ -202,6 +220,10 @@ export const AdminAssetForm: React.FC = ({ setUploadProgress(percent); setUploadStatusMessage(msg); }, + { + title: uploadTitle.trim() || undefined, + description: uploadDescription.trim() || undefined, + }, ); } catch (err: unknown) { setAlertMessage(err instanceof Error ? err.message : "Upload failed"); @@ -222,7 +244,9 @@ export const AdminAssetForm: React.FC = ({ await onSubmitEdit({ name: trimmed, - type: editType, + title: editTitle.trim() || undefined, + description: editDescription.trim() || undefined, + type: isChildAsset && asset ? asset.type : editType, }); }; @@ -243,10 +267,7 @@ export const AdminAssetForm: React.FC = ({

- {t( - AppLocales.Admin.Assets.UploadDialog.BulkNoticeTitle, - "Upload Guidelines", - )} + {t(AppLocales.Admin.Assets.UploadDialog.BulkNoticeTitle)}

@@ -361,10 +382,7 @@ export const AdminAssetForm: React.FC = ({ } > - {t( - AppLocales.Admin.Assets.UploadDialog.CompressImages, - "Compress Images (TinyPNG)", - )} + {t(AppLocales.Admin.Assets.UploadDialog.CompressImages)} ↗ @@ -381,10 +399,7 @@ export const AdminAssetForm: React.FC = ({ } > - {t( - AppLocales.Admin.Assets.UploadDialog.CompressVideos, - "Compress Videos (FreeConvert)", - )} + {t(AppLocales.Admin.Assets.UploadDialog.CompressVideos)} ↗ @@ -405,16 +420,10 @@ export const AdminAssetForm: React.FC = ({
- {t( - AppLocales.Admin.Assets.UploadDialog.CompressTipTitle, - "Compression Recommended", - )} + {t(AppLocales.Admin.Assets.UploadDialog.CompressTipTitle)}

- {t( - AppLocales.Admin.Assets.UploadDialog.CompressTipDesc, - "Built-in media compression is not yet available for oversized files. Please compress large files before uploading:", - )} + {t(AppLocales.Admin.Assets.UploadDialog.CompressTipDesc)}

@@ -431,10 +440,7 @@ export const AdminAssetForm: React.FC = ({
({ value: opt.value, @@ -444,16 +450,29 @@ export const AdminAssetForm: React.FC = ({ disabled={isLoading} /> + setUploadTitle(e.target.value)} + placeholder="Optional title" + disabled={isLoading} + /> + +