diff --git a/website/src/__tests__/DashboardOverviewPage.test.tsx b/website/src/__tests__/DashboardOverviewPage.test.tsx index 9b43fd3..ffdbbda 100644 --- a/website/src/__tests__/DashboardOverviewPage.test.tsx +++ b/website/src/__tests__/DashboardOverviewPage.test.tsx @@ -191,6 +191,9 @@ describe('DashboardOverviewPage', () => { expect(screen.getByText('Billing price-plan map: missing')).toBeTruthy() expect(screen.getByText('Paddle webhook secret: missing')).toBeTruthy() expect(screen.getByText('Public auth runtime posture: local-or-mixed')).toBeTruthy() + expect(screen.getByText('Current release action')).toBeTruthy() + expect(screen.getByText('Desktop access is visible, but no packaged target is published yet.')).toBeTruthy() + expect(screen.getAllByRole('link', { name: 'Review release notes' })[0]?.getAttribute('href')).toBe('/changelog') expect(screen.getByText('Protected operator quick routes')).toBeTruthy() expect(screen.getByText('Signed-in help lanes')).toBeTruthy() expect(screen.getByText('Protected browser-versus-desktop reality')).toBeTruthy() @@ -396,5 +399,8 @@ describe('DashboardOverviewPage', () => { expect(await screen.findByText('Live authority sync')).toBeTruthy() expect(screen.getByText(/plan changed from local free to live operator/i)).toBeTruthy() expect(screen.getByText(/desktop download access changed from local not entitled to live enabled/i)).toBeTruthy() + expect(screen.getByText('Current release action')).toBeTruthy() + expect(screen.getByText('Windows package is ready for this account.')).toBeTruthy() + expect(screen.getByRole('link', { name: 'Download Windows package' }).getAttribute('href')).toBe('https://downloads.hypertwist.app/windows.exe') }) }) diff --git a/website/src/__tests__/download-center-page.test.tsx b/website/src/__tests__/download-center-page.test.tsx index 4a4c7ec..b92d865 100644 --- a/website/src/__tests__/download-center-page.test.tsx +++ b/website/src/__tests__/download-center-page.test.tsx @@ -196,7 +196,7 @@ describe('DownloadCenterPage', () => { expect(screen.getByText(/preserved your Windows selection/i)).toBeTruthy() expect(screen.getAllByText('Requested target').length).toBeGreaterThan(0) - expect((await screen.findByRole('link', { name: /download windows/i })).getAttribute('href')).toBe('https://downloads.hypertwist.app/windows.exe') + expect((await screen.findByRole('link', { name: 'Download Windows package' })).getAttribute('href')).toBe('https://downloads.hypertwist.app/windows.exe') expect(screen.getByText('Release guide')).toBeTruthy() expect(screen.getByText('First launch follow-through')).toBeTruthy() expect(screen.getByText('Verify the current training lanes')).toBeTruthy() @@ -291,7 +291,7 @@ describe('DownloadCenterPage', () => { , ) - expect((await screen.findByRole('link', { name: /download windows/i })).getAttribute('href')).toBe('https://downloads.hypertwist.app/windows.exe') + expect((await screen.findByRole('link', { name: 'Download Windows package' })).getAttribute('href')).toBe('https://downloads.hypertwist.app/windows.exe') await waitFor(() => { expect(screen.queryByText(/does not yet have desktop download entitlement/i)).toBeNull() }) @@ -316,8 +316,9 @@ describe('DownloadCenterPage', () => { , ) - expect(await screen.findByText(/fallback release posture/i)).toBeTruthy() - expect(screen.getByText(/your signed-in account still resolves to desktop access/i)).toBeTruthy() + expect(await screen.findByText('Current release action')).toBeTruthy() + expect(await screen.findAllByText(/your signed-in account still resolves to desktop access/i)).toHaveLength(2) + expect(screen.getAllByText((_content, node) => node?.textContent === 'Live release authority must recover before package delivery resumes.').length).toBeGreaterThan(0) expect(screen.getAllByText('Live release authority temporarily unavailable').length).toBeGreaterThan(0) expect(screen.getByText('What stays intentionally withheld')).toBeTruthy() expect(screen.getAllByRole('link', { name: 'Open dashboard' }).every((link) => link.getAttribute('href') === '/app')).toBe(true) diff --git a/website/src/__tests__/protected-app-pages.test.tsx b/website/src/__tests__/protected-app-pages.test.tsx index b49a716..0abe8b7 100644 --- a/website/src/__tests__/protected-app-pages.test.tsx +++ b/website/src/__tests__/protected-app-pages.test.tsx @@ -213,6 +213,9 @@ describe('protected app pages', () => { expect(await screen.findByText('Release targets')).toBeTruthy() expect(await screen.findByText('Version: 1.0.0')).toBeTruthy() expect(screen.getAllByText('Requested target').length).toBeGreaterThan(0) + expect(screen.getByText('Current release action')).toBeTruthy() + expect(screen.getByText('Windows package is ready for this account.')).toBeTruthy() + expect(screen.getByRole('link', { name: 'Download Windows package' }).getAttribute('href')).toBe('https://downloads.hypertwist.app/windows.exe') expect(screen.getByText('Current packaged desktop proof')).toBeTruthy() expect(screen.getAllByText('Packaged validation passed').length).toBeGreaterThan(0) expect(screen.getByText('Protected operator quick routes')).toBeTruthy() @@ -232,14 +235,17 @@ describe('protected app pages', () => { renderPage(, '/app/account') expect(await screen.findByText('Session profile')).toBeTruthy() + expect(await screen.findByRole('link', { name: 'Download Windows package' })).toBeTruthy() expect(screen.getByText('Current packaged desktop proof')).toBeTruthy() expect(screen.getByText('Packaged validation passed')).toBeTruthy() expect(screen.getByText('Protected browser-versus-desktop reality')).toBeTruthy() expect(screen.getByText('Why the browser is intentionally narrower')).toBeTruthy() expect(screen.getByText('Current simulator control ownership')).toBeTruthy() + expect(screen.getByText('Current release action')).toBeTruthy() + expect(screen.getByRole('link', { name: 'Download Windows package' }).getAttribute('href')).toBe('https://downloads.hypertwist.app/windows.exe') expect(screen.getAllByText(/classic-wca-keyboard\/v1/i).length).toBeGreaterThan(0) expect(screen.getByText(byExactTextContent('Billing source: session', 'LI'))).toBeTruthy() - expect(await screen.findByText(byExactTextContent('Manifest viewer access: active', 'LI'))).toBeTruthy() + expect(await screen.findAllByText(byExactTextContent('Manifest viewer access: active', 'LI'))).toHaveLength(2) expect(await screen.findByText(byExactTextContent('Windows: Primary shipping lane (Version 1.0.0, Channel candidate)', 'LI'))).toBeTruthy() expect(screen.getAllByRole('link', { name: 'Open downloads' }).every((link) => link.getAttribute('href') === '/app/downloads')).toBe(true) expect(screen.getByRole('link', { name: 'Get help' }).getAttribute('href')).toBe('/support?topic=operator-access') @@ -272,8 +278,8 @@ describe('protected app pages', () => { expect(await screen.findByText('Session profile')).toBeTruthy() expect(await screen.findByText(byExactTextContent('Manifest viewer authenticated: yes', 'LI'))).toBeTruthy() - expect(await screen.findByText(byExactTextContent('Manifest viewer plan: operator', 'LI'))).toBeTruthy() - expect(await screen.findByText(byExactTextContent('Manifest viewer access: active', 'LI'))).toBeTruthy() + expect(await screen.findAllByText(byExactTextContent('Manifest viewer plan: operator', 'LI'))).toHaveLength(2) + expect(await screen.findAllByText(byExactTextContent('Manifest viewer access: active', 'LI'))).toHaveLength(2) expect(screen.getByText(/bounded fallback metadata until auth-server release authority returns/i)).toBeTruthy() }) @@ -376,4 +382,99 @@ describe('protected app pages', () => { expect(screen.getByText(/desktop download access changed from local not entitled to live enabled/i)).toBeTruthy() expect(screen.getByText(/access status changed from local session-default to live active/i)).toBeTruthy() }) + + it('turns protected non-entitled account posture into explicit checkout guidance when live commerce targets exist', async () => { + mockUsePlatformAuth.mockReturnValue({ + user: { + id: 'viewer-1', + name: 'Viewer', + email: 'viewer@hypertwist.app', + authMethod: 'email', + plan: 'free', + role: 'viewer', + canDownload: false, + billing: { + source: 'billing-state', + accessStatus: 'payment_required', + canDownload: false, + lastEventType: 'transaction.payment_failed', + }, + }, + superTokensConfigured: true, + invalidateSession: (...args: unknown[]) => mockInvalidateSession(...args), + reconcileReleaseAuthority: vi.fn(), + releaseAuthoritySyncItems: [], + }) + + mockGetReleaseManifest.mockResolvedValue({ + ok: true, + manifest: { + generated_at: '2026-06-23T12:00:00.000Z', + support_email: 'hello@hypertwist.app', + public_docs_url: 'https://docs.hypertwist.app', + release_notes_url: 'https://notes.hypertwist.app', + corresponding_source_url: 'https://hypertwist.app/open-source/source.zip', + open_source_repo_url: 'https://github.com/hypertwist/hypertwist', + commerce: { + operator_checkout_url: 'https://buy.paddle.com/operator-live', + studio_checkout_url: 'https://buy.paddle.com/studio-live', + plan_price_operator: '$29/mo', + plan_price_studio: '$99/mo', + }, + viewer: { + authenticated: true, + canDownload: false, + plan: 'free', + role: 'viewer', + accessStatus: 'payment_required', + }, + platforms: [ + { + platform_key: 'windows', + platform: 'Windows', + subtitle: 'Primary shipping lane', + details: 'Current packaged validation is strongest on the Windows Unreal lane.', + configured: true, + channel: 'candidate', + version: '1.0.1', + build_id: 'win64-1001', + published_at: '2026-06-23T00:00:00.000Z', + file_name: 'HyperTwist-Windows.zip', + file_size_bytes: 1048576, + checksum_sha256: 'abc124', + download_url: null, + download_available: false, + validation_summary: { + lane: 'Windows Unreal packaged validation', + result: 'passed', + generated_at: '2026-06-23T01:43:08.7625247Z', + configuration: 'Development', + skip_build: true, + smoke_map_count: 2, + smoke_maps: [ + { + map_url: '/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining', + label: 'Magic120Cell dedicated-family training map', + result: 'passed', + }, + { + map_url: '/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining', + label: 'MagicCube5D dedicated-family training map', + result: 'passed', + }, + ], + }, + }, + ], + }, + }) + + renderPage(, '/app/account') + + expect(await screen.findByText('Current release action')).toBeTruthy() + expect(await screen.findByText('Desktop package delivery still requires billing or operator provisioning.')).toBeTruthy() + expect((await screen.findByRole('link', { name: 'Open operator checkout' })).getAttribute('href')).toBe('https://buy.paddle.com/operator-live') + expect(screen.getByRole('link', { name: 'Open studio checkout' }).getAttribute('href')).toBe('https://buy.paddle.com/studio-live') + expect(screen.getByRole('link', { name: 'Open pricing' }).getAttribute('href')).toBe('/pricing') + }) }) diff --git a/website/src/pages/app-pages.tsx b/website/src/pages/app-pages.tsx index c22d471..96e1701 100644 --- a/website/src/pages/app-pages.tsx +++ b/website/src/pages/app-pages.tsx @@ -10,7 +10,13 @@ import { ReleaseValidationSummary } from '../components/ui/ReleaseValidationSumm import { resolvePublicLaunchStatusSummary } from '../public-launch' import { downloadTargets, launchReadiness, mplSourceUrl, openSourceRepoUrl, publicDocsUrl, releaseNotesUrl } from '../site-config' import { buildReleaseMetadataItems, resolveReleaseManifestView, type ReleaseManifestView } from '../release-manifest' -import { buildProtectedDownloadPath, buildSupportPath, getDownloadPlatformLabel, normalizeDownloadPlatform } from '../site-routes' +import { + buildProtectedDownloadPath, + buildSupportPath, + getDownloadPlatformLabel, + isExternalHref, + normalizeDownloadPlatform, +} from '../site-routes' import { browserDesktopRealityCards, controlProfileRosterCards, @@ -219,6 +225,21 @@ type ProtectedSupportTopicGuide = (typeof supportTopicDirectory)[number] & { actions: readonly ProtectedSupportTopicRoute[] } +type ProtectedReleaseAction = { + label: string + href: string + primary?: boolean +} + +type ProtectedReleaseActionSurface = { + badge: string + tone: 'success' | 'info' + title: string + summary: string + items: readonly string[] + actions: readonly ProtectedReleaseAction[] +} + function buildProtectedSupportTopicActions(topicKey: string): readonly ProtectedSupportTopicRoute[] { switch (topicKey) { case 'launch-readiness': @@ -249,6 +270,260 @@ const protectedSupportTopicGuides: readonly ProtectedSupportTopicGuide[] = suppo actions: buildProtectedSupportTopicActions(topic.topicKey), })) +function ProtectedActionLink({ + action, +}: { + action: ProtectedReleaseAction +}) { + const className = action.primary ? 'button button--primary' : 'button button--ghost' + + if (isExternalHref(action.href)) { + return ( + + {action.label} + + ) + } + + return ( + + {action.label} + + ) +} + +function describeReleaseTarget( + platform: ReleaseManifestView['platforms'][number] | null | undefined, +) { + if (!platform) { + return 'No desktop target selected yet' + } + + const metadata: string[] = [] + if (platform.version) { + metadata.push(platform.version) + } + if (platform.channel) { + metadata.push(platform.channel) + } + + return metadata.length > 0 + ? `${platform.platform} (${metadata.join(', ')})` + : platform.platform +} + +function buildProtectedCheckoutActions(releaseManifest: ReleaseManifestView) { + const operatorCheckoutUrl = releaseManifest.commerce?.operator_checkout_url || null + const studioCheckoutUrl = releaseManifest.commerce?.studio_checkout_url || null + const preferredCheckoutUrl = releaseManifest.viewer.plan === 'studio' + ? (studioCheckoutUrl || operatorCheckoutUrl) + : (operatorCheckoutUrl || studioCheckoutUrl) + + const preferredCheckoutLabel = preferredCheckoutUrl === studioCheckoutUrl + ? 'Open studio checkout' + : 'Open operator checkout' + + const secondaryCheckoutUrl = [operatorCheckoutUrl, studioCheckoutUrl] + .find((candidate) => candidate && candidate !== preferredCheckoutUrl) || null + + const secondaryCheckoutLabel = secondaryCheckoutUrl === studioCheckoutUrl + ? 'Open studio checkout' + : 'Open operator checkout' + + return [ + preferredCheckoutUrl ? { + label: preferredCheckoutLabel, + href: preferredCheckoutUrl, + primary: true, + } : null, + secondaryCheckoutUrl ? { + label: secondaryCheckoutLabel, + href: secondaryCheckoutUrl, + } : null, + ].filter((action): action is ProtectedReleaseAction => action != null) +} + +function buildProtectedReleaseActionSurface({ + releaseManifest, + requestedPlatform, + releaseAuthorityUnavailable, +}: { + releaseManifest: ReleaseManifestView + requestedPlatform?: string | null + releaseAuthorityUnavailable: boolean +}): ProtectedReleaseActionSurface { + const configuredTargets = releaseManifest.platforms.filter((platform) => platform.configured) + const requestedTarget = requestedPlatform + ? (releaseManifest.platforms.find((platform) => platform.platform_key === requestedPlatform) || null) + : null + const downloadableTargets = releaseManifest.platforms.filter((platform) => Boolean(platform.download_url)) + const primaryDownloadTarget = downloadableTargets.find((platform) => ( + requestedTarget ? platform.platform_key === requestedTarget.platform_key : platform.platform_key === 'windows' + )) || downloadableTargets[0] || null + const supportEmail = releaseManifest.support_email || 'hello@hypertwist.app' + const manifestPlan = releaseManifest.viewer.plan || 'unresolved' + const manifestAccessStatus = releaseManifest.viewer.accessStatus || 'not resolved yet' + const configuredTargetsLabel = `${configuredTargets.length}/${releaseManifest.platforms.length} configured` + + if (releaseAuthorityUnavailable) { + if (releaseManifest.viewer.canDownload) { + return { + badge: 'Fallback release posture', + tone: 'info', + title: 'Live release authority must recover before package delivery resumes.', + summary: + 'Your signed-in account still resolves to desktop access, but this protected lane intentionally withholds direct downloads until the auth server returns.', + items: [ + `Manifest viewer plan: ${manifestPlan}`, + `Manifest viewer access: ${manifestAccessStatus}`, + `Protected target posture: ${requestedTarget ? describeReleaseTarget(requestedTarget) : 'Windows default'}`, + `Configured release targets: ${configuredTargetsLabel}`, + `Support contact: ${supportEmail}`, + ], + actions: [ + { label: 'Open downloads', href: buildProtectedDownloadPath('windows'), primary: true }, + { label: 'Review notices', href: '/app/notices' }, + { label: 'Get help', href: buildSupportPath('operator-access') }, + ], + } + } + + return { + badge: 'Release authority degraded', + tone: 'info', + title: 'Wait for live release authority before trusting upgrade or delivery posture.', + summary: + 'This protected lane is still useful for orientation, but checkout, entitlement, and package-delivery decisions should wait until the auth server is back.', + items: [ + `Manifest viewer plan: ${manifestPlan}`, + `Manifest viewer access: ${manifestAccessStatus}`, + `Configured release targets: ${configuredTargetsLabel}`, + `Support contact: ${supportEmail}`, + ], + actions: [ + { label: 'Open pricing', href: '/pricing', primary: true }, + { label: 'Review notices', href: '/app/notices' }, + { label: 'Get help', href: buildSupportPath('operator-access') }, + ], + } + } + + if (releaseManifest.viewer.canDownload && primaryDownloadTarget?.download_url) { + const primaryActionTarget = requestedTarget && requestedTarget.configured === false + ? primaryDownloadTarget + : (requestedTarget || primaryDownloadTarget) + + return { + badge: 'Download ready', + tone: 'success', + title: requestedTarget && requestedTarget.configured === false + ? `${requestedTarget.platform} is not published yet, but another entitled package is ready.` + : `${primaryActionTarget.platform} package is ready for this account.`, + summary: requestedTarget && requestedTarget.configured === false + ? `Your requested ${requestedTarget.platform} lane is not configured yet, but the protected release lane can still deliver ${describeReleaseTarget(primaryDownloadTarget)} right now.` + : 'The protected release lane now has both live entitlement and a current package target, so download and release references can stay attached to the same operator workflow.', + items: [ + `Manifest viewer plan: ${manifestPlan}`, + `Manifest viewer access: ${manifestAccessStatus}`, + `Primary release target: ${describeReleaseTarget(primaryActionTarget)}`, + `Configured release targets: ${configuredTargetsLabel}`, + `Support contact: ${supportEmail}`, + ], + actions: [ + { label: `Download ${primaryActionTarget.platform} package`, href: primaryDownloadTarget.download_url, primary: true }, + { label: 'Review notices', href: '/app/notices' }, + { label: 'Review release notes', href: '/changelog' }, + ], + } + } + + if (configuredTargets.length === 0) { + return { + badge: 'Awaiting packaged release target', + tone: 'info', + title: 'Desktop access is visible, but no packaged target is published yet.', + summary: + 'This protected lane can still show account and release posture, but it cannot deliver a desktop package until at least one target is configured.', + items: [ + `Manifest viewer plan: ${manifestPlan}`, + `Manifest viewer access: ${manifestAccessStatus}`, + `Configured release targets: ${configuredTargetsLabel}`, + `Support contact: ${supportEmail}`, + ], + actions: [ + { label: 'Review release notes', href: '/changelog', primary: true }, + { label: 'Review notices', href: '/app/notices' }, + { label: 'Get help', href: buildSupportPath('launch-readiness') }, + ], + } + } + + if (!releaseManifest.viewer.canDownload) { + const checkoutActions = buildProtectedCheckoutActions(releaseManifest) + const statusItems = [ + `Manifest viewer plan: ${manifestPlan}`, + `Manifest viewer access: ${manifestAccessStatus}`, + `Configured release targets: ${configuredTargetsLabel}`, + `Operator checkout target: ${releaseManifest.commerce?.operator_checkout_url ? 'configured' : 'missing'}`, + `Studio checkout target: ${releaseManifest.commerce?.studio_checkout_url ? 'configured' : 'missing'}`, + ] + + if (checkoutActions.length > 0) { + return { + badge: 'Upgrade or provisioning required', + tone: 'info', + title: 'Desktop package delivery still requires billing or operator provisioning.', + summary: + 'The protected release lane can show live release posture now, but it still keeps actual package delivery behind the current checkout and entitlement rules.', + items: [ + ...statusItems, + `Support contact: ${supportEmail}`, + ], + actions: [ + ...checkoutActions, + { label: 'Open pricing', href: '/pricing' }, + ], + } + } + + return { + badge: 'Checkout not live yet', + tone: 'info', + title: 'Protected release posture is ready, but live checkout is not configured.', + summary: + 'This deployment can show the signed-in release lane, yet commercial access still depends on manual operator help because live checkout targets are not configured.', + items: [ + ...statusItems, + `Support contact: ${supportEmail}`, + ], + actions: [ + { label: 'Open pricing', href: '/pricing', primary: true }, + { label: 'Review notices', href: '/app/notices' }, + { label: 'Get help', href: buildSupportPath('operator-access') }, + ], + } + } + + return { + badge: 'Release lane ready for review', + tone: 'info', + title: 'Protected release posture is available for operator follow-through.', + summary: + 'The protected lane is carrying live release metadata, packaged proof, and sign-in continuity even though a direct package action is not available from this exact state.', + items: [ + `Manifest viewer plan: ${manifestPlan}`, + `Manifest viewer access: ${manifestAccessStatus}`, + `Configured release targets: ${configuredTargetsLabel}`, + `Support contact: ${supportEmail}`, + ], + actions: [ + { label: 'Open downloads', href: buildProtectedDownloadPath('windows'), primary: true }, + { label: 'Review notices', href: '/app/notices' }, + { label: 'Get help', href: buildSupportPath('operator-access') }, + ], + } +} + function ProtectedReleaseAuthorityNotice({ badge, title, @@ -297,6 +572,46 @@ function ProtectedReleaseAuthorityNotice({ ) } +function ProtectedReleaseActionPanel({ + title, + kicker, + releaseManifest, + requestedPlatform, + releaseAuthorityUnavailable = false, +}: { + title: string + kicker: string + releaseManifest: ReleaseManifestView + requestedPlatform?: string | null + releaseAuthorityUnavailable?: boolean +}) { + const surface = buildProtectedReleaseActionSurface({ + releaseManifest, + requestedPlatform, + releaseAuthorityUnavailable, + }) + + return ( + +

+ {surface.badge} +

+

{surface.title}

+

{surface.summary}

+
    + {surface.items.map((item) => ( +
  • {item}
  • + ))} +
+
+ {surface.actions.map((action) => ( + + ))} +
+
+ ) +} + function PackagedValidationPanel({ title, kicker, @@ -724,6 +1039,13 @@ export function DashboardOverviewPage() { + +

{launchStatus.ready ? 'Launch-ready posture' : 'Preview posture'} @@ -871,6 +1193,14 @@ export function DownloadCenterPage() { ) : null} + + {!viewerCanDownload ? (

@@ -1216,6 +1546,14 @@ export function AccountPage() { ) : null} + + +