Skip to content

Commit 4e61cb3

Browse files
Merge pull request #39 from InstaNode-dev/pricing/u2-context-prompts-fresh
dashboard: in-context upgrade prompts replacing generic CTAs (U2)
2 parents c097702 + deabe0b commit 4e61cb3

11 files changed

Lines changed: 796 additions & 48 deletions

src/api/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,20 @@ export interface OverviewStats {
231231
}
232232

233233
// ---------- Auth/me response (locked) ----------
234+
//
235+
// `experiments` is an optional bag of A/B-test variants the agent API
236+
// attaches to the /auth/me response. The dashboard reads `upgrade_cta`
237+
// (set by track P1) to vary the upgrade button label across visitors.
238+
// Modelled as optional + permissive so the dashboard ships before the
239+
// API side lands without forcing a coordinated rollout.
234240
export interface AuthMeResponse {
235241
user: User
236242
team: DashboardTeam
237243
access_token?: string
244+
experiments?: {
245+
upgrade_cta?: {
246+
variant?: string
247+
label?: string
248+
}
249+
}
238250
}

src/components/CustomDomainPanel.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,13 @@ import { useEffect, useState } from 'react'
1414
import * as api from '../api'
1515
import type { CustomDomain, CustomDomainRecord, CustomDomainStatus } from '../api'
1616
import { copyToClipboard } from './Common'
17+
import { UpgradePromptCard } from './UpgradePromptCard'
1718

1819
// Endpoint hint shown beneath the section header. The exact path is
1920
// /api/v1/stacks/:slug/domains — keeping it as a constant so the heading
2021
// and the docs string don't drift apart.
2122
const DOMAINS_ENDPOINT_HINT = 'POST /api/v1/stacks/:slug/domains'
2223

23-
// Billing page anchor we link to from the 402 upsell — matches the route
24-
// already mounted in App.tsx for the in-app billing surface.
25-
const BILLING_PATH = '/app/billing'
26-
2724
type Props = { stackSlug: string }
2825

