Skip to content

Commit 910214d

Browse files
committed
feat(settings): one Max-plan wall, and give the create modal the same one
The create-sandbox modal answered a non-Max workspace with a red line under a form it could never submit, and no way to act on it. It now renders the same wall the Settings > Sandboxes tab does — heading, one sentence on what the plan unlocks, and an Upgrade to Max chip — instead of the fields. That wall existed twice already (sandboxes and Sim Mailer), so this extracts it rather than adding a third copy. `SettingsUpgradeNotice` owns the copy rhythm and the route, and `compact` trades the page's full-height centering for a modal's. Both settings consumers now compose it; neither keeps its own markup. The action lands on billing, which `resolveSettingsHref` already redirects to the plan-comparison page for a member who cannot manage billing — so it is a route to explore plans, never a dead end. The chip stays hidden for non-admins, exactly as the settings pages had it. A non-admin on an entitled workspace gets the muted reason rather than the upgrade wall: buying a plan is not what is in their way.
1 parent 16fd9b5 commit 910214d

6 files changed

Lines changed: 162 additions & 110 deletions

File tree

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

Lines changed: 6 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
'use client'
22

3-
import { Chip } from '@sim/emcn'
4-
import { ArrowRight } from 'lucide-react'
53
import { useParams } from 'next/navigation'
64
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
75
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -12,12 +10,11 @@ import {
1210
} from '@/app/workspace/[workspaceId]/settings/components/inbox/components'
1311
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
1412
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
13+
import { SettingsUpgradeNotice } from '@/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice'
1514
import { useInboxConfig } from '@/hooks/queries/inbox'
16-
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
1715

1816
export function Inbox() {
1917
const params = useParams()
20-
const { navigateToSettings } = useSettingsNavigation()
2118
const workspaceId = params.workspaceId as string
2219

2320
const { data: config, isLoading } = useInboxConfig(workspaceId)
@@ -38,26 +35,11 @@ export function Inbox() {
3835
}
3936
return (
4037
<SettingsPanel>
41-
<div className='flex flex-col items-center justify-center gap-4 py-20'>
42-
<div className='text-center'>
43-
<h3 className='font-medium text-[var(--text-primary)] text-md'>
44-
Sim Mailer requires an active Max plan
45-
</h3>
46-
<p className='mt-1.5 text-[var(--text-muted)] text-sm'>
47-
Upgrade to Max and ensure billing is active to receive tasks via email and let Sim
48-
work on your behalf.
49-
</p>
50-
</div>
51-
{canAdmin && (
52-
<Chip
53-
variant='primary'
54-
rightIcon={ArrowRight}
55-
onClick={() => navigateToSettings({ section: 'billing' })}
56-
>
57-
Upgrade to Max
58-
</Chip>
59-
)}
60-
</div>
38+
<SettingsUpgradeNotice
39+
title='Sim Mailer requires an active Max plan'
40+
description='Upgrade to Max and ensure billing is active to receive tasks via email and let Sim work on your behalf.'
41+
canUpgrade={canAdmin}
42+
/>
6143
</SettingsPanel>
6244
)
6345
}

apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-create-modal.tsx

Lines changed: 82 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,16 @@ import {
2020
emptyDraft,
2121
extractIssues,
2222
LANGUAGE_OPTIONS,
23+
SANDBOX_UPGRADE_DESCRIPTION,
24+
SANDBOX_UPGRADE_TITLE,
2325
type SandboxDraft,
2426
type SandboxLanguage,
2527
toSubmittedLines,
2628
} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils'
29+
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
30+
import { SettingsUpgradeNotice } from '@/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice'
2731
import { type Sandbox, useCreateSandbox, useSandboxes } from '@/hooks/queries/sandboxes'
2832

