Skip to content

Commit 40864ca

Browse files
committed
feat(billing): unify upgrade routing with reason context + storage/tables limit emails
1 parent e96b150 commit 40864ca

28 files changed

Lines changed: 17644 additions & 18 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5-
import { toError } from '@sim/utils/errors'
5+
import { getErrorMessage, toError } from '@sim/utils/errors'
66
import { useParams, useRouter } from 'next/navigation'
77
import { useQueryStates } from 'nuqs'
88
import { usePostHog } from 'posthog-js/react'
@@ -25,6 +25,7 @@ import {
2525
} from '@/components/emcn'
2626
import { Download, Send } from '@/components/emcn/icons'
2727
import { getDocumentIcon } from '@/components/icons/document-icons'
28+
import { useLimitUpgradeToast } from '@/lib/billing/client'
2829
import { captureEvent } from '@/lib/posthog/client'
2930
import { triggerFileDownload } from '@/lib/uploads/client/download'
3031
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
@@ -197,6 +198,7 @@ export function Files() {
197198
const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId)
198199
const { data: members } = useWorkspaceMembersQuery(workspaceId)
199200
const uploadFile = useUploadWorkspaceFile()
201+
const notifyLimit = useLimitUpgradeToast()
200202
const deleteFile = useDeleteWorkspaceFile()
201203
const renameFile = useRenameWorkspaceFile()
202204
const createFolder = useCreateWorkspaceFileFolder()
@@ -699,6 +701,12 @@ export function Files() {
699701
})
700702
} catch (err) {
701703
logger.error('Error uploading file:', err)
704+
const message = getErrorMessage(err)
705+
if (/storage limit/i.test(message)) {
706+
notifyLimit('storage', message)
707+
} else {
708+
toast.error(`Failed to upload "${allowedFiles[i].name}"`)
709+
}
702710
}
703711
}
704712
} catch (err) {
@@ -708,7 +716,7 @@ export function Files() {
708716
setUploadProgress({ completed: 0, total: 0, currentPercent: 0 })
709717
}
710718
},
711-
[workspaceId, canEdit, currentFolderId]
719+
[workspaceId, canEdit, currentFolderId, notifyLimit]
712720
)
713721