2926
export function CustomDomainPanel({ stackSlug }: Props) {
@@ -149,11 +146,8 @@ export function CustomDomainPanel({ stackSlug }: Props) {
149146
<span className="help">Fully qualified hostname. We'll guide you through DNS records once it's added.</span>
150147
</div>
151148
{createErr && createErr.kind === 'upgrade_required' && (
152-
<div style={{
153-
fontSize: 12.5, color: 'var(--amber)', marginBottom: 8,
154-
padding: '8px 10px', borderLeft: '2px solid var(--amber)', background: 'rgba(255,192,105,0.04)',
155-
}}>
156-
{createErr.message} <a href={BILLING_PATH} style={{ color: 'var(--accent)' }}>Upgrade to Pro →</a>
149+
<div style={{ marginBottom: 8 }} data-testid="custom-domain-upgrade-banner">
150+
<UpgradePromptCard feature="custom_domain" dense />
157151
</div>
158152
)}
159153
{createErr && createErr.kind === 'other' && (
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/* UpgradePromptCard.test.tsx — unit tests for the in-context upgrade card (U2).
2+
*
3+
* Two responsibilities, tested separately:
4+
* 1. Per-feature copy (title + body) — the surface-specific framing
5+
* 2. CTA label resolution — P1 experiment variant from /auth/me, with
6+
* fallbacks to per-feature override and DEFAULT_UPGRADE_CTA
7+
*/
8+
9+
import { describe, it, expect, afterEach, vi } from 'vitest'
10+
import { render, screen, cleanup } from '@testing-library/react'
11+
import type { ReactNode } from 'react'
12+
13+
// Module-level mock for useDashboardCtx so each test controls what /auth/me
14+
// returns. Mutable holder so the suite can flip variants between cases.
15+
let mockMe: any = null
16+
vi.mock('../hooks/useDashboardCtx', () => ({
17+
useDashboardCtx: () => ({
18+
me: mockMe,
19+
meErr: null,
20+
meLoading: false,
21+
env: 'production',
22+
envs: ['production'],
23+
counts: { resources: 0, deployments: 0, vault: 0, team: 1 },
24+
resources: [],
25+
billing: null,
26+
billingLoading: false,
27+
}),
28+
}))
29+
30+
import { UpgradePromptCard } from './UpgradePromptCard'
31+
import {
32+
UPGRADE_COPY,
33+
DEFAULT_UPGRADE_CTA,
34+
BILLING_PATH,
35+
type UpgradeFeature,
36+
} from './upgradeCopy'
37+
38+
afterEach(() => {
39+
mockMe = null
40+
cleanup()
41+
})
42+
43+
function withMe(me: any, ui: ReactNode) {
44+
mockMe = me
45+
return render(<>{ui}</>)
46+
}
47+
48+
describe('UpgradePromptCard — per-feature copy', () => {
49+
const FEATURES: UpgradeFeature[] = [
50+
'vault_prod',
51+
'provision_twin',
52+
'family_bindings',
53+
'quota_wall',
54+
'custom_domain',
55+
]
56+
57+
for (const feature of FEATURES) {
58+
it(`renders the ${feature} title and body from upgradeCopy`, () => {
59+
withMe(null, <UpgradePromptCard feature={feature} />)
60+
const card = screen.getByTestId(`upgrade-prompt-${feature}`)
61+
expect(card).toBeTruthy()
62+
expect(card.getAttribute('data-feature')).toBe(feature)
63+
// Title and body come straight from the central copy map.
64+
expect(screen.getByTestId('upgrade-prompt-title').textContent).toContain(
65+
UPGRADE_COPY[feature].title,
66+
)
67+
expect(screen.getByTestId('upgrade-prompt-body').textContent).toContain(
68+
UPGRADE_COPY[feature].body,
69+
)
70+
})
71+
}
72+
73+
it('renders different copy for two different feature keys', () => {
74+
const { container, rerender } = withMe(
75+
null,
76+
<UpgradePromptCard feature="vault_prod" />,
77+
)
78+
const vaultTitle = container.querySelector('[data-testid="upgrade-prompt-title"]')!.textContent
79+
rerender(<UpgradePromptCard feature="custom_domain" />)
80+
const cdTitle = container.querySelector('[data-testid="upgrade-prompt-title"]')!.textContent
81+
expect(vaultTitle).not.toEqual(cdTitle)
82+
})
83+
84+
it('renders the priceLine footer when defined', () => {
85+
withMe(null, <UpgradePromptCard feature="vault_prod" />)
86+
expect(screen.getByTestId('upgrade-prompt-price').textContent).toContain('$9/mo')
87+
})
88+
})
89+
90+
describe('UpgradePromptCard — CTA label resolution', () => {
91+
it('falls back to DEFAULT_UPGRADE_CTA when /auth/me has no experiment', () => {
92+
withMe(null, <UpgradePromptCard feature="family_bindings" />)
93+
expect(screen.getByTestId('upgrade-prompt-cta').textContent).toBe(DEFAULT_UPGRADE_CTA)
94+
})
95+
96+
it("falls back to DEFAULT_UPGRADE_CTA when /auth/me has experiments but no upgrade_cta", () => {
97+
withMe(
98+
{ user: {}, team: { tier: 'hobby' }, experiments: {} },
99+
<UpgradePromptCard feature="family_bindings" />,
100+
)
101+
expect(screen.getByTestId('upgrade-prompt-cta').textContent).toBe(DEFAULT_UPGRADE_CTA)
102+
})
103+
104+
it('uses P1 experiment label when /auth/me supplies one', () => {
105+
withMe(
106+
{
107+
user: {},
108+
team: { tier: 'hobby' },
109+
experiments: { upgrade_cta: { variant: 'B', label: 'Start free trial' } },
110+
},
111+
<UpgradePromptCard feature="family_bindings" />,
112+
)
113+
expect(screen.getByTestId('upgrade-prompt-cta').textContent).toBe('Start free trial')
114+
})
115+
116+
it('respects the variantOverride prop (used by tests / parents)', () => {
117+
withMe(
118+
null,
119+
<UpgradePromptCard
120+
feature="vault_prod"
121+
variantOverride={{ label: 'Unlock for $9' }}
122+
/>,
123+
)
124+
expect(screen.getByTestId('upgrade-prompt-cta').textContent).toBe('Unlock for $9')
125+
})
126+
127+
it('variantOverride={null} forces the default even when /auth/me has a variant', () => {
128+
withMe(
129+
{
130+
user: {},
131+
team: { tier: 'hobby' },
132+
experiments: { upgrade_cta: { variant: 'B', label: 'Start free trial' } },
133+
},
134+
<UpgradePromptCard feature="family_bindings" variantOverride={null} />,
135+
)
136+
expect(screen.getByTestId('upgrade-prompt-cta').textContent).toBe(DEFAULT_UPGRADE_CTA)
137+
})
138+
})
139+
140+
describe('UpgradePromptCard — link target', () => {
141+
it('defaults to BILLING_PATH (/app/billing)', () => {
142+
withMe(null, <UpgradePromptCard feature="quota_wall" />)
143+
expect(
144+
(screen.getByTestId('upgrade-prompt-cta') as HTMLAnchorElement).getAttribute('href'),
145+
).toBe(BILLING_PATH)
146+
})
147+
148+
it('respects an explicit href prop', () => {
149+
withMe(
150+
null,
151+
<UpgradePromptCard feature="quota_wall" href="/custom/upgrade" />,
152+
)
153+
expect(
154+
(screen.getByTestId('upgrade-prompt-cta') as HTMLAnchorElement).getAttribute('href'),
155+
).toBe('/custom/upgrade')
156+
})
157+
})
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// UpgradePromptCard — shared in-context upgrade prompt (U2).
2+
//
3+
// Renders a single feature-specific upsell tile (title + body + small CTA).
4+
// Copy is sourced from upgradeCopy.ts by the `feature` key, so every call site
5+
// stays at one prop. The CTA button label respects P1's /auth/me experiment
6+
// variant when present — see upgradeCopy.readUpgradeCtaVariant for the shape.
7+
//
8+
// Visual style mirrors the existing inline upsells (PromoteUpsell in
9+
// DeployDetailPage, CustomDomainPanel's upgrade_required banner) so the
10+
// chrome doesn't fork: one-line card with text on the left and a small
11+
// btn-primary on the right.
12+
//
13+
// Variant strategy:
14+
// - Card copy varies by feature (this module's responsibility)
15+
// - Button label varies by P1 experiment (read from /auth/me)
16+
// - Per-surface override via `ctaLabel` on the copy entry takes priority
17+
// over the experiment label, so a surface that needs a specific call
18+
// to action can override.
19+
20+
import { useDashboardCtx } from '../hooks/useDashboardCtx'
21+
import {
22+
UPGRADE_COPY,
23+
BILLING_PATH,
24+
DEFAULT_UPGRADE_CTA,
25+
readUpgradeCtaVariant,
26+
type UpgradeFeature,
27+
} from './upgradeCopy'
28+
29+
export interface UpgradePromptCardProps {
30+
/** Which surface is showing the prompt — drives the copy. */
31+
feature: UpgradeFeature
32+
/** Override the CTA href (defaults to BILLING_PATH = /app/billing). */
33+
href?: string
34+
/** Optional dense layout — drops padding for use inside narrow inline
35+
* banners (matches the old CustomDomainPanel banner footprint). */
36+
dense?: boolean
37+
/** Test-id hook — defaults to `upgrade-prompt-<feature>`. */
38+
testId?: string
39+
/** Experiment variant override for tests. Production callers should not
40+
* pass this — the card reads from useDashboardCtx automatically. */
41+
variantOverride?: { label?: string } | null
42+
}
43+
44+
export function UpgradePromptCard({
45+
feature,
46+
href,
47+
dense = false,
48+
testId,
49+
variantOverride,
50+
}: UpgradePromptCardProps) {
51+
const copy = UPGRADE_COPY[feature]
52+
const ctx = useDashboardCtx()
53+
54+
// Variant resolution order:
55+
// 1. test override (lets the unit test drive variant without /auth/me)
56+
// 2. per-feature override (copy.ctaLabel) — surfaces that need their
57+
// own default ("Upgrade now" on a hard quota wall, etc.)
58+
// 3. P1 experiment variant from /auth/me
59+
// 4. DEFAULT_UPGRADE_CTA fallback
60+
const experimentLabel =
61+
variantOverride !== undefined
62+
? variantOverride?.label
63+
: readUpgradeCtaVariant(ctx.me)?.label
64+
const ctaLabel = copy.ctaLabel ?? experimentLabel ?? DEFAULT_UPGRADE_CTA
65+
const ctaHref = href ?? copy.ctaHref ?? BILLING_PATH
66+
67+
const padY = dense ? 10 : 14
68+
const padX = dense ? 12 : 18
69+
70+
return (
71+
<section
72+
className="card"
73+
data-testid={testId ?? `upgrade-prompt-${feature}`}
74+
data-feature={feature}
75+
style={{
76+
padding: `${padY}px ${padX}px`,
77+
display: 'flex',
78+
alignItems: 'center',
79+
gap: 14,
80+
flexWrap: 'wrap',
81+
}}
82+
>
83+
<div style={{ flex: 1, minWidth: 0 }}>
84+
<div
85+
data-testid="upgrade-prompt-title"
86+
style={{ fontSize: 13.5, color: 'var(--text)', marginBottom: 4 }}
87+
>
88+
<strong style={{ fontWeight: 500 }}>{copy.title}</strong>
89+
</div>
90+
<div
91+
data-testid="upgrade-prompt-body"
92+
style={{ fontSize: 12.5, color: 'var(--text-dim)', lineHeight: 1.5 }}
93+
>
94+
{copy.body}
95+
</div>
96+
{copy.priceLine && (
97+
<div
98+
data-testid="upgrade-prompt-price"
99+
style={{
100+
marginTop: 6,
101+
fontFamily: 'var(--font-mono)',
102+
fontSize: 11,
103+
color: 'var(--text-faint)',
104+
letterSpacing: '0.03em',
105+
}}
106+
>
107+
{copy.priceLine}
108+
</div>
109+
)}
110+
</div>
111+
<a
112+
href={ctaHref}
113+
className="btn btn-primary btn-sm"
114+
data-testid="upgrade-prompt-cta"
115+
>
116+
{ctaLabel}
117+
</a>
118+
</section>
119+
)
120+
}

0 commit comments

Comments
 (0)