Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/mesh/src/web/components/chat/highlight/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -301,6 +302,10 @@ export function ChatHighlight() {
return <CreditsExhaustedBanner onDismiss={clearError} />;
}

if (flags.isPaidSeatRequired) {
return <PaidSeatDialog onDismiss={clearError} />;
}

const { showError, showWarning, hasApprovals } = flags;
const userAskKey = userAskParts.map((p) => p.toolCallId).join("|");
const planKey = selectActivePlan(pendingPlans)?.toolCallId ?? "";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {

const noFlags: HighlightFlags = {
isCreditExhausted: false,
isPaidSeatRequired: false,
hasTodos: false,
showError: false,
showWarning: false,
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -42,6 +44,7 @@ export interface DeriveHighlightFlagsInput {

const EMPTY_FLAGS: HighlightFlags = {
isCreditExhausted: false,
isPaidSeatRequired: false,
hasTodos: false,
showError: false,
showWarning: false,
Expand All @@ -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 : [];
Expand Down Expand Up @@ -110,6 +118,7 @@ export function deriveHighlightFlags(

return {
isCreditExhausted: false,
isPaidSeatRequired: false,
hasTodos: todos.length > 0,
showError,
showWarning,
Expand Down Expand Up @@ -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) +
Expand Down
26 changes: 26 additions & 0 deletions apps/mesh/src/web/components/paywall/is-paid-seat-error.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 13 additions & 0 deletions apps/mesh/src/web/components/paywall/is-paid-seat-error.ts
Original file line number Diff line number Diff line change
@@ -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]");
}
177 changes: 177 additions & 0 deletions apps/mesh/src/web/components/paywall/paid-seat-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open onOpenChange={(next) => !next && onDismiss()}>
<DialogContent
className="dark max-h-[90dvh] max-w-[440px] gap-0 overflow-y-auto rounded-2xl bg-background p-0 text-foreground"
closeButtonClassName="top-5 right-5 z-10 size-8 rounded-full bg-muted p-1.5 text-muted-foreground opacity-100 hover:bg-accent hover:text-foreground"
onOpenAutoFocus={(e) => e.preventDefault()}
>
{/* Hero — layered deco-green spotlight behind a gradient badge */}
<div className="relative flex flex-col items-center px-6 pt-12 pb-5 sm:px-8 sm:pt-16 sm:pb-6">
<div className="relative flex items-center justify-center">
{/* 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. */}
<div
aria-hidden
className="pointer-events-none absolute left-1/2 top-1/2 size-80 -translate-x-1/2 -translate-y-1/2 scale-[0.8] sm:scale-100"
style={{
maskImage:
"linear-gradient(to bottom, black 42%, transparent 86%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 42%, transparent 86%)",
}}
>
<span
className="absolute inset-0 m-auto size-80 rounded-full"
style={{
background:
"radial-gradient(circle, rgba(208,236,26,0.16) 0%, transparent 68%)",
}}
/>
<span
className="absolute inset-0 m-auto size-64 rounded-full"
style={{
backgroundColor: "rgba(208,236,26,0.03)",
border: "1px solid rgba(208,236,26,0.10)",
}}
/>
<span
className="absolute inset-0 m-auto size-48 rounded-full"
style={{
backgroundColor: "rgba(208,236,26,0.05)",
border: "1px solid rgba(208,236,26,0.16)",
}}
/>
<span
className="absolute inset-0 m-auto size-32 rounded-full"
style={{
backgroundColor: "rgba(208,236,26,0.07)",
border: "1px solid rgba(208,236,26,0.22)",
}}
/>
</div>

{/* Gradient badge with sparkle */}
<div
className="relative z-10 flex size-14 items-center justify-center rounded-full sm:size-16"
style={{
backgroundImage:
"linear-gradient(150deg, #d0ec1a 0%, #07401a 100%)",
boxShadow: "0 8px 40px rgba(208,236,26,0.4)",
}}
>
<Stars01 className="size-6 text-white sm:size-7" />
</div>
</div>

<DialogHeader className="relative mt-6 items-center gap-2 text-center sm:mt-8 sm:text-center">
<DialogTitle className="text-xl font-bold leading-tight tracking-tight sm:text-2xl">
{t("paywall.title")}
</DialogTitle>
<DialogDescription className="max-w-[320px] text-sm leading-relaxed">
{t("paywall.description", { org: org.name })}
</DialogDescription>
</DialogHeader>
</div>

{/* Capability card */}
<div className="px-6 sm:px-8">
<ul className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:gap-3.5 sm:p-5">
{CAPABILITY_KEYS.map((key) => (
<li key={key} className="flex items-start gap-3">
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-muted">
<Check className="size-3 text-muted-foreground" />
</span>
<span className="text-sm text-foreground">{t(key)}</span>
</li>
))}
</ul>
</div>

{/* Action */}
<div className="mt-6 border-t border-border px-6 py-5 sm:px-8 sm:py-6">
{loading ? (
<div className="h-12 w-full animate-pulse rounded-full bg-muted" />
) : isPrivileged ? (
<div className="flex flex-col items-center gap-3">
<SubscribeCta
organizationId={org.id}
className={cn(
PILL_CLASS,
"bg-foreground text-background hover:bg-foreground/90",
)}
/>
<p className="text-xs text-muted-foreground">
{t("paywall.owner.reassure")}
</p>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<p className="text-center text-sm leading-relaxed text-muted-foreground">
{t("paywall.member.hint")}
</p>
<Button
variant="outline"
size="lg"
className={PILL_CLASS}
onClick={onDismiss}
>
{t("paywall.member.done")}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
19 changes: 19 additions & 0 deletions apps/mesh/src/web/components/paywall/paid-seat-paywall-host.tsx
Original file line number Diff line number Diff line change
@@ -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 <PaidSeatDialog onDismiss={closePaidSeatPaywall} />;
}
49 changes: 49 additions & 0 deletions apps/mesh/src/web/components/paywall/paid-seat-store.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading