SubscriptionPlanUpdateDialog.tsx601 lines · main
| 1 | import { Elements } from '@stripe/react-stripe-js' |
| 2 | import { loadStripe, PaymentIntentResult, StripeElementsOptions } from '@stripe/stripe-js' |
| 3 | import { useParams } from 'common' |
| 4 | import { Check, InfoIcon } from 'lucide-react' |
| 5 | import { useTheme } from 'next-themes' |
| 6 | import Link from 'next/link' |
| 7 | import { useMemo, useRef, useState } from 'react' |
| 8 | import { plans as subscriptionsPlans } from 'shared-data/plans' |
| 9 | import { toast } from 'sonner' |
| 10 | import { Button, cn, Dialog, DialogContent } from 'ui' |
| 11 | import { Admonition } from 'ui-patterns' |
| 12 | import { InfoTooltip } from 'ui-patterns/info-tooltip' |
| 13 | import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' |
| 14 | |
| 15 | import { InvoiceEstimateTooltip } from './InvoiceEstimateTooltip' |
| 16 | import PaymentMethodSelection from './PaymentMethodSelection' |
| 17 | import { getStripeElementsAppearanceOptions } from '@/components/interfaces/Billing/Payment/Payment.utils' |
| 18 | import { PaymentConfirmation } from '@/components/interfaces/Billing/Payment/PaymentConfirmation' |
| 19 | import type { PaymentMethodElementRef } from '@/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement' |
| 20 | import { |
| 21 | billingPartnerLabel, |
| 22 | getPlanChangeType, |
| 23 | } from '@/components/interfaces/Billing/Subscription/Subscription.utils' |
| 24 | import { type OrganizationBillingSubscriptionPreviewQueryResult } from '@/data/organizations/organization-billing-subscription-preview' |
| 25 | import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types' |
| 26 | import { OrgProject } from '@/data/projects/org-projects-infinite-query' |
| 27 | import { useConfirmPendingSubscriptionChangeMutation } from '@/data/subscriptions/org-subscription-confirm-pending-change' |
| 28 | import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' |
| 29 | import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation' |
| 30 | import { OrgPlan, SubscriptionTier } from '@/data/subscriptions/types' |
| 31 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 32 | import { |
| 33 | DOCS_URL, |
| 34 | PRICING_TIER_PRODUCT_IDS, |
| 35 | PROJECT_STATUS, |
| 36 | STRIPE_PUBLIC_KEY, |
| 37 | } from '@/lib/constants' |
| 38 | import { formatCurrency } from '@/lib/helpers' |
| 39 | |
| 40 | const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) |
| 41 | |
| 42 | const PLAN_HEADINGS = { |
| 43 | tier_pro: |
| 44 | 'the Pro plan to unlock more compute resources, daily backups, no project pausing, and email support whenever you need it', |
| 45 | tier_team: 'the Team plan for SOC2, SSO, priority support and greater data and log retention', |
| 46 | default: 'to a new plan', |
| 47 | } as const |
| 48 | |
| 49 | type PlanHeadingKey = keyof typeof PLAN_HEADINGS |
| 50 | |
| 51 | // Add downgrade headings |
| 52 | const DOWNGRADE_PLAN_HEADINGS = { |
| 53 | tier_free: 'the Free plan with limited resources and active projects', |
| 54 | tier_pro: 'the Pro plan', |
| 55 | default: 'to a lower plan', |
| 56 | } as const |
| 57 | |
| 58 | type DowngradePlanHeadingKey = keyof typeof DOWNGRADE_PLAN_HEADINGS |
| 59 | |
| 60 | type BreakdownItem = |
| 61 | | { type: 'amount'; label: string; amount: number; tooltip?: string } |
| 62 | | { type: 'notice'; label: string } |
| 63 | |
| 64 | interface Props { |
| 65 | selectedTier: 'tier_free' | 'tier_pro' | 'tier_team' | undefined |
| 66 | onClose: () => void |
| 67 | planMeta?: OrgPlan | null |
| 68 | currentPlanMeta?: Partial<OrgPlan> & { features: (string | string[])[] } |
| 69 | subscriptionPreviewQueryResult: OrganizationBillingSubscriptionPreviewQueryResult |
| 70 | projects: OrgProject[] |
| 71 | onAddressChange?: (address: CustomerAddress) => void |
| 72 | onTaxIdChange?: (taxId: CustomerTaxId | null) => void |
| 73 | useAsDefaultBillingAddress: boolean |
| 74 | onUseAsDefaultBillingAddressChange: (useAsDefault: boolean) => void |
| 75 | } |
| 76 | |
| 77 | export const SubscriptionPlanUpdateDialog = ({ |
| 78 | selectedTier, |
| 79 | onClose, |
| 80 | planMeta, |
| 81 | subscriptionPreviewQueryResult, |
| 82 | currentPlanMeta, |
| 83 | projects, |
| 84 | onAddressChange, |
| 85 | onTaxIdChange, |
| 86 | useAsDefaultBillingAddress, |
| 87 | onUseAsDefaultBillingAddressChange, |
| 88 | }: Props) => { |
| 89 | const { slug } = useParams() |
| 90 | const { resolvedTheme } = useTheme() |
| 91 | const { data: selectedOrganization } = useSelectedOrganizationQuery() |
| 92 | const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>() |
| 93 | const [paymentIntentSecret, setPaymentIntentSecret] = useState<string | null>(null) |
| 94 | const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) |
| 95 | const paymentMethodSelectionRef = useRef<{ |
| 96 | createPaymentMethod: PaymentMethodElementRef['createPaymentMethod'] |
| 97 | validateBillingProfile: () => Promise<boolean> |
| 98 | }>(null) |
| 99 | |
| 100 | const { |
| 101 | data: subscriptionPreview, |
| 102 | isPending: subscriptionPreviewIsLoading, |
| 103 | isFetching: subscriptionPreviewIsFetching, |
| 104 | isSuccess: subscriptionPreviewInitialized, |
| 105 | } = subscriptionPreviewQueryResult |
| 106 | |
| 107 | const { data: subscription } = useOrgSubscriptionQuery({ |
| 108 | orgSlug: slug, |
| 109 | }) |
| 110 | const billingViaPartner = subscription?.billing_via_partner === true |
| 111 | const billingPartner = subscription?.billing_partner |
| 112 | |
| 113 | const stripeOptionsConfirm = useMemo(() => { |
| 114 | return { |
| 115 | clientSecret: paymentIntentSecret, |
| 116 | appearance: getStripeElementsAppearanceOptions(resolvedTheme), |
| 117 | } as StripeElementsOptions |
| 118 | }, [paymentIntentSecret, resolvedTheme]) |
| 119 | |
| 120 | const changeType = useMemo(() => { |
| 121 | return getPlanChangeType(subscription?.plan?.id, planMeta?.id) |
| 122 | }, [planMeta, subscription]) |
| 123 | |
| 124 | const subscriptionPlanMeta = useMemo( |
| 125 | () => subscriptionsPlans.find((tier) => tier.id === selectedTier), |
| 126 | [selectedTier] |
| 127 | ) |
| 128 | |
| 129 | const onSuccessfulPlanChange = () => { |
| 130 | setPaymentConfirmationLoading(false) |
| 131 | toast.success( |
| 132 | `Successfully ${changeType === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!` |
| 133 | ) |
| 134 | onClose() |
| 135 | window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) |
| 136 | } |
| 137 | |
| 138 | const { mutate: updateOrgSubscription, isPending: isUpdating } = useOrgSubscriptionUpdateMutation( |
| 139 | { |
| 140 | onSuccess: (data) => { |
| 141 | if (data.pending_payment_intent_secret) { |
| 142 | setPaymentIntentSecret(data.pending_payment_intent_secret) |
| 143 | return |
| 144 | } |
| 145 | |
| 146 | onSuccessfulPlanChange() |
| 147 | }, |
| 148 | onError: (error) => { |
| 149 | setPaymentConfirmationLoading(false) |
| 150 | toast.error(`Unable to update subscription: ${error.message}`) |
| 151 | }, |
| 152 | } |
| 153 | ) |
| 154 | |
| 155 | const { mutate: confirmPendingSubscriptionChange, isPending: isConfirming } = |
| 156 | useConfirmPendingSubscriptionChangeMutation({ |
| 157 | onSuccess: () => { |
| 158 | onSuccessfulPlanChange() |
| 159 | }, |
| 160 | onError: (error) => { |
| 161 | toast.error(`Unable to update subscription: ${error.message}`) |
| 162 | }, |
| 163 | }) |
| 164 | |
| 165 | const paymentIntentConfirmed = async (paymentIntentConfirmation: PaymentIntentResult) => { |
| 166 | // Reset payment intent secret to ensure another attempt works as expected |
| 167 | setPaymentIntentSecret('') |
| 168 | |
| 169 | if (paymentIntentConfirmation.paymentIntent?.status === 'succeeded') { |
| 170 | await confirmPendingSubscriptionChange({ |
| 171 | slug: selectedOrganization?.slug, |
| 172 | payment_intent_id: paymentIntentConfirmation.paymentIntent.id, |
| 173 | }) |
| 174 | } else { |
| 175 | setPaymentConfirmationLoading(false) |
| 176 | // If the payment intent is not successful, we reset the payment method and show an error |
| 177 | toast.error(`Could not confirm payment. Please try again or use a different card.`) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | const onUpdateSubscription = async () => { |
| 182 | if (!selectedOrganization?.slug) return console.error('org slug is required') |
| 183 | if (!selectedTier) return console.error('Selected plan is required') |
| 184 | |
| 185 | setPaymentConfirmationLoading(true) |
| 186 | |
| 187 | if (paymentMethodSelectionRef.current) { |
| 188 | const isValid = await paymentMethodSelectionRef.current.validateBillingProfile() |
| 189 | if (!isValid) { |
| 190 | setPaymentConfirmationLoading(false) |
| 191 | return |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | const result = await paymentMethodSelectionRef.current?.createPaymentMethod() |
| 196 | if (result) { |
| 197 | setSelectedPaymentMethod(result.paymentMethod.id) |
| 198 | } else { |
| 199 | setPaymentConfirmationLoading(false) |
| 200 | } |
| 201 | |
| 202 | if (!result && subscription?.payment_method_type !== 'invoice' && changeType === 'upgrade') { |
| 203 | return |
| 204 | } |
| 205 | |
| 206 | // If the user is downgrading from team, should have spend cap disabled by default |
| 207 | const tier = |
| 208 | subscription?.plan?.id === 'team' && selectedTier === PRICING_TIER_PRODUCT_IDS.PRO |
| 209 | ? (PRICING_TIER_PRODUCT_IDS.PAYG as SubscriptionTier) |
| 210 | : selectedTier |
| 211 | |
| 212 | updateOrgSubscription({ |
| 213 | slug: selectedOrganization?.slug, |
| 214 | tier, |
| 215 | paymentMethod: result?.paymentMethod?.id, |
| 216 | address: result?.address, |
| 217 | tax_id: result?.taxId ?? undefined, |
| 218 | billing_name: result?.customerName ?? undefined, |
| 219 | }) |
| 220 | } |
| 221 | |
| 222 | const features = subscriptionPlanMeta?.features || [] |
| 223 | const topFeatures = features |
| 224 | |
| 225 | // Get current plan features for downgrade comparison |
| 226 | const currentPlanFeatures = currentPlanMeta?.features || [] |
| 227 | |
| 228 | // Features that will be lost when downgrading |
| 229 | const featuresToLose = |
| 230 | changeType === 'downgrade' |
| 231 | ? currentPlanFeatures.filter((feature) => { |
| 232 | const featureStr = typeof feature === 'string' ? feature : feature[0] |
| 233 | // Check if this feature exists in the new plan |
| 234 | return !topFeatures.some((newFeature: string | string[]) => { |
| 235 | const newFeatureStr = typeof newFeature === 'string' ? newFeature : newFeature[0] |
| 236 | return newFeatureStr === featureStr |
| 237 | }) |
| 238 | }) |
| 239 | : [] |
| 240 | |
| 241 | const upfrontCharge = subscriptionPreview?.upfront_charge |
| 242 | |
| 243 | const proratedCredit = upfrontCharge?.prorated_credit ?? 0 |
| 244 | const customerBalance = upfrontCharge?.customer_balance ?? 0 |
| 245 | const totalCharge = upfrontCharge?.total ?? 0 |
| 246 | const tax = upfrontCharge?.tax |
| 247 | const taxableAmount = upfrontCharge?.taxable_amount |
| 248 | const taxStatus = upfrontCharge?.tax_status |
| 249 | const hasTax = taxStatus === 'calculated' && (tax?.tax_amount ?? 0) > 0 |
| 250 | const taxFailed = taxStatus === 'failed' |
| 251 | |
| 252 | const newPlanCost = Number(subscriptionPlanMeta?.priceMonthly) || 0 |
| 253 | |
| 254 | const currentPlanId = subscription?.plan?.id |
| 255 | const currentPlanName = subscription?.plan?.name |
| 256 | |
| 257 | // Derives the itemized charge breakdown rows shown above "Charge today". |
| 258 | // Example: Pro -> Team upgrade with proration, tax, and credits: |
| 259 | // Team Plan $25.00 |
| 260 | // Tax (10%) $1.67 |
| 261 | // Subtotal $26.67 |
| 262 | // Unused Time on Pro -$8.33 |
| 263 | // Credits -$5.00 |
| 264 | // ───────────────────────────── |
| 265 | // Charge today $13.34 |
| 266 | const breakdownItems = useMemo(() => { |
| 267 | const items: BreakdownItem[] = [] |
| 268 | |
| 269 | if (hasTax && tax) { |
| 270 | items.push({ |
| 271 | type: 'amount', |
| 272 | label: `Tax (${tax.tax_rate_percentage}%)`, |
| 273 | amount: tax.tax_amount, |
| 274 | }) |
| 275 | if (taxableAmount !== newPlanCost) { |
| 276 | items.push({ type: 'amount', label: 'Subtotal', amount: taxableAmount! }) |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | if (taxFailed) { |
| 281 | items.push({ |
| 282 | type: 'notice', |
| 283 | label: 'Tax could not be estimated and may be applied separately', |
| 284 | }) |
| 285 | } |
| 286 | |
| 287 | if (currentPlanId !== 'free' && proratedCredit > 0) { |
| 288 | items.push({ |
| 289 | type: 'amount', |
| 290 | label: `Unused Time on ${currentPlanName} Plan`, |
| 291 | amount: -proratedCredit, |
| 292 | tooltip: |
| 293 | 'Your previous plan was charged upfront, so a plan change will prorate any unused time in credits. If the prorated credits exceed the new plan charge, the excessive credits are added to your organization for future use.' + |
| 294 | (hasTax ? ' Includes proportional tax if applicable.' : ''), |
| 295 | }) |
| 296 | } |
| 297 | |
| 298 | if (customerBalance > 0) { |
| 299 | items.push({ |
| 300 | type: 'amount', |
| 301 | label: 'Credits', |
| 302 | amount: -customerBalance, |
| 303 | tooltip: 'Credits will be used first before charging your card.', |
| 304 | }) |
| 305 | } |
| 306 | |
| 307 | // Prepend the plan cost row when there are adjustment items to show |
| 308 | if (items.length > 0) { |
| 309 | items.unshift({ |
| 310 | type: 'amount', |
| 311 | label: `${subscriptionPlanMeta?.name} Plan`, |
| 312 | amount: newPlanCost, |
| 313 | }) |
| 314 | } |
| 315 | |
| 316 | return items |
| 317 | }, [ |
| 318 | currentPlanId, |
| 319 | currentPlanName, |
| 320 | proratedCredit, |
| 321 | hasTax, |
| 322 | tax, |
| 323 | taxableAmount, |
| 324 | newPlanCost, |
| 325 | taxFailed, |
| 326 | customerBalance, |
| 327 | subscriptionPlanMeta?.name, |
| 328 | ]) |
| 329 | |
| 330 | return ( |
| 331 | <Dialog |
| 332 | open={selectedTier !== undefined && selectedTier !== 'tier_free'} |
| 333 | onOpenChange={(open) => { |
| 334 | // Do not allow closing mid-change |
| 335 | if (isUpdating || paymentConfirmationLoading || isConfirming) { |
| 336 | return |
| 337 | } |
| 338 | if (!open) onClose() |
| 339 | }} |
| 340 | > |
| 341 | <DialogContent |
| 342 | onOpenAutoFocus={(event) => event.preventDefault()} |
| 343 | size="xlarge" |
| 344 | className="p-0" |
| 345 | > |
| 346 | <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 h-full items-stretch"> |
| 347 | {/* Left Column */} |
| 348 | <div className="p-8 pb-8 flex flex-col xl:col-span-3"> |
| 349 | <div className="flex-1"> |
| 350 | <div> |
| 351 | {!billingViaPartner && |
| 352 | subscriptionPreviewInitialized && |
| 353 | changeType === 'upgrade' && ( |
| 354 | <div className="space-y-2 mb-4"> |
| 355 | <PaymentMethodSelection |
| 356 | ref={paymentMethodSelectionRef} |
| 357 | selectedPaymentMethod={selectedPaymentMethod} |
| 358 | onSelectPaymentMethod={(pm) => setSelectedPaymentMethod(pm)} |
| 359 | readOnly={paymentConfirmationLoading || isConfirming || isUpdating} |
| 360 | onAddressChange={onAddressChange} |
| 361 | onTaxIdChange={onTaxIdChange} |
| 362 | useAsDefaultBillingAddress={useAsDefaultBillingAddress} |
| 363 | onUseAsDefaultBillingAddressChange={onUseAsDefaultBillingAddressChange} |
| 364 | /> |
| 365 | </div> |
| 366 | )} |
| 367 | |
| 368 | {billingViaPartner && ( |
| 369 | <div className="mb-4"> |
| 370 | <p className="text-sm"> |
| 371 | This organization is billed through our partner{' '} |
| 372 | {billingPartnerLabel(billingPartner)}.{' '} |
| 373 | {/* @ts-ignore [Joshen] Might be API types issue */} |
| 374 | {billingPartner === 'aws' ? ( |
| 375 | <>The organization's credit balance will be decreased accordingly.</> |
| 376 | ) : ( |
| 377 | <>You will be charged by them directly.</> |
| 378 | )} |
| 379 | </p> |
| 380 | {billingViaPartner && |
| 381 | billingPartner === 'fly' && |
| 382 | subscriptionPreview?.plan_change_type === 'downgrade' && ( |
| 383 | <p className="text-sm"> |
| 384 | Your organization will be downgraded at the end of your current billing |
| 385 | cycle. |
| 386 | </p> |
| 387 | )} |
| 388 | </div> |
| 389 | )} |
| 390 | </div> |
| 391 | |
| 392 | {subscriptionPreviewIsLoading && ( |
| 393 | <div className="space-y-2 mb-4 mt-2"> |
| 394 | <ShimmeringLoader /> |
| 395 | <ShimmeringLoader className="w-3/4" /> |
| 396 | <ShimmeringLoader className="w-1/2" /> |
| 397 | </div> |
| 398 | )} |
| 399 | {subscriptionPreviewInitialized && ( |
| 400 | <> |
| 401 | <div |
| 402 | className={cn( |
| 403 | 'mt-2 mb-4 text-foreground-light text-sm transition-opacity', |
| 404 | subscriptionPreviewIsFetching && 'opacity-50' |
| 405 | )} |
| 406 | > |
| 407 | {breakdownItems.map((item, i) => |
| 408 | item.type === 'amount' ? ( |
| 409 | <div |
| 410 | key={i} |
| 411 | className="flex items-center justify-between gap-2 border-b border-muted text-xs" |
| 412 | > |
| 413 | <div className="py-2 pl-0 flex items-center gap-1"> |
| 414 | <span>{item.label}</span> |
| 415 | {item.tooltip && ( |
| 416 | <InfoTooltip className="max-w-sm">{item.tooltip}</InfoTooltip> |
| 417 | )} |
| 418 | </div> |
| 419 | <div className="py-2 pr-0 text-right tabular-nums" translate="no"> |
| 420 | {formatCurrency(item.amount)} |
| 421 | </div> |
| 422 | </div> |
| 423 | ) : ( |
| 424 | <div |
| 425 | key={i} |
| 426 | className="flex items-center justify-between gap-2 border-b border-muted text-xs" |
| 427 | > |
| 428 | <div className="py-2 pl-0 text-foreground-lighter">{item.label}</div> |
| 429 | </div> |
| 430 | ) |
| 431 | )} |
| 432 | |
| 433 | <div className="flex items-center justify-between gap-2 border-b border-muted text-foreground"> |
| 434 | <div className="py-2 pl-0">Charge today</div> |
| 435 | <div className="py-2 pr-0 text-right tabular-nums" translate="no"> |
| 436 | {formatCurrency(totalCharge)} |
| 437 | {currentPlanId !== 'free' && ( |
| 438 | <> |
| 439 | {' '} |
| 440 | <Link |
| 441 | href={`/org/${selectedOrganization?.slug}/billing#breakdown`} |
| 442 | className="text-sm text-brand hover:text-brand-600 transition" |
| 443 | target="_blank" |
| 444 | > |
| 445 | + current spend |
| 446 | </Link> |
| 447 | </> |
| 448 | )} |
| 449 | </div> |
| 450 | </div> |
| 451 | |
| 452 | <div className="flex items-center justify-between gap-2 text-foreground-lighter text-xs mt-4"> |
| 453 | <div className="py-2 pl-0 flex items-center gap-1"> |
| 454 | <span>Monthly invoice estimate</span> |
| 455 | <InvoiceEstimateTooltip |
| 456 | subscriptionPreviewQueryResult={subscriptionPreviewQueryResult} |
| 457 | /> |
| 458 | </div> |
| 459 | <div className="py-2 pr-0 text-right tabular-nums" translate="no"> |
| 460 | {formatCurrency( |
| 461 | subscriptionPreview?.breakdown.reduce( |
| 462 | (prev: number, cur) => prev + cur.total_price, |
| 463 | 0 |
| 464 | ) ?? 0 |
| 465 | )} |
| 466 | </div> |
| 467 | </div> |
| 468 | </div> |
| 469 | </> |
| 470 | )} |
| 471 | </div> |
| 472 | |
| 473 | <div className="pt-4"> |
| 474 | {projects.filter( |
| 475 | (it) => |
| 476 | it.status === PROJECT_STATUS.ACTIVE_HEALTHY || |
| 477 | it.status === PROJECT_STATUS.COMING_UP |
| 478 | ).length === 0 && |
| 479 | subscriptionPreview?.plan_change_type !== 'downgrade' && ( |
| 480 | <div className="pb-2"> |
| 481 | <Admonition title="Empty organization" type="warning"> |
| 482 | This organization has no active projects. Did you select the correct |
| 483 | organization? |
| 484 | </Admonition> |
| 485 | </div> |
| 486 | )} |
| 487 | |
| 488 | {projects.filter( |
| 489 | (it) => |
| 490 | it.status === PROJECT_STATUS.ACTIVE_HEALTHY || |
| 491 | it.status === PROJECT_STATUS.COMING_UP |
| 492 | ).length === 1 && |
| 493 | subscriptionPlanMeta?.planId === 'pro' && |
| 494 | changeType === 'upgrade' && ( |
| 495 | <div className="pb-2"> |
| 496 | <Admonition type="note"> |
| 497 | <div className="text-sm prose"> |
| 498 | First project included. Additional projects cost{' '} |
| 499 | <span translate="no">$10</span>+/month regardless of activity.{' '} |
| 500 | <Link |
| 501 | href={`${DOCS_URL}/guides/platform/manage-your-usage/compute`} |
| 502 | target="_blank" |
| 503 | className="underline" |
| 504 | > |
| 505 | Learn more |
| 506 | </Link> |
| 507 | </div> |
| 508 | </Admonition> |
| 509 | </div> |
| 510 | )} |
| 511 | |
| 512 | <div className="flex space-x-2"> |
| 513 | <Button |
| 514 | loading={isUpdating || paymentConfirmationLoading || isConfirming} |
| 515 | disabled={subscriptionPreviewIsLoading || subscriptionPreviewIsFetching} |
| 516 | type="primary" |
| 517 | onClick={onUpdateSubscription} |
| 518 | className="flex-1" |
| 519 | size="small" |
| 520 | > |
| 521 | Confirm {changeType === 'downgrade' ? 'downgrade' : 'upgrade'} |
| 522 | </Button> |
| 523 | </div> |
| 524 | </div> |
| 525 | </div> |
| 526 | |
| 527 | {/* Right Column */} |
| 528 | <div className="bg-surface-100 p-8 flex flex-col border-l xl:col-span-2"> |
| 529 | <h3 className="mb-8"> |
| 530 | {changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '} |
| 531 | <span className="font-bold">{selectedOrganization?.name}</span> to{' '} |
| 532 | {changeType === 'downgrade' |
| 533 | ? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default'] |
| 534 | : PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']} |
| 535 | </h3> |
| 536 | {changeType === 'downgrade' |
| 537 | ? featuresToLose.length > 0 && ( |
| 538 | <div className="mb-4"> |
| 539 | <h3 className="text-sm mb-1">Features you'll lose</h3> |
| 540 | <p className="text-xs text-foreground-light mb-4"> |
| 541 | Please review carefully before downgrading. |
| 542 | </p> |
| 543 | <div className="space-y-2 mb-4 text-foreground-light"> |
| 544 | {featuresToLose.map((feature) => ( |
| 545 | <div |
| 546 | key={typeof feature === 'string' ? feature : feature[0]} |
| 547 | className="flex items-center gap-2" |
| 548 | > |
| 549 | <div className="w-4"> |
| 550 | <InfoIcon className="h-3 w-3 text-amber-900" strokeWidth={3} /> |
| 551 | </div> |
| 552 | <p className="text-sm"> |
| 553 | {typeof feature === 'string' ? feature : feature[0]} |
| 554 | </p> |
| 555 | </div> |
| 556 | ))} |
| 557 | </div> |
| 558 | </div> |
| 559 | ) |
| 560 | : topFeatures.length > 0 && ( |
| 561 | <div className="mb-4"> |
| 562 | <h3 className="text-sm mb-4">Upgrade features</h3> |
| 563 | |
| 564 | <div className="space-y-2 mb-4 text-foreground-light"> |
| 565 | {topFeatures.map((feature: string | string[]) => ( |
| 566 | <div |
| 567 | key={typeof feature === 'string' ? feature : feature[0]} |
| 568 | className="flex items-center gap-2" |
| 569 | > |
| 570 | <div className="w-4"> |
| 571 | <Check className="h-3 w-3 text-brand" strokeWidth={3} /> |
| 572 | </div> |
| 573 | <div className="text-sm"> |
| 574 | <p>{typeof feature === 'string' ? feature : feature[0]}</p> |
| 575 | {Array.isArray(feature) && feature.length > 1 && ( |
| 576 | <p className="text-foreground-lighter text-xs">{feature[1]}</p> |
| 577 | )} |
| 578 | </div> |
| 579 | </div> |
| 580 | ))} |
| 581 | </div> |
| 582 | </div> |
| 583 | )} |
| 584 | </div> |
| 585 | </div> |
| 586 | |
| 587 | {stripePromise && paymentIntentSecret && ( |
| 588 | <Elements stripe={stripePromise} options={stripeOptionsConfirm}> |
| 589 | <PaymentConfirmation |
| 590 | paymentIntentSecret={paymentIntentSecret} |
| 591 | onPaymentIntentConfirm={(paymentIntentConfirmation) => |
| 592 | paymentIntentConfirmed(paymentIntentConfirmation) |
| 593 | } |
| 594 | onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} |
| 595 | /> |
| 596 | </Elements> |
| 597 | )} |
| 598 | </DialogContent> |
| 599 | </Dialog> |
| 600 | ) |
| 601 | } |