714722
const rowDragDropConfig = useMemo<RowDragDropConfig>(

apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Chip } from '@/components/emcn'
77
import { Credit } from '@/components/emcn/icons'
88
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
99
import { formatCredits } from '@/lib/billing/credits/conversion'
10+
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
1011
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
1112
import { useMyMemberCredits } from '@/hooks/queries/organization'
1213
import { usePlanView } from '@/hooks/queries/plan-view'
@@ -33,7 +34,7 @@ function CreditsChipInner() {
3334
const { workspaceId } = useParams<{ workspaceId: string }>()
3435
const { data: memberCredits, isLoading: memberLoading } = useMyMemberCredits(workspaceId)
3536

36-
const upgradeHref = `/workspace/${workspaceId}/upgrade`
37+
const upgradeHref = buildUpgradeHref(workspaceId, 'credits')
3738

3839
/**
3940
* Warm the route bundle and the exact queries the Upgrade page gates on, so

apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
hasPaidSubscriptionStatus,
3434
hasUsableSubscriptionAccess,
3535
} from '@/lib/billing/subscriptions/utils'
36+
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
3637
import { cn } from '@/lib/core/utils/cn'
3738
import { getBaseUrl } from '@/lib/core/utils/urls'
3839
import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field'
@@ -125,7 +126,7 @@ export function Billing() {
125126
const betterAuthSubscription = useSubscription()
126127
const openBillingPortal = useOpenBillingPortal()
127128

128-
const upgradeHref = `/workspace/${workspaceId}/upgrade`
129+
const upgradeHref = buildUpgradeHref(workspaceId)
129130

130131
/**
131132
* Warm the Upgrade route bundle and the exact queries that page gates on, so

apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
workspaceRoleLockReason,
2525
} from '@/components/permissions'
2626
import type { WorkspacePermission } from '@/lib/api/contracts/workspaces'
27+
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
2728
import {
2829
MemberRow,
2930
MemberSection,
@@ -105,7 +106,7 @@ export function Teammates() {
105106
const inviteDisabledReason = activeWorkspace?.inviteDisabledReason ?? null
106107
const isInvitationsDisabled = isInvitationsDisabledByConfig || inviteDisabledReason !== null
107108

108-
const upgradeHref = `/workspace/${workspaceId}/upgrade`
109+
const upgradeHref = buildUpgradeHref(workspaceId, 'seats')
109110

110111
/**
111112
* Warm the Upgrade route bundle and the queries it gates on, so a gated

apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Suspense } from 'react'
12
import type { Metadata } from 'next'
23
import { Upgrade } from '@/app/workspace/[workspaceId]/upgrade/upgrade'
34

@@ -9,5 +10,9 @@ export default async function UpgradePage({
910
params: Promise<{ workspaceId: string }>
1011
}) {
1112
const { workspaceId } = await params
12-
return <Upgrade workspaceId={workspaceId} />
13+
return (
14+
<Suspense fallback={<div className='h-full bg-[var(--bg)]' />}>
15+
<Upgrade workspaceId={workspaceId} />
16+
</Suspense>
17+
)
1318
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { parseAsStringLiteral } from 'nuqs/server'
2+
import { UPGRADE_REASONS } from '@/lib/billing/upgrade-reasons'
3+
4+
/**
5+
* Single source of truth for the upgrade page's `reason` query param.
6+
*
7+
* Nullable (no `.withDefault`): a clean URL means no reason and the page keeps
8+
* its generic header. Shared by the client (`useQueryState`) and any server
9+
* read via `createSearchParamsCache`.
10+
*/
11+
export const upgradeReasonParam = {
12+
key: 'reason',
13+
parser: parseAsStringLiteral(UPGRADE_REASONS),
14+
} as const
15+
16+
/** Clean URLs, no back-stack churn — the reason is a passive header hint. */
17+
export const upgradeUrlKeys = {
18+
history: 'replace',
19+
clearOnDefault: true,
20+
} as const

apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useCallback, useEffect, useState } from 'react'
44
import { getErrorMessage } from '@sim/utils/errors'
55
import { useRouter } from 'next/navigation'
6+
import { useQueryState } from 'nuqs'
67
import { ArrowLeft, Chip, toast } from '@/components/emcn'
78
import {
89
getUpgradeCardCta,
@@ -11,6 +12,7 @@ import {
1112
type UpgradeCardId,
1213
} from '@/lib/billing/client'
1314
import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants'
15+
import { DEFAULT_UPGRADE_HEADER, UPGRADE_REASON_COPY } from '@/lib/billing/upgrade-reasons'
1416
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
1517
import {
1618
BillingPeriodToggle,
@@ -26,6 +28,10 @@ import {
2628
PRO_PLAN_CREDITS,
2729
PRO_PLAN_FEATURES,
2830
} from '@/app/workspace/[workspaceId]/upgrade/plan-configs'
31+
import {
32+
upgradeReasonParam,
33+
upgradeUrlKeys,
34+
} from '@/app/workspace/[workspaceId]/upgrade/search-params'
2935
import { useFullscreenOriginStore } from '@/stores/fullscreen-origin'
3036

3137
const TYPEFORM_ENTERPRISE_URL = 'https://form.typeform.com/to/jqCO12pF' as const
@@ -47,8 +53,14 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
4753
const state = useUpgradeState()
4854
const router = useRouter()
4955
const origin = useFullscreenOriginStore((s) => s.origin)
56+
const [reason] = useQueryState(upgradeReasonParam.key, {
57+
...upgradeReasonParam.parser,
58+
...upgradeUrlKeys,
59+
})
5060
const [showAllFeatures, setShowAllFeatures] = useState(false)
5161

62+
const header = reason ? UPGRADE_REASON_COPY[reason].header : DEFAULT_UPGRADE_HEADER
63+
5264
const handleBack = useCallback(() => {
5365
router.replace(origin ?? `/workspace/${workspaceId}/home`)
5466
}, [origin, router, workspaceId])
@@ -152,7 +164,7 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
152164
<div className='mx-auto flex w-full max-w-[960px] flex-col gap-7 pt-6 pb-3'>
153165
<div className='flex flex-col items-center gap-4'>
154166
<h1 className='text-balance text-center font-season text-[30px] text-[var(--text-primary)]'>
155-
Plans that scale with you
167+
{header}
156168
</h1>
157169
{state.showUpgradePlans && (
158170
<BillingPeriodToggle isAnnual={state.isAnnual} onChange={state.setIsAnnual} />

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'
44
import { ArrowRight } from 'lucide-react'
55
import { useParams, useRouter } from 'next/navigation'
66
import { ChipLink } from '@/components/emcn'
7+
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
78
import { prefetchUpgradeBillingData } from '@/hooks/queries/subscription'
89
import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace'
910

@@ -15,7 +16,7 @@ export function DeployUpgradeGate({ feature }: DeployUpgradeGateProps) {
1516
const router = useRouter()
1617
const queryClient = useQueryClient()
1718
const { workspaceId } = useParams<{ workspaceId: string }>()
18-
const upgradeHref = `/workspace/${workspaceId}/upgrade`
19+
const upgradeHref = buildUpgradeHref(workspaceId)
1920

2021
// Warm the upgrade route + the queries it gates on so the click lands on
2122
// cached data. ChipLink isn't memoized, so no useCallback is needed.

apps/sim/components/emails/billing/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export { CreditPurchaseEmail } from './credit-purchase-email'
33
export { CreditsExhaustedEmail } from './credits-exhausted-email'
44
export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email'
55
export { FreeTierUpgradeEmail } from './free-tier-upgrade-email'
6+
export { LimitThresholdEmail } from './limit-threshold-email'
67
export { PaymentFailedEmail } from './payment-failed-email'
78
export { PlanWelcomeEmail } from './plan-welcome-email'
89
export { UsageThresholdEmail } from './usage-threshold-email'
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Link, Section, Text } from '@react-email/components'
2+
import { baseStyles } from '@/components/emails/_styles'
3+
import { EmailLayout } from '@/components/emails/components'
4+
import { UPGRADE_REASON_COPY, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
5+
import { getBrandConfig } from '@/ee/whitelabeling'
6+
7+
interface LimitThresholdEmailProps {
8+
/** `warning` = approaching the limit (~80%); `reached` = at/over the limit. */
9+
kind: 'warning' | 'reached'
10+
/** Limit category, drives the shared copy. */
11+
reason: UpgradeReason
12+
userName?: string
13+
/** Pre-formatted current usage, e.g. "4.2 GB", "48,000 rows", "9 seats". */
14+
usageLabel: string
15+
/** Pre-formatted limit, e.g. "5 GB", "50,000 rows", "10 seats". */
16+
limitLabel: string
17+
percentUsed: number
18+
upgradeLink: string
19+
}
20+
21+
/**
22+
* Single template for the per-category usage-limit emails (storage, tables,
23+
* seats). Copy comes from {@link UPGRADE_REASON_COPY} so the email language
24+
* matches the upgrade-page header the user lands on.
25+
*/
26+
export function LimitThresholdEmail({
27+
kind,
28+
reason,
29+
userName,
30+
usageLabel,
31+
limitLabel,
32+
percentUsed,
33+
upgradeLink,
34+
}: LimitThresholdEmailProps) {
35+
const brand = getBrandConfig()
36+
const copy = UPGRADE_REASON_COPY[reason]
37+
const lead = kind === 'reached' ? copy.reachedLead : copy.warningLead
38+
const previewText = `${brand.name}: ${lead}`
39+
40+
return (
41+
<EmailLayout preview={previewText} showUnsubscribe={true}>
42+
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
43+
{userName ? `Hi ${userName},` : 'Hi,'}
44+
</Text>
45+
46+
<Text style={baseStyles.paragraph}>
47+
{lead} Upgrade your plan for more {copy.noun}.
48+
</Text>
49+
50+
<Section style={baseStyles.infoBox}>
51+
<Text style={baseStyles.infoBoxTitle}>Usage</Text>
52+
<Text style={baseStyles.infoBoxList}>
53+
{usageLabel} of {limitLabel} used ({percentUsed}%)
54+
</Text>
55+
</Section>
56+
57+
{/* Divider */}
58+
<div style={baseStyles.divider} />
59+
60+
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
61+
<Text style={baseStyles.button}>Upgrade</Text>
62+
</Link>
63+
64+
{/* Divider */}
65+
<div style={baseStyles.divider} />
66+
67+
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
68+
{kind === 'reached'
69+
? 'One-time notification at 100% usage.'
70+
: 'One-time notification at 80% usage.'}
71+
</Text>
72+
</EmailLayout>
73+
)
74+
}
75+
76+
export default LimitThresholdEmail

0 commit comments

Comments
 (0)