29-
const NOT_ENTITLED_MESSAGE = 'Sandboxes require an active Max plan.'
30-
const NOT_ADMIN_MESSAGE = 'Only workspace admins can create sandboxes.'
31-
3233
interface SandboxCreateModalProps {
3334
open: boolean
3435
onOpenChange: (open: boolean) => void
@@ -85,9 +86,10 @@ export function SandboxCreateModal({
8586
}
8687
}
8788

88-
const blockedReason = !canAdmin ? NOT_ADMIN_MESSAGE : !entitled ? NOT_ENTITLED_MESSAGE : null
89+
// A gate answers the whole dialog rather than reddening a form the user can
90+
// never submit — the same call the settings page makes.
91+
const gate = !entitled ? 'plan' : !canAdmin ? 'permission' : null
8992
const saving = createSandbox.isPending
90-
const disabled = saving || blockedReason !== null
9193

9294
const handleCreate = async () => {
9395
setIssues([])
@@ -116,66 +118,84 @@ export function SandboxCreateModal({
116118
<ChipModalHeader onClose={() => onOpenChange(false)}>Create sandbox</ChipModalHeader>
117119

118120
<ChipModalBody>
119-
<ChipModalField
120-
type='input'
121-
title='Name'
122-
value={draft.name}
123-
onChange={(name) => setDraft((prev) => ({ ...prev, name }))}
124-
placeholder='bigquery-etl'
125-
maxLength={64}
126-
autoComplete='off'
127-
required
128-
disabled={disabled}
129-
/>
130-
131-
<ChipModalField type='custom' title='Language'>
132-
<ChipDropdown
133-
value={draft.language}
134-
onChange={(language) =>
135-
setDraft((prev) => ({ ...prev, language: language as SandboxLanguage }))
136-
}
137-
options={LANGUAGE_OPTIONS.map((option) => ({
138-
label: option.label,
139-
value: option.value,
140-
}))}
141-
disabled={disabled}
142-
aria-label='Language'
121+
{gate === 'plan' ? (
122+
<SettingsUpgradeNotice
123+
title={SANDBOX_UPGRADE_TITLE}
124+
description={SANDBOX_UPGRADE_DESCRIPTION}
125+
canUpgrade={canAdmin}
126+
compact
143127
/>
144-
</ChipModalField>
145-
146-
<ChipModalField
147-
type='textarea'
148-
title='Dependencies'
149-
value={draft.dependencies}
150-
onChange={(dependencies) => setDraft((prev) => ({ ...prev, dependencies }))}
151-
placeholder={DEPENDENCY_PLACEHOLDERS[draft.language]}
152-
rows={8}
153-
disabled={disabled}
154-
hint='One per line. Version pins are optional.'
155-
error={
156-
issues.length > 0 ? (
157-
<>
158-
{issues.map((issue) => (
159-
<span key={issue.line} className='block'>
160-
Line {issue.line}: {issue.reason}
161-
</span>
162-
))}
163-
</>
164-
) : undefined
165-
}
166-
/>
167-
168-
<ChipModalError>{blockedReason ?? error}</ChipModalError>
128+
) : gate === 'permission' ? (
129+
<SettingsEmptyState variant='inline'>
130+
Only workspace admins can create sandboxes.
131+
</SettingsEmptyState>
132+
) : (
133+
<>
134+
<ChipModalField
135+
type='input'
136+
title='Name'
137+
value={draft.name}
138+
onChange={(name) => setDraft((prev) => ({ ...prev, name }))}
139+
placeholder='bigquery-etl'
140+
maxLength={64}
141+
autoComplete='off'
142+
required
143+
disabled={saving}
144+
/>
145+
146+
<ChipModalField type='custom' title='Language'>
147+
<ChipDropdown
148+
value={draft.language}
149+
onChange={(language) =>
150+
setDraft((prev) => ({ ...prev, language: language as SandboxLanguage }))
151+
}
152+
options={LANGUAGE_OPTIONS.map((option) => ({
153+
label: option.label,
154+
value: option.value,
155+
}))}
156+
disabled={saving}
157+
aria-label='Language'
158+
/>
159+
</ChipModalField>
160+
161+
<ChipModalField
162+
type='textarea'
163+
title='Dependencies'
164+
value={draft.dependencies}
165+
onChange={(dependencies) => setDraft((prev) => ({ ...prev, dependencies }))}
166+
placeholder={DEPENDENCY_PLACEHOLDERS[draft.language]}
167+
rows={8}
168+
disabled={saving}
169+
hint='One per line. Version pins are optional.'
170+
error={
171+
issues.length > 0 ? (
172+
<>
173+
{issues.map((issue) => (
174+
<span key={issue.line} className='block'>
175+
Line {issue.line}: {issue.reason}
176+
</span>
177+
))}
178+
</>
179+
) : undefined
180+
}
181+
/>
182+
183+
<ChipModalError>{error}</ChipModalError>
184+
</>
185+
)}
169186
</ChipModalBody>
170187

171-
<ChipModalFooter
172-
onCancel={() => onOpenChange(false)}
173-
primaryAction={{
174-
label: saving ? 'Creating...' : 'Create',
175-
onClick: () => void handleCreate(),
176-
disabled: disabled || draft.name.trim().length === 0,
177-
}}
178-
/>
188+
{gate === null && (
189+
<ChipModalFooter
190+
onCancel={() => onOpenChange(false)}
191+
cancelDisabled={saving}
192+
primaryAction={{
193+
label: saving ? 'Creating...' : 'Create',
194+
onClick: () => void handleCreate(),
195+
disabled: saving || draft.name.trim().length === 0,
196+
}}
197+
/>
198+
)}
179199
</ChipModal>
180200
)
181201
}

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

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
'use client'
22

33
import { useCallback, useMemo, useState } from 'react'
4-
import { Chip, toast } from '@sim/emcn'
5-
import { ArrowLeft, ArrowRight, Plus } from '@sim/emcn/icons'
4+
import { toast } from '@sim/emcn'
5+
import { ArrowLeft, Plus } from '@sim/emcn/icons'
66
import { getErrorMessage } from '@sim/utils/errors'
77
import { useParams } from 'next/navigation'
88
import { useQueryState } from 'nuqs'
@@ -24,6 +24,8 @@ import {
2424
draftFromSandbox,
2525
emptyDraft,
2626
extractIssues,
27+
SANDBOX_UPGRADE_DESCRIPTION,
28+
SANDBOX_UPGRADE_TITLE,
2729
type SandboxDraft,
2830
toSubmittedLines,
2931
} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils'
@@ -32,6 +34,7 @@ import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/compo
3234
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
3335
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
3436
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
37+
import { SettingsUpgradeNotice } from '@/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice'
3538
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
3639
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
3740
import {
@@ -41,12 +44,10 @@ import {
4144
useSandboxes,
4245
useUpdateSandbox,
4346
} from '@/hooks/queries/sandboxes'
44-
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
4547

4648
export function Sandboxes() {
4749
const params = useParams()
4850
const workspaceId = params.workspaceId as string
49-
const { navigateToSettings } = useSettingsNavigation()
5051

5152
const [searchTerm, setSearchTerm] = useSettingsSearch()
5253
const [selectedId, setSelectedId] = useQueryState(sandboxIdParam.key, {
@@ -170,26 +171,11 @@ export function Sandboxes() {
170171
if (!entitled) {
171172
return (
172173
<SettingsPanel>
173-
<div className='flex flex-col items-center justify-center gap-4 py-20'>
174-
<div className='text-center'>
175-
<h3 className='font-medium text-[var(--text-primary)] text-md'>
176-
Sandboxes require an active Max plan
177-
</h3>
178-
<p className='mt-1.5 text-[var(--text-muted)] text-sm'>
179-
Upgrade to Max and ensure billing is active to install Python or npm packages that
180-
your Function blocks can import.
181-
</p>
182-
</div>
183-
{canAdmin && (
184-
<Chip
185-
variant='primary'
186-
rightIcon={ArrowRight}
187-
onClick={() => navigateToSettings({ section: 'billing' })}
188-
>
189-
Upgrade to Max
190-
</Chip>
191-
)}
192-
</div>
174+
<SettingsUpgradeNotice
175+
title={SANDBOX_UPGRADE_TITLE}
176+
description={SANDBOX_UPGRADE_DESCRIPTION}
177+
canUpgrade={canAdmin}
178+
/>
193179
</SettingsPanel>
194180
)
195181
}

apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import type { Sandbox, SandboxDependencyIssue } from '@/lib/api/contracts/sandbo
22

33
export type SandboxLanguage = Sandbox['language']
44

5+
/** Shared by the settings page and the picker's create modal so the wall reads identically. */
6+
export const SANDBOX_UPGRADE_TITLE = 'Sandboxes require an active Max plan'
7+
export const SANDBOX_UPGRADE_DESCRIPTION =
8+
'Upgrade to Max and ensure billing is active to install Python or npm packages that your Function blocks can import.'
9+
510
/** Ordered to match the Function block's own `language` dropdown. */
611
export const LANGUAGE_OPTIONS = [
712
{ label: 'JavaScript', value: 'javascript' },
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { SettingsUpgradeNotice } from '@/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice/settings-upgrade-notice'
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
'use client'
2+
3+
import { Chip, cn } from '@sim/emcn'
4+
import { ArrowRight } from '@sim/emcn/icons'
5+
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
6+
7+
interface SettingsUpgradeNoticeProps {
8+
/** Names the gated surface, e.g. `Sandboxes require an active Max plan`. */
9+
title: string
10+
/** One sentence on what the plan unlocks. */
11+
description: string
12+
/**
13+
* Whether to offer the upgrade action. Members who cannot act on it are shown
14+
* the reason without a button that would only dead-end them.
15+
*/
16+
canUpgrade?: boolean
17+
/**
18+
* Tightens the vertical rhythm for a modal, where the full-height centering a
19+
* settings page wants would leave the dialog mostly empty.
20+
*/
21+
compact?: boolean
22+
}
23+
24+
/**
25+
* Canonical wall for a surface gated behind the Max plan. Owns the copy rhythm
26+
* and the route to upgrade, so every gated section reads and behaves the same.
27+
*
28+
* The action lands on billing, which redirects a member who cannot manage
29+
* billing to the plan-comparison page instead — so it is never a dead end.
30+
*/
31+
export function SettingsUpgradeNotice({
32+
title,
33+
description,
34+
canUpgrade = false,
35+
compact = false,
36+
}: SettingsUpgradeNoticeProps) {
37+
const { navigateToSettings } = useSettingsNavigation()
38+
39+
return (
40+
<div
41+
className={cn('flex flex-col items-center justify-center gap-4', compact ? 'py-10' : 'py-20')}
42+
>
43+
<div className='text-center'>
44+
<h3 className='font-medium text-[var(--text-primary)] text-md'>{title}</h3>
45+
<p className='mt-1.5 text-[var(--text-muted)] text-sm'>{description}</p>
46+
</div>
47+
{canUpgrade && (
48+
<Chip
49+
variant='primary'
50+
rightIcon={ArrowRight}
51+
onClick={() => navigateToSettings({ section: 'billing' })}
52+
>
53+
Upgrade to Max
54+
</Chip>
55+
)}
56+
</div>
57+
)
58+
}

0 commit comments

Comments
 (0)