diff --git a/apps/mesh/src/web/components/chat/highlight/index.tsx b/apps/mesh/src/web/components/chat/highlight/index.tsx index d9f2b53e31..671237908f 100644 --- a/apps/mesh/src/web/components/chat/highlight/index.tsx +++ b/apps/mesh/src/web/components/chat/highlight/index.tsx @@ -20,6 +20,7 @@ import { UserAskQuestionHighlight } from "./user-ask-question"; import { TodosHighlight } from "./todos"; import { CollapsibleHighlight } from "./collapsible-highlight"; import { CreditsExhaustedBanner } from "../credits-exhausted-banner"; +import { PaidSeatDialog } from "../../paywall/paid-seat-dialog"; import { DesktopOfflineBanner } from "../desktop-offline-banner"; import { useHighlightFlags } from "./use-highlight-count"; import { parseErrorMessage } from "./parse-error-message"; @@ -301,6 +302,10 @@ export function ChatHighlight() { return ; } + if (flags.isPaidSeatRequired) { + return ; + } + const { showError, showWarning, hasApprovals } = flags; const userAskKey = userAskParts.map((p) => p.toolCallId).join("|"); const planKey = selectActivePlan(pendingPlans)?.toolCallId ?? ""; diff --git a/apps/mesh/src/web/components/chat/highlight/use-highlight-count.test.ts b/apps/mesh/src/web/components/chat/highlight/use-highlight-count.test.ts index d4117c3fad..1852a644db 100644 --- a/apps/mesh/src/web/components/chat/highlight/use-highlight-count.test.ts +++ b/apps/mesh/src/web/components/chat/highlight/use-highlight-count.test.ts @@ -7,6 +7,7 @@ import { const noFlags: HighlightFlags = { isCreditExhausted: false, + isPaidSeatRequired: false, hasTodos: false, showError: false, showWarning: false, @@ -59,6 +60,27 @@ describe("deriveHighlightFlags", () => { ).toEqual({ ...noFlags, isCreditExhausted: true }); }); + test("paid-seat error → isPaidSeatRequired true, all other flags false", () => { + // isPaidSeatError checks for `[PAID_SEAT_REQUIRED]` prefix. + const err = new Error("[PAID_SEAT_REQUIRED] this action needs a paid seat"); + expect( + deriveHighlightFlags({ + ...baseInput, + error: err, + }), + ).toEqual({ ...noFlags, isPaidSeatRequired: true }); + }); + + test("paid-seat error while streaming → showError false", () => { + expect( + deriveHighlightFlags({ + ...baseInput, + error: new Error("[PAID_SEAT_REQUIRED] nope"), + isStreaming: true, + }), + ).toEqual(noFlags); + }); + test("finishReason 'length' (not streaming, no error) → showWarning true", () => { expect( deriveHighlightFlags({ diff --git a/apps/mesh/src/web/components/chat/highlight/use-highlight-count.ts b/apps/mesh/src/web/components/chat/highlight/use-highlight-count.ts index b9e8edd86e..59099fea40 100644 --- a/apps/mesh/src/web/components/chat/highlight/use-highlight-count.ts +++ b/apps/mesh/src/web/components/chat/highlight/use-highlight-count.ts @@ -17,10 +17,12 @@ import { deriveCurrentTodos } from "./derive-current-todos"; import { extractPendingApprovals } from "./extract-pending-approvals"; import { extractPendingPlans } from "./extract-pending-plans"; import { isCreditError } from "../is-credit-error"; +import { isPaidSeatError } from "../../paywall/is-paid-seat-error"; import { useChatStream } from "../context"; export interface HighlightFlags { isCreditExhausted: boolean; + isPaidSeatRequired: boolean; hasTodos: boolean; showError: boolean; showWarning: boolean; @@ -42,6 +44,7 @@ export interface DeriveHighlightFlagsInput { const EMPTY_FLAGS: HighlightFlags = { isCreditExhausted: false, + isPaidSeatRequired: false, hasTodos: false, showError: false, showWarning: false, @@ -62,6 +65,11 @@ export function deriveHighlightFlags( return { ...EMPTY_FLAGS, isCreditExhausted: true }; } + // Paid-seat-required is likewise a modal, not a stacked card. + if (!isStreaming && error && isPaidSeatError(error)) { + return { ...EMPTY_FLAGS, isPaidSeatRequired: true }; + } + const lastMessage = messages.at(-1); const assistantParts = lastMessage?.role === "assistant" ? lastMessage.parts : []; @@ -110,6 +118,7 @@ export function deriveHighlightFlags( return { isCreditExhausted: false, + isPaidSeatRequired: false, hasTodos: todos.length > 0, showError, showWarning, @@ -147,7 +156,7 @@ export function useHighlightFlags(): HighlightFlags { */ export function useHighlightCount(): number { const flags = useHighlightFlags(); - if (flags.isCreditExhausted) return 0; + if (flags.isCreditExhausted || flags.isPaidSeatRequired) return 0; return ( Number(flags.hasTodos) + Number(flags.showError) + diff --git a/apps/mesh/src/web/components/paywall/is-paid-seat-error.test.ts b/apps/mesh/src/web/components/paywall/is-paid-seat-error.test.ts new file mode 100644 index 0000000000..eb6fb7a15f --- /dev/null +++ b/apps/mesh/src/web/components/paywall/is-paid-seat-error.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { isPaidSeatError } from "./is-paid-seat-error.ts"; + +describe("isPaidSeatError", () => { + test("matches the [PAID_SEAT_REQUIRED] prefix", () => { + expect( + isPaidSeatError(new Error("[PAID_SEAT_REQUIRED] needs a paid seat")), + ).toBe(true); + }); + + test("prefix must be at the start, not mid-message", () => { + expect( + isPaidSeatError(new Error("wrapped: [PAID_SEAT_REQUIRED] nope")), + ).toBe(false); + }); + + test("unrelated errors do not match", () => { + expect(isPaidSeatError(new Error("boom"))).toBe(false); + expect(isPaidSeatError(new Error("[CREDITS] out of credits"))).toBe(false); + }); + + test("null / undefined are safe", () => { + expect(isPaidSeatError(null)).toBe(false); + expect(isPaidSeatError(undefined)).toBe(false); + }); +}); diff --git a/apps/mesh/src/web/components/paywall/is-paid-seat-error.ts b/apps/mesh/src/web/components/paywall/is-paid-seat-error.ts new file mode 100644 index 0000000000..013f7eb8ff --- /dev/null +++ b/apps/mesh/src/web/components/paywall/is-paid-seat-error.ts @@ -0,0 +1,13 @@ +/** + * Detect "paid seat required" errors. + * The backend prefixes these with `[PAID_SEAT_REQUIRED]` so detection is + * deterministic — same convention as `[CREDITS]` in + * ../chat/is-credit-error.ts. + * + * Extracted as a pure .ts module so it can be imported by bun:test code + * without dragging in @deco/ui transitively. + */ +export function isPaidSeatError(error: Error | null | undefined): boolean { + if (!error) return false; + return error.message.startsWith("[PAID_SEAT_REQUIRED]"); +} diff --git a/apps/mesh/src/web/components/paywall/paid-seat-dialog.tsx b/apps/mesh/src/web/components/paywall/paid-seat-dialog.tsx new file mode 100644 index 0000000000..ebf07f1cec --- /dev/null +++ b/apps/mesh/src/web/components/paywall/paid-seat-dialog.tsx @@ -0,0 +1,177 @@ +/** + * Paid Seat Paywall — shown when a free seat attempts a paid action and the + * backend responds with a `[PAID_SEAT_REQUIRED]` error. + * + * Free seats can view everything but cannot mutate or spend AI. This dialog + * replaces the generic error toast with a role-aware upgrade surface: + * - owner/admin see the Subscribe CTA (checkout wiring is a follow-up) + * - everyone else is pointed at their org administrators + * + * A deliberately dark, branded "premium" surface: the content is pinned to + * `.dark` (so design-system tokens resolve against it regardless of the app + * theme) with a deco-green spotlight as the only brand art. + * + * Rendered from two independent triggers, never both at once for one error: + * - non-chat mutations, via the global MutationCache → paid-seat-store → + * PaidSeatPaywallHost (root mount) + * - chat streaming errors, inline from the chat highlight stack + */ +import { Check, Stars01 } from "@untitledui/icons"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { useProjectContext } from "@decocms/mesh-sdk"; +import { useCapabilities } from "@/web/hooks/use-capability.ts"; +import { useT } from "@/web/i18n/use-t.ts"; +import type { TranslationKey } from "@/web/i18n/en/index.ts"; +import { SubscribeCta } from "./subscribe-cta.tsx"; + +const CAPABILITY_KEYS = [ + "paywall.capability.diagnose", + "paywall.capability.plan", + "paywall.capability.automate", + "paywall.capability.preview", +] as const satisfies readonly TranslationKey[]; + +// Shared pill sizing for the CTA buttons so Subscribe and the member +// "Got it" match the surface. +const PILL_CLASS = "h-12 w-full rounded-full"; + +export function PaidSeatDialog({ onDismiss }: { onDismiss: () => void }) { + const t = useT(); + const { org } = useProjectContext(); + const { isPrivileged, loading } = useCapabilities(); + + return ( + !next && onDismiss()}> + e.preventDefault()} + > + {/* Hero — layered deco-green spotlight behind a gradient badge */} +
+
+ {/* Concentric ring layers + soft core, in the deco brand green + (#d0ec1a), masked to fade downward into the surface. Scaled + down on small screens so it doesn't dominate the viewport. */} +
+ + + + +
+ + {/* Gradient badge with sparkle */} +
+ +
+
+ + + + {t("paywall.title")} + + + {t("paywall.description", { org: org.name })} + + +
+ + {/* Capability card */} +
+
    + {CAPABILITY_KEYS.map((key) => ( +
  • + + + + {t(key)} +
  • + ))} +
+
+ + {/* Action */} +
+ {loading ? ( +
+ ) : isPrivileged ? ( +
+ +

+ {t("paywall.owner.reassure")} +

+
+ ) : ( +
+

+ {t("paywall.member.hint")} +

+ +
+ )} +
+ +
+ ); +} diff --git a/apps/mesh/src/web/components/paywall/paid-seat-paywall-host.tsx b/apps/mesh/src/web/components/paywall/paid-seat-paywall-host.tsx new file mode 100644 index 0000000000..745593c547 --- /dev/null +++ b/apps/mesh/src/web/components/paywall/paid-seat-paywall-host.tsx @@ -0,0 +1,19 @@ +/** + * Root-mounted host for the paid-seat paywall. Subscribes to the module-level + * paid-seat store (written by the QueryClient's global MutationCache.onError) + * and renders the dialog when a mutation has failed with `[PAID_SEAT_REQUIRED]`. + * + * Must mount inside the org's ProjectContextProvider — the dialog reads org + * context and the current user's capabilities to pick the role-aware CTA. + */ +import { PaidSeatDialog } from "./paid-seat-dialog.tsx"; +import { + closePaidSeatPaywall, + usePaidSeatPaywallOpen, +} from "./paid-seat-store.ts"; + +export function PaidSeatPaywallHost() { + const open = usePaidSeatPaywallOpen(); + if (!open) return null; + return ; +} diff --git a/apps/mesh/src/web/components/paywall/paid-seat-store.ts b/apps/mesh/src/web/components/paywall/paid-seat-store.ts new file mode 100644 index 0000000000..39d0fb7733 --- /dev/null +++ b/apps/mesh/src/web/components/paywall/paid-seat-store.ts @@ -0,0 +1,49 @@ +/** + * Tiny external store driving the paid-seat paywall dialog. + * + * A module-level store (rather than React state) is what lets the + * QueryClient's global `MutationCache.onError` — which runs outside any + * component — open the paywall when a mutation fails with a + * `[PAID_SEAT_REQUIRED]` error. The single root-mounted `PaidSeatPaywallHost` + * subscribes via `useSyncExternalStore` and renders the dialog. + * + * The chat surface does NOT use this store: chat streaming errors flow through + * `useChatStream()` (not react-query), so the chat highlight stack renders the + * dialog inline instead — see components/chat/highlight. + */ +import { useSyncExternalStore } from "react"; + +let open = false; +const listeners = new Set<() => void>(); + +function emit(): void { + for (const listener of listeners) listener(); +} + +export function openPaidSeatPaywall(): void { + if (open) return; + open = true; + emit(); +} + +export function closePaidSeatPaywall(): void { + if (!open) return; + open = false; + emit(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): boolean { + return open; +} + +/** Reactive read of whether the paywall dialog should be open. */ +export function usePaidSeatPaywallOpen(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/apps/mesh/src/web/components/paywall/subscribe-cta.tsx b/apps/mesh/src/web/components/paywall/subscribe-cta.tsx new file mode 100644 index 0000000000..255cfc335e --- /dev/null +++ b/apps/mesh/src/web/components/paywall/subscribe-cta.tsx @@ -0,0 +1,39 @@ +/** + * Subscribe CTA — the owner/admin action on the paid-seat paywall. + * + * Standalone on purpose so it's the single seam for the billing follow-up: + * wire `onClick` to checkout (`ORGANIZATION_BILLING_CHECKOUT_START`) when the + * org has no subscription, or to the seat-add flow otherwise. For now it just + * signals "coming soon". The dialog passes the shared pill styling so the + * button matches the surface. + */ +import { toast } from "sonner"; +import { Button } from "@deco/ui/components/button.tsx"; +import { track } from "@/web/lib/posthog-client"; +import { useT } from "@/web/i18n/use-t.ts"; + +export function SubscribeCta({ + organizationId, + className, +}: { + organizationId?: string; + className?: string; +}) { + const t = useT(); + + return ( + + ); +} diff --git a/apps/mesh/src/web/i18n/en/index.ts b/apps/mesh/src/web/i18n/en/index.ts index 5e790e9f46..489f5dc9fd 100644 --- a/apps/mesh/src/web/i18n/en/index.ts +++ b/apps/mesh/src/web/i18n/en/index.ts @@ -33,6 +33,7 @@ import { admin } from "./admin.ts"; import { sandbox } from "./sandbox.ts"; import { settings } from "./settings.ts"; import { announcements } from "./announcements.ts"; +import { paywall } from "./paywall.ts"; // English is the source of truth: every domain file is spread here and // TranslationKey is derived from the result. pt-BR mirrors this structure @@ -73,6 +74,7 @@ export const en = { ...sandbox, ...settings, ...announcements, + ...paywall, } as const; export type TranslationKey = keyof typeof en; diff --git a/apps/mesh/src/web/i18n/en/paywall.ts b/apps/mesh/src/web/i18n/en/paywall.ts new file mode 100644 index 0000000000..7068665d36 --- /dev/null +++ b/apps/mesh/src/web/i18n/en/paywall.ts @@ -0,0 +1,16 @@ +export const paywall = { + "paywall.title": "Unlock your seat", + "paywall.description": "Free seats can view {org}. A paid seat unlocks:", + "paywall.capability.diagnose": + "Run your diagnostic every week, automatically", + "paywall.capability.plan": "Turn findings into a prioritized work plan", + "paywall.capability.automate": "Let agents fix the issues for you", + "paywall.capability.preview": + "Preview and approve every change before it goes live", + "paywall.owner.subscribe": "Subscribe", + "paywall.owner.reassure": "Manage your seats anytime.", + "paywall.owner.comingSoon": "Checkout is coming soon.", + "paywall.member.hint": "Ask an admin to give you a paid seat.", + "paywall.member.done": "Got it", + "paywall.dismiss": "Not now", +} as const; diff --git a/apps/mesh/src/web/i18n/pt-br/index.ts b/apps/mesh/src/web/i18n/pt-br/index.ts index 0b85541ddc..ccad27f84c 100644 --- a/apps/mesh/src/web/i18n/pt-br/index.ts +++ b/apps/mesh/src/web/i18n/pt-br/index.ts @@ -34,6 +34,7 @@ import { sandbox } from "./sandbox.ts"; import type { TranslationKey } from "../en/index.ts"; import { announcements } from "./announcements.ts"; import { settings } from "./settings.ts"; +import { paywall } from "./paywall.ts"; export const ptBR = { ...virtualMcp, @@ -71,4 +72,5 @@ export const ptBR = { ...sandbox, ...settings, ...announcements, + ...paywall, } satisfies Record; diff --git a/apps/mesh/src/web/i18n/pt-br/paywall.ts b/apps/mesh/src/web/i18n/pt-br/paywall.ts new file mode 100644 index 0000000000..608b50404b --- /dev/null +++ b/apps/mesh/src/web/i18n/pt-br/paywall.ts @@ -0,0 +1,21 @@ +import type { paywall as enPaywall } from "../en/paywall.ts"; + +export const paywall = { + "paywall.title": "Libere seu assento", + "paywall.description": + "Assentos gratuitos só podem ver {org}. Um assento pago libera:", + "paywall.capability.diagnose": + "Rode seu diagnóstico toda semana, automaticamente", + "paywall.capability.plan": + "Transforme os achados em um plano de trabalho priorizado", + "paywall.capability.automate": + "Deixe os agents resolverem os problemas por você", + "paywall.capability.preview": + "Pré-visualize e aprove cada mudança antes de publicar", + "paywall.owner.subscribe": "Assinar", + "paywall.owner.reassure": "Gerencie seus assentos quando quiser.", + "paywall.owner.comingSoon": "O checkout está chegando em breve.", + "paywall.member.hint": "Peça um assento pago a um administrador.", + "paywall.member.done": "Entendi", + "paywall.dismiss": "Agora não", +} satisfies Record; diff --git a/apps/mesh/src/web/layouts/shell-layout.tsx b/apps/mesh/src/web/layouts/shell-layout.tsx index 5babe8f753..db76462ca4 100644 --- a/apps/mesh/src/web/layouts/shell-layout.tsx +++ b/apps/mesh/src/web/layouts/shell-layout.tsx @@ -5,6 +5,7 @@ import { FloatingReleaseCard } from "@/web/components/release-channel/floating-r import { KeyboardShortcutsDialog } from "@/web/components/keyboard-shortcuts-dialog"; import { VersionCheckDialog } from "@/web/components/version-check-dialog"; import { LanguageAnnouncementDialog } from "@/web/components/language-announcement-dialog"; +import { PaidSeatPaywallHost } from "@/web/components/paywall/paid-seat-paywall-host"; import { isModKey } from "@/web/lib/keyboard-shortcuts"; import RequiredAuthLayout from "@/web/layouts/required-auth-layout"; import { authClient } from "@/web/lib/auth-client"; @@ -368,6 +369,10 @@ function ShellLayoutContent() { /> + + {/* Paid-seat paywall: opened by the global MutationCache when a free + seat's write is rejected with a [PAID_SEAT_REQUIRED] error. */} + ); } diff --git a/apps/mesh/src/web/providers/providers.tsx b/apps/mesh/src/web/providers/providers.tsx index d5e2d7dd6c..e999eb3c70 100644 --- a/apps/mesh/src/web/providers/providers.tsx +++ b/apps/mesh/src/web/providers/providers.tsx @@ -1,6 +1,12 @@ import { Suspense } from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + MutationCache, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; import { setCollectionToastTranslations } from "@decocms/mesh-sdk"; +import { isPaidSeatError } from "@/web/components/paywall/is-paid-seat-error"; +import { openPaidSeatPaywall } from "@/web/components/paywall/paid-seat-store"; import { AuthConfigProvider } from "@/web/providers/auth-config-provider"; import { BetterAuthUIProvider } from "@/web/providers/better-auth-ui-provider"; @@ -19,6 +25,18 @@ import { Toaster } from "sonner"; import { useT } from "@/web/i18n/use-t"; const queryClient = new QueryClient({ + // A free seat that attempts any paid mutation gets a `[PAID_SEAT_REQUIRED]` + // 403 from the backend. Intercept it globally here — instead of leaving each + // call site to toast a raw error — and open the paywall dialog. Chat spends + // don't flow through react-query, so those are handled separately in the + // chat highlight stack. + mutationCache: new MutationCache({ + onError: (error) => { + if (error instanceof Error && isPaidSeatError(error)) { + openPaidSeatPaywall(); + } + }, + }), defaultOptions: { queries: { // Data is fresh for 1 minute by default