Skip to content

Commit 117ab5a

Browse files
feat(pricing): Enterprise "contact us" wall above Team (#56) (#189)
* feat(pricing): add Enterprise "contact us" wall above Team (#56) The strict-≥80%-margin redesign (#188) made Team a finite self-serve tier. Anyone who breaches Team's caps — or needs dedicated/isolated infra, multi-region, or compliance (SOC2/BAA/SSO/SLA/custom DPA) — now has a home: an Enterprise column to the right of Team plus a dedicated "contact us" card. GTM surface ONLY — no api changes, no Team un-gating, no plans.yaml row, no dedicated-infra build. Enterprise is quote-based: no price, no checkout. PricingPage: - new `enterprise` TierKey + a 5th column (headline "Custom" / "let's talk", CTA = mailto:sales@instanode.dev?subject=Enterprise%20inquiry — matches Team's contact address). - every ROW grows to a 5-tuple; Enterprise cells read "Custom" (capacity) or "Contact us" (SSO/SAML, SLA). No numbers, no "unlimited" (retired word). - dedicated Enterprise callout card with trigger criteria + Contact-us CTA. - grid widened 5→6 tracks; tier-scroll allowlist + section copy updated. Docs contract: - public/llms.txt gains a standalone `**Enterprise**` line so agents introspecting tiers see the wall. - llmsContract.test.ts pins the Enterprise line + sales address (rule 18/22). - fetch-content.mjs requireMarkers gains '**Enterprise**' so a content sync missing the line preserves the local copy until the content PR lands. Tests: +6 PricingPage assertions (column renders, CTA is sales mailto not checkout, "Custom" headline no $, callout card present, trigger criteria surfaced, zero "unlimited" anywhere on the page). npm run gate green (tsc + build incl. prerender + 1127 vitest passing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(pricing): cover requested-tier scroll path for Enterprise (?tier=enterprise) diff-cover flagged PricingPage.tsx:305 (the validated-tier allow-list now including 'enterprise') uncovered — the existing per-tier-id test renders without a tier param so the requestedTier useEffect returns early. Add a render with ?tier=enterprise that executes the path. 100% patch coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0d6427c commit 117ab5a

5 files changed

Lines changed: 223 additions & 38 deletions

File tree

public/llms.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ on `/api/v1/capabilities` for agent introspection.
102102
> **Upgrading auto-promotes in-flight deployment TTLs.** When a team upgrades to any paid tier (Hobby / Hobby Plus / Pro / Growth / Team), the Razorpay subscription.charged webhook flips the team's `default_deployment_ttl_policy` from `auto_24h` to `permanent` (so every future `POST /deploy/new` defaults to no TTL) AND promotes every existing `auto_24h` non-terminal deploy to permanent (clearing `expires_at`). Per-deploy `ttl_policy='custom'` and `ttl_policy='permanent'` rows are never touched — only the `auto_24h` class is rolled forward. To restore the 24h-default behaviour after an upgrade, `PATCH /api/v1/team/settings {"default_deployment_ttl_policy":"auto_24h"}`.
103103
- **Pro**: $49/mo. 10 GB Postgres, 512 MB Redis, 5 GB Mongo, 50 GB storage, 10 apps. Resource-count cap: 5 active resources per service (redis is 3 — Redis RAM is the binding cost). Per-tier counts are introspectable via `resource_count_limit` on `/api/v1/capabilities`.
104104
- **Team**: available soon — not yet self-serve. Planned at $199/mo with high finite limits (50 GB Postgres, 1.5 GB Redis, 40 GB Mongo, 40 GB queues, 300 GB storage, 30 GB vector, 100 deployments, 1000 vault entries, 100k webhooks), 50 custom domains, 90-day backups with self-serve restore, RBAC + audit log; SSO/SAML and a 99.9% SLA are also planned. Capacity beyond these caps (or dedicated/isolated infra, multi-region, or compliance such as SOC2/BAA/SSO/SLA/DPA) is Enterprise — contact sales. Team cannot be purchased or claimed today — contact support@instanode.dev for onboarding.
105+
- **Enterprise**: custom limits, dedicated infra, compliance; contact sales@instanode.dev. Not a self-serve tier and not in `plans.yaml` — no price, no checkout. Triggers: needs more than Team's caps, dedicated/isolated or multi-region infra, or SOC2/BAA/SSO/SLA/custom DPA.
105106

106107
## Conventions an LLM should follow when scripting against the platform
107108

scripts/fetch-content.mjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,14 @@ const SYNC_FILES = [
7373
// tier), this PRESERVES the committed public/llms.txt so the build can't
7474
// silently re-market Team as buyable. The content repo currently carries
7575
// the same wording, so the guard is a no-op today.
76-
requireMarkers: ['redeploy=true', '"redeployed":', 'not yet a self-serve tier'],
76+
//
77+
// task #56 (2026-06-05): the '**Enterprise**' marker guards the Enterprise
78+
// contact-us wall. If the instanode-web PR lands before the content-repo
79+
// PR, an upstream sync would otherwise overwrite public/llms.txt with a
80+
// version missing the Enterprise line; this PRESERVES the committed local
81+
// copy until content HEAD also carries it. Becomes a no-op once the
82+
// content PR merges.
83+
requireMarkers: ['redeploy=true', '"redeployed":', 'not yet a self-serve tier', '**Enterprise**'],
7784
},
7885
]
7986

src/lib/llmsContract.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ const CONTRACT_MARKERS: DocsMarker[] = [
7171
file: 'public/llms.txt',
7272
mustContain: ['not yet a self-serve tier'],
7373
},
74+
{
75+
// task #56 (2026-06-05): Enterprise "contact us" wall above Team. NOT a
76+
// self-serve tier and NOT a plans.yaml row — a GTM surface only. Pin the
77+
// dedicated Enterprise line + the sales contact so a docs revert or a
78+
// content-repo sync that drops the Enterprise wall fails CI. Mirrors the
79+
// Enterprise column shipped on PricingPage in the same PR.
80+
field: 'Enterprise contact-us wall (custom limits / dedicated infra / compliance)',
81+
file: 'public/llms.txt',
82+
mustContain: ['**Enterprise**', 'sales@instanode.dev'],
83+
},
7484
]
7585

