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:
Steven Enamakel
2026-04-13 18:10:07 -07:00
committed by GitHub
parent f26a0c50b0
commit 5a8a7edb91
58 changed files with 3086 additions and 1971 deletions
+13 -3
View File
@@ -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
+15 -2
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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",
+51
View File
@@ -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"
+88 -130
View File
@@ -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&apos;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>
);
}
@@ -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&apos;s referral code. You&apos;re eligible if you haven&apos;t subscribed
yet once you subscribe, you&apos;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&apos;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&apos;s referral code. You&apos;re eligible if you haven&apos;t
subscribed yet once you subscribe, you&apos;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&apos;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(
+19 -2
View File
@@ -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',
];
+164
View File
@@ -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" />,
};
+36
View File
@@ -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);
});
});
+9
View File
@@ -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,
};
+2 -1
View File
@@ -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);
+10
View File
@@ -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;
}
+32 -32
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)}
/>
)}
+85 -6
View File
@@ -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 });
});
});
+50 -10
View File
@@ -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
),
};
}
+9 -1
View File
@@ -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 =
+1
View File
@@ -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
View File
@@ -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
);
}
}
+66 -2
View File
@@ -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"));
}
}
+15 -6
View File
@@ -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.
+26 -1
View 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),
}
}
}
+13 -3
View File
@@ -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"),
+9 -5
View File
@@ -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"))
}
+2 -3
View File
@@ -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)
}
+2 -3
View File
@@ -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)
}
+5
View File
@@ -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"