-
Notifications
You must be signed in to change notification settings - Fork 392
fix(clerk-js): Hide CTA for <PricingTable forOrganization/>
and orgId is null
#6883
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@clerk/clerk-js': patch | ||
--- | ||
|
||
Hide CTA for `<PricingTable forOrganization/>` when the user is does not have an active organization selected. | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,126 @@ | ||
import type { BillingPlanResource, BillingSubscriptionItemResource, BillingSubscriptionPlanPeriod } from '@clerk/types'; | ||
import { describe, expect, it } from 'vitest'; | ||
|
||
import { getPricingFooterState } from './pricing-footer-state'; | ||
|
||
const basePlan: BillingPlanResource = { | ||
id: 'plan_1', | ||
name: 'Pro', | ||
fee: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, | ||
annualFee: { amount: 10000, amountFormatted: '100.00', currency: 'USD', currencySymbol: '$' }, | ||
annualMonthlyFee: { amount: 833, amountFormatted: '8.33', currency: 'USD', currencySymbol: '$' }, | ||
description: 'desc', | ||
isDefault: false, | ||
isRecurring: true, | ||
hasBaseFee: true, | ||
forPayerType: 'user', | ||
publiclyVisible: true, | ||
slug: 'pro', | ||
avatarUrl: '', | ||
features: [], | ||
freeTrialDays: 14, | ||
freeTrialEnabled: true, | ||
pathRoot: '', | ||
reload: async () => undefined as any, | ||
}; | ||
|
||
const makeSub = (overrides: Partial<BillingSubscriptionItemResource>): BillingSubscriptionItemResource => ({ | ||
id: 'si_1', | ||
plan: basePlan, | ||
planPeriod: 'month', | ||
status: 'active', | ||
createdAt: new Date('2021-01-01'), | ||
paymentSourceId: 'src_1', | ||
pastDueAt: null, | ||
periodStart: new Date('2021-01-01'), | ||
periodEnd: new Date('2021-01-31'), | ||
canceledAt: null, | ||
isFreeTrial: false, | ||
cancel: async () => undefined as any, | ||
pathRoot: '', | ||
reload: async () => undefined as any, | ||
...overrides, | ||
}); | ||
|
||
const run = (args: { | ||
subscription?: BillingSubscriptionItemResource; | ||
plan?: BillingPlanResource; | ||
planPeriod?: BillingSubscriptionPlanPeriod; | ||
forOrganizations?: boolean; | ||
hasActiveOrganization?: boolean; | ||
}) => | ||
getPricingFooterState({ | ||
subscription: args.subscription, | ||
plan: args.plan ?? basePlan, | ||
planPeriod: args.planPeriod ?? 'month', | ||
forOrganizations: args.forOrganizations, | ||
hasActiveOrganization: args.hasActiveOrganization ?? false, | ||
}); | ||
|
||
describe('usePricingFooterState', () => { | ||
it('hides footer when org plans and no active org', () => { | ||
const res = run({ subscription: undefined, forOrganizations: true, hasActiveOrganization: false }); | ||
expect(res).toEqual({ shouldShowFooter: false, shouldShowFooterNotice: false }); | ||
}); | ||
|
||
it('shows footer when no subscription and user plans', () => { | ||
const res = run({ subscription: undefined, forOrganizations: false }); | ||
expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: false }); | ||
}); | ||
|
||
it('shows notice when subscription is upcoming', () => { | ||
const res = run({ subscription: makeSub({ status: 'upcoming' }) }); | ||
expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: true }); | ||
}); | ||
|
||
it('shows footer when active but canceled', () => { | ||
const res = run({ subscription: makeSub({ status: 'active', canceledAt: new Date('2021-02-01') }) }); | ||
expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: false }); | ||
}); | ||
|
||
it('shows footer when switching period to paid annual', () => { | ||
const res = run({ | ||
subscription: makeSub({ status: 'active', planPeriod: 'month' }), | ||
planPeriod: 'annual', | ||
plan: basePlan, | ||
}); | ||
expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: false }); | ||
}); | ||
|
||
it('shows notice when active free trial', () => { | ||
const res = run({ subscription: makeSub({ status: 'active', isFreeTrial: true }) }); | ||
expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: true }); | ||
}); | ||
|
||
it('hides footer when active and matching period without trial', () => { | ||
const res = run({ subscription: makeSub({ status: 'active', planPeriod: 'month', isFreeTrial: false }) }); | ||
expect(res).toEqual({ shouldShowFooter: false, shouldShowFooterNotice: false }); | ||
}); | ||
|
||
it('shows footer when switching period to paid monthly', () => { | ||
const res = run({ | ||
subscription: makeSub({ status: 'active', planPeriod: 'annual' }), | ||
planPeriod: 'month', | ||
plan: basePlan, | ||
}); | ||
expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: false }); | ||
}); | ||
|
||
it('does not show footer when switching period if annualMonthlyFee is 0', () => { | ||
const freeAnnualPlan: BillingPlanResource = { | ||
...basePlan, | ||
annualMonthlyFee: { ...basePlan.annualMonthlyFee, amount: 0, amountFormatted: '0.00' }, | ||
}; | ||
const res = run({ | ||
subscription: makeSub({ status: 'active', planPeriod: 'month' }), | ||
planPeriod: 'annual', | ||
plan: freeAnnualPlan, | ||
}); | ||
expect(res).toEqual({ shouldShowFooter: false, shouldShowFooterNotice: false }); | ||
}); | ||
|
||
it('hides footer when subscription is past_due', () => { | ||
const res = run({ subscription: makeSub({ status: 'past_due' }) }); | ||
expect(res).toEqual({ shouldShowFooter: false, shouldShowFooterNotice: false }); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
import type { BillingPlanResource, BillingSubscriptionItemResource, BillingSubscriptionPlanPeriod } from '@clerk/types'; | ||
|
||
type UsePricingFooterStateParams = { | ||
subscription: BillingSubscriptionItemResource | undefined; | ||
plan: BillingPlanResource; | ||
planPeriod: BillingSubscriptionPlanPeriod; | ||
forOrganizations?: boolean; | ||
hasActiveOrganization: boolean; | ||
}; | ||
|
||
/** | ||
* Calculates the correct show/hide state for the footer of a card in the `<PricingTableDefault/>` component. | ||
* @returns [shouldShowFooter, shouldShowFooterNotice] | ||
*/ | ||
const valueResolution = (params: UsePricingFooterStateParams): [boolean, boolean] => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🙃 Can we document the return type as JSDoc? So it's easier to read the tuples down in the code There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. any specific reason to return [bool, bool] instead of an object? It's probably a pattern but asking as I'm not familliar with the SDK codebase There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just saving on bytes. No other reason. |
||
const { subscription, plan, planPeriod, forOrganizations, hasActiveOrganization } = params; | ||
const show_with_notice: [boolean, boolean] = [true, true]; | ||
const show_without_notice: [boolean, boolean] = [true, false]; | ||
const hide: [boolean, boolean] = [false, false]; | ||
|
||
// No subscription | ||
if (!subscription) { | ||
if (forOrganizations && !hasActiveOrganization) { | ||
return hide; | ||
} | ||
return show_without_notice; | ||
} | ||
|
||
// Upcoming subscription | ||
if (subscription.status === 'upcoming') { | ||
return show_with_notice; | ||
} | ||
|
||
// Active subscription | ||
if (subscription.status === 'active') { | ||
const isCanceled = !!subscription.canceledAt; | ||
const isSwitchingPaidPeriod = planPeriod !== subscription.planPeriod && plan.annualMonthlyFee.amount > 0; | ||
const isActiveFreeTrial = plan.freeTrialEnabled && subscription.isFreeTrial; | ||
|
||
if (isCanceled || isSwitchingPaidPeriod) { | ||
return show_without_notice; | ||
} | ||
|
||
if (isActiveFreeTrial) { | ||
return show_with_notice; | ||
} | ||
|
||
return hide; | ||
} | ||
return hide; | ||
}; | ||
|
||
export const getPricingFooterState = ( | ||
params: UsePricingFooterStateParams, | ||
): { shouldShowFooter: boolean; shouldShowFooterNotice: boolean } => { | ||
const [shouldShowFooter, shouldShowFooterNotice] = valueResolution(params); | ||
return { shouldShowFooter, shouldShowFooterNotice }; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix grammar in the changeset entry.
Please drop the extra “is” so the sentence reads naturally.
📝 Committable suggestion
🤖 Prompt for AI Agents