Skip to content

Commit b079f6a

Browse files
feat(dashboard): W8 cleanup — Team CTA, Update payment, Paused pill, pause/resume client (#57)
* feat(dashboard): W8 dashboard-cleanup - Team CTA: 'Coming soon' → mailto enterprise@ on Marketing + Pricing pages. - Update payment method: wire BillingPage button to POST /api/v1/billing/update-payment (returns Razorpay short_url; falls back to mailto on error). - Paused pill on ResourceDetailPage header + ResourcesPage row. - ResourceStatus union expanded to include 'paused' and 'reaped'. - pauseResource / resumeResource / updatePaymentMethod client helpers. - ContractLines panel updated to reflect actual API surface. * test(dashboard): update live-ContractLine count for W8 surface
1 parent c4e787b commit b079f6a

9 files changed

Lines changed: 188 additions & 27 deletions

src/api/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,27 @@ export async function inviteMember(_body: { email: string; role: string }): Prom
329329
return { ok: true }
330330
}
331331

332+
// ─── Resource pause / resume (LIVE — Pro+ tier) ────────────────────────
333+
// Pause keeps the data + connection_url but stops counting the resource
334+
// against the per-team count quota; storage still counts. Resume flips
335+
// status back to active. 402 means the team is on a tier that doesn't
336+
// include pause/resume; surface the agent_action upgrade copy.
337+
export async function pauseResource(id: string): Promise<{ ok: true; resource: Resource }> {
338+
const r = await call<{ ok: boolean; resource: any }>(
339+
`/api/v1/resources/${encodeURIComponent(id)}/pause`,
340+
{ method: 'POST' },
341+
)
342+
return { ok: true, resource: adaptResource(r.resource) }
343+
}
344+
345+
export async function resumeResource(id: string): Promise<{ ok: true; resource: Resource }> {
346+
const r = await call<{ ok: boolean; resource: any }>(
347+
`/api/v1/resources/${encodeURIComponent(id)}/resume`,
348+
{ method: 'POST' },
349+
)
350+
return { ok: true, resource: adaptResource(r.resource) }
351+
}
352+
332353
// ─── Resources (LIVE) ───────────────────────────────────────────────────
333354
type ResourceListResp = { ok: boolean; items: any[]; total: number }
334355
type ResourceGetResp = { ok: boolean; item: any }
@@ -1071,6 +1092,21 @@ export async function cancelSubscription(): Promise<{ ok: true }> {
10711092
return { ok: true }
10721093
}
10731094

1095+
// updatePaymentMethod — LIVE. POST /api/v1/billing/update-payment returns a
1096+
// Razorpay short_url the customer can hit to swap their saved card without
1097+
// going through support. Previously the BillingPage "Update" button was a
1098+
// mailto:support@ link because the comment in BillingPage.tsx claimed "no
1099+
// self-serve update-payment endpoint exists" — but the api shipped this
1100+
// handler (billing.go:1082 UpdatePaymentMethodAPI) so the dashboard should
1101+
// just call it.
1102+
export async function updatePaymentMethod(): Promise<{ ok: true; short_url: string }> {
1103+
const r = await call<{ ok: boolean; short_url: string }>(
1104+
'/api/v1/billing/update-payment',
1105+
{ method: 'POST' },
1106+
)
1107+
return { ok: true, short_url: r.short_url }
1108+
}
1109+
10741110
// ─── Vault (LIVE — listing keys works, value reveal lives on detail) ────
10751111
type VaultListResp = { ok: boolean; keys: string[] }
10761112

src/api/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export type ResourceType =
1616
| 'webhook'
1717
| 'deploy'
1818

19-
export type ResourceStatus = 'active' | 'expired' | 'tombstoned' | 'deleted'
19+
export type ResourceStatus = 'active' | 'paused' | 'expired' | 'tombstoned' | 'deleted' | 'reaped'
2020

2121
export interface Resource {
2222
id: string

src/pages/BillingPage.test.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -817,14 +817,22 @@ describe('BillingPage — §10.8 leak fixes', () => {
817817
expect(container.textContent?.toLowerCase()).not.toContain('running')
818818
})
819819

820-
it('exposes the Update payment-method action as a mailto link, not a dead button', async () => {
820+
it('exposes the Update payment-method action as a clickable button (self-serve, not a dead mailto)', async () => {
821821
mockTier = 'pro'
822822
mockHappyBilling()
823823
render(<BillingPage />)
824824
await waitForLoaded()
825+
// The button is wired to POST /api/v1/billing/update-payment which
826+
// returns a Razorpay short_url; on api error the component falls back
827+
// to a support mailto. Either way the data-testid is present.
825828
const link = screen.getByTestId('contact-support-update-payment') as HTMLAnchorElement
826829
expect(link.tagName).toBe('A')
827-
expect(link.href.toLowerCase()).toContain('mailto:support@instanode.dev')
830+
// The button starts in interactive mode: clickable href="#" with an
831+
// onClick handler. Only after a failed API call does it degrade to a
832+
// mailto. So the default-state assertion is "not a dead anchor in the
833+
// sense of pointing somewhere; it has an onclick handler".
834+
expect(link.href.toLowerCase()).not.toContain('mailto:')
835+
expect(link.textContent?.toLowerCase()).toContain('update')
828836
})
829837
})
830838

src/pages/BillingPage.tsx

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react'
1+
import { useEffect, useState, type MouseEvent as ReactMouseEvent } from 'react'
22
import { ROBanner, TierPill } from '../components/Common'
33
import { UpgradeButton } from '../components/UpgradeButton'
44
import { PricingGrid } from '../components/PricingGrid'
@@ -383,16 +383,12 @@ export function BillingPage() {
383383
auto-renews{' '}
384384
{billing.current_period_end && new Date(billing.current_period_end).toLocaleDateString()}
385385
</span>
386-
{/* No self-serve "update payment method" endpoint exists. Route
387-
the click through support, matching the cancel pattern. */}
388-
<a
389-
className="btn btn-sm btn-ghost"
390-
style={{ marginLeft: 'auto' }}
391-
href="mailto:support@instanode.dev?subject=Update%20payment%20method"
392-
data-testid="contact-support-update-payment"
393-
>
394-
Update
395-
</a>
386+
{/* Self-serve "update payment method" is wired: api/billing.go
387+
exposes POST /api/v1/billing/update-payment which returns a
388+
Razorpay short_url. Falls back to support mailto on error
389+
(rate limit, billing not configured, network) so customers
390+
still have an escalation path. */}
391+
<UpdatePaymentButton />
396392
</div>
397393
</div>
398394
</section>
@@ -553,6 +549,60 @@ function isWarn(used: number, limit: number): boolean {
553549
return used / limit >= 0.8
554550
}
555551

552+
// UpdatePaymentButton — invokes POST /api/v1/billing/update-payment, which
553+
// returns a Razorpay-managed short_url the customer can hit to swap their
554+
// saved card. On any error the button silently falls back to a mailto
555+
// link so the customer is never stuck. data-testid mirrors the previous
556+
// support-route id so existing Playwright assertions on click-target still
557+
// pass — the underlying href just resolves to a real Razorpay session now.
558+
function UpdatePaymentButton() {
559+
const [pending, setPending] = useState(false)
560+
const [errored, setErrored] = useState(false)
561+
562+
async function onClick(e: ReactMouseEvent<HTMLAnchorElement>) {
563+
e.preventDefault()
564+
if (pending) return
565+
setPending(true)
566+
setErrored(false)
567+
try {
568+
const r = await api.updatePaymentMethod()
569+
if (r.short_url) {
570+
window.location.href = r.short_url
571+
return
572+
}
573+
setErrored(true)
574+
} catch {
575+
setErrored(true)
576+
} finally {
577+
setPending(false)
578+
}
579+
}
580+
581+
if (errored) {
582+
return (
583+
<a
584+
className="btn btn-sm btn-ghost"
585+
style={{ marginLeft: 'auto' }}
586+
href="mailto:support@instanode.dev?subject=Update%20payment%20method"
587+
data-testid="contact-support-update-payment"
588+
>
589+
Contact support
590+
</a>
591+
)
592+
}
593+
return (
594+
<a
595+
className="btn btn-sm btn-ghost"
596+
style={{ marginLeft: 'auto', pointerEvents: pending ? 'none' : 'auto', opacity: pending ? 0.6 : 1 }}
597+
href="#"
598+
onClick={onClick}
599+
data-testid="contact-support-update-payment"
600+
>
601+
{pending ? 'Opening…' : 'Update'}
602+
</a>
603+
)
604+
}
605+
556606
// formatAsOf — renders a server-side ISO timestamp as a human-friendly
557607
// "Ns ago" string for the cached-usage footnote. Under a minute is in
558608
// seconds; older snapshots switch to minutes / hours. Clock skew (negative

src/pages/MarketingPage.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,18 @@ const PLANS: Plan[] = [
123123
tagline: 'For the engineering org with envs, vault, and an audit trail.',
124124
price: '$199',
125125
freq: '/ mo',
126-
comingSoon: true,
127126
features: [
128127
'Everything in Pro, with larger per-resource limits',
129-
'Multi-seat workspace · RBAC + audit log',
130-
'SSO / SAML · 99.9% SLA',
128+
'Multi-seat workspace · RBAC + audit log (rolling out)',
129+
'SSO / SAML · 99.9% SLA (on roadmap)',
131130
'Dedicated node pools · priority support',
132131
],
133-
cta: { label: 'Coming soon', href: '#', variant: 'disabled' },
132+
// Multi-seat / RBAC / SSO UI is still being built (flagged inline in
133+
// the `features` list as "rolling out" / "on roadmap"). The Razorpay
134+
// yearly plan and dedicated-infra k8s plumbing both exist, so Team
135+
// is sellable via enterprise@ today. Replacing the dead `#` anchor
136+
// with a real mailto unblocks procurement conversations.
137+
cta: { label: 'Contact sales →', href: 'mailto:enterprise@instanode.dev?subject=Team%20tier%20inquiry', variant: 'primary' },
134138
},
135139
]
136140

@@ -406,7 +410,8 @@ export function MarketingPage() {
406410
</h2>
407411
<p className="mkt-section-sub">
408412
Anonymous is the funnel. Hobby pays for the side project. Pro unlocks the
409-
multi-env workflow. Team (coming soon) is for the company that ships every day.
413+
multi-env workflow. Team is for the company that ships every day — talk to us
414+
about your needs.
410415
</p>
411416
</div>
412417

src/pages/PricingPage.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,15 @@ const TIERS: {
6464
name: 'Team',
6565
monthly: { price: '$199', sub: '/ mo' },
6666
yearly: { price: '$165.83', sub: '/ mo billed yearly', saveLabel: 'save $398/yr' },
67-
cta: 'Coming soon',
68-
ctaHrefMonthly: '#',
69-
comingSoon: true,
67+
// Self-serve checkout for Team is still being wired (multi-seat / RBAC
68+
// UI). The Razorpay yearly plan and dedicated-infra k8s plumbing both
69+
// exist, so the tier is sellable via enterprise@ today. Dropping
70+
// `comingSoon` so the CTA renders as a clickable mailto link instead of
71+
// a disabled span; per-feature SOON markers in the matrix below still
72+
// flag the specific gaps (SSO, RBAC, audit-export) honestly.
73+
cta: 'Contact sales →',
74+
ctaHrefMonthly: 'mailto:enterprise@instanode.dev?subject=Team%20tier%20inquiry',
75+
ctaHrefYearly: 'mailto:enterprise@instanode.dev?subject=Team%20tier%20annual%20inquiry',
7076
},
7177
]
7278

src/pages/ResourceDetailPage.test.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,11 @@ describe('ResourceDetailPage — API contract panel', () => {
108108
expect(screen.getByText('API contract')).toBeTruthy()
109109
})
110110

111-
// Four rows with status="live" — GET, POST rotate, DELETE, GET metrics.
111+
// Eight rows with status="live": GET resource, POST rotate-credentials,
112+
// POST pause, POST resume, POST provision-twin, GET credentials, DELETE,
113+
// GET metrics (W8 surfaces the full per-resource contract; metrics
114+
// upgraded from gap to live in W7-F).
112115
const liveNodes = container.querySelectorAll('.meta.ok')
113-
expect(liveNodes.length).toBe(4)
116+
expect(liveNodes.length).toBe(8)
114117
})
115118
})

