diff --git a/src/locales/en/main.json b/src/locales/en/main.json index df44b4f1688..e88eaf2cbcb 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1924,25 +1924,29 @@ }, "tiers": { "founder": { - "name": "Founder's Edition Standard", + "name": "Founder's Edition", "price": "20.00", "benefits": { - "monthlyCredits": "5,460 monthly credits", - "maxDuration": "30 min max duration of each workflow run", - "rtx6000": "RTX 6000 Pro (96GB VRAM)", - "addCredits": "Add more credits whenever" + "monthlyCredits": "5,460", + "monthlyCreditsLabel": "monthly credits", + "maxDuration": "30 min", + "maxDurationLabel": "max duration of each workflow run", + "gpuLabel": "RTX 6000 Pro (96GB VRAM)", + "addCreditsLabel": "Add more credits whenever", + "customLoRAsLabel": "Import your own LoRAs" } }, "standard": { "name": "Standard", "price": "20.00", "benefits": { - "monthlyCredits": "4,200 monthly credits", - "maxDuration": "30 min max duration of each workflow run", - "rtx6000": "RTX 6000 Pro (96GB VRAM)", - "addCredits": "Add more credits whenever", - "customLoRAs": "Import your own LoRAs", - "videoEstimate": "164" + "monthlyCredits": "4,200", + "monthlyCreditsLabel": "monthly credits", + "maxDuration": "30 min", + "maxDurationLabel": "max duration of each workflow run", + "gpuLabel": "RTX 6000 Pro (96GB VRAM)", + "addCreditsLabel": "Add more credits whenever", + "customLoRAsLabel": "Import your own LoRAs" } }, "creator": { @@ -1962,12 +1966,13 @@ "name": "Pro", "price": "100.00", "benefits": { - "monthlyCredits": "21,100 monthly credits", - "maxDuration": "1 hr max duration of each workflow run", - "rtx6000": "RTX 6000 Pro (96GB VRAM)", - "addCredits": "Add more credits whenever", - "customLoRAs": "Import your own LoRAs", - "videoEstimate": "821" + "monthlyCredits": "21,100", + "monthlyCreditsLabel": "monthly credits", + "maxDuration": "1 hr", + "maxDurationLabel": "max duration of each workflow run", + "gpuLabel": "RTX 6000 Pro (96GB VRAM)", + "addCreditsLabel": "Add more credits whenever", + "customLoRAsLabel": "Import your own LoRAs" } } }, diff --git a/src/platform/cloud/subscription/components/SubscriptionPanel.vue b/src/platform/cloud/subscription/components/SubscriptionPanel.vue index 3a342e99c27..d5db565e8f7 100644 --- a/src/platform/cloud/subscription/components/SubscriptionPanel.vue +++ b/src/platform/cloud/subscription/components/SubscriptionPanel.vue @@ -353,8 +353,21 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions' import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits' import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' +import type { components } from '@/types/comfyRegistryTypes' import { cn } from '@/utils/tailwindUtil' +type SubscriptionTier = components['schemas']['SubscriptionTier'] + +/** Maps API subscription tier values to i18n translation keys */ +const TIER_TO_I18N_KEY: Record = { + STANDARD: 'standard', + CREATOR: 'creator', + PRO: 'pro', + FOUNDERS_EDITION: 'founder' +} + +const DEFAULT_TIER_KEY = 'standard' + const { buildDocsUrl } = useExternalLink() const { t } = useI18n() @@ -363,14 +376,20 @@ const { isCancelled, formattedRenewalDate, formattedEndDate, + subscriptionTier, handleInvoiceHistory } = useSubscription() const { show: showSubscriptionDialog } = useSubscriptionDialog() -// Tier data - hardcoded for Creator tier as requested -const tierName = computed(() => t('subscription.tiers.creator.name')) -const tierPrice = computed(() => t('subscription.tiers.creator.price')) +const tierKey = computed(() => { + const tier = subscriptionTier.value + if (!tier) return DEFAULT_TIER_KEY + return TIER_TO_I18N_KEY[tier] ?? DEFAULT_TIER_KEY +}) + +const tierName = computed(() => t(`subscription.tiers.${tierKey.value}.name`)) +const tierPrice = computed(() => t(`subscription.tiers.${tierKey.value}.price`)) // Tier benefits for v-for loop type BenefitType = 'metric' | 'feature' @@ -383,33 +402,34 @@ interface Benefit { } const tierBenefits = computed(() => { + const key = tierKey.value const baseBenefits: Benefit[] = [ { key: 'monthlyCredits', type: 'metric', - value: t('subscription.tiers.creator.benefits.monthlyCredits'), - label: t('subscription.tiers.creator.benefits.monthlyCreditsLabel') + value: t(`subscription.tiers.${key}.benefits.monthlyCredits`), + label: t(`subscription.tiers.${key}.benefits.monthlyCreditsLabel`) }, { key: 'maxDuration', type: 'metric', - value: t('subscription.tiers.creator.benefits.maxDuration'), - label: t('subscription.tiers.creator.benefits.maxDurationLabel') + value: t(`subscription.tiers.${key}.benefits.maxDuration`), + label: t(`subscription.tiers.${key}.benefits.maxDurationLabel`) }, { key: 'gpu', type: 'feature', - label: t('subscription.tiers.creator.benefits.gpuLabel') + label: t(`subscription.tiers.${key}.benefits.gpuLabel`) }, { key: 'addCredits', type: 'feature', - label: t('subscription.tiers.creator.benefits.addCreditsLabel') + label: t(`subscription.tiers.${key}.benefits.addCreditsLabel`) }, { key: 'customLoRAs', type: 'feature', - label: t('subscription.tiers.creator.benefits.customLoRAsLabel') + label: t(`subscription.tiers.${key}.benefits.customLoRAsLabel`) } ] diff --git a/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue b/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue index 2dd4ae548ce..ddb1680d8b6 100644 --- a/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue +++ b/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue @@ -139,6 +139,7 @@ import { computed, onBeforeUnmount, watch } from 'vue' import CloudBadge from '@/components/topbar/CloudBadge.vue' import { useFeatureFlags } from '@/composables/useFeatureFlags' +import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig' import StripePricingTable from '@/platform/cloud/subscription/components/StripePricingTable.vue' import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue' import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue' @@ -155,8 +156,18 @@ const emit = defineEmits<{ close: [subscribed: boolean] }>() -const { formattedMonthlyPrice, fetchStatus, isActiveSubscription } = - useSubscription() +const { fetchStatus, isActiveSubscription } = useSubscription() + +// Legacy price for non-tier flow with locale-aware formatting +const formattedMonthlyPrice = new Intl.NumberFormat( + navigator.language || 'en-US', + { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0 + } +).format(MONTHLY_SUBSCRIPTION_PRICE) const { featureFlag } = useFeatureFlags() const subscriptionTiersEnabled = featureFlag( 'subscription_tiers_enabled', diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 9762647d830..4e6c7aba2dc 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -5,7 +5,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { useErrorHandling } from '@/composables/useErrorHandling' import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi' -import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig' import { t } from '@/i18n' import { isCloud } from '@/platform/distribution/types' import { useTelemetry } from '@/platform/telemetry' @@ -14,18 +13,16 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import { useDialogService } from '@/services/dialogService' +import type { operations } from '@/types/comfyRegistryTypes' import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher' type CloudSubscriptionCheckoutResponse = { checkout_url: string } -export type CloudSubscriptionStatusResponse = { - is_active: boolean - subscription_id: string - renewal_date: string | null - end_date?: string | null -} +export type CloudSubscriptionStatusResponse = NonNullable< + operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json'] +> function useSubscriptionInternal() { const subscriptionStatus = ref(null) @@ -72,8 +69,8 @@ function useSubscriptionInternal() { }) }) - const formattedMonthlyPrice = computed( - () => `$${MONTHLY_SUBSCRIPTION_PRICE.toFixed(0)}` + const subscriptionTier = computed( + () => subscriptionStatus.value?.subscription_tier ?? null ) const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}` @@ -227,7 +224,7 @@ function useSubscriptionInternal() { isCancelled, formattedRenewalDate, formattedEndDate, - formattedMonthlyPrice, + subscriptionTier, // Actions subscribe, diff --git a/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts b/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts index 5be9f7d80ca..6bed98305a8 100644 --- a/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts +++ b/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts @@ -1,18 +1,25 @@ import { createTestingPinia } from '@pinia/testing' import { mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { computed, ref } from 'vue' import { createI18n } from 'vue-i18n' import SubscriptionPanel from '@/platform/cloud/subscription/components/SubscriptionPanel.vue' -// Mock composables +// Mock state refs that can be modified between tests +const mockIsActiveSubscription = ref(false) +const mockIsCancelled = ref(false) +const mockSubscriptionTier = ref< + 'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null +>('CREATOR') + +// Mock composables - using computed to match composable return types const mockSubscriptionData = { - isActiveSubscription: false, - isCancelled: false, - formattedRenewalDate: '2024-12-31', - formattedEndDate: '2024-12-31', - formattedMonthlyPrice: '$9.99', - manageSubscription: vi.fn(), + isActiveSubscription: computed(() => mockIsActiveSubscription.value), + isCancelled: computed(() => mockIsCancelled.value), + formattedRenewalDate: computed(() => '2024-12-31'), + formattedEndDate: computed(() => '2024-12-31'), + subscriptionTier: computed(() => mockSubscriptionTier.value), handleInvoiceHistory: vi.fn() } @@ -50,6 +57,15 @@ vi.mock( }) ) +vi.mock( + '@/platform/cloud/subscription/composables/useSubscriptionDialog', + () => ({ + useSubscriptionDialog: () => ({ + show: vi.fn() + }) + }) +) + // Create i18n instance for testing const i18n = createI18n({ legacy: false, @@ -58,12 +74,15 @@ const i18n = createI18n({ en: { subscription: { title: 'Subscription', + titleUnsubscribed: 'Subscribe', perMonth: '/ month', subscribeNow: 'Subscribe Now', manageSubscription: 'Manage Subscription', partnerNodesBalance: 'Partner Nodes Balance', partnerNodesDescription: 'Credits for partner nodes', totalCredits: 'Total Credits', + creditsRemainingThisMonth: 'Credits remaining this month', + creditsYouveAdded: "Credits you've added", monthlyBonusDescription: 'Monthly bonus', prepaidDescription: 'Prepaid credits', monthlyCreditsRollover: 'Monthly credits rollover info', @@ -71,11 +90,67 @@ const i18n = createI18n({ viewUsageHistory: 'View Usage History', addCredits: 'Add Credits', yourPlanIncludes: 'Your plan includes', + viewMoreDetailsPlans: 'View more details about plans & pricing', learnMore: 'Learn More', messageSupport: 'Message Support', invoiceHistory: 'Invoice History', + partnerNodesCredits: 'Partner nodes pricing', renewsDate: 'Renews {date}', - expiresDate: 'Expires {date}' + expiresDate: 'Expires {date}', + tiers: { + founder: { + name: "Founder's Edition", + price: '20.00', + benefits: { + monthlyCredits: '5,460', + monthlyCreditsLabel: 'monthly credits', + maxDuration: '30 min', + maxDurationLabel: 'max duration of each workflow run', + gpuLabel: 'RTX 6000 Pro (96GB VRAM)', + addCreditsLabel: 'Add more credits whenever', + customLoRAsLabel: 'Import your own LoRAs' + } + }, + standard: { + name: 'Standard', + price: '20.00', + benefits: { + monthlyCredits: '4,200', + monthlyCreditsLabel: 'monthly credits', + maxDuration: '30 min', + maxDurationLabel: 'max duration of each workflow run', + gpuLabel: 'RTX 6000 Pro (96GB VRAM)', + addCreditsLabel: 'Add more credits whenever', + customLoRAsLabel: 'Import your own LoRAs' + } + }, + creator: { + name: 'Creator', + price: '35.00', + benefits: { + monthlyCredits: '7,400', + monthlyCreditsLabel: 'monthly credits', + maxDuration: '30 min', + maxDurationLabel: 'max duration of each workflow run', + gpuLabel: 'RTX 6000 Pro (96GB VRAM)', + addCreditsLabel: 'Add more credits whenever', + customLoRAsLabel: 'Import your own LoRAs' + } + }, + pro: { + name: 'Pro', + price: '100.00', + benefits: { + monthlyCredits: '21,100', + monthlyCreditsLabel: 'monthly credits', + maxDuration: '1 hr', + maxDurationLabel: 'max duration of each workflow run', + gpuLabel: 'RTX 6000 Pro (96GB VRAM)', + addCreditsLabel: 'Add more credits whenever', + customLoRAsLabel: 'Import your own LoRAs' + } + } + } } } } @@ -116,18 +191,22 @@ function createWrapper(overrides = {}) { describe('SubscriptionPanel', () => { beforeEach(() => { vi.clearAllMocks() + // Reset mock state + mockIsActiveSubscription.value = false + mockIsCancelled.value = false + mockSubscriptionTier.value = 'CREATOR' }) describe('subscription state functionality', () => { it('shows correct UI for active subscription', () => { - mockSubscriptionData.isActiveSubscription = true + mockIsActiveSubscription.value = true const wrapper = createWrapper() expect(wrapper.text()).toContain('Manage Subscription') expect(wrapper.text()).toContain('Add Credits') }) it('shows correct UI for inactive subscription', () => { - mockSubscriptionData.isActiveSubscription = false + mockIsActiveSubscription.value = false const wrapper = createWrapper() expect(wrapper.findComponent({ name: 'SubscribeButton' }).exists()).toBe( true @@ -137,18 +216,32 @@ describe('SubscriptionPanel', () => { }) it('shows renewal date for active non-cancelled subscription', () => { - mockSubscriptionData.isActiveSubscription = true - mockSubscriptionData.isCancelled = false + mockIsActiveSubscription.value = true + mockIsCancelled.value = false const wrapper = createWrapper() expect(wrapper.text()).toContain('Renews 2024-12-31') }) it('shows expiry date for cancelled subscription', () => { - mockSubscriptionData.isActiveSubscription = true - mockSubscriptionData.isCancelled = true + mockIsActiveSubscription.value = true + mockIsCancelled.value = true const wrapper = createWrapper() expect(wrapper.text()).toContain('Expires 2024-12-31') }) + + it('displays FOUNDERS_EDITION tier correctly', () => { + mockSubscriptionTier.value = 'FOUNDERS_EDITION' + const wrapper = createWrapper() + expect(wrapper.text()).toContain("Founder's Edition") + expect(wrapper.text()).toContain('5,460') + }) + + it('displays CREATOR tier correctly', () => { + mockSubscriptionTier.value = 'CREATOR' + const wrapper = createWrapper() + expect(wrapper.text()).toContain('Creator') + expect(wrapper.text()).toContain('7,400') + }) }) describe('credit display functionality', () => { diff --git a/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts b/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts index 1305e9f156a..9409709d3aa 100644 --- a/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts +++ b/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts @@ -152,10 +152,28 @@ describe('useSubscription', () => { expect(formattedRenewalDate.value).toBe('') }) - it('should format monthly price correctly', () => { - const { formattedMonthlyPrice } = useSubscription() + it('should return subscription tier from status', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + is_active: true, + subscription_id: 'sub_123', + subscription_tier: 'CREATOR', + renewal_date: '2025-11-16T12:00:00Z' + }) + } as Response) + + mockIsLoggedIn.value = true + const { subscriptionTier, fetchStatus } = useSubscription() + + await fetchStatus() + expect(subscriptionTier.value).toBe('CREATOR') + }) + + it('should return null when subscription tier is not available', () => { + const { subscriptionTier } = useSubscription() - expect(formattedMonthlyPrice.value).toBe('$20') + expect(subscriptionTier.value).toBeNull() }) })