7686
describe('agent docs contract — POST /deploy/new redeploy=true', () => {

src/pages/PricingPage.test.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,69 @@ describe('PricingPage — four public tier cards present (M11 regression guard)'
196196
})
197197
})
198198

199+
// ─── 3b. Enterprise "contact us" wall (task #56) ──────────────────────────
200+
201+
describe('PricingPage — Enterprise contact-us wall (task #56)', () => {
202+
it('renders an Enterprise column header to the right of Team', () => {
203+
renderPage()
204+
const headerCells = Array.from(
205+
document.querySelectorAll('[role="columnheader"][data-tier="enterprise"]'),
206+
)
207+
expect(headerCells.length).toBeGreaterThan(0)
208+
expect(document.body.textContent ?? '').toContain('Enterprise')
209+
})
210+
211+
it('Enterprise CTA is a sales mailto (contact us), NOT a self-serve checkout', () => {
212+
renderPage()
213+
const cta = screen.getByTestId('pricing-cta-enterprise')
214+
const href = cta.getAttribute('href') ?? ''
215+
expect(href.startsWith('mailto:')).toBe(true)
216+
// Matches Team's contact address (sales@instanode.dev), Enterprise subject.
217+
expect(href).toContain('sales@instanode.dev')
218+
expect(href).not.toContain('/app/checkout')
219+
expect(href).not.toContain('plan=enterprise')
220+
expect((cta.textContent ?? '').toLowerCase()).toContain('contact us')
221+
})
222+
223+
it('Enterprise has no dollar price — the headline is "Custom"', () => {
224+
renderPage()
225+
const header = document.querySelector(
226+
'[role="columnheader"][data-tier="enterprise"]',
227+
)
228+
expect(header).toBeTruthy()
229+
const headerText = header?.textContent ?? ''
230+
expect(headerText).toContain('Custom')
231+
expect(headerText).not.toContain('$')
232+
})
233+
234+
it('renders the Enterprise callout card with a Contact-us CTA', () => {
235+
renderPage()
236+
expect(screen.getByTestId('pricing-enterprise-callout')).toBeTruthy()
237+
expect(screen.getByTestId('pricing-enterprise-card')).toBeTruthy()
238+
const calloutCta = screen.getByTestId('pricing-enterprise-cta')
239+
expect(calloutCta.tagName).toBe('A')
240+
expect(calloutCta.getAttribute('href') ?? '').toContain('mailto:sales@instanode.dev')
241+
expect((calloutCta.textContent ?? '').toLowerCase()).toContain('contact us')
242+
})
243+
244+
it('surfaces the Enterprise trigger criteria (caps / dedicated infra / compliance)', () => {
245+
renderPage()
246+
const body = document.body.textContent ?? ''
247+
expect(body).toContain('dedicated')
248+
expect(body).toContain('multi-region')
249+
// At least one named compliance ask routes to Enterprise.
250+
expect(body).toContain('SOC2')
251+
})
252+
253+
it('the page introduces NO "unlimited" wording (retired by the strict-margin redesign)', () => {
254+
renderPage()
255+
// Case-insensitive: neither the Team column nor the Enterprise wall may
256+
// reintroduce the retired "unlimited" claim anywhere on /pricing.
257+
const body = (document.body.textContent ?? '').toLowerCase()
258+
expect(body).not.toContain('unlimited')
259+
})
260+
})
261+
199262
// ─── 4. B2-P1-1 / B2-P1-2: URL params + anchors (BugBash 2026-05-20) ──────
200263

201264
describe('PricingPage — URL params + hash anchors (BugBash B2-P1-1 / B2-P1-2)', () => {
@@ -231,6 +294,19 @@ describe('PricingPage — URL params + hash anchors (BugBash B2-P1-1 / B2-P1-2)'
231294
expect(el).toBeTruthy()
232295
}
233296
})
297+
298+
it('?tier=enterprise exercises the requested-tier scroll path + renders the Enterprise anchor (task #56)', () => {
299+
// The requested-tier useEffect (validates the tier against the allow-list,
300+
// which now includes 'enterprise', then scrolls it into view) only runs when
301+
// a tier is requested via ?tier= or #hash. Render with the Enterprise param
302+
// so that path executes for the new column and the shareable anchor exists.
303+
render(
304+
<MemoryRouter initialEntries={['/pricing?tier=enterprise']}>
305+
<PricingPage />
306+
</MemoryRouter>,
307+
)
308+
expect(document.getElementById('pricing-tier-enterprise')).toBeTruthy()
309+
})
234310
})
235311

236312
// ─── 4b. DOG-3 / BUG-P001: hobby_plus + growth tiers surfaced inline ──────

0 commit comments

Comments
 (0)