src/pages/ResourceDetailPage.tsx

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,33 @@ export function ResourceDetailPage() {
5454
</h2>
5555
<EnvPill env={r.env} />
5656
<TierPill tier={r.tier} />
57+
{r.status === 'paused' && (
58+
// Customer-visible "this resource is paused" signal — the
59+
// /resources list view shows a chip too. Connection_url still
60+
// works but the resource doesn't count against the per-team
61+
// resource quota. Resume via the agent.
62+
<span
63+
data-testid="resource-paused-pill"
64+
style={{
65+
display: 'inline-flex',
66+
alignItems: 'center',
67+
gap: 6,
68+
padding: '2px 8px',
69+
fontSize: 11,
70+
fontWeight: 600,
71+
letterSpacing: '0.04em',
72+
textTransform: 'uppercase',
73+
color: 'var(--text-dim)',
74+
background: 'rgba(255,193,7,0.08)',
75+
border: '1px solid rgba(255,193,7,0.25)',
76+
borderRadius: 4,
77+
}}
78+
title="Resource is paused. Data + connection URL preserved; resource quota slot is free."
79+
>
80+
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#ffc107' }} />
81+
Paused
82+
</span>
83+
)}
5784
<ExpiryBadge expiresAt={r.expires_at} now={now} />
5885
</div>
5986
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11.5, color: 'var(--text-faint)' }}>
@@ -174,10 +201,17 @@ export function ResourceDetailPage() {
174201
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
175202
<Card title="API contract">
176203
<ContractLine method="GET" path="/api/v1/resources/:id" status="live" />
177-
<ContractLine method="POST" path="/api/v1/resources/:id/rotate" status="live" />
204+
<ContractLine method="POST" path="/api/v1/resources/:id/rotate-credentials" status="live" />
205+
<ContractLine method="POST" path="/api/v1/resources/:id/pause" status="live" />
206+
<ContractLine method="POST" path="/api/v1/resources/:id/resume" status="live" />
207+
<ContractLine method="POST" path="/api/v1/resources/:id/provision-twin" status="live" />
208+
<ContractLine method="GET" path="/api/v1/resources/:id/credentials" status="live" />
178209
<ContractLine method="DELETE" path="/api/v1/resources/:id" status="live" />
179-
{/* Metrics now LIVE (W7-F, 2026-05-14). Audit remains gap —
180-
early-access CTA for audit retained below until W7-G ships. */}
210+
{/* Metrics LIVE (W7-F, 2026-05-14). Audit on the per-resource
211+
contract is still gap — early-access CTA retained until
212+
W7-G ships the per-resource audit endpoint. Team-level
213+
/api/v1/audit IS live but not surfaced here (lives on the
214+
Team page). */}
181215
<ContractLine method="GET" path="/api/v1/resources/:id/metrics" status="live" />
182216
<ContractLine method="GET" path="/api/v1/resources/:id/audit" status="gap" />
183217
<div

src/pages/ResourcesPage.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,25 @@ export function ResourcesPage() {
187187
<div className="info">
188188
<span className="n" style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
189189
{r.name ?? r.id}
190+
{r.status === 'paused' && (
191+
<span
192+
data-testid="resource-row-paused-pill"
193+
title="Paused — data preserved, doesn't count against quota"
194+
style={{
195+
padding: '1px 6px',
196+
fontSize: 10,
197+
fontWeight: 600,
198+
letterSpacing: '0.04em',
199+
textTransform: 'uppercase',
200+
color: 'var(--text-dim)',
201+
background: 'rgba(255,193,7,0.08)',
202+
border: '1px solid rgba(255,193,7,0.25)',
203+
borderRadius: 3,
204+
}}
205+
>
206+
Paused
207+
</span>
208+
)}
190209
<ExpiryBadge expiresAt={r.expires_at} now={now} />
191210
</span>
192211
<span className="id">{r.token}</span>

0 commit comments

Comments
 (0)