mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Refine billing, settings, rewards, and usage UI (#547)
* feat: add react-icons support and refactor skill category handling - Added `react-icons` dependency to the project for enhanced icon usage. - Introduced new skill icons in the `toolkitMeta.tsx` component, replacing SVG icons with `react-icons` for improved maintainability and consistency. - Created a new `skillCategories.ts` file to define skill categories and their order, streamlining the management of skill categories across the application. - Refactored the `SkillCategoryFilter` component to utilize the new skill categories structure, enhancing clarity and reducing redundancy in the codebase. - Updated the `Skills` page to leverage the new icon rendering method and skill categories, improving the overall user experience. - Added tests to ensure the correct functionality of the new skill category and icon implementations. * feat: update environment configuration and enhance settings UI - Updated `.env.example` and `app/.env.example` to reflect new backend URL and added optional environment selector for staging. - Enhanced `SettingsHome` component by separating account and billing sections for improved clarity. - Introduced a new billing section in the settings menu to streamline user navigation. - Updated `useSettingsNavigation` hook to accommodate changes in settings structure. - Improved `BillingPanel` to handle session token checks and ensure accurate billing state retrieval. - Refactored `Rewards` page to enhance user experience with clearer progress indicators and improved layout. - Added tests to validate changes in the rewards and settings components. * feat(config): enhance API base URL handling and environment configuration - Introduced a new staging API base URL constant for better environment management. - Added app environment variable constants to streamline environment detection. - Refactored effective API URL resolution to accommodate environment-specific defaults. - Implemented functions to resolve app environment from process environment variables. - Added tests to validate the correct behavior of staging and production API URL handling. * feat(rewards): add community and referrals tabs for rewards management - Introduced `RewardsCommunityTab` and `RewardsReferralsTab` components to enhance the rewards management interface. - The `RewardsCommunityTab` displays user progress, Discord role statuses, and connection options, improving user engagement with rewards. - The `RewardsReferralsTab` allows users to manage their referral program, track progress, and access coupon rewards in a streamlined layout. - Updated the `Rewards` page to integrate these new tabs, enhancing overall user experience and navigation. * feat(rewards): introduce ReferralRewardsSection and RewardsRedeemTab components - Added `ReferralRewardsSection` to manage referral statistics and code application, enhancing user engagement with the referral program. - Created `RewardsRedeemTab` to streamline the process of applying reward codes, improving the overall rewards management experience. - Updated `Rewards` page to include the new redeem tab, allowing users to easily switch between referral and redeem functionalities. - Refactored `RewardsCommunityTab` to adjust the referral selection handler, ensuring consistent navigation across the rewards interface. - Removed redundant UI elements in `RewardsCouponSection` for a cleaner layout. - Enhanced tests to cover new functionalities and ensure robust performance across the rewards system. * feat(ui): introduce PillTabBar component and refactor SkillCategoryFilter and Rewards pages - Added a new `PillTabBar` component to enhance tab navigation with customizable styles and item rendering. - Refactored `SkillCategoryFilter` to utilize `PillTabBar`, improving code clarity and reducing redundancy in button rendering. - Updated the `Rewards` page to replace the existing tab navigation with `PillTabBar`, streamlining the user interface for switching between referral, rewards, and redeem tabs. - Enhanced the `ReferralRewardsSection` layout for better user experience and consistency across the rewards management interface. - Improved tests to cover the new `PillTabBar` functionality and ensure robust performance across the updated components. * fix(rewards): update placeholder and button text in RewardsCouponSection - Changed the input placeholder from "Promo code" to "Coupon code" for clarity. - Updated button text from "Apply code" to "Redeem Code" to better reflect the action being performed. - Adjusted loading state text from "Applying…" to "Redeeming..." for consistency in user feedback. * feat(composio): enhance toolkit handling and improve user messaging - Updated the `ComposioConnectModal` to simplify the connection message, removing unnecessary wording for clarity. - Introduced a `TOOLKIT_ALIASES` mapping in `toolkitMeta.tsx` to standardize toolkit slugs, improving consistency across the application. - Refactored the `composioToolkitMeta` function to utilize the new slug mapping, enhancing toolkit metadata retrieval. - Improved the `useComposioIntegrations` hook to leverage the canonicalized toolkit slugs for better integration handling. - Adjusted the `Skills` page to normalize toolkit slugs during rendering, ensuring a consistent user experience. - Updated tests to reflect changes in messaging and toolkit handling, ensuring robust functionality across the application. * feat(intelligence): add new tabs for Dreams, Memory, and Subconscious features - Introduced `IntelligenceDreamsTab`, which displays generated dreams based on daily life events, enhancing user engagement with a visually appealing layout. - Added `IntelligenceMemoryTab` to manage actionable items, featuring search and filter capabilities for improved user interaction with memory data. - Created `IntelligenceSubconsciousTab` to handle subconscious tasks and logs, providing users with insights and management options for their subconscious activities. - Refactored the `RewardsCommunityTab` to remove unused Discord role status logic, streamlining the component for better performance. - Implemented a new `PageBackButton` component for consistent navigation across settings pages. - Enhanced the `BillingPanel` and its subcomponents to improve user experience in managing billing and subscription details, including transaction history and payment methods. - Added new billing-related tabs for better organization and access to billing features, including `BillingOverviewTab`, `BillingPaymentsTab`, and `BillingPlansTab`. - Updated tests to ensure functionality across new and refactored components, maintaining robust application performance. * refactor(billing): update BillingPanel and BillingPlansTab for improved user experience - Changed the default selected tab in `BillingPanel` from 'overview' to 'plans' to prioritize subscription management. - Removed the `BillingOverviewTab` from the `BillingPanel`, streamlining the billing interface. - Enhanced the `BillingPlansTab` header to clarify the purpose, changing "Explore tiers" to "Choose a Subscription Plan". - Updated the description in `BillingPlansTab` for better clarity on payment options. - Improved the layout of the `SubscriptionPlans` component to better highlight crypto payment options and their availability. - Cleaned up unused code and comments for better maintainability. * refactor(billing): streamline BillingPanel and BillingPlansTab components - Removed unused `teamUsage` state and related API call from `BillingPanel` to simplify data management. - Adjusted layout in `BillingPlansTab` for improved visual hierarchy and user experience. - Cleaned up code by eliminating unnecessary comments and enhancing maintainability. * refactor(billing): enhance SubscriptionPlans layout for improved responsiveness - Updated the layout of the `SubscriptionPlans` component to ensure better responsiveness and visual consistency. - Adjusted class names to include minimum height and width properties for better alignment across different screen sizes. - Enhanced the layout of the pricing display section for improved clarity and user experience. * refactor(billing): update subscription plans and billing components for improved clarity and user experience - Adjusted pricing for BASIC and PRO plans to reflect new monthly and annual rates. - Enhanced feature descriptions for subscription plans to better communicate value. - Removed redundant UI elements in the BillingPaymentsTab and PayAsYouGoCard for a cleaner layout. - Added loading and confirmation messages in SubscriptionPlans to improve user feedback during payment processes. - Updated class names for better responsiveness and visual consistency across billing components. * refactor(billing): improve error messaging and UI consistency in billing components - Updated error message in BillingPanel to specify adding a payment card on Stripe for clarity. - Changed header text in AutoRechargeSection to "Enable Auto-Recharge" for better user understanding. - Modified button text in AutoRechargeSection to "Add card on Stripe" for consistency. - Enhanced styling in PayAsYouGoCard for improved visual appeal and user interaction. - Removed redundant UI elements in PayAsYouGoCard for a cleaner layout. * refactor(billing): remove unused error handling and improve UI consistency across billing components - Eliminated `setArError` prop from BillingPanel, AutoRechargeSection, and BillingPaymentsTab to streamline error handling. - Enhanced layout and styling in BillingHistoryTab and SubscriptionPlans for better visual consistency and user experience. - Removed the BillingOverviewTab component to simplify the billing interface and improve maintainability. * refactor(billing): update feature descriptions and enhance SubscriptionPlans display - Revised feature descriptions in the PLANS array for clarity and conciseness. - Increased the number of displayed features in the SubscriptionPlans component to provide users with more information at a glance. * refactor(billing): update billing components for improved clarity and functionality - Revised feature descriptions in the PLANS array to reflect increased usage limits. - Renamed header in BillingHistoryTab to "Transaction History" and updated description for clarity. - Adjusted transaction amount formatting in BillingHistoryTab to display five decimal places. - Removed redundant UI elements in BillingPaymentsTab to streamline the layout. - Enhanced PayAsYouGoCard with improved credit balance display and top-up options, including validation for custom amounts. * feat(usage): add budget completion message logic and update tests - Introduced `shouldShowBudgetCompletedMessage` to the `UsageState` interface to indicate when the budget completion message should be displayed. - Updated the `useUsageState` hook to calculate the budget completion message based on user credits and budget status. - Enhanced tests for `useUsageState` to verify the correct behavior of the budget completion message under various scenarios. - Modified the `Conversations` component to display the budget completion message appropriately based on the new logic. * style: apply formatter fixes from pre-push hook * refactor(rewards): update coupon section and test cases for clarity and consistency - Renamed placeholder text and button labels in the RewardsCouponSection test to reflect updated terminology. - Adjusted test assertions to ensure they align with the new button and placeholder names. - Updated pricing details in billingHelpers tests to reflect new monthly and annual rates for BASIC and PRO plans. - Enhanced the ContextGatheringStep labels for better clarity regarding email and LinkedIn processing stages. * style(tests): format coupon code input changes for consistency - Reformatted the coupon code input changes in the RewardsCouponSection test for improved readability and consistency. - Ensured that the test cases maintain a uniform style for better maintainability. * refactor: enhance accessibility and improve code structure in various components - Added ARIA roles and attributes to the PillTabBar for better accessibility. - Refactored the canonicalization logic for toolkit slugs into a new file for improved organization. - Updated the IntelligenceDreamsTab and IntelligenceMemoryTab components for better readability and accessibility. - Enhanced error handling and logging in the IntelligenceSubconsciousTab for improved debugging. - Streamlined the ReferralRewardsSection and RewardsCommunityTab components by normalizing referral code input handling. - Removed unused props and improved layout consistency in BillingPanel and related components. - Updated the Skills page to handle subconscious escalation dismissals more effectively. * feat(release): configure staging environment for deployment - Added steps to configure the staging app environment in the release workflow. - Set environment variables for staging, including OPENHUMAN_APP_ENV and VITE_OPENHUMAN_APP_ENV. - Updated build and deployment steps to conditionally use staging settings based on the build target. - Ensured proper handling of workspace paths for staging deployments. * chore(env): add optional staging environment variable to .env.example - Introduced OPENHUMAN_APP_ENV variable to specify the app environment as 'staging'. - Updated .env.example to include new environment configuration for clarity. * refactor(intelligence): streamline navigation and improve text formatting - Simplified the navigation logic in the IntelligenceSubconsciousTab for better readability. - Improved text formatting in the IntelligenceDreamsTab for enhanced clarity and consistency. - Refactored import statements in toolkitMeta.tsx for better organization.
This commit is contained in:
+13
-3
@@ -4,13 +4,23 @@
|
||||
#
|
||||
# Tags: [required] must be set, [optional] has a sensible default or can be blank
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App environment
|
||||
# ---------------------------------------------------------------------------
|
||||
# [optional] set to either 'production' or 'staging'
|
||||
OPENHUMAN_APP_ENV=staging
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend API
|
||||
# ---------------------------------------------------------------------------
|
||||
# [optional] App environment selector for default paths/URLs: production | staging
|
||||
OPENHUMAN_APP_ENV=staging
|
||||
# [required] Primary backend URL (read by Rust core and QuickJS skills sandbox)
|
||||
BACKEND_URL=https://staging-api.alphahuman.xyz
|
||||
BACKEND_URL=https://staging-api.tinyhumans.ai
|
||||
# [required] Also read by Vite frontend (VITE_ prefix required for browser exposure)
|
||||
VITE_BACKEND_URL=https://staging-api.alphahuman.xyz
|
||||
VITE_OPENHUMAN_APP_ENV=staging
|
||||
VITE_BACKEND_URL=https://staging-api.tinyhumans.ai
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authentication (for skills OAuth proxy and debug scripts)
|
||||
@@ -46,7 +56,7 @@ OPENHUMAN_CORE_BIN=
|
||||
OPENHUMAN_API_KEY=
|
||||
# [optional] Default model to use
|
||||
OPENHUMAN_MODEL=
|
||||
# [optional] Workspace directory (default: ~/.openhuman)
|
||||
# [optional] Workspace directory (default: ~/.openhuman or ~/.openhuman-staging when OPENHUMAN_APP_ENV=staging)
|
||||
OPENHUMAN_WORKSPACE=
|
||||
# [optional] Default: 0.7
|
||||
OPENHUMAN_TEMPERATURE=0.7
|
||||
|
||||
@@ -271,6 +271,13 @@ jobs:
|
||||
fetch-depth: 1
|
||||
submodules: true
|
||||
|
||||
- name: Configure staging app environment
|
||||
if: inputs.build_target == 'staging'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "OPENHUMAN_APP_ENV=staging" >> "$GITHUB_ENV"
|
||||
echo "VITE_OPENHUMAN_APP_ENV=staging" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set Xcode version
|
||||
if: matrix.settings.platform == 'macos-latest'
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
@@ -368,6 +375,7 @@ jobs:
|
||||
run: yarn build
|
||||
env:
|
||||
NODE_ENV: production
|
||||
VITE_OPENHUMAN_APP_ENV: ${{ inputs.build_target == 'staging' && 'staging' || 'production' }}
|
||||
VITE_BACKEND_URL: ${{ needs.prepare-build.outputs.base_url }}
|
||||
VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}
|
||||
VITE_DEBUG: ${{ vars.VITE_DEBUG }}
|
||||
@@ -414,6 +422,7 @@ jobs:
|
||||
MATRIX_TARGET: ${{ matrix.settings.target }}
|
||||
CORE_MANIFEST: ${{ steps.core-paths.outputs.core_manifest }}
|
||||
CORE_BIN_NAME: ${{ steps.core-paths.outputs.core_bin_name }}
|
||||
OPENHUMAN_APP_ENV: ${{ inputs.build_target == 'staging' && 'staging' || 'production' }}
|
||||
OPENHUMAN_SENTRY_DSN: ${{ vars.OPENHUMAN_SENTRY_DSN }}
|
||||
|
||||
- name: Stage sidecar for Tauri bundler
|
||||
@@ -452,6 +461,8 @@ jobs:
|
||||
# where we sign everything with hardened runtime + entitlements
|
||||
# (required by Apple notarization).
|
||||
BASE_URL: ${{ needs.prepare-build.outputs.base_url }}
|
||||
OPENHUMAN_APP_ENV: ${{ inputs.build_target == 'staging' && 'staging' || 'production' }}
|
||||
VITE_OPENHUMAN_APP_ENV: ${{ inputs.build_target == 'staging' && 'staging' || 'production' }}
|
||||
VITE_BACKEND_URL: ${{ needs.prepare-build.outputs.base_url }}
|
||||
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.settings.platform == 'macos-latest' && '10.15' || '' }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.UPDATER_PRIVATE_KEY_PASSWORD }}
|
||||
@@ -474,6 +485,8 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
BASE_URL: ${{ needs.prepare-build.outputs.base_url }}
|
||||
OPENHUMAN_APP_ENV: staging
|
||||
VITE_OPENHUMAN_APP_ENV: staging
|
||||
VITE_BACKEND_URL: ${{ needs.prepare-build.outputs.base_url }}
|
||||
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.settings.platform == 'macos-latest' && '10.15' || '' }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.UPDATER_PRIVATE_KEY_PASSWORD }}
|
||||
@@ -662,8 +675,8 @@ jobs:
|
||||
if: >-
|
||||
always()
|
||||
&& needs.prepare-build.result == 'success'
|
||||
&& (needs.prepare-build.outputs.release_enabled != 'true'
|
||||
|| needs.create-release.result == 'success')
|
||||
&& needs.prepare-build.outputs.release_enabled == 'true'
|
||||
&& needs.create-release.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
environment: Production
|
||||
env:
|
||||
|
||||
+4
-1
@@ -4,11 +4,14 @@
|
||||
#
|
||||
# Tags: [required] must be set, [optional] has a sensible default or can be blank
|
||||
|
||||
# [optional] App environment selector for default backend fallback: production | staging
|
||||
VITE_OPENHUMAN_APP_ENV=staging
|
||||
|
||||
# [optional] Core RPC endpoint (Tauri sets this automatically; override for web-only dev)
|
||||
VITE_OPENHUMAN_CORE_RPC_URL=http://127.0.0.1:7788/rpc
|
||||
|
||||
# [required] Backend API URL (web fallback when core RPC is unavailable)
|
||||
VITE_BACKEND_URL=https://staging-api.alphahuman.xyz
|
||||
VITE_BACKEND_URL=https://staging-api.tinyhumans.ai
|
||||
|
||||
# [optional] Telegram bot username used for managed DM linking fallback (default: openhuman_bot)
|
||||
VITE_TELEGRAM_BOT_USERNAME=openhuman_bot
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"process": "^0.11.10",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PillTabBarItem<T extends string> {
|
||||
label: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
interface PillTabBarProps<T extends string> {
|
||||
activeClassName?: string;
|
||||
containerClassName?: string;
|
||||
inactiveClassName?: string;
|
||||
items: PillTabBarItem<T>[];
|
||||
onChange: (value: T) => void;
|
||||
renderItem?: (item: PillTabBarItem<T>, active: boolean) => ReactNode;
|
||||
selected: T;
|
||||
}
|
||||
|
||||
export default function PillTabBar<T extends string>({
|
||||
activeClassName = 'border-primary-200 bg-primary-50 text-primary-700',
|
||||
containerClassName = 'flex gap-2 overflow-x-auto pb-1 scrollbar-hide',
|
||||
inactiveClassName = 'border-stone-200 bg-white text-stone-600 hover:bg-stone-50',
|
||||
items,
|
||||
onChange,
|
||||
renderItem,
|
||||
selected,
|
||||
}: PillTabBarProps<T>) {
|
||||
return (
|
||||
<div className={containerClassName} role="tablist">
|
||||
{items.map(item => {
|
||||
const active = selected === item.value;
|
||||
const tabId = `pill-tab-${String(item.value)}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
id={tabId}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => onChange(item.value)}
|
||||
className={`flex-shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
|
||||
active ? activeClassName : inactiveClassName
|
||||
}`}>
|
||||
{renderItem ? renderItem(item, active) : item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -251,9 +251,8 @@ export default function ComposioConnectModal({
|
||||
{phase === 'idle' && (
|
||||
<>
|
||||
<p className="text-sm text-stone-600">
|
||||
Connect your {toolkit.name} account through Composio. We will open a browser window
|
||||
where you can grant access, and then this app will detect the connection
|
||||
automatically.
|
||||
Connect your {toolkit.name} account. We will open a browser window where you can
|
||||
grant access, and then this app will detect the connection automatically.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -11,8 +11,23 @@
|
||||
* new backend toolkits will still render, just without custom copy.
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
SiFacebook,
|
||||
SiGithub,
|
||||
SiGmail,
|
||||
SiGooglecalendar,
|
||||
SiGoogledrive,
|
||||
SiGooglesheets,
|
||||
SiInstagram,
|
||||
SiLinear,
|
||||
SiNotion,
|
||||
SiReddit,
|
||||
SiSlack,
|
||||
} from 'react-icons/si';
|
||||
|
||||
import type { SkillCategory } from '../skills/SkillCategoryFilter';
|
||||
import { canonicalizeComposioToolkitSlug } from '../../lib/composio/toolkitSlug';
|
||||
import type { SkillCategory } from '../skills/skillCategories';
|
||||
import { SkillIconBadge } from '../skills/skillIcons';
|
||||
|
||||
export interface ComposioToolkitMeta {
|
||||
/** Toolkit slug as returned by the backend, e.g. `"gmail"`. */
|
||||
@@ -27,188 +42,131 @@ export interface ComposioToolkitMeta {
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
function BrandIcon({ bgClassName, children }: { bgClassName: string; children: ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-xl shadow-sm ring-1 ring-black/5 ${bgClassName}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function GmailIcon() {
|
||||
return (
|
||||
<BrandIcon bgClassName="bg-white">
|
||||
<svg className="h-[18px] w-[18px]" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M3 7.25 12 14l9-6.75V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.25Z"
|
||||
fill="#EA4335"
|
||||
opacity="0.15"
|
||||
/>
|
||||
<path d="M3 7.5 12 14l9-6.5" fill="none" stroke="#EA4335" strokeWidth="2" />
|
||||
<path d="M3 8v9a2 2 0 0 0 2 2h3V11.5L3 8Z" fill="#34A853" />
|
||||
<path d="M21 8v9a2 2 0 0 1-2 2h-3v-7.5L21 8Z" fill="#4285F4" />
|
||||
<path d="M8 19v-7.5l4 3 4-3V19H8Z" fill="#FBBC05" />
|
||||
</svg>
|
||||
</BrandIcon>
|
||||
<SkillIconBadge
|
||||
icon={SiGmail}
|
||||
label="Gmail"
|
||||
bgClassName="bg-white"
|
||||
iconClassName="text-[#EA4335]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleCalendarIcon() {
|
||||
return (
|
||||
<BrandIcon bgClassName="bg-[#E8F0FE]">
|
||||
<svg className="h-[18px] w-[18px]" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="4" y="5" width="16" height="15" rx="3" fill="#4285F4" />
|
||||
<rect x="4" y="8" width="16" height="12" rx="0" fill="white" />
|
||||
<path d="M8 4v4M16 4v4" stroke="#4285F4" strokeWidth="2" strokeLinecap="round" />
|
||||
<text x="12" y="17" textAnchor="middle" fontSize="7" fontWeight="700" fill="#4285F4">
|
||||
31
|
||||
</text>
|
||||
</svg>
|
||||
</BrandIcon>
|
||||
<SkillIconBadge
|
||||
icon={SiGooglecalendar}
|
||||
label="Google Calendar"
|
||||
bgClassName="bg-[#E8F0FE]"
|
||||
iconClassName="text-[#4285F4]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleDriveIcon() {
|
||||
return (
|
||||
<BrandIcon bgClassName="bg-white">
|
||||
<svg className="h-[18px] w-[18px]" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M7 4h5l5 8h-5L7 4Z" fill="#0F9D58" />
|
||||
<path d="M7 4 2 12l2.5 4h5L14 8 7 4Z" fill="#F4B400" />
|
||||
<path d="M9.5 16h10L22 12h-10L9.5 16Z" fill="#4285F4" />
|
||||
</svg>
|
||||
</BrandIcon>
|
||||
<SkillIconBadge
|
||||
icon={SiGoogledrive}
|
||||
label="Google Drive"
|
||||
bgClassName="bg-white"
|
||||
iconClassName="text-[#0F9D58]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NotionIcon() {
|
||||
return (
|
||||
<BrandIcon bgClassName="bg-white">
|
||||
<svg className="h-[18px] w-[18px]" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="5" y="5" width="14" height="14" rx="1.8" fill="white" stroke="#111" />
|
||||
<path d="M9 16V8l6 8V8" fill="none" stroke="#111" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
</BrandIcon>
|
||||
<SkillIconBadge
|
||||
icon={SiNotion}
|
||||
label="Notion"
|
||||
bgClassName="bg-white"
|
||||
iconClassName="text-[#111111]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubIcon() {
|
||||
return (
|
||||
<BrandIcon bgClassName="bg-[#111827]">
|
||||
<svg className="h-[18px] w-[18px]" viewBox="0 0 24 24" aria-hidden="true" fill="white">
|
||||
<path d="M12 3.5a8.5 8.5 0 0 0-2.69 16.56c.43.08.58-.19.58-.42v-1.48c-2.36.51-2.86-1-2.86-1-.39-.98-.95-1.25-.95-1.25-.77-.53.06-.52.06-.52.85.06 1.3.87 1.3.87.75 1.29 1.98.92 2.46.7.08-.55.3-.92.55-1.13-1.88-.21-3.86-.94-3.86-4.18 0-.92.33-1.67.87-2.25-.08-.21-.38-1.07.09-2.23 0 0 .71-.23 2.34.86a8.02 8.02 0 0 1 4.26 0c1.63-1.09 2.34-.86 2.34-.86.47 1.16.17 2.02.09 2.23.54.58.87 1.33.87 2.25 0 3.25-1.99 3.97-3.89 4.18.31.27.58.79.58 1.59v2.35c0 .23.15.51.59.42A8.5 8.5 0 0 0 12 3.5Z" />
|
||||
</svg>
|
||||
</BrandIcon>
|
||||
<SkillIconBadge
|
||||
icon={SiGithub}
|
||||
label="GitHub"
|
||||
bgClassName="bg-[#111827]"
|
||||
iconClassName="text-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SlackIcon() {
|
||||
return (
|
||||
<BrandIcon bgClassName="bg-white">
|
||||
<svg className="h-[18px] w-[18px]" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M9.2 3.5a2.2 2.2 0 1 1 0 4.4H7v2.2a2.2 2.2 0 1 1-4.4 0A2.2 2.2 0 0 1 4.8 7.9H7V5.7a2.2 2.2 0 0 1 2.2-2.2Z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M20.5 9.2a2.2 2.2 0 1 1-4.4 0V7h-2.2a2.2 2.2 0 1 1 0-4.4 2.2 2.2 0 0 1 2.2 2.2V7h2.2a2.2 2.2 0 0 1 2.2 2.2Z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M14.8 20.5a2.2 2.2 0 1 1 0-4.4H17v-2.2a2.2 2.2 0 1 1 4.4 0 2.2 2.2 0 0 1-2.2 2.2H17v2.2a2.2 2.2 0 0 1-2.2 2.2Z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 14.8a2.2 2.2 0 1 1 4.4 0V17h2.2a2.2 2.2 0 1 1 0 4.4 2.2 2.2 0 0 1-2.2-2.2V17H5.7a2.2 2.2 0 0 1-2.2-2.2Z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
</svg>
|
||||
</BrandIcon>
|
||||
<SkillIconBadge
|
||||
icon={SiSlack}
|
||||
label="Slack"
|
||||
bgClassName="bg-white"
|
||||
iconClassName="text-[#4A154B]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LinearIcon() {
|
||||
return (
|
||||
<BrandIcon bgClassName="bg-[#0F172A]">
|
||||
<svg className="h-[18px] w-[18px]" viewBox="0 0 24 24" aria-hidden="true" fill="white">
|
||||
<path d="M6 6h8.5v2H8v2.5h5.5v2H8V15h6.5v2H6V6Zm10.8-.25a1.45 1.45 0 1 1 0 2.9 1.45 1.45 0 0 1 0-2.9Zm-1.3 4.5h2.6V17h-2.6v-6.75Z" />
|
||||
</svg>
|
||||
</BrandIcon>
|
||||
<SkillIconBadge
|
||||
icon={SiLinear}
|
||||
label="Linear"
|
||||
bgClassName="bg-[#0F172A]"
|
||||
iconClassName="text-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FacebookIcon() {
|
||||
return (
|
||||
<BrandIcon bgClassName="bg-[#1877F2]">
|
||||
<svg className="h-[18px] w-[18px]" viewBox="0 0 24 24" aria-hidden="true" fill="white">
|
||||
<path d="M13.4 20v-6.3h2.12l.32-2.48H13.4V9.61c0-.72.2-1.2 1.23-1.2H16V6.2c-.67-.07-1.34-.1-2.01-.1-1.99 0-3.35 1.21-3.35 3.43v1.66H8.4v2.48h2.24V20h2.76Z" />
|
||||
</svg>
|
||||
</BrandIcon>
|
||||
<SkillIconBadge
|
||||
icon={SiFacebook}
|
||||
label="Facebook"
|
||||
bgClassName="bg-[#1877F2]"
|
||||
iconClassName="text-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleSheetsIcon() {
|
||||
return (
|
||||
<BrandIcon bgClassName="bg-[#E6F4EA]">
|
||||
<svg className="h-[18px] w-[18px]" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 3h7l5 5v11a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" fill="#0F9D58" />
|
||||
<path d="M15 3v5h5" fill="#34A853" />
|
||||
<path
|
||||
d="M9 11h6M9 14h6M9 17h6M13 9v10"
|
||||
stroke="white"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
opacity="0.95"
|
||||
/>
|
||||
</svg>
|
||||
</BrandIcon>
|
||||
<SkillIconBadge
|
||||
icon={SiGooglesheets}
|
||||
label="Google Sheets"
|
||||
bgClassName="bg-[#E6F4EA]"
|
||||
iconClassName="text-[#0F9D58]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InstagramIcon() {
|
||||
return (
|
||||
<BrandIcon bgClassName="bg-[radial-gradient(circle_at_30%_107%,_#fdf497_0%,_#fdf497_5%,_#fd5949_45%,_#d6249f_60%,_#285AEB_90%)]">
|
||||
<svg className="h-[18px] w-[18px]" viewBox="0 0 24 24" aria-hidden="true" fill="none">
|
||||
<rect x="6" y="6" width="12" height="12" rx="4" stroke="white" strokeWidth="1.8" />
|
||||
<circle cx="12" cy="12" r="3" stroke="white" strokeWidth="1.8" />
|
||||
<circle cx="16.3" cy="7.8" r="1" fill="white" />
|
||||
</svg>
|
||||
</BrandIcon>
|
||||
<SkillIconBadge
|
||||
icon={SiInstagram}
|
||||
label="Instagram"
|
||||
bgClassName="bg-[radial-gradient(circle_at_30%_107%,_#fdf497_0%,_#fdf497_5%,_#fd5949_45%,_#d6249f_60%,_#285AEB_90%)]"
|
||||
iconClassName="text-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RedditIcon() {
|
||||
return (
|
||||
<BrandIcon bgClassName="bg-[#FF4500]">
|
||||
<svg className="h-[18px] w-[18px]" viewBox="0 0 24 24" aria-hidden="true" fill="none">
|
||||
<circle cx="12" cy="13" r="5.5" fill="white" />
|
||||
<circle cx="9.3" cy="12.5" r="1" fill="#FF4500" />
|
||||
<circle cx="14.7" cy="12.5" r="1" fill="#FF4500" />
|
||||
<path
|
||||
d="M9.5 15.1c.7.6 1.52.9 2.5.9.98 0 1.8-.3 2.5-.9"
|
||||
stroke="#FF4500"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path d="m13 7.2 1.2-2.2 2.5.6" stroke="white" strokeWidth="1.3" strokeLinecap="round" />
|
||||
<circle cx="17.5" cy="5.7" r="1.2" fill="white" />
|
||||
<path
|
||||
d="M6.9 10.6c-.86 0-1.55-.72-1.55-1.6 0-.89.69-1.6 1.55-1.6.87 0 1.56.71 1.56 1.6 0 .88-.69 1.6-1.56 1.6Zm10.2 0c-.87 0-1.56-.72-1.56-1.6 0-.89.69-1.6 1.56-1.6.86 0 1.55.71 1.55 1.6 0 .88-.69 1.6-1.55 1.6Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</BrandIcon>
|
||||
<SkillIconBadge
|
||||
icon={SiReddit}
|
||||
label="Reddit"
|
||||
bgClassName="bg-[#FF4500]"
|
||||
iconClassName="text-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericIntegrationIcon() {
|
||||
return (
|
||||
<BrandIcon bgClassName="bg-stone-100">
|
||||
<svg
|
||||
className="h-[18px] w-[18px] text-stone-600"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
fill="none">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-stone-100 text-stone-600 shadow-sm ring-1 ring-black/5">
|
||||
<svg className="h-[18px] w-[18px]" viewBox="0 0 24 24" aria-hidden="true" fill="none">
|
||||
<path
|
||||
d="M8 8h8v8H8zM5 12h3m8 0h3M12 5v3m0 8v3"
|
||||
stroke="currentColor"
|
||||
@@ -217,7 +175,7 @@ function GenericIntegrationIcon() {
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</BrandIcon>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -318,7 +276,7 @@ export const KNOWN_COMPOSIO_TOOLKITS = Object.freeze([
|
||||
'gmail',
|
||||
'googlecalendar',
|
||||
'googledrive',
|
||||
'google_sheets',
|
||||
'googlesheets',
|
||||
'notion',
|
||||
'github',
|
||||
'slack',
|
||||
@@ -329,7 +287,7 @@ export const KNOWN_COMPOSIO_TOOLKITS = Object.freeze([
|
||||
]);
|
||||
|
||||
export function composioToolkitMeta(slug: string): ComposioToolkitMeta {
|
||||
const key = slug.toLowerCase();
|
||||
const key = canonicalizeComposioToolkitSlug(slug);
|
||||
const hit = CATALOG[key];
|
||||
if (hit) return { slug: key, ...hit };
|
||||
// Fallback: title-case the slug and bucket it under "Other".
|
||||
@@ -337,7 +295,7 @@ export function composioToolkitMeta(slug: string): ComposioToolkitMeta {
|
||||
return {
|
||||
slug: key,
|
||||
name,
|
||||
description: `Composio integration for ${name}.`,
|
||||
description: `Integration for ${name}.`,
|
||||
category: 'Other',
|
||||
icon: <GenericIntegrationIcon />,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export default function IntelligenceDreamsTab() {
|
||||
return (
|
||||
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-sky-500/10">
|
||||
<svg className="w-8 h-8 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-stone-900 mb-2">Dreams</h2>
|
||||
<p className="text-stone-400 text-sm mb-1">
|
||||
Twice every day, OpenHuman will generate a dream (or a summary) based on everything that has
|
||||
happened in your life today. These dreams are then indexed and can be used to influence
|
||||
OpenHuman's behavior.
|
||||
</p>
|
||||
<p className="text-xs text-stone-500">Coming soon</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { ActionableItem, ActionableItemSource, TimeGroup } from '../../types/intelligence';
|
||||
import { ActionableCard } from './ActionableCard';
|
||||
|
||||
interface IntelligenceMemoryTabProps {
|
||||
handleAnalyzeNow: () => Promise<void>;
|
||||
handleComplete: (item: ActionableItem) => Promise<void>;
|
||||
handleDismiss: (item: ActionableItem) => void;
|
||||
handleSnooze: (item: ActionableItem, duration: number) => Promise<void>;
|
||||
isRunning: boolean;
|
||||
items: ActionableItem[];
|
||||
itemsLoading: boolean;
|
||||
searchFilter: string;
|
||||
setSearchFilter: (value: string) => void;
|
||||
setSourceFilter: (value: ActionableItemSource | 'all') => void;
|
||||
sourceFilter: ActionableItemSource | 'all';
|
||||
timeGroups: TimeGroup[];
|
||||
usingMemoryData: boolean;
|
||||
}
|
||||
|
||||
export default function IntelligenceMemoryTab({
|
||||
handleAnalyzeNow,
|
||||
handleComplete,
|
||||
handleDismiss,
|
||||
handleSnooze,
|
||||
isRunning,
|
||||
items,
|
||||
itemsLoading,
|
||||
searchFilter,
|
||||
setSearchFilter,
|
||||
setSourceFilter,
|
||||
sourceFilter,
|
||||
timeGroups,
|
||||
usingMemoryData,
|
||||
}: IntelligenceMemoryTabProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-6 animate-fade-up">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="actionable-search" className="sr-only">
|
||||
Search actionable items
|
||||
</label>
|
||||
<input
|
||||
id="actionable-search"
|
||||
type="text"
|
||||
placeholder="Search actionable items..."
|
||||
value={searchFilter}
|
||||
onChange={e => setSearchFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<label htmlFor="actionable-source" className="sr-only">
|
||||
Filter by source
|
||||
</label>
|
||||
<select
|
||||
id="actionable-source"
|
||||
value={sourceFilter}
|
||||
onChange={e => setSourceFilter(e.target.value as ActionableItemSource | 'all')}
|
||||
className="px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:border-primary-500/50 transition-colors">
|
||||
<option value="all">All Sources</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="calendar">Calendar</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="ai_insight">AI Insights</option>
|
||||
<option value="system">System</option>
|
||||
<option value="trading">Trading</option>
|
||||
<option value="security">Security</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{itemsLoading && !usingMemoryData ? (
|
||||
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
|
||||
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-stone-900 mb-2">Loading Intelligence...</h2>
|
||||
<p className="text-stone-400 text-sm">Fetching your actionable items</p>
|
||||
</div>
|
||||
) : isRunning && items.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
|
||||
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-stone-900 mb-2">Analyzing your data…</h2>
|
||||
<p className="text-stone-400 text-sm">
|
||||
The conscious loop is reviewing your connected skills
|
||||
</p>
|
||||
</div>
|
||||
) : timeGroups.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
|
||||
<svg
|
||||
className="w-8 h-8 text-primary-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{searchFilter || sourceFilter !== 'all' ? (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-stone-900 mb-2">No matches</h2>
|
||||
<p className="text-stone-400 text-sm">No items match your current filters.</p>
|
||||
</>
|
||||
) : usingMemoryData ? (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-stone-900 mb-2">All caught up!</h2>
|
||||
<p className="text-stone-400 text-sm">No actionable items at the moment.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-stone-900 mb-2">No analysis yet</h2>
|
||||
<p className="text-stone-400 text-sm mb-4">
|
||||
Run an analysis to extract actionable items from your connected skills.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => void handleAnalyzeNow()}
|
||||
disabled={isRunning}
|
||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-40 text-white text-sm rounded-lg transition-colors">
|
||||
Analyze Now
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2 text-xs text-stone-400 animate-fade-up">
|
||||
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
|
||||
Analyzing your data…
|
||||
</div>
|
||||
)}
|
||||
{timeGroups.map((group, groupIndex) => (
|
||||
<div
|
||||
key={group.label}
|
||||
className="animate-fade-up"
|
||||
style={{ animationDelay: `${groupIndex * 50}ms` }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-stone-900 opacity-80">{group.label}</h2>
|
||||
<div className="text-xs bg-stone-100 text-stone-900 px-2 py-1 rounded-full">
|
||||
{group.count}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{group.items.map((item, itemIndex) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="animate-fade-up"
|
||||
style={{ animationDelay: `${groupIndex * 50 + itemIndex * 25}ms` }}>
|
||||
<ActionableCard
|
||||
item={item}
|
||||
onComplete={handleComplete}
|
||||
onDismiss={handleDismiss}
|
||||
onSnooze={handleSnooze}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import type { Dispatch, FormEvent, SetStateAction } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import type {
|
||||
SubconsciousEscalation,
|
||||
SubconsciousLogEntry,
|
||||
SubconsciousStatus,
|
||||
SubconsciousTask,
|
||||
} from '../../utils/tauriCommands/subconscious';
|
||||
|
||||
const SKILL_KEYWORDS =
|
||||
/\bskill\b|\boauth\b|\bnotion\b|\bgmail\b|\bintegration\b|\bdisconnect|\breconnect|\bre-?auth/i;
|
||||
|
||||
function isSkillRelated(title: string, description: string): boolean {
|
||||
return SKILL_KEYWORDS.test(title) || SKILL_KEYWORDS.test(description);
|
||||
}
|
||||
|
||||
interface IntelligenceSubconsciousTabProps {
|
||||
addSubconsciousTask: (title: string) => Promise<void>;
|
||||
approveEscalation: (escalationId: string) => Promise<void>;
|
||||
dismissEscalation: (escalationId: string) => Promise<void>;
|
||||
expandedLogIds: Set<string>;
|
||||
logEntries: SubconsciousLogEntry[];
|
||||
newTaskTitle: string;
|
||||
removeSubconsciousTask: (taskId: string) => Promise<void>;
|
||||
setExpandedLogIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
setNewTaskTitle: (value: string) => void;
|
||||
status: SubconsciousStatus | null;
|
||||
tasks: SubconsciousTask[];
|
||||
toggleSubconsciousTask: (taskId: string, enabled: boolean) => Promise<void>;
|
||||
triggerTick: () => Promise<void>;
|
||||
triggering: boolean;
|
||||
escalations: SubconsciousEscalation[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export default function IntelligenceSubconsciousTab({
|
||||
addSubconsciousTask,
|
||||
approveEscalation,
|
||||
dismissEscalation,
|
||||
escalations,
|
||||
expandedLogIds,
|
||||
loading,
|
||||
logEntries,
|
||||
newTaskTitle,
|
||||
removeSubconsciousTask,
|
||||
setExpandedLogIds,
|
||||
setNewTaskTitle,
|
||||
status,
|
||||
tasks,
|
||||
toggleSubconsciousTask,
|
||||
triggerTick,
|
||||
triggering,
|
||||
}: IntelligenceSubconsciousTabProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleAddTask = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const title = newTaskTitle.trim();
|
||||
if (!title) return;
|
||||
console.debug('[subconscious-ui] add task:start', { title });
|
||||
try {
|
||||
await addSubconsciousTask(title);
|
||||
setNewTaskTitle('');
|
||||
console.debug('[subconscious-ui] add task:success', { title });
|
||||
} catch (error) {
|
||||
console.debug('[subconscious-ui] add task:error', {
|
||||
title,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunTick = async () => {
|
||||
console.debug('[subconscious-ui] run tick:start', { triggering });
|
||||
try {
|
||||
await triggerTick();
|
||||
console.debug('[subconscious-ui] run tick:done');
|
||||
} catch (error) {
|
||||
console.debug('[subconscious-ui] run tick:error', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveEscalation = async (escalationId: string) => {
|
||||
console.debug('[subconscious-ui] escalation approve:start', { escalationId });
|
||||
try {
|
||||
await approveEscalation(escalationId);
|
||||
console.debug('[subconscious-ui] escalation approve:success', { escalationId });
|
||||
} catch (error) {
|
||||
console.debug('[subconscious-ui] escalation approve:error', {
|
||||
escalationId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissEscalation = async (escalationId: string) => {
|
||||
console.debug('[subconscious-ui] escalation dismiss:start', { escalationId });
|
||||
try {
|
||||
await dismissEscalation(escalationId);
|
||||
console.debug('[subconscious-ui] escalation dismiss:success', { escalationId });
|
||||
} catch (error) {
|
||||
console.debug('[subconscious-ui] escalation dismiss:error', {
|
||||
escalationId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFixInSkills = (escalationId: string) => {
|
||||
console.debug('[subconscious-ui] escalation fix in skills:navigate', { escalationId });
|
||||
navigate('/skills', { state: { subconsciousEscalationId: escalationId } });
|
||||
};
|
||||
|
||||
const handleToggleTask = async (taskId: string, enabled: boolean, title: string) => {
|
||||
console.debug('[subconscious-ui] task toggle:start', { taskId, enabled, title });
|
||||
try {
|
||||
await toggleSubconsciousTask(taskId, enabled);
|
||||
console.debug('[subconscious-ui] task toggle:success', { taskId, enabled, title });
|
||||
} catch (error) {
|
||||
console.debug('[subconscious-ui] task toggle:error', {
|
||||
taskId,
|
||||
enabled,
|
||||
title,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTask = async (taskId: string, title: string) => {
|
||||
console.debug('[subconscious-ui] task remove:start', { taskId, title });
|
||||
try {
|
||||
await removeSubconsciousTask(taskId);
|
||||
console.debug('[subconscious-ui] task remove:success', { taskId, title });
|
||||
} catch (error) {
|
||||
console.debug('[subconscious-ui] task remove:error', {
|
||||
taskId,
|
||||
title,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-up">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-stone-400">
|
||||
{status && (
|
||||
<>
|
||||
<span>{status.task_count} tasks</span>
|
||||
<span className="text-stone-300">|</span>
|
||||
<span>{status.total_ticks} ticks</span>
|
||||
{status.last_tick_at && (
|
||||
<>
|
||||
<span className="text-stone-300">|</span>
|
||||
<span>Last: {new Date(status.last_tick_at * 1000).toLocaleTimeString()}</span>
|
||||
</>
|
||||
)}
|
||||
{status.consecutive_failures > 0 && (
|
||||
<>
|
||||
<span className="text-stone-300">|</span>
|
||||
<span className="text-coral-500">{status.consecutive_failures} failed</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg
|
||||
className="w-3 h-3 text-stone-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<select
|
||||
value={status?.interval_minutes ?? 5}
|
||||
onChange={() => {
|
||||
// Config update would require restart — show as read-only for now
|
||||
}}
|
||||
disabled
|
||||
title="Tick interval (change in Settings > Advanced)"
|
||||
className="text-xs bg-stone-50 border border-stone-200 rounded px-1.5 py-0.5 text-stone-500 cursor-not-allowed">
|
||||
<option value={5}>5 min</option>
|
||||
<option value={10}>10 min</option>
|
||||
<option value={15}>15 min</option>
|
||||
<option value={30}>30 min</option>
|
||||
<option value={60}>1 hour</option>
|
||||
<option value={360}>6 hours</option>
|
||||
<option value={720}>12 hours</option>
|
||||
<option value={1440}>1 day</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleRunTick()}
|
||||
disabled={triggering}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-stone-50 hover:bg-stone-100 disabled:opacity-40 border border-stone-200 rounded-lg text-stone-600 transition-colors">
|
||||
{triggering ? (
|
||||
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
Run Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{escalations.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900 mb-3 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
Approval Needed
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
|
||||
{escalations.length}
|
||||
</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{escalations.map(esc => (
|
||||
<div key={esc.id} className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-stone-900">{esc.title}</p>
|
||||
<p className="text-xs text-stone-500 mt-1">{esc.description}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full ${
|
||||
esc.priority === 'critical'
|
||||
? 'bg-coral-100 text-coral-700'
|
||||
: esc.priority === 'important'
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-stone-100 text-stone-600'
|
||||
}`}>
|
||||
{esc.priority}
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-400">
|
||||
Requires your approval to proceed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-3 flex-shrink-0">
|
||||
{isSkillRelated(esc.title, esc.description) ? (
|
||||
<button
|
||||
onClick={() => handleFixInSkills(esc.id)}
|
||||
className="px-3 py-1.5 text-xs bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors">
|
||||
Fix in Skills
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void handleApproveEscalation(esc.id)}
|
||||
className="px-3 py-1.5 text-xs bg-sage-500 hover:bg-sage-600 text-white rounded-lg transition-colors">
|
||||
Go ahead
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void handleDismissEscalation(esc.id)}
|
||||
className="px-3 py-1.5 text-xs bg-stone-100 hover:bg-stone-200 text-stone-600 rounded-lg transition-colors">
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900 mb-3">Active Tasks</h3>
|
||||
{loading && tasks.length === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="w-6 h-6 mx-auto border-2 border-stone-300 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : tasks.filter(t => !t.completed).length === 0 ? (
|
||||
<p className="text-xs text-stone-400 py-3">No active tasks. Add one below.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{tasks
|
||||
.filter(t => !t.completed && t.source === 'system')
|
||||
.map(task => (
|
||||
<div key={task.id} className="flex items-center py-2 px-3 bg-stone-50 rounded-lg">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-sage-400 flex-shrink-0 mr-2.5" />
|
||||
<span className="text-sm text-stone-900 truncate flex-1">{task.title}</span>
|
||||
<span className="text-[10px] text-stone-400 flex-shrink-0 px-1.5 py-0.5 rounded bg-stone-100">
|
||||
default
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{tasks
|
||||
.filter(t => !t.completed && t.source !== 'system')
|
||||
.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center justify-between py-2 px-3 bg-stone-50 rounded-lg group">
|
||||
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={task.enabled}
|
||||
aria-label={`${task.enabled ? 'Disable' : 'Enable'} ${task.title}`}
|
||||
onClick={() => void handleToggleTask(task.id, !task.enabled, task.title)}
|
||||
className={`relative w-7 h-4 rounded-full flex-shrink-0 transition-colors ${
|
||||
task.enabled ? 'bg-sage-500' : 'bg-stone-300'
|
||||
}`}>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform ${
|
||||
task.enabled ? 'translate-x-3' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
className={`text-sm truncate ${task.enabled ? 'text-stone-900' : 'text-stone-400'}`}>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${task.title}`}
|
||||
onClick={() => void handleRemoveTask(task.id, task.title)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-stone-400 hover:text-coral-500 transition-all">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={e => void handleAddTask(e)} className="flex gap-2 mt-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a task... (e.g. 'Check urgent emails')"
|
||||
value={newTaskTitle}
|
||||
onChange={e => setNewTaskTitle(e.target.value)}
|
||||
className="flex-1 px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newTaskTitle.trim()}
|
||||
className="px-3 py-2 text-sm bg-primary-500 hover:bg-primary-600 disabled:opacity-40 text-white rounded-lg transition-colors">
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900 mb-3">Activity Log</h3>
|
||||
{logEntries.length === 0 ? (
|
||||
<p className="text-xs text-stone-400 py-3">No activity yet. Run a tick to see results.</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{logEntries.map(entry => (
|
||||
<div key={entry.id} className="flex items-start gap-2 py-1.5 px-2 text-xs">
|
||||
<span className="text-stone-400 flex-shrink-0 w-14">
|
||||
{new Date(entry.tick_at * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={`flex-shrink-0 w-1.5 h-1.5 rounded-full mt-1.5 ${
|
||||
entry.decision === 'act'
|
||||
? 'bg-sage-400'
|
||||
: entry.decision === 'in_progress'
|
||||
? 'bg-primary-400 animate-pulse'
|
||||
: entry.decision === 'escalate'
|
||||
? 'bg-amber-400'
|
||||
: entry.decision === 'failed'
|
||||
? 'bg-coral-400'
|
||||
: entry.decision === 'cancelled'
|
||||
? 'bg-stone-300'
|
||||
: entry.decision === 'dismissed'
|
||||
? 'bg-stone-300'
|
||||
: 'bg-stone-200'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`break-words min-w-0 ${
|
||||
entry.decision === 'in_progress'
|
||||
? 'text-stone-400'
|
||||
: entry.decision === 'failed'
|
||||
? 'text-coral-500'
|
||||
: 'text-stone-600'
|
||||
} ${entry.result && entry.result.length > 120 ? 'cursor-pointer hover:text-stone-900' : ''}`}
|
||||
onClick={() => {
|
||||
if (entry.result && entry.result.length > 120) {
|
||||
setExpandedLogIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(entry.id)) next.delete(entry.id);
|
||||
else next.add(entry.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}>
|
||||
{entry.result
|
||||
? expandedLogIds.has(entry.id)
|
||||
? entry.result
|
||||
: entry.result.length > 120
|
||||
? `${entry.result.substring(0, 120)}...`
|
||||
: entry.result
|
||||
: entry.decision === 'noop'
|
||||
? 'Nothing new'
|
||||
: entry.decision === 'act'
|
||||
? 'Completed'
|
||||
: entry.decision === 'in_progress'
|
||||
? 'Evaluating...'
|
||||
: entry.decision === 'escalate'
|
||||
? 'Waiting for approval'
|
||||
: entry.decision === 'failed'
|
||||
? 'Failed'
|
||||
: entry.decision === 'cancelled'
|
||||
? 'Cancelled'
|
||||
: entry.decision === 'dismissed'
|
||||
? 'Skipped'
|
||||
: entry.decision}
|
||||
</span>
|
||||
{entry.duration_ms != null && (
|
||||
<span className="text-stone-300 flex-shrink-0 ml-auto">
|
||||
{entry.duration_ms > 1000
|
||||
? `${(entry.duration_ms / 1000).toFixed(1)}s`
|
||||
: `${entry.duration_ms}ms`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+124
-120
@@ -126,10 +126,11 @@ const ReferralRewardsSection = () => {
|
||||
const handleApply = async () => {
|
||||
const trimmed = applyCode.trim();
|
||||
if (!trimmed) return;
|
||||
const normalizedValue = /^https?:\/\//i.test(trimmed) ? trimmed : trimmed.toUpperCase();
|
||||
setApplyLoading(true);
|
||||
setApplyError(null);
|
||||
try {
|
||||
await referralApi.claimReferral(trimmed);
|
||||
await referralApi.claimReferral(normalizedValue);
|
||||
setApplySuccess(true);
|
||||
setApplyCode('');
|
||||
await refetch();
|
||||
@@ -161,96 +162,135 @@ const ReferralRewardsSection = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 space-y-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-800">
|
||||
Referral rewards
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 space-y-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold text-stone-900">Invite friends, earn credits</h2>
|
||||
<p className="text-sm text-stone-600 max-w-xl">
|
||||
Share your personal link. When a friend subscribes to a monthly plan, you both get $5
|
||||
in account credit. Self-referrals and duplicate rewards are blocked on the server.
|
||||
</p>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-stone-900">Invite friends, earn credits</h2>
|
||||
<p className="text-sm text-stone-600 max-w-xl">
|
||||
Share your personal link. When a friend subscribes to a monthly plan, you both get $5 in
|
||||
account credit. Self-referrals and duplicate rewards are blocked on the server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading && !stats ? (
|
||||
<p className="text-sm text-stone-500">Loading referral program…</p>
|
||||
) : null}
|
||||
{loadError ? (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-800">
|
||||
{loadError}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadStats()}
|
||||
className="ml-2 underline font-medium">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{stats ? (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Your code
|
||||
</div>
|
||||
<div className="mt-2 font-mono text-lg font-semibold text-stone-900 break-all">
|
||||
{stats.referralCode || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Total earned
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-stone-900">
|
||||
{formatUsd(stats.totals.totalRewardUsd)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Pending referrals
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-stone-900">
|
||||
{stats.totals.pendingCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Completed
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-stone-900">
|
||||
{stats.totals.convertedCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopy()}
|
||||
disabled={!shareOrCopyTarget}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-stone-900 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-stone-800 disabled:opacity-50">
|
||||
Copy link or code
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleShare()}
|
||||
disabled={!shareOrCopyTarget}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50 disabled:opacity-50">
|
||||
Share
|
||||
</button>
|
||||
{copyHint ? (
|
||||
<span className="self-center text-sm text-sage-600">{copyHint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{stats.referralLink ? (
|
||||
<p className="text-xs text-stone-500 break-all font-mono">{stats.referralLink}</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading && !stats ? (
|
||||
<p className="text-sm text-stone-500">Loading referral program…</p>
|
||||
) : null}
|
||||
{loadError ? (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-800">
|
||||
{loadError}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadStats()}
|
||||
className="ml-2 underline font-medium">
|
||||
Retry
|
||||
</button>
|
||||
{stats && stats.canApplyReferral !== false && showApplyForm ? (
|
||||
<div className="rounded-xl shadow-soft border border-stone-200 bg-white p-4 space-y-3">
|
||||
<h2 className="text-2xl font-semibold text-stone-900">Have a referral code?</h2>
|
||||
<p className="text-xs text-stone-600">
|
||||
Enter a friend's referral code. You're eligible if you haven't subscribed
|
||||
yet — once you subscribe, you'll both get $5 in credit.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={applyCode}
|
||||
onChange={e => setApplyCode(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && void handleApply()}
|
||||
placeholder="Referral code or link"
|
||||
disabled={applyLoading}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 bg-white font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleApply()}
|
||||
disabled={applyLoading || !applyCode.trim()}
|
||||
className="rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50">
|
||||
{applyLoading ? 'Applying…' : 'Apply'}
|
||||
</button>
|
||||
</div>
|
||||
{applyError ? <p className="text-xs text-coral-600">{applyError}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{stats && (hasAppliedFromStats || hasAppliedFromProfile || applySuccess) && !showApplyForm ? (
|
||||
<p className="text-sm text-sage-700 rounded-xl border border-sage-200 bg-sage-50 px-3 py-2">
|
||||
You're linked to a referral program
|
||||
{stats.appliedReferralCode ? ` (code ${stats.appliedReferralCode})` : ''}.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{stats ? (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Your code
|
||||
</div>
|
||||
<div className="mt-2 font-mono text-lg font-semibold text-stone-900 break-all">
|
||||
{stats.referralCode || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Total earned
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-stone-900">
|
||||
{formatUsd(stats.totals.totalRewardUsd)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Pending referrals
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-stone-900">
|
||||
{stats.totals.pendingCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Completed
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-stone-900">
|
||||
{stats.totals.convertedCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopy()}
|
||||
disabled={!shareOrCopyTarget}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-stone-900 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-stone-800 disabled:opacity-50">
|
||||
Copy link or code
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleShare()}
|
||||
disabled={!shareOrCopyTarget}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50 disabled:opacity-50">
|
||||
Share
|
||||
</button>
|
||||
{copyHint ? (
|
||||
<span className="self-center text-sm text-sage-600">{copyHint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{stats.referralLink ? (
|
||||
<p className="text-xs text-stone-500 break-all font-mono">{stats.referralLink}</p>
|
||||
) : null}
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900 mb-2">Referral activity</h3>
|
||||
{stats.referrals.length === 0 ? (
|
||||
@@ -299,43 +339,7 @@ const ReferralRewardsSection = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showApplyForm ? (
|
||||
<div className="rounded-xl border border-primary-100 bg-primary-50/40 p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Have a referral code?</h3>
|
||||
<p className="text-xs text-stone-600">
|
||||
Enter a friend's referral code. You're eligible if you haven't
|
||||
subscribed yet — once you subscribe, you'll both get $5 in credit.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={applyCode}
|
||||
onChange={e => setApplyCode(e.target.value.toUpperCase())}
|
||||
onKeyDown={e => e.key === 'Enter' && void handleApply()}
|
||||
placeholder="Referral code"
|
||||
disabled={applyLoading}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 bg-white font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleApply()}
|
||||
disabled={applyLoading || !applyCode.trim()}
|
||||
className="rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50">
|
||||
{applyLoading ? 'Applying…' : 'Apply'}
|
||||
</button>
|
||||
</div>
|
||||
{applyError ? <p className="text-xs text-coral-600">{applyError}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(hasAppliedFromStats || hasAppliedFromProfile || applySuccess) && !showApplyForm ? (
|
||||
<p className="text-sm text-sage-700 rounded-xl border border-sage-200 bg-sage-50 px-3 py-2">
|
||||
You're linked to a referral program
|
||||
{stats.appliedReferralCode ? ` (code ${stats.appliedReferralCode})` : ''}.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import type { RewardsAchievement, RewardsSnapshot } from '../../types/rewards';
|
||||
import { DISCORD_INVITE_URL } from '../../utils/links';
|
||||
|
||||
function discordMembershipLabel(snapshot: RewardsSnapshot | null): string {
|
||||
if (!snapshot) return 'Waiting for backend sync';
|
||||
switch (snapshot.discord.membershipStatus) {
|
||||
case 'member':
|
||||
return 'Joined the server';
|
||||
case 'not_in_guild':
|
||||
return 'Linked, but not in server';
|
||||
case 'not_linked':
|
||||
return 'Not linked';
|
||||
default:
|
||||
return 'Membership status unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(Math.max(0, Math.trunc(value)));
|
||||
}
|
||||
|
||||
function roleAccentTone(index: number) {
|
||||
const tones = [
|
||||
{ iconBg: 'bg-amber-50', iconText: 'text-amber-600', iconBorder: 'border-amber-100' },
|
||||
{ iconBg: 'bg-blue-50', iconText: 'text-primary-600', iconBorder: 'border-blue-100' },
|
||||
{ iconBg: 'bg-slate-100', iconText: 'text-slate-600', iconBorder: 'border-slate-200' },
|
||||
{ iconBg: 'bg-emerald-50', iconText: 'text-emerald-600', iconBorder: 'border-emerald-100' },
|
||||
] as const;
|
||||
|
||||
return tones[index % tones.length];
|
||||
}
|
||||
|
||||
function roleGlyph(index: number) {
|
||||
switch (index % 4) {
|
||||
case 0:
|
||||
return (
|
||||
<path
|
||||
d="M12 3l2.4 4.86 5.36.78-3.88 3.78.92 5.35L12 15.27 7.2 17.77l.92-5.35L4.24 8.64l5.36-.78L12 3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<path
|
||||
d="M12 2.5 14.78 8l5.97.87-4.32 4.2 1.02 5.93L12 16.2 6.55 19l1.04-5.93-4.33-4.2L9.22 8 12 2.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<path
|
||||
d="M12 3 5 6v5c0 4.08 2.87 7.9 7 8.9 4.13-1 7-4.82 7-8.9V6l-7-3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<path
|
||||
d="M12 2a5 5 0 0 1 5 5v3h1a2 2 0 0 1 2 2v2c0 4.42-3.58 8-8 8s-8-3.58-8-8v-2a2 2 0 0 1 2-2h1V7a5 5 0 0 1 5-5Zm-3 8h6V7a3 3 0 1 0-6 0v3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface RewardsCommunityTabProps {
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
snapshot: RewardsSnapshot | null;
|
||||
}
|
||||
|
||||
export default function RewardsCommunityTab({
|
||||
error,
|
||||
isLoading,
|
||||
snapshot,
|
||||
}: RewardsCommunityTabProps) {
|
||||
const navigate = useNavigate();
|
||||
const rewardRoles: RewardsAchievement[] = snapshot?.achievements ?? [];
|
||||
const unlocked =
|
||||
snapshot?.summary.unlockedCount ?? rewardRoles.filter(role => role.unlocked).length;
|
||||
const total = snapshot?.summary.totalCount ?? rewardRoles.length;
|
||||
const inviteUrl = snapshot?.discord.inviteUrl ?? DISCORD_INVITE_URL;
|
||||
const progressPercent = total > 0 ? Math.round((unlocked / total) * 100) : 0;
|
||||
const achievementSlots =
|
||||
rewardRoles.length > 0 ? rewardRoles.slice(0, 8) : new Array(4).fill(null);
|
||||
const ringCircumference = 2 * Math.PI * 24;
|
||||
const ringOffset = ringCircumference - (progressPercent / 100) * ringCircumference;
|
||||
return (
|
||||
<>
|
||||
<section className="relative overflow-hidden rounded-[1.25rem] bg-gradient-to-br from-[#004ad0] to-[#2b64f1] p-6 text-white shadow-[0_20px_40px_rgba(25,28,30,0.08)]">
|
||||
<div className="relative z-10 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white">
|
||||
Earn Rewards & Discord Roles
|
||||
</h1>
|
||||
<p className="text-sm font-medium leading-relaxed text-white/90">
|
||||
Unlock exclusive channels, supporter badges, and backend-synced rewards by connecting
|
||||
your Discord account.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
onClick={() => navigate('/settings/messaging')}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-white px-4 py-3 text-sm font-semibold text-primary-700 shadow-lg transition-transform active:scale-[0.98]">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.828 10.172a4 4 0 0 0-5.656 0l-1 1a4 4 0 0 0 5.656 5.656l.586-.586m-3.242-2.828a4 4 0 0 0 5.656 0l1-1a4 4 0 1 0-5.656-5.656l-.586.586"
|
||||
/>
|
||||
</svg>
|
||||
Connect Discord
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(inviteUrl, '_blank', 'noopener,noreferrer')}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/20 bg-white/10 px-4 py-3 text-sm font-semibold text-white backdrop-blur-sm transition-colors hover:bg-white/15">
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.317 4.369A19.79 19.79 0 0 0 15.885 3c-.191.328-.403.775-.552 1.124a18.27 18.27 0 0 0-5.29 0A11.56 11.56 0 0 0 9.49 3a19.74 19.74 0 0 0-4.433 1.369C2.253 8.51 1.492 12.55 1.872 16.533a19.9 19.9 0 0 0 5.239 2.673c.423-.58.8-1.196 1.123-1.845a12.84 12.84 0 0 1-1.767-.85c.148-.106.292-.217.43-.332c3.408 1.6 7.104 1.6 10.472 0c.14.115.283.226.43.332c-.565.338-1.157.623-1.771.851c.322.648.698 1.264 1.123 1.844a19.84 19.84 0 0 0 5.241-2.673c.446-4.617-.761-8.621-3.787-12.164ZM9.46 14.088c-1.02 0-1.855-.936-1.855-2.084c0-1.148.82-2.084 1.855-2.084c1.044 0 1.87.944 1.855 2.084c0 1.148-.82 2.084-1.855 2.084Zm5.08 0c-1.02 0-1.855-.936-1.855-2.084c0-1.148.82-2.084 1.855-2.084c1.044 0 1.87.944 1.855 2.084c0 1.148-.812 2.084-1.855 2.084Z" />
|
||||
</svg>
|
||||
Join Discord
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-white/10 blur-2xl" />
|
||||
<div className="absolute -bottom-10 -left-8 h-24 w-24 rounded-full bg-white/15 blur-xl" />
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Rewards sync is unavailable right now. The page is showing connection guidance without
|
||||
claiming new unlocks. Details: {error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-[1.25rem] bg-white p-6 shadow-[0_4px_20px_rgba(25,28,30,0.04)]">
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-stone-900">Your Progress</h2>
|
||||
<p className="text-xs text-stone-500">
|
||||
{isLoading ? 'Loading rewards…' : `${unlocked} of ${total} achievements unlocked`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative flex h-14 w-14 items-center justify-center">
|
||||
<svg className="h-full w-full -rotate-90" viewBox="0 0 56 56" aria-hidden="true">
|
||||
<circle
|
||||
cx="28"
|
||||
cy="28"
|
||||
r="24"
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
className="text-stone-200"
|
||||
/>
|
||||
<circle
|
||||
cx="28"
|
||||
cy="28"
|
||||
r="24"
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeDasharray={ringCircumference}
|
||||
strokeDashoffset={ringOffset}
|
||||
className="text-primary-600 transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-sm font-bold text-stone-900">{progressPercent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{achievementSlots.map((role, index) => (
|
||||
<div
|
||||
key={role?.id ?? `placeholder-${index}`}
|
||||
className={`flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full border-2 ${
|
||||
role?.unlocked
|
||||
? 'border-primary-200 bg-primary-50 text-primary-600'
|
||||
: 'border-dashed border-stone-300 bg-stone-100 text-stone-400'
|
||||
}`}>
|
||||
<svg className="h-6 w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
{roleGlyph(index)}
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-stone-900">Roles & Rewards</h2>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-soft">
|
||||
<div className="text-sm text-stone-600">Loading rewards…</div>
|
||||
</div>
|
||||
) : rewardRoles.length > 0 ? (
|
||||
rewardRoles.map((role, index) => {
|
||||
const tone = roleAccentTone(index);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={role.id}
|
||||
className={`rounded-[1.25rem] bg-white p-5 shadow-sm transition-shadow hover:shadow-md ${
|
||||
role.unlocked ? 'ring-1 ring-primary-100' : 'ring-1 ring-black/[0.04]'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex gap-4">
|
||||
<div
|
||||
className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl border ${tone.iconBg} ${tone.iconText} ${tone.iconBorder}`}>
|
||||
<svg className="h-6 w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
{roleGlyph(index)}
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-stone-900">{role.title}</h3>
|
||||
<p className="mt-1 text-xs leading-relaxed text-stone-600">
|
||||
{role.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-primary-700">
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.16em]">
|
||||
{role.unlocked ? 'Unlocked' : 'Locked'}
|
||||
</span>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true">
|
||||
{role.unlocked ? (
|
||||
<path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
) : (
|
||||
<path d="M12 2a5 5 0 0 1 5 5v3h1a2 2 0 0 1 2 2v2c0 4.42-3.58 8-8 8s-8-3.58-8-8v-2a2 2 0 0 1 2-2h1V7a5 5 0 0 1 5-5Zm-3 8h6V7a3 3 0 1 0-6 0v3Z" />
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-soft">
|
||||
<h2 className="text-lg font-semibold text-stone-900">Rewards sync pending</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">
|
||||
The backend did not return achievement data yet. Join Discord and connect your
|
||||
account now, then refresh this page once sync is available again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[1.25rem] bg-[#f2f4f6] p-4 text-sm text-stone-600">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>Discord server</span>
|
||||
<span className="font-semibold text-stone-900">{discordMembershipLabel(snapshot)}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<span>Current streak</span>
|
||||
<span className="font-semibold text-stone-900">
|
||||
{snapshot ? `${snapshot.metrics.currentStreakDays} days` : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<span>Cumulative tokens</span>
|
||||
<span className="font-semibold text-stone-900">
|
||||
{snapshot ? formatNumber(snapshot.metrics.cumulativeTokens) : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -147,149 +147,142 @@ const RewardsCouponSection = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-sage-200 bg-sage-50 px-3 py-1 text-xs font-medium text-sage-700">
|
||||
Promo and reward codes
|
||||
<>
|
||||
<section className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold text-stone-900">Redeem a coupon code</h2>
|
||||
<p className="max-w-2xl text-sm text-stone-600">
|
||||
Redeem promo or coupon codes here. Successful redemptions refresh your credits
|
||||
immediately, and pending rewards stay visible in your history.
|
||||
</p>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-stone-900">Apply a reward code</h2>
|
||||
<p className="max-w-2xl text-sm text-stone-600">
|
||||
Redeem promo or campaign codes here. Referral attribution stays in the referral section
|
||||
above. Successful redemptions refresh your credits immediately, and pending rewards stay
|
||||
visible in your history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Promo credits
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Promo credits
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-stone-900">
|
||||
{creditBalance ? formatUsd(creditBalance.promotionBalanceUsd) : loading ? '…' : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-stone-900">
|
||||
{creditBalance ? formatUsd(creditBalance.promotionBalanceUsd) : loading ? '…' : '—'}
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Redeemed codes
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-stone-900">
|
||||
{redeemedCoupons.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Team top-up
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-stone-900">
|
||||
{creditBalance ? formatUsd(creditBalance.teamTopupUsd) : loading ? '…' : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Redeemed codes
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-stone-900">{redeemedCoupons.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-primary-100 bg-primary-50/40 p-4 space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={couponCode}
|
||||
onChange={event => {
|
||||
setCouponCode(event.target.value.toUpperCase());
|
||||
if (submitError) setSubmitError(null);
|
||||
if (submitSuccess) setSubmitSuccess(null);
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
void handleRedeem();
|
||||
}
|
||||
}}
|
||||
placeholder="Promo code"
|
||||
disabled={submitLoading}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 bg-white font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRedeem()}
|
||||
disabled={submitLoading || !couponCode.trim()}
|
||||
className="rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-primary-700 disabled:opacity-50">
|
||||
{submitLoading ? 'Applying…' : 'Apply code'}
|
||||
</button>
|
||||
<div className="rounded-xl border border-primary-100 bg-primary-50/40 p-4 space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={couponCode}
|
||||
onChange={event => {
|
||||
setCouponCode(event.target.value.toUpperCase());
|
||||
if (submitError) setSubmitError(null);
|
||||
if (submitSuccess) setSubmitSuccess(null);
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
void handleRedeem();
|
||||
}
|
||||
}}
|
||||
placeholder="Coupon code"
|
||||
disabled={submitLoading}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 bg-white font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRedeem()}
|
||||
disabled={submitLoading || !couponCode.trim()}
|
||||
className="rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-primary-700 disabled:opacity-50">
|
||||
{submitLoading ? 'Redeeming...' : 'Redeem Code'}
|
||||
</button>
|
||||
</div>
|
||||
{submitSuccess ? (
|
||||
<div className="rounded-xl border border-sage-200 bg-sage-50 px-3 py-2 text-sm text-sage-800">
|
||||
{submitSuccess}
|
||||
</div>
|
||||
) : null}
|
||||
{submitError ? (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-800">
|
||||
{submitError}
|
||||
</div>
|
||||
) : null}
|
||||
{loadError ? (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-800">
|
||||
{loadError}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadCouponState()}
|
||||
className="ml-2 font-medium underline">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{submitSuccess ? (
|
||||
<div className="rounded-xl border border-sage-200 bg-sage-50 px-3 py-2 text-sm text-sage-800">
|
||||
{submitSuccess}
|
||||
</div>
|
||||
) : null}
|
||||
{submitError ? (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-800">
|
||||
{submitError}
|
||||
</div>
|
||||
) : null}
|
||||
{loadError ? (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-800">
|
||||
{loadError}
|
||||
</section>
|
||||
<section className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Recent redemptions</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadCouponState()}
|
||||
className="ml-2 font-medium underline">
|
||||
Retry
|
||||
disabled={loading}
|
||||
className="text-xs font-medium text-stone-500 transition-colors hover:text-stone-700 disabled:opacity-50">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Recent redemptions</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadCouponState()}
|
||||
disabled={loading}
|
||||
className="text-xs font-medium text-stone-500 transition-colors hover:text-stone-700 disabled:opacity-50">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{loading && redeemedCoupons.length === 0 ? (
|
||||
<p className="text-sm text-stone-500">Loading reward history…</p>
|
||||
) : null}
|
||||
|
||||
{loading && redeemedCoupons.length === 0 ? (
|
||||
<p className="text-sm text-stone-500">Loading reward history…</p>
|
||||
) : null}
|
||||
|
||||
{redeemedCoupons.length === 0 && !loading && !loadError ? (
|
||||
<p className="text-sm text-stone-500 rounded-xl border border-dashed border-stone-200 px-4 py-6 text-center">
|
||||
No reward codes redeemed yet.
|
||||
</p>
|
||||
) : redeemedCoupons.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-xl border border-stone-200">
|
||||
<table className="min-w-full text-sm text-left">
|
||||
<thead className="bg-stone-50 text-xs uppercase tracking-wide text-stone-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">Code</th>
|
||||
<th className="px-3 py-2 font-medium">Reward</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Redeemed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{redeemedCoupons.map(coupon => (
|
||||
<tr
|
||||
key={`${coupon.code}-${coupon.redeemedAt ?? coupon.activationType}`}
|
||||
className="bg-white">
|
||||
<td className="px-3 py-2 font-mono text-stone-800">{coupon.code}</td>
|
||||
<td className="px-3 py-2 text-stone-700">{formatUsd(coupon.amountUsd)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${redemptionStatusClass(coupon)}`}>
|
||||
{redemptionStatus(coupon)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-stone-500">
|
||||
{formatDateTime(coupon.redeemedAt)}
|
||||
</td>
|
||||
{redeemedCoupons.length === 0 && !loading && !loadError ? (
|
||||
<p className="text-sm text-stone-500 rounded-xl border border-dashed border-stone-200 px-4 py-6 text-center">
|
||||
No reward codes redeemed yet.
|
||||
</p>
|
||||
) : redeemedCoupons.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-xl border border-stone-200">
|
||||
<table className="min-w-full text-sm text-left">
|
||||
<thead className="bg-stone-50 text-xs uppercase tracking-wide text-stone-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">Code</th>
|
||||
<th className="px-3 py-2 font-medium">Reward</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Redeemed</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{redeemedCoupons.map(coupon => (
|
||||
<tr
|
||||
key={`${coupon.code}-${coupon.redeemedAt ?? coupon.activationType}`}
|
||||
className="bg-white">
|
||||
<td className="px-3 py-2 font-mono text-stone-800">{coupon.code}</td>
|
||||
<td className="px-3 py-2 text-stone-700">{formatUsd(coupon.amountUsd)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${redemptionStatusClass(coupon)}`}>
|
||||
{redemptionStatus(coupon)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-stone-500">
|
||||
{formatDateTime(coupon.redeemedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import RewardsCouponSection from './RewardsCouponSection';
|
||||
|
||||
export default function RewardsRedeemTab() {
|
||||
return (
|
||||
<>
|
||||
<RewardsCouponSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import ReferralRewardsSection from './ReferralRewardsSection';
|
||||
|
||||
export default function RewardsReferralsTab() {
|
||||
return (
|
||||
<>
|
||||
<ReferralRewardsSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -54,8 +54,10 @@ describe('RewardsCouponSection', () => {
|
||||
expect(await screen.findByText('$3.00')).toBeInTheDocument();
|
||||
expect(screen.getByText('No reward codes redeemed yet.')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Promo code'), { target: { value: 'aprl-2026' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply code' }));
|
||||
fireEvent.change(screen.getByPlaceholderText('Coupon code'), {
|
||||
target: { value: 'aprl-2026' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Redeem Code' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('APRL-2026 redeemed. $5.00 was added to your credits.')
|
||||
@@ -80,8 +82,10 @@ describe('RewardsCouponSection', () => {
|
||||
|
||||
expect(await screen.findByText('$3.00')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Promo code'), { target: { value: 'used-code' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply code' }));
|
||||
fireEvent.change(screen.getByPlaceholderText('Coupon code'), {
|
||||
target: { value: 'used-code' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Redeem Code' }));
|
||||
|
||||
expect(await screen.findByText('This coupon has already been used.')).toBeInTheDocument();
|
||||
expect(mocks.mockCreditsApi.getBalance).toHaveBeenCalledTimes(1);
|
||||
@@ -115,8 +119,10 @@ describe('RewardsCouponSection', () => {
|
||||
|
||||
expect(await screen.findByText('$3.00')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Promo code'), { target: { value: 'aprl-2026' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply code' }));
|
||||
fireEvent.change(screen.getByPlaceholderText('Coupon code'), {
|
||||
target: { value: 'aprl-2026' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Redeem Code' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
|
||||
@@ -69,8 +69,8 @@ const SettingsHome = () => {
|
||||
const groupedMenuItems = [
|
||||
{
|
||||
id: 'account',
|
||||
title: 'Account & Billing',
|
||||
description: 'Recovery phrase, team, connections, billing, and privacy',
|
||||
title: 'Account',
|
||||
description: 'Recovery phrase, team, connections, and privacy',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -84,6 +84,23 @@ const SettingsHome = () => {
|
||||
onClick: () => navigateToSettings('account'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'billing',
|
||||
title: 'Billing & Usage',
|
||||
description: 'Subscription plan, pay-as-you-go credits, and payment methods',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H5a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('billing'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'features',
|
||||
title: 'Features',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PageBackButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
trailingContent?: ReactNode;
|
||||
}
|
||||
|
||||
const PageBackButton = ({ label, onClick, trailingContent }: PageBackButtonProps) => {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700 shadow-sm transition-colors hover:bg-stone-50">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{label}
|
||||
</button>
|
||||
{trailingContent}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageBackButton;
|
||||
@@ -134,7 +134,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
const settingsCrumb: BreadcrumbItem = { label: 'Settings', onClick: () => navigate('/settings') };
|
||||
|
||||
const accountCrumb: BreadcrumbItem = {
|
||||
label: 'Account & Billing',
|
||||
label: 'Account',
|
||||
onClick: () => navigate('/settings/account'),
|
||||
};
|
||||
|
||||
@@ -163,14 +163,16 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
case 'ai-models':
|
||||
return [settingsCrumb];
|
||||
|
||||
// Leaf panels under account & billing
|
||||
// Leaf panels under account
|
||||
case 'recovery-phrase':
|
||||
case 'team':
|
||||
case 'connections':
|
||||
case 'billing':
|
||||
case 'privacy':
|
||||
return [settingsCrumb, accountCrumb];
|
||||
|
||||
case 'billing':
|
||||
return [settingsCrumb];
|
||||
|
||||
// Leaf panels under features
|
||||
case 'screen-intelligence':
|
||||
case 'autocomplete':
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
import createDebug from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import PillTabBar from '../../../components/PillTabBar';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { billingApi } from '../../../services/api/billingApi';
|
||||
import {
|
||||
type AutoRechargeSettings,
|
||||
type CreditBalance,
|
||||
creditsApi,
|
||||
type CreditTransaction,
|
||||
type SavedCard,
|
||||
type TeamUsage,
|
||||
} from '../../../services/api/creditsApi';
|
||||
import type { CurrentPlanData, PlanTier } from '../../../types/api';
|
||||
import { openUrl } from '../../../utils/openUrl';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import PageBackButton from '../components/PageBackButton';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import AutoRechargeSection from './billing/AutoRechargeSection';
|
||||
import InferenceBudget from './billing/InferenceBudget';
|
||||
import PayAsYouGoCard from './billing/PayAsYouGoCard';
|
||||
import SubscriptionPlans from './billing/SubscriptionPlans';
|
||||
import { buildPlanId, formatUsdAmount, getPlanMeta } from './billingHelpers';
|
||||
import BillingHistoryTab from './billing/BillingHistoryTab';
|
||||
import BillingPaymentsTab from './billing/BillingPaymentsTab';
|
||||
import BillingPlansTab from './billing/BillingPlansTab';
|
||||
import { buildPlanId } from './billingHelpers';
|
||||
|
||||
const log = createDebug('openhuman:billing-panel');
|
||||
|
||||
type BillingTab = 'overview' | 'plans' | 'payments' | 'history';
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────────
|
||||
const BillingPanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const { snapshot, teams, refresh } = useCoreState();
|
||||
const user = snapshot.currentUser;
|
||||
const sessionToken = snapshot.sessionToken;
|
||||
|
||||
// Active team context
|
||||
const activeTeamId = user?.activeTeamId;
|
||||
@@ -36,9 +39,10 @@ const BillingPanel = () => {
|
||||
// Credits & usage state
|
||||
const [currentPlan, setCurrentPlan] = useState<CurrentPlanData | null>(null);
|
||||
const [creditBalance, setCreditBalance] = useState<CreditBalance | null>(null);
|
||||
const [teamUsage, setTeamUsage] = useState<TeamUsage | null>(null);
|
||||
const [transactions, setTransactions] = useState<CreditTransaction[]>([]);
|
||||
const [isLoadingCredits, setIsLoadingCredits] = useState(false);
|
||||
const [isToppingUp, setIsToppingUp] = useState(false);
|
||||
const [selectedTab, setSelectedTab] = useState<BillingTab>('plans');
|
||||
|
||||
// Local state
|
||||
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly');
|
||||
@@ -82,18 +86,25 @@ const BillingPanel = () => {
|
||||
currentPlan?.hasActiveSubscription ??
|
||||
activeTeam?.team.subscription?.hasActiveSubscription ??
|
||||
false;
|
||||
const planExpiry = currentPlan?.planExpiry ?? activeTeam?.team.subscription?.planExpiry ?? null;
|
||||
const currentPlanMeta = getPlanMeta(currentTier);
|
||||
|
||||
// Fetch current plan, credits balance, and team usage on mount
|
||||
// Fetch current plan, credits balance, and team usage once auth is available.
|
||||
useEffect(() => {
|
||||
if (!sessionToken) {
|
||||
log('[load] skipped: no session token yet');
|
||||
setCurrentPlan(null);
|
||||
setCreditBalance(null);
|
||||
setIsLoadingCredits(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingCredits(true);
|
||||
log('[load] fetching billing state tokenPresent=%s activeTeamId=%s', true, activeTeamId);
|
||||
Promise.allSettled([
|
||||
billingApi.getCurrentPlan(),
|
||||
creditsApi.getBalance(),
|
||||
creditsApi.getTeamUsage(),
|
||||
creditsApi.getTransactions(5, 0),
|
||||
])
|
||||
.then(([planResult, balanceResult, usageResult]) => {
|
||||
.then(([planResult, balanceResult, transactionsResult]) => {
|
||||
if (planResult.status === 'fulfilled') {
|
||||
const plan = planResult.value;
|
||||
log(
|
||||
@@ -102,23 +113,42 @@ const BillingPanel = () => {
|
||||
plan.hasActiveSubscription,
|
||||
plan.weeklyBudgetUsd
|
||||
);
|
||||
setCurrentPlan(plan);
|
||||
if (!cancelled) {
|
||||
setCurrentPlan(plan);
|
||||
}
|
||||
} else {
|
||||
log('[load] getCurrentPlan failed: %O', planResult.reason);
|
||||
}
|
||||
if (balanceResult.status === 'fulfilled') {
|
||||
setCreditBalance(balanceResult.value);
|
||||
log(
|
||||
'[load] balance promotion=%s teamTopup=%s',
|
||||
balanceResult.value.promotionBalanceUsd,
|
||||
balanceResult.value.teamTopupUsd
|
||||
);
|
||||
if (!cancelled) {
|
||||
setCreditBalance(balanceResult.value);
|
||||
}
|
||||
} else {
|
||||
log('[load] getBalance failed: %O', balanceResult.reason);
|
||||
}
|
||||
if (usageResult.status === 'fulfilled') {
|
||||
setTeamUsage(usageResult.value);
|
||||
if (transactionsResult.status === 'fulfilled') {
|
||||
if (!cancelled) {
|
||||
setTransactions(transactionsResult.value.transactions);
|
||||
}
|
||||
} else {
|
||||
log('[load] getTeamUsage failed: %O', usageResult.reason);
|
||||
log('[load] getTransactions failed: %O', transactionsResult.reason);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoadingCredits(false));
|
||||
}, []);
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoadingCredits(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionToken, activeTeamId]);
|
||||
|
||||
// When crypto is selected, force annual
|
||||
useEffect(() => {
|
||||
@@ -172,7 +202,7 @@ const BillingPanel = () => {
|
||||
const nextEnabled = !arSettings.enabled;
|
||||
|
||||
if (nextEnabled && !arSettings.hasSavedPaymentMethod && cards.length === 0) {
|
||||
setArError('Add a payment card before enabling auto-recharge.');
|
||||
setArError('Add a payment card on Stripe before enabling auto-recharge.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -269,6 +299,13 @@ const BillingPanel = () => {
|
||||
const plan = await billingApi.getCurrentPlan();
|
||||
log('[payment-success] plan=%s active=%s', plan.plan, plan.hasActiveSubscription);
|
||||
setCurrentPlan(plan);
|
||||
const balance = await creditsApi.getBalance();
|
||||
log(
|
||||
'[payment-success] refreshed balance promotion=%s teamTopup=%s',
|
||||
balance.promotionBalanceUsd,
|
||||
balance.teamTopupUsd
|
||||
);
|
||||
setCreditBalance(balance);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch current plan after payment', e);
|
||||
}
|
||||
@@ -368,100 +405,35 @@ const BillingPanel = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBalanceRefresh = useCallback(async () => {
|
||||
try {
|
||||
const balance = await creditsApi.getBalance();
|
||||
setCreditBalance(balance);
|
||||
} catch (err) {
|
||||
log('[balance-refresh] failed: %O', err);
|
||||
}
|
||||
}, []);
|
||||
const transactionRows = transactions.slice(0, 4);
|
||||
|
||||
// ── JSX ─────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={teamName ? `Billing — ${teamName}` : 'Billing & Subscription'}
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="overflow-y-auto">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* ── Current Plan Header ───────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Current Plan — {currentTier}</h3>
|
||||
{hasActive && (
|
||||
<button
|
||||
onClick={handleManageSubscription}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 font-medium transition-colors">
|
||||
Manage
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{planExpiry && (
|
||||
<p className="text-xs text-stone-400 mb-1.5">
|
||||
Renews{' '}
|
||||
{new Date(planExpiry).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-stone-500">
|
||||
{currentTier === 'FREE'
|
||||
? 'Free accounts use pay-as-you-go credits. New accounts may receive a one-time signup credit.'
|
||||
: 'Your subscription includes premium usage each cycle. Top-ups cover any overage.'}
|
||||
</p>
|
||||
{currentPlan && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{currentPlan.monthlyBudgetUsd > 0 && (
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
Included monthly value: {formatUsdAmount(currentPlan.monthlyBudgetUsd)}
|
||||
</span>
|
||||
)}
|
||||
{currentPlan.weeklyBudgetUsd > 0 && (
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
7-day cycle budget: {formatUsdAmount(currentPlan.weeklyBudgetUsd)}
|
||||
</span>
|
||||
)}
|
||||
{currentPlan.fiveHourCapUsd > 0 && (
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
10-hour cap: {formatUsdAmount(currentPlan.fiveHourCapUsd)}
|
||||
</span>
|
||||
)}
|
||||
{currentPlanMeta && currentPlanMeta.discountPercent > 0 && (
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
Premium-usage discount: {currentPlanMeta.discountPercent}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Pay as You Go ── PROMOTED TO TOP ─────────────────── */}
|
||||
<PayAsYouGoCard
|
||||
creditBalance={creditBalance}
|
||||
isLoadingCredits={isLoadingCredits}
|
||||
isToppingUp={isToppingUp}
|
||||
onTopUp={handleTopUp}
|
||||
onBalanceRefresh={handleBalanceRefresh}
|
||||
<div className="overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl space-y-5 px-4 py-6 sm:px-6 sm:py-8">
|
||||
<header className="space-y-5">
|
||||
<PageBackButton
|
||||
label={
|
||||
teamName ? `Back to ${breadcrumbs.at(-1)?.label ?? 'settings'}` : 'Back to settings'
|
||||
}
|
||||
onClick={navigateBack}
|
||||
/>
|
||||
<PillTabBar
|
||||
items={[
|
||||
{ label: 'Plans', value: 'plans' },
|
||||
{ label: 'Top ups & Credits', value: 'payments' },
|
||||
{ label: 'History', value: 'history' },
|
||||
]}
|
||||
selected={selectedTab}
|
||||
onChange={setSelectedTab}
|
||||
activeClassName="border-primary-600 bg-primary-600 text-white"
|
||||
inactiveClassName="border-stone-200 bg-white text-stone-600 hover:bg-stone-50"
|
||||
containerClassName="flex gap-2 overflow-x-auto pb-1 scrollbar-hide"
|
||||
/>
|
||||
</header>
|
||||
|
||||
{/* ── Divider ──────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<div className="flex-1 h-px bg-stone-200" />
|
||||
<span className="text-xs text-stone-400 whitespace-nowrap">
|
||||
Or subscribe for included usage + discounts
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* ── Subscription Plans ──────────────────────────────── */}
|
||||
<SubscriptionPlans
|
||||
{selectedTab === 'plans' && (
|
||||
<BillingPlansTab
|
||||
currentTier={currentTier}
|
||||
billingInterval={billingInterval}
|
||||
setBillingInterval={setBillingInterval}
|
||||
@@ -472,39 +444,46 @@ const BillingPanel = () => {
|
||||
paymentConfirmed={paymentConfirmed}
|
||||
onUpgrade={handleUpgrade}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Inference Budget ────────────────────────────────── */}
|
||||
<div className="pt-2">
|
||||
<InferenceBudget teamUsage={teamUsage} isLoadingCredits={isLoadingCredits} />
|
||||
</div>
|
||||
|
||||
{/* ── Auto-Recharge + Payment Methods ────────────────── */}
|
||||
<AutoRechargeSection
|
||||
arSettings={arSettings}
|
||||
arLoading={arLoading}
|
||||
arError={arError}
|
||||
arSaving={arSaving}
|
||||
arThreshold={arThreshold}
|
||||
{selectedTab === 'payments' && (
|
||||
<BillingPaymentsTab
|
||||
arAmount={arAmount}
|
||||
arWeeklyLimit={arWeeklyLimit}
|
||||
arDirty={arDirty}
|
||||
setArThreshold={setArThreshold}
|
||||
setArAmount={setArAmount}
|
||||
setArWeeklyLimit={setArWeeklyLimit}
|
||||
setArError={setArError}
|
||||
onArToggle={handleArToggle}
|
||||
onArSave={handleArSave}
|
||||
arError={arError}
|
||||
arLoading={arLoading}
|
||||
arSaving={arSaving}
|
||||
arSettings={arSettings}
|
||||
arThreshold={arThreshold}
|
||||
arWeeklyLimit={arWeeklyLimit}
|
||||
cards={cards}
|
||||
cardsLoading={cardsLoading}
|
||||
confirmDeleteId={confirmDeleteId}
|
||||
creditBalance={creditBalance}
|
||||
deletingCardId={deletingCardId}
|
||||
settingDefaultId={settingDefaultId}
|
||||
setConfirmDeleteId={setConfirmDeleteId}
|
||||
onSetDefault={handleSetDefault}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
isLoadingCredits={isLoadingCredits}
|
||||
isToppingUp={isToppingUp}
|
||||
onAddCard={handleAddCard}
|
||||
onArSave={handleArSave}
|
||||
onArToggle={handleArToggle}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onSetDefault={handleSetDefault}
|
||||
onTopUp={handleTopUp}
|
||||
setArAmount={setArAmount}
|
||||
setArThreshold={setArThreshold}
|
||||
setArWeeklyLimit={setArWeeklyLimit}
|
||||
setConfirmDeleteId={setConfirmDeleteId}
|
||||
settingDefaultId={settingDefaultId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTab === 'history' && (
|
||||
<BillingHistoryTab
|
||||
hasActive={hasActive}
|
||||
onManageSubscription={handleManageSubscription}
|
||||
transactionRows={transactionRows}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,8 +34,8 @@ describe('PLANS', () => {
|
||||
|
||||
it('should have BASIC plan aligned with backend config', () => {
|
||||
const basic = PLANS.find(p => p.tier === 'BASIC')!;
|
||||
expect(basic.monthlyPrice).toBe(20);
|
||||
expect(basic.annualPrice).toBe(200);
|
||||
expect(basic.monthlyPrice).toBe(19.99);
|
||||
expect(basic.annualPrice).toBe(199);
|
||||
expect(basic.discountPercent).toBe(20);
|
||||
expect(basic.monthlyBudgetUsd).toBe(20);
|
||||
expect(basic.weeklyBudgetUsd).toBe(10);
|
||||
@@ -44,11 +44,11 @@ describe('PLANS', () => {
|
||||
|
||||
it('should have PRO plan aligned with backend config', () => {
|
||||
const pro = PLANS.find(p => p.tier === 'PRO')!;
|
||||
expect(pro.monthlyPrice).toBe(200);
|
||||
expect(pro.annualPrice).toBe(2000);
|
||||
expect(pro.monthlyPrice).toBe(199.99);
|
||||
expect(pro.annualPrice).toBe(1799.99);
|
||||
expect(pro.discountPercent).toBe(40);
|
||||
expect(pro.monthlyBudgetUsd).toBe(200);
|
||||
expect(pro.weeklyBudgetUsd).toBe(100);
|
||||
expect(pro.monthlyBudgetUsd).toBe(199);
|
||||
expect(pro.weeklyBudgetUsd).toBe(99);
|
||||
expect(pro.fiveHourCapUsd).toBe(30);
|
||||
});
|
||||
|
||||
@@ -109,12 +109,12 @@ describe('displayPrice', () => {
|
||||
expect(displayPrice(freePlan, 'monthly')).toBe('$0');
|
||||
});
|
||||
|
||||
it('should return $20 for BASIC plan', () => {
|
||||
expect(displayPrice(basicPlan, 'monthly')).toBe('$20');
|
||||
it('should return $19.99 for BASIC plan', () => {
|
||||
expect(displayPrice(basicPlan, 'monthly')).toBe('$19.99');
|
||||
});
|
||||
|
||||
it('should return $200 for PRO plan', () => {
|
||||
expect(displayPrice(proPlan, 'monthly')).toBe('$200');
|
||||
it('should return $199.99 for PRO plan', () => {
|
||||
expect(displayPrice(proPlan, 'monthly')).toBe('$199.99');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,13 +123,12 @@ describe('displayPrice', () => {
|
||||
expect(displayPrice(freePlan, 'annual')).toBe('$0');
|
||||
});
|
||||
|
||||
it('should return annual equivalent monthly price for BASIC ($200/12 = $17)', () => {
|
||||
it('should return annual equivalent monthly price for BASIC ($199/12 = $17)', () => {
|
||||
expect(displayPrice(basicPlan, 'annual')).toBe('$17');
|
||||
});
|
||||
|
||||
it('should return annual equivalent monthly price for PRO ($2000/12 = $167)', () => {
|
||||
// $2000 / 12 = 166.67, rounded to $167
|
||||
expect(displayPrice(proPlan, 'annual')).toBe('$167');
|
||||
it('should return annual equivalent monthly price for PRO ($1799.99/12 = $150)', () => {
|
||||
expect(displayPrice(proPlan, 'annual')).toBe('$150');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,15 +166,15 @@ describe('annualSavings', () => {
|
||||
});
|
||||
|
||||
it('should calculate savings for BASIC annual', () => {
|
||||
// Monthly total: $20 * 12 = $240, Annual: $200
|
||||
// Savings: ($240 - $200) / $240 = 16.67%, rounded to 17%
|
||||
// Monthly total: $19.99 * 12 = $239.88, Annual: $199
|
||||
// Savings: ($239.88 - $199) / $239.88 = 17.04%, rounded to 17%
|
||||
expect(annualSavings(basicPlan, 'annual')).toBe(17);
|
||||
});
|
||||
|
||||
it('should calculate savings for PRO annual', () => {
|
||||
// Monthly total: $200 * 12 = $2400, Annual: $2000
|
||||
// Savings: ($2400 - $2000) / $2400 = 16.67%, rounded to 17%
|
||||
expect(annualSavings(proPlan, 'annual')).toBe(17);
|
||||
// Monthly total: $199.99 * 12 = $2399.88, Annual: $1799.99
|
||||
// Savings: ($2399.88 - $1799.99) / $2399.88 = 25.00%, rounded to 25%
|
||||
expect(annualSavings(proPlan, 'annual')).toBe(25);
|
||||
});
|
||||
|
||||
it('should return null when annual price equals monthly * 12 (no savings)', () => {
|
||||
|
||||
@@ -31,7 +31,6 @@ interface AutoRechargeSectionProps {
|
||||
setArThreshold: (v: number) => void;
|
||||
setArAmount: (v: number) => void;
|
||||
setArWeeklyLimit: (v: number) => void;
|
||||
setArError: (v: string | null) => void;
|
||||
onArToggle: () => void;
|
||||
onArSave: () => void;
|
||||
// Cards
|
||||
@@ -58,7 +57,6 @@ const AutoRechargeSection = ({
|
||||
setArThreshold,
|
||||
setArAmount,
|
||||
setArWeeklyLimit,
|
||||
setArError,
|
||||
onArToggle,
|
||||
onArSave,
|
||||
cards,
|
||||
@@ -75,7 +73,7 @@ const AutoRechargeSection = ({
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-stone-900">Auto-Recharge Credits</p>
|
||||
<p className="text-md font-semibold text-stone-900">Enable Auto-Recharge</p>
|
||||
<p className="text-[11px] text-stone-400 mt-0.5">
|
||||
Automatically top up when your balance runs low
|
||||
</p>
|
||||
@@ -116,20 +114,7 @@ const AutoRechargeSection = ({
|
||||
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-[11px] text-coral-300 leading-relaxed">{arError}</p>
|
||||
<button
|
||||
onClick={() => setArError(null)}
|
||||
className="ml-auto text-coral-400 hover:text-coral-300 flex-shrink-0"
|
||||
aria-label="Dismiss error">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<p className="text-[12px] text-coral-400 leading-relaxed">{arError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -283,7 +268,7 @@ const AutoRechargeSection = ({
|
||||
<button
|
||||
onClick={onAddCard}
|
||||
className="text-[11px] text-primary-400 hover:text-primary-300 font-medium transition-colors">
|
||||
+ Add card
|
||||
+ Add card on Stripe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CreditTransaction } from '../../../../services/api/creditsApi';
|
||||
|
||||
interface BillingHistoryTabProps {
|
||||
hasActive: boolean;
|
||||
onManageSubscription: () => void;
|
||||
transactionRows: CreditTransaction[];
|
||||
}
|
||||
|
||||
export default function BillingHistoryTab({
|
||||
hasActive,
|
||||
onManageSubscription,
|
||||
transactionRows,
|
||||
}: BillingHistoryTabProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-col gap-2 rounded-2xl bg-white p-4 border border-stone-200">
|
||||
<h3 className="font-headline text-2xl font-bold tracking-tight text-stone-950">
|
||||
Transaction History
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">
|
||||
A quick view of your credit ledger. All credits/debits are shown here.
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{hasActive && (
|
||||
<button
|
||||
onClick={onManageSubscription}
|
||||
className="text-sm font-semibold text-primary-600 transition-colors hover:text-primary-700">
|
||||
Open billing portal
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[28px] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.06)] ring-1 ring-stone-950/5">
|
||||
{transactionRows.length > 0 ? (
|
||||
<div className="divide-y divide-stone-100">
|
||||
{transactionRows.map(transaction => {
|
||||
const isEarn = transaction.type === 'EARN';
|
||||
return (
|
||||
<div
|
||||
key={transaction.id}
|
||||
className="grid gap-3 px-5 py-4 text-sm sm:grid-cols-[1.3fr_0.8fr_0.7fr_0.8fr] sm:items-center">
|
||||
<div>
|
||||
<p className="font-semibold text-stone-950">{transaction.action}</p>
|
||||
<p className="mt-1 text-xs text-stone-500">
|
||||
{new Date(transaction.createdAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-stone-500">{transaction.type}</div>
|
||||
<div className={`font-semibold ${isEarn ? 'text-sage-600' : 'text-stone-950'}`}>
|
||||
{isEarn ? '+' : '-'}${Math.abs(transaction.amountUsd).toFixed(2)}
|
||||
</div>
|
||||
<div className="sm:text-right">
|
||||
<span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-stone-500">
|
||||
Posted
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-5 py-8 text-sm text-stone-500">
|
||||
No recent billing activity is available yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type {
|
||||
AutoRechargeSettings,
|
||||
CreditBalance,
|
||||
SavedCard,
|
||||
} from '../../../../services/api/creditsApi';
|
||||
import AutoRechargeSection from './AutoRechargeSection';
|
||||
import PayAsYouGoCard from './PayAsYouGoCard';
|
||||
|
||||
interface BillingPaymentsTabProps {
|
||||
arAmount: number;
|
||||
arDirty: boolean;
|
||||
arError: string | null;
|
||||
arLoading: boolean;
|
||||
arSaving: boolean;
|
||||
arSettings: AutoRechargeSettings | null;
|
||||
arThreshold: number;
|
||||
arWeeklyLimit: number;
|
||||
cards: SavedCard[];
|
||||
cardsLoading: boolean;
|
||||
confirmDeleteId: string | null;
|
||||
creditBalance: CreditBalance | null;
|
||||
deletingCardId: string | null;
|
||||
isLoadingCredits: boolean;
|
||||
isToppingUp: boolean;
|
||||
onAddCard: () => void;
|
||||
onArSave: () => void;
|
||||
onArToggle: () => void;
|
||||
onDeleteCard: (paymentMethodId: string) => void;
|
||||
onSetDefault: (paymentMethodId: string) => void;
|
||||
onTopUp: (amountUsd: number) => void;
|
||||
setArAmount: (value: number) => void;
|
||||
setArThreshold: (value: number) => void;
|
||||
setArWeeklyLimit: (value: number) => void;
|
||||
setConfirmDeleteId: (value: string | null) => void;
|
||||
settingDefaultId: string | null;
|
||||
}
|
||||
|
||||
export default function BillingPaymentsTab({
|
||||
arAmount,
|
||||
arDirty,
|
||||
arError,
|
||||
arLoading,
|
||||
arSaving,
|
||||
arSettings,
|
||||
arThreshold,
|
||||
arWeeklyLimit,
|
||||
cards,
|
||||
cardsLoading,
|
||||
confirmDeleteId,
|
||||
creditBalance,
|
||||
deletingCardId,
|
||||
isLoadingCredits,
|
||||
isToppingUp,
|
||||
onAddCard,
|
||||
onArSave,
|
||||
onArToggle,
|
||||
onDeleteCard,
|
||||
onSetDefault,
|
||||
onTopUp,
|
||||
setArAmount,
|
||||
setArThreshold,
|
||||
setArWeeklyLimit,
|
||||
setConfirmDeleteId,
|
||||
settingDefaultId,
|
||||
}: BillingPaymentsTabProps) {
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4">
|
||||
<PayAsYouGoCard
|
||||
creditBalance={creditBalance}
|
||||
isLoadingCredits={isLoadingCredits}
|
||||
isToppingUp={isToppingUp}
|
||||
onTopUp={onTopUp}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AutoRechargeSection
|
||||
arSettings={arSettings}
|
||||
arLoading={arLoading}
|
||||
arError={arError}
|
||||
arSaving={arSaving}
|
||||
arThreshold={arThreshold}
|
||||
arAmount={arAmount}
|
||||
arWeeklyLimit={arWeeklyLimit}
|
||||
arDirty={arDirty}
|
||||
setArThreshold={setArThreshold}
|
||||
setArAmount={setArAmount}
|
||||
setArWeeklyLimit={setArWeeklyLimit}
|
||||
onArToggle={onArToggle}
|
||||
onArSave={onArSave}
|
||||
cards={cards}
|
||||
cardsLoading={cardsLoading}
|
||||
confirmDeleteId={confirmDeleteId}
|
||||
deletingCardId={deletingCardId}
|
||||
settingDefaultId={settingDefaultId}
|
||||
setConfirmDeleteId={setConfirmDeleteId}
|
||||
onSetDefault={onSetDefault}
|
||||
onDeleteCard={onDeleteCard}
|
||||
onAddCard={onAddCard}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PlanTier } from '../../../../types/api';
|
||||
import SubscriptionPlans from './SubscriptionPlans';
|
||||
|
||||
interface BillingPlansTabProps {
|
||||
billingInterval: 'monthly' | 'annual';
|
||||
currentTier: PlanTier;
|
||||
isPurchasing: boolean;
|
||||
onUpgrade: (tier: PlanTier) => void;
|
||||
paymentConfirmed: boolean;
|
||||
paymentMethod: 'card' | 'crypto';
|
||||
purchasingTier: PlanTier | null;
|
||||
setBillingInterval: (value: 'monthly' | 'annual') => void;
|
||||
setPaymentMethod: (value: 'card' | 'crypto') => void;
|
||||
}
|
||||
|
||||
export default function BillingPlansTab({
|
||||
billingInterval,
|
||||
currentTier,
|
||||
isPurchasing,
|
||||
onUpgrade,
|
||||
paymentConfirmed,
|
||||
paymentMethod,
|
||||
purchasingTier,
|
||||
setBillingInterval,
|
||||
setPaymentMethod,
|
||||
}: BillingPlansTabProps) {
|
||||
return (
|
||||
<SubscriptionPlans
|
||||
currentTier={currentTier}
|
||||
billingInterval={billingInterval}
|
||||
setBillingInterval={setBillingInterval}
|
||||
paymentMethod={paymentMethod}
|
||||
setPaymentMethod={setPaymentMethod}
|
||||
isPurchasing={isPurchasing}
|
||||
purchasingTier={purchasingTier}
|
||||
paymentConfirmed={paymentConfirmed}
|
||||
onUpgrade={onUpgrade}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
import createDebug from 'debug';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { type CreditBalance, creditsApi } from '../../../../services/api/creditsApi';
|
||||
|
||||
const log = createDebug('openhuman:billing-payg');
|
||||
import { type CreditBalance } from '../../../../services/api/creditsApi';
|
||||
|
||||
interface PayAsYouGoCardProps {
|
||||
creditBalance: CreditBalance | null;
|
||||
isLoadingCredits: boolean;
|
||||
isToppingUp: boolean;
|
||||
onTopUp: (amountUsd: number) => void;
|
||||
onBalanceRefresh: () => void;
|
||||
}
|
||||
|
||||
const PayAsYouGoCard = ({
|
||||
@@ -18,7 +14,6 @@ const PayAsYouGoCard = ({
|
||||
isLoadingCredits,
|
||||
isToppingUp,
|
||||
onTopUp,
|
||||
onBalanceRefresh,
|
||||
}: PayAsYouGoCardProps) => {
|
||||
// Backend `GET /payments/credits/balance` returns
|
||||
// { promotionBalanceUsd, teamTopupUsd }
|
||||
@@ -30,155 +25,117 @@ const PayAsYouGoCard = ({
|
||||
const teamTopupCredits = creditBalance?.teamTopupUsd ?? 0;
|
||||
const availableCredits = promoCredits + teamTopupCredits;
|
||||
|
||||
// Coupon state (local — no need to share with other sections)
|
||||
const [couponCode, setCouponCode] = useState('');
|
||||
const [couponLoading, setCouponLoading] = useState(false);
|
||||
const [couponError, setCouponError] = useState<string | null>(null);
|
||||
const [couponSuccess, setCouponSuccess] = useState<string | null>(null);
|
||||
const [customTopUpAmount, setCustomTopUpAmount] = useState('');
|
||||
const customTopUpAmountValid = Number(customTopUpAmount) > 0;
|
||||
|
||||
const handleRedeemCoupon = async () => {
|
||||
const code = couponCode.trim();
|
||||
if (!code || couponLoading) return;
|
||||
|
||||
setCouponLoading(true);
|
||||
setCouponError(null);
|
||||
setCouponSuccess(null);
|
||||
|
||||
try {
|
||||
log('[coupon] redeeming code=%s', code);
|
||||
const result = await creditsApi.redeemCoupon(code);
|
||||
setCouponSuccess(
|
||||
result.pending
|
||||
? `Coupon accepted. $${result.amountUsd.toFixed(2)} will be added after the required action.`
|
||||
: `Coupon redeemed! $${result.amountUsd.toFixed(2)} added to your credits.`
|
||||
);
|
||||
setCouponCode('');
|
||||
onBalanceRefresh();
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
? String((err as { error: unknown }).error)
|
||||
: 'Invalid or expired coupon code.';
|
||||
log('[coupon] error: %s', msg);
|
||||
setCouponError(msg);
|
||||
} finally {
|
||||
setCouponLoading(false);
|
||||
}
|
||||
const handleCustomTopUp = () => {
|
||||
if (!customTopUpAmountValid || isToppingUp) return;
|
||||
onTopUp(Number(customTopUpAmount));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900 mb-2">Pay as You Go</h3>
|
||||
|
||||
{/* Balance display */}
|
||||
{creditBalance ? (
|
||||
<div className="space-y-1.5 mb-3">
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-2.5 py-2">
|
||||
<span className="text-xs text-stone-500">Available credits</span>
|
||||
<span className="text-sm font-semibold text-stone-900">
|
||||
${availableCredits.toFixed(2)}
|
||||
</span>
|
||||
<>
|
||||
<div className="rounded-lg bg-white p-6 shadow-[0_24px_70px_rgba(15,23,42,0.06)] ring-1 ring-stone-950/5">
|
||||
<h3 className="font-headline text-xl font-bold tracking-tight text-stone-950">
|
||||
Your Credit Balance
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">
|
||||
You can top up your credits if you ever exhaust your monthly budget or hit rate limits.
|
||||
Credits are consumed after any included subscription budget is exhausted.
|
||||
</p>
|
||||
{creditBalance ? (
|
||||
<div className="grid mt-4 gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-400">Available</p>
|
||||
<p className="mt-2 text-2xl font-bold tracking-tight text-stone-600">
|
||||
${availableCredits.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-400">Promotional Credits</p>
|
||||
<p className="mt-2 text-xl font-bold tracking-tight text-stone-600">
|
||||
${promoCredits.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-400">Top-up Balance</p>
|
||||
<p className="mt-2 text-xl font-bold tracking-tight text-stone-600">
|
||||
${teamTopupCredits.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-stone-400">Signup + promo credits</span>
|
||||
<span className="text-xs font-medium text-stone-900">${promoCredits.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-stone-400">Team top-up credits</span>
|
||||
<span className="text-xs font-medium text-stone-900">
|
||||
${teamTopupCredits.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoadingCredits ? (
|
||||
<div className="space-y-1.5 mb-3">
|
||||
<div className="h-3 w-full rounded bg-stone-700/60 animate-pulse" />
|
||||
<div className="h-3 w-3/4 rounded bg-stone-700/60 animate-pulse" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-stone-500 mb-3">Unable to load balance</p>
|
||||
)}
|
||||
|
||||
<p className="mb-3 text-[11px] text-stone-500">
|
||||
Credits are used after any included subscription budget is consumed.
|
||||
</p>
|
||||
|
||||
{/* Top-up buttons */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
{[5, 10, 25].map(amount => (
|
||||
<button
|
||||
key={amount}
|
||||
onClick={() => onTopUp(amount)}
|
||||
disabled={isToppingUp}
|
||||
className="flex-1 py-1.5 rounded-lg bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 text-xs font-medium border border-primary-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{isToppingUp ? '…' : `+$${amount}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Coupon redemption */}
|
||||
<div className="border-t border-stone-100 pt-3">
|
||||
<p className="text-[11px] text-stone-400 mb-1.5">Have a coupon?</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={couponCode}
|
||||
onChange={e => {
|
||||
setCouponCode(e.target.value.toUpperCase());
|
||||
if (couponError) setCouponError(null);
|
||||
if (couponSuccess) setCouponSuccess(null);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleRedeemCoupon();
|
||||
}}
|
||||
placeholder="XXXX-XXXX"
|
||||
className="flex-1 px-2.5 py-1.5 text-xs rounded-lg border border-stone-200 bg-stone-50 text-stone-900 placeholder-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleRedeemCoupon}
|
||||
disabled={couponLoading || !couponCode.trim()}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{couponLoading ? '…' : 'Redeem'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{couponSuccess && (
|
||||
<div className="mt-2 flex items-center gap-1.5 rounded-lg bg-sage-500/10 border border-sage-500/20 px-2.5 py-1.5">
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-sage-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-[11px] text-sage-300 font-medium">{couponSuccess}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{couponError && (
|
||||
<div className="mt-2 flex items-center gap-1.5 rounded-lg bg-coral-500/10 border border-coral-500/20 px-2.5 py-1.5">
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-coral-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-[11px] text-coral-300">{couponError}</p>
|
||||
) : isLoadingCredits ? (
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
{[0, 1, 2].map(index => (
|
||||
<div key={index} className="h-24 rounded-2xl bg-stone-100 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-5 text-sm text-stone-500">Unable to load balance.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white p-6 shadow-[0_24px_70px_rgba(15,23,42,0.06)] ring-1 ring-stone-950/5">
|
||||
<h3 className="font-headline text-xl font-bold tracking-tight text-stone-950">
|
||||
Choose a Top-up Amount
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">
|
||||
Choose one of the preset amounts above or enter your own charge amount.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-3">
|
||||
{[5, 10, 25].map(amount => (
|
||||
<button
|
||||
key={amount}
|
||||
onClick={() => onTopUp(amount)}
|
||||
disabled={isToppingUp}
|
||||
className="group rounded-2xl border border-primary-200/50 bg-primary-50/50 px-4 py-5 text-center transition-all hover:border-primary-200 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<div className="text-2xl font-bold tracking-tight text-primary-600">
|
||||
{isToppingUp ? 'Opening…' : `$${amount.toFixed(2)}`}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] font-semibold text-stone-400">Top Up Credits</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="billing-custom-top-up"
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.24em] text-stone-400">
|
||||
Custom amount
|
||||
</label>
|
||||
<div className="mt-2 flex items-center rounded-2xl bg-white px-4 ring-1 ring-stone-200 focus-within:ring-2 focus-within:ring-primary-500/20">
|
||||
<span className="text-sm font-semibold text-stone-500">$</span>
|
||||
<input
|
||||
id="billing-custom-top-up"
|
||||
type="number"
|
||||
min="1"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={customTopUpAmount}
|
||||
onChange={e => setCustomTopUpAmount(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleCustomTopUp();
|
||||
}}
|
||||
placeholder="Enter amount"
|
||||
className="w-full border-0 bg-transparent px-3 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-stone-500">
|
||||
Choose one of the preset amounts above or enter your own charge amount.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCustomTopUp}
|
||||
disabled={!customTopUpAmountValid || isToppingUp}
|
||||
className="rounded-2xl bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50 lg:self-end">
|
||||
{isToppingUp ? 'Opening…' : 'Charge custom amount'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -25,212 +25,231 @@ const SubscriptionPlans = ({
|
||||
onUpgrade,
|
||||
}: SubscriptionPlansProps) => (
|
||||
<>
|
||||
{/* Interval toggle */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (paymentMethod !== 'crypto') setBillingInterval('monthly');
|
||||
}}
|
||||
disabled={paymentMethod === 'crypto'}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
billingInterval === 'monthly'
|
||||
? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
|
||||
: 'text-stone-500 hover:text-stone-700'
|
||||
} ${paymentMethod === 'crypto' ? 'opacity-40 cursor-not-allowed' : ''}`}>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingInterval('annual')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
billingInterval === 'annual'
|
||||
? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
|
||||
: 'text-stone-500 hover:text-stone-700'
|
||||
}`}>
|
||||
Annual
|
||||
</button>
|
||||
<div className="flex flex-col gap-2 rounded-2xl bg-white p-4 border border-stone-200">
|
||||
<h3 className="font-headline text-2xl font-bold tracking-tight text-stone-950">
|
||||
Choose a Subscription Plan
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">
|
||||
Compare plans, switch billing cadence, and choose a payment method.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-950">Pay using crypto?</p>
|
||||
<p className="mt-0.5 text-xs text-stone-500">
|
||||
You can optionally choose to pay annually using BTC/ETH/USDC.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPaymentMethod(paymentMethod === 'card' ? 'crypto' : 'card')}
|
||||
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||||
paymentMethod === 'crypto' ? 'bg-primary-600' : 'bg-stone-300'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={paymentMethod === 'crypto'}>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
|
||||
paymentMethod === 'crypto' ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan tier cards */}
|
||||
<div className="space-y-3">
|
||||
{PLANS.map(plan => {
|
||||
const isCurrent = plan.tier === currentTier;
|
||||
const isUpgrade = checkIsUpgrade(plan.tier, currentTier);
|
||||
const savings = annualSavings(plan, billingInterval);
|
||||
const isThisPurchasing = isPurchasing && purchasingTier === plan.tier;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.tier}
|
||||
className={`relative rounded-2xl border p-4 transition-all ${
|
||||
plan.recommended
|
||||
? 'border-primary-500 bg-primary-500/5 shadow-sm'
|
||||
: isCurrent
|
||||
? 'border-primary-500/40 bg-primary-500/5'
|
||||
: 'border-stone-200 bg-white'
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="mx-auto inline-flex w-fit rounded-full bg-white p-1 shadow-sm ring-1 ring-stone-950/5 lg:mx-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (paymentMethod !== 'crypto') setBillingInterval('monthly');
|
||||
}}
|
||||
disabled={paymentMethod === 'crypto'}
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
billingInterval === 'monthly'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-stone-500 hover:text-stone-900'
|
||||
} ${paymentMethod === 'crypto' ? 'cursor-not-allowed opacity-40' : ''}`}>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingInterval('annual')}
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
billingInterval === 'annual'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-stone-500 hover:text-stone-900'
|
||||
}`}>
|
||||
{/* Popular badge */}
|
||||
{plan.recommended && (
|
||||
<span className="absolute -top-2.5 left-4 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide rounded-full bg-primary-500 text-white">
|
||||
Popular
|
||||
</span>
|
||||
)}
|
||||
Annual
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header: name + tagline on left, price on right */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-stone-900">{plan.name}</h4>
|
||||
{plan.tagline && <p className="text-xs text-stone-400 mt-0.5">{plan.tagline}</p>}
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="flex items-baseline gap-0.5 justify-end">
|
||||
<span className="text-2xl font-bold text-stone-900">
|
||||
{displayPrice(plan, billingInterval)}
|
||||
</span>
|
||||
{plan.tier !== 'FREE' && <span className="text-xs text-stone-400">/mo</span>}
|
||||
</div>
|
||||
{plan.tier !== 'FREE' && billingInterval === 'annual' && (
|
||||
<p className="text-[11px] text-stone-400 mt-0.5">billed ${plan.annualPrice}/yr</p>
|
||||
)}
|
||||
{savings && (
|
||||
<span className="inline-block mt-1 px-2 py-0.5 text-[10px] font-medium rounded-full bg-sage-500/20 text-sage-500 border border-sage-500/30">
|
||||
Save {savings}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{paymentConfirmed && (
|
||||
<div className="rounded-2xl border border-sage-500/20 bg-sage-500/10 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4 flex-shrink-0 text-sage-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm font-medium text-sage-700">
|
||||
Payment confirmed! Your plan has been updated.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-stone-100 my-3" />
|
||||
{isPurchasing && (
|
||||
<div className="rounded-2xl border border-amber-500/20 bg-amber-100/90 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 animate-spin text-amber-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-amber-700">
|
||||
Waiting for payment confirmation... Complete checkout in the browser window that
|
||||
opened.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature list */}
|
||||
<ul className="space-y-2">
|
||||
{plan.features.map(f => (
|
||||
<li key={f.text} className="flex items-start gap-2">
|
||||
{f.included ? (
|
||||
<svg
|
||||
className="w-4 h-4 text-sage-500 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<div className="space-y-3">
|
||||
{PLANS.map(plan => {
|
||||
const isCurrent = plan.tier === currentTier;
|
||||
const isUpgrade = checkIsUpgrade(plan.tier, currentTier);
|
||||
const savings = annualSavings(plan, billingInterval);
|
||||
const isThisPurchasing = isPurchasing && purchasingTier === plan.tier;
|
||||
const isPopular = plan.recommended && billingInterval === 'annual';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.tier}
|
||||
className={`relative flex flex-col gap-5 rounded-[24px] px-5 py-5 transition-all sm:flex-row sm:items-center sm:justify-between ${
|
||||
isPopular
|
||||
? 'bg-primary-50 ring-2 ring-primary-500 shadow-sm'
|
||||
: isCurrent
|
||||
? 'bg-white ring-1 ring-primary-200 shadow-sm'
|
||||
: 'bg-white ring-1 ring-stone-950/5 shadow-sm'
|
||||
}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`flex h-12 w-12 min-h-12 min-w-12 flex-shrink-0 items-center justify-center rounded-full ${
|
||||
plan.recommended
|
||||
? 'bg-primary-600 text-white'
|
||||
: isCurrent
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'bg-stone-100 text-stone-700'
|
||||
}`}>
|
||||
{plan.tier === 'PRO' ? (
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2 9.2 8.5 2 9.2l5.4 4.7-1.6 7.1L12 17l6.2 4-1.6-7.1L22 9.2l-7.2-.7z" />
|
||||
</svg>
|
||||
) : plan.tier === 'BASIC' ? (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
d="M5 12h14M12 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-300 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
d="M12 12c2.761 0 5-2.239 5-5S14.761 2 12 2 7 4.239 7 7s2.239 5 5 5Zm0 2c-4.418 0-8 1.79-8 4v2h16v-2c0-2.21-3.582-4-8-4Z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className={`text-xs ${f.included ? 'text-stone-600' : 'text-stone-400'}`}>
|
||||
{f.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-4">
|
||||
{isCurrent ? (
|
||||
<div className="w-full py-2 text-center text-xs font-medium rounded-lg border border-primary-500/30 bg-primary-500/10 text-primary-500">
|
||||
Current Plan
|
||||
</div>
|
||||
) : isUpgrade ? (
|
||||
<button
|
||||
onClick={() => onUpgrade(plan.tier)}
|
||||
disabled={isPurchasing}
|
||||
className={`w-full py-2 text-xs font-medium rounded-lg transition-colors ${
|
||||
isPurchasing
|
||||
? 'bg-stone-200 text-stone-400 cursor-not-allowed'
|
||||
: plan.recommended
|
||||
? 'bg-primary-500 hover:bg-primary-600 text-white'
|
||||
: 'bg-stone-900 hover:bg-stone-800 text-white'
|
||||
}`}>
|
||||
{isThisPurchasing ? 'Waiting...' : 'Upgrade'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h4 className="font-headline text-xl font-bold tracking-tight text-stone-950">
|
||||
{plan.name}
|
||||
</h4>
|
||||
{isPopular && (
|
||||
<span className="rounded-full bg-primary-600 px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.24em] text-white">
|
||||
Popular
|
||||
</span>
|
||||
)}
|
||||
{isCurrent && !plan.recommended && (
|
||||
<span className="rounded-full bg-stone-950 px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.24em] text-white">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{plan.features.slice(0, 4).map(feature => (
|
||||
<span
|
||||
key={feature.text}
|
||||
className="rounded-full bg-stone-100/50 border border-primary-200/50 px-3 py-1 text-xs font-medium text-stone-600">
|
||||
{feature.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-2 sm:min-w-[148px] sm:flex-col sm:items-end">
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold tracking-tight text-stone-950">
|
||||
{displayPrice(plan, billingInterval)}
|
||||
{plan.tier !== 'FREE' && (
|
||||
<span className="text-sm font-medium text-stone-400">/mo</span>
|
||||
)}
|
||||
</p>
|
||||
{plan.tier !== 'FREE' && billingInterval === 'annual' && (
|
||||
<p className="mt-1 text-xs text-stone-500">Billed ${plan.annualPrice}/yr</p>
|
||||
)}
|
||||
{savings && (
|
||||
<p className="mt-1 text-xs font-semibold uppercase text-primary-600">
|
||||
Save {savings}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCurrent ? (
|
||||
<div className="rounded-full bg-primary-600 px-4 py-2 text-xs font-semibold text-white">
|
||||
Current Plan
|
||||
</div>
|
||||
) : isUpgrade ? (
|
||||
<button
|
||||
onClick={() => onUpgrade(plan.tier)}
|
||||
disabled={isPurchasing}
|
||||
className={`rounded-full px-4 py-2 text-xs font-semibold transition-colors ${
|
||||
isPurchasing
|
||||
? 'cursor-not-allowed bg-stone-200 text-stone-400'
|
||||
: 'bg-stone-950 text-white hover:bg-primary-600'
|
||||
}`}>
|
||||
{isThisPurchasing ? 'Waiting…' : 'Upgrade'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Payment confirmed banner */}
|
||||
{paymentConfirmed && (
|
||||
<div className="rounded-xl bg-sage-500/10 border border-sage-500/20 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-sage-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<p className="text-xs text-sage-300 font-medium">
|
||||
Payment confirmed! Your plan has been updated.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Purchasing overlay message */}
|
||||
{isPurchasing && (
|
||||
<div className="rounded-xl bg-amber-500/10 border border-amber-500/20 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-amber-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-xs text-amber-700">
|
||||
Waiting for payment confirmation... Complete checkout in the browser window that opened.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pay with crypto toggle */}
|
||||
<div className="flex items-center justify-between rounded-xl bg-stone-50 border border-stone-200 p-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-stone-900">Pay with Crypto</p>
|
||||
<p className="text-[11px] text-stone-400 mt-0.5">
|
||||
You can choose to pay annually using crypto
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPaymentMethod(paymentMethod === 'card' ? 'crypto' : 'card')}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors ${
|
||||
paymentMethod === 'crypto' ? 'bg-primary-500' : 'bg-stone-600'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={paymentMethod === 'crypto'}>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
paymentMethod === 'crypto' ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -32,17 +32,17 @@ export const PLANS: PlanMeta[] = [
|
||||
discountPercent: 0,
|
||||
tagline: 'Get started at no cost',
|
||||
features: [
|
||||
{ text: 'Access to all integrations and inference', included: true },
|
||||
{ text: 'One-time signup credits when available', included: true },
|
||||
{ text: 'Pay-as-you-go top-ups', included: true },
|
||||
{ text: 'No subscription discount', included: false },
|
||||
{ text: 'Access to Everything', included: true },
|
||||
{ text: 'Heavy Rate Limits', included: true },
|
||||
{ text: 'Pay-as-you-go', included: true },
|
||||
{ text: 'No discounts', included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
tier: 'BASIC',
|
||||
name: 'Basic',
|
||||
monthlyPrice: 20,
|
||||
annualPrice: 200,
|
||||
monthlyPrice: 19.99,
|
||||
annualPrice: 199,
|
||||
monthlyBudgetUsd: 20,
|
||||
weeklyBudgetUsd: 10,
|
||||
fiveHourCapUsd: 3,
|
||||
@@ -50,25 +50,25 @@ export const PLANS: PlanMeta[] = [
|
||||
recommended: true,
|
||||
tagline: 'Best value for most users',
|
||||
features: [
|
||||
{ text: '$20/mo in premium usage included', included: true },
|
||||
{ text: '20% discount on all premium usage', included: true },
|
||||
{ text: 'Pay-as-you-go top-ups for overflow', included: true },
|
||||
{ text: 'Everything in Free', included: true },
|
||||
{ text: '20x more usage', included: true },
|
||||
{ text: 'Cloud features enabled', included: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
tier: 'PRO',
|
||||
name: 'Pro',
|
||||
monthlyPrice: 200,
|
||||
annualPrice: 2000,
|
||||
monthlyBudgetUsd: 200,
|
||||
weeklyBudgetUsd: 100,
|
||||
monthlyPrice: 199.99,
|
||||
annualPrice: 1799.99,
|
||||
monthlyBudgetUsd: 199,
|
||||
weeklyBudgetUsd: 99,
|
||||
fiveHourCapUsd: 30,
|
||||
discountPercent: 40,
|
||||
tagline: 'For power users and teams',
|
||||
features: [
|
||||
{ text: '$200/mo in premium usage included', included: true },
|
||||
{ text: '40% discount on all premium usage', included: true },
|
||||
{ text: 'Best fit for heavy bandwidth and agents', included: true },
|
||||
{ text: 'Everything in Basic', included: true },
|
||||
{ text: '40x more usage', included: true },
|
||||
{ text: 'Higher Rate Limits', included: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
export type SkillCategory =
|
||||
| 'All'
|
||||
| 'Built-in'
|
||||
| 'Channels'
|
||||
| 'Productivity'
|
||||
| 'Chat'
|
||||
| 'Tools & Automation'
|
||||
| 'Social'
|
||||
| 'Platform'
|
||||
| 'Other';
|
||||
import PillTabBar from '../PillTabBar';
|
||||
import type { SkillCategory } from './skillCategories';
|
||||
import {
|
||||
skillCategoryChipClassName,
|
||||
SkillCategoryIcon,
|
||||
skillCategoryIconClassName,
|
||||
} from './skillIcons';
|
||||
|
||||
interface SkillCategoryFilterProps {
|
||||
categories: SkillCategory[];
|
||||
@@ -15,28 +12,27 @@ interface SkillCategoryFilterProps {
|
||||
onChange: (category: SkillCategory) => void;
|
||||
}
|
||||
|
||||
const SkillCategoryFilter = ({
|
||||
categories,
|
||||
selected,
|
||||
onChange,
|
||||
}: SkillCategoryFilterProps) => {
|
||||
const SkillCategoryFilter = ({ categories, selected, onChange }: SkillCategoryFilterProps) => {
|
||||
return (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
aria-pressed={selected === cat}
|
||||
onClick={() => onChange(cat)}
|
||||
className={`flex-shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
|
||||
selected === cat
|
||||
? 'bg-primary-50 text-primary-700 border-primary-200'
|
||||
: 'bg-white text-stone-600 border-stone-200 hover:bg-stone-50'
|
||||
}`}>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<PillTabBar
|
||||
items={categories.map(category => ({ label: category, value: category }))}
|
||||
selected={selected}
|
||||
onChange={onChange}
|
||||
renderItem={(item, active) => (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${
|
||||
active ? skillCategoryChipClassName(item.value) : 'bg-stone-100 text-stone-500'
|
||||
}`}>
|
||||
<SkillCategoryIcon
|
||||
category={item.value}
|
||||
className={active ? skillCategoryIconClassName(item.value) : 'text-stone-500'}
|
||||
/>
|
||||
</span>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export type SkillCategory =
|
||||
| 'All'
|
||||
| 'Built-in'
|
||||
| 'Channels'
|
||||
| 'Productivity'
|
||||
| 'Chat'
|
||||
| 'Tools & Automation'
|
||||
| 'Social'
|
||||
| 'Platform'
|
||||
| 'Other';
|
||||
|
||||
export const SKILL_CATEGORY_ORDER: SkillCategory[] = [
|
||||
'All',
|
||||
'Built-in',
|
||||
'Channels',
|
||||
'Chat',
|
||||
'Productivity',
|
||||
'Tools & Automation',
|
||||
'Social',
|
||||
'Platform',
|
||||
'Other',
|
||||
];
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { IconType } from 'react-icons';
|
||||
import { FaDiscord, FaGlobe, FaTelegramPlane } from 'react-icons/fa';
|
||||
import {
|
||||
LuBlocks,
|
||||
LuBot,
|
||||
LuKeyboard,
|
||||
LuMessageSquareMore,
|
||||
LuMic,
|
||||
LuMonitor,
|
||||
LuPlugZap,
|
||||
LuShare2,
|
||||
LuSparkles,
|
||||
LuWrench,
|
||||
} from 'react-icons/lu';
|
||||
|
||||
import type { SkillCategory } from './skillCategories';
|
||||
|
||||
function iconClasses(...parts: Array<string | undefined>): string {
|
||||
return parts.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function SkillIconBadge({
|
||||
icon: Icon,
|
||||
label,
|
||||
bgClassName,
|
||||
iconClassName,
|
||||
className,
|
||||
}: {
|
||||
icon: IconType;
|
||||
label: string;
|
||||
bgClassName: string;
|
||||
iconClassName: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className={iconClasses(
|
||||
'flex h-8 w-8 items-center justify-center rounded-xl shadow-sm ring-1 ring-black/5',
|
||||
bgClassName,
|
||||
className
|
||||
)}>
|
||||
<Icon className={iconClasses('h-[18px] w-[18px]', iconClassName)} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const CHANNEL_ICONS: Record<string, ReactNode> = {
|
||||
telegram: (
|
||||
<SkillIconBadge
|
||||
icon={FaTelegramPlane}
|
||||
label="Telegram"
|
||||
bgClassName="bg-[#E7F4FB]"
|
||||
iconClassName="text-[#249CD8]"
|
||||
/>
|
||||
),
|
||||
discord: (
|
||||
<SkillIconBadge
|
||||
icon={FaDiscord}
|
||||
label="Discord"
|
||||
bgClassName="bg-[#EEF2FF]"
|
||||
iconClassName="text-[#5865F2]"
|
||||
/>
|
||||
),
|
||||
web: (
|
||||
<SkillIconBadge
|
||||
icon={FaGlobe}
|
||||
label="Web"
|
||||
bgClassName="bg-stone-100"
|
||||
iconClassName="text-stone-600"
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const CATEGORY_META: Record<
|
||||
SkillCategory,
|
||||
{ icon: IconType; chipClassName: string; iconClassName: string; headingClassName: string }
|
||||
> = {
|
||||
All: {
|
||||
icon: LuBlocks,
|
||||
chipClassName: 'bg-stone-100 text-stone-600',
|
||||
iconClassName: 'text-stone-500',
|
||||
headingClassName: 'text-stone-500',
|
||||
},
|
||||
'Built-in': {
|
||||
icon: LuSparkles,
|
||||
chipClassName: 'bg-primary-50 text-primary-700',
|
||||
iconClassName: 'text-primary-600',
|
||||
headingClassName: 'text-primary-600',
|
||||
},
|
||||
Channels: {
|
||||
icon: LuMessageSquareMore,
|
||||
chipClassName: 'bg-sky-50 text-sky-700',
|
||||
iconClassName: 'text-sky-600',
|
||||
headingClassName: 'text-sky-600',
|
||||
},
|
||||
Productivity: {
|
||||
icon: LuBot,
|
||||
chipClassName: 'bg-emerald-50 text-emerald-700',
|
||||
iconClassName: 'text-emerald-600',
|
||||
headingClassName: 'text-emerald-600',
|
||||
},
|
||||
Chat: {
|
||||
icon: LuShare2,
|
||||
chipClassName: 'bg-violet-50 text-violet-700',
|
||||
iconClassName: 'text-violet-600',
|
||||
headingClassName: 'text-violet-600',
|
||||
},
|
||||
'Tools & Automation': {
|
||||
icon: LuWrench,
|
||||
chipClassName: 'bg-amber-50 text-amber-700',
|
||||
iconClassName: 'text-amber-600',
|
||||
headingClassName: 'text-amber-600',
|
||||
},
|
||||
Social: {
|
||||
icon: LuPlugZap,
|
||||
chipClassName: 'bg-rose-50 text-rose-700',
|
||||
iconClassName: 'text-rose-600',
|
||||
headingClassName: 'text-rose-600',
|
||||
},
|
||||
Platform: {
|
||||
icon: LuShare2,
|
||||
chipClassName: 'bg-cyan-50 text-cyan-700',
|
||||
iconClassName: 'text-cyan-600',
|
||||
headingClassName: 'text-cyan-600',
|
||||
},
|
||||
Other: {
|
||||
icon: LuBlocks,
|
||||
chipClassName: 'bg-stone-100 text-stone-700',
|
||||
iconClassName: 'text-stone-500',
|
||||
headingClassName: 'text-stone-500',
|
||||
},
|
||||
};
|
||||
|
||||
export function SkillCategoryIcon({
|
||||
category,
|
||||
className,
|
||||
}: {
|
||||
category: SkillCategory;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = CATEGORY_META[category].icon;
|
||||
return <Icon className={iconClasses('h-3.5 w-3.5', className)} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
export function skillCategoryChipClassName(category: SkillCategory): string {
|
||||
return CATEGORY_META[category].chipClassName;
|
||||
}
|
||||
|
||||
export function skillCategoryIconClassName(category: SkillCategory): string {
|
||||
return CATEGORY_META[category].iconClassName;
|
||||
}
|
||||
|
||||
export function skillCategoryHeadingClassName(category: SkillCategory): string {
|
||||
return CATEGORY_META[category].headingClassName;
|
||||
}
|
||||
|
||||
export const BUILT_IN_SKILL_ICONS = {
|
||||
screenIntelligence: <LuMonitor className="h-5 w-5" aria-hidden="true" />,
|
||||
textAutocomplete: <LuKeyboard className="h-5 w-5" aria-hidden="true" />,
|
||||
voiceStt: <LuMic className="h-5 w-5" aria-hidden="true" />,
|
||||
};
|
||||
@@ -51,6 +51,7 @@ describe('useUsageState', () => {
|
||||
|
||||
expect(result.current.isFreeTier).toBe(true);
|
||||
expect(result.current.isBudgetExhausted).toBe(false);
|
||||
expect(result.current.shouldShowBudgetCompletedMessage).toBe(true);
|
||||
expect(result.current.isRateLimited).toBe(false);
|
||||
expect(result.current.isAtLimit).toBe(false);
|
||||
expect(result.current.usagePct7d).toBe(0);
|
||||
@@ -92,7 +93,42 @@ describe('useUsageState', () => {
|
||||
});
|
||||
|
||||
expect(result.current.isBudgetExhausted).toBe(true);
|
||||
expect(result.current.shouldShowBudgetCompletedMessage).toBe(true);
|
||||
expect(result.current.isAtLimit).toBe(true);
|
||||
expect(result.current.usagePct7d).toBe(1);
|
||||
});
|
||||
|
||||
it('does not show the completed-budget message when credits remain without a recurring budget', async () => {
|
||||
const { useUsageState } = await import('./useUsageState');
|
||||
|
||||
mockGetCurrentPlan.mockResolvedValue({
|
||||
plan: 'FREE',
|
||||
hasActiveSubscription: false,
|
||||
planExpiry: null,
|
||||
subscription: null,
|
||||
monthlyBudgetUsd: 0,
|
||||
weeklyBudgetUsd: 0,
|
||||
fiveHourCapUsd: 0,
|
||||
});
|
||||
mockGetTeamUsage.mockResolvedValue({
|
||||
remainingUsd: 7,
|
||||
cycleBudgetUsd: 0,
|
||||
cycleLimit5hr: 0,
|
||||
cycleLimit7day: 0,
|
||||
fiveHourCapUsd: 0,
|
||||
fiveHourResetsAt: null,
|
||||
cycleStartDate: '2026-04-09T00:00:00.000Z',
|
||||
cycleEndsAt: '2026-04-16T00:00:00.000Z',
|
||||
bypassCycleLimit: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUsageState());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.isBudgetExhausted).toBe(false);
|
||||
expect(result.current.shouldShowBudgetCompletedMessage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface UsageState {
|
||||
isAtLimit: boolean;
|
||||
isRateLimited: boolean;
|
||||
isBudgetExhausted: boolean;
|
||||
shouldShowBudgetCompletedMessage: boolean;
|
||||
isLoading: boolean;
|
||||
refresh: () => void;
|
||||
}
|
||||
@@ -86,6 +87,13 @@ export function useUsageState(): UsageState {
|
||||
? teamUsage.cycleBudgetUsd > 0 && teamUsage.remainingUsd <= 0
|
||||
: false;
|
||||
|
||||
// Some users have no included recurring budget at all. They still need the
|
||||
// completed-budget warning in chat even though they are not in an exhausted
|
||||
// paid cycle.
|
||||
const shouldShowBudgetCompletedMessage = teamUsage
|
||||
? isBudgetExhausted || (teamUsage.cycleBudgetUsd <= 0 && teamUsage.remainingUsd <= 0)
|
||||
: false;
|
||||
|
||||
const isRateLimited =
|
||||
teamUsage !== null &&
|
||||
!teamUsage.bypassCycleLimit &&
|
||||
@@ -107,6 +115,7 @@ export function useUsageState(): UsageState {
|
||||
isAtLimit,
|
||||
isRateLimited,
|
||||
isBudgetExhausted,
|
||||
shouldShowBudgetCompletedMessage,
|
||||
isLoading,
|
||||
refresh,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { listConnections, listToolkits } from './composioApi';
|
||||
import { canonicalizeComposioToolkitSlug } from './toolkitSlug';
|
||||
import type { ComposioConnection } from './types';
|
||||
|
||||
// ── useComposioIntegrations ───────────────────────────────────────
|
||||
@@ -113,7 +114,7 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte
|
||||
return 0;
|
||||
};
|
||||
for (const conn of connections) {
|
||||
const key = conn.toolkit.toLowerCase();
|
||||
const key = canonicalizeComposioToolkitSlug(conn.toolkit);
|
||||
const existing = map.get(key);
|
||||
if (!existing || score(conn.status) > score(existing.status)) {
|
||||
map.set(key, conn);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
const TOOLKIT_ALIASES: Record<string, string> = {
|
||||
google_calendar: 'googlecalendar',
|
||||
google_drive: 'googledrive',
|
||||
google_sheets: 'googlesheets',
|
||||
};
|
||||
|
||||
export function canonicalizeComposioToolkitSlug(slug: string): string {
|
||||
const key = slug.toLowerCase();
|
||||
return TOOLKIT_ALIASES[key] ?? key;
|
||||
}
|
||||
@@ -233,8 +233,10 @@ const Conversations = () => {
|
||||
isLoading: isLoadingBudget,
|
||||
isAtLimit,
|
||||
isBudgetExhausted,
|
||||
isRateLimited,
|
||||
isNearLimit,
|
||||
isFreeTier,
|
||||
shouldShowBudgetCompletedMessage,
|
||||
usagePct10h,
|
||||
usagePct7d,
|
||||
currentTier,
|
||||
@@ -1367,40 +1369,38 @@ const Conversations = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{teamUsage &&
|
||||
((teamUsage.cycleBudgetUsd > 0 && teamUsage.remainingUsd <= 0) ||
|
||||
(!teamUsage.bypassCycleLimit &&
|
||||
teamUsage.fiveHourCapUsd > 0 &&
|
||||
teamUsage.cycleLimit5hr >= teamUsage.fiveHourCapUsd)) && (
|
||||
<div className="mb-3 p-3 rounded-xl bg-coral-50 border border-coral-200 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<svg
|
||||
className="w-4 h-4 text-coral-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-xs text-coral-600 truncate">
|
||||
{teamUsage.cycleBudgetUsd > 0 && teamUsage.remainingUsd <= 0
|
||||
{teamUsage && (shouldShowBudgetCompletedMessage || isRateLimited) && (
|
||||
<div className="mb-3 p-3 rounded-xl bg-coral-50 border border-coral-200 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<svg
|
||||
className="w-4 h-4 text-coral-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-xs text-coral-600 truncate">
|
||||
{shouldShowBudgetCompletedMessage
|
||||
? teamUsage.cycleBudgetUsd > 0
|
||||
? `You've hit your weekly limit.${teamUsage.cycleEndsAt ? ` Resets ${formatResetTime(teamUsage.cycleEndsAt)}.` : ''} Top up to continue.`
|
||||
: `10-hour rate limit reached.${teamUsage.fiveHourResetsAt ? ` Resets ${formatResetTime(teamUsage.fiveHourResetsAt)}.` : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
{teamUsage.cycleBudgetUsd > 0 && teamUsage.remainingUsd <= 0 && (
|
||||
<button
|
||||
onClick={() => navigate('/settings/billing')}
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-lg bg-coral-500 hover:bg-coral-400 text-white text-xs font-medium transition-colors">
|
||||
Top Up
|
||||
</button>
|
||||
)}
|
||||
: 'Your included budget is complete. Add credits or upgrade to continue.'
|
||||
: `10-hour rate limit reached.${teamUsage.fiveHourResetsAt ? ` Resets ${formatResetTime(teamUsage.fiveHourResetsAt)}.` : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{shouldShowBudgetCompletedMessage && (
|
||||
<button
|
||||
onClick={() => navigate('/settings/billing')}
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-lg bg-coral-500 hover:bg-coral-400 text-white text-xs font-medium transition-colors">
|
||||
Top Up
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 mb-2">
|
||||
{(isLoadingBudget || teamUsage) && (
|
||||
|
||||
+64
-544
@@ -1,10 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ActionableCard } from '../components/intelligence/ActionableCard';
|
||||
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
|
||||
import IntelligenceDreamsTab from '../components/intelligence/IntelligenceDreamsTab';
|
||||
import IntelligenceMemoryTab from '../components/intelligence/IntelligenceMemoryTab';
|
||||
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import { filterItems, getItemStats, groupItemsByTime } from '../components/intelligence/utils';
|
||||
import PillTabBar from '../components/PillTabBar';
|
||||
import { useConsciousItems } from '../hooks/useConsciousItems';
|
||||
import {
|
||||
useSnoozeActionableItem,
|
||||
@@ -27,15 +29,7 @@ import type {
|
||||
|
||||
type IntelligenceTab = 'memory' | 'subconscious' | 'dreams';
|
||||
|
||||
const SKILL_KEYWORDS =
|
||||
/\bskill\b|\boauth\b|\bnotion\b|\bgmail\b|\bintegration\b|\bdisconnect|\breconnect|\bre-?auth/i;
|
||||
|
||||
function isSkillRelated(title: string, description: string): boolean {
|
||||
return SKILL_KEYWORDS.test(title) || SKILL_KEYWORDS.test(description);
|
||||
}
|
||||
|
||||
export default function Intelligence() {
|
||||
const navigate = useNavigate();
|
||||
const { aiStatus } = useIntelligenceStats();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<IntelligenceTab>('memory');
|
||||
@@ -273,7 +267,32 @@ export default function Intelligence() {
|
||||
|
||||
return (
|
||||
<div className="min-h-full p-4 pt-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="max-w-2xl mx-auto space-y-4">
|
||||
<PillTabBar
|
||||
items={tabs.map(tab => ({ label: tab.label, value: tab.id }))}
|
||||
selected={activeTab}
|
||||
onChange={setActiveTab}
|
||||
activeClassName="border-primary-600 bg-primary-600 text-white"
|
||||
renderItem={(item, active) => {
|
||||
const tab = tabs.find(entry => entry.id === item.value);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span>{item.label}</span>
|
||||
{tab?.comingSoon && (
|
||||
<span
|
||||
className={`rounded-full border px-1.5 py-0.5 text-[10px] ${
|
||||
active
|
||||
? 'border-white/30 bg-white/15 text-white'
|
||||
: 'border-stone-200 bg-stone-50 text-stone-500'
|
||||
}`}>
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6">
|
||||
<div>
|
||||
{/* Header */}
|
||||
@@ -320,546 +339,47 @@ export default function Intelligence() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-stone-200 mb-6">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'text-primary-400 border-b-2 border-primary-400'
|
||||
: 'text-stone-400 hover:text-stone-700'
|
||||
}`}>
|
||||
{tab.label}
|
||||
{tab.comingSoon && (
|
||||
<span className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded-full bg-stone-50 text-stone-500 border border-stone-200">
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'memory' && (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 mb-6 animate-fade-up">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search actionable items..."
|
||||
value={searchFilter}
|
||||
onChange={e => setSearchFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={e => setSourceFilter(e.target.value as ActionableItemSource | 'all')}
|
||||
className="px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:border-primary-500/50 transition-colors">
|
||||
<option value="all">All Sources</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="calendar">Calendar</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="ai_insight">AI Insights</option>
|
||||
<option value="system">System</option>
|
||||
<option value="trading">Trading</option>
|
||||
<option value="security">Security</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{itemsLoading && !usingMemoryData ? (
|
||||
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
|
||||
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-stone-900 mb-2">
|
||||
Loading Intelligence...
|
||||
</h2>
|
||||
<p className="text-stone-400 text-sm">Fetching your actionable items</p>
|
||||
</div>
|
||||
) : isRunning && items.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
|
||||
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-stone-900 mb-2">
|
||||
Analyzing your data…
|
||||
</h2>
|
||||
<p className="text-stone-400 text-sm">
|
||||
The conscious loop is reviewing your connected skills
|
||||
</p>
|
||||
</div>
|
||||
) : timeGroups.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
|
||||
<svg
|
||||
className="w-8 h-8 text-primary-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{searchFilter || sourceFilter !== 'all' ? (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-stone-900 mb-2">No matches</h2>
|
||||
<p className="text-stone-400 text-sm">
|
||||
No items match your current filters.
|
||||
</p>
|
||||
</>
|
||||
) : usingMemoryData ? (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-stone-900 mb-2">
|
||||
All caught up!
|
||||
</h2>
|
||||
<p className="text-stone-400 text-sm">No actionable items at the moment.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-stone-900 mb-2">
|
||||
No analysis yet
|
||||
</h2>
|
||||
<p className="text-stone-400 text-sm mb-4">
|
||||
Run an analysis to extract actionable items from your connected skills.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleAnalyzeNow}
|
||||
disabled={isRunning}
|
||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-40 text-white text-sm rounded-lg transition-colors">
|
||||
Analyze Now
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2 text-xs text-stone-400 animate-fade-up">
|
||||
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
|
||||
Analyzing your data…
|
||||
</div>
|
||||
)}
|
||||
{timeGroups.map((group, groupIndex) => (
|
||||
<div
|
||||
key={group.label}
|
||||
className="animate-fade-up"
|
||||
style={{ animationDelay: `${groupIndex * 50}ms` }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-stone-900 opacity-80">
|
||||
{group.label}
|
||||
</h2>
|
||||
<div className="text-xs bg-stone-100 text-stone-900 px-2 py-1 rounded-full">
|
||||
{group.count}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{group.items.map((item, itemIndex) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="animate-fade-up"
|
||||
style={{ animationDelay: `${groupIndex * 50 + itemIndex * 25}ms` }}>
|
||||
<ActionableCard
|
||||
item={item}
|
||||
onComplete={handleComplete}
|
||||
onDismiss={handleDismiss}
|
||||
onSnooze={handleSnooze}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<IntelligenceMemoryTab
|
||||
handleAnalyzeNow={handleAnalyzeNow}
|
||||
handleComplete={handleComplete}
|
||||
handleDismiss={handleDismiss}
|
||||
handleSnooze={handleSnooze}
|
||||
isRunning={isRunning}
|
||||
items={items}
|
||||
itemsLoading={itemsLoading}
|
||||
searchFilter={searchFilter}
|
||||
setSearchFilter={setSearchFilter}
|
||||
setSourceFilter={setSourceFilter}
|
||||
sourceFilter={sourceFilter}
|
||||
timeGroups={timeGroups}
|
||||
usingMemoryData={usingMemoryData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'subconscious' && (
|
||||
<div className="space-y-6 animate-fade-up">
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-stone-400">
|
||||
{subconsciousEngineStatus && (
|
||||
<>
|
||||
<span>{subconsciousEngineStatus.task_count} tasks</span>
|
||||
<span className="text-stone-300">|</span>
|
||||
<span>{subconsciousEngineStatus.total_ticks} ticks</span>
|
||||
{subconsciousEngineStatus.last_tick_at && (
|
||||
<>
|
||||
<span className="text-stone-300">|</span>
|
||||
<span>
|
||||
Last:{' '}
|
||||
{new Date(
|
||||
subconsciousEngineStatus.last_tick_at * 1000
|
||||
).toLocaleTimeString()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{subconsciousEngineStatus.consecutive_failures > 0 && (
|
||||
<>
|
||||
<span className="text-stone-300">|</span>
|
||||
<span className="text-coral-500">
|
||||
{subconsciousEngineStatus.consecutive_failures} failed
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Interval selector */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg
|
||||
className="w-3 h-3 text-stone-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<select
|
||||
value={subconsciousEngineStatus?.interval_minutes ?? 5}
|
||||
onChange={() => {
|
||||
// Config update would require restart — show as read-only for now
|
||||
}}
|
||||
disabled
|
||||
title="Tick interval (change in Settings > Advanced)"
|
||||
className="text-xs bg-stone-50 border border-stone-200 rounded px-1.5 py-0.5 text-stone-500 cursor-not-allowed">
|
||||
<option value={5}>5 min</option>
|
||||
<option value={10}>10 min</option>
|
||||
<option value={15}>15 min</option>
|
||||
<option value={30}>30 min</option>
|
||||
<option value={60}>1 hour</option>
|
||||
<option value={360}>6 hours</option>
|
||||
<option value={720}>12 hours</option>
|
||||
<option value={1440}>1 day</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={triggerTick}
|
||||
disabled={subconsciousTriggering}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-stone-50 hover:bg-stone-100 disabled:opacity-40 border border-stone-200 rounded-lg text-stone-600 transition-colors">
|
||||
{subconsciousTriggering ? (
|
||||
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
Run Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Escalations — needs user input */}
|
||||
{escalations.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900 mb-3 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
Approval Needed
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
|
||||
{escalations.length}
|
||||
</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{escalations.map(esc => (
|
||||
<div
|
||||
key={esc.id}
|
||||
className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-stone-900">{esc.title}</p>
|
||||
<p className="text-xs text-stone-500 mt-1">{esc.description}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full ${
|
||||
esc.priority === 'critical'
|
||||
? 'bg-coral-100 text-coral-700'
|
||||
: esc.priority === 'important'
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-stone-100 text-stone-600'
|
||||
}`}>
|
||||
{esc.priority}
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-400">
|
||||
Requires your approval to proceed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-3 flex-shrink-0">
|
||||
{isSkillRelated(esc.title, esc.description) ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
dismissEscalation(esc.id);
|
||||
navigate('/skills');
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors">
|
||||
Fix in Skills
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => approveEscalation(esc.id)}
|
||||
className="px-3 py-1.5 text-xs bg-sage-500 hover:bg-sage-600 text-white rounded-lg transition-colors">
|
||||
Go ahead
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => dismissEscalation(esc.id)}
|
||||
className="px-3 py-1.5 text-xs bg-stone-100 hover:bg-stone-200 text-stone-600 rounded-lg transition-colors">
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active tasks */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900 mb-3">Active Tasks</h3>
|
||||
{subconsciousLoading && subconsciousTasks.length === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="w-6 h-6 mx-auto border-2 border-stone-300 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : subconsciousTasks.filter(t => !t.completed).length === 0 ? (
|
||||
<p className="text-xs text-stone-400 py-3">No active tasks. Add one below.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{/* System tasks — always-on, no controls */}
|
||||
{subconsciousTasks
|
||||
.filter(t => !t.completed && t.source === 'system')
|
||||
.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center py-2 px-3 bg-stone-50 rounded-lg">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-sage-400 flex-shrink-0 mr-2.5" />
|
||||
<span className="text-sm text-stone-900 truncate flex-1">
|
||||
{task.title}
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-400 flex-shrink-0 px-1.5 py-0.5 rounded bg-stone-100">
|
||||
default
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* User tasks — toggle switch + delete */}
|
||||
{subconsciousTasks
|
||||
.filter(t => !t.completed && t.source !== 'system')
|
||||
.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center justify-between py-2 px-3 bg-stone-50 rounded-lg group">
|
||||
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
<button
|
||||
onClick={() => toggleSubconsciousTask(task.id, !task.enabled)}
|
||||
className={`relative w-7 h-4 rounded-full flex-shrink-0 transition-colors ${
|
||||
task.enabled ? 'bg-sage-500' : 'bg-stone-300'
|
||||
}`}>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform ${
|
||||
task.enabled ? 'translate-x-3' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
className={`text-sm truncate ${task.enabled ? 'text-stone-900' : 'text-stone-400'}`}>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeSubconsciousTask(task.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-stone-400 hover:text-coral-500 transition-all">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add task */}
|
||||
<form
|
||||
onSubmit={async e => {
|
||||
e.preventDefault();
|
||||
const title = newTaskTitle.trim();
|
||||
if (!title) return;
|
||||
try {
|
||||
await addSubconsciousTask(title);
|
||||
setNewTaskTitle('');
|
||||
} catch {
|
||||
// handled by hook
|
||||
}
|
||||
}}
|
||||
className="flex gap-2 mt-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a task... (e.g. 'Check urgent emails')"
|
||||
value={newTaskTitle}
|
||||
onChange={e => setNewTaskTitle(e.target.value)}
|
||||
className="flex-1 px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newTaskTitle.trim()}
|
||||
className="px-3 py-2 text-sm bg-primary-500 hover:bg-primary-600 disabled:opacity-40 text-white rounded-lg transition-colors">
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Execution log */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900 mb-3">Activity Log</h3>
|
||||
{logEntries.length === 0 ? (
|
||||
<p className="text-xs text-stone-400 py-3">
|
||||
No activity yet. Run a tick to see results.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{logEntries.map(entry => (
|
||||
<div key={entry.id} className="flex items-start gap-2 py-1.5 px-2 text-xs">
|
||||
<span className="text-stone-400 flex-shrink-0 w-14">
|
||||
{new Date(entry.tick_at * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={`flex-shrink-0 w-1.5 h-1.5 rounded-full mt-1.5 ${
|
||||
entry.decision === 'act'
|
||||
? 'bg-sage-400'
|
||||
: entry.decision === 'in_progress'
|
||||
? 'bg-primary-400 animate-pulse'
|
||||
: entry.decision === 'escalate'
|
||||
? 'bg-amber-400'
|
||||
: entry.decision === 'failed'
|
||||
? 'bg-coral-400'
|
||||
: entry.decision === 'cancelled'
|
||||
? 'bg-stone-300'
|
||||
: entry.decision === 'dismissed'
|
||||
? 'bg-stone-300'
|
||||
: 'bg-stone-200'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`break-words min-w-0 ${
|
||||
entry.decision === 'in_progress'
|
||||
? 'text-stone-400'
|
||||
: entry.decision === 'failed'
|
||||
? 'text-coral-500'
|
||||
: 'text-stone-600'
|
||||
} ${entry.result && entry.result.length > 120 ? 'cursor-pointer hover:text-stone-900' : ''}`}
|
||||
onClick={() => {
|
||||
if (entry.result && entry.result.length > 120) {
|
||||
setExpandedLogIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(entry.id)) next.delete(entry.id);
|
||||
else next.add(entry.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}>
|
||||
{entry.result
|
||||
? expandedLogIds.has(entry.id)
|
||||
? entry.result
|
||||
: entry.result.length > 120
|
||||
? `${entry.result.substring(0, 120)}...`
|
||||
: entry.result
|
||||
: entry.decision === 'noop'
|
||||
? 'Nothing new'
|
||||
: entry.decision === 'act'
|
||||
? 'Completed'
|
||||
: entry.decision === 'in_progress'
|
||||
? 'Evaluating...'
|
||||
: entry.decision === 'escalate'
|
||||
? 'Waiting for approval'
|
||||
: entry.decision === 'failed'
|
||||
? 'Failed'
|
||||
: entry.decision === 'cancelled'
|
||||
? 'Cancelled'
|
||||
: entry.decision === 'dismissed'
|
||||
? 'Skipped'
|
||||
: entry.decision}
|
||||
</span>
|
||||
{entry.duration_ms != null && (
|
||||
<span className="text-stone-300 flex-shrink-0 ml-auto">
|
||||
{entry.duration_ms > 1000
|
||||
? `${(entry.duration_ms / 1000).toFixed(1)}s`
|
||||
: `${entry.duration_ms}ms`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<IntelligenceSubconsciousTab
|
||||
addSubconsciousTask={addSubconsciousTask}
|
||||
approveEscalation={approveEscalation}
|
||||
dismissEscalation={dismissEscalation}
|
||||
escalations={escalations}
|
||||
expandedLogIds={expandedLogIds}
|
||||
loading={subconsciousLoading}
|
||||
logEntries={logEntries}
|
||||
newTaskTitle={newTaskTitle}
|
||||
removeSubconsciousTask={removeSubconsciousTask}
|
||||
setExpandedLogIds={setExpandedLogIds}
|
||||
setNewTaskTitle={setNewTaskTitle}
|
||||
status={subconsciousEngineStatus}
|
||||
tasks={subconsciousTasks}
|
||||
toggleSubconsciousTask={toggleSubconsciousTask}
|
||||
triggerTick={triggerTick}
|
||||
triggering={subconsciousTriggering}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'dreams' && (
|
||||
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-sky-500/10">
|
||||
<svg
|
||||
className="w-8 h-8 text-sky-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-stone-900 mb-2">Dreams</h2>
|
||||
<p className="text-stone-400 text-sm mb-1">
|
||||
Twice everyday, OpenHuman will generate a dream (or a summary) based on everything
|
||||
that has happened in your life today. These dreams re then indexed and can be used
|
||||
to influence OpenHuman's behavior.
|
||||
</p>
|
||||
<p className="text-xs text-stone-500">Coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'dreams' && <IntelligenceDreamsTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+26
-259
@@ -1,55 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import ReferralRewardsSection from '../components/referral/ReferralRewardsSection';
|
||||
import RewardsCouponSection from '../components/rewards/RewardsCouponSection';
|
||||
import { useUser } from '../hooks/useUser';
|
||||
import PillTabBar from '../components/PillTabBar';
|
||||
import RewardsCommunityTab from '../components/rewards/RewardsCommunityTab';
|
||||
import RewardsRedeemTab from '../components/rewards/RewardsRedeemTab';
|
||||
import RewardsReferralsTab from '../components/rewards/RewardsReferralsTab';
|
||||
import { rewardsApi } from '../services/api/rewardsApi';
|
||||
import type {
|
||||
RewardsAchievement,
|
||||
RewardsDiscordRoleStatus,
|
||||
RewardsSnapshot,
|
||||
} from '../types/rewards';
|
||||
import { DISCORD_INVITE_URL } from '../utils/links';
|
||||
import type { RewardsSnapshot } from '../types/rewards';
|
||||
|
||||
function discordMembershipLabel(snapshot: RewardsSnapshot | null): string {
|
||||
if (!snapshot) return 'Waiting for backend sync';
|
||||
switch (snapshot.discord.membershipStatus) {
|
||||
case 'member':
|
||||
return 'Joined the server';
|
||||
case 'not_in_guild':
|
||||
return 'Linked, but not in server';
|
||||
case 'not_linked':
|
||||
return 'Not linked';
|
||||
default:
|
||||
return 'Membership status unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
function roleStatusLabel(status: RewardsDiscordRoleStatus): string {
|
||||
switch (status) {
|
||||
case 'assigned':
|
||||
return 'Assigned in Discord';
|
||||
case 'not_assigned':
|
||||
return 'Earned, pending Discord assignment';
|
||||
case 'not_linked':
|
||||
return 'Link Discord to receive this role';
|
||||
case 'not_in_guild':
|
||||
return 'Join the server to receive this role';
|
||||
case 'not_configured':
|
||||
return 'Discord role not configured';
|
||||
default:
|
||||
return 'Role sync status unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(Math.max(0, Math.trunc(value)));
|
||||
}
|
||||
type RewardsTab = 'referrals' | 'redeem' | 'rewards';
|
||||
|
||||
const Rewards = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useUser();
|
||||
const [selectedTab, setSelectedTab] = useState<RewardsTab>('rewards');
|
||||
const [snapshot, setSnapshot] = useState<RewardsSnapshot | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -93,221 +54,27 @@ const Rewards = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rewardRoles: RewardsAchievement[] = snapshot?.achievements ?? [];
|
||||
const unlocked =
|
||||
snapshot?.summary.unlockedCount ?? rewardRoles.filter(role => role.unlocked).length;
|
||||
const total = snapshot?.summary.totalCount ?? rewardRoles.length;
|
||||
const inviteUrl = snapshot?.discord.inviteUrl ?? DISCORD_INVITE_URL;
|
||||
const progressWidth = total > 0 ? (unlocked / total) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-full px-4 pt-6 pb-8">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<ReferralRewardsSection />
|
||||
<RewardsCouponSection />
|
||||
<div className="min-h-full px-4 pt-6 pb-10">
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
<PillTabBar
|
||||
items={[
|
||||
{ label: 'Referrals', value: 'referrals' },
|
||||
{ label: 'Rewards', value: 'rewards' },
|
||||
{ label: 'Redeem', value: 'redeem' },
|
||||
]}
|
||||
selected={selectedTab}
|
||||
onChange={setSelectedTab}
|
||||
activeClassName="border-primary-600 bg-primary-600 text-white"
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6">
|
||||
<div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-medium text-amber-700">
|
||||
Discord Rewards
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold text-stone-900">Earn community roles</h1>
|
||||
<p className="max-w-xl text-sm text-stone-600">
|
||||
Join the OpenHuman Discord, connect your account, and track backend-synced rewards
|
||||
and role assignments from one place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
onClick={() => window.open(inviteUrl, '_blank', 'noopener,noreferrer')}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-stone-900 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-stone-800">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.317 4.369A19.79 19.79 0 0 0 15.885 3c-.191.328-.403.775-.552 1.124a18.27 18.27 0 0 0-5.29 0A11.56 11.56 0 0 0 9.49 3a19.74 19.74 0 0 0-4.433 1.369C2.253 8.51 1.492 12.55 1.872 16.533a19.9 19.9 0 0 0 5.239 2.673c.423-.58.8-1.196 1.123-1.845a12.84 12.84 0 0 1-1.767-.85c.148-.106.292-.217.43-.332c3.408 1.6 7.104 1.6 10.472 0c.14.115.283.226.43.332c-.565.338-1.157.623-1.771.851c.322.648.698 1.264 1.123 1.844a19.84 19.84 0 0 0 5.241-2.673c.446-4.617-.761-8.621-3.787-12.164ZM9.46 14.088c-1.02 0-1.855-.936-1.855-2.084c0-1.148.82-2.084 1.855-2.084c1.044 0 1.87.944 1.855 2.084c0 1.148-.82 2.084-1.855 2.084Zm5.08 0c-1.02 0-1.855-.936-1.855-2.084c0-1.148.82-2.084 1.855-2.084c1.044 0 1.87.944 1.855 2.084c0 1.148-.812 2.084-1.855 2.084Z" />
|
||||
</svg>
|
||||
Join Discord
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/settings/messaging')}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50">
|
||||
Connect Discord
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Rewards sync is unavailable right now. The page is showing connection guidance without
|
||||
claiming new unlocks. Details: {error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[1.1fr_1.9fr]">
|
||||
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-5">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.18em] text-stone-400">
|
||||
Progress
|
||||
</div>
|
||||
<div className="mt-3 text-3xl font-semibold text-stone-900">
|
||||
{isLoading ? '...' : `${unlocked}/${total}`}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-stone-600">
|
||||
{snapshot
|
||||
? 'Server-tracked achievements and Discord reward state.'
|
||||
: isLoading
|
||||
? 'Loading rewards from the backend.'
|
||||
: 'Waiting for backend rewards data.'}
|
||||
</p>
|
||||
|
||||
<div className="mt-5 h-2 overflow-hidden rounded-full bg-stone-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary-500 to-amber-400 transition-all duration-300"
|
||||
style={{ width: `${progressWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3 text-sm text-stone-600">
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span>Discord linked</span>
|
||||
<span className={snapshot?.discord.linked ? 'text-sage-600' : 'text-stone-500'}>
|
||||
{snapshot ? (snapshot.discord.linked ? 'Yes' : 'No') : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span>Discord server</span>
|
||||
<span className="text-stone-900">{discordMembershipLabel(snapshot)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span>Plan</span>
|
||||
<span className="text-stone-900">
|
||||
{snapshot?.summary.plan ?? user?.subscription?.plan ?? 'FREE'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span>Current streak</span>
|
||||
<span className="text-stone-900">
|
||||
{snapshot ? `${snapshot.metrics.currentStreakDays} days` : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span>Cumulative tokens</span>
|
||||
<span className="text-stone-900">
|
||||
{snapshot ? formatNumber(snapshot.metrics.cumulativeTokens) : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-soft">
|
||||
<div className="text-sm text-stone-600">Loading rewards…</div>
|
||||
</div>
|
||||
) : rewardRoles.length > 0 ? (
|
||||
rewardRoles.map(role => (
|
||||
<div
|
||||
key={role.id}
|
||||
className={`rounded-2xl border p-5 shadow-soft ${
|
||||
role.unlocked ? 'border-sage-200 bg-white' : 'border-stone-200 bg-white/90'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-stone-900">{role.title}</h2>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium ${
|
||||
role.unlocked
|
||||
? 'bg-sage-100 text-sage-700'
|
||||
: 'bg-stone-100 text-stone-500'
|
||||
}`}>
|
||||
{role.unlocked ? 'Unlocked' : 'Locked'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-stone-600">{role.description}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-xl ${
|
||||
role.unlocked ? 'bg-sage-100 text-sage-700' : 'bg-stone-100 text-stone-500'
|
||||
}`}>
|
||||
{role.unlocked ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 sm:grid-cols-[1.3fr_1fr]">
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Unlock Action
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-stone-800">{role.actionLabel}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Server Progress
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium text-stone-600">
|
||||
{role.progressLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Discord Role
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-stone-800">
|
||||
{roleStatusLabel(role.discordRoleStatus)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Credit Reward
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-stone-800">
|
||||
{role.creditAmountUsd != null ? `$${role.creditAmountUsd}` : 'None'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-soft">
|
||||
<h2 className="text-lg font-semibold text-stone-900">Rewards sync pending</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">
|
||||
The backend did not return achievement data yet. Join Discord and connect your
|
||||
account now, then refresh this page once sync is available again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedTab === 'referrals' ? (
|
||||
<RewardsReferralsTab />
|
||||
) : selectedTab === 'redeem' ? (
|
||||
<RewardsRedeemTab />
|
||||
) : (
|
||||
<RewardsCommunityTab error={error} isLoading={isLoading} snapshot={snapshot} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+97
-86
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import AgentChatPanel from '../components/settings/panels/AgentChatPanel';
|
||||
@@ -78,22 +79,6 @@ const accountSettingsItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'billing',
|
||||
title: 'Billing & Usage',
|
||||
description: 'Subscription plan, pay-as-you-go credits, and payment methods',
|
||||
route: 'billing',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H5a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'privacy',
|
||||
title: 'Privacy',
|
||||
@@ -220,82 +205,108 @@ const aiModelsSettingsItems = [
|
||||
},
|
||||
];
|
||||
|
||||
const Settings = () => {
|
||||
const WrappedSettingsPage = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<div className="p-4 pt-6">
|
||||
<div className="max-w-lg mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden">
|
||||
<Routes>
|
||||
<Route index element={<SettingsHome />} />
|
||||
<Route
|
||||
path="account"
|
||||
element={
|
||||
<SettingsSectionPage
|
||||
title="Account & Billing"
|
||||
description="Recovery phrase, team, connections, billing, and privacy settings."
|
||||
items={accountSettingsItems}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="features"
|
||||
element={
|
||||
<SettingsSectionPage
|
||||
title="Features"
|
||||
description="Screen awareness, autocomplete, voice, messaging, and tools."
|
||||
items={featuresSettingsItems}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="ai-models"
|
||||
element={
|
||||
<SettingsSectionPage
|
||||
title="AI & Models"
|
||||
description="Local AI model setup and management."
|
||||
items={aiModelsSettingsItems}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* Account & Billing leaf panels */}
|
||||
<Route path="recovery-phrase" element={<RecoveryPhrasePanel />} />
|
||||
<Route path="team" element={<TeamPanel />} />
|
||||
<Route path="team/manage/:teamId" element={<TeamManagementPanel />} />
|
||||
<Route path="team/manage/:teamId/members" element={<TeamMembersPanel />} />
|
||||
<Route path="team/manage/:teamId/invites" element={<TeamInvitesPanel />} />
|
||||
<Route path="team/members" element={<TeamMembersPanel />} />
|
||||
<Route path="team/invites" element={<TeamInvitesPanel />} />
|
||||
<Route path="connections" element={<ConnectionsPanel />} />
|
||||
<Route path="billing" element={<BillingPanel />} />
|
||||
<Route path="privacy" element={<PrivacyPanel />} />
|
||||
{/* Features leaf panels */}
|
||||
<Route path="screen-intelligence" element={<ScreenIntelligencePanel />} />
|
||||
<Route path="autocomplete" element={<AutocompletePanel />} />
|
||||
<Route path="voice" element={<VoicePanel />} />
|
||||
<Route path="messaging" element={<MessagingPanel />} />
|
||||
<Route path="tools" element={<ToolsPanel />} />
|
||||
{/* AI & Models leaf panels */}
|
||||
<Route path="local-model" element={<LocalModelPanel />} />
|
||||
{/* Developer Options */}
|
||||
<Route path="developer-options" element={<DeveloperOptionsPanel />} />
|
||||
<Route path="ai" element={<AIPanel />} />
|
||||
<Route path="agent-chat" element={<AgentChatPanel />} />
|
||||
<Route path="cron-jobs" element={<CronJobsPanel />} />
|
||||
<Route path="screen-awareness-debug" element={<ScreenAwarenessDebugPanel />} />
|
||||
<Route path="autocomplete-debug" element={<AutocompleteDebugPanel />} />
|
||||
<Route path="voice-debug" element={<VoiceDebugPanel />} />
|
||||
<Route path="local-model-debug" element={<LocalModelDebugPanel />} />
|
||||
<Route path="webhooks-debug" element={<WebhooksDebugPanel />} />
|
||||
<Route path="memory-data" element={<MemoryDataPanel />} />
|
||||
<Route path="memory-debug" element={<MemoryDebugPanel />} />
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/settings" replace />} />
|
||||
</Routes>
|
||||
<div className="border-t border-stone-100 px-4 py-3 text-center text-[11px] text-stone-400">
|
||||
Beta build - v{APP_VERSION}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function wrapSettingsPage(element: ReactNode) {
|
||||
return (
|
||||
<WrappedSettingsPage>
|
||||
{element}
|
||||
<div className="border-t border-stone-100 px-4 py-3 text-center text-[11px] text-stone-400">
|
||||
Beta build - v{APP_VERSION}
|
||||
</div>
|
||||
</WrappedSettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
const Settings = () => {
|
||||
return (
|
||||
<div>
|
||||
<Routes>
|
||||
<Route index element={wrapSettingsPage(<SettingsHome />)} />
|
||||
<Route
|
||||
path="account"
|
||||
element={wrapSettingsPage(
|
||||
<SettingsSectionPage
|
||||
title="Account"
|
||||
description="Recovery phrase, team, connections, and privacy settings."
|
||||
items={accountSettingsItems}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="features"
|
||||
element={wrapSettingsPage(
|
||||
<SettingsSectionPage
|
||||
title="Features"
|
||||
description="Screen awareness, autocomplete, voice, messaging, and tools."
|
||||
items={featuresSettingsItems}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="ai-models"
|
||||
element={wrapSettingsPage(
|
||||
<SettingsSectionPage
|
||||
title="AI & Models"
|
||||
description="Local AI model setup and management."
|
||||
items={aiModelsSettingsItems}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* Account & Billing leaf panels */}
|
||||
<Route path="recovery-phrase" element={wrapSettingsPage(<RecoveryPhrasePanel />)} />
|
||||
<Route path="team" element={wrapSettingsPage(<TeamPanel />)} />
|
||||
<Route path="team/manage/:teamId" element={wrapSettingsPage(<TeamManagementPanel />)} />
|
||||
<Route
|
||||
path="team/manage/:teamId/members"
|
||||
element={wrapSettingsPage(<TeamMembersPanel />)}
|
||||
/>
|
||||
<Route
|
||||
path="team/manage/:teamId/invites"
|
||||
element={wrapSettingsPage(<TeamInvitesPanel />)}
|
||||
/>
|
||||
<Route path="team/members" element={wrapSettingsPage(<TeamMembersPanel />)} />
|
||||
<Route path="team/invites" element={wrapSettingsPage(<TeamInvitesPanel />)} />
|
||||
<Route path="connections" element={wrapSettingsPage(<ConnectionsPanel />)} />
|
||||
{/* BillingPanel intentionally uses its own wider layout. */}
|
||||
<Route path="billing" element={<BillingPanel />} />
|
||||
<Route path="privacy" element={wrapSettingsPage(<PrivacyPanel />)} />
|
||||
{/* Features leaf panels */}
|
||||
<Route path="screen-intelligence" element={wrapSettingsPage(<ScreenIntelligencePanel />)} />
|
||||
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />
|
||||
<Route path="voice" element={wrapSettingsPage(<VoicePanel />)} />
|
||||
<Route path="messaging" element={wrapSettingsPage(<MessagingPanel />)} />
|
||||
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
|
||||
{/* AI & Models leaf panels */}
|
||||
<Route path="local-model" element={wrapSettingsPage(<LocalModelPanel />)} />
|
||||
{/* Developer Options */}
|
||||
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
|
||||
<Route path="ai" element={wrapSettingsPage(<AIPanel />)} />
|
||||
<Route path="agent-chat" element={wrapSettingsPage(<AgentChatPanel />)} />
|
||||
<Route path="cron-jobs" element={wrapSettingsPage(<CronJobsPanel />)} />
|
||||
<Route
|
||||
path="screen-awareness-debug"
|
||||
element={wrapSettingsPage(<ScreenAwarenessDebugPanel />)}
|
||||
/>
|
||||
<Route path="autocomplete-debug" element={wrapSettingsPage(<AutocompleteDebugPanel />)} />
|
||||
<Route path="voice-debug" element={wrapSettingsPage(<VoiceDebugPanel />)} />
|
||||
<Route path="local-model-debug" element={wrapSettingsPage(<LocalModelDebugPanel />)} />
|
||||
<Route path="webhooks-debug" element={wrapSettingsPage(<WebhooksDebugPanel />)} />
|
||||
<Route path="memory-data" element={wrapSettingsPage(<MemoryDataPanel />)} />
|
||||
<Route path="memory-debug" element={wrapSettingsPage(<MemoryDebugPanel />)} />
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/settings" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
|
||||
+69
-55
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import ChannelSetupModal from '../components/channels/ChannelSetupModal';
|
||||
import ComposioConnectModal from '../components/composio/ComposioConnectModal';
|
||||
@@ -11,7 +11,14 @@ import {
|
||||
import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal';
|
||||
import ScreenIntelligenceSetupModal from '../components/skills/ScreenIntelligenceSetupModal';
|
||||
import UnifiedSkillCard from '../components/skills/SkillCard';
|
||||
import SkillCategoryFilter, { type SkillCategory } from '../components/skills/SkillCategoryFilter';
|
||||
import { SKILL_CATEGORY_ORDER, type SkillCategory } from '../components/skills/skillCategories';
|
||||
import SkillCategoryFilter from '../components/skills/SkillCategoryFilter';
|
||||
import {
|
||||
BUILT_IN_SKILL_ICONS,
|
||||
CHANNEL_ICONS,
|
||||
skillCategoryHeadingClassName,
|
||||
SkillCategoryIcon,
|
||||
} from '../components/skills/skillIcons';
|
||||
import SkillSearchBar from '../components/skills/SkillSearchBar';
|
||||
import VoiceSetupModal from '../components/skills/VoiceSetupModal';
|
||||
import { useAutocompleteSkillStatus } from '../features/autocomplete/useAutocompleteSkillStatus';
|
||||
@@ -19,15 +26,11 @@ import { useScreenIntelligenceSkillStatus } from '../features/screen-intelligenc
|
||||
import { useVoiceSkillStatus } from '../features/voice/useVoiceSkillStatus';
|
||||
import { useChannelDefinitions } from '../hooks/useChannelDefinitions';
|
||||
import { useComposioIntegrations } from '../lib/composio/hooks';
|
||||
import { canonicalizeComposioToolkitSlug } from '../lib/composio/toolkitSlug';
|
||||
import { type ComposioConnection, deriveComposioState } from '../lib/composio/types';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import type { ChannelConnectionStatus, ChannelDefinition, ChannelType } from '../types/channels';
|
||||
|
||||
const CHANNEL_ICONS: Record<string, string> = {
|
||||
telegram: '\u2708\uFE0F',
|
||||
discord: '\uD83C\uDFAE',
|
||||
web: '\uD83C\uDF10',
|
||||
};
|
||||
import { subconsciousEscalationsDismiss } from '../utils/tauriCommands';
|
||||
|
||||
function channelStatusDot(status: ChannelConnectionStatus): string {
|
||||
switch (status) {
|
||||
@@ -120,16 +123,7 @@ const BUILT_IN_SKILLS = [
|
||||
description:
|
||||
'Capture windows, summarize what is on screen, and feed useful context into memory.',
|
||||
route: '/settings/screen-intelligence',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M3 5h18v12H3zM8 21h8m-4-4v4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
icon: BUILT_IN_SKILL_ICONS.screenIntelligence,
|
||||
},
|
||||
{
|
||||
id: 'text-autocomplete',
|
||||
@@ -137,32 +131,14 @@ const BUILT_IN_SKILLS = [
|
||||
description:
|
||||
'Suggest inline completions while you type and control where autocomplete is active.',
|
||||
route: '/settings/autocomplete',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M4 7h16M4 12h10m-10 5h7m10 0l3 3m0 0l3-3m-3 3v-8"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
icon: BUILT_IN_SKILL_ICONS.textAutocomplete,
|
||||
},
|
||||
{
|
||||
id: 'voice-stt',
|
||||
title: 'Voice Intelligence',
|
||||
description: 'Use the microphone for dictation and voice-driven chat with your AI.',
|
||||
route: '/settings/voice',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
icon: BUILT_IN_SKILL_ICONS.voiceStt,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -188,6 +164,7 @@ interface SkillItem {
|
||||
// ─── Main Skills Page ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function Skills() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { definitions: channelDefs } = useChannelDefinitions();
|
||||
const channelConnections = useAppSelector(state => state.channelConnections);
|
||||
@@ -212,6 +189,43 @@ export default function Skills() {
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<SkillCategory>('All');
|
||||
const pendingEscalationId =
|
||||
location.state &&
|
||||
typeof location.state === 'object' &&
|
||||
'subconsciousEscalationId' in location.state &&
|
||||
typeof location.state.subconsciousEscalationId === 'string'
|
||||
? location.state.subconsciousEscalationId
|
||||
: null;
|
||||
|
||||
const clearPendingEscalationState = useCallback(() => {
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
}, [location.pathname, navigate]);
|
||||
|
||||
const dismissPendingEscalationIfResolved = useCallback(
|
||||
async (resolution: string) => {
|
||||
if (!pendingEscalationId) return;
|
||||
console.debug('[skills][subconscious] dismiss escalation:start', {
|
||||
escalationId: pendingEscalationId,
|
||||
resolution,
|
||||
});
|
||||
try {
|
||||
await subconsciousEscalationsDismiss(pendingEscalationId);
|
||||
console.debug('[skills][subconscious] dismiss escalation:success', {
|
||||
escalationId: pendingEscalationId,
|
||||
resolution,
|
||||
});
|
||||
} catch (error) {
|
||||
console.debug('[skills][subconscious] dismiss escalation:error', {
|
||||
escalationId: pendingEscalationId,
|
||||
resolution,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
clearPendingEscalationState();
|
||||
},
|
||||
[clearPendingEscalationState, pendingEscalationId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
@@ -239,7 +253,7 @@ export default function Skills() {
|
||||
);
|
||||
|
||||
const composioCatalogToolkits = useMemo(() => {
|
||||
const normalizedToolkits = composioToolkits.map(slug => slug.toLowerCase());
|
||||
const normalizedToolkits = composioToolkits.map(slug => canonicalizeComposioToolkitSlug(slug));
|
||||
const missingKnownToolkits = KNOWN_COMPOSIO_TOOLKITS.filter(
|
||||
slug => !normalizedToolkits.includes(slug)
|
||||
);
|
||||
@@ -281,7 +295,7 @@ export default function Skills() {
|
||||
kind: 'channel',
|
||||
channelDef: def,
|
||||
channelStatus: bestChannelStatus(def.id as ChannelType),
|
||||
icon: <span className="text-lg">{CHANNEL_ICONS[def.icon] ?? ''}</span>,
|
||||
icon: CHANNEL_ICONS[def.icon],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -318,18 +332,7 @@ export default function Skills() {
|
||||
for (const item of allItems) {
|
||||
cats.add(item.category);
|
||||
}
|
||||
const order: SkillCategory[] = [
|
||||
'All',
|
||||
'Built-in',
|
||||
'Channels',
|
||||
'Chat',
|
||||
'Productivity',
|
||||
'Tools & Automation',
|
||||
'Social',
|
||||
'Platform',
|
||||
'Other',
|
||||
];
|
||||
return order.filter(c => cats.has(c));
|
||||
return SKILL_CATEGORY_ORDER.filter(c => cats.has(c));
|
||||
}, [allItems]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
@@ -397,7 +400,15 @@ export default function Skills() {
|
||||
key={category}
|
||||
className="rounded-2xl border border-stone-200 bg-white p-3 shadow-soft animate-fade-up">
|
||||
<div className="px-1 pb-3 pt-1">
|
||||
<h2 className="text-sm font-semibold text-stone-900">{category}</h2>
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-stone-900">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-stone-100">
|
||||
<SkillCategoryIcon
|
||||
category={category}
|
||||
className={skillCategoryHeadingClassName(category)}
|
||||
/>
|
||||
</span>
|
||||
{category}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map(item => {
|
||||
@@ -593,7 +604,10 @@ export default function Skills() {
|
||||
<ComposioConnectModal
|
||||
toolkit={composioModalToolkit}
|
||||
connection={composioConnectionByToolkit.get(composioModalToolkit.slug)}
|
||||
onChanged={() => void refreshComposio()}
|
||||
onChanged={() => {
|
||||
void refreshComposio();
|
||||
void dismissPendingEscalationIfResolved(`composio:${composioModalToolkit.slug}`);
|
||||
}}
|
||||
onClose={() => setComposioModalToolkit(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -6,11 +6,11 @@ import Rewards from '../Rewards';
|
||||
|
||||
const { rewardsApi } = vi.hoisted(() => ({ rewardsApi: { getMyRewards: vi.fn() } }));
|
||||
|
||||
vi.mock('../../components/referral/ReferralRewardsSection', () => ({
|
||||
vi.mock('../../components/rewards/RewardsReferralsTab', () => ({
|
||||
default: () => <div>Referral Rewards Section</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/rewards/RewardsCouponSection', () => ({
|
||||
vi.mock('../../components/rewards/RewardsRedeemTab', () => ({
|
||||
default: () => <div>Rewards Coupon Section</div>,
|
||||
}));
|
||||
|
||||
@@ -70,14 +70,14 @@ describe('Rewards page', () => {
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading rewards…')).toBeInTheDocument();
|
||||
expect(screen.queryAllByText('Loading rewards…').length).toBeGreaterThan(0);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('7-Day Streak')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Assigned in Discord')).toBeInTheDocument();
|
||||
expect(screen.getByText('1/2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Joined the server')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 of 2 achievements unlocked')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a conservative error state when rewards fail to load', async () => {
|
||||
@@ -96,4 +96,83 @@ describe('Rewards page', () => {
|
||||
expect(screen.getByText('Rewards sync pending')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Unlocked')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to the referrals tab content', async () => {
|
||||
rewardsApi.getMyRewards.mockResolvedValueOnce({
|
||||
discord: {
|
||||
linked: false,
|
||||
discordId: null,
|
||||
inviteUrl: 'https://discord.gg/openhuman',
|
||||
membershipStatus: 'not_linked',
|
||||
},
|
||||
summary: {
|
||||
unlockedCount: 0,
|
||||
totalCount: 0,
|
||||
assignedDiscordRoleCount: 0,
|
||||
plan: 'FREE',
|
||||
hasActiveSubscription: false,
|
||||
},
|
||||
metrics: {
|
||||
currentStreakDays: 0,
|
||||
longestStreakDays: 0,
|
||||
cumulativeTokens: 0,
|
||||
featuresUsedCount: 0,
|
||||
trackedFeaturesCount: 0,
|
||||
lastEvaluatedAt: '2026-04-09T00:00:00.000Z',
|
||||
lastSyncedAt: '2026-04-09T01:00:00.000Z',
|
||||
},
|
||||
achievements: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Rewards />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Referrals' }));
|
||||
|
||||
expect(screen.getByText('Referral Rewards Section')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Rewards Coupon Section')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Earn community roles')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to the redeem tab content', async () => {
|
||||
rewardsApi.getMyRewards.mockResolvedValueOnce({
|
||||
discord: {
|
||||
linked: false,
|
||||
discordId: null,
|
||||
inviteUrl: 'https://discord.gg/openhuman',
|
||||
membershipStatus: 'not_linked',
|
||||
},
|
||||
summary: {
|
||||
unlockedCount: 0,
|
||||
totalCount: 0,
|
||||
assignedDiscordRoleCount: 0,
|
||||
plan: 'FREE',
|
||||
hasActiveSubscription: false,
|
||||
},
|
||||
metrics: {
|
||||
currentStreakDays: 0,
|
||||
longestStreakDays: 0,
|
||||
cumulativeTokens: 0,
|
||||
featuresUsedCount: 0,
|
||||
trackedFeaturesCount: 0,
|
||||
lastEvaluatedAt: '2026-04-09T00:00:00.000Z',
|
||||
lastSyncedAt: '2026-04-09T01:00:00.000Z',
|
||||
},
|
||||
achievements: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Rewards />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Redeem' }));
|
||||
|
||||
expect(screen.getByText('Rewards Coupon Section')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Referral Rewards Section')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,6 @@ describe('Skills page — Notion composio integration', () => {
|
||||
fireEvent.click(within(notionCard as HTMLElement).getByRole('button', { name: 'Connect' }));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Connect Notion' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Connect your Notion account through Composio\./i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Connect your Notion account\./i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,14 +53,14 @@ interface Stage {
|
||||
const STAGES: Stage[] = [
|
||||
{
|
||||
id: 'gmail-search',
|
||||
label: 'Searching your emails',
|
||||
label: 'Indexing your GMail',
|
||||
doneSignal: 'Found LinkedIn profile',
|
||||
errorSignal: 'Gmail search failed',
|
||||
skipSignal: 'No LinkedIn profile URL',
|
||||
},
|
||||
{
|
||||
id: 'apify-scrape',
|
||||
label: 'Scraping your LinkedIn',
|
||||
label: 'Finding your LinkedIn',
|
||||
doneSignal: 'profile scraped successfully',
|
||||
errorSignal: 'scrape failed',
|
||||
},
|
||||
|
||||
@@ -214,4 +214,22 @@ describe('creditsApi.getBalance', () => {
|
||||
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_get_balance');
|
||||
expect(result).toEqual({ promotionBalanceUsd: 0, teamTopupUsd: 3 });
|
||||
});
|
||||
|
||||
it('accepts snake_case balance fields from the backend', async () => {
|
||||
mockCallCoreCommand.mockResolvedValue({ promotion_balance_usd: '12.5', team_topup_usd: 4 });
|
||||
|
||||
const result = await creditsApi.getBalance();
|
||||
|
||||
expect(result).toEqual({ promotionBalanceUsd: 12.5, teamTopupUsd: 4 });
|
||||
});
|
||||
|
||||
it('accepts nested balance payloads used by some adapters', async () => {
|
||||
mockCallCoreCommand.mockResolvedValue({
|
||||
data: { promotional_balance_usd: 6, team_topup_balance_usd: '9.25' },
|
||||
});
|
||||
|
||||
const result = await creditsApi.getBalance();
|
||||
|
||||
expect(result).toEqual({ promotionBalanceUsd: 6, teamTopupUsd: 9.25 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,13 +150,13 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
function normalizeUsd(value: unknown, fallback = 0): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return 0;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function asStringOrNull(value: unknown): string | null {
|
||||
@@ -172,7 +172,7 @@ export function normalizeCouponRedeemResult(raw: unknown): CouponRedeemResult {
|
||||
(typeof payload.couponCode === 'string' && payload.couponCode.trim()) ||
|
||||
(typeof payload.code === 'string' && payload.code.trim()) ||
|
||||
'',
|
||||
amountUsd: asNumber(payload.amountUsd ?? payload.amount_usd),
|
||||
amountUsd: normalizeUsd(payload.amountUsd ?? payload.amount_usd),
|
||||
pending: Boolean(payload.pending),
|
||||
};
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export function normalizeRedeemedCoupon(raw: unknown): RedeemedCoupon {
|
||||
(typeof record.code === 'string' && record.code.trim()) ||
|
||||
(typeof record.couponCode === 'string' && record.couponCode.trim()) ||
|
||||
'',
|
||||
amountUsd: asNumber(record.amountUsd ?? record.amount_usd),
|
||||
amountUsd: normalizeUsd(record.amountUsd ?? record.amount_usd),
|
||||
redeemedAt: asStringOrNull(record.redeemedAt ?? record.redeemed_at),
|
||||
activationType:
|
||||
(typeof record.activationType === 'string' && record.activationType.trim()) ||
|
||||
@@ -196,16 +196,56 @@ export function normalizeRedeemedCoupon(raw: unknown): RedeemedCoupon {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUsd(value: unknown, fallback = 0): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeCreditBalance(payload: unknown): CreditBalance {
|
||||
const raw = (payload && typeof payload === 'object' ? payload : {}) as Record<string, unknown>;
|
||||
const nested = asRecord(raw.data) ?? asRecord(raw.balance) ?? null;
|
||||
const source = nested ?? raw;
|
||||
const promotionBalanceKeys = [
|
||||
'promotionBalanceUsd',
|
||||
'promotion_balance_usd',
|
||||
'promotionalBalanceUsd',
|
||||
'promotional_balance_usd',
|
||||
'promoBalanceUsd',
|
||||
'promo_balance_usd',
|
||||
] as const;
|
||||
const teamTopupKeys = [
|
||||
'teamTopupUsd',
|
||||
'team_topup_usd',
|
||||
'teamTopUpUsd',
|
||||
'team_top_up_usd',
|
||||
'teamTopupBalanceUsd',
|
||||
'team_topup_balance_usd',
|
||||
] as const;
|
||||
const missingPromotionBalance = promotionBalanceKeys.every(key => !(key in source));
|
||||
const missingTeamTopup = teamTopupKeys.every(key => !(key in source));
|
||||
|
||||
if (missingPromotionBalance || missingTeamTopup) {
|
||||
console.debug('[creditsApi] normalizeCreditBalance missing expected keys', {
|
||||
raw: source,
|
||||
missingPromotionBalance,
|
||||
missingPromotionBalanceKeys: missingPromotionBalance ? promotionBalanceKeys : [],
|
||||
missingTeamTopup,
|
||||
missingTeamTopupKeys: missingTeamTopup ? teamTopupKeys : [],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
promotionBalanceUsd: normalizeUsd(raw.promotionBalanceUsd),
|
||||
teamTopupUsd: normalizeUsd(raw.teamTopupUsd),
|
||||
promotionBalanceUsd: normalizeUsd(
|
||||
source.promotionBalanceUsd ??
|
||||
source.promotion_balance_usd ??
|
||||
source.promotionalBalanceUsd ??
|
||||
source.promotional_balance_usd ??
|
||||
source.promoBalanceUsd ??
|
||||
source.promo_balance_usd
|
||||
),
|
||||
teamTopupUsd: normalizeUsd(
|
||||
source.teamTopupUsd ??
|
||||
source.team_topup_usd ??
|
||||
source.teamTopUpUsd ??
|
||||
source.team_top_up_usd ??
|
||||
source.teamTopupBalanceUsd ??
|
||||
source.team_topup_balance_usd
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import packageJson from '../../package.json';
|
||||
|
||||
const APP_ENV = (import.meta.env.VITE_OPENHUMAN_APP_ENV as string | undefined)
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const DEFAULT_BACKEND_URL =
|
||||
APP_ENV === 'staging' ? 'https://staging-api.tinyhumans.ai' : 'https://api.tinyhumans.ai';
|
||||
|
||||
export const CORE_RPC_URL =
|
||||
import.meta.env.VITE_OPENHUMAN_CORE_RPC_URL || 'http://127.0.0.1:7788/rpc';
|
||||
|
||||
@@ -32,7 +39,8 @@ export const SKILLS_GITHUB_REPO =
|
||||
export const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN as string | undefined;
|
||||
|
||||
/** Backend API URL (web fallback when core RPC is unavailable). */
|
||||
export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL as string | undefined;
|
||||
export const BACKEND_URL =
|
||||
(import.meta.env.VITE_BACKEND_URL as string | undefined)?.trim() || DEFAULT_BACKEND_URL;
|
||||
|
||||
/** Telegram bot username used for managed DM linking when backend does not return a launch URL. */
|
||||
export const TELEGRAM_BOT_USERNAME =
|
||||
|
||||
Vendored
+1
@@ -1,6 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_OPENHUMAN_APP_ENV?: string;
|
||||
readonly VITE_OPENHUMAN_CORE_RPC_URL?: string;
|
||||
readonly VITE_BACKEND_URL?: string;
|
||||
readonly VITE_SKILLS_GITHUB_REPO?: string;
|
||||
|
||||
+55
-2
@@ -2,11 +2,19 @@
|
||||
|
||||
/// Default API host when `config.api_url` is unset or blank and no env override is set.
|
||||
pub const DEFAULT_API_BASE_URL: &str = "https://api.tinyhumans.ai";
|
||||
/// Default staging API host when the app environment is explicitly `staging`.
|
||||
pub const DEFAULT_STAGING_API_BASE_URL: &str = "https://staging-api.tinyhumans.ai";
|
||||
/// Primary app-environment selector used by the core and desktop app.
|
||||
pub const APP_ENV_VAR: &str = "OPENHUMAN_APP_ENV";
|
||||
/// Vite-exposed app-environment selector used by the frontend bundle.
|
||||
pub const VITE_APP_ENV_VAR: &str = "VITE_OPENHUMAN_APP_ENV";
|
||||
|
||||
/// Resolves the hosted API base URL (no path suffix).
|
||||
///
|
||||
/// Order: non-empty `api_url` from config → [`api_base_from_env`] (`BACKEND_URL`, then `VITE_BACKEND_URL`)
|
||||
/// → [`DEFAULT_API_BASE_URL`] (same host your curl command used when config is blank).
|
||||
/// → [`default_api_base_url_for_env`](`app_env_from_env().as_deref()`), which selects the
|
||||
/// environment-aware default host based on the current app environment instead of always using
|
||||
/// [`DEFAULT_API_BASE_URL`].
|
||||
pub fn effective_api_url(api_url: &Option<String>) -> String {
|
||||
if let Some(u) = api_url.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
return normalize_api_base_url(u);
|
||||
@@ -14,7 +22,7 @@ pub fn effective_api_url(api_url: &Option<String>) -> String {
|
||||
if let Some(env_url) = api_base_from_env() {
|
||||
return env_url;
|
||||
}
|
||||
DEFAULT_API_BASE_URL.to_string()
|
||||
default_api_base_url_for_env(app_env_from_env().as_deref()).to_string()
|
||||
}
|
||||
|
||||
/// Trim and strip trailing slashes so paths join consistently.
|
||||
@@ -30,3 +38,48 @@ pub fn api_base_from_env() -> Option<String> {
|
||||
.map(|s| normalize_api_base_url(&s))
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Resolve the app environment from process environment.
|
||||
pub fn app_env_from_env() -> Option<String> {
|
||||
std::env::var(APP_ENV_VAR)
|
||||
.or_else(|_| std::env::var(VITE_APP_ENV_VAR))
|
||||
.ok()
|
||||
.map(|s| s.trim().to_ascii_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn is_staging_app_env(app_env: Option<&str>) -> bool {
|
||||
matches!(app_env.map(str::trim), Some(env) if env.eq_ignore_ascii_case("staging"))
|
||||
}
|
||||
|
||||
pub fn default_api_base_url_for_env(app_env: Option<&str>) -> &'static str {
|
||||
if is_staging_app_env(app_env) {
|
||||
DEFAULT_STAGING_API_BASE_URL
|
||||
} else {
|
||||
DEFAULT_API_BASE_URL
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn staging_app_env_uses_staging_default_api() {
|
||||
assert_eq!(
|
||||
default_api_base_url_for_env(Some("staging")),
|
||||
DEFAULT_STAGING_API_BASE_URL
|
||||
);
|
||||
assert!(is_staging_app_env(Some("STAGING")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_staging_app_env_uses_production_default_api() {
|
||||
assert_eq!(
|
||||
default_api_base_url_for_env(Some("production")),
|
||||
DEFAULT_API_BASE_URL
|
||||
);
|
||||
assert_eq!(default_api_base_url_for_env(None), DEFAULT_API_BASE_URL);
|
||||
assert!(!is_staging_app_env(Some("development")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,16 @@ fn user_home_agents_dir() -> Option<PathBuf> {
|
||||
if let Ok(custom) = std::env::var("OPENHUMAN_HOME") {
|
||||
return Some(PathBuf::from(custom).join("agents"));
|
||||
}
|
||||
dirs::home_dir().map(|h| h.join(".openhuman").join("agents"))
|
||||
match crate::openhuman::config::default_root_openhuman_dir() {
|
||||
Ok(dir) => Some(dir.join("agents")),
|
||||
Err(error) => {
|
||||
tracing::debug!(
|
||||
error = %error,
|
||||
"[agent-definition-loader] resolving root openhuman dir failed"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,15 +4,18 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Which authentication mode a channel connection uses.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChannelAuthMode {
|
||||
/// User provides an API key or access token.
|
||||
#[serde(rename = "api_key")]
|
||||
ApiKey,
|
||||
/// User provides a bot token (e.g. Telegram BotFather token).
|
||||
#[serde(rename = "bot_token")]
|
||||
BotToken,
|
||||
/// User authenticates via OAuth (server-side flow).
|
||||
#[serde(rename = "oauth")]
|
||||
OAuth,
|
||||
/// User messages the platform's managed bot directly.
|
||||
#[serde(rename = "managed_dm")]
|
||||
ManagedDm,
|
||||
}
|
||||
|
||||
@@ -427,4 +430,52 @@ mod tests {
|
||||
assert_eq!(parsed, mode);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_mode_serializes_to_expected_wire_values() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(ChannelAuthMode::ApiKey).expect("serialize"),
|
||||
serde_json::Value::String("api_key".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ChannelAuthMode>(serde_json::Value::String(
|
||||
"api_key".to_string()
|
||||
))
|
||||
.expect("deserialize"),
|
||||
ChannelAuthMode::ApiKey
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(ChannelAuthMode::BotToken).expect("serialize"),
|
||||
serde_json::Value::String("bot_token".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ChannelAuthMode>(serde_json::Value::String(
|
||||
"bot_token".to_string()
|
||||
))
|
||||
.expect("deserialize"),
|
||||
ChannelAuthMode::BotToken
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(ChannelAuthMode::OAuth).expect("serialize"),
|
||||
serde_json::Value::String("oauth".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ChannelAuthMode>(serde_json::Value::String(
|
||||
"oauth".to_string()
|
||||
))
|
||||
.expect("deserialize"),
|
||||
ChannelAuthMode::OAuth
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(ChannelAuthMode::ManagedDm).expect("serialize"),
|
||||
serde_json::Value::String("managed_dm".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ChannelAuthMode>(serde_json::Value::String(
|
||||
"managed_dm".to_string()
|
||||
))
|
||||
.expect("deserialize"),
|
||||
ChannelAuthMode::ManagedDm
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashMap;
|
||||
@@ -75,6 +76,17 @@ static THREAD_SESSIONS: Lazy<Mutex<HashMap<String, SessionEntry>>> =
|
||||
|
||||
static IN_FLIGHT: Lazy<Mutex<HashMap<String, InFlightEntry>>> =
|
||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
static BUDGET_ERROR_NORMALIZE_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"[-_\s]+").expect("budget normalize regex"));
|
||||
static BUDGET_ERROR_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
|
||||
vec![
|
||||
Regex::new(r"budget.*exceed").expect("budget exceeded regex"),
|
||||
Regex::new(r"top up").expect("top up regex"),
|
||||
Regex::new(r"add.*credits").expect("add credits regex"),
|
||||
Regex::new(r"out of credits").expect("out of credits regex"),
|
||||
Regex::new(r"no remaining credits").expect("no remaining credits regex"),
|
||||
]
|
||||
});
|
||||
|
||||
fn key_for(client_id: &str, thread_id: &str) -> String {
|
||||
format!("{client_id}::{thread_id}")
|
||||
@@ -88,6 +100,19 @@ fn event_session_id_for(client_id: &str, thread_id: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn is_inference_budget_exceeded_error(message: &str) -> bool {
|
||||
let normalized = BUDGET_ERROR_NORMALIZE_RE
|
||||
.replace_all(&message.trim().to_ascii_lowercase(), " ")
|
||||
.into_owned();
|
||||
BUDGET_ERROR_PATTERNS
|
||||
.iter()
|
||||
.any(|pattern| pattern.is_match(&normalized))
|
||||
}
|
||||
|
||||
fn inference_budget_exceeded_user_message() -> &'static str {
|
||||
"I don't have any budget available right now. Please top up your credits or choose a plan to continue."
|
||||
}
|
||||
|
||||
pub async fn start_chat(
|
||||
client_id: &str,
|
||||
thread_id: &str,
|
||||
@@ -335,7 +360,23 @@ async fn run_chat_task(
|
||||
request_id.to_string(),
|
||||
);
|
||||
|
||||
let result = agent.run_single(message).await.map_err(|e| e.to_string());
|
||||
let result = match agent.run_single(message).await {
|
||||
Ok(response) => Ok(response),
|
||||
Err(err) => {
|
||||
let err_message = err.to_string();
|
||||
if is_inference_budget_exceeded_error(&err_message) {
|
||||
log::warn!(
|
||||
"[web-channel] inference budget exhausted for client={} thread={} request_id={} error_category=budget_exhausted",
|
||||
client_id,
|
||||
thread_id,
|
||||
request_id
|
||||
);
|
||||
Ok(inference_budget_exceeded_user_message().to_string())
|
||||
} else {
|
||||
Err(err_message)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Clear the sender so it doesn't hold the channel open across sessions.
|
||||
agent.set_on_progress(None);
|
||||
@@ -798,7 +839,10 @@ fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String>
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cancel_chat, start_chat};
|
||||
use super::{
|
||||
cancel_chat, inference_budget_exceeded_user_message, is_inference_budget_exceeded_error,
|
||||
start_chat,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_chat_validates_required_fields() {
|
||||
@@ -830,4 +874,24 @@ mod tests {
|
||||
.expect_err("thread id should be required");
|
||||
assert!(err.contains("thread_id is required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_backend_budget_exhaustion_error() {
|
||||
assert!(is_inference_budget_exceeded_error(
|
||||
"OpenHuman API error (402 Payment Required): Budget exceeded — add credits to continue."
|
||||
));
|
||||
assert!(is_inference_budget_exceeded_error(
|
||||
"provider error: budget exceeded, please add credits"
|
||||
));
|
||||
assert!(!is_inference_budget_exceeded_error(
|
||||
"OpenHuman API error (500): Internal server error"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_exceeded_copy_mentions_top_up() {
|
||||
let message = inference_budget_exceeded_user_message();
|
||||
assert!(message.contains("top up"));
|
||||
assert!(message.contains("credits"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,17 +39,26 @@ pub async fn load_config_with_timeout() -> Result<Config, String> {
|
||||
|
||||
/// Returns the default workspace directory fallback (~/.openhuman/workspace).
|
||||
fn fallback_workspace_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".openhuman")
|
||||
crate::openhuman::config::default_root_openhuman_dir()
|
||||
.unwrap_or_else(|_| env_scoped_fallback_root_dir())
|
||||
.join("workspace")
|
||||
}
|
||||
|
||||
/// Returns the default OpenHuman configuration directory (~/.openhuman).
|
||||
fn default_openhuman_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".openhuman")
|
||||
crate::openhuman::config::default_root_openhuman_dir()
|
||||
.unwrap_or_else(|_| env_scoped_fallback_root_dir())
|
||||
}
|
||||
|
||||
fn env_scoped_fallback_root_dir() -> PathBuf {
|
||||
let suffix = if crate::api::config::is_staging_app_env(
|
||||
crate::api::config::app_env_from_env().as_deref(),
|
||||
) {
|
||||
"-staging"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
PathBuf::from(format!(".openhuman{suffix}"))
|
||||
}
|
||||
|
||||
/// Returns the path to the active workspace marker file.
|
||||
|
||||
@@ -33,6 +33,14 @@ fn default_config_dir() -> Result<PathBuf> {
|
||||
default_root_openhuman_dir()
|
||||
}
|
||||
|
||||
fn default_root_dir_name() -> &'static str {
|
||||
if crate::api::config::is_staging_app_env(crate::api::config::app_env_from_env().as_deref()) {
|
||||
".openhuman-staging"
|
||||
} else {
|
||||
".openhuman"
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the root openhuman directory (`~/.openhuman`), independent of any
|
||||
/// per-user scoping. Used to locate `active_user.toml` and the shared
|
||||
/// `users/` tree.
|
||||
@@ -40,7 +48,7 @@ pub fn default_root_openhuman_dir() -> Result<PathBuf> {
|
||||
let home = UserDirs::new()
|
||||
.map(|u| u.home_dir().to_path_buf())
|
||||
.context("Could not find home directory")?;
|
||||
Ok(home.join(".openhuman"))
|
||||
Ok(home.join(default_root_dir_name()))
|
||||
}
|
||||
|
||||
fn active_workspace_state_path(default_dir: &Path) -> PathBuf {
|
||||
@@ -1261,4 +1269,21 @@ mod tests {
|
||||
PathBuf::from("/home/test/.openhuman/users").join(PRE_LOGIN_USER_ID)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_root_dir_name_uses_staging_suffix_for_staging_env() {
|
||||
let prior = std::env::var(crate::api::config::APP_ENV_VAR).ok();
|
||||
|
||||
std::env::set_var(crate::api::config::APP_ENV_VAR, "staging");
|
||||
assert!(crate::api::config::is_staging_app_env(Some("staging")));
|
||||
assert_eq!(default_root_dir_name(), ".openhuman-staging");
|
||||
|
||||
std::env::set_var(crate::api::config::APP_ENV_VAR, "production");
|
||||
assert_eq!(default_root_dir_name(), ".openhuman");
|
||||
|
||||
match prior {
|
||||
Some(value) => std::env::set_var(crate::api::config::APP_ENV_VAR, value),
|
||||
None => std::env::remove_var(crate::api::config::APP_ENV_VAR),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,9 +198,19 @@ pub struct Config {
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let home =
|
||||
UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
|
||||
let openhuman_dir = home.join(".openhuman");
|
||||
let openhuman_dir =
|
||||
crate::openhuman::config::default_root_openhuman_dir().unwrap_or_else(|_| {
|
||||
let home = UserDirs::new()
|
||||
.map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
|
||||
let dir_name = if crate::api::config::is_staging_app_env(
|
||||
crate::api::config::app_env_from_env().as_deref(),
|
||||
) {
|
||||
".openhuman-staging"
|
||||
} else {
|
||||
".openhuman"
|
||||
};
|
||||
home.join(dir_name)
|
||||
});
|
||||
|
||||
Self {
|
||||
workspace_dir: openhuman_dir.join("workspace"),
|
||||
|
||||
@@ -101,11 +101,14 @@ impl EncryptionKey {
|
||||
}
|
||||
|
||||
/// Get the path to the OpenHuman data directory.
|
||||
/// If an active user is set, returns the user-scoped directory
|
||||
/// (`~/.openhuman/users/{user_id}`); otherwise falls back to `~/.openhuman/`.
|
||||
/// If an active user is set, returns the user-scoped directory under the
|
||||
/// env-aware root returned by `default_root_openhuman_dir()`
|
||||
/// (for example `~/.openhuman/users/{user_id}` in production or
|
||||
/// `~/.openhuman-staging/users/{user_id}` when `OPENHUMAN_APP_ENV=staging`);
|
||||
/// otherwise it falls back to that root directory itself.
|
||||
pub fn get_data_dir() -> Result<PathBuf, String> {
|
||||
let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
|
||||
let root_dir = home.join(".openhuman");
|
||||
let root_dir = crate::openhuman::config::default_root_openhuman_dir()
|
||||
.map_err(|e| format!("Cannot determine app data directory: {e}"))?;
|
||||
std::fs::create_dir_all(&root_dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {e}"))?;
|
||||
|
||||
@@ -121,7 +124,8 @@ pub fn get_data_dir() -> Result<PathBuf, String> {
|
||||
Ok(data_dir)
|
||||
}
|
||||
|
||||
/// Get the path to the encryption key file (~/.openhuman/encryption.key).
|
||||
/// Get the path to the encryption key file under the env-aware OpenHuman root
|
||||
/// (for example `~/.openhuman/encryption.key` or `~/.openhuman-staging/encryption.key`).
|
||||
fn get_key_file_path() -> Result<PathBuf, String> {
|
||||
Ok(get_data_dir()?.join("encryption.key"))
|
||||
}
|
||||
|
||||
@@ -49,9 +49,8 @@ pub fn init(workspace_dir: PathBuf) -> Result<MemoryClientRef, String> {
|
||||
|
||||
/// Initialise using the default `.openhuman/workspace` directory.
|
||||
pub fn init_default() -> Result<MemoryClientRef, String> {
|
||||
let workspace_dir = dirs::home_dir()
|
||||
.ok_or_else(|| "failed to resolve home directory".to_string())?
|
||||
.join(".openhuman")
|
||||
let workspace_dir = crate::openhuman::config::default_root_openhuman_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("workspace");
|
||||
init(workspace_dir)
|
||||
}
|
||||
|
||||
@@ -64,9 +64,8 @@ impl MemoryClient {
|
||||
/// Returns an error string if the home directory cannot be resolved or if
|
||||
/// initialization fails.
|
||||
pub fn new_local() -> Result<Self, String> {
|
||||
let workspace_dir = dirs::home_dir()
|
||||
.ok_or_else(|| "Failed to resolve home directory".to_string())?
|
||||
.join(".openhuman")
|
||||
let workspace_dir = crate::openhuman::config::default_root_openhuman_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("workspace");
|
||||
Self::from_workspace_dir(workspace_dir)
|
||||
}
|
||||
|
||||
@@ -6727,6 +6727,11 @@ react-dom@^19.1.0:
|
||||
dependencies:
|
||||
scheduler "^0.27.0"
|
||||
|
||||
react-icons@^5.6.0:
|
||||
version "5.6.0"
|
||||
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.6.0.tgz#27bcc4acbc836e762548d76041cf9b9fef4e3837"
|
||||
integrity sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==
|
||||
|
||||
react-is@^16.13.1:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
|
||||
|
||||
Reference in New Issue
Block a user