🚀 feat: launch v1.0 — next-generation frontend built from the ground up (#4265)
* feat: add parameter coverage for the operations: copy, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, and regex_replace * fix: CrossGroupRetry default false 移除gorm:"default:false",避免每次 AutoMigrate时都执行ALTER TABLE `tokens` MODIFY COLUMN `cross_group_retry` boolean DEFAULT false 且bool默认false不影响原有功能 * feat: check-in feature integrates Turnstile security check * feat: add support for Doubao /v1/responses (#2567) * feat: add support for Doubao /v1/responses * fix: fix model deployment style issues, lint problems, and i18n gaps. (#2556) * fix: fix model deployment style issues, lint problems, and i18n gaps. * fix: adjust the key not to be displayed on the frontend, tested via the backend. * fix: adjust the sidebar configuration logic to use the default configuration items if they are not defined. * feat: add plans directory to .gitignore * fix: 修复 gemini 文件类型不支持 image/jpg * fix: fix the proxyURL is empty, not using the default HTTP client configuration && the AWS calling side did not apply the relay timeout. * fix: batch add key backend deduplication * Merge pull request #2582 from seefs001/fix/tips fix: add tips for model management and channel testing * fix(gin): update request body size check to allow zero limit * feat: add regex pattern to mask API keys in sensitive information * fix(task): 修复使用 auto 分组时 Task Relay 不记录日志和不扣费的问题 问题描述: - 使用 auto 分组的令牌调用 /v1/videos 等 Task 接口时,虽然任务能成功创建, 但使用日志不显示记录,且不会扣费 根本原因: - Distribute 中间件在选择渠道后,会将实际选中的分组存储在 ContextKeyAutoGroup 中 - 但 RelayTaskSubmit 函数没有从 context 中读取这个值来更新 info.UsingGroup - 导致 info.UsingGroup 始终是 "auto" 而不是实际选中的分组(如 "sora2逆") - 当 auto 分组的倍率配置为 0 时,quota 计算结果为 0 - 日志记录条件 "if quota != 0" 不满足,导致日志不记录、不扣费 修复方案: - 在 RelayTaskSubmit 函数中计算分组倍率之前,添加从 ContextKeyAutoGroup 获取实际分组的逻辑 - 使用安全的类型断言,避免潜在的 panic 风险 影响范围: - 仅影响 Task Relay 流程(/v1/videos, /suno, /kling 等接口) - 不影响使用具体分组令牌的调用 - 不影响其他 Relay 类型(chat/completions 等已有类似处理逻辑) * 🚀 feat(web): port legacy v2 frontend changes into new UI (deployments, check-in, ollama) + align APIs Bring over the key frontend functionality introduced in merge `efa3301` and integrate it cleanly into the new `web/src` architecture and design system. - **Model deployments (io.net)** - Align frontend endpoints and payloads with backend deployment routes (`/api/deployments/*`) - Add missing deployment operations: details, logs (container-aware), update config, rename, extend duration - Improve create-deployment flow (proper request shape, name availability check, price estimation parity) - **System settings** - Enhance io.net deployment settings: allow testing connection with an unsaved API key and add “how to get API key” guidance - **Channels / Ollama** - Improve Ollama model management: live fetch via base_url with fallback to channel fetch, selection + apply flows, delete confirmation - Refactor for feature-layer consistency: extract Ollama parsing/normalization utilities into `features/channels/lib` - **Quality** - Ensure TypeScript typecheck passes after refactor and new dialogs/components integration * Merge pull request #2590 from xyfacai/fix/max-body-limit fix: 设置默认max req body 为128MB * docs: update readme * i18n: add missing translations * fix(gemini): fetch model list via native v1beta/models endpoint Use the native Gemini Models API (/v1beta/models) instead of the OpenAI-compatible path when listing models for Gemini channels, improving compatibility with third-party Gemini-format providers that don't implement OpenAI routes. - Add paginated model listing with timeout and optional proxy support - Select an enabled key for multi-key Gemini channels * refactor(gemini): 更新 GeminiModelsResponse 以使用 dto.GeminiModel 类型 * fix: remove Minimax from FETCHABLE channels * fix(minimax): 添加 MiniMax-M2 系列模型到 ModelList * feat: add doubao video 1.5 * 🤢 chore: remove useless file * feat: /v1/chat/completion -> /v1/response (#2629) * feat: /v1/chat/completion -> /v1/response * fix: clean propertyNames for gemini function * fix: support snake_case fields in GeminiChatGenerationConfig * chore: update dependencies and lockfile for improved compatibility - Updated @clerk/clerk-react to version 5.59.3 - Updated @hookform/resolvers to version 5.2.2 - Updated @lobehub/icons to version 2.48.0 - Updated various Radix UI components to their latest versions - Updated @tanstack/react-query and related packages for better performance - Updated axios, i18next, and other libraries for security and feature enhancements - Updated lockfile to include configVersion and ensure consistency across environments * Merge pull request #2647 from seefs001/feature/status-code-auto-disable feat: status code auto-disable configuration * fix: chat2response setting ui (#2643) * fix: setting ui * fix: rm global.chat_completions_to_responses_policy * fix: rm global.chat_completions_to_responses_policy * Merge pull request #2627 from seefs001/feature/channel-test-param-override feat: channel testing supports parameter overriding * chore: update dependencies and lockfile for improved compatibility - Updated @lobehub/icons to version 4.0.3 - Updated ai to version 6.0.27 - Updated various libraries including axios, react-day-picker, and streamdown for security and feature enhancements - Updated devDependencies for eslint, prettier, and typescript for better performance and compatibility - Updated lockfile to ensure consistency across environments * chore: update lockfile and Vite configuration for improved build process - Updated lockfile to version 1 for better compatibility and consistency - Enhanced Vite configuration to support production optimizations, including code minification and chunking for dependencies - Added environment-specific console and debugger removal for production builds * chore: migrate from Vite to Rsbuild for build process - Added Rsbuild configuration for development and production builds - Updated package.json scripts to use Rsbuild instead of Vite - Replaced @tailwindcss/vite with @tailwindcss/postcss in dependencies - Introduced postcss.config.mjs for Tailwind CSS integration - Updated TypeScript configuration to include Rsbuild config - Removed Vite configuration file to streamline the build process * refactor: optimize user data handling and API calls - Replaced direct API calls to get user data with cached user information from auth-store in ModelsFilter and SummaryCards components. - Improved session management in RootComponent and Authenticated route to utilize localStorage for user authentication status, reducing unnecessary API requests. - Added caching for setup status checks to enhance performance during navigation. * feat: enhance session validation in authenticated route - Implemented session verification to check user authentication status via API call only once per session. - Updated beforeLoad logic to redirect users to the login page if session validation fails or if no user information is available in localStorage. - Improved user data handling by updating the auth store with fresh user information upon successful session verification. * refactor: improve useMediaQuery hook for better SSR handling - Enhanced the useMediaQuery hook to check for window availability before accessing matchMedia, preventing errors during server-side rendering. - Simplified state initialization and change handling by using a dedicated function to determine initial matches. - Updated event listener management for improved performance and clarity. * feat(hooks): export useMediaQuery from hooks index * refactor: update useMediaQuery imports to use unified hooks index * fix(rsbuild): fix loadEnv API usage and removeConsole type * feat: customizable automatic retry status codes * refactor(hooks): use useSyncExternalStore for better SSR handling in useMediaQuery * refactor: simplify embedded file structure in main.go - Updated the embedded file directive to include the entire web/dist directory instead of individual assets, streamlining the build process. * refactor: replace DropdownMenu with Sheet component in ProfileDropdown - Updated the ProfileDropdown component to use a Sheet for user interactions instead of a DropdownMenu. - Enhanced user info display with improved layout and styling. - Added navigation links and sign-out functionality within the Sheet. * refactor: streamline ProfileDropdown layout and improve user info display - Removed unused Badge component and secondary text from user display. - Enhanced styling for user info section and navigation links. - Updated sign-out functionality to use a button for better accessibility. * feat: add System Settings link for super admin in ProfileDropdown - Introduced a new link to System Settings in the ProfileDropdown, visible only to users with the SUPER_ADMIN role. - Updated imports to include the Settings icon and adjusted the component logic accordingly. - Removed the Settings entry from the sidebar data to streamline navigation. * feat: codex channel (#2652) * feat: codex channel * feat: codex channel * feat: codex oauth flow * feat: codex refresh cred * feat: codex usage * fix: codex err message detail * fix: codex setting ui * feat: codex refresh cred task * fix: import err * fix: codex store must be false * fix: chat -> responses tool call * fix: chat -> responses tool call * feat(i18n): add missing translations * fix(i18n): restore missing translations for "360" and add "User Menu" in multiple locales - Reintroduced the translation for "360" in English, French, Japanese, Russian, Vietnamese, and Chinese locales. - Added the "User Menu" translation in the same languages to enhance user interface consistency. * fix: openAI function to gemini function field adjusted to whitelist mode * feat: TLS_INSECURE_SKIP_VERIFY env * fix: for chat-based calls to the Claude model, tagging is required. Using Claude's rendering logs, the two approaches handle input rendering differently. * refactor(system-settings): restructure settings sections and navigation - Replaced SettingsAccordion with a unified SettingsSection component across various settings sections for consistency. - Introduced a section registry to manage general settings sections dynamically. - Updated navigation items in the system settings sidebar to utilize the new section registry. - Enhanced the GeneralSettings component to support section-based content rendering based on user selection. * fix(system-settings): remove type assertion for quotaDisplayType in GeneralSettings - Eliminated the type assertion for quotaDisplayType in the GeneralSettings component to improve type inference and maintain cleaner code. * refactor(system-settings): update zod import syntax in general settings - Changed the import statement for zod from a default import to a namespace import for better clarity and consistency in the codebase. * fix: the login method cannot be displayed under the aff link. * feat(system-settings): implement generic settings page and enhance navigation - Added a new generic SettingsPage component to handle loading states, data fetching, and section rendering. - Integrated section registry for general and authentication settings to streamline navigation and content management. - Updated URL utility functions to improve query parameter handling for active navigation states. - Enhanced the system settings sidebar to include authentication section items dynamically. * refactor(system-settings): replace SettingsAccordion with SettingsSection across authentication settings - Updated BasicAuthSection, BotProtectionSection, OAuthSection, and PasskeySection to use the new SettingsSection component for consistency. - Introduced a section registry to manage authentication settings dynamically, enhancing navigation and content rendering. * feat(system-settings): enhance request limits settings with new section and unified component - Added a new Request Limits section to the system settings sidebar, integrating it with the section registry for improved navigation. - Replaced SettingsAccordion with SettingsSection in RateLimitSection, SensitiveWordsSection, and SSRFSection for consistency. - Updated RequestLimitsSettings to utilize the new SettingsPage component for better data handling and rendering. - Implemented a search schema for request limits to streamline navigation and section management. * feat(system-settings): integrate content settings sections with unified component and registry - Added a new Content section to the system settings sidebar, incorporating it into the section registry for improved navigation. - Replaced SettingsAccordion with SettingsSection in multiple content-related components for consistency. - Created a section registry to manage content settings dynamically, enhancing the rendering and navigation experience. - Updated the ContentSettings component to utilize the new section registry and streamline content display. * feat(system-settings): enhance integrations settings with unified section registry and components - Introduced a new section registry for integrations settings, consolidating various settings components for better organization and navigation. - Replaced SettingsAccordion with SettingsSection in multiple integration-related components for consistency. - Updated IntegrationSettings to utilize the new SettingsPage component, improving data handling and rendering. - Added a new integrations section to the system settings sidebar, enhancing user experience and accessibility. * feat(system-settings): unify model settings with new section registry and components - Introduced a section registry for model settings, consolidating various model-related components for improved organization and navigation. - Replaced SettingsAccordion with SettingsSection in multiple model settings components for consistency. - Updated ModelSettings to utilize the new SettingsPage component, enhancing data handling and rendering. - Added a new Models section to the system settings sidebar, improving user experience and accessibility. * feat(system-settings): enhance maintenance settings with unified section registry and components - Introduced a new section registry for maintenance settings, consolidating various maintenance-related components for improved organization and navigation. - Replaced SettingsAccordion with SettingsSection in multiple maintenance components for consistency. - Updated MaintenanceSettings to utilize the new section registry, enhancing data handling and rendering. - Added a new Maintenance section to the system settings sidebar, improving user experience and accessibility. * feat(system-settings): update section titles for improved clarity and consistency - Renamed various section titles across content, integrations, maintenance, models, and request limits to enhance clarity and better reflect their functionalities. - Adjusted titles such as 'Dashboard' to 'Data Dashboard', 'API Info' to 'API Addresses', and 'Update Checker' to 'System maintenance' for improved user understanding. - Ensured consistency in naming conventions across all settings sections to streamline user experience and navigation. * feat(nav-group): enhance collapsible menu behavior and URL matching logic - Added controlled state management for collapsible menu items to automatically expand based on active sub-item paths. - Updated the URL matching logic in checkIsActive to improve handling of query parameters and ensure accurate navigation state detection. - Refactored the collapsible component to utilize the new state management, enhancing user experience in the sidebar navigation. * feat(system-settings): update system settings navigation and redirect logic - Changed the link in the profile dropdown to point directly to the general section of system settings with a search parameter for section identification. - Implemented a redirect in the general settings route to ensure users are directed to the default section if no section parameter is provided, enhancing navigation consistency. * feat(system-settings): unify route configuration for settings sections - Refactored route configuration for various system settings sections (auth, content, general, integrations, maintenance, models, request limits) to utilize a new `createSettingsRouteConfig` function. - This change consolidates the repetitive logic of creating search schemas and handling redirects, improving code maintainability and readability. - Enhanced navigation by ensuring default sections are loaded when no section parameter is provided. * feat(url-utils): enhance URL handling and matching logic - Introduced a new utility function `urlToString` to convert various URL formats (string and object) into a standardized string format. - Updated the `checkIsActive` function to utilize `urlToString`, improving the accuracy of URL matching and handling of query parameters. - Refactored URL comparison logic to ensure consistent behavior across different URL types, enhancing navigation state detection. * feat(system-settings): validate DataExportDefaultTime for improved data handling - Introduced a new function `validateDataExportDefaultTime` to ensure the `DataExportDefaultTime` value is either 'week', 'hour', or 'day', defaulting to 'hour' for unexpected values. - Updated the `DataExportDefaultTime` assignment in the settings section to utilize this validation function, enhancing data integrity and user experience. * perf(system-settings): Improve the i18n of system settings content - Changed button labels in various sections to use consistent capitalization and translation functions, enhancing user experience. - Updated validation messages in schemas to utilize translation functions for improved internationalization support. - Ensured all user-facing strings are properly translated, improving accessibility for non-English users. * fix(system-settings): update ApiInfoFormValues type inference for improved schema validation - Changed the type inference for ApiInfoFormValues to utilize ReturnType of createApiInfoSchema, ensuring accurate type representation and enhancing type safety in the API info section. * fix(chat-settings): improve validation logic for chat settings schema - Updated the validation logic to ensure that null values are correctly handled and that only objects are accepted as valid items in the chat settings schema. - Simplified error handling by removing the error message from the catch block, providing a consistent user-facing message for invalid JSON strings. * fix(system-settings): enhance validation error handling in uptime-kuma schema - Updated the validation logic for category name, URL, and slug fields to use an object format for error messages, improving clarity and consistency in user feedback. - Ensured that all validation messages are properly structured to enhance internationalization support. * fix(i18n): add translations for Uptime Kuma group management - Added English, French, Japanese, Russian, Vietnamese, and Chinese translations for "Add Uptime Kuma Group" and "Edit Uptime Kuma Group" to enhance internationalization support. - Included validation messages for category name and slug fields across multiple languages to improve user feedback and accessibility. * fix(system-settings): improve validation error message structure for SystemName - Updated the validation logic for the SystemName field to use an object format for error messages, enhancing clarity and consistency in user feedback. - This change aligns with recent improvements in internationalization support across the system settings schemas. * perf(i18n): add new validation error message translations - Added translations for the new validation error message "Invalid JSON format or values out of allowed range" in English, French, Japanese, Russian, Vietnamese, and Chinese. - This update enhances internationalization support by ensuring users receive clear feedback across multiple languages. * fix(i18n): update Japanese translation for payment method configuration message - Corrected the Japanese translation for the message regarding payment methods configuration to use the term "メソッド" instead of "方法" for improved accuracy and consistency in user feedback. - This change enhances the clarity of the message for Japanese-speaking users. * fix(i18n): remove unnecessary loading messages from French translations - Removed the French translations for "Loading settings...", "Loading maintenance settings...", and "Loading content settings..." to streamline the localization file. - This change improves the clarity and relevance of the translations provided to users. * fix(i18n): add translations for Uptime Kuma group management in multiple languages - Added French, Japanese, Russian, Vietnamese, and Chinese translations for "Add Uptime Kuma Group" and "Edit Uptime Kuma Group" to enhance internationalization support. - This update improves user experience by providing clear and consistent messaging across different languages. * fix(validation): enhance pricing schema error messages and add translations - Updated the pricing schema to include localized error messages for validation, ensuring users receive clear feedback when input values are invalid. - Added new translations for "Exchange rate is required" and "Exchange rate must be greater than 0" in English, French, Japanese, and Chinese to improve internationalization support. - This change enhances user experience by providing accurate and contextually relevant messages across multiple languages. * fix: codex Unsupported parameter: max_output_tokens * fix(model-mapping-editor): simplify JSON parsing logic in useEffect * fix: jimeng i2v support multi image by metadata * refactor(models): restructure models section handling and improve UI components - Replaced tab-based navigation with section-based navigation for better clarity and organization. - Introduced a new section registry to manage model sections, including 'metadata' and 'deployments'. - Updated the ModelsContent component to reflect the new section structure and added a Create Deployment button. - Removed the ModelsTabs component as it was no longer needed. - Enhanced internationalization support by adding new translations for section descriptions and management tasks. - Adjusted sidebar configuration to accommodate the new section structure. * fix: update warning threshold label from '5$' to '2$' * fix: video content api Priority use url field * fix: update abortWithOpenAiMessage function to use types.ErrorCode * feat(deployment): introduce CreateDeploymentDrawer component and update dialog references - Replaced the CreateDeploymentDialog with a new CreateDeploymentDrawer component for improved user experience. - Added comprehensive form handling for deployment creation, including validation and price estimation features. - Updated internationalization files to include new translations for UI elements and descriptions related to deployment configuration. - Enhanced the ModelsContent component to integrate the new drawer for creating deployments. * perf(i18n): enhance internationalization for models table and columns - Updated labels and titles in the ModelsTable and useModelsColumns components to utilize translation functions for improved localization. - Changed static text for vendor and sync status to dynamic translations, enhancing user experience for non-English speakers. - Updated empty state messages in the ModelsTable to support internationalization, ensuring clarity for all users. * fix: fix email send * fix: issue where consecutive calls to multiple tools in gemini all returned an index of 0 * fix: replace Alibaba's Claude-compatible interface with the new interface * fix: Only models with the "qwen" designation can use the Claude-compatible interface; others require conversion. * feat: log shows request conversion * feat: optimized display * feat: optimized display * feat: optimized display * fix: codex rm Temperature * Revert "fix: video content api Priority use url field" * feat: requestId time string use UTC * feat(qwen): support qwen image sync image model config * feat: sync old ui * feat: more ui sync * feat: replace theme * fix build * refactor(web): revert theme colors and variables in CSS Updated color variables for light and dark themes to improve consistency and visual appeal. * feat(deployment): enhance deployment access guard and model deployment settings - Introduced loading phase management in the DeploymentAccessGuard component to provide better user feedback during connection checks. - Updated the ModelsContent component to prefetch the deployments list while checking connection status, improving data readiness. - Implemented a caching mechanism for connection status in useModelDeploymentSettings to optimize performance and reduce unnecessary API calls. - Enhanced loading states and error handling for improved user experience during deployment settings retrieval and connection testing. * feat(i18n): add new translations for connection and loading states across multiple languages - Introduced translations for "Checking connection" and "Loading configuration" in English, French, Japanese, Russian, Vietnamese, and Chinese. - This update enhances the internationalization support, providing clearer user feedback during connection checks and loading phases. * refactor(pagination): adjust layout and styling for pagination component - Updated the pagination component to improve layout by removing unnecessary width constraints and enhancing responsiveness. - Increased minimum width for pagination text to ensure better visibility and alignment across different screen sizes. * feat(i18n): implement translations for various UI elements across multiple components - Updated several components to utilize the translation function for titles and placeholders, enhancing internationalization support. - Added new translation entries for "Filter by name or key..." and "Log Type" in English, French, Japanese, Russian, Vietnamese, and Chinese. - This update improves user experience by providing localized text in the ChannelsTable, SummaryCards, ApiKeysTable, RedemptionsTable, UsageLogsTable, and UsersTable components. * feat(i18n): integrate translation support in SummaryCards component - Added the useTranslation hook to the SummaryCards component to enhance internationalization. - This update allows for localized text rendering, improving user experience for diverse language speakers. * feat(dashboard): refactor dashboard structure and introduce section-based navigation - Removed the tab navigation in favor of a section-based approach, enhancing user experience by providing clearer context for the dashboard content. - Introduced a new section registry to manage dashboard sections, allowing for easier expansion and maintenance. - Updated sidebar configuration to reflect the new section structure, ensuring proper navigation links are displayed. - Added translations for new section titles and descriptions to support internationalization. * feat(i18n): update time range labels and enhance translation support - Changed time range labels from shorthand (e.g., '1D') to full text (e.g., '1 Day') for better clarity. - Updated various components to utilize the translation function for time range labels, improving internationalization. - Added new translation entries for time ranges in English, French, Japanese, Russian, Vietnamese, and Chinese, enhancing user experience across languages. * feat(dashboard): enhance type safety and improve component structure - Updated the Dashboard component to use specific types for model data and filters, enhancing type safety. - Introduced new types for announcements and FAQs, improving clarity and maintainability. - Refactored LogStatCards and UptimePanel components to utilize AbortController for better data fetching management. - Optimized the rendering of announcements and FAQs by using unique keys based on item IDs. - Improved theme management in ModelCharts by caching the ThemeManager import to reduce dynamic imports. * feat(agents): add comprehensive guidelines for React and Next.js development - Introduced a new set of best practices and optimization techniques for React and Next.js applications, aimed at enhancing performance and maintainability. - Included detailed rules covering various aspects such as event handling, API routes, rendering strategies, and state management. - Added extensive documentation in AGENTS.md and SKILL.md to support developers in adhering to these practices. - This update serves as a foundational resource for improving code quality and efficiency in React-based projects. * chore(web): update package.json dependencies - Removed outdated dependencies including @base-ui/react, @clerk/clerk-react, and others to streamline the project. - Updated remaining dependencies to their latest versions for improved performance and security. - This cleanup enhances the overall maintainability of the project. * feat(usage-logs): implement section-based navigation and enhance log management - Introduced a section registry for usage logs, allowing for better organization and navigation between different log categories (common, drawing, task). - Updated the UsageLogsContent component to dynamically render titles and descriptions based on the selected section. - Refactored UsageLogsTable and UsageLogsPrimaryButtons components to accept the active log category as a prop, improving modularity. - Enhanced sidebar configuration to support new section navigation, ensuring users can easily access different log types. - Updated routing to redirect to the default section if none is specified, improving user experience. * feat(i18n): enhance internationalization across usage logs components - Integrated the useTranslation hook in various components related to usage logs, including CommonLogsStats, UsageLogsTable, and column helpers. - Updated labels, titles, and messages to utilize translation functions, improving localization support. - Added new translation entries for log-related terms in English, French, Japanese, Russian, Vietnamese, and Chinese, enhancing user experience for diverse language speakers. * feat(datetime-picker): integrate dayjs for date formatting - Added dayjs as a dependency to the project for improved date handling. - Updated the DateTimePicker component to use dayjs for formatting dates, enhancing consistency and readability of date displays. * feat(date-handling): replace date-fns with dayjs for improved date management - Updated the project to use dayjs instead of date-fns for date formatting and manipulation, enhancing consistency across components. - Refactored DatePicker, DateTimePicker, and other components to utilize dayjs for date-related functionalities. - Added a new dayjs configuration file to extend its capabilities with relative time support. - Updated AGENTS.md to reflect the new technology stack, emphasizing the use of dayjs for date handling. * refactor(agents): streamline front-end development guidelines and update technology stack - Revised AGENTS.md to condense front-end development standards and best practices, making it more accessible for developers and AI assistants. - Updated the technology stack section to reflect current dependencies, emphasizing the use of Bun, React 19, TypeScript, and other key libraries. - Enhanced the document structure with a new table format for better readability and navigation, including a comprehensive table of contents for quick access to sections. * feat(i18n): enhance date picker and datetime picker localization support - Integrated internationalization support in DatePicker and DateTimePicker components by adding locale handling for multiple languages (English, French, Japanese, Russian, Vietnamese, Chinese). - Updated the calendar component to accept a locale prop, ensuring proper localization of month and weekday labels. - Improved user experience by allowing date selection to adapt based on the user's language preference. * feat(layout): add SectionPageLayout component for structured page layouts - Introduced a new SectionPageLayout component to facilitate structured layouts for pages with sections, enhancing the organization of content. - Added subcomponents for Title, Description, Actions, and Content to improve clarity and maintainability of page structures. - Updated AGENTS.md to include guidelines on avoiding unnecessary destructuring of props for better code readability. * feat(layout): refactor components to use SectionPageLayout for improved structure - Replaced AppHeader and Main components with SectionPageLayout across multiple features including Channels, Dashboard, ApiKeys, Models, Redemption Codes, Usage Logs, Users, and Wallet. - Enhanced page organization by utilizing SectionPageLayout's Title, Description, Actions, and Content subcomponents, improving clarity and maintainability. - This update standardizes the layout structure across the application, facilitating a more cohesive user experience. * feat(usage-logs): enhance URL state management and redirection logic - Added useEffect to synchronize column filters with URL search changes, preventing infinite loops caused by inline references. - Improved redirection logic in usage logs to clear 'type' from the URL when the section is not 'common', enhancing user experience and URL cleanliness. * fix(usage-logs): disable global filter and update DataTableToolbar props - Disabled the global filter in the UsageLogsTable component to streamline the user interface. - Updated the DataTableToolbar component to accept a null customSearch prop, enhancing flexibility in toolbar configuration. * feat(routes): implement section-based routing for system settings and dashboard - Introduced section-based routing for system settings and dashboard features, enhancing navigation and organization. - Updated route definitions to include dynamic sections, allowing for more granular access to settings and dashboard components. - Refactored existing routes to redirect to default sections when no specific section is provided, improving user experience. - Added new section routes for models, usage logs, and system settings, ensuring consistency across the application. - Removed deprecated routes to streamline the routing structure and improve maintainability. * refactor(usage-logs): update column helper functions to require config parameter - Modified createFailReasonColumn and createProgressColumn functions to require a config parameter instead of allowing it to be optional. - Simplified destructuring of config to enhance clarity and ensure necessary properties are always provided, improving code reliability. * refactor(usage-logs): improve section ID validation and routing logic - Introduced a type guard function, isUsageLogsSectionId, to validate section IDs, enhancing type safety and reducing the need for casting. - Updated UsageLogsContent to utilize the new validation function for determining the active category, improving clarity and reliability. - Refactored routing logic to use isUsageLogsSectionId for section validation, ensuring proper redirection to the default section when necessary. * refactor(calendar): update locale documentation for i18n support - Revised the locale prop documentation in the Calendar component to specify the use of react-day-picker for internationalization, clarifying the expected locale setup for users. * chore(i18n): remove redundant user information description from locale files - Removed the user information description from English, French, Japanese, Russian, Vietnamese, and Chinese locale files to streamline translations and improve clarity. * chore(i18n): streamline locale files by removing redundant entries - Removed unnecessary entries from English, French, Japanese, Russian, Vietnamese, and Chinese locale files to enhance clarity and reduce clutter. - Adjusted translations for consistency and improved user experience across multiple languages. * chore(sidebar): remove deprecated usage logs route from sidebar config - Eliminated the '/usage-logs' entry from the sidebar configuration to streamline navigation and improve clarity in the sidebar structure. * refactor(redemption-codes): enhance internationalization support and improve UI consistency - Updated various components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages. - Added meta labels for table columns to improve accessibility and clarity. - Revised confirmation and action texts in dialogs and tooltips to leverage translation, enhancing user experience. - Updated locale files to include new translations for improved clarity and consistency. * feat(masked-value-display): add MaskedValueDisplay component for sensitive data handling - Introduced a new MaskedValueDisplay component to display masked values with a popover for full value visibility and a copy button for easy access. - Updated api-keys-columns and redemptions-columns to utilize the new component, enhancing code reusability and UI consistency. - Revised translation keys in locale files to remove colons for improved clarity. * refactor(url-utils): simplify query parameter matching logic in checkIsActive function - Updated the checkIsActive function to streamline the logic for matching URLs with and without query parameters. - Removed unnecessary checks for query parameters when matching base paths, improving clarity and maintainability of the code. * fix(channels-table): update group filter label to use translation function - Replaced hardcoded 'All Groups' label with a translation function call to enhance internationalization support in the ChannelsTable component. * chore(api-keys): remove deprecated API key action messages and related exports - Deleted the api-key-actions.ts file, which contained action messages for enabling, disabling, and deleting API keys. - Updated index.ts to remove the export of getApiKeyActionMessage, streamlining the codebase by eliminating unused functionality. * refactor(i18n): enhance internationalization support across various components - Updated multiple components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages. - Revised constants and labels in the channels and redemption codes features to use i18n keys, improving maintainability and clarity. - Ensured that success and error messages leverage translation functions, enhancing user experience and accessibility. - Streamlined the handling of i18n keys in the constants files for better organization and clarity. * refactor(i18n): enhance translation support across various components - Updated multiple components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages. - Revised pagination and status labels to use i18n keys, improving maintainability and clarity. - Enhanced response time formatting to support internationalization, allowing for localized display of time values. - Updated locale files to include new translations for improved clarity and consistency. * docs(AGENTS): add type checking requirement for TypeScript changes - Included a new guideline stating that type checks must be executed after modifying TypeScript or TSX code, ensuring no type errors are left unresolved. - Updated the document to reflect this addition in the relevant section for better clarity on coding standards. * feat(combobox-input): add ComboboxInput component for enhanced token selection - Introduced a new ComboboxInput component to facilitate token name selection with search and filtering capabilities. - Integrated the ComboboxInput into the UsageLogsFilterDialog for improved user experience when filtering by token name. - Updated locale files to include new translations for user-facing strings related to token filtering. * feat(combobox): integrate translation support for custom value prompt - Added translation functionality to the Combobox component, replacing hardcoded text with a translatable string for the custom value prompt. - Utilized the useTranslation hook from react-i18next to enhance internationalization support, ensuring a consistent user experience across different languages. * refactor(i18n): improve Chinese translations for consistency and clarity - Adjusted spacing in various Chinese translations to enhance readability and maintain consistency across the locale file. - Updated multiple user-facing strings to ensure proper formatting and alignment with localization standards. * feat(calendar): add CalendarDropdown component for enhanced dropdown functionality - Introduced a new CalendarDropdown component to improve user interaction with dropdown selections in the calendar. - Implemented state management for dropdown visibility and selection handling, enhancing the overall user experience. - Updated styling for dropdown elements to ensure consistency and better alignment with the UI design. * fix(balance-query-dialog): handle null currentRow and improve usage query logic - Updated the BalanceQueryDialog component to safely access currentRow properties using optional chaining. - Added a check to ensure currentRow is not null before proceeding with usage queries, preventing potential runtime errors. - Refactored the handleQueryCodexUsage function to use a local variable for currentRow, enhancing code clarity. * feat(i18n): add new translations for batch creation and channel updates - Added new translation strings for batch creation instructions across multiple languages, enhancing user guidance. - Included translations for the "Update Channel" prompt to improve clarity in channel configuration settings. - Ensured consistency in terminology across locale files for better user experience. * feat(channel-mutate-drawer): improve API key input handling and update translations - Refactored the API key input logic in the ChannelMutateDrawer component to enhance readability and maintainability. - Added new placeholder translations for batch creation and existing key prompts in multiple languages, improving user guidance. - Ensured consistency in translation strings across locale files for better user experience. * feat(fetch-models-dialog): implement sorting for model categories - Added a new function to sort model categories alphabetically, placing 'Other' at the end for easier navigation. - Updated the rendering logic in the FetchModelsDialog component to utilize the new sorting function for both new and existing models, enhancing user experience. * refactor(wallet-stats-card): standardize props usage and improve layout consistency Standardizes props usage and improves layout consistency in wallet stats card Refactors the wallet stats card component to: - Use props directly instead of destructuring for consistency - Add min-w-0 to prevent content overflow - Adjust text sizing with break-all for proper wrapping - Implement responsive font sizes (3xl on mobile, 4xl on larger screens) - Improve leading and tracking for better readability Refactor wallet stats card for consistency and layout Standardizes props usage and improves layout consistency in wallet stats card - Uses props directly instead of destructuring for consistency - Adds min-w-0 to prevent content overflow - Adjusts text sizing with break-all for proper wrapping - Implements responsive font sizes (3xl on mobile, 4xl on larger screens) - Improves leading and tracking for better readability * feat(web): add subscription management and admin settings UI * feat(web): add subscription management and admin settings UI - Add subscription management module (plans, pricing, toggle status, and related dialogs/tables with Stripe/Creem integration notes) - Add channel affinity (rules and cache stats), Waffo integration, performance, and Grok model sections to system settings, with extended types and section registry - Add status code mapping validation/risk warnings, upstream update hooks, and utilities for channels; add available models and sidebar module cards to user profile - Add chat2link route and useMinimumLoadingTime, useTableCompactMode shared hooks Made-with: Cursor * fix: remove duplicate GenerateOAuthCode and add missing TaskBulkUpdate - remove duplicate GenerateOAuthCode from github.go since oauth.go already has the generic version. - add model.TaskBulkUpdate for bulk update by upstream task_id strings, fixing task_video.go build failure. * feat(router): add chat2link and subscriptions routes - register /chat2link page route under authenticated layout. - register /subscriptions/ page route under authenticated layout. - update auto-generated routeTree type definitions and route mappings. * feat(docker): add development environment setup with Docker Compose - Introduced docker-compose.dev.yml for local development, including services for new-api, Redis, and PostgreSQL. - Created Dockerfile.dev for backend-only builds, optimizing the development workflow. - Updated makefile to include new commands for starting backend services and frontend development. * feat(web): complete i18n coverage for setup wizard and add language switcher - wrap all hardcoded English strings in setup-wizard, database-step, usage-mode-step, and complete-step with t() calls, covering step titles, descriptions, form validation messages, and fallback strings. - add LanguageSwitcher component to the top-right corner of the setup page so users can switch language during initial setup. - register 25 dynamic i18n keys in static-keys.ts and provide full translations for zh/en/ja/fr/ru/vi. * feat(i18n): internationalize default version text in workspace-switcher - remove hardcoded 'Unknown version' default, use t('Unknown version') for i18n fallback - add "Unknown version" translation entries across all 6 locale files (zh/en/fr/ru/ja/vi) * feat(i18n): add full i18n coverage for channel-affinity settings page - replace Chinese t() keys with English keys across three channel-affinity components to align with new frontend i18n conventions. - add 51 translation entries to all 6 locale files (en/zh/ja/fr/ru/vi) covering main page, rule editor, and cache stats dialog. - register section-registry dynamic keys in static-keys.ts. * feat(i18n): add full i18n coverage for Waffo payment settings page - replace Chinese i18n keys with English keys in waffo-settings-section.tsx for consistency. - wrap previously hardcoded labels (Pay Method Type / Pay Method Name) with t(). - add 26 Waffo-related translation entries across all 6 locale files (en/zh/fr/ru/ja/vi). * feat(i18n): add missing translations for global model settings page - add all 6 locale translations for 3 missing t() keys in global-settings-card. - register dynamically used 'Grok' key in static-keys.ts for i18n scanner coverage. * feat(i18n): add full i18n coverage for Grok model settings page - add translations in all 6 locales (en/zh/fr/ja/ru/vi) for grok-settings-card t() calls. - cover violation fee toggle, amount input, and official docs link labels. - include section-registry descriptionKey translation entries. * feat(i18n): add full i18n coverage for performance settings page - migrate all t() keys from Chinese to English to align with project conventions. - add translations for all 6 locales (en/zh/ja/fr/ru/vi) covering disk cache, system monitoring, log management, and stats dashboard sections. - remove 71 obsolete Chinese-keyed entries from every locale file. * fix(i18n): add 116 missing English translation keys across all locales - scan all t() calls to identify English keys used in code but absent from locale files. - add translations for zh/en/fr/ja/ru/vi, keeping key sets and sort order consistent. - covers system-settings, channels, models, auth, wallet and other modules. * fix(i18n): add missing translations for log cleanup quick-select and confirm dialog - wrap quick-select button labels (24 hours ago / 7 days ago / 30 days ago) with t(). - replace hardcoded English strings in purge confirm dialog with t() calls and date interpolation. - add 5 new translation keys across all 6 locale files (zh/en/fr/ja/ru/vi). * refactor(web): unify all time display with dayjs formatting - replace all toLocaleString/toLocaleDateString/toLocaleTimeString and manual padStart concatenation with dayjs.format(). - standardize output: datetime as YYYY-MM-DD HH:mm:ss, date as YYYY-MM-DD, time as HH:mm:ss. - add formatDateTimeStr, formatDateStr, formatTimeStr dayjs-based helpers in lib/format.ts. - update 12 files across core utils and feature components. * refactor(web): replace native datetime-local input with DateTimePicker in announcements - swap browser-native datetime-local for the project's DateTimePicker component to match the UI used in log cleanup and other pages. - convert between Date objects and ISO strings to bridge the form's string-based schema. * refactor(web): replace native HTML elements with design system components - replace ~35 native <button> with <Button> across pricing, profile, channels modules - replace native <input>/<textarea>/<label> with <Input>/<Textarea>/<Label> for consistent form styling - replace native <table> with <Table> components, <details>/<summary> with <Collapsible> - replace decorative <hr> with <Separator> to ensure global UI consistency * refactor(web): enhance profile components with design system consistency - update ProfileSecurityCard to use buttons for security actions, improving accessibility and styling. - modify AccountBindingsTab layout to a grid for better responsiveness and visual alignment. - refactor NotificationTab to utilize icons for notification methods, enhancing user experience and clarity. * fix(i18n): complete i18n coverage for profile page components - wrap passkey card status badges (enabled/disabled, backup state) and last-used text with t() - fix hardcoded button labels in security dialogs (change password, access token, delete account) - internationalize all 2FA dialog strings (setup, disable, backup codes) - fix email bind dialog description and button state text missing i18n - wrap remaining hardcoded strings in notification tab and checkin calendar - add all missing translation entries to zh.json and en.json * fix(i18n): enhance error messages with translations for deployment access and settings - wrap connection error messages in DeploymentAccessGuard and IoNetDeploymentSettingsSection with t() for internationalization. - add missing translation key for "io.net model deployment is not enabled or api key missing" in all locale files (en, fr, ja, ru, vi, zh). * 🧹 chore(web): resolve all ESLint errors and warnings Align the Vite/React frontend with the current ESLint flat config and React Compiler–related rules by fixing violations instead of broad suppression where practical. - Replace `any` with concrete types (`unknown`, `Record<string, unknown>`, domain types) where upstream/API shapes allow - Fix duplicate imports, unused bindings, `no-console`, and empty blocks - Address react-hooks issues: reorder declarations, memoize unstable callbacks (`useCallback`), extend dependency arrays, and use targeted disables only where sync-from-props in `useEffect` is intentional - Refactor `motion.create` usage in ai-elements shimmer to avoid creating components during render (static-components) - Stabilize TanStack Query/Mutation hook usage (query keys, `mutate` in deps) and add narrowly scoped rule disables where the linter conflicts with library patterns - Disable `react-hooks/incompatible-library` in ESLint config for TanStack Table / RHF false positives - Add file-level `react-refresh/only-export-components` disables for registry/provider/column modules that intentionally mix exports `bun lint` completes with 0 errors and 0 warnings. * ✨ feat(web): add subscription management to sidebar and align drawer with project conventions - Register "Subscription Management" nav item in the admin sidebar group with CreditCard icon pointing to /subscriptions - Add subscription module to sidebar config defaults and URL mapping so it integrates with the admin sidebar modules toggle in system settings - Add subscription entry to sidebar-modules-section moduleMeta for the maintenance settings UI - Refactor SubscriptionsMutateDrawer to follow the same patterns used by users, redemption-codes, and other mutate drawers: - Use shadcn Form/FormField/FormItem/FormControl/FormLabel/FormMessage instead of raw register() + Label + manual error display - Move SheetFooter outside the form with form attribute association - Use SheetClose for the cancel button - Reset form state on drawer close - Align SheetContent width (sm:max-w-[600px]) and spacing conventions * ✨ feat(web): overhaul UI/UX with Vercel Geist design alignment Refactor the entire frontend UI/UX to align with Vercel/OpenAI design principles, covering layout, animations, skeleton loading, and overall visual polish. Motion & Page Transitions: - Add centralized motion system (lib/motion.ts) with Vercel-style transition presets, stagger variants for tables, cards, and sidebars - Implement AnimatedOutlet for route-level page enter animations using TanStack Router pathname keying - Add PageTransition, StaggerContainer, StaggerItem, CardStagger, and TableStagger wrapper components for progressive reveal effects Skeleton Loading — Vercel Geist Style: - Replace shadcn default `animate-pulse` with Geist-style shimmer sweep animation (linear-gradient + background-position keyframes) - Add `--skeleton-base` / `--skeleton-highlight` CSS variables tuned for both light and dark themes with neutral oklch tones - Override auto-skeleton-react inline styles via CSS to unify all skeleton elements under the same shimmer effect - Update TableSkeleton with varied column widths for a natural feel - Add ContentSkeleton and QuerySkeleton wrappers for auto-skeleton integration with React Query error/loading states - Respect prefers-reduced-motion: disable shimmer for accessibility Layout & Sidebar: - Upgrade sidebar expand/collapse transitions to cubic-bezier easing - Add hover micro-interactions (background-color, color, transform) to sidebar menu buttons with smooth 150ms transitions - Fix oklch color compatibility in sidebar outline variant - Integrate AnimatedOutlet into AuthenticatedLayout for unified route-level animations Theme & CSS: - Streamline theme.css with cleaner oklch color definitions - Add CSS table row stagger-in animations with nth-child delays - Fix hover-scrollbar color bug (hsl → color-mix for oklch compat) - Add content-auto utility for long list rendering optimization Cleanup: - Remove deprecated skeleton-wrapper.tsx - Remove unused imports and dead code across components - Add empty-state, error-state, and loading-state utility components * 🐛 fix(docker): track bun.lock to fix Docker build failure Remove `web/bun.lock` from `.gitignore` so the lock file is committed to version control. The Dockerfile `COPY web/bun.lock .` instruction requires this file to be present in the build context, and ignoring it caused the build to fail with a "not found" error. * ⬆️ chore(web): upgrade dependencies and fix all type/lint errors Upgrade all frontend dependencies to latest stable versions: - lucide-react 0.562 → 1.7 (major: brand icons removed) - shiki 3.x → 4.x, eslint 9.x → 10.x, knip 5.x → 6.x - @rsbuild/core 1.3 → 1.7, @types/node 24 → 25 - tailwindcss/postcss 4.1 → 4.2, motion 12.25 → 12.38 - @tanstack/react-query 5.90 → 5.95, zod 4.3.5 → 4.3.6 - react 19.2.3 → 19.2.4, axios 1.13.2 → 1.13.6 - prettier 3.7 → 3.8, typescript-eslint 8.52 → 8.57 - Add missing optional deps: @xyflow/react, embla-carousel-react Resolve all TypeScript compilation errors introduced by upgrades: - Replace lucide-react brand icons (Github) with react-icons/si - Fix react-hook-form Control/Resolver generics for zod v4 - Fix Record<string, unknown> type constraints across API utils - Fix axios interceptor return types in lib/api.ts - Add type assertions for useSettings/useStatus hook returns - Resolve Badge variant, spread type, and route path mismatches Resolve all ESLint 10 errors: - preserve-caught-error: attach cause to re-thrown errors - no-useless-assignment: refactor redundant variable assignments - prefer-as-const: use `as const` over literal type assertions - no-unused-vars: prefix type-only schemas with underscore Update tsconfig lib from ES2020 to ES2022 for Error.cause support. * 🐛 fix(web): stop pricing model row from centering its content Wrapping the row in shadcn <Button variant='ghost'> inherits `justify-center`, and the inner flex container had no width, so `justify-between` collapsed and the row appeared centered. * feat: add Waffo payment integration and related UI components - Introduced Waffo payment method with support for custom icons and settings. - Updated payment settings section to include Waffo settings. - Added Waffo payment request handling in the wallet API. - Enhanced wallet recharge form to support Waffo payment methods. - Implemented hooks for Waffo payment processing. - Updated localization files for new Waffo-related strings. - Added new payment type and icon for Waffo in constants and UI components. - Refactored topup info handling to include Waffo payment methods and configurations. * feat(profile): add admin-only upstream model update notification setting * fix(web): make sidebar module user settings actually take effect Previously, saving sidebar module preferences in profile had no effect because the client ignored user-level sidebar_modules entirely. This fix wires user config into useSidebarConfig so the sidebar updates immediately without a page refresh. Changes: - Add UserPermissions type with sidebar_settings/sidebar_modules fields - Refactor useSidebarConfig to merge admin × user config with AND logic - Sync sidebar_modules to auth store on save for immediate UI updates - Conditionally render SidebarModulesCard based on user permissions - Treat null/empty user config as "do not narrow" for legacy users * feat(web): add custom OAuth provider CRUD and login button support Migrate custom OAuth from v1 to v2: - Admin CRUD UI with provider table, form dialog, preset templates, and OIDC discovery - Login page renders dynamic buttons for custom OAuth providers - Fix account bindings display showing "Not bound" text when already bound * feat(web): add ServerAddress, SMTPForceAuthLogin, CreateCacheRatio and group special usable settings Migrate missing v1 system settings to v2: - ServerAddress input in General > System Information - SMTPForceAuthLogin toggle in Integrations > Email - CreateCacheRatio JSON editor in Models > Ratio - Group special usable group rules editor in Models > Ratio * feat(web): wire user subscriptions dialog to users table row actions The UserSubscriptionsDialog component already existed but had no entry point in the users table dropdown menu. Add "Manage Subscriptions" menu item. * chore(web): update i18n translations for new settings and custom OAuth * 💎 refactor(web): redesign pricing page with flat, typography-driven layout * 🌐 chore(i18n): complete missing translations and normalize project config - Add 425+ missing translations across fr, ja, ru, zh, vi locales for subscription management, sidebar navigation, Grok settings, upstream model updates, pricing page, and other UI components - Add 37 missing i18n keys used in t() calls but absent from locale files (pricing filters, display options, audio/cache labels, etc.) - Fix stale tech stack info in CLAUDE.md, AGENTS.md, and project.mdc: React 18 → 19, Vite → Rsbuild, Semi Design → Radix UI + Tailwind - Fix i18n key format description: "Chinese source strings" → English - Deduplicate .cursor/rules/project.mdc to avoid triple-loading the same rules already present in root CLAUDE.md and AGENTS.md - Add i18n-translate Cursor skill for repeatable translation workflow * 🎨 refactor(web): redesign dashboard with flat, typography-driven layout Replace Card-based dashboard components with a flat, border-driven design system consistent with the pricing page, following the ui-style.mdc conventions. Overview section: - StatCard: replace Card wrapper with flat flex layout, monospace tabular values, uppercase tracking-wider labels, layered opacity hierarchy - PanelWrapper: replace Card/CardHeader/CardContent with rounded-lg border container and border-b header - SummaryCards: merge three stat cards into a single bordered container with divide-x separators; decouple border from stagger animation to prevent border deformation during entrance transitions - ApiInfoPanel/Item: full-width list rows with border-b separators, monospace route names, layered opacity for URLs and descriptions - AnnouncementsPanel: native button rows with hover:bg-muted/40, i18n for "Click for details" hint - FAQPanel: lighter border-border/60 accordion dividers, muted answer text - UptimePanel: uppercase tracking-wider group headers with bg-muted/30 background, monospace uptime percentages, fine-grained border opacity Models section: - LogStatCards: replace Card with rounded-lg border + divide-x grid, fix react-hooks/exhaustive-deps by destructuring props before useEffect - ModelCharts: replace Card+Tabs with bordered container + custom segmented control matching ui-style spec - Suspense fallbacks: match new flat skeleton layout with accurate column structure Animation: - Wrap models section in FadeIn with staggered delay - Keep CardStagger for overview panel grid (each panel has own border) Other: - Add ui-style.mdc cursor rule documenting the design language - Disable react-refresh/only-export-components for src/routes/** in eslint config (TanStack Router route files always export Route objects) - Fix zh.json: "Token-based" translation "基于令牌的" → "按量计费" * ✨ refactor(web): adopt flat dot-and-text design for all status badges Replace the bordered/colored-background StatusBadge and Badge components across the entire frontend with a minimal flat design: a small colored dot followed by colored text, eliminating visual noise from heavy borders, backgrounds, and rounded pill shapes. Key changes: - Redesign StatusBadge to use dot + text instead of bordered pill style, removing cva-based background/border variants in favor of exported dotColorMap and textColorMap lookup tables - Add children prop support to StatusBadge for flexible content rendering alongside the existing label prop - Migrate all Badge usages (except pricing page) to StatusBadge with appropriate variant mappings (default→info, secondary→neutral, outline→neutral, destructive→danger) - Consolidate adjacent multi-badge groups into single-dot layouts with dot separators (·) to reduce visual clutter in: - Channel balance columns (used + remaining) - Channel type column (type + IO.NET indicator) - User invite info column (invited + revenue + inviter) - Usage log stats bar (usage + RPM + TPM) - Usage log time/FRT column (time + FRT + stream status) - Subscription plan counts (active + expired) - Channel affinity scope/regex/key-source columns - Prefill group card headers (type + ID) - Export dotColorMap and textColorMap for direct use in custom inline layouts that need consistent status colors without the full component * ✨ refactor(web): redesign public layout and landing page with modern UI Overhaul the public-facing layout, header, and homepage to deliver a polished, animation-rich landing experience inspired by contemporary SaaS design patterns. Header: - Replace sticky header with fixed floating navbar that compacts into a pill-shaped glass-morphism bar on scroll (backdrop-blur + ring) - Add smooth 700ms cubic-bezier transitions for scroll-based shrinking - Build full-screen mobile menu overlay with staggered entry animations - Remove background color from logo container, show logo image directly Homepage sections: - Hero: gradient text title, radial gradient + grid pattern background, interactive terminal demo showcasing API request/response - Terminal demo: auto-cycles through gpt-4o, claude-sonnet-4-20250514, gemini-2.5-pro, deepseek-chat with smooth cross-fade transitions, clickable model badges, dual theme support (light/dark), fixed height - Stats: animated counters driven by IntersectionObserver with cubic-bezier easing, supports integer and decimal modes - Features: Bento grid layout with gap-px border technique, each card includes contextual visuals (model list, security badge, workflow) - How It Works: new three-step process section (Configure → Connect → Monitor) with connecting gradient line and numbered badges - CTA: gradient mesh background with scale-in scroll animation - Footer: streamlined brand column + link columns layout New components: - AnimateInView: IntersectionObserver-based scroll animation component supporting fade-up, fade-in, scale-in, fade-left, fade-right - HeroTerminalDemo: themed terminal with model carousel and live request/response preview CSS: - Add landing page scroll-triggered keyframe animations - Add terminal demo animations (blink cursor, spinner, pulse indicator) - Respect prefers-reduced-motion throughout i18n: - Add 17 new translation keys across all 6 locales (en/zh/fr/ja/ru/vi) * ✨ feat(web): align usage logs and channels with legacy UI Usage logs - Show Refund (type 6) in detail dialog and hide conversion chain for refunds - Sync filter dialog state from URL for model, token, group, username, and requestId Channels - Support optional stream flag in channel test API, actions, and test dialog - Show upstream model update badges (+added / -removed) on fetchable channel types - Add form fields and drawer toggles for upstream model update check and auto-sync - Persist upstream model update flags in channel settings JSON for fetchable types i18n - Add locale strings for upstream model update UI (en, zh, fr, ja, ru, vi) * 🐛 fix(web): prevent transient vertical scrollbar on tables during animations Add overflow-y-clip to the shared Table container (data-slot=table-container) alongside overflow-x-auto. Setting overflow-x to auto implicitly pairs with overflow-y: auto in browsers, which made the table shell briefly show a vertical scrollbar during route enter motion (y/blur) and table row stagger. Remove the redundant descendant selector workaround from the model pricing GroupPricingSection; behavior is now covered globally by the Table component. * 🏗️ refactor(web): redesign console layout with fixed header, scrollable content, and pinned footer Overhaul the authenticated console layout to match the OpenAI dashboard pattern: header and page title bar stay fixed at the top, only the content area scrolls, and table pagination is pinned to the bottom. Layout architecture: - Lock SidebarInset to full viewport height (h-svh) so all inner regions are controlled by flexbox instead of document scroll - Convert Main from a generic div to a semantic <main> flex container with overflow-hidden, removing the legacy `fixed` prop and `data-layout` attribute - Strip scroll-shadow logic and `fixed` prop from Header/AppHeader; the header is now naturally fixed as a shrink-0 flex child - Restructure SectionPageLayout into three flex regions: a shrink-0 title bar, a flex-1 overflow-auto content area, and a shrink-0 footer portal target with empty:hidden - Add min-h-0 to AnimatedOutlet wrappers to prevent flex overflow Footer portal system: - Introduce PageFooterProvider / PageFooterPortal (React Context + createPortal) so deeply nested table components can render their DataTablePagination into the fixed footer without prop drilling - Migrate all 8 data tables (api-keys, channels, users, models, deployments, usage-logs, subscriptions, redemption-codes) to use PageFooterPortal for pagination Page-level fixes: - Profile: wrap content in a scrollable flex child with proper padding - SystemSettings: remove overflow-auto from wrapper to avoid nested scrollbars (sub-pages manage their own scroll) - Playground / Error pages: remove obsolete `fixed` props API keys UX improvement: - Replace inline key show/hide toggle with a Popover-based reveal, removing toggleKeyVisibility and keyVisibility state from the provider context Cleanup: - Remove dead CSS rule for body:has([data-layout='fixed']) - Remove unused `fixed` prop from Header, AppHeader, and Main types - Export PageFooterPortal from layout barrel file * 💅 refactor(web): polish table UI consistency and add pagination transitions - Standardize primary action buttons (Create, Add, Search) to size="sm" across all pages for visual consistency with channels and models - Redesign NumericSpinnerInput with minimal inline style: plain text by default, hover-revealed +/- buttons, click-to-edit — replacing the clunky bordered input with stacked chevron arrows - Fix vertical scrollbar in channels group column by replacing overflow-x-auto with overflow-hidden (redundant with +N collapse) - Simplify API keys group column: replace colorful StatusBadge pairs with clean typography using opacity hierarchy and dot separators - Move API key copy loading indicator from key text to the copy button itself, eliminating layout shift during key resolution - Reduce page title from text-2xl to text-lg and subtitle to text-sm in SectionPageLayout for a more compact header - Add smooth opacity transition (duration-150) on all 7 server-paginated tables during background data fetches (isFetching && !isLoading), with pointer-events-none to prevent interaction during loading - Constrain usage logs Details column width (size: 200, maxSize: 220) * 🐛 fix(web): restore missing padding on system settings content The console layout refactor in d2150469 moved padding ownership from Main onto each route, but SystemSettings was missed — its Outlet wrapper had no padding, so the content area sat flush against the sidebar and top nav. Add `px-4 pt-6 pb-4` to match the vertical rhythm used by SectionPageLayout and the Profile page. * 📱 refactor(web): standardize mobile responsive layout across all table pages Unify mobile experience for all data table pages (channels, keys, models, deployments, usage-logs, users, redemption-codes, subscriptions) with a consistent layout pattern and cleaner header area. DataTableToolbar: - Redesign mobile layout: full-width search input + collapsible filter toggle button with active filter count badge - Filters, additional search, and reset button collapse into an expandable section on mobile, keeping the default view compact - Desktop layout remains unchanged SectionPageLayout: - Tighten mobile spacing (padding, gaps) for higher content density - Scale down title (text-base) and description (text-xs) on mobile - Shrink action button gaps on small screens ChannelsPrimaryButtons: - Move Tag Mode and Sort by ID toggles into the "More" dropdown on mobile (via DropdownMenuCheckboxItem), freeing header space - Desktop toggle switches remain visible outside the dropdown MobileCardList (shared component): - Compact list-item layout with title + badge header row and side-by-side key fields, replacing individual card components - Structured (CompactRow) and fallback (FallbackRow) rendering modes driven by column meta (mobileTitle, mobileBadge, mobileHidden) New MobileCardList integration: - Users table: username as title, status as badge; hide id, display_name, invite_info on mobile - Redemptions table: name as title, status as badge; hide id, created_time, expired_time, used_user_id on mobile - Subscriptions table: plan title as title, enabled as badge; hide id, sort_order, reset, payment, total_amount, upgrade_group on mobile Column meta updates: - Add mobileTitle/mobileBadge/mobileHidden meta across all 8 table column definitions for consistent mobile field prioritization Minor fixes: - Hide Subscriptions Stripe/Creem alert on mobile - Disable card hover animations on mobile via CSS media query * 🐛 fix(web): sync favicon with custom system logo Favicon stayed at the hardcoded /logo.png while document.title already followed system_name, leaving tab icon and site branding out of sync. Apply the logo as favicon from localStorage cache on startup, refresh from getStatus(), and re-apply when useSystemConfig finishes preloading. Extract applyFaviconToDom helper into lib/dom-utils with idempotent guard to avoid redundant DOM writes. * ✨ feat(web): add channel affinity rule templates and CreateCacheRatio visual editing Port missing features from legacy frontend (b8650b9 merge) to the new React frontend: - Add Codex CLI and Claude CLI channel affinity rule templates with header passthrough presets (pass_headers operations for Originator, Session_id, X-Codex-*, X-Stainless-*, Anthropic-*, etc.) - Introduce "Add Rule" dropdown menu with blank, Codex CLI, and Claude CLI template options in the channel affinity settings page - Add "Fill Templates" button to batch-append both CLI templates with duplicate name resolution and confirmation dialog - Support templateKey prop in RuleEditorDialog to pre-fill form fields from selected template, auto-expanding advanced settings when a param_override_template is present - Add CreateCacheRatio support to the model ratio visual editor, edit dialog, and form — previously only editable in JSON mode, now fully integrated into the visual table column, add/edit dialog fields, and save/delete handlers * 🐛 fix(web): fix content-type detection bugs in About and Home pages - Fix About page URL detection: replace naive `startsWith('https://')` with proper `new URL()` validation to support both http and https, and handle untrimmed input that previously caused silent misdetection - Fix About page HTML detection: remove overly broad `startsWith('<')` and `endsWith('>')` checks that could misclassify Markdown or XML content; align with LegalDocument's regex-only `isLikelyHtml` approach - Fix Home page URL detection: same `startsWith('https://')` bug, replaced with `new URL()` protocol validation - Refactor About page to use early-return pattern instead of deeply nested ternary expressions for better readability - Replace About loading spinner with Skeleton placeholder consistent with LegalDocument - Add `prose prose-neutral dark:prose-invert` typography classes to About HTML/Markdown rendering for proper dark mode support - Remove unused `Code` icon import from About page * ✨ feat(web): port missing features from legacy frontend and complete i18n Backport and enhance several features from the old frontend (web/old) that were missing or incomplete in the new React frontend: - Playground & channel test: parse structured JSON error responses from SSE streams and non-streaming API calls, extract error codes, and display actionable UI for `model_price_error` (admin settings link) - User management: replace local quota manipulation with atomic server-side quota adjustments (add/subtract/override) via dedicated API endpoint, making the quota field read-only in the edit drawer - Subscriptions: display next quota reset time for active subscriptions - Dashboard: limit model ranking chart to top 20 models with an "Other" bucket, add dimension tooltips with sorted values and totals to model call trend and user consumption trend charts - i18n: add 24 new translation keys across all 6 locales (en, zh, fr, ja, ru, vi) for the newly introduced UI elements and messages * 🎨 feat: add backend-configurable frontend theme switching (default/classic) Introduce runtime frontend theme switching so administrators can switch between the new frontend (Radix UI + Tailwind) and the classic frontend (Semi Design) from the settings page without restarting the server. Directory restructuring: - Move new frontend from web/ to web/default/ - Move classic frontend from web/old/ to web/classic/ - One-frontend-per-folder layout for extensibility Backend (injection pattern): - Add setting/system_setting/theme.go with GlobalConfig.Register("theme") so the DB key "theme.frontend" is handled automatically by handleConfigUpdate — no switch-case in updateOptionMap needed - Use atomic.Value in common.GetTheme()/SetTheme() for lock-free concurrent reads on the hot path (static file middleware) - Add themeAwareFileSystem that delegates to the correct embedded FS based on the current theme at request time - Embed both frontends into the binary via go:embed - Add controller validation for theme.frontend values - Expose theme in GET /api/status response Frontend settings UI: - New frontend: add "Frontend Theme" select in System Information section using Radix UI Select + react-hook-form + Zod validation - Classic frontend: add "前端主题" select in Personalization section using Semi Design Form.Select Build system: - Update Dockerfile with multi-stage builds for both frontends - Update Makefile with separate build targets for each frontend - Update GitHub Actions release workflow for dual frontend builds i18n: - New frontend: add translations for all 6 locales (en/zh/fr/ja/ru/vi) - Classic frontend: add translations for all 7 locales (en/zh-TW/ja/fr/ru/vi) - Fix zh "AI Proxy Library" → "AI 代理库" Documentation: - Update CLAUDE.md, AGENTS.md, .cursor/rules/project.mdc to reflect the new web/default/ and web/classic/ directory structure * ✨ feat(web): add allow_speed passthrough for Claude channels, fix multi-key index and inference_geo scope - Add `allow_speed` toggle for Anthropic (type 14) channels to control Claude inference speed mode passthrough, with full form schema, settings persistence, and UI switch - Fix `allow_inference_geo` to also apply to Anthropic (type 14) channels, not just OpenAI (type 1), matching the backend behavior for Claude data residency region control - Fix multi-key management dialog to display 1-based key indices instead of 0-based (#{key.index + 1}) - Fix TypeScript type error in section-registry by adding type assertion for theme.frontend enum - Add i18n translations for all new keys across 6 locales (en, zh, fr, ja, ru, vi) * 🧹 chore: clean up editor configs, consolidate agent skills, and set classic as default theme - Add .cursor/ to .gitignore and remove tracked editor config files (.cursor/rules/, .cursor/skills/) from version control - Consolidate .agents/skills/vercel-react-best-practices by keeping only the compiled AGENTS.md and removing redundant SKILL.md and 57 individual rule files under rules/ - Change default frontend theme from "default" to "classic" in both common/constants.go init and setting/system_setting/theme.go * feat: Frontend Tiered Pricing, Waffo Payments, and Rsbuild 2 Upgrade (#24) * feat(ui): add codex extra limits, key last used, and admin audit surfaces - codex usage dialog: render `additional_rate_limits` with `RateLimitGroupSection` and typed base/secondary window data. - api keys table: add "Last Used" column from `accessed_time`. - usage log details: show top-up audit and manage operator for admins; extend `LogOtherData` audit fields; broaden IP display; warn when legacy records lack audit data. - billing history: show user id badge for admins; add zh i18n for new strings. * feat(web): add dynamic pricing breakdown and Waffo Pancake payments - add billing-expr parsing and DynamicPricingBreakdown; surface tiered_expr in model list/details. - extend PricingModel with billing_mode, billing_expr, and pricing_version for backend parity. - add Waffo Pancake integration settings, amount/pay APIs, hook, and recharge flow wiring. - update payment confirm/recharge UI and Chinese locale strings. * feat(pricing): add tiered billing editor and tool price settings - introduce tier-expr and extend billing-expr (time/param conditions, combine/split helpers, editor utilities) for visual tiers and request rules. - support tiered_expr in model ratio dialog, form, and visual editor with billing_setting fields and default JSON placeholders. - add TieredPricingEditor and tool price settings UI plus i18n updates. * chore(web): bump rsbuild to v2 and align build config - upgrade @rsbuild/core, @rsbuild/plugin-react, and Rspack 2 transitives; bump TanStack Router packages and refresh bun.lock. - replace deprecated performance.chunkSplit with top-level splitChunks cache groups for react, radix, and tanstack vendors. - factor dev server proxy into devProxy; set legalComments to none in prod; enable performance.buildCache keyed by VITE_REACT_APP_VERSION. - TanStack Router plugin: enable autoCodeSplitting only in production for faster dev navigation and HMR. * fix(i18n): update translations for API keys and Waffo Pancake settings - Corrected translations for "API Private Key" and "Merchant ID is required" across multiple languages. - Added new translation for "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups." - Updated various existing translations to ensure consistency and clarity in user interface text. * refactor(code-block): simplify code highlighting and improve theme handling - Updated the highlightCode function to support dual themes in a single call, reducing complexity. - Removed unnecessary state management for dark theme HTML, streamlining the component. - Enhanced CSS for Shiki themes to ensure proper token color application in dark mode. * refactor(wallet): use isWaffoPancakePayment for pancake payment dispatch - replace the waffo_pancake string literal with the shared helper for consistency with use-payment and PAYMENT_TYPES. - centralize the value so a constant change does not require hunting for typos in multiple call sites. * fix(wallet): validate waffo pancake checkout url and safe open - allow only parseable http/https redirect targets from the backend, rejecting dangerous schemes. - pass noopener and noreferrer in window.open to reduce reverse tabnabbing. - show a toast and abort on invalid URLs; add i18n entries across locales. * fix(wallet): harden payment icon image URLs - add normalizeHttpIconUrl to allow only http(s) after resolution and reject userinfo in URLs. - set referrerPolicy, lazy loading, and async decode on the icon <img> to cut referrer leakage. - fall back to built-in icons on invalid URLs, same as when iconUrl is missing. * fix(pricing): label param() conditions as body param in dynamic pricing - non-header request rules map to `param()`, not query strings. - align with tiered pricing editor by using the existing `Body param` string. * fix(rsbuild): update legalComments handling in build config - Rely on Rsbuild's default legalComments setting in all modes to ensure compliance with open-source licensing requirements. - Clarified comments to explain the implications of omitting legalComments in production. * fix(i18n): correct billing and codex UI strings in locale files - restore ~83 en.json values to English (tool pricing, audit text, alipay label, etc.). - add proper fr/ru/vi/ja strings so those locales no longer copy zh. - change five locale files only; zh.json unchanged. * fix(i18n): update locale files for improved translations and sync report - Added missing translations and corrected existing strings in English, French, Japanese, Russian, Vietnamese, and Chinese locale files. - Updated the sync report to reflect zero missing translations across multiple locales. - Enhanced the untranslated count for Japanese locale to ensure completeness. - Changed the base locale from zh.json to en.json for better alignment. * chore(agents): add i18n-translate agent skill - add `.agents/skills/i18n-translate/SKILL.md` documenting locale layout under `web/default` and `bun run i18n:sync` usage. - capture a repeatable maintainer workflow with embedded script examples to find missing keys and untranslated values. - give agents a clear path to complete and verify translations across en, zh, fr, ja, ru, and vi. * feat(settings): hide frontend theme setting (#25) * feat(settings): hide frontend theme setting - add a local hidden feature flag with window.newapiUnlock support. - hide the frontend theme option by default and reveal it immediately after unlock. * feat(settings): support click unlock for frontend theme setting - add a shared hidden click unlock hook for repeated-click gated UI. - reveal the frontend theme option after triple-clicking the system information title. - preserve the Doubao API address ten-click unlock behavior and remove global unlock functions. * feat(sync 59337e9): Sync classic tiered billing, upstream price synchronization, and model management features to web/default (#26) * feat(skill): add classic-to-default-sync skill for auditing and syncing web/classic changes to web/default - Introduced a new skill to inspect a given commit's changes in web/classic and synchronize features and fixes to web/default. - Documented workflow steps for extracting diffs, mapping changes, triaging, implementing, and reporting on the synchronization process. - Emphasized quality standards and internationalization considerations for new user-visible strings. * feat(web/default): sync billing and model management features from classic - add `len` condition variable (total input context length); introduce BILLING_PRICING_VARS / BILLING_CONDITION_VARS to separate pricing vars from condition-only vars; fix tier condition regex to accept `len`. - rewrite upstream ratio sync components to support per-model grouped rows and new ratio types (create_cache, image, audio, billing_expr). - add LlmPromptHelper component; update tiered presets to use `len` for conditions; add GLM-4.5 Air, Doubao Seed 1.8, Qwen3 Omni Flash, and weekend-discount presets. - add created_at / last_login_at columns to users table; add "Removed Models" tab to FetchModelsDialog for mapping source keys not in the models list. - add extractMappingSourceModels helper; update dynamic-pricing-breakdown to use system currency settings; add 19 i18n keys across all locales. * ✨ feat(default): surface tiered billing in usage logs and gate Passkey ops behind 2FA Continues the classic-to-default sync (commit 1be6cdb) by porting the remaining audit-log, pricing-hint, and Passkey lifecycle features from web/classic to web/default using the default frontend's component patterns (Radix UI, Tailwind, shadcn-style dialogs). * feat(usage-logs): show tiered_expr breakdown and matched tier in details - Extend `LogOtherData` with `billing_mode`, `expr_b64`, and `matched_tier` fields populated by the backend for tiered logs. - Add `decodeBillingExprB64`, `resolveMatchedTier`, and `getTieredBillingSummary` helpers in `usage-logs/lib/format.ts` that centralise tiered-billing parsing on top of the canonical `parseTiersFromExpr` / `BILLING_PRICING_VARS` from the pricing feature, instead of duplicating the classic-frontend renderer. - Render `<DynamicPricingBreakdown>` inside the consume-log details dialog with the matched tier row highlighted in emerald and tagged "Matched"; suppress the legacy claude/audio/image cost rows when a tiered expression is in effect. - Surface per-tier prices and the matched tier label in log row segments and the billing breakdown table. * feat(pricing): show tier-count, time-based, and request-based hints in model list - Add `summarizeTieredExpr` that derives compact dynamic-pricing metadata (tier count + presence of time/request conditions) from a `tiered_expr` model, computed once per render via `useMemo`, so users can tell *what kind* of dynamic pricing applies before drilling into the model details. - Render the hints alongside the existing "Dynamic Pricing" badge in `<ModelRow>`. - Extend `<DynamicPricingBreakdown>` with a `matchedTierLabel` prop so the same component can be reused from the usage-log details dialog to highlight the tier that actually fired. * feat(profile): require Security Verification for Passkey register/remove - Wire `usePasskeyManagement` through `useSecureVerification` and `<SecureVerificationDialog>` in `<PasskeyCard>`. - Registration prompts for 2FA before issuing the Passkey credential (only when 2FA is already enabled — otherwise the browser-level Passkey prompt itself acts as proof of presence and we register directly). - Removal prompts for 2FA or Passkey, whichever the account has enabled, with informative toasts when neither method is available or the device lacks Passkey support. - Scope the dialog method set to the required factor so users cannot fall back to a weaker method, and propagate cancellation cleanly. * refactor: tighten upstream-ratio-sync and fix tier editor narrowing - Drop the unused `hasSynced` state and dead `getOrderedRatioTypes` / `isSelectableUpstreamValue` imports from `upstream-ratio-sync.tsx`. - In the cost estimator, narrow `BILLING_EXTRA_VARS` entries with a null-`field` guard to silence the type checker and make the "pricing variables only" contract explicit. - Apply Prettier-consistent formatting to the upstream-ratio-sync table/columns, channel mutate drawer, system info section, tier-expr, and wallet helpers (no behaviour change). * i18n: add 9 keys across en/zh/fr/ja/ru/vi - `{{count}} tiers`, `Billing Process`, `Matched`, `Matched Tier`, `Request-based`, `Security verification`, `Time-based`, plus the two new Passkey verification description strings. * 🔧 refactor(default): align upstream price sync, tiered billing, and fetch-models with classic 59337e9 Port and optimize the remaining web/classic features from commit 59337e9 to web/default, covering upstream price synchronization, tiered billing expressions, model fetching, and channel preset detection. Improve component architecture, memoization, and i18n coverage. Upstream Price Sync - Extend sync to all ratio fields: CacheRatio, CreateCacheRatio, ImageRatio, AudioRatio, AudioCompletionRatio in addition to ModelRatio / CompletionRatio / ModelPrice - Add tiered billing sync (billing_mode + billing_expr) with auto-pairing so selecting one upstream tier value populates the other from the same source - Bulk select / unselect per upstream column with indeterminate checkbox state reflecting partial selection - Confidence indicators warn when an upstream entry is heuristically derived - Conflict confirm dialog gains loading state and disables actions during sync - Default endpoint per channel: /api/pricing for official preset, /api/models.dev for the models.dev preset, /api/v1/models for OpenRouter, with the rest falling back to the global default - Rename tab label from "Upstream sync" to "Upstream price sync" for clarity Tiered Pricing Editor - Add `len` (full input length, including cache hits) as a tier-condition variable to avoid mis-routing when cache hits reduce `p` - When inserting a new tier, automatically convert the previous catch-all into a bounded tier with a `len <= X` upper bound - Cap each tier at 0~2 conditions and disable the add-condition button at the limit, with an Alert explaining the recommended `len` usage - Extend presets with Multimodal (img / img_o / ai / ao), Request rule (header/param matching), and Time-based (hour / weekday) entries - Embed an LLM prompt helper that copies a model-aware template for designing expressions with ChatGPT / Claude Fetch Models Dialog - Add a "Removed Models" tab listing models still in the local selection but no longer returned by the upstream listing - Exclude `model_mapping` source keys from the removed view so aliases never appear as missing entries - Force-remount tab content on tab switch via `key` prop to clear stale state - Switch count placeholders to `{{count}}` interpolation across "Existing Models", "New Models", and "Removed Models" labels Channel Selector & Constants - Recognize the models.dev preset (id, base_url, name) alongside the existing official-channel preset detection - Add MODELS_DEV_PRESET_* and OPENROUTER_* constants and reorder ENDPOINT_OPTIONS so `pricing` is preferred over `ratio_config` - Expose the new ratio types in RATIO_TYPE_OPTIONS for the sync filter Types - Add optional `type` field to UpstreamChannel for endpoint inference - Extend RatioType union with create_cache_ratio, image_ratio, audio_ratio, audio_completion_ratio, billing_mode, and billing_expr Code Quality & Performance - Extract upstream-ratio-sync-helpers.ts to host shared types (RatioDifferenceEntry, ModelRow, ResolutionsMap), field ordering (RATIO_SYNC_FIELDS, SYNC_FIELD_ORDER, NUMERIC_SYNC_FIELDS), and selection logic (getPreferredSyncField, isSelectableUpstreamValue, getSyncFieldLabel) - Memoize the column definitions in useUpstreamRatioSyncColumns and pull the per-cell rendering into a renderUpstreamValue helper to remove inline IIFEs - Wrap handleBulkSelect / handleBulkUnselect in useCallback for stable refs; rename the misleading `_upstream` parameter to `upstream` - Convert parsedRatios from useCallback (returning a function) to useMemo (returning the value) and update all call sites to read it as a value - Memoize the channels list with useMemo so the endpoint-init effect no longer fires on every render due to a fresh `?? []` reference i18n - Add and translate new keys ("Upstream price sync", "Audio Ratio", "Audio Completion Ratio", "Cache Create Ratio", "Image Ratio", "Expression Billing", "Fixed Price", "{{n}} model(s) selected", tier guidance, etc.) across en, zh, fr, ja, ru, vi - Fix truncated keys ("Existing Models (", "New Models (", "Removed Models (") to proper {{count}} interpolated forms in every locale - bun run i18n:sync reports 0 missing and 0 extra keys for every locale Verification - bun run typecheck: pass - bun run lint: pass - bun run i18n:sync: pass (0 missing / 0 extras across all locales) * 🐛 fix(default): port classic 73e5557 tiered-billing fixes and dedupe Title-Case ratio i18n keys Sync the web/classic frontend fixes from upstream merge 73e5557 to web/default, and clean up duplicated Title-Case ratio labels in the upstream sync UI that were shadowing the canonical sentence-case i18n keys. Cache-token filter for tiered model price (port of9f8a4ec05) - The matched-tier breakdown shown in the usage-log details dialog and in the log table previously listed every cache-related price (Cache Read, Cache Write, Cache Write 1h) regardless of whether the request actually consumed cache tokens. - `getTieredBillingSummary` in `usage-logs/lib/format.ts` now skips `cache`-group vars when none of `cache_tokens`, `cache_creation_tokens`, `cache_creation_tokens_5m`, or `cache_creation_tokens_1h` are positive, mirroring the classic `renderTieredModelPrice` / `renderTieredModelPriceSimple` logic. - Extract `hasAnyCacheTokens(other)` as an exported helper so the predicate is defined once. - Add a `hideCacheColumns?: boolean` prop to `DynamicPricingBreakdown` and wire it up from the log details dialog so the full tier table hides cache columns under the same condition. `model-details.tsx` keeps the default (show all configured prices), since that view represents the model's pricing structure rather than a specific call. `tiered_expr` ratio/price fallback during sync delays (port ofbee339d27) - When saving a model in tiered-expression mode, the visual editor used to delete every ratio/price map entry for the model and only write `billing_setting.billing_mode` / `billing_setting.billing_expr`. In multi-instance deployments, instances that had not yet observed the billing_setting update fell back to ratios that no longer existed, breaking pricing. - `model-ratio-dialog.tsx`: `handleSubmit` always passes every form field (`price`, `ratio`, `cacheRatio`, `createCacheRatio`, `completionRatio`, `imageRatio`, `audioRatio`, `audioCompletionRatio`) into the data object regardless of `pricingMode`, so a switch from per-token to tiered_expr no longer drops the previously entered ratios. - `model-ratio-visual-editor.tsx`: - The row builder now also surfaces ratio/price values for `tiered_expr` rows, so they survive the edit-and-save round trip and the next save. - `handleSave` factors out a `setIfPresent` helper and persists ratio/price entries for `tiered_expr` models alongside billing_mode / billing_expr. These act purely as fallback because the backend's `ModelPriceHelper` checks `billing_mode` first. - Cell rendering mutes ratio/price values whenever the row is `tiered_expr` (in addition to the existing per-request muting), making it visually clear the values are fallback, not the active pricing source. i18n: dedupe Title-Case ratio labels in upstream sync - `upstream-ratio-sync` `RATIO_TYPE_OPTIONS` previously used Title-Case labels (`Model Ratio`, `Cache Ratio`, `Audio Completion Ratio`, …) that were rendered through `t()` but never existed as canonical keys in the catalogue. The form-field side has used sentence-case (`Model ratio`, `Cache ratio`, …) for some time, leaving two parallel translation entries per ratio type and causing the upstream sync UI to fall back to the English source string in zh/ja/ru/fr/vi. - Rewrite `RATIO_TYPE_OPTIONS` in `system-settings/models/constants.ts` and the conflict-detection labels in `upstream-ratio-sync.tsx` to reuse the sentence-case keys. - Drop the duplicate Title-Case entries from every locale and promote the better translations onto the surviving sentence-case keys (e.g. zh `Image ratio` keeps "图片倍率", `Audio completion ratio` keeps "音频补全倍率"). - Add a comment to `RATIO_TYPE_OPTIONS` warning future contributors not to switch back to Title Case without updating the catalogue. Note on backend fix4e93148d9- The backend portion of the merge (allocating a fresh map in `updateConfigFromMap` so removed keys are properly cleared) is already on HEAD; no additional change is needed. Verification - `bun run typecheck`: pass - `bun run lint`: pass - `bun run i18n:sync`: 0 missing / 0 extras across en / zh / fr / ja / ru / vi --------- Co-authored-by: Seefs <40468931+seefs001@users.noreply.github.com> Co-authored-by: Seefs <i@seefs.me> Co-authored-by: feitianbubu <feitianbubu@qq.com> Co-authored-by: Calcium-Ion <i@caion.me> Co-authored-by: Xyfacai <xyfacai@gmail.com> Co-authored-by: xiangsx <1984871009@qq.com> Co-authored-by: 郑伯涛 <351175318@qq.com> Co-authored-by: RedwindA <austinaosid@gmail.com> Co-authored-by: dean <1006393151@qq.com> Co-authored-by: QuentinHsu <xuquentinyang@gmail.com> Co-authored-by: Bliod <bliod@bliod.lan> Co-authored-by: Apple\Apple <zeraturing@foxmail.com>
This commit is contained in:
+217
@@ -0,0 +1,217 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
RedemptionRequest,
|
||||
PaymentRequest,
|
||||
AmountRequest,
|
||||
AffiliateTransferRequest,
|
||||
ApiResponse,
|
||||
TopupInfoResponse,
|
||||
RedemptionResponse,
|
||||
AmountResponse,
|
||||
PaymentResponse,
|
||||
StripePaymentResponse,
|
||||
AffiliateCodeResponse,
|
||||
AffiliateTransferResponse,
|
||||
BillingHistoryResponse,
|
||||
CompleteOrderRequest,
|
||||
CreemPaymentRequest,
|
||||
CreemPaymentResponse,
|
||||
WaffoPaymentRequest,
|
||||
WaffoPaymentResponse,
|
||||
WaffoPancakePaymentRequest,
|
||||
WaffoPancakePaymentResponse,
|
||||
} from './types'
|
||||
|
||||
// ============================================================================
|
||||
// Wallet API Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if API response is successful
|
||||
*/
|
||||
export function isApiSuccess(response: ApiResponse): boolean {
|
||||
return response.success === true || response.message === 'success'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get topup configuration info
|
||||
*/
|
||||
export async function getTopupInfo(): Promise<TopupInfoResponse> {
|
||||
const res = await api.get('/api/user/topup/info')
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem a topup code
|
||||
*/
|
||||
export async function redeemTopupCode(
|
||||
request: RedemptionRequest
|
||||
): Promise<RedemptionResponse> {
|
||||
const res = await api.post('/api/user/topup', request)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate payment amount for regular payment
|
||||
*/
|
||||
export async function calculateAmount(
|
||||
request: AmountRequest
|
||||
): Promise<AmountResponse> {
|
||||
const res = await api.post('/api/user/amount', request, {
|
||||
skipBusinessError: true,
|
||||
} as Record<string, unknown>)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate payment amount for Stripe payment
|
||||
*/
|
||||
export async function calculateStripeAmount(
|
||||
request: AmountRequest
|
||||
): Promise<AmountResponse> {
|
||||
const res = await api.post('/api/user/stripe/amount', request, {
|
||||
skipBusinessError: true,
|
||||
} as Record<string, unknown>)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Request regular payment
|
||||
*/
|
||||
export async function requestPayment(
|
||||
request: PaymentRequest
|
||||
): Promise<PaymentResponse> {
|
||||
const res = await api.post('/api/user/pay', request, {
|
||||
skipBusinessError: true,
|
||||
} as Record<string, unknown>)
|
||||
return {
|
||||
...res.data,
|
||||
url: res.data.url || (res as unknown as { url?: string }).url,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request Stripe payment
|
||||
*/
|
||||
export async function requestStripePayment(
|
||||
request: PaymentRequest
|
||||
): Promise<StripePaymentResponse> {
|
||||
const res = await api.post('/api/user/stripe/pay', request, {
|
||||
skipBusinessError: true,
|
||||
} as Record<string, unknown>)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Request Creem payment
|
||||
*/
|
||||
export async function requestCreemPayment(
|
||||
request: CreemPaymentRequest
|
||||
): Promise<CreemPaymentResponse> {
|
||||
const res = await api.post('/api/user/creem/pay', request, {
|
||||
skipBusinessError: true,
|
||||
} as Record<string, unknown>)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Request Waffo payment
|
||||
*/
|
||||
export async function requestWaffoPayment(
|
||||
request: WaffoPaymentRequest
|
||||
): Promise<WaffoPaymentResponse> {
|
||||
const res = await api.post('/api/user/waffo/pay', request, {
|
||||
skipBusinessError: true,
|
||||
} as Record<string, unknown>)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate payment amount for Waffo Pancake payment
|
||||
*/
|
||||
export async function calculateWaffoPancakeAmount(
|
||||
request: AmountRequest
|
||||
): Promise<AmountResponse> {
|
||||
const res = await api.post('/api/user/waffo-pancake/amount', request, {
|
||||
skipBusinessError: true,
|
||||
} as Record<string, unknown>)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Request Waffo Pancake payment
|
||||
*/
|
||||
export async function requestWaffoPancakePayment(
|
||||
request: WaffoPancakePaymentRequest
|
||||
): Promise<WaffoPancakePaymentResponse> {
|
||||
const res = await api.post('/api/user/waffo-pancake/pay', request, {
|
||||
skipBusinessError: true,
|
||||
} as Record<string, unknown>)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get affiliate code
|
||||
*/
|
||||
export async function getAffiliateCode(): Promise<AffiliateCodeResponse> {
|
||||
const res = await api.get('/api/user/aff')
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer affiliate quota to balance
|
||||
*/
|
||||
export async function transferAffiliateQuota(
|
||||
request: AffiliateTransferRequest
|
||||
): Promise<AffiliateTransferResponse> {
|
||||
const res = await api.post('/api/user/aff_transfer', request)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get billing history for current user
|
||||
*/
|
||||
export async function getUserBillingHistory(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
keyword?: string
|
||||
): Promise<ApiResponse<BillingHistoryResponse>> {
|
||||
const params = new URLSearchParams({
|
||||
p: page.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
})
|
||||
if (keyword) {
|
||||
params.append('keyword', keyword)
|
||||
}
|
||||
const res = await api.get(`/api/user/topup/self?${params.toString()}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get billing history for all users (admin only)
|
||||
*/
|
||||
export async function getAllBillingHistory(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
keyword?: string
|
||||
): Promise<ApiResponse<BillingHistoryResponse>> {
|
||||
const params = new URLSearchParams({
|
||||
p: page.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
})
|
||||
if (keyword) {
|
||||
params.append('keyword', keyword)
|
||||
}
|
||||
const res = await api.get(`/api/user/topup?${params.toString()}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a pending order (admin only)
|
||||
*/
|
||||
export async function completeOrder(
|
||||
request: CompleteOrderRequest
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.post('/api/user/topup/complete', request)
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import type { UserWalletData } from '../types'
|
||||
|
||||
interface AffiliateRewardsCardProps {
|
||||
user: UserWalletData | null
|
||||
affiliateLink: string
|
||||
onTransfer: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function AffiliateRewardsCard({
|
||||
user,
|
||||
affiliateLink,
|
||||
onTransfer,
|
||||
loading,
|
||||
}: AffiliateRewardsCardProps) {
|
||||
const { t } = useTranslation()
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className='h-6 w-32' />
|
||||
<Skeleton className='mt-2 h-4 w-48' />
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-8'>
|
||||
{/* Statistics Skeleton */}
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-3 sm:gap-6'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className='space-y-2'>
|
||||
<Skeleton className='h-3 w-16' />
|
||||
<Skeleton className='h-8 w-24' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Affiliate Link Skeleton */}
|
||||
<div className='space-y-3'>
|
||||
<Skeleton className='h-3 w-32' />
|
||||
<div className='flex gap-2'>
|
||||
<Skeleton className='h-10 flex-1' />
|
||||
<Skeleton className='size-9' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Section Skeleton */}
|
||||
<Skeleton className='h-20 w-full rounded-lg' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const hasRewards = (user?.aff_quota ?? 0) > 0
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className='text-xl font-semibold tracking-tight'>
|
||||
{t('Referral Program')}
|
||||
</h3>
|
||||
<p className='text-muted-foreground mt-2 text-sm'>
|
||||
{t('Share your link and earn rewards')}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-8'>
|
||||
{/* Statistics */}
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-3 sm:gap-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
|
||||
{t('Pending')}
|
||||
</div>
|
||||
<div className='text-2xl font-semibold'>
|
||||
{formatQuota(user?.aff_quota ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
|
||||
{t('Total Earned')}
|
||||
</div>
|
||||
<div className='text-2xl font-semibold'>
|
||||
{formatQuota(user?.aff_history_quota ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
|
||||
{t('Invites')}
|
||||
</div>
|
||||
<div className='text-2xl font-semibold'>{user?.aff_count ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transfer Button */}
|
||||
{hasRewards && (
|
||||
<Button onClick={onTransfer} className='w-full' variant='default'>
|
||||
{t('Transfer to Balance')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Affiliate Link */}
|
||||
<div className='space-y-3'>
|
||||
<Label className='text-muted-foreground text-xs tracking-wider uppercase'>
|
||||
{t('Your Referral Link')}
|
||||
</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
value={affiliateLink}
|
||||
readOnly
|
||||
className='border-muted bg-muted/30 font-mono text-sm'
|
||||
/>
|
||||
<CopyButton
|
||||
value={affiliateLink}
|
||||
variant='outline'
|
||||
className='size-9'
|
||||
iconClassName='size-4'
|
||||
tooltip={t('Copy referral link')}
|
||||
aria-label={t('Copy referral link')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className='bg-muted/30 space-y-2 rounded-lg p-4'>
|
||||
<p className='text-muted-foreground text-sm leading-relaxed'>
|
||||
{t(
|
||||
'Earn rewards when your referrals add funds. Transfer accumulated rewards to your balance anytime.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatNumber } from '@/lib/format'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { formatCreemPrice } from '../lib/format'
|
||||
import type { CreemProduct } from '../types'
|
||||
|
||||
interface CreemProductsSectionProps {
|
||||
products: CreemProduct[]
|
||||
onProductSelect: (product: CreemProduct) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function CreemProductsSection({
|
||||
products,
|
||||
onProductSelect,
|
||||
loading,
|
||||
}: CreemProductsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-24 rounded-lg' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!Array.isArray(products) || products.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3'>
|
||||
{products.map((product) => (
|
||||
<Card
|
||||
key={product.productId}
|
||||
className='hover:border-foreground/50 cursor-pointer transition-all hover:shadow-md'
|
||||
onClick={() => onProductSelect(product)}
|
||||
>
|
||||
<CardContent className='p-4 text-center'>
|
||||
<div className='mb-2 text-lg font-medium'>{product.name}</div>
|
||||
<div className='text-muted-foreground mb-2 text-sm'>
|
||||
{t('Quota')}: {formatNumber(product.quota)}
|
||||
</div>
|
||||
<div className='text-lg font-semibold text-indigo-600'>
|
||||
{formatCreemPrice(product.price, product.currency)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
import { useState } from 'react'
|
||||
import { Search, Copy, Check, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatCurrencyFromUSD } from '@/lib/currency'
|
||||
import { formatNumber } from '@/lib/format'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { useBillingHistory } from '../../hooks/use-billing-history'
|
||||
import {
|
||||
getStatusConfig,
|
||||
getPaymentMethodName,
|
||||
formatTimestamp,
|
||||
} from '../../lib/billing'
|
||||
|
||||
interface BillingHistoryDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function BillingHistoryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: BillingHistoryDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
records,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
keyword,
|
||||
loading,
|
||||
completing,
|
||||
isAdmin,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
handleSearch,
|
||||
handleCompleteOrder,
|
||||
} = useBillingHistory()
|
||||
|
||||
const [confirmTradeNo, setConfirmTradeNo] = useState<string | null>(null)
|
||||
const { copyToClipboard, copiedText } = useCopyToClipboard({ notify: false })
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
const handleConfirmComplete = async () => {
|
||||
if (confirmTradeNo) {
|
||||
const success = await handleCompleteOrder(confirmTradeNo)
|
||||
if (success) {
|
||||
setConfirmTradeNo(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-w-4xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Billing History')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('View your topup transaction records and payment history')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{/* Search and Filter Bar */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='relative flex-1'>
|
||||
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
|
||||
<Input
|
||||
placeholder={t('Search by order number...')}
|
||||
value={keyword}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className='pl-10'
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={(value) => handlePageSizeChange(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className='w-32'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='10'>{t('10 / page')}</SelectItem>
|
||||
<SelectItem value='20'>{t('20 / page')}</SelectItem>
|
||||
<SelectItem value='50'>{t('50 / page')}</SelectItem>
|
||||
<SelectItem value='100'>{t('100 / page')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Records List */}
|
||||
<ScrollArea className='h-[500px] pr-4'>
|
||||
{loading ? (
|
||||
<div className='space-y-3'>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className='rounded-lg border p-4'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<div className='flex-1 space-y-2'>
|
||||
<Skeleton className='h-4 w-48' />
|
||||
<Skeleton className='h-3 w-32' />
|
||||
</div>
|
||||
<Skeleton className='h-5 w-16' />
|
||||
</div>
|
||||
<div className='mt-3 grid grid-cols-3 gap-4'>
|
||||
<Skeleton className='h-3 w-full' />
|
||||
<Skeleton className='h-3 w-full' />
|
||||
<Skeleton className='h-3 w-full' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className='text-muted-foreground flex h-[400px] flex-col items-center justify-center text-center'>
|
||||
<p className='text-sm font-medium'>
|
||||
{t('No billing records found')}
|
||||
</p>
|
||||
<p className='mt-1 text-xs'>
|
||||
{keyword
|
||||
? 'Try adjusting your search'
|
||||
: 'Your transaction history will appear here'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{records.map((record) => {
|
||||
const statusConfig = getStatusConfig(record.status)
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className='hover:bg-muted/50 rounded-lg border p-4 transition-colors'
|
||||
>
|
||||
{/* Header Row */}
|
||||
<div className='flex items-start justify-between'>
|
||||
<div className='flex-1 space-y-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='text-foreground font-mono text-sm'>
|
||||
{record.trade_no}
|
||||
</code>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-5 w-5 p-0'
|
||||
onClick={() => copyToClipboard(record.trade_no)}
|
||||
>
|
||||
{copiedText === record.trade_no ? (
|
||||
<Check className='h-3 w-3' />
|
||||
) : (
|
||||
<Copy className='h-3 w-3' />
|
||||
)}
|
||||
</Button>
|
||||
{isAdmin && record.user_id != null && (
|
||||
<StatusBadge
|
||||
label={`${t('User ID')}: ${record.user_id}`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyText={String(record.user_id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{formatTimestamp(record.create_time)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge
|
||||
label={statusConfig.label}
|
||||
variant={statusConfig.variant}
|
||||
showDot
|
||||
copyable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className='mt-4 grid grid-cols-3 gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-muted-foreground text-xs'>
|
||||
Payment Method
|
||||
</Label>
|
||||
<div className='text-sm font-medium'>
|
||||
{getPaymentMethodName(record.payment_method)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-muted-foreground text-xs'>
|
||||
Amount
|
||||
</Label>
|
||||
<div className='text-sm font-semibold'>
|
||||
{formatCurrencyFromUSD(record.amount, {
|
||||
digitsLarge: 2,
|
||||
digitsSmall: 2,
|
||||
abbreviate: false,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-muted-foreground text-xs'>
|
||||
Payment
|
||||
</Label>
|
||||
<div className='text-sm font-semibold text-red-600'>
|
||||
{formatNumber(record.money)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Actions */}
|
||||
{isAdmin && record.status === 'pending' && (
|
||||
<div className='mt-4 flex justify-end'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => setConfirmTradeNo(record.trade_no)}
|
||||
disabled={completing}
|
||||
>
|
||||
Complete Order
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && records.length > 0 && (
|
||||
<div className='flex flex-col items-center gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='text-muted-foreground text-xs sm:text-sm'>
|
||||
{t('Showing')} {(page - 1) * pageSize + 1}-
|
||||
{Math.min(page * pageSize, total)} {t('of')} {total}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className='h-8 w-8 p-0'
|
||||
>
|
||||
<ChevronLeft className='h-4 w-4' />
|
||||
</Button>
|
||||
<div className='text-muted-foreground flex items-center gap-1 text-sm'>
|
||||
<span className='font-medium'>{page}</span>
|
||||
<span>/</span>
|
||||
<span>{totalPages}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className='h-8 w-8 p-0'
|
||||
>
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Confirm Complete Order Dialog */}
|
||||
<AlertDialog
|
||||
open={!!confirmTradeNo}
|
||||
onOpenChange={(open) => !open && setConfirmTradeNo(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Complete Order')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(
|
||||
'Are you sure you want to manually complete this order? The user will be credited with the corresponding quota.'
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={completing}>
|
||||
{t('Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmComplete}
|
||||
disabled={completing}
|
||||
>
|
||||
{completing ? 'Processing...' : 'Confirm'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatNumber } from '@/lib/format'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { formatCreemPrice } from '../../lib/format'
|
||||
import type { CreemProduct } from '../../types'
|
||||
|
||||
interface CreemConfirmDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
product: CreemProduct | null
|
||||
processing: boolean
|
||||
}
|
||||
|
||||
export function CreemConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
product,
|
||||
processing,
|
||||
}: CreemConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!product) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-[425px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Confirm Creem Purchase')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Review your purchase details before proceeding.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('Product')}</span>
|
||||
<span className='font-medium'>{product.name}</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('Price')}</span>
|
||||
<span className='font-medium text-indigo-600'>
|
||||
{formatCreemPrice(product.price, product.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('Quota')}</span>
|
||||
<span className='font-medium'>{formatNumber(product.quota)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={processing}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} disabled={processing}>
|
||||
{processing && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{t('Confirm Payment')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatLocalCurrencyAmount } from '@/lib/currency'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { DEFAULT_DISCOUNT_RATE } from '../../constants'
|
||||
import { formatCurrency, getPaymentIcon } from '../../lib'
|
||||
import type { PaymentMethod } from '../../types'
|
||||
|
||||
interface PaymentConfirmDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
topupAmount: number
|
||||
paymentAmount: number
|
||||
paymentMethod: PaymentMethod | undefined
|
||||
calculating: boolean
|
||||
processing: boolean
|
||||
discountRate?: number
|
||||
usdExchangeRate?: number
|
||||
}
|
||||
|
||||
export function PaymentConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
topupAmount,
|
||||
paymentAmount,
|
||||
paymentMethod,
|
||||
calculating,
|
||||
processing,
|
||||
discountRate = DEFAULT_DISCOUNT_RATE,
|
||||
usdExchangeRate = 1,
|
||||
}: PaymentConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const hasDiscount = discountRate > 0 && discountRate < 1 && paymentAmount > 0
|
||||
const originalAmount = hasDiscount ? paymentAmount / discountRate : 0
|
||||
const discountAmount = hasDiscount ? originalAmount - paymentAmount : 0
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className='max-w-md'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className='text-xl font-semibold'>
|
||||
{t('Confirm Payment')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('Review your payment details')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Topup Amount')}
|
||||
</span>
|
||||
<span className='text-lg font-semibold'>
|
||||
{formatLocalCurrencyAmount(topupAmount * usdExchangeRate, {
|
||||
digitsLarge: 2,
|
||||
digitsSmall: 2,
|
||||
abbreviate: false,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('You Pay')}
|
||||
</span>
|
||||
{calculating ? (
|
||||
<Skeleton className='h-6 w-24' />
|
||||
) : (
|
||||
<div className='flex items-baseline gap-2'>
|
||||
<span className='text-2xl font-semibold'>
|
||||
{formatCurrency(paymentAmount)}
|
||||
</span>
|
||||
{hasDiscount && (
|
||||
<span className='text-muted-foreground text-sm line-through'>
|
||||
{formatCurrency(originalAmount)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasDiscount && !calculating && (
|
||||
<div className='bg-muted/50 rounded-lg p-3'>
|
||||
<div className='flex items-center justify-between text-sm'>
|
||||
<span className='text-muted-foreground'>{t('You save')}</span>
|
||||
<span className='font-semibold text-green-600'>
|
||||
{formatCurrency(discountAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='border-t pt-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Payment Method')}
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
{getPaymentIcon(
|
||||
paymentMethod?.type,
|
||||
'h-4 w-4',
|
||||
paymentMethod?.icon,
|
||||
paymentMethod?.name
|
||||
)}
|
||||
<span className='font-medium'>{paymentMethod?.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={processing}>
|
||||
{t('Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm} disabled={processing}>
|
||||
{processing && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{t('Confirm Payment')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { QUOTA_PER_DOLLAR } from '../../constants'
|
||||
|
||||
interface TransferDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (amount: number) => Promise<boolean>
|
||||
availableQuota: number
|
||||
transferring: boolean
|
||||
}
|
||||
|
||||
export function TransferDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
availableQuota,
|
||||
transferring,
|
||||
}: TransferDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [amount, setAmount] = useState(QUOTA_PER_DOLLAR)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAmount(QUOTA_PER_DOLLAR)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const success = await onConfirm(amount)
|
||||
if (success) {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='text-xl font-semibold'>
|
||||
{t('Transfer Rewards')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Move affiliate rewards to your main balance')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-6 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
|
||||
{t('Available Rewards')}
|
||||
</Label>
|
||||
<div className='text-2xl font-semibold'>
|
||||
{formatQuota(availableQuota)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<Label
|
||||
htmlFor='transfer-amount'
|
||||
className='text-muted-foreground text-xs font-medium tracking-wider uppercase'
|
||||
>
|
||||
{t('Transfer Amount')}
|
||||
</Label>
|
||||
<Input
|
||||
id='transfer-amount'
|
||||
type='number'
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(Number(e.target.value))}
|
||||
min={QUOTA_PER_DOLLAR}
|
||||
max={availableQuota}
|
||||
step={QUOTA_PER_DOLLAR}
|
||||
className='font-mono text-lg'
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Minimum:')} {formatQuota(QUOTA_PER_DOLLAR)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={transferring}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={transferring}>
|
||||
{transferring && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{t('Transfer')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Gift, ExternalLink, Loader2, Receipt } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatNumber } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
formatCurrency,
|
||||
getDiscountLabel,
|
||||
getPaymentIcon,
|
||||
getMinTopupAmount,
|
||||
calculatePresetPricing,
|
||||
} from '../lib'
|
||||
import type {
|
||||
PaymentMethod,
|
||||
PresetAmount,
|
||||
TopupInfo,
|
||||
CreemProduct,
|
||||
WaffoPayMethod,
|
||||
} from '../types'
|
||||
import { CreemProductsSection } from './creem-products-section'
|
||||
|
||||
interface RechargeFormCardProps {
|
||||
topupInfo: TopupInfo | null
|
||||
presetAmounts: PresetAmount[]
|
||||
selectedPreset: number | null
|
||||
onSelectPreset: (preset: PresetAmount) => void
|
||||
topupAmount: number
|
||||
onTopupAmountChange: (amount: number) => void
|
||||
paymentAmount: number
|
||||
calculating: boolean
|
||||
onPaymentMethodSelect: (method: PaymentMethod) => void
|
||||
paymentLoading: string | null
|
||||
redemptionCode: string
|
||||
onRedemptionCodeChange: (code: string) => void
|
||||
onRedeem: () => void
|
||||
redeeming: boolean
|
||||
topupLink?: string
|
||||
loading?: boolean
|
||||
priceRatio?: number
|
||||
usdExchangeRate?: number
|
||||
onOpenBilling?: () => void
|
||||
creemProducts?: CreemProduct[]
|
||||
enableCreemTopup?: boolean
|
||||
onCreemProductSelect?: (product: CreemProduct) => void
|
||||
enableWaffoTopup?: boolean
|
||||
waffoPayMethods?: WaffoPayMethod[]
|
||||
waffoMinTopup?: number
|
||||
onWaffoMethodSelect?: (method: WaffoPayMethod, index: number) => void
|
||||
enableWaffoPancakeTopup?: boolean
|
||||
}
|
||||
|
||||
export function RechargeFormCard({
|
||||
topupInfo,
|
||||
presetAmounts,
|
||||
selectedPreset,
|
||||
onSelectPreset,
|
||||
topupAmount,
|
||||
onTopupAmountChange,
|
||||
paymentAmount,
|
||||
calculating,
|
||||
onPaymentMethodSelect,
|
||||
paymentLoading,
|
||||
redemptionCode,
|
||||
onRedemptionCodeChange,
|
||||
onRedeem,
|
||||
redeeming,
|
||||
topupLink,
|
||||
loading,
|
||||
priceRatio = 1,
|
||||
usdExchangeRate = 1,
|
||||
onOpenBilling,
|
||||
creemProducts,
|
||||
enableCreemTopup,
|
||||
onCreemProductSelect,
|
||||
enableWaffoTopup,
|
||||
waffoPayMethods,
|
||||
waffoMinTopup,
|
||||
onWaffoMethodSelect,
|
||||
enableWaffoPancakeTopup,
|
||||
}: RechargeFormCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const [localAmount, setLocalAmount] = useState(topupAmount.toString())
|
||||
|
||||
useEffect(() => {
|
||||
setLocalAmount(topupAmount.toString())
|
||||
}, [topupAmount])
|
||||
|
||||
const handleAmountChange = (value: string) => {
|
||||
setLocalAmount(value)
|
||||
const numValue = parseInt(value) || 0
|
||||
if (numValue >= 0) {
|
||||
onTopupAmountChange(numValue)
|
||||
}
|
||||
}
|
||||
|
||||
const hasConfigurableTopup =
|
||||
topupInfo?.enable_online_topup ||
|
||||
topupInfo?.enable_stripe_topup ||
|
||||
enableWaffoTopup ||
|
||||
enableWaffoPancakeTopup
|
||||
const hasAnyTopup = hasConfigurableTopup || enableCreemTopup
|
||||
const hasStandardPaymentMethods =
|
||||
Array.isArray(topupInfo?.pay_methods) && topupInfo.pay_methods.length > 0
|
||||
const hasWaffoPaymentMethods =
|
||||
Array.isArray(waffoPayMethods) && waffoPayMethods.length > 0
|
||||
const minTopup = getMinTopupAmount(topupInfo)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className='h-6 w-32' />
|
||||
<Skeleton className='mt-2 h-4 w-48' />
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-8'>
|
||||
<div className='space-y-6'>
|
||||
{/* Preset Amounts Skeleton */}
|
||||
<div className='space-y-3'>
|
||||
<Skeleton className='h-3 w-16' />
|
||||
<div className='grid grid-cols-2 gap-3 sm:grid-cols-4'>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-[72px] rounded-lg' />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Amount Input Skeleton */}
|
||||
<div className='space-y-3'>
|
||||
<Skeleton className='h-3 w-28' />
|
||||
<Skeleton className='h-[42px] w-full' />
|
||||
</div>
|
||||
|
||||
{/* Payment Methods Skeleton */}
|
||||
<div className='space-y-3'>
|
||||
<Skeleton className='h-3 w-32' />
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-10 w-24 rounded-lg' />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Redemption Code Section Skeleton */}
|
||||
<div className='space-y-3 border-t pt-8'>
|
||||
<Skeleton className='h-3 w-24' />
|
||||
<div className='flex gap-2'>
|
||||
<Skeleton className='h-10 flex-1' />
|
||||
<Skeleton className='h-10 w-20' />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h3 className='text-xl font-semibold tracking-tight'>
|
||||
{t('Add Funds')}
|
||||
</h3>
|
||||
<p className='text-muted-foreground mt-2 text-sm'>
|
||||
{t('Choose an amount and payment method')}
|
||||
</p>
|
||||
</div>
|
||||
{onOpenBilling && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onOpenBilling}
|
||||
className='gap-2'
|
||||
>
|
||||
<Receipt className='h-4 w-4' />
|
||||
{t('Billing')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-8'>
|
||||
{/* Online Topup Section */}
|
||||
{hasAnyTopup ? (
|
||||
<div className='space-y-6'>
|
||||
{hasConfigurableTopup && (
|
||||
<>
|
||||
{presetAmounts.length > 0 && (
|
||||
<div className='space-y-3'>
|
||||
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
|
||||
{t('Amount')}
|
||||
</Label>
|
||||
<div className='grid grid-cols-2 gap-3 sm:grid-cols-4'>
|
||||
{presetAmounts.map((preset, index) => {
|
||||
const discount =
|
||||
preset.discount ||
|
||||
topupInfo?.discount?.[preset.value] ||
|
||||
1.0
|
||||
const {
|
||||
displayValue,
|
||||
actualPrice,
|
||||
savedAmount,
|
||||
hasDiscount,
|
||||
} = calculatePresetPricing(
|
||||
preset.value,
|
||||
priceRatio,
|
||||
discount,
|
||||
usdExchangeRate
|
||||
)
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'hover:border-foreground h-auto rounded-lg p-4 text-left whitespace-normal',
|
||||
selectedPreset === preset.value
|
||||
? 'border-foreground bg-foreground/5'
|
||||
: 'border-muted'
|
||||
)}
|
||||
onClick={() => onSelectPreset(preset)}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div className='text-lg font-semibold'>
|
||||
{formatNumber(displayValue)}
|
||||
</div>
|
||||
{hasDiscount && (
|
||||
<div className='text-xs font-medium text-green-600'>
|
||||
{getDiscountLabel(discount)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-2 w-full text-xs'>
|
||||
Pay {formatCurrency(actualPrice)}
|
||||
{hasDiscount && savedAmount > 0 && (
|
||||
<span className='text-green-600'>
|
||||
{' '}
|
||||
• Save {formatCurrency(savedAmount)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-3'>
|
||||
<Label
|
||||
htmlFor='topup-amount'
|
||||
className='text-muted-foreground text-xs font-medium tracking-wider uppercase'
|
||||
>
|
||||
{t('Custom Amount')}
|
||||
</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='topup-amount'
|
||||
type='number'
|
||||
value={localAmount}
|
||||
onChange={(e) => handleAmountChange(e.target.value)}
|
||||
min={minTopup}
|
||||
placeholder={`Minimum ${minTopup}`}
|
||||
className='pr-32 text-lg'
|
||||
/>
|
||||
<div className='absolute end-3 top-1/2 flex -translate-y-1/2 items-center gap-2'>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Amount to pay:')}
|
||||
</span>
|
||||
{calculating ? (
|
||||
<Skeleton className='h-5 w-16' />
|
||||
) : (
|
||||
<span className='text-sm font-semibold'>
|
||||
{formatCurrency(paymentAmount)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
|
||||
{t('Payment Method')}
|
||||
</Label>
|
||||
{hasStandardPaymentMethods ? (
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{topupInfo?.pay_methods?.map((method) => {
|
||||
const minTopup = method.min_topup || 0
|
||||
const disabled = minTopup > topupAmount
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
key={method.type}
|
||||
variant='outline'
|
||||
onClick={() => onPaymentMethodSelect(method)}
|
||||
disabled={disabled || !!paymentLoading}
|
||||
className='gap-2 rounded-lg'
|
||||
>
|
||||
{paymentLoading === method.type ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
getPaymentIcon(
|
||||
method.type,
|
||||
'h-4 w-4',
|
||||
method.icon,
|
||||
method.name
|
||||
)
|
||||
)}
|
||||
{method.name}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return disabled ? (
|
||||
<TooltipProvider key={method.type}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('Minimum topup amount: {{amount}}', {
|
||||
amount: minTopup,
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
button
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : hasWaffoPaymentMethods ? null : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'No payment methods available. Please contact administrator.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enableWaffoTopup &&
|
||||
hasWaffoPaymentMethods &&
|
||||
onWaffoMethodSelect && (
|
||||
<div className='space-y-3'>
|
||||
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
|
||||
{t('Waffo Payment')}
|
||||
</Label>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{waffoPayMethods?.map((method, index) => {
|
||||
const loadingKey = `waffo-${index}`
|
||||
const waffoMin = waffoMinTopup || 0
|
||||
const belowMin = waffoMin > topupAmount
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
key={`${method.name}-${index}`}
|
||||
variant='outline'
|
||||
onClick={() => onWaffoMethodSelect(method, index)}
|
||||
disabled={belowMin || !!paymentLoading}
|
||||
className='gap-2 rounded-lg'
|
||||
>
|
||||
{paymentLoading === loadingKey ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : method.icon ? (
|
||||
<img
|
||||
src={method.icon}
|
||||
alt={method.name}
|
||||
className='h-4 w-4 object-contain'
|
||||
/>
|
||||
) : (
|
||||
getPaymentIcon('waffo')
|
||||
)}
|
||||
{method.name}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return belowMin ? (
|
||||
<TooltipProvider key={`${method.name}-${index}`}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{button}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('Minimum topup amount: {{amount}}', {
|
||||
amount: waffoMin,
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
button
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'Online topup is not enabled. Please use redemption code or contact administrator.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Creem Products Section */}
|
||||
{enableCreemTopup &&
|
||||
Array.isArray(creemProducts) &&
|
||||
creemProducts.length > 0 &&
|
||||
onCreemProductSelect && (
|
||||
<div className='space-y-3 border-t pt-6'>
|
||||
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
|
||||
{t('Creem Payment')}
|
||||
</Label>
|
||||
<CreemProductsSection
|
||||
products={creemProducts}
|
||||
onProductSelect={onCreemProductSelect}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Redemption Code Section */}
|
||||
<div className='space-y-3 border-t pt-8'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Gift className='text-muted-foreground h-4 w-4' />
|
||||
<Label
|
||||
htmlFor='redemption-code'
|
||||
className='text-muted-foreground text-xs font-medium tracking-wider uppercase'
|
||||
>
|
||||
{t('Have a Code?')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='redemption-code'
|
||||
value={redemptionCode}
|
||||
onChange={(e) => onRedemptionCodeChange(e.target.value)}
|
||||
placeholder={t('Enter your redemption code')}
|
||||
className='flex-1'
|
||||
/>
|
||||
<Button onClick={onRedeem} disabled={redeeming} variant='outline'>
|
||||
{redeeming && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{t('Redeem')}
|
||||
</Button>
|
||||
</div>
|
||||
{topupLink && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Need a code?')}{' '}
|
||||
<a
|
||||
href={topupLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center gap-1 underline-offset-4 hover:underline'
|
||||
>
|
||||
{t('Purchase here')}
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { Crown, RefreshCw, Sparkles, Check } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
StatusBadge,
|
||||
dotColorMap,
|
||||
textColorMap,
|
||||
} from '@/components/status-badge'
|
||||
import {
|
||||
getPublicPlans,
|
||||
getSelfSubscriptionFull,
|
||||
updateBillingPreference,
|
||||
} from '@/features/subscriptions/api'
|
||||
import { SubscriptionPurchaseDialog } from '@/features/subscriptions/components/dialogs/subscription-purchase-dialog'
|
||||
import { formatDuration, formatResetPeriod } from '@/features/subscriptions/lib'
|
||||
import type {
|
||||
PlanRecord,
|
||||
UserSubscriptionRecord,
|
||||
} from '@/features/subscriptions/types'
|
||||
import type { PaymentMethod, TopupInfo } from '../types'
|
||||
|
||||
interface SubscriptionPlansCardProps {
|
||||
topupInfo: TopupInfo | null
|
||||
}
|
||||
|
||||
function getEpayMethods(payMethods: PaymentMethod[] = []): PaymentMethod[] {
|
||||
return payMethods.filter(
|
||||
(m) => m?.type && m.type !== 'stripe' && m.type !== 'creem'
|
||||
)
|
||||
}
|
||||
|
||||
export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const { status } = useStatus()
|
||||
|
||||
const [plans, setPlans] = useState<PlanRecord[]>([])
|
||||
const [activeSubscriptions, setActiveSubscriptions] = useState<
|
||||
UserSubscriptionRecord[]
|
||||
>([])
|
||||
const [allSubscriptions, setAllSubscriptions] = useState<
|
||||
UserSubscriptionRecord[]
|
||||
>([])
|
||||
const [billingPreference, setBillingPreference] =
|
||||
useState('subscription_first')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const [purchaseOpen, setPurchaseOpen] = useState(false)
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanRecord | null>(null)
|
||||
|
||||
const enableStripe = !!status?.enable_stripe_topup
|
||||
const enableCreem = !!props.topupInfo?.enable_creem_topup
|
||||
const enableOnlineTopUp = !!status?.enable_online_topup
|
||||
const epayMethods = useMemo(
|
||||
() => getEpayMethods(props.topupInfo?.pay_methods),
|
||||
[props.topupInfo?.pay_methods]
|
||||
)
|
||||
|
||||
const fetchPlans = useCallback(async () => {
|
||||
try {
|
||||
const res = await getPublicPlans()
|
||||
if (res.success) {
|
||||
setPlans(res.data || [])
|
||||
}
|
||||
} catch {
|
||||
setPlans([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSelfSubscription = useCallback(async () => {
|
||||
try {
|
||||
const res = await getSelfSubscriptionFull()
|
||||
if (res.success && res.data) {
|
||||
setBillingPreference(
|
||||
res.data.billing_preference || 'subscription_first'
|
||||
)
|
||||
setActiveSubscriptions(res.data.subscriptions || [])
|
||||
setAllSubscriptions(res.data.all_subscriptions || [])
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setLoading(true)
|
||||
await Promise.all([fetchPlans(), fetchSelfSubscription()])
|
||||
setLoading(false)
|
||||
}
|
||||
init()
|
||||
}, [fetchPlans, fetchSelfSubscription])
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await fetchSelfSubscription()
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreferenceChange = async (pref: string) => {
|
||||
const previous = billingPreference
|
||||
setBillingPreference(pref)
|
||||
try {
|
||||
const res = await updateBillingPreference(pref)
|
||||
if (res.success) {
|
||||
toast.success(t('Updated successfully'))
|
||||
const normalized = res.data?.billing_preference || pref
|
||||
setBillingPreference(normalized)
|
||||
} else {
|
||||
toast.error(res.message || t('Update failed'))
|
||||
setBillingPreference(previous)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('Request failed'))
|
||||
setBillingPreference(previous)
|
||||
}
|
||||
}
|
||||
|
||||
const hasActive = activeSubscriptions.length > 0
|
||||
const hasAny = allSubscriptions.length > 0
|
||||
const disablePref = !hasActive
|
||||
const isSubPref =
|
||||
billingPreference === 'subscription_first' ||
|
||||
billingPreference === 'subscription_only'
|
||||
const displayPref =
|
||||
disablePref && isSubPref ? 'wallet_first' : billingPreference
|
||||
|
||||
const planPurchaseCountMap = useMemo(() => {
|
||||
const map = new Map<number, number>()
|
||||
for (const sub of allSubscriptions) {
|
||||
const planId = sub?.subscription?.plan_id
|
||||
if (!planId) continue
|
||||
map.set(planId, (map.get(planId) || 0) + 1)
|
||||
}
|
||||
return map
|
||||
}, [allSubscriptions])
|
||||
|
||||
const planTitleMap = useMemo(() => {
|
||||
const map = new Map<number, string>()
|
||||
for (const p of plans) {
|
||||
if (p?.plan?.id) {
|
||||
map.set(p.plan.id, p.plan.title || '')
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [plans])
|
||||
|
||||
const getRemainingDays = (sub: UserSubscriptionRecord) => {
|
||||
const endTime = sub?.subscription?.end_time || 0
|
||||
if (!endTime) return 0
|
||||
const now = Date.now() / 1000
|
||||
return Math.max(0, Math.ceil((endTime - now) / 86400))
|
||||
}
|
||||
|
||||
const getUsagePercent = (sub: UserSubscriptionRecord) => {
|
||||
const total = Number(sub?.subscription?.amount_total || 0)
|
||||
const used = Number(sub?.subscription?.amount_used || 0)
|
||||
if (total <= 0) return 0
|
||||
return Math.round((used / total) * 100)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className='h-6 w-32' />
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<Skeleton className='h-20 w-full' />
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-48 w-full' />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (plans.length === 0 && !hasAny) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<Crown className='h-4 w-4' />
|
||||
{t('Subscription Plans')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-5'>
|
||||
{/* My subscriptions & billing preference */}
|
||||
<Card className='bg-muted/50'>
|
||||
<CardContent className='p-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm font-medium'>
|
||||
{t('My Subscriptions')}
|
||||
</span>
|
||||
<span className='flex items-center gap-1.5 text-xs font-medium'>
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
hasActive ? dotColorMap.success : dotColorMap.neutral
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
{hasActive ? (
|
||||
<span className={cn(textColorMap.success)}>
|
||||
{activeSubscriptions.length} {t('active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground'>
|
||||
{t('No Active')}
|
||||
</span>
|
||||
)}
|
||||
{allSubscriptions.length > activeSubscriptions.length && (
|
||||
<>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='text-muted-foreground'>
|
||||
{allSubscriptions.length - activeSubscriptions.length}{' '}
|
||||
{t('expired')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Select
|
||||
value={displayPref}
|
||||
onValueChange={handlePreferenceChange}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[140px] text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
value='subscription_first'
|
||||
disabled={disablePref}
|
||||
>
|
||||
{t('Subscription First')}
|
||||
{disablePref ? ` (${t('No Active')})` : ''}
|
||||
</SelectItem>
|
||||
<SelectItem value='wallet_first'>
|
||||
{t('Wallet First')}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value='subscription_only'
|
||||
disabled={disablePref}
|
||||
>
|
||||
{t('Subscription Only')}
|
||||
{disablePref ? ` (${t('No Active')})` : ''}
|
||||
</SelectItem>
|
||||
<SelectItem value='wallet_only'>
|
||||
{t('Wallet Only')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${refreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{disablePref && isSubPref && (
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
{t(
|
||||
'Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.',
|
||||
{
|
||||
pref:
|
||||
billingPreference === 'subscription_only'
|
||||
? t('Subscription Only')
|
||||
: t('Subscription First'),
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasAny && (
|
||||
<>
|
||||
<Separator className='my-3' />
|
||||
<div className='max-h-64 space-y-3 overflow-y-auto pr-1'>
|
||||
{allSubscriptions.map((sub) => {
|
||||
const subscription = sub.subscription
|
||||
const totalAmount = Number(
|
||||
subscription?.amount_total || 0
|
||||
)
|
||||
const usedAmount = Number(subscription?.amount_used || 0)
|
||||
const remainAmount =
|
||||
totalAmount > 0
|
||||
? Math.max(0, totalAmount - usedAmount)
|
||||
: 0
|
||||
const planTitle =
|
||||
planTitleMap.get(subscription?.plan_id) || ''
|
||||
const remainDays = getRemainingDays(sub)
|
||||
const usagePercent = getUsagePercent(sub)
|
||||
const now = Date.now() / 1000
|
||||
const isExpired = (subscription?.end_time || 0) < now
|
||||
const isCancelled = subscription?.status === 'cancelled'
|
||||
const isActive =
|
||||
subscription?.status === 'active' && !isExpired
|
||||
|
||||
return (
|
||||
<div
|
||||
key={subscription?.id}
|
||||
className='bg-background rounded-md border p-3 text-xs'
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium'>
|
||||
{planTitle
|
||||
? `${planTitle} · ${t('Subscription')} #${subscription?.id}`
|
||||
: `${t('Subscription')} #${subscription?.id}`}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<StatusBadge
|
||||
label={t('Active')}
|
||||
variant='success'
|
||||
copyable={false}
|
||||
/>
|
||||
) : isCancelled ? (
|
||||
<StatusBadge
|
||||
label={t('Cancelled')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge
|
||||
label={t('Expired')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isActive && (
|
||||
<span className='text-muted-foreground'>
|
||||
{t('{{count}} days remaining', {
|
||||
count: remainDays,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-1.5'>
|
||||
{isActive
|
||||
? t('Until')
|
||||
: isCancelled
|
||||
? t('Cancelled at')
|
||||
: t('Expired at')}{' '}
|
||||
{new Date(
|
||||
(subscription?.end_time || 0) * 1000
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
{isActive &&
|
||||
(subscription?.next_reset_time ?? 0) > 0 && (
|
||||
<div className='text-muted-foreground mt-1'>
|
||||
{t('Next reset')}:{' '}
|
||||
{new Date(
|
||||
subscription!.next_reset_time! * 1000
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
<div className='text-muted-foreground mt-1'>
|
||||
{t('Total Quota')}:{' '}
|
||||
{totalAmount > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='cursor-help'>
|
||||
{formatQuota(usedAmount)}/
|
||||
{formatQuota(totalAmount)} ·{' '}
|
||||
{t('Remaining')} {formatQuota(remainAmount)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('Raw Quota')}: {usedAmount}/{totalAmount} ·{' '}
|
||||
{t('Remaining')} {remainAmount}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
t('Unlimited')
|
||||
)}
|
||||
{totalAmount > 0 && (
|
||||
<span className='ml-2'>
|
||||
{t('Used')} {usagePercent}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{totalAmount > 0 && isActive && (
|
||||
<Progress
|
||||
value={usagePercent}
|
||||
className='mt-2 h-1.5'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hasAny && (
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
{t('Purchase a plan to enjoy model benefits')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Available plans grid */}
|
||||
{plans.length > 0 ? (
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3'>
|
||||
{plans.map((p, index) => {
|
||||
const plan = p?.plan
|
||||
if (!plan) return null
|
||||
const totalAmount = Number(plan.total_amount || 0)
|
||||
const price = Number(plan.price_amount || 0).toFixed(2)
|
||||
const isPopular = index === 0 && plans.length > 1
|
||||
const limit = Number(plan.max_purchase_per_user || 0)
|
||||
const count = planPurchaseCountMap.get(plan.id) || 0
|
||||
const reached = limit > 0 && count >= limit
|
||||
|
||||
const benefits = [
|
||||
`${t('Validity Period')}: ${formatDuration(plan, t)}`,
|
||||
formatResetPeriod(plan, t) !== t('No Reset')
|
||||
? `${t('Quota Reset')}: ${formatResetPeriod(plan, t)}`
|
||||
: null,
|
||||
totalAmount > 0
|
||||
? `${t('Total Quota')}: ${formatQuota(totalAmount)}`
|
||||
: `${t('Total Quota')}: ${t('Unlimited')}`,
|
||||
limit > 0 ? `${t('Purchase Limit')}: ${limit}` : null,
|
||||
plan.upgrade_group
|
||||
? `${t('Upgrade Group')}: ${plan.upgrade_group}`
|
||||
: null,
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className={`relative transition-shadow hover:shadow-md ${
|
||||
isPopular ? 'ring-primary ring-2' : ''
|
||||
}`}
|
||||
>
|
||||
{isPopular && (
|
||||
<div className='absolute -top-2.5 left-3'>
|
||||
<StatusBadge variant='info' copyable={false}>
|
||||
<Sparkles className='h-3 w-3' />
|
||||
{t('Recommended')}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className='flex h-full flex-col p-4 pt-5'>
|
||||
<div className='mb-2'>
|
||||
<h4 className='truncate font-semibold'>
|
||||
{plan.title || t('Subscription Plans')}
|
||||
</h4>
|
||||
{plan.subtitle && (
|
||||
<p className='text-muted-foreground truncate text-xs'>
|
||||
{plan.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='py-2'>
|
||||
<span className='text-primary text-2xl font-bold'>
|
||||
${price}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 space-y-1.5 pb-3'>
|
||||
{benefits.map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
className='text-muted-foreground flex items-center gap-2 text-xs'
|
||||
>
|
||||
<Check className='text-primary h-3 w-3 shrink-0' />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator className='mb-3' />
|
||||
|
||||
{reached ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
disabled
|
||||
>
|
||||
{t('Limit Reached')}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('Purchase limit reached')} ({count}/{limit})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
setSelectedPlan(p)
|
||||
setPurchaseOpen(true)
|
||||
}}
|
||||
>
|
||||
{t('Subscribe Now')}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground py-4 text-center text-sm'>
|
||||
{t('No plans available')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SubscriptionPurchaseDialog
|
||||
open={purchaseOpen}
|
||||
onOpenChange={(open) => {
|
||||
setPurchaseOpen(open)
|
||||
if (!open) {
|
||||
fetchSelfSubscription()
|
||||
}
|
||||
}}
|
||||
plan={selectedPlan}
|
||||
enableStripe={enableStripe}
|
||||
enableCreem={enableCreem}
|
||||
enableOnlineTopUp={enableOnlineTopUp}
|
||||
epayMethods={epayMethods}
|
||||
purchaseLimit={
|
||||
selectedPlan?.plan?.max_purchase_per_user
|
||||
? Number(selectedPlan.plan.max_purchase_per_user)
|
||||
: undefined
|
||||
}
|
||||
purchaseCount={
|
||||
selectedPlan?.plan?.id
|
||||
? planPurchaseCountMap.get(selectedPlan.plan.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { UserWalletData } from '../types'
|
||||
|
||||
interface WalletStatsCardProps {
|
||||
user: UserWalletData | null
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function WalletStatsCard(props: WalletStatsCardProps) {
|
||||
const { t } = useTranslation()
|
||||
if (props.loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className='grid grid-cols-1 gap-6 sm:grid-cols-3 sm:gap-8'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className='space-y-2'>
|
||||
<Skeleton className='h-5 w-28' />
|
||||
<Skeleton className='h-11 w-32' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className='grid grid-cols-1 gap-6 sm:grid-cols-3 sm:gap-8'>
|
||||
{/* Current Balance */}
|
||||
<div className='min-w-0 space-y-2'>
|
||||
<div className='text-muted-foreground text-sm font-medium'>
|
||||
{t('Current Balance')}
|
||||
</div>
|
||||
<div className='text-3xl leading-tight font-semibold tracking-tight break-all lg:text-4xl'>
|
||||
{formatQuota(props.user?.quota ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Usage */}
|
||||
<div className='min-w-0 space-y-2'>
|
||||
<div className='text-muted-foreground text-sm font-medium'>
|
||||
{t('Total Usage')}
|
||||
</div>
|
||||
<div className='text-3xl leading-tight font-semibold tracking-tight break-all lg:text-4xl'>
|
||||
{formatQuota(props.user?.used_quota ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request Count */}
|
||||
<div className='min-w-0 space-y-2'>
|
||||
<div className='text-muted-foreground text-sm font-medium'>
|
||||
{t('API Requests')}
|
||||
</div>
|
||||
<div className='text-3xl leading-tight font-semibold tracking-tight break-all lg:text-4xl'>
|
||||
{(props.user?.request_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
// ============================================================================
|
||||
// Wallet Constants
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Default preset amount multipliers
|
||||
* Used to generate quick select amounts based on minimum topup
|
||||
*/
|
||||
export const DEFAULT_PRESET_MULTIPLIERS = [1, 5, 10, 30, 50, 100, 300, 500]
|
||||
|
||||
/**
|
||||
* Payment method types
|
||||
*/
|
||||
export const PAYMENT_TYPES = {
|
||||
ALIPAY: 'alipay',
|
||||
WECHAT: 'wxpay',
|
||||
STRIPE: 'stripe',
|
||||
CREEM: 'creem',
|
||||
WAFFO: 'waffo',
|
||||
WAFFO_PANCAKE: 'waffo_pancake',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Default payment type
|
||||
*/
|
||||
export const DEFAULT_PAYMENT_TYPE = PAYMENT_TYPES.ALIPAY
|
||||
|
||||
/**
|
||||
* Payment icon colors (HEX format for react-icons)
|
||||
*/
|
||||
export const PAYMENT_ICON_COLORS = {
|
||||
[PAYMENT_TYPES.ALIPAY]: '#1677FF',
|
||||
[PAYMENT_TYPES.WECHAT]: '#07C160',
|
||||
[PAYMENT_TYPES.STRIPE]: '#635BFF',
|
||||
[PAYMENT_TYPES.CREEM]: '#6366F1',
|
||||
[PAYMENT_TYPES.WAFFO]: '#2563EB',
|
||||
[PAYMENT_TYPES.WAFFO_PANCAKE]: '#F97316',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Quota conversion rate: 500,000 units = $1
|
||||
*/
|
||||
export const QUOTA_PER_DOLLAR = 500000
|
||||
|
||||
/**
|
||||
* Default discount rate (no discount)
|
||||
*/
|
||||
export const DEFAULT_DISCOUNT_RATE = 1.0
|
||||
|
||||
/**
|
||||
* Default minimum topup amount
|
||||
*/
|
||||
export const DEFAULT_MIN_TOPUP = 1
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
// ============================================================================
|
||||
// Wallet Hooks Exports
|
||||
// ============================================================================
|
||||
|
||||
export * from './use-topup-info'
|
||||
export * from './use-payment'
|
||||
export * from './use-affiliate'
|
||||
export * from './use-redemption'
|
||||
export * from './use-creem-payment'
|
||||
export * from './use-waffo-payment'
|
||||
export * from './use-waffo-pancake-payment'
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { getSelf } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { getAffiliateCode, transferAffiliateQuota } from '../api'
|
||||
import { generateAffiliateLink } from '../lib'
|
||||
|
||||
// ============================================================================
|
||||
// Affiliate Hook
|
||||
// ============================================================================
|
||||
|
||||
export function useAffiliate() {
|
||||
const [affiliateCode, setAffiliateCode] = useState<string>('')
|
||||
const [affiliateLink, setAffiliateLink] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [transferring, setTransferring] = useState(false)
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
// Fetch affiliate code
|
||||
const fetchAffiliateCode = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await getAffiliateCode()
|
||||
|
||||
if (response.success && response.data) {
|
||||
setAffiliateCode(response.data)
|
||||
const link = generateAffiliateLink(response.data)
|
||||
setAffiliateLink(link)
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch affiliate code:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Copy affiliate link
|
||||
const copyAffiliateLink = useCallback(() => {
|
||||
copyToClipboard(affiliateLink)
|
||||
}, [affiliateLink, copyToClipboard])
|
||||
|
||||
// Transfer affiliate quota to balance
|
||||
const transferQuota = useCallback(async (quota: number): Promise<boolean> => {
|
||||
try {
|
||||
setTransferring(true)
|
||||
const response = await transferAffiliateQuota({ quota })
|
||||
|
||||
if (response.success) {
|
||||
toast.success(response.message || i18next.t('Transfer successful'))
|
||||
await getSelf()
|
||||
return true
|
||||
}
|
||||
|
||||
toast.error(response.message || i18next.t('Transfer failed'))
|
||||
return false
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Transfer failed'))
|
||||
return false
|
||||
} finally {
|
||||
setTransferring(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAffiliateCode()
|
||||
}, [fetchAffiliateCode])
|
||||
|
||||
return {
|
||||
affiliateCode,
|
||||
affiliateLink,
|
||||
loading,
|
||||
transferring,
|
||||
copyAffiliateLink,
|
||||
transferQuota,
|
||||
refetch: fetchAffiliateCode,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
import {
|
||||
getUserBillingHistory,
|
||||
getAllBillingHistory,
|
||||
completeOrder,
|
||||
isApiSuccess,
|
||||
} from '../api'
|
||||
import type { TopupRecord } from '../types'
|
||||
|
||||
// ============================================================================
|
||||
// Billing History Hook
|
||||
// ============================================================================
|
||||
|
||||
interface UseBillingHistoryOptions {
|
||||
/** Initial page number */
|
||||
initialPage?: number
|
||||
/** Initial page size */
|
||||
initialPageSize?: number
|
||||
}
|
||||
|
||||
export function useBillingHistory(options: UseBillingHistoryOptions = {}) {
|
||||
const { initialPage = 1, initialPageSize = 10 } = options
|
||||
const isAdmin = useIsAdmin()
|
||||
|
||||
const [records, setRecords] = useState<TopupRecord[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(initialPage)
|
||||
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [completing, setCompleting] = useState(false)
|
||||
|
||||
/**
|
||||
* Fetch billing history
|
||||
*/
|
||||
const fetchBillingHistory = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = isAdmin
|
||||
? await getAllBillingHistory(page, pageSize, keyword)
|
||||
: await getUserBillingHistory(page, pageSize, keyword)
|
||||
|
||||
if (isApiSuccess(response) && response.data) {
|
||||
setRecords(response.data.items || [])
|
||||
setTotal(response.data.total || 0)
|
||||
} else {
|
||||
toast.error(
|
||||
response.message || i18next.t('Failed to load billing history')
|
||||
)
|
||||
setRecords([])
|
||||
setTotal(0)
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch billing history:', error)
|
||||
toast.error(i18next.t('Failed to load billing history'))
|
||||
setRecords([])
|
||||
setTotal(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [isAdmin, page, pageSize, keyword])
|
||||
|
||||
/**
|
||||
* Complete a pending order (admin only)
|
||||
*/
|
||||
const handleCompleteOrder = useCallback(
|
||||
async (tradeNo: string) => {
|
||||
if (!isAdmin) {
|
||||
toast.error(i18next.t('Admin access required'))
|
||||
return false
|
||||
}
|
||||
|
||||
setCompleting(true)
|
||||
try {
|
||||
const response = await completeOrder({ trade_no: tradeNo })
|
||||
if (isApiSuccess(response)) {
|
||||
toast.success(i18next.t('Order completed successfully'))
|
||||
// Refresh the list
|
||||
await fetchBillingHistory()
|
||||
return true
|
||||
} else {
|
||||
toast.error(response.message || i18next.t('Failed to complete order'))
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to complete order:', error)
|
||||
toast.error(i18next.t('Failed to complete order'))
|
||||
return false
|
||||
} finally {
|
||||
setCompleting(false)
|
||||
}
|
||||
},
|
||||
[isAdmin, fetchBillingHistory]
|
||||
)
|
||||
|
||||
/**
|
||||
* Change page
|
||||
*/
|
||||
const handlePageChange = useCallback((newPage: number) => {
|
||||
setPage(newPage)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Change page size
|
||||
*/
|
||||
const handlePageSizeChange = useCallback((newPageSize: number) => {
|
||||
setPageSize(newPageSize)
|
||||
setPage(1) // Reset to first page when changing page size
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Search by keyword
|
||||
*/
|
||||
const handleSearch = useCallback((newKeyword: string) => {
|
||||
setKeyword(newKeyword)
|
||||
setPage(1) // Reset to first page when searching
|
||||
}, [])
|
||||
|
||||
// Fetch data when dependencies change
|
||||
useEffect(() => {
|
||||
fetchBillingHistory()
|
||||
}, [fetchBillingHistory])
|
||||
|
||||
return {
|
||||
records,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
keyword,
|
||||
loading,
|
||||
completing,
|
||||
isAdmin,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
handleSearch,
|
||||
handleCompleteOrder,
|
||||
refresh: fetchBillingHistory,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { requestCreemPayment, isApiSuccess } from '../api'
|
||||
|
||||
/**
|
||||
* Hook for handling Creem payment processing
|
||||
*/
|
||||
export function useCreemPayment() {
|
||||
const [processing, setProcessing] = useState(false)
|
||||
|
||||
const processCreemPayment = useCallback(async (productId: string) => {
|
||||
setProcessing(true)
|
||||
try {
|
||||
const response = await requestCreemPayment({
|
||||
product_id: productId,
|
||||
payment_method: 'creem',
|
||||
})
|
||||
|
||||
if (isApiSuccess(response) && response.data?.checkout_url) {
|
||||
window.open(response.data.checkout_url, '_blank')
|
||||
toast.success(i18next.t('Redirecting to Creem checkout...'))
|
||||
return true
|
||||
}
|
||||
|
||||
toast.error(response.message || i18next.t('Payment request failed'))
|
||||
return false
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Payment request failed'))
|
||||
return false
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { processing, processCreemPayment }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
calculateAmount,
|
||||
calculateStripeAmount,
|
||||
calculateWaffoPancakeAmount,
|
||||
requestPayment,
|
||||
requestStripePayment,
|
||||
isApiSuccess,
|
||||
} from '../api'
|
||||
import {
|
||||
isStripePayment,
|
||||
isWaffoPancakePayment,
|
||||
submitPaymentForm,
|
||||
} from '../lib'
|
||||
|
||||
// ============================================================================
|
||||
// Payment Hook
|
||||
// ============================================================================
|
||||
|
||||
export function usePayment() {
|
||||
const [amount, setAmount] = useState<number>(0)
|
||||
const [calculating, setCalculating] = useState(false)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
|
||||
// Calculate payment amount
|
||||
const calculatePaymentAmount = useCallback(
|
||||
async (topupAmount: number, paymentType: string) => {
|
||||
try {
|
||||
setCalculating(true)
|
||||
|
||||
const isStripe = isStripePayment(paymentType)
|
||||
const isPancake = isWaffoPancakePayment(paymentType)
|
||||
const response = isStripe
|
||||
? await calculateStripeAmount({ amount: topupAmount })
|
||||
: isPancake
|
||||
? await calculateWaffoPancakeAmount({ amount: topupAmount })
|
||||
: await calculateAmount({ amount: topupAmount })
|
||||
|
||||
if (isApiSuccess(response) && response.data) {
|
||||
const calculatedAmount = parseFloat(response.data)
|
||||
setAmount(calculatedAmount)
|
||||
return calculatedAmount
|
||||
}
|
||||
|
||||
// Don't show error for calculation, just set to 0
|
||||
setAmount(0)
|
||||
return 0
|
||||
} catch (_error) {
|
||||
setAmount(0)
|
||||
return 0
|
||||
} finally {
|
||||
setCalculating(false)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Process payment
|
||||
const processPayment = useCallback(
|
||||
async (topupAmount: number, paymentType: string) => {
|
||||
try {
|
||||
setProcessing(true)
|
||||
|
||||
const isStripe = isStripePayment(paymentType)
|
||||
const amount = Math.floor(topupAmount)
|
||||
|
||||
const response = isStripe
|
||||
? await requestStripePayment({
|
||||
amount,
|
||||
payment_method: 'stripe',
|
||||
})
|
||||
: await requestPayment({
|
||||
amount,
|
||||
payment_method: paymentType,
|
||||
})
|
||||
|
||||
if (!isApiSuccess(response)) {
|
||||
toast.error(response.message || i18next.t('Payment request failed'))
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle Stripe payment
|
||||
if (isStripe && response.data?.pay_link) {
|
||||
window.open(response.data.pay_link as string, '_blank')
|
||||
toast.success(i18next.t('Redirecting to payment page...'))
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle non-Stripe payment
|
||||
if (!isStripe && response.data) {
|
||||
const url = (response as unknown as { url?: string }).url
|
||||
if (url) {
|
||||
submitPaymentForm(url, response.data)
|
||||
toast.success(i18next.t('Redirecting to payment page...'))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Payment request failed'))
|
||||
return false
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return {
|
||||
amount,
|
||||
calculating,
|
||||
processing,
|
||||
calculatePaymentAmount,
|
||||
processPayment,
|
||||
setAmount,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { getSelf } from '@/lib/api'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { redeemTopupCode } from '../api'
|
||||
|
||||
// ============================================================================
|
||||
// Redemption Hook
|
||||
// ============================================================================
|
||||
|
||||
export function useRedemption() {
|
||||
const [redeeming, setRedeeming] = useState(false)
|
||||
|
||||
const redeemCode = useCallback(async (code: string): Promise<boolean> => {
|
||||
if (!code || code.trim() === '') {
|
||||
toast.error(i18next.t('Please enter a redemption code'))
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setRedeeming(true)
|
||||
const response = await redeemTopupCode({ key: code })
|
||||
|
||||
if (response.success && response.data) {
|
||||
const quotaAdded = response.data
|
||||
toast.success(
|
||||
i18next.t('Redemption successful! Added: {{quota}}', {
|
||||
quota: formatQuota(quotaAdded),
|
||||
})
|
||||
)
|
||||
await getSelf()
|
||||
return true
|
||||
}
|
||||
|
||||
toast.error(response.message || i18next.t('Redemption failed'))
|
||||
return false
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Redemption failed'))
|
||||
return false
|
||||
} finally {
|
||||
setRedeeming(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
redeeming,
|
||||
redeemCode,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getTopupInfo } from '../api'
|
||||
import {
|
||||
generatePresetAmounts,
|
||||
mergePresetAmounts,
|
||||
getMinTopupAmount,
|
||||
} from '../lib'
|
||||
import type {
|
||||
TopupInfo,
|
||||
PresetAmount,
|
||||
CreemProduct,
|
||||
PaymentMethod,
|
||||
WaffoPayMethod,
|
||||
} from '../types'
|
||||
|
||||
// ============================================================================
|
||||
// Topup Info Hook
|
||||
// ============================================================================
|
||||
|
||||
function parseJsonArray(data: unknown): unknown[] {
|
||||
if (Array.isArray(data)) {
|
||||
return data
|
||||
}
|
||||
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function parsePaymentMethods(
|
||||
data: unknown,
|
||||
stripeMinTopup: number
|
||||
): PaymentMethod[] {
|
||||
return parseJsonArray(data)
|
||||
.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
!!item && typeof item === 'object'
|
||||
)
|
||||
.map((item) => {
|
||||
const rawMinTopup = Number(item.min_topup)
|
||||
const normalizedMinTopup = Number.isFinite(rawMinTopup) ? rawMinTopup : 0
|
||||
const type = typeof item.type === 'string' ? item.type : ''
|
||||
|
||||
return {
|
||||
name: typeof item.name === 'string' ? item.name : '',
|
||||
type,
|
||||
color: typeof item.color === 'string' ? item.color : undefined,
|
||||
min_topup:
|
||||
type === 'stripe' && normalizedMinTopup <= 0
|
||||
? stripeMinTopup
|
||||
: normalizedMinTopup,
|
||||
}
|
||||
})
|
||||
.filter((item) => item.name && item.type && item.type !== 'waffo')
|
||||
}
|
||||
|
||||
function parseWaffoPayMethods(data: unknown): WaffoPayMethod[] {
|
||||
return parseJsonArray(data)
|
||||
.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
!!item && typeof item === 'object'
|
||||
)
|
||||
.map((item) => ({
|
||||
name: typeof item.name === 'string' ? item.name : '',
|
||||
icon: typeof item.icon === 'string' ? item.icon : undefined,
|
||||
payMethodType:
|
||||
typeof item.payMethodType === 'string' ? item.payMethodType : undefined,
|
||||
payMethodName:
|
||||
typeof item.payMethodName === 'string' ? item.payMethodName : undefined,
|
||||
}))
|
||||
.filter((item) => item.name)
|
||||
}
|
||||
|
||||
function parseCreemProducts(data: unknown): CreemProduct[] {
|
||||
return parseJsonArray(data)
|
||||
.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
!!item && typeof item === 'object'
|
||||
)
|
||||
.map((item) => {
|
||||
const currency: CreemProduct['currency'] =
|
||||
item.currency === 'EUR' ? 'EUR' : 'USD'
|
||||
|
||||
return {
|
||||
name: typeof item.name === 'string' ? item.name : '',
|
||||
productId: typeof item.productId === 'string' ? item.productId : '',
|
||||
price: Number(item.price) || 0,
|
||||
quota: Number(item.quota) || 0,
|
||||
currency,
|
||||
}
|
||||
})
|
||||
.filter((item) => item.name && item.productId)
|
||||
}
|
||||
|
||||
function parseAmountOptions(data: unknown): number[] {
|
||||
return parseJsonArray(data)
|
||||
.map((item) => Number(item))
|
||||
.filter((item) => Number.isFinite(item) && item > 0)
|
||||
}
|
||||
|
||||
function parseDiscountMap(data: unknown): Record<number, number> {
|
||||
if (!data) {
|
||||
return {}
|
||||
}
|
||||
|
||||
let parsedData = data
|
||||
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
parsedData = JSON.parse(data)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!parsedData ||
|
||||
typeof parsedData !== 'object' ||
|
||||
Array.isArray(parsedData)
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.entries(parsedData).reduce<Record<number, number>>(
|
||||
(result, [key, value]) => {
|
||||
const numericKey = Number(key)
|
||||
const numericValue = Number(value)
|
||||
|
||||
if (Number.isFinite(numericKey) && Number.isFinite(numericValue)) {
|
||||
result[numericKey] = numericValue
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
export function useTopupInfo() {
|
||||
const [topupInfo, setTopupInfo] = useState<TopupInfo | null>(null)
|
||||
const [presetAmounts, setPresetAmounts] = useState<PresetAmount[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchTopupInfo = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const response = await getTopupInfo()
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch topup info:', response.message)
|
||||
return
|
||||
}
|
||||
|
||||
const processedData: TopupInfo = {
|
||||
...response.data,
|
||||
pay_methods: parsePaymentMethods(
|
||||
response.data.pay_methods,
|
||||
response.data.stripe_min_topup
|
||||
),
|
||||
amount_options: parseAmountOptions(response.data.amount_options),
|
||||
discount: parseDiscountMap(response.data.discount),
|
||||
creem_products: parseCreemProducts(response.data.creem_products),
|
||||
waffo_pay_methods: parseWaffoPayMethods(
|
||||
response.data.waffo_pay_methods
|
||||
),
|
||||
}
|
||||
|
||||
setTopupInfo(processedData)
|
||||
|
||||
if (processedData.amount_options.length > 0) {
|
||||
const customPresets = mergePresetAmounts(
|
||||
processedData.amount_options,
|
||||
processedData.discount || {}
|
||||
)
|
||||
setPresetAmounts(customPresets)
|
||||
} else {
|
||||
const minTopup = getMinTopupAmount(processedData)
|
||||
const defaultPresets = generatePresetAmounts(minTopup)
|
||||
setPresetAmounts(defaultPresets)
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch topup info:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopupInfo()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
topupInfo,
|
||||
presetAmounts,
|
||||
loading,
|
||||
refetch: fetchTopupInfo,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { requestWaffoPancakePayment, isApiSuccess } from '../api'
|
||||
|
||||
function getCheckoutUrl(data: unknown): string | null {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
if ('checkout_url' in data && typeof data.checkout_url === 'string') {
|
||||
return data.checkout_url
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject non-navigable schemes (e.g. javascript:, data:) and relative URLs.
|
||||
* Only http/https are allowed for backend-provided redirect targets.
|
||||
*/
|
||||
function isSafeHttpCheckoutUrl(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const u = new URL(trimmed)
|
||||
return u.protocol === 'http:' || u.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(message: string | undefined, data: unknown): string {
|
||||
if (typeof data === 'string' && data.trim()) {
|
||||
return data
|
||||
}
|
||||
|
||||
return message || i18next.t('Payment request failed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling Waffo Pancake payment processing
|
||||
*
|
||||
* Pancake uses a hosted checkout URL flow rather than the generic epay form
|
||||
* submission, so we open the returned URL in a new tab once the backend
|
||||
* returns a successful response.
|
||||
*/
|
||||
export function useWaffoPancakePayment() {
|
||||
const [processing, setProcessing] = useState(false)
|
||||
|
||||
const processWaffoPancakePayment = useCallback(
|
||||
async (topupAmount: number) => {
|
||||
setProcessing(true)
|
||||
|
||||
try {
|
||||
const response = await requestWaffoPancakePayment({
|
||||
amount: Math.floor(topupAmount),
|
||||
})
|
||||
|
||||
if (isApiSuccess(response)) {
|
||||
const checkoutUrl = getCheckoutUrl(response.data)
|
||||
|
||||
if (checkoutUrl) {
|
||||
if (!isSafeHttpCheckoutUrl(checkoutUrl)) {
|
||||
toast.error(i18next.t('Invalid payment redirect URL'))
|
||||
return false
|
||||
}
|
||||
window.open(checkoutUrl, '_blank', 'noopener,noreferrer')
|
||||
toast.success(i18next.t('Redirecting to payment page...'))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
toast.error(getErrorMessage(response.message, response.data))
|
||||
return false
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Payment request failed'))
|
||||
return false
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return { processing, processWaffoPancakePayment }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { requestWaffoPayment, isApiSuccess } from '../api'
|
||||
|
||||
function getPaymentUrl(data: unknown): string | null {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
if ('payment_url' in data && typeof data.payment_url === 'string') {
|
||||
return data.payment_url
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getErrorMessage(message: string | undefined, data: unknown): string {
|
||||
if (typeof data === 'string' && data.trim()) {
|
||||
return data
|
||||
}
|
||||
|
||||
return message || i18next.t('Payment request failed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling Waffo payment processing
|
||||
*/
|
||||
export function useWaffoPayment() {
|
||||
const [processing, setProcessing] = useState(false)
|
||||
|
||||
const processWaffoPayment = useCallback(
|
||||
async (topupAmount: number, payMethodIndex?: number) => {
|
||||
setProcessing(true)
|
||||
|
||||
try {
|
||||
const response = await requestWaffoPayment({
|
||||
amount: Math.floor(topupAmount),
|
||||
pay_method_index: payMethodIndex,
|
||||
})
|
||||
|
||||
if (isApiSuccess(response)) {
|
||||
const paymentUrl = getPaymentUrl(response.data)
|
||||
|
||||
if (paymentUrl) {
|
||||
window.open(paymentUrl, '_blank')
|
||||
toast.success(i18next.t('Redirecting to payment page...'))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
toast.error(getErrorMessage(response.message, response.data))
|
||||
return false
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Payment request failed'))
|
||||
return false
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return { processing, processWaffoPayment }
|
||||
}
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getSelf } from '@/lib/api'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import { AffiliateRewardsCard } from './components/affiliate-rewards-card'
|
||||
import { BillingHistoryDialog } from './components/dialogs/billing-history-dialog'
|
||||
import { CreemConfirmDialog } from './components/dialogs/creem-confirm-dialog'
|
||||
import { PaymentConfirmDialog } from './components/dialogs/payment-confirm-dialog'
|
||||
import { TransferDialog } from './components/dialogs/transfer-dialog'
|
||||
import { RechargeFormCard } from './components/recharge-form-card'
|
||||
import { SubscriptionPlansCard } from './components/subscription-plans-card'
|
||||
import { WalletStatsCard } from './components/wallet-stats-card'
|
||||
import { DEFAULT_DISCOUNT_RATE } from './constants'
|
||||
import {
|
||||
useTopupInfo,
|
||||
usePayment,
|
||||
useAffiliate,
|
||||
useRedemption,
|
||||
useCreemPayment,
|
||||
useWaffoPayment,
|
||||
useWaffoPancakePayment,
|
||||
} from './hooks'
|
||||
import {
|
||||
getDefaultPaymentType,
|
||||
getMinTopupAmount,
|
||||
isWaffoPancakePayment,
|
||||
} from './lib'
|
||||
import type {
|
||||
UserWalletData,
|
||||
PaymentMethod,
|
||||
PresetAmount,
|
||||
CreemProduct,
|
||||
} from './types'
|
||||
|
||||
interface WalletProps {
|
||||
initialShowHistory?: boolean
|
||||
}
|
||||
|
||||
export function Wallet(props: WalletProps) {
|
||||
const { t } = useTranslation()
|
||||
const [user, setUser] = useState<UserWalletData | null>(null)
|
||||
const [userLoading, setUserLoading] = useState(true)
|
||||
const [topupAmount, setTopupAmount] = useState(0)
|
||||
const [selectedPreset, setSelectedPreset] = useState<number | null>(null)
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] =
|
||||
useState<PaymentMethod>()
|
||||
const [paymentLoading, setPaymentLoading] = useState<string | null>(null)
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
|
||||
const [transferDialogOpen, setTransferDialogOpen] = useState(false)
|
||||
const [billingDialogOpen, setBillingDialogOpen] = useState(false)
|
||||
const [redemptionCode, setRedemptionCode] = useState('')
|
||||
const [creemDialogOpen, setCreemDialogOpen] = useState(false)
|
||||
const [selectedCreemProduct, setSelectedCreemProduct] =
|
||||
useState<CreemProduct | null>(null)
|
||||
|
||||
const { status } = useStatus()
|
||||
const { currency } = useSystemConfig()
|
||||
const { topupInfo, presetAmounts, loading: topupLoading } = useTopupInfo()
|
||||
|
||||
// Calculate effective exchange rate - when display type is USD, use rate of 1
|
||||
const effectiveUsdExchangeRate = useMemo(() => {
|
||||
return currency?.quotaDisplayType === 'USD'
|
||||
? 1
|
||||
: currency?.usdExchangeRate || 1
|
||||
}, [currency?.quotaDisplayType, currency?.usdExchangeRate])
|
||||
const {
|
||||
amount: paymentAmount,
|
||||
calculating,
|
||||
processing,
|
||||
calculatePaymentAmount,
|
||||
processPayment,
|
||||
} = usePayment()
|
||||
const {
|
||||
affiliateLink,
|
||||
loading: affiliateLoading,
|
||||
transferQuota,
|
||||
transferring,
|
||||
} = useAffiliate()
|
||||
const { redeeming, redeemCode } = useRedemption()
|
||||
const { processing: creemProcessing, processCreemPayment } = useCreemPayment()
|
||||
const { processWaffoPayment } = useWaffoPayment()
|
||||
const { processing: pancakeProcessing, processWaffoPancakePayment } =
|
||||
useWaffoPancakePayment()
|
||||
|
||||
// Fetch and refresh user data
|
||||
const fetchUser = useCallback(async () => {
|
||||
try {
|
||||
setUserLoading(true)
|
||||
const response = await getSelf()
|
||||
if (response.success && response.data) {
|
||||
setUser(response.data as UserWalletData)
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch user data:', error)
|
||||
} finally {
|
||||
setUserLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser()
|
||||
}, [fetchUser])
|
||||
|
||||
useEffect(() => {
|
||||
if (props.initialShowHistory) {
|
||||
setBillingDialogOpen(true)
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
}
|
||||
}, [props.initialShowHistory])
|
||||
|
||||
// Initialize topup amount when topup info is loaded
|
||||
useEffect(() => {
|
||||
if (topupInfo && topupAmount === 0) {
|
||||
const minTopup = getMinTopupAmount(topupInfo)
|
||||
setTopupAmount(minTopup)
|
||||
|
||||
// Calculate initial payment amount with default payment type
|
||||
const defaultPaymentType = getDefaultPaymentType(topupInfo)
|
||||
calculatePaymentAmount(minTopup, defaultPaymentType)
|
||||
}
|
||||
}, [topupInfo, topupAmount, calculatePaymentAmount])
|
||||
|
||||
// Get current payment type (selected or default)
|
||||
const getCurrentPaymentType = useCallback(() => {
|
||||
return selectedPaymentMethod?.type || getDefaultPaymentType(topupInfo)
|
||||
}, [selectedPaymentMethod, topupInfo])
|
||||
|
||||
// Handle preset selection
|
||||
const handleSelectPreset = (preset: PresetAmount) => {
|
||||
setTopupAmount(preset.value)
|
||||
setSelectedPreset(preset.value)
|
||||
calculatePaymentAmount(preset.value, getCurrentPaymentType())
|
||||
}
|
||||
|
||||
// Handle topup amount change
|
||||
const handleTopupAmountChange = (amount: number) => {
|
||||
setTopupAmount(amount)
|
||||
setSelectedPreset(null)
|
||||
calculatePaymentAmount(amount, getCurrentPaymentType())
|
||||
}
|
||||
|
||||
// Handle payment method selection
|
||||
const handlePaymentMethodSelect = async (method: PaymentMethod) => {
|
||||
setSelectedPaymentMethod(method)
|
||||
setPaymentLoading(method.type)
|
||||
|
||||
try {
|
||||
// Validate minimum topup
|
||||
const minTopup = getMinTopupAmount(topupInfo)
|
||||
if (topupAmount < minTopup) {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate payment amount and show confirmation dialog
|
||||
await calculatePaymentAmount(topupAmount, method.type)
|
||||
setConfirmDialogOpen(true)
|
||||
} finally {
|
||||
setPaymentLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle payment confirmation
|
||||
const handlePaymentConfirm = async () => {
|
||||
if (!selectedPaymentMethod) return
|
||||
|
||||
const isPancake = isWaffoPancakePayment(selectedPaymentMethod.type)
|
||||
const success = isPancake
|
||||
? await processWaffoPancakePayment(topupAmount)
|
||||
: await processPayment(topupAmount, selectedPaymentMethod.type)
|
||||
|
||||
if (success) {
|
||||
setConfirmDialogOpen(false)
|
||||
await fetchUser()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle redemption
|
||||
const handleRedeem = async () => {
|
||||
if (!redemptionCode) return
|
||||
|
||||
const success = await redeemCode(redemptionCode)
|
||||
if (success) {
|
||||
setRedemptionCode('')
|
||||
await fetchUser()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle transfer
|
||||
const handleTransfer = async (amount: number) => {
|
||||
const success = await transferQuota(amount)
|
||||
if (success) {
|
||||
await fetchUser()
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
// Handle Creem product selection
|
||||
const handleCreemProductSelect = (product: CreemProduct) => {
|
||||
setSelectedCreemProduct(product)
|
||||
setCreemDialogOpen(true)
|
||||
}
|
||||
|
||||
// Handle Creem payment confirmation
|
||||
const handleCreemConfirm = async () => {
|
||||
if (!selectedCreemProduct) return
|
||||
|
||||
const success = await processCreemPayment(selectedCreemProduct.productId)
|
||||
if (success) {
|
||||
setCreemDialogOpen(false)
|
||||
setSelectedCreemProduct(null)
|
||||
await fetchUser()
|
||||
}
|
||||
}
|
||||
|
||||
const handleWaffoMethodSelect = async (_method: unknown, index: number) => {
|
||||
const loadingKey = `waffo-${index}`
|
||||
setPaymentLoading(loadingKey)
|
||||
|
||||
try {
|
||||
await processWaffoPayment(topupAmount, index)
|
||||
} finally {
|
||||
setPaymentLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Get discount rate for current topup amount
|
||||
const getDiscountRate = useCallback(() => {
|
||||
return topupInfo?.discount?.[topupAmount] || DEFAULT_DISCOUNT_RATE
|
||||
}, [topupInfo, topupAmount])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout.Title>{t('Wallet')}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Description>
|
||||
{t('Manage your balance and payment methods')}
|
||||
</SectionPageLayout.Description>
|
||||
<SectionPageLayout.Content>
|
||||
<div className='grid gap-6 lg:grid-cols-3'>
|
||||
{/* Left Column - Stats & Recharge */}
|
||||
<div className='space-y-6 lg:col-span-2'>
|
||||
<WalletStatsCard user={user} loading={userLoading} />
|
||||
<RechargeFormCard
|
||||
topupInfo={topupInfo}
|
||||
presetAmounts={presetAmounts}
|
||||
selectedPreset={selectedPreset}
|
||||
onSelectPreset={handleSelectPreset}
|
||||
topupAmount={topupAmount}
|
||||
onTopupAmountChange={handleTopupAmountChange}
|
||||
paymentAmount={paymentAmount}
|
||||
calculating={calculating}
|
||||
onPaymentMethodSelect={handlePaymentMethodSelect}
|
||||
paymentLoading={paymentLoading}
|
||||
redemptionCode={redemptionCode}
|
||||
onRedemptionCodeChange={setRedemptionCode}
|
||||
onRedeem={handleRedeem}
|
||||
redeeming={redeeming}
|
||||
topupLink={topupInfo?.topup_link}
|
||||
loading={topupLoading}
|
||||
priceRatio={(status?.price as number) || 1}
|
||||
usdExchangeRate={effectiveUsdExchangeRate}
|
||||
onOpenBilling={() => setBillingDialogOpen(true)}
|
||||
creemProducts={topupInfo?.creem_products}
|
||||
enableCreemTopup={topupInfo?.enable_creem_topup}
|
||||
onCreemProductSelect={handleCreemProductSelect}
|
||||
enableWaffoTopup={topupInfo?.enable_waffo_topup}
|
||||
waffoPayMethods={topupInfo?.waffo_pay_methods}
|
||||
waffoMinTopup={topupInfo?.waffo_min_topup}
|
||||
onWaffoMethodSelect={handleWaffoMethodSelect}
|
||||
enableWaffoPancakeTopup={topupInfo?.enable_waffo_pancake_topup}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Affiliate & Subscriptions */}
|
||||
<div className='space-y-6 lg:col-span-1'>
|
||||
<AffiliateRewardsCard
|
||||
user={user}
|
||||
affiliateLink={affiliateLink}
|
||||
onTransfer={() => setTransferDialogOpen(true)}
|
||||
loading={affiliateLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscription Plans */}
|
||||
<SubscriptionPlansCard topupInfo={topupInfo} />
|
||||
</SectionPageLayout.Content>
|
||||
</SectionPageLayout>
|
||||
|
||||
<PaymentConfirmDialog
|
||||
open={confirmDialogOpen}
|
||||
onOpenChange={setConfirmDialogOpen}
|
||||
onConfirm={handlePaymentConfirm}
|
||||
topupAmount={topupAmount}
|
||||
paymentAmount={paymentAmount}
|
||||
paymentMethod={selectedPaymentMethod}
|
||||
calculating={calculating}
|
||||
processing={processing || pancakeProcessing}
|
||||
discountRate={getDiscountRate()}
|
||||
usdExchangeRate={effectiveUsdExchangeRate}
|
||||
/>
|
||||
|
||||
<TransferDialog
|
||||
open={transferDialogOpen}
|
||||
onOpenChange={setTransferDialogOpen}
|
||||
onConfirm={handleTransfer}
|
||||
availableQuota={user?.aff_quota ?? 0}
|
||||
transferring={transferring}
|
||||
/>
|
||||
|
||||
<BillingHistoryDialog
|
||||
open={billingDialogOpen}
|
||||
onOpenChange={setBillingDialogOpen}
|
||||
/>
|
||||
|
||||
<CreemConfirmDialog
|
||||
open={creemDialogOpen}
|
||||
onOpenChange={setCreemDialogOpen}
|
||||
onConfirm={handleCreemConfirm}
|
||||
product={selectedCreemProduct}
|
||||
processing={creemProcessing}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// ============================================================================
|
||||
// Affiliate Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate affiliate registration link
|
||||
*/
|
||||
export function generateAffiliateLink(affCode: string): string {
|
||||
if (typeof window === 'undefined') return ''
|
||||
return `${window.location.origin}/register?aff=${affCode}`
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import type { StatusBadgeProps } from '@/components/status-badge'
|
||||
import type { TopupStatus } from '../types'
|
||||
|
||||
// ============================================================================
|
||||
// Billing Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
interface StatusConfig {
|
||||
variant: StatusBadgeProps['variant']
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Status badge configuration
|
||||
*/
|
||||
export const STATUS_CONFIG: Record<TopupStatus, StatusConfig> = {
|
||||
success: {
|
||||
variant: 'success',
|
||||
label: 'Success',
|
||||
},
|
||||
pending: {
|
||||
variant: 'warning',
|
||||
label: 'Pending',
|
||||
},
|
||||
expired: {
|
||||
variant: 'danger',
|
||||
label: 'Expired',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status badge configuration
|
||||
*/
|
||||
export function getStatusConfig(status: TopupStatus): StatusConfig {
|
||||
return STATUS_CONFIG[status] || STATUS_CONFIG.pending
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment method display names
|
||||
*/
|
||||
export const PAYMENT_METHOD_NAMES: Record<string, string> = {
|
||||
stripe: 'Stripe',
|
||||
alipay: 'Alipay',
|
||||
wxpay: 'WeChat Pay',
|
||||
waffo: 'Waffo',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payment method display name
|
||||
*/
|
||||
export function getPaymentMethodName(method: string): string {
|
||||
return PAYMENT_METHOD_NAMES[method] || method
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to readable date string
|
||||
*/
|
||||
export function formatTimestamp(timestamp: number): string {
|
||||
return formatTimestampToDate(timestamp)
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { formatLocalCurrencyAmount } from '@/lib/currency'
|
||||
import { DEFAULT_DISCOUNT_RATE } from '../constants'
|
||||
|
||||
// ============================================================================
|
||||
// Wallet-specific Formatting Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format Creem price with currency symbol (USD/EUR)
|
||||
*/
|
||||
export function formatCreemPrice(
|
||||
price: number,
|
||||
currency: 'USD' | 'EUR'
|
||||
): string {
|
||||
const symbol = currency === 'EUR' ? '€' : '$'
|
||||
return `${symbol}${price.toFixed(2)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format large quota numbers with K/M suffix
|
||||
*/
|
||||
export function formatQuotaShort(quota: number): string {
|
||||
if (quota >= 1000000) {
|
||||
return `${(quota / 1000000).toFixed(1)}M`
|
||||
}
|
||||
if (quota >= 1000) {
|
||||
return `${(quota / 1000).toFixed(1)}K`
|
||||
}
|
||||
return quota.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency amount that is already in local currency.
|
||||
* This is used for payment amounts that have been calculated via priceRatio.
|
||||
*/
|
||||
export function formatCurrency(amount: number | string): string {
|
||||
const numeric =
|
||||
typeof amount === 'number' ? amount : Number.parseFloat(String(amount))
|
||||
return formatLocalCurrencyAmount(Number.isFinite(numeric) ? numeric : null, {
|
||||
digitsLarge: 2,
|
||||
digitsSmall: 2,
|
||||
abbreviate: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get discount label for display (e.g., "20% OFF")
|
||||
*/
|
||||
export function getDiscountLabel(discount: number): string {
|
||||
if (discount >= DEFAULT_DISCOUNT_RATE) {
|
||||
return ''
|
||||
}
|
||||
const off = Math.round((1 - discount) * 100)
|
||||
return `${off}% OFF`
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate pricing details for a preset amount
|
||||
*/
|
||||
export function calculatePresetPricing(
|
||||
presetValue: number,
|
||||
priceRatio: number,
|
||||
discount: number,
|
||||
usdExchangeRate: number = 1
|
||||
) {
|
||||
const originalPrice = presetValue * priceRatio
|
||||
const actualPrice = originalPrice * discount
|
||||
const savedAmount = originalPrice - actualPrice
|
||||
const hasDiscount = discount < 1.0
|
||||
const displayValue = presetValue * usdExchangeRate
|
||||
|
||||
return {
|
||||
displayValue,
|
||||
originalPrice,
|
||||
actualPrice,
|
||||
savedAmount,
|
||||
hasDiscount,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// ============================================================================
|
||||
// Wallet Library Exports
|
||||
// ============================================================================
|
||||
|
||||
export * from './affiliate'
|
||||
export * from './format'
|
||||
export * from './payment'
|
||||
export * from './ui'
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
PAYMENT_TYPES,
|
||||
DEFAULT_PRESET_MULTIPLIERS,
|
||||
DEFAULT_PAYMENT_TYPE,
|
||||
DEFAULT_MIN_TOPUP,
|
||||
} from '../constants'
|
||||
import type { PresetAmount, TopupInfo } from '../types'
|
||||
|
||||
// ============================================================================
|
||||
// Payment Processing Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if browser is Safari
|
||||
*/
|
||||
function isSafariBrowser(): boolean {
|
||||
return (
|
||||
navigator.userAgent.indexOf('Safari') > -1 &&
|
||||
navigator.userAgent.indexOf('Chrome') < 1
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit payment form (for non-Stripe payments)
|
||||
*/
|
||||
export function submitPaymentForm(
|
||||
url: string,
|
||||
params: Record<string, unknown>
|
||||
): void {
|
||||
const form = document.createElement('form')
|
||||
form.action = url
|
||||
form.method = 'POST'
|
||||
|
||||
// Don't open in new tab for Safari
|
||||
if (!isSafariBrowser()) {
|
||||
form.target = '_blank'
|
||||
}
|
||||
|
||||
// Add form parameters
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'hidden'
|
||||
input.name = key
|
||||
input.value = String(value)
|
||||
form.appendChild(input)
|
||||
})
|
||||
|
||||
document.body.appendChild(form)
|
||||
form.submit()
|
||||
document.body.removeChild(form)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if payment method is Stripe
|
||||
*/
|
||||
export function isStripePayment(paymentType: string): boolean {
|
||||
return paymentType === PAYMENT_TYPES.STRIPE
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if payment method is Waffo Pancake
|
||||
*
|
||||
* Pancake is a metered-style payment that goes through a dedicated checkout
|
||||
* URL flow rather than the generic epay form submission, so it must be
|
||||
* special-cased in payment dispatch logic.
|
||||
*/
|
||||
export function isWaffoPancakePayment(paymentType: string): boolean {
|
||||
return paymentType === PAYMENT_TYPES.WAFFO_PANCAKE
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default payment type from topup info
|
||||
*/
|
||||
export function getDefaultPaymentType(topupInfo: TopupInfo | null): string {
|
||||
if (!topupInfo) {
|
||||
return DEFAULT_PAYMENT_TYPE
|
||||
}
|
||||
|
||||
// Return first available payment method or default
|
||||
if (topupInfo.pay_methods?.length > 0) {
|
||||
return topupInfo.pay_methods[0].type
|
||||
}
|
||||
|
||||
if (topupInfo.enable_stripe_topup) {
|
||||
return PAYMENT_TYPES.STRIPE
|
||||
}
|
||||
|
||||
if (topupInfo.enable_waffo_topup) {
|
||||
return PAYMENT_TYPES.WAFFO
|
||||
}
|
||||
|
||||
if (topupInfo.enable_waffo_pancake_topup) {
|
||||
return PAYMENT_TYPES.WAFFO_PANCAKE
|
||||
}
|
||||
|
||||
return DEFAULT_PAYMENT_TYPE
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum topup amount from topup info
|
||||
*/
|
||||
export function getMinTopupAmount(topupInfo: TopupInfo | null): number {
|
||||
if (!topupInfo) {
|
||||
return DEFAULT_MIN_TOPUP
|
||||
}
|
||||
|
||||
if (topupInfo.enable_online_topup) {
|
||||
return topupInfo.min_topup
|
||||
}
|
||||
|
||||
if (topupInfo.enable_stripe_topup) {
|
||||
return topupInfo.stripe_min_topup
|
||||
}
|
||||
|
||||
if (topupInfo.enable_waffo_topup) {
|
||||
return topupInfo.waffo_min_topup || DEFAULT_MIN_TOPUP
|
||||
}
|
||||
|
||||
if (topupInfo.enable_waffo_pancake_topup) {
|
||||
return topupInfo.waffo_pancake_min_topup || DEFAULT_MIN_TOPUP
|
||||
}
|
||||
|
||||
return DEFAULT_MIN_TOPUP
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate preset amounts based on minimum topup
|
||||
*/
|
||||
export function generatePresetAmounts(minAmount: number): PresetAmount[] {
|
||||
return DEFAULT_PRESET_MULTIPLIERS.map((multiplier) => ({
|
||||
value: minAmount * multiplier,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge custom preset amounts with discounts
|
||||
*/
|
||||
export function mergePresetAmounts(
|
||||
amountOptions: number[],
|
||||
discounts: Record<number, number>
|
||||
): PresetAmount[] {
|
||||
if (!amountOptions || amountOptions.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return amountOptions.map((amount) => ({
|
||||
value: amount,
|
||||
discount: discounts[amount] || 1.0,
|
||||
}))
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { CreditCard, Landmark } from 'lucide-react'
|
||||
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'
|
||||
import { PAYMENT_TYPES, PAYMENT_ICON_COLORS } from '../constants'
|
||||
|
||||
// ============================================================================
|
||||
// UI Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
const HAS_LOCATION =
|
||||
typeof globalThis !== 'undefined' && 'location' in globalThis
|
||||
|
||||
/**
|
||||
* Resolves a backend-provided image URL to http(s) only. Rejects javascript:,
|
||||
* data:, blob:, file:, and URLs with userinfo, which are unsafe in <img src/>.
|
||||
*/
|
||||
function normalizeHttpIconUrl(raw: string | undefined | null): string | null {
|
||||
if (!raw) return null
|
||||
const s = raw.trim()
|
||||
if (!s) return null
|
||||
let url: URL
|
||||
try {
|
||||
url = HAS_LOCATION
|
||||
? new URL(s, (globalThis as { location: Location }).location.href)
|
||||
: new URL(s)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return null
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
return null
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payment method icon component
|
||||
*
|
||||
* When iconUrl is provided, render an <img/> with that URL so custom
|
||||
* gateway logos can be configured per-method.
|
||||
*/
|
||||
export function getPaymentIcon(
|
||||
paymentType: string | undefined,
|
||||
className: string = 'h-4 w-4',
|
||||
iconUrl?: string,
|
||||
altName?: string
|
||||
): ReactNode {
|
||||
const safeIconUrl = normalizeHttpIconUrl(iconUrl)
|
||||
if (safeIconUrl) {
|
||||
return (
|
||||
<img
|
||||
src={safeIconUrl}
|
||||
alt={altName || paymentType || 'payment'}
|
||||
className={className}
|
||||
style={{ objectFit: 'contain' }}
|
||||
loading='lazy'
|
||||
decoding='async'
|
||||
referrerPolicy='no-referrer'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!paymentType) {
|
||||
return <CreditCard className={className} />
|
||||
}
|
||||
|
||||
switch (paymentType) {
|
||||
case PAYMENT_TYPES.ALIPAY:
|
||||
return (
|
||||
<SiAlipay
|
||||
className={className}
|
||||
style={{ color: PAYMENT_ICON_COLORS[PAYMENT_TYPES.ALIPAY] }}
|
||||
/>
|
||||
)
|
||||
case PAYMENT_TYPES.WECHAT:
|
||||
return (
|
||||
<SiWechat
|
||||
className={className}
|
||||
style={{ color: PAYMENT_ICON_COLORS[PAYMENT_TYPES.WECHAT] }}
|
||||
/>
|
||||
)
|
||||
case PAYMENT_TYPES.STRIPE:
|
||||
return (
|
||||
<SiStripe
|
||||
className={className}
|
||||
style={{ color: PAYMENT_ICON_COLORS[PAYMENT_TYPES.STRIPE] }}
|
||||
/>
|
||||
)
|
||||
case PAYMENT_TYPES.CREEM:
|
||||
return (
|
||||
<Landmark
|
||||
className={className}
|
||||
style={{ color: PAYMENT_ICON_COLORS[PAYMENT_TYPES.CREEM] }}
|
||||
/>
|
||||
)
|
||||
case PAYMENT_TYPES.WAFFO:
|
||||
return (
|
||||
<CreditCard
|
||||
className={className}
|
||||
style={{ color: PAYMENT_ICON_COLORS[PAYMENT_TYPES.WAFFO] }}
|
||||
/>
|
||||
)
|
||||
case PAYMENT_TYPES.WAFFO_PANCAKE:
|
||||
return (
|
||||
<CreditCard
|
||||
className={className}
|
||||
style={{ color: PAYMENT_ICON_COLORS[PAYMENT_TYPES.WAFFO_PANCAKE] }}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return <CreditCard className={className} />
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
// ============================================================================
|
||||
// Wallet Type Definitions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generic API response
|
||||
*/
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success?: boolean
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard API response types
|
||||
*/
|
||||
export type TopupInfoResponse = ApiResponse<TopupInfo>
|
||||
export type RedemptionResponse = ApiResponse<number>
|
||||
export type AmountResponse = ApiResponse<string>
|
||||
export type PaymentResponse = ApiResponse<Record<string, unknown>> & {
|
||||
url?: string
|
||||
}
|
||||
export type StripePaymentResponse = ApiResponse<{ pay_link: string }>
|
||||
export type AffiliateCodeResponse = ApiResponse<string>
|
||||
export type AffiliateTransferResponse = ApiResponse
|
||||
export type CreemPaymentResponse = ApiResponse<{ checkout_url: string }>
|
||||
export type WaffoPaymentResponse = ApiResponse<
|
||||
{ payment_url?: string } | string
|
||||
>
|
||||
export type WaffoPancakePaymentResponse = ApiResponse<
|
||||
| {
|
||||
checkout_url?: string
|
||||
session_id?: string
|
||||
expires_at?: number | string
|
||||
order_id?: string
|
||||
}
|
||||
| string
|
||||
>
|
||||
|
||||
/**
|
||||
* Creem product configuration
|
||||
*/
|
||||
export interface CreemProduct {
|
||||
/** Product display name */
|
||||
name: string
|
||||
/** Creem product ID */
|
||||
productId: string
|
||||
/** Product price */
|
||||
price: number
|
||||
/** Quota amount to credit */
|
||||
quota: number
|
||||
/** Currency (USD or EUR) */
|
||||
currency: 'USD' | 'EUR'
|
||||
}
|
||||
|
||||
/**
|
||||
* Creem payment request
|
||||
*/
|
||||
export interface CreemPaymentRequest {
|
||||
/** Creem product ID */
|
||||
product_id: string
|
||||
/** Payment method identifier */
|
||||
payment_method: 'creem'
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment method configuration
|
||||
*/
|
||||
export interface PaymentMethod {
|
||||
/** Display name of payment method */
|
||||
name: string
|
||||
/** Payment method type identifier */
|
||||
type: string
|
||||
/** Optional color for UI display */
|
||||
color?: string
|
||||
/** Minimum topup amount for this payment method */
|
||||
min_topup?: number
|
||||
/** Optional icon URL provided by backend (preferred over built-in icons) */
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Waffo payment method configuration
|
||||
*/
|
||||
export interface WaffoPayMethod {
|
||||
/** Display name of payment method */
|
||||
name: string
|
||||
/** Optional icon path */
|
||||
icon?: string
|
||||
/** Waffo pay method type */
|
||||
payMethodType?: string
|
||||
/** Waffo pay method name */
|
||||
payMethodName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Topup configuration information
|
||||
*/
|
||||
export interface TopupInfo {
|
||||
/** Whether online topup is enabled */
|
||||
enable_online_topup: boolean
|
||||
/** Whether Stripe topup is enabled */
|
||||
enable_stripe_topup: boolean
|
||||
/** Available payment methods */
|
||||
pay_methods: PaymentMethod[]
|
||||
/** Minimum topup amount for online topup */
|
||||
min_topup: number
|
||||
/** Minimum topup amount for Stripe */
|
||||
stripe_min_topup: number
|
||||
/** Preset amount options */
|
||||
amount_options: number[]
|
||||
/** Discount rates by amount */
|
||||
discount: Record<number, number>
|
||||
/** Optional topup link for purchasing codes */
|
||||
topup_link?: string
|
||||
/** Whether Creem topup is enabled */
|
||||
enable_creem_topup?: boolean
|
||||
/** Available Creem products */
|
||||
creem_products?: CreemProduct[]
|
||||
/** Whether Waffo topup is enabled */
|
||||
enable_waffo_topup?: boolean
|
||||
/** Available Waffo payment methods */
|
||||
waffo_pay_methods?: WaffoPayMethod[]
|
||||
/** Minimum topup amount for Waffo */
|
||||
waffo_min_topup?: number
|
||||
/** Whether Waffo Pancake topup is enabled */
|
||||
enable_waffo_pancake_topup?: boolean
|
||||
/** Minimum topup amount for Waffo Pancake */
|
||||
waffo_pancake_min_topup?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset amount option with optional discount
|
||||
*/
|
||||
export interface PresetAmount {
|
||||
/** Preset amount value */
|
||||
value: number
|
||||
/** Optional discount rate (0-1) */
|
||||
discount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Redemption code request
|
||||
*/
|
||||
export interface RedemptionRequest {
|
||||
/** Redemption code key */
|
||||
key: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment request parameters
|
||||
*/
|
||||
export interface PaymentRequest {
|
||||
/** Topup amount */
|
||||
amount: number
|
||||
/** Payment method identifier */
|
||||
payment_method: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Waffo payment request parameters
|
||||
*/
|
||||
export interface WaffoPaymentRequest {
|
||||
/** Topup amount */
|
||||
amount: number
|
||||
/** Optional server-side Waffo payment method index */
|
||||
pay_method_index?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Waffo Pancake payment request parameters
|
||||
*/
|
||||
export interface WaffoPancakePaymentRequest {
|
||||
/** Topup amount */
|
||||
amount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Amount calculation request
|
||||
*/
|
||||
export interface AmountRequest {
|
||||
/** Topup amount to calculate */
|
||||
amount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Affiliate quota transfer request
|
||||
*/
|
||||
export interface AffiliateTransferRequest {
|
||||
/** Quota amount to transfer */
|
||||
quota: number
|
||||
}
|
||||
|
||||
/**
|
||||
* User wallet data
|
||||
*/
|
||||
export interface UserWalletData {
|
||||
/** User ID */
|
||||
id: number
|
||||
/** Username */
|
||||
username: string
|
||||
/** Current quota balance */
|
||||
quota: number
|
||||
/** Total used quota */
|
||||
used_quota: number
|
||||
/** Total request count */
|
||||
request_count: number
|
||||
/** Affiliate quota (pending rewards) */
|
||||
aff_quota: number
|
||||
/** Total affiliate quota earned (historical) */
|
||||
aff_history_quota: number
|
||||
/** Number of successful affiliate invites */
|
||||
aff_count: number
|
||||
/** User group */
|
||||
group: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Topup record status
|
||||
*/
|
||||
export type TopupStatus = 'success' | 'pending' | 'expired'
|
||||
|
||||
/**
|
||||
* Topup billing record
|
||||
*/
|
||||
export interface TopupRecord {
|
||||
/** Record ID */
|
||||
id: number
|
||||
/** User ID */
|
||||
user_id: number
|
||||
/** Topup amount (quota) */
|
||||
amount: number
|
||||
/** Payment amount (actual money paid) */
|
||||
money: number
|
||||
/** Trade/order number */
|
||||
trade_no: string
|
||||
/** Payment method type */
|
||||
payment_method: string
|
||||
/** Creation timestamp */
|
||||
create_time: number
|
||||
/** Completion timestamp */
|
||||
complete_time?: number
|
||||
/** Payment status */
|
||||
status: TopupStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* Billing history response
|
||||
*/
|
||||
export interface BillingHistoryResponse {
|
||||
items: TopupRecord[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete order request (admin only)
|
||||
*/
|
||||
export interface CompleteOrderRequest {
|
||||
trade_no: string
|
||||
}
|
||||
Reference in New Issue
Block a user