From 7fdf12810b5e1521c53e96d602a25742795ec64a Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Wed, 1 Apr 2026 15:29:33 -0700 Subject: [PATCH 01/10] feat(den): separate team billing from worker capacity --- ee/apps/den-api/.env.example | 2 + ee/apps/den-api/README.md | 7 ++ ee/apps/den-api/src/billing/polar.ts | 101 +++++++++++++++--- ee/apps/den-api/src/env.ts | 4 + ee/apps/den-api/src/routes/workers/billing.ts | 4 + ee/apps/den-api/src/routes/workers/core.ts | 30 +++++- ee/apps/den-api/src/routes/workers/shared.ts | 12 ++- .../app/(den)/_components/checkout-screen.tsx | 28 ++--- .../(den)/_components/dashboard-screen.tsx | 68 +++++++++--- ee/apps/den-web/app/(den)/_lib/den-flow.ts | 8 ++ .../(den)/_providers/den-flow-provider.tsx | 24 +++-- .../_components/billing-dashboard-screen.tsx | 55 +++++----- ee/apps/landing/app/pricing/page.tsx | 5 +- ee/apps/landing/components/landing-home.tsx | 8 +- ee/apps/landing/components/pricing-grid.tsx | 78 +++++++++----- 15 files changed, 315 insertions(+), 119 deletions(-) diff --git a/ee/apps/den-api/.env.example b/ee/apps/den-api/.env.example index 23afc77f3..8a6a5e0ca 100644 --- a/ee/apps/den-api/.env.example +++ b/ee/apps/den-api/.env.example @@ -18,6 +18,8 @@ POLAR_FEATURE_GATE_ENABLED=false POLAR_ACCESS_TOKEN= POLAR_PRODUCT_ID= POLAR_BENEFIT_ID= +POLAR_WORKER_PRODUCT_ID= +POLAR_WORKER_BENEFIT_ID= POLAR_SUCCESS_URL= POLAR_RETURN_URL= DAYTONA_API_KEY= diff --git a/ee/apps/den-api/README.md b/ee/apps/den-api/README.md index 0469126f3..ac3a8356a 100644 --- a/ee/apps/den-api/README.md +++ b/ee/apps/den-api/README.md @@ -12,6 +12,13 @@ It carries the full migrated Den API route surface in a foldered Hono structure pnpm --filter @openwork-ee/den-api dev:local ``` +## Billing model + +- `POLAR_PRODUCT_ID` / `POLAR_BENEFIT_ID`: base OpenWork Cloud team plan +- `POLAR_WORKER_PRODUCT_ID` / `POLAR_WORKER_BENEFIT_ID`: per-worker add-on product +- The base plan unlocks the shared cloud workspace. +- Workers are counted separately and billed as additional recurring subscriptions. + ## Current routes - `GET /` -> `302 https://openworklabs.com` diff --git a/ee/apps/den-api/src/billing/polar.ts b/ee/apps/den-api/src/billing/polar.ts index d202a6f3c..bc47cdfd2 100644 --- a/ee/apps/den-api/src/billing/polar.ts +++ b/ee/apps/den-api/src/billing/polar.ts @@ -119,6 +119,10 @@ export type CloudWorkerBillingStatus = { hasActivePlan: boolean checkoutRequired: boolean checkoutUrl: string | null + activeWorkerSubscriptions: number + workerCheckoutUrl: string | null + workerCheckoutRequired: boolean + workerPrice: CloudWorkerBillingPrice | null portalUrl: string | null price: CloudWorkerBillingPrice | null subscription: CloudWorkerBillingSubscription | null @@ -271,17 +275,17 @@ async function linkCustomerExternalId(customer: PolarCustomer, externalCustomerI }) } -function hasRequiredBenefit(state: PolarCustomerState | null) { - if (!state?.granted_benefits || !env.polar.benefitId) { +function hasBenefit(state: PolarCustomerState | null, benefitId: string | undefined) { + if (!state?.granted_benefits || !benefitId) { return false } - return state.granted_benefits.some((grant) => grant.benefit_id === env.polar.benefitId) + return state.granted_benefits.some((grant) => grant.benefit_id === benefitId) } -async function createCheckoutSession(input: CloudAccessInput): Promise { +async function createCheckoutSessionForProduct(input: CloudAccessInput, productId: string): Promise { const payload = { - products: [env.polar.productId], + products: [productId], success_url: env.polar.successUrl, return_url: env.polar.returnUrl, external_customer_id: input.userId, @@ -326,7 +330,7 @@ async function evaluateCloudWorkerAccess( assertPaywallConfig() const externalState = await getCustomerStateByExternalId(input.userId) - if (hasRequiredBenefit(externalState)) { + if (hasBenefit(externalState, env.polar.benefitId)) { return { featureGateEnabled: true, hasActivePlan: true, @@ -337,7 +341,7 @@ async function evaluateCloudWorkerAccess( const customer = await getCustomerByEmail(input.email) if (customer?.id) { const emailState = await getCustomerStateById(customer.id) - if (hasRequiredBenefit(emailState)) { + if (hasBenefit(emailState, env.polar.benefitId)) { await linkCustomerExternalId(customer, input.userId).catch(() => undefined) return { featureGateEnabled: true, @@ -347,13 +351,36 @@ async function evaluateCloudWorkerAccess( } } + const productId = env.polar.productId return { featureGateEnabled: true, hasActivePlan: false, - checkoutUrl: options.includeCheckoutUrl ? await createCheckoutSession(input) : null, + checkoutUrl: options.includeCheckoutUrl && productId ? await createCheckoutSessionForProduct(input, productId) : null, } } +async function getActiveWorkerSubscriptionCount(input: CloudAccessInput): Promise { + if (!env.polar.workerProductId) { + return 0 + } + + const subscriptions = await listSubscriptionsByExternalCustomer(input.userId, { + activeOnly: true, + limit: 100, + productId: env.polar.workerProductId, + }) + + return subscriptions.filter((subscription) => isActiveSubscriptionStatus(subscription.status)).length +} + +async function createWorkerCheckoutSession(input: CloudAccessInput): Promise { + if (!env.polar.workerProductId) { + return null + } + + return createCheckoutSessionForProduct(input, env.polar.workerProductId) +} + function normalizeRecurringInterval(value: string | null | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null } @@ -419,12 +446,12 @@ async function getSubscriptionById(subscriptionId: string): Promise { const params = new URLSearchParams() params.set("external_customer_id", externalCustomerId) - if (env.polar.productId) { - params.set("product_id", env.polar.productId) + if (options.productId) { + params.set("product_id", options.productId) } params.set("limit", String(options.limit ?? 1)) params.set("sorting", "-started_at") @@ -458,12 +485,20 @@ async function listSubscriptionsByExternalCustomer( } async function getPrimarySubscriptionForCustomer(externalCustomerId: string): Promise { - const active = await listSubscriptionsByExternalCustomer(externalCustomerId, { activeOnly: true, limit: 1 }) + const active = await listSubscriptionsByExternalCustomer(externalCustomerId, { + activeOnly: true, + limit: 1, + productId: env.polar.productId, + }) if (active[0]) { return active[0] } - const recent = await listSubscriptionsByExternalCustomer(externalCustomerId, { activeOnly: false, limit: 1 }) + const recent = await listSubscriptionsByExternalCustomer(externalCustomerId, { + activeOnly: false, + limit: 1, + productId: env.polar.productId, + }) return recent[0] ?? null } @@ -649,6 +684,27 @@ export async function requireCloudWorkerAccess(input: CloudAccessInput): Promise } } +export async function requireAdditionalCloudWorkerAccess(input: CloudAccessInput & { ownedWorkerCount: number }): Promise { + if (!env.polar.featureGateEnabled || !env.polar.workerProductId) { + return { allowed: true } + } + + const activeWorkerSubscriptions = await getActiveWorkerSubscriptionCount(input) + if (input.ownedWorkerCount < activeWorkerSubscriptions) { + return { allowed: true } + } + + const checkoutUrl = await createWorkerCheckoutSession(input) + if (!checkoutUrl) { + throw new Error("Polar worker checkout URL unavailable") + } + + return { + allowed: false, + checkoutUrl, + } +} + export async function getCloudWorkerBillingStatus( input: CloudAccessInput, options: BillingStatusOptions = {}, @@ -665,6 +721,10 @@ export async function getCloudWorkerBillingStatus( hasActivePlan: true, checkoutRequired: false, checkoutUrl: null, + activeWorkerSubscriptions: 0, + workerCheckoutUrl: null, + workerCheckoutRequired: false, + workerPrice: null, portalUrl: null, price: null, subscription: null, @@ -676,6 +736,12 @@ export async function getCloudWorkerBillingStatus( await sendSubscribedToDenEvent(input) } + const [activeWorkerSubscriptions, workerCheckoutUrl, workerPrice] = await Promise.all([ + getActiveWorkerSubscriptionCount(input).catch(() => 0), + options.includeCheckoutUrl ? createWorkerCheckoutSession(input).catch(() => null) : Promise.resolve(null), + env.polar.workerProductId ? getProductBillingPrice(env.polar.workerProductId).catch(() => null) : Promise.resolve(null), + ]) + const [subscriptionResult, priceResult, portalResult, invoicesResult] = await Promise.all([ getPrimarySubscriptionForCustomer(input.userId).catch(() => null), env.polar.productId ? getProductBillingPrice(env.polar.productId).catch(() => null) : Promise.resolve(null), @@ -693,6 +759,10 @@ export async function getCloudWorkerBillingStatus( hasActivePlan: evaluation.hasActivePlan, checkoutRequired: evaluation.featureGateEnabled && !evaluation.hasActivePlan, checkoutUrl: evaluation.checkoutUrl, + activeWorkerSubscriptions, + workerCheckoutUrl, + workerCheckoutRequired: activeWorkerSubscriptions <= 0, + workerPrice, portalUrl, price: productPrice ?? toBillingPriceFromSubscription(subscription), subscription, @@ -733,14 +803,14 @@ export async function getCloudWorkerAdminBillingStatus( if (env.polar.benefitId) { const externalState = await getCustomerStateByExternalId(input.userId) - if (hasRequiredBenefit(externalState)) { + if (hasBenefit(externalState, env.polar.benefitId)) { paidByBenefit = true note = "Benefit granted via external customer id." } else { const customer = await getCustomerByEmail(input.email) if (customer?.id) { const emailState = await getCustomerStateById(customer.id) - if (hasRequiredBenefit(emailState)) { + if (hasBenefit(emailState, env.polar.benefitId)) { paidByBenefit = true note = "Benefit granted via matching customer email." await linkCustomerExternalId(customer, input.userId).catch(() => undefined) @@ -792,6 +862,7 @@ export async function setCloudWorkerSubscriptionCancellation( const activeSubscriptions = await listSubscriptionsByExternalCustomer(input.userId, { activeOnly: true, limit: 1, + productId: env.polar.productId, }) const active = activeSubscriptions[0] if (!active?.id) { diff --git a/ee/apps/den-api/src/env.ts b/ee/apps/den-api/src/env.ts index ce84d9b88..37177a24d 100644 --- a/ee/apps/den-api/src/env.ts +++ b/ee/apps/den-api/src/env.ts @@ -50,6 +50,8 @@ const EnvSchema = z.object({ POLAR_ACCESS_TOKEN: z.string().optional(), POLAR_PRODUCT_ID: z.string().optional(), POLAR_BENEFIT_ID: z.string().optional(), + POLAR_WORKER_PRODUCT_ID: z.string().optional(), + POLAR_WORKER_BENEFIT_ID: z.string().optional(), POLAR_SUCCESS_URL: z.string().optional(), POLAR_RETURN_URL: z.string().optional(), DAYTONA_API_URL: z.string().optional(), @@ -208,6 +210,8 @@ export const env = { accessToken: parsed.POLAR_ACCESS_TOKEN, productId: parsed.POLAR_PRODUCT_ID, benefitId: parsed.POLAR_BENEFIT_ID, + workerProductId: parsed.POLAR_WORKER_PRODUCT_ID, + workerBenefitId: parsed.POLAR_WORKER_BENEFIT_ID, successUrl: parsed.POLAR_SUCCESS_URL, returnUrl: parsed.POLAR_RETURN_URL, }, diff --git a/ee/apps/den-api/src/routes/workers/billing.ts b/ee/apps/den-api/src/routes/workers/billing.ts index c39c2c22d..265496969 100644 --- a/ee/apps/den-api/src/routes/workers/billing.ts +++ b/ee/apps/den-api/src/routes/workers/billing.ts @@ -29,6 +29,8 @@ export function registerWorkerBillingRoutes 0) { + if (input.destination === "cloud" && !env.devMode) { const email = getRequiredUserEmail(user) if (!email) { return c.json({ error: "user_email_required" }, 400) } - const access = await requireCloudAccessOrPayment({ + const baseAccess = await requireCloudAccessOrPayment({ userId: user.id, email, name: user.name ?? user.email ?? "OpenWork User", }) - if (!access.allowed) { + if (!baseAccess.allowed) { return c.json({ error: "payment_required", - message: "Additional cloud workers require an active Den Cloud plan.", + message: "OpenWork Cloud billing is required before launching workers.", polar: { - checkoutUrl: access.checkoutUrl, + checkoutUrl: baseAccess.checkoutUrl, productId: env.polar.productId, benefitId: env.polar.benefitId, }, }, 402) } + + const ownedWorkerCount = await countUserCloudWorkers(user.id) + const workerAccess = await requireAdditionalCloudCapacityOrPayment({ + userId: user.id, + email, + name: user.name ?? user.email ?? "OpenWork User", + ownedWorkerCount, + }) + if (!workerAccess.allowed) { + return c.json({ + error: "payment_required", + message: "No workers are included by default. Purchase a worker add-on to launch another hosted worker.", + polar: { + checkoutUrl: workerAccess.checkoutUrl, + productId: env.polar.workerProductId, + benefitId: env.polar.workerBenefitId, + }, + }, 402) + } } const workerId = createDenTypeId("worker") diff --git a/ee/apps/den-api/src/routes/workers/shared.ts b/ee/apps/den-api/src/routes/workers/shared.ts index 996104c51..d64701cb7 100644 --- a/ee/apps/den-api/src/routes/workers/shared.ts +++ b/ee/apps/den-api/src/routes/workers/shared.ts @@ -12,7 +12,7 @@ import { } from "@openwork-ee/den-db/schema" import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid" import { z } from "zod" -import { getCloudWorkerBillingStatus, requireCloudWorkerAccess, setCloudWorkerSubscriptionCancellation } from "../../billing/polar.js" +import { getCloudWorkerBillingStatus, requireAdditionalCloudWorkerAccess, requireCloudWorkerAccess, setCloudWorkerSubscriptionCancellation } from "../../billing/polar.js" import { db } from "../../db.js" import { env } from "../../env.js" import type { UserOrganizationsContext } from "../../middleware/index.js" @@ -286,7 +286,6 @@ export async function countUserCloudWorkers(userId: UserId) { .select({ id: WorkerTable.id }) .from(WorkerTable) .where(and(eq(WorkerTable.created_by_user_id, userId), eq(WorkerTable.destination, "cloud"))) - .limit(2) return rows.length } @@ -385,6 +384,15 @@ export async function requireCloudAccessOrPayment(input: { return requireCloudWorkerAccess(input) } +export async function requireAdditionalCloudCapacityOrPayment(input: { + userId: UserId + email: string + name: string + ownedWorkerCount: number +}) { + return requireAdditionalCloudWorkerAccess(input) +} + export async function getWorkerBilling(input: { userId: UserId email: string diff --git a/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx b/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx index 2fc483bee..c79b11732 100644 --- a/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx @@ -12,7 +12,7 @@ const MOCK_BILLING = process.env.NEXT_PUBLIC_DEN_MOCK_BILLING === "1"; const MOCK_CHECKOUT_URL = (process.env.NEXT_PUBLIC_DEN_MOCK_CHECKOUT_URL ?? "").trim() || null; function formatSubscriptionStatus(value: string | null | undefined) { - if (!value) return "Purchase required"; + if (!value) return "Plan required"; return value .split(/[_\s]+/) .filter(Boolean) @@ -59,9 +59,13 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: featureGateEnabled: true, hasActivePlan: false, checkoutRequired: true, - checkoutUrl: MOCK_CHECKOUT_URL, - portalUrl: null, - price: { amount: 5000, currency: "usd", recurringInterval: "month", recurringIntervalCount: 1 }, + checkoutUrl: MOCK_CHECKOUT_URL, + activeWorkerSubscriptions: 0, + workerCheckoutUrl: MOCK_CHECKOUT_URL, + workerCheckoutRequired: true, + workerPrice: { amount: 5000, currency: "usd", recurringInterval: "month", recurringIntervalCount: 1 }, + portalUrl: null, + price: { amount: 5000, currency: "usd", recurringInterval: "month", recurringIntervalCount: 1 }, subscription: null, invoices: [], productId: null, @@ -174,16 +178,16 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:

OpenWork Cloud

-

Purchase worker access before launch.

+

Activate your team workspace.

- Workers are disabled by default. Add one hosted OpenWork worker for $50/month, then launch it from your dashboard. + OpenWork Cloud starts at $50/month for up to 5 seats. Worker runtime is purchased separately after your team workspace is active.

{checkoutHref ? ( - Purchase worker — $50/month + Start team plan — $50/month ) : ( )} @@ -201,7 +205,7 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:
- $50/month per worker + $50/month for 5 seats {planAmountLabel} billed monthly @@ -225,7 +229,7 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: OpenWork Cloud

Share your setup across your team.

- Manage your team's setup, invite teammates, and keep everything in sync. + Manage your team's setup, invite up to 5 seats, and add hosted workers only when you need runtime.

@@ -279,7 +283,7 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:

Billing status

{subscriptionStatus}

- {billingSummary.hasActivePlan ? "Your worker billing is active." : "Purchase a worker to enable hosted launches."} + {billingSummary.hasActivePlan ? "Your base team plan is active. Workers are sold separately." : "Purchase the base team plan to unlock OpenWork Cloud."}

@@ -303,7 +307,7 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:
{checkoutHref && !billingSummary.hasActivePlan ? ( - Purchase worker + Purchase base plan ) : null} {billingSummary.portalUrl ? ( diff --git a/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx b/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx index 1ecf7e9e3..fa6d3436b 100644 --- a/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx @@ -190,6 +190,7 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean isSelectedWorkerFailed, ownedWorkerCount, billingSummary, + effectiveWorkerCheckoutUrl, refreshWorkers, checkWorkerStatus, generateWorkerToken, @@ -234,6 +235,8 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean const webDisabled = !openworkAppConnectUrl || !isReady; const desktopDisabled = !openworkDeepLink || !isReady; const showConnectionHint = !openworkDeepLink || !hasWorkspaceScopedUrl; + const workerAllowance = billingSummary?.activeWorkerSubscriptions ?? 0; + const workerCapacityRemaining = Math.max(workerAllowance - ownedWorkerCount, 0); const mainContent = (
@@ -557,16 +560,37 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean

{billingSummary?.featureGateEnabled ? billingSummary.hasActivePlan - ? "Your account has active worker billing." - : "Workers stay disabled until you purchase one for $50/month." + ? workerAllowance > 0 + ? `${workerAllowance} worker subscription${workerAllowance === 1 ? "" : "s"} active. ${workerCapacityRemaining} remaining.` + : "Your team plan is active, but no workers are included by default." + : "Activate the base team plan before purchasing workers." : "Billing gates are disabled in this environment."}

- - Open billing - + {billingSummary?.hasActivePlan ? ( + effectiveWorkerCheckoutUrl ? ( + + Purchase worker + + ) : ( + + Open billing + + ) + ) : ( + + Open billing + + )}
@@ -575,13 +599,23 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean

No workers yet

-

Purchase your first worker to unlock connection details and runtime controls.

- - Purchase worker billing - +

Your base plan includes 0 workers. Purchase one for $50/month to unlock hosted runtime controls.

+ {effectiveWorkerCheckoutUrl ? ( + + Purchase first worker + + ) : ( + + Open billing + + )}
)} @@ -666,8 +700,8 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean Signed in as {user.email}
{billingSummary?.featureGateEnabled && !billingSummary.hasActivePlan - ? "Purchase required before the next launch." - : `${ownedWorkerCount} worker${ownedWorkerCount === 1 ? "" : "s"} in your account.`} + ? "Base plan required before the next launch." + : `${ownedWorkerCount} worker${ownedWorkerCount === 1 ? "" : "s"} in your account · ${workerAllowance} purchased.`}
diff --git a/ee/apps/den-web/app/(den)/_lib/den-flow.ts b/ee/apps/den-web/app/(den)/_lib/den-flow.ts index 8a98f2406..fba692def 100644 --- a/ee/apps/den-web/app/(den)/_lib/den-flow.ts +++ b/ee/apps/den-web/app/(den)/_lib/den-flow.ts @@ -43,6 +43,10 @@ export type BillingSummary = { hasActivePlan: boolean; checkoutRequired: boolean; checkoutUrl: string | null; + activeWorkerSubscriptions: number; + workerCheckoutUrl: string | null; + workerCheckoutRequired: boolean; + workerPrice: BillingPrice | null; portalUrl: string | null; price: BillingPrice | null; subscription: BillingSubscription | null; @@ -593,6 +597,10 @@ export function getBillingSummary(payload: unknown): BillingSummary | null { hasActivePlan, checkoutRequired, checkoutUrl: typeof billing.checkoutUrl === "string" ? billing.checkoutUrl : null, + activeWorkerSubscriptions: typeof billing.activeWorkerSubscriptions === "number" ? billing.activeWorkerSubscriptions : 0, + workerCheckoutUrl: typeof billing.workerCheckoutUrl === "string" ? billing.workerCheckoutUrl : null, + workerCheckoutRequired: billing.workerCheckoutRequired === true, + workerPrice: getBillingPrice(billing.workerPrice), portalUrl: typeof billing.portalUrl === "string" ? billing.portalUrl : null, price: getBillingPrice(billing.price), subscription: getBillingSubscription(billing.subscription), diff --git a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx index 9ca62a29e..66fb39d09 100644 --- a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx +++ b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx @@ -96,6 +96,7 @@ type DenFlowContextValue = { billingSubscriptionBusy: boolean; billingError: string | null; effectiveCheckoutUrl: string | null; + effectiveWorkerCheckoutUrl: string | null; refreshBilling: (options?: { includeCheckout?: boolean; quiet?: boolean }) => Promise; handleSubscriptionCancellation: (cancelAtPeriodEnd: boolean) => Promise; refreshCheckoutReturn: (sessionTokenPresent: boolean) => Promise; @@ -267,16 +268,17 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { { autoConnect: true } ); const ownedWorkerCount = workers.filter((item) => item.isMine).length; + const workerAllowance = billingSummary?.activeWorkerSubscriptions ?? 0; const additionalWorkerNeedsPlan = Boolean( user && - ownedWorkerCount > 0 && billingSummary?.featureGateEnabled && - !billingSummary.hasActivePlan + ownedWorkerCount >= workerAllowance ); const selectedWorkerStatus = activeWorker?.status ?? selectedWorker?.status ?? "unknown"; const selectedStatusMeta = getWorkerStatusMeta(selectedWorkerStatus); const isSelectedWorkerFailed = selectedWorkerStatus.trim().toLowerCase() === "failed"; const effectiveCheckoutUrl = checkoutUrl ?? billingSummary?.checkoutUrl ?? null; + const effectiveWorkerCheckoutUrl = checkoutUrl ?? billingSummary?.workerCheckoutUrl ?? null; const onboardingPending = Boolean(onboardingIntent?.shouldLaunch && !onboardingIntent.completed); const onboardingDecisionBusy = onboardingPending && !billingLoadedOnce && (billingBusy || billingCheckoutBusy || !sessionHydrated); @@ -1324,13 +1326,12 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { return current; } - return { - ...current, - hasActivePlan: false, - checkoutRequired: true, - checkoutUrl: url ?? current.checkoutUrl - }; - }); + return { + ...current, + workerCheckoutRequired: true, + workerCheckoutUrl: url ?? current.workerCheckoutUrl + }; + }); setLaunchStatus("Payment is required. Complete checkout and return to continue launch."); setLaunchError(url ? null : "Checkout URL missing from paywall response."); appendEvent("warning", "Paywall required", url ? "Checkout URL generated" : "Checkout URL missing"); @@ -2015,7 +2016,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { return; } - if (ownedWorkerCount > 0) { + if (ownedWorkerCount >= workerAllowance) { markOnboardingComplete(); return; } @@ -2031,7 +2032,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { onboardingAutoLaunchKeyRef.current = autoLaunchKey; markOnboardingComplete(); - }, [billingSummary?.featureGateEnabled, billingSummary?.hasActivePlan, launchBusy, onboardingIntent?.workerName, onboardingPending, ownedWorkerCount, user?.id]); + }, [billingSummary?.featureGateEnabled, billingSummary?.hasActivePlan, billingSummary?.activeWorkerSubscriptions, launchBusy, onboardingIntent?.workerName, onboardingPending, ownedWorkerCount, workerAllowance, user?.id]); useEffect(() => { if (!user) { @@ -2078,6 +2079,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { billingSubscriptionBusy, billingError, effectiveCheckoutUrl, + effectiveWorkerCheckoutUrl, refreshBilling, handleSubscriptionCancellation, refreshCheckoutReturn, diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx index 95cd8d9a4..8c79c42be 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx @@ -49,6 +49,7 @@ export function BillingDashboardScreen() { } const billingPrice = billingSummary?.price ?? null; + const workerPrice = billingSummary?.workerPrice ?? null; const subscription = billingSummary?.subscription ?? null; const planAmountLabel = billingPrice ? `${formatMoneyMinor(billingPrice.amount, billingPrice.currency)} · ${formatRecurringInterval( @@ -60,7 +61,7 @@ export function BillingDashboardScreen() { ? formatSubscriptionStatus(subscription.status) : billingSummary?.hasActivePlan ? "Active" - : "Purchase required"; + : "Plan required"; const nextBillingDate = subscription?.currentPeriodEnd ? formatIsoDate(subscription.currentPeriodEnd) : "Not available"; @@ -92,7 +93,7 @@ export function BillingDashboardScreen() {

{billingSummary?.hasActivePlan ? `This workspace's plan is currently ${statusLabel.toLowerCase()} and renews on ${nextBillingDate}.` - : "Workers are $50/month each. Purchase a worker to enable hosted launches for your team."} + : "Start your OpenWork Cloud base plan when your team is ready to share templates and cloud workflows."}

@@ -107,6 +108,23 @@ export function BillingDashboardScreen() {
{planAmountLabel}
+
+

Included seats

+
5 seats
+
+ +
+

Purchased workers

+
{billingSummary?.activeWorkerSubscriptions ?? 0}
+
+ +
+

Worker add-on price

+
+ {workerPrice ? `${formatMoneyMinor(workerPrice.amount, workerPrice.currency)} · ${formatRecurringInterval(workerPrice.recurringInterval, workerPrice.recurringIntervalCount)}` : "$50.00 · month"} +
+
+

Next billing date

{nextBillingDate}
@@ -144,7 +162,17 @@ export function BillingDashboardScreen() { rel="noreferrer" className="rounded-full bg-gray-900 px-5 py-2.5 text-[14px] font-medium text-white transition-colors hover:bg-gray-800" > - Purchase worker + Purchase base plan + + ) : null} + + {billingSummary?.workerCheckoutUrl ? ( + + Purchase worker add-on ) : null} @@ -184,27 +212,6 @@ export function BillingDashboardScreen() {
-
-

Pricing

-
-
-

Solo

-

$0

-

Free forever · open source

-
-
-

Cloud worker

-

$50/month

-

Per worker · 5 seats included

-
-
-

Enterprise

-

Custom

-

Windows included · talk to us

-
-
-
-

Invoices

diff --git a/ee/apps/landing/app/pricing/page.tsx b/ee/apps/landing/app/pricing/page.tsx index 86a96d26f..e705bbde1 100644 --- a/ee/apps/landing/app/pricing/page.tsx +++ b/ee/apps/landing/app/pricing/page.tsx @@ -7,7 +7,7 @@ import { getGithubData } from "../../lib/github"; export const metadata = { title: "OpenWork — Pricing", description: - "Free desktop app, cloud workers from $50/month, and enterprise licensing." + "Free solo desktop usage, annual Windows support, cloud teams starting at 5 seats, and worker add-ons billed separately." }; export default async function PricingPage() { @@ -35,6 +35,9 @@ export default async function PricingPage() {

Pricing

+

+ Start solo for free. Purchase Windows support when you need it. OpenWork Cloud starts at 5 seats, then add hosted workers separately when you want runtime. Talk to us for enterprise licensing. +

diff --git a/ee/apps/landing/components/landing-home.tsx b/ee/apps/landing/components/landing-home.tsx index 48d3eb48d..6e337d7b6 100644 --- a/ee/apps/landing/components/landing-home.tsx +++ b/ee/apps/landing/components/landing-home.tsx @@ -111,9 +111,9 @@ export function LandingHome(props: Props) {
Solo free forever - Workers $50/month + Windows support $99/year - Enterprise talk to us + Cloud $50/month for 5 seats
@@ -269,8 +269,8 @@ export function LandingHome(props: Props) {

Hosted sandboxed workers

- Workers are disabled by default. Purchase one for $50/month when - you need hosted runtime. + Cloud starts at $50/month for 5 seats. Workers are disabled by + default and added separately for $50/month each.

Learn more diff --git a/ee/apps/landing/components/pricing-grid.tsx b/ee/apps/landing/components/pricing-grid.tsx index 459f1be2c..5adc34895 100644 --- a/ee/apps/landing/components/pricing-grid.tsx +++ b/ee/apps/landing/components/pricing-grid.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowUpRight, Cloud, Download, Shield, CornerRightDown } from "lucide-react"; +import { ArrowUpRight, Cloud, Download, Monitor, Shield } from "lucide-react"; import { ResponsiveGrain } from "./responsive-grain"; type PricingGridProps = { @@ -27,11 +27,9 @@ type PricingCard = { function PricingCardView({ card }: { card: PricingCard }) { return ( -
- {/* ── Header card ── */} -
- {/* Shader layer — hidden by default, revealed on hover */} -
+
+
+
-
+
-
+

{card.title}

{card.isCustomPricing ? ( -
{card.price}
+
{card.price}
) : (
- {card.price} - + {card.price} + {card.priceSub}
@@ -67,7 +65,7 @@ function PricingCardView({ card }: { card: PricingCard }) { {card.ctaLabel} @@ -75,7 +73,6 @@ function PricingCardView({ card }: { card: PricingCard }) {
- {/* ── Features list ── */}
{card.features.map((feature, idx) => { @@ -83,9 +80,9 @@ function PricingCardView({ card }: { card: PricingCard }) { return (
- + {feature.text}
); @@ -93,7 +90,6 @@ function PricingCardView({ card }: { card: PricingCard }) {
- {/* ── Footer ── */}
{card.footer}
@@ -121,19 +117,37 @@ export function PricingGrid(props: PricingGridProps) { gradientShape: "wave", }, { - id: "cloud-workers", - title: "Cloud workers", + id: "windows-support", + title: "Windows support", + price: "$99", + priceSub: "per year · 1 seat", + ctaLabel: "Purchase Windows support", + href: props.windowsCheckoutUrl, + external: /^https?:\/\//.test(props.windowsCheckoutUrl), + features: [ + { text: "1 Windows seat", icon: Monitor }, + { text: "Binary access", icon: Monitor }, + { text: "1 year of updates", icon: Monitor }, + ], + footer: "Manual fulfillment in phase one", + gradientColors: ["#7C3AED", "#E11D48", "#9333EA", "#1F2937"], + gradientBack: "#111827", + gradientShape: "corners", + }, + { + id: "cloud-teams", + title: "Cloud teams", price: "$50", - priceSub: "per month · per worker", - ctaLabel: "Purchase worker", + priceSub: "per month · 5 seats", + ctaLabel: "Start cloud plan", href: "https://app.openworklabs.com/checkout", external: true, features: [ { text: "5 seats included", icon: Cloud }, - { text: "Hosted OpenWork worker", icon: Cloud }, + { text: "0 workers included by default", icon: Cloud }, { text: "$50 per additional worker", icon: Cloud }, ], - footer: "Workers disabled by default", + footer: "Base plan first, then add worker capacity as needed", gradientColors: ["#2563EB", "#0284C7", "#0EA5E9", "#0F172A"], gradientBack: "#0C1220", gradientShape: "ripple", @@ -163,22 +177,30 @@ export function PricingGrid(props: PricingGridProps) {
{props.showHeader !== false ? (
-

- Pricing -

+
+
+ Pricing +
+

+ Gray by default. Clear when you hover. +

+
+

+ Solo stays free forever. Windows is annual. Cloud starts at 5 seats, and workers are added separately. Enterprise starts with a conversation. +

) : null} -
+
{cards.map((card) => ( -
+
))}

- Prices exclude taxes. + Prices exclude taxes. Windows delivery is manual in phase one.

); From 861d26415a1842c5e16b215e049c8edb2f547d2d Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Wed, 1 Apr 2026 15:38:38 -0700 Subject: [PATCH 02/10] fix(den-web): keep checkout page open for billing --- .../app/(den)/_components/checkout-screen.tsx | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx b/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx index c79b11732..4e308c284 100644 --- a/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx @@ -35,7 +35,6 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: const router = useRouter(); const pathname = usePathname(); const handledReturnRef = useRef(false); - const redirectingRef = useRef(false); const [resuming, setResuming] = useState(false); const [redirectMessage, setRedirectMessage] = useState(null); const { @@ -49,7 +48,6 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: onboardingPending, refreshBilling, refreshCheckoutReturn, - resolveUserLandingRoute, } = useDenFlow(); const mockMode = MOCK_BILLING && process.env.NODE_ENV !== "production"; @@ -128,27 +126,6 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: user, ]); - useEffect(() => { - if (!sessionHydrated || !user || resuming || onboardingPending || mockMode || redirectingRef.current) { - return; - } - - redirectingRef.current = true; - void resolveUserLandingRoute() - .then((target) => { - if (target && !isSamePathname(pathname, target)) { - setRedirectMessage("Redirecting to your workspace..."); - router.replace(target); - return; - } - - setRedirectMessage(null); - }) - .finally(() => { - redirectingRef.current = false; - }); - }, [mockMode, onboardingPending, pathname, resolveUserLandingRoute, resuming, router, sessionHydrated, user]); - if (!sessionHydrated || (!user && !mockMode)) { return ( Date: Wed, 1 Apr 2026 15:49:38 -0700 Subject: [PATCH 03/10] fix(den): bill cloud access at the org level --- ee/apps/den-api/src/billing/polar.ts | 30 ++++++++++++------- ee/apps/den-api/src/routes/workers/billing.ts | 16 ++++++++-- ee/apps/den-api/src/routes/workers/core.ts | 6 ++-- ee/apps/den-api/src/routes/workers/shared.ts | 10 +++++-- .../(den)/_components/dashboard-screen.tsx | 6 ++-- .../(den)/_providers/den-flow-provider.tsx | 2 +- .../_components/billing-dashboard-screen.tsx | 2 +- ee/apps/landing/app/pricing/page.tsx | 4 +-- 8 files changed, 51 insertions(+), 25 deletions(-) diff --git a/ee/apps/den-api/src/billing/polar.ts b/ee/apps/den-api/src/billing/polar.ts index bc47cdfd2..0e6bc391a 100644 --- a/ee/apps/den-api/src/billing/polar.ts +++ b/ee/apps/den-api/src/billing/polar.ts @@ -143,6 +143,7 @@ type CloudAccessInput = { userId: string email: string name: string + orgId?: string | null } type BillingStatusOptions = { @@ -167,6 +168,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null } +function getExternalCustomerId(input: CloudAccessInput) { + return input.orgId?.trim() || input.userId +} + async function polarFetch(path: string, init: RequestInit = {}) { const headers = new Headers(init.headers) headers.set("Authorization", `Bearer ${env.polar.accessToken}`) @@ -284,11 +289,12 @@ function hasBenefit(state: PolarCustomerState | null, benefitId: string | undefi } async function createCheckoutSessionForProduct(input: CloudAccessInput, productId: string): Promise { + const externalCustomerId = getExternalCustomerId(input) const payload = { products: [productId], success_url: env.polar.successUrl, return_url: env.polar.returnUrl, - external_customer_id: input.userId, + external_customer_id: externalCustomerId, customer_email: input.email, customer_name: input.name, } @@ -329,7 +335,8 @@ async function evaluateCloudWorkerAccess( assertPaywallConfig() - const externalState = await getCustomerStateByExternalId(input.userId) + const externalCustomerId = getExternalCustomerId(input) + const externalState = await getCustomerStateByExternalId(externalCustomerId) if (hasBenefit(externalState, env.polar.benefitId)) { return { featureGateEnabled: true, @@ -342,7 +349,7 @@ async function evaluateCloudWorkerAccess( if (customer?.id) { const emailState = await getCustomerStateById(customer.id) if (hasBenefit(emailState, env.polar.benefitId)) { - await linkCustomerExternalId(customer, input.userId).catch(() => undefined) + await linkCustomerExternalId(customer, externalCustomerId).catch(() => undefined) return { featureGateEnabled: true, hasActivePlan: true, @@ -364,7 +371,7 @@ async function getActiveWorkerSubscriptionCount(input: CloudAccessInput): Promis return 0 } - const subscriptions = await listSubscriptionsByExternalCustomer(input.userId, { + const subscriptions = await listSubscriptionsByExternalCustomer(getExternalCustomerId(input), { activeOnly: true, limit: 100, productId: env.polar.workerProductId, @@ -743,10 +750,10 @@ export async function getCloudWorkerBillingStatus( ]) const [subscriptionResult, priceResult, portalResult, invoicesResult] = await Promise.all([ - getPrimarySubscriptionForCustomer(input.userId).catch(() => null), + getPrimarySubscriptionForCustomer(getExternalCustomerId(input)).catch(() => null), env.polar.productId ? getProductBillingPrice(env.polar.productId).catch(() => null) : Promise.resolve(null), - includePortalUrl ? createCustomerPortalUrl(input.userId).catch(() => null) : Promise.resolve(null), - includeInvoices ? listBillingInvoices(input.userId).catch(() => []) : Promise.resolve([]), + includePortalUrl ? createCustomerPortalUrl(getExternalCustomerId(input)).catch(() => null) : Promise.resolve(null), + includeInvoices ? listBillingInvoices(getExternalCustomerId(input)).catch(() => []) : Promise.resolve([]), ]) const subscription = toBillingSubscription(subscriptionResult) @@ -802,7 +809,8 @@ export async function getCloudWorkerAdminBillingStatus( let paidByBenefit = false if (env.polar.benefitId) { - const externalState = await getCustomerStateByExternalId(input.userId) + const externalCustomerId = getExternalCustomerId(input) + const externalState = await getCustomerStateByExternalId(externalCustomerId) if (hasBenefit(externalState, env.polar.benefitId)) { paidByBenefit = true note = "Benefit granted via external customer id." @@ -813,13 +821,13 @@ export async function getCloudWorkerAdminBillingStatus( if (hasBenefit(emailState, env.polar.benefitId)) { paidByBenefit = true note = "Benefit granted via matching customer email." - await linkCustomerExternalId(customer, input.userId).catch(() => undefined) + await linkCustomerExternalId(customer, externalCustomerId).catch(() => undefined) } } } } - const subscription = env.polar.productId ? await getPrimarySubscriptionForCustomer(input.userId) : null + const subscription = env.polar.productId ? await getPrimarySubscriptionForCustomer(getExternalCustomerId(input)) : null const normalizedSubscription = toBillingSubscription(subscription) const paidBySubscription = isActiveSubscriptionStatus(normalizedSubscription?.status) @@ -859,7 +867,7 @@ export async function setCloudWorkerSubscriptionCancellation( assertPaywallConfig() - const activeSubscriptions = await listSubscriptionsByExternalCustomer(input.userId, { + const activeSubscriptions = await listSubscriptionsByExternalCustomer(getExternalCustomerId(input), { activeOnly: true, limit: 1, productId: env.polar.productId, diff --git a/ee/apps/den-api/src/routes/workers/billing.ts b/ee/apps/den-api/src/routes/workers/billing.ts index 265496969..04478e55e 100644 --- a/ee/apps/den-api/src/routes/workers/billing.ts +++ b/ee/apps/den-api/src/routes/workers/billing.ts @@ -1,22 +1,27 @@ import type { Hono } from "hono" import { env } from "../../env.js" -import { jsonValidator, queryValidator, requireUserMiddleware } from "../../middleware/index.js" +import { jsonValidator, queryValidator, requireUserMiddleware, resolveUserOrganizationsMiddleware } from "../../middleware/index.js" import { getRequiredUserEmail } from "../../user.js" import type { WorkerRouteVariables } from "./shared.js" import { billingQuerySchema, billingSubscriptionSchema, getWorkerBilling, setWorkerBillingSubscription, queryIncludesFlag } from "./shared.js" export function registerWorkerBillingRoutes(app: Hono) { - app.get("/v1/workers/billing", requireUserMiddleware, queryValidator(billingQuerySchema), async (c) => { + app.get("/v1/workers/billing", requireUserMiddleware, resolveUserOrganizationsMiddleware, queryValidator(billingQuerySchema), async (c) => { const user = c.get("user") + const orgId = c.get("activeOrganizationId") const query = c.req.valid("query") const email = getRequiredUserEmail(user) if (!email) { return c.json({ error: "user_email_required" }, 400) } + if (!orgId) { + return c.json({ error: "organization_required" }, 409) + } const billing = await getWorkerBilling({ userId: user.id, + orgId, email, name: user.name ?? user.email ?? "OpenWork User", includeCheckoutUrl: queryIncludesFlag(query.includeCheckout), @@ -35,17 +40,22 @@ export function registerWorkerBillingRoutes { + app.post("/v1/workers/billing/subscription", requireUserMiddleware, resolveUserOrganizationsMiddleware, jsonValidator(billingSubscriptionSchema), async (c) => { const user = c.get("user") + const orgId = c.get("activeOrganizationId") const input = c.req.valid("json") const email = getRequiredUserEmail(user) if (!email) { return c.json({ error: "user_email_required" }, 400) } + if (!orgId) { + return c.json({ error: "organization_required" }, 409) + } const billingInput = { userId: user.id, + orgId, email, name: user.name ?? user.email ?? "OpenWork User", } diff --git a/ee/apps/den-api/src/routes/workers/core.ts b/ee/apps/den-api/src/routes/workers/core.ts index 5e856a18a..b0392bf31 100644 --- a/ee/apps/den-api/src/routes/workers/core.ts +++ b/ee/apps/den-api/src/routes/workers/core.ts @@ -9,7 +9,7 @@ import { getRequiredUserEmail } from "../../user.js" import type { WorkerRouteVariables } from "./shared.js" import { continueCloudProvisioning, - countUserCloudWorkers, + countOrgCloudWorkers, createWorkerSchema, deleteWorkerCascade, getLatestWorkerInstance, @@ -77,6 +77,7 @@ export function registerWorkerCoreRoutes 0 - ? `${workerAllowance} worker subscription${workerAllowance === 1 ? "" : "s"} active. ${workerCapacityRemaining} remaining.` - : "Your team plan is active, but no workers are included by default." + ? `${workerAllowance} worker subscription${workerAllowance === 1 ? "" : "s"} active for this org. ${workerCapacityRemaining} remaining.` + : "Your team plan is active, but no workers are included for this org by default." : "Activate the base team plan before purchasing workers." : "Billing gates are disabled in this environment."}

@@ -701,7 +701,7 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean
{billingSummary?.featureGateEnabled && !billingSummary.hasActivePlan ? "Base plan required before the next launch." - : `${ownedWorkerCount} worker${ownedWorkerCount === 1 ? "" : "s"} in your account · ${workerAllowance} purchased.`} + : `${ownedWorkerCount} worker${ownedWorkerCount === 1 ? "" : "s"} in this org · ${workerAllowance} purchased.`}
diff --git a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx index 66fb39d09..171df3475 100644 --- a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx +++ b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx @@ -267,7 +267,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { activeWorker?.workerName ?? null, { autoConnect: true } ); - const ownedWorkerCount = workers.filter((item) => item.isMine).length; + const ownedWorkerCount = workers.length; const workerAllowance = billingSummary?.activeWorkerSubscriptions ?? 0; const additionalWorkerNeedsPlan = Boolean( user && diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx index 8c79c42be..1d0cd58a2 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx @@ -92,7 +92,7 @@ export function BillingDashboardScreen() {

{billingSummary?.hasActivePlan - ? `This workspace's plan is currently ${statusLabel.toLowerCase()} and renews on ${nextBillingDate}.` + ? `This organization's plan is currently ${statusLabel.toLowerCase()} and renews on ${nextBillingDate}.` : "Start your OpenWork Cloud base plan when your team is ready to share templates and cloud workflows."}

diff --git a/ee/apps/landing/app/pricing/page.tsx b/ee/apps/landing/app/pricing/page.tsx index e705bbde1..f6496f04c 100644 --- a/ee/apps/landing/app/pricing/page.tsx +++ b/ee/apps/landing/app/pricing/page.tsx @@ -30,7 +30,7 @@ export default async function PricingPage() { />
-
+

Pricing @@ -40,7 +40,7 @@ export default async function PricingPage() {

- +
From b2c6ed4e242949b977be685604904862625858a5 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Wed, 1 Apr 2026 17:00:04 -0700 Subject: [PATCH 04/10] fix(den): keep billing scoped to the active org Remove legacy user-level Polar fallbacks, sync the session org from the routed dashboard context, and distinguish worker add-on checkout from the base plan. --- ee/apps/den-api/src/billing/polar.ts | 93 +------------------ ee/apps/den-api/src/routes/org/core.ts | 14 ++- .../app/(den)/_components/checkout-screen.tsx | 44 +++++++-- .../_components/billing-dashboard-screen.tsx | 28 ++++-- ee/apps/landing/components/den-hero.tsx | 8 +- 5 files changed, 74 insertions(+), 113 deletions(-) diff --git a/ee/apps/den-api/src/billing/polar.ts b/ee/apps/den-api/src/billing/polar.ts index 0e6bc391a..9639a011c 100644 --- a/ee/apps/den-api/src/billing/polar.ts +++ b/ee/apps/den-api/src/billing/polar.ts @@ -15,12 +15,6 @@ type PolarCustomerSession = { customer_portal_url?: string } -type PolarCustomer = { - id?: string - email?: string - external_id?: string | null -} - type PolarListResource = { items?: T[] } @@ -225,61 +219,6 @@ async function getCustomerStateByExternalId(externalCustomerId: string): Promise return payload } -async function getCustomerStateById(customerId: string): Promise { - const encodedCustomerId = encodeURIComponent(customerId) - const { response, payload, text } = await polarFetchJson(`/v1/customers/${encodedCustomerId}/state`, { - method: "GET", - }) - - if (response.status === 404) { - return null - } - - if (!response.ok) { - throw new Error(`Polar customer state lookup by ID failed (${response.status}): ${text.slice(0, 400)}`) - } - - return payload -} - -async function getCustomerByEmail(email: string): Promise { - const normalizedEmail = email.trim().toLowerCase() - if (!normalizedEmail) { - return null - } - - const encodedEmail = encodeURIComponent(normalizedEmail) - const { response, payload, text } = await polarFetchJson>(`/v1/customers/?email=${encodedEmail}`, { - method: "GET", - }) - - if (!response.ok) { - throw new Error(`Polar customer lookup by email failed (${response.status}): ${text.slice(0, 400)}`) - } - - const customers = payload?.items ?? [] - const exact = customers.find((customer) => customer.email?.trim().toLowerCase() === normalizedEmail) - return exact ?? customers[0] ?? null -} - -async function linkCustomerExternalId(customer: PolarCustomer, externalCustomerId: string): Promise { - if (!customer.id) { - return - } - - if (typeof customer.external_id === "string" && customer.external_id.length > 0) { - return - } - - const encodedCustomerId = encodeURIComponent(customer.id) - await polarFetch(`/v1/customers/${encodedCustomerId}`, { - method: "PATCH", - body: JSON.stringify({ - external_id: externalCustomerId, - }), - }) -} - function hasBenefit(state: PolarCustomerState | null, benefitId: string | undefined) { if (!state?.granted_benefits || !benefitId) { return false @@ -345,19 +284,6 @@ async function evaluateCloudWorkerAccess( } } - const customer = await getCustomerByEmail(input.email) - if (customer?.id) { - const emailState = await getCustomerStateById(customer.id) - if (hasBenefit(emailState, env.polar.benefitId)) { - await linkCustomerExternalId(customer, externalCustomerId).catch(() => undefined) - return { - featureGateEnabled: true, - hasActivePlan: true, - checkoutUrl: null, - } - } - } - const productId = env.polar.productId return { featureGateEnabled: true, @@ -512,9 +438,6 @@ async function getPrimarySubscriptionForCustomer(externalCustomerId: string): Pr async function listRecentOrdersByExternalCustomer(externalCustomerId: string, limit = 6): Promise { const params = new URLSearchParams() params.set("external_customer_id", externalCustomerId) - if (env.polar.productId) { - params.set("product_id", env.polar.productId) - } params.set("limit", String(limit)) params.set("sorting", "-created_at") @@ -745,7 +668,9 @@ export async function getCloudWorkerBillingStatus( const [activeWorkerSubscriptions, workerCheckoutUrl, workerPrice] = await Promise.all([ getActiveWorkerSubscriptionCount(input).catch(() => 0), - options.includeCheckoutUrl ? createWorkerCheckoutSession(input).catch(() => null) : Promise.resolve(null), + evaluation.hasActivePlan && options.includeCheckoutUrl + ? createWorkerCheckoutSession(input).catch(() => null) + : Promise.resolve(null), env.polar.workerProductId ? getProductBillingPrice(env.polar.workerProductId).catch(() => null) : Promise.resolve(null), ]) @@ -768,7 +693,7 @@ export async function getCloudWorkerBillingStatus( checkoutUrl: evaluation.checkoutUrl, activeWorkerSubscriptions, workerCheckoutUrl, - workerCheckoutRequired: activeWorkerSubscriptions <= 0, + workerCheckoutRequired: evaluation.hasActivePlan && activeWorkerSubscriptions <= 0, workerPrice, portalUrl, price: productPrice ?? toBillingPriceFromSubscription(subscription), @@ -814,16 +739,6 @@ export async function getCloudWorkerAdminBillingStatus( if (hasBenefit(externalState, env.polar.benefitId)) { paidByBenefit = true note = "Benefit granted via external customer id." - } else { - const customer = await getCustomerByEmail(input.email) - if (customer?.id) { - const emailState = await getCustomerStateById(customer.id) - if (hasBenefit(emailState, env.polar.benefitId)) { - paidByBenefit = true - note = "Benefit granted via matching customer email." - await linkCustomerExternalId(customer, externalCustomerId).catch(() => undefined) - } - } } } diff --git a/ee/apps/den-api/src/routes/org/core.ts b/ee/apps/den-api/src/routes/org/core.ts index 469f9c77c..f00083540 100644 --- a/ee/apps/den-api/src/routes/org/core.ts +++ b/ee/apps/den-api/src/routes/org/core.ts @@ -101,9 +101,19 @@ export function registerOrgCoreRoutes { + async (c) => { + const session = c.get("session") + const organizationContext = c.get("organizationContext") + + if (session?.id) { + await setSessionActiveOrganization( + normalizeDenTypeId("session", session.id), + organizationContext.organization.id, + ) + } + return c.json({ - ...c.get("organizationContext"), + ...organizationContext, currentMemberTeams: c.get("memberTeams") ?? [], }) }, diff --git a/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx b/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx index 4e308c284..8a825c581 100644 --- a/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx @@ -45,7 +45,7 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: billingCheckoutBusy, billingError, effectiveCheckoutUrl, - onboardingPending, + effectiveWorkerCheckoutUrl, refreshBilling, refreshCheckoutReturn, } = useDenFlow(); @@ -112,7 +112,11 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: return; } - if (!billingSummary?.hasActivePlan && !effectiveCheckoutUrl && !billingBusy && !billingCheckoutBusy) { + const needsCheckoutUrl = billingSummary?.hasActivePlan + ? !effectiveWorkerCheckoutUrl + : !effectiveCheckoutUrl; + + if (needsCheckoutUrl && !billingBusy && !billingCheckoutBusy) { void refreshBilling({ includeCheckout: true, quiet: true }); } }, [ @@ -120,6 +124,7 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: billingCheckoutBusy, billingSummary?.hasActivePlan, effectiveCheckoutUrl, + effectiveWorkerCheckoutUrl, refreshBilling, resuming, sessionHydrated, @@ -140,12 +145,20 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: } const billingPrice = billingSummary?.price ?? null; + const workerBillingPrice = billingSummary?.workerPrice ?? null; const showLoading = resuming || (billingBusy && !billingSummary && !MOCK_BILLING); - const checkoutHref = effectiveCheckoutUrl ?? MOCK_CHECKOUT_URL ?? null; + const isWorkerCheckout = billingSummary?.hasActivePlan === true; + const checkoutHref = isWorkerCheckout + ? effectiveWorkerCheckoutUrl ?? MOCK_CHECKOUT_URL ?? null + : effectiveCheckoutUrl ?? MOCK_CHECKOUT_URL ?? null; const planAmountLabel = billingPrice && billingPrice.amount !== null ? `${formatMoneyMinor(billingPrice.amount, billingPrice.currency)}/${billingPrice.recurringInterval}` : "$50.00/month"; + const workerAmountLabel = + workerBillingPrice && workerBillingPrice.amount !== null + ? `${formatMoneyMinor(workerBillingPrice.amount, workerBillingPrice.currency)}/${workerBillingPrice.recurringInterval}` + : "$50.00/month"; const subscription = billingSummary?.subscription ?? null; const subscriptionStatus = formatSubscriptionStatus(subscription?.status); @@ -155,16 +168,20 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:

OpenWork Cloud

-

Activate your team workspace.

+

+ {isWorkerCheckout ? "Add worker capacity to your org." : "Activate your team workspace."} +

- OpenWork Cloud starts at $50/month for up to 5 seats. Worker runtime is purchased separately after your team workspace is active. + {isWorkerCheckout + ? "Your base team plan is active. Purchase worker runtime separately whenever your org needs more hosted capacity." + : "OpenWork Cloud starts at $50/month for up to 5 seats. Worker runtime is purchased separately after your team workspace is active."}

{checkoutHref ? ( - Start team plan — $50/month + {isWorkerCheckout ? "Purchase worker add-on — $50/month" : "Start team plan — $50/month"} ) : ( )} @@ -182,9 +199,9 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:
- $50/month for 5 seats + {isWorkerCheckout ? "$50/month per worker" : "$50/month for 5 seats"} - {planAmountLabel} billed monthly + {isWorkerCheckout ? workerAmountLabel : planAmountLabel} billed monthly {user?.email ?? "Signed in"}
@@ -260,7 +277,9 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:

Billing status

{subscriptionStatus}

- {billingSummary.hasActivePlan ? "Your base team plan is active. Workers are sold separately." : "Purchase the base team plan to unlock OpenWork Cloud."} + {billingSummary.hasActivePlan + ? "Your base team plan is active. Add worker capacity whenever you need more hosted runtime." + : "Purchase the base team plan to unlock OpenWork Cloud."}

@@ -287,6 +306,11 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: Purchase base plan ) : null} + {checkoutHref && billingSummary.hasActivePlan ? ( + + Purchase worker add-on + + ) : null} {billingSummary.portalUrl ? ( Open billing portal diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx index 1d0cd58a2..6860db9d1 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx @@ -51,6 +51,17 @@ export function BillingDashboardScreen() { const billingPrice = billingSummary?.price ?? null; const workerPrice = billingSummary?.workerPrice ?? null; const subscription = billingSummary?.subscription ?? null; + const basePlanAmount = subscription?.amount ?? billingPrice?.amount ?? null; + const workerSubscriptionCount = billingSummary?.activeWorkerSubscriptions ?? 0; + const workerRecurringTotal = + workerPrice?.amount !== null && workerPrice?.amount !== undefined + ? workerPrice.amount * workerSubscriptionCount + : null; + const estimatedMonthlyTotal = + basePlanAmount !== null || workerRecurringTotal !== null + ? (basePlanAmount ?? 0) + (workerRecurringTotal ?? 0) + : null; + const totalCurrency = subscription?.currency ?? billingPrice?.currency ?? workerPrice?.currency ?? null; const planAmountLabel = billingPrice ? `${formatMoneyMinor(billingPrice.amount, billingPrice.currency)} · ${formatRecurringInterval( billingPrice.recurringInterval, @@ -65,10 +76,9 @@ export function BillingDashboardScreen() { const nextBillingDate = subscription?.currentPeriodEnd ? formatIsoDate(subscription.currentPeriodEnd) : "Not available"; - const nextPaymentAmount = subscription?.amount - ? formatMoneyMinor(subscription.amount, subscription.currency) - : billingPrice - ? formatMoneyMinor(billingPrice.amount, billingPrice.currency) + const nextPaymentAmount = + estimatedMonthlyTotal !== null + ? formatMoneyMinor(estimatedMonthlyTotal, totalCurrency) : "Not available"; return ( @@ -104,7 +114,7 @@ export function BillingDashboardScreen() {
-

Plan cost

+

Base plan cost

{planAmountLabel}
@@ -115,7 +125,7 @@ export function BillingDashboardScreen() {

Purchased workers

-
{billingSummary?.activeWorkerSubscriptions ?? 0}
+
{workerSubscriptionCount}
@@ -126,12 +136,12 @@ export function BillingDashboardScreen() {
-

Next billing date

+

Base plan renews

{nextBillingDate}
-

Next payment amount

+

Estimated monthly total

{nextPaymentAmount}
@@ -166,7 +176,7 @@ export function BillingDashboardScreen() {
) : null} - {billingSummary?.workerCheckoutUrl ? ( + {billingSummary?.hasActivePlan && billingSummary?.workerCheckoutUrl ? ( Agents that never sleep

- Cloud gives you a personal cloud workspace for long-running tasks, background automation, and the same agent workflows you already use locally in OpenWork, without keeping your own machine awake. + OpenWork Cloud gives your team a shared workspace for long-running tasks, + background automation, and the same agent workflows you already use locally, + with hosted workers added only when you need runtime.

@@ -27,8 +29,8 @@ export function DenHero(props: DenHeroProps) { Get started
- $50/mo per worker - Free for a limited time + $50/mo for 5 seats + $50/mo per worker add-on
From 89bc7883f8598fabb65998a2c69400958459ce4f Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 1 Apr 2026 17:01:11 -0700 Subject: [PATCH 05/10] feat(den): separate team billing from worker capacity (#1286) * feat(den): separate team billing from worker capacity * fix(den-web): keep checkout page open for billing * fix(den): bill cloud access at the org level * fix(den): keep billing scoped to the active org Remove legacy user-level Polar fallbacks, sync the session org from the routed dashboard context, and distinguish worker add-on checkout from the base plan. --- ee/apps/den-api/.env.example | 2 + ee/apps/den-api/README.md | 7 + ee/apps/den-api/src/billing/polar.ts | 210 +++++++++--------- ee/apps/den-api/src/env.ts | 4 + ee/apps/den-api/src/routes/org/core.ts | 14 +- ee/apps/den-api/src/routes/workers/billing.ts | 20 +- ee/apps/den-api/src/routes/workers/core.ts | 34 ++- ee/apps/den-api/src/routes/workers/shared.ts | 22 +- .../app/(den)/_components/checkout-screen.tsx | 83 +++---- .../(den)/_components/dashboard-screen.tsx | 68 ++++-- ee/apps/den-web/app/(den)/_lib/den-flow.ts | 8 + .../(den)/_providers/den-flow-provider.tsx | 26 ++- .../_components/billing-dashboard-screen.tsx | 81 ++++--- ee/apps/landing/app/pricing/page.tsx | 13 +- ee/apps/landing/components/den-hero.tsx | 8 +- ee/apps/landing/components/landing-home.tsx | 8 +- ee/apps/landing/components/pricing-grid.tsx | 78 ++++--- 17 files changed, 421 insertions(+), 265 deletions(-) diff --git a/ee/apps/den-api/.env.example b/ee/apps/den-api/.env.example index 23afc77f3..8a6a5e0ca 100644 --- a/ee/apps/den-api/.env.example +++ b/ee/apps/den-api/.env.example @@ -18,6 +18,8 @@ POLAR_FEATURE_GATE_ENABLED=false POLAR_ACCESS_TOKEN= POLAR_PRODUCT_ID= POLAR_BENEFIT_ID= +POLAR_WORKER_PRODUCT_ID= +POLAR_WORKER_BENEFIT_ID= POLAR_SUCCESS_URL= POLAR_RETURN_URL= DAYTONA_API_KEY= diff --git a/ee/apps/den-api/README.md b/ee/apps/den-api/README.md index 0469126f3..ac3a8356a 100644 --- a/ee/apps/den-api/README.md +++ b/ee/apps/den-api/README.md @@ -12,6 +12,13 @@ It carries the full migrated Den API route surface in a foldered Hono structure pnpm --filter @openwork-ee/den-api dev:local ``` +## Billing model + +- `POLAR_PRODUCT_ID` / `POLAR_BENEFIT_ID`: base OpenWork Cloud team plan +- `POLAR_WORKER_PRODUCT_ID` / `POLAR_WORKER_BENEFIT_ID`: per-worker add-on product +- The base plan unlocks the shared cloud workspace. +- Workers are counted separately and billed as additional recurring subscriptions. + ## Current routes - `GET /` -> `302 https://openworklabs.com` diff --git a/ee/apps/den-api/src/billing/polar.ts b/ee/apps/den-api/src/billing/polar.ts index d202a6f3c..9639a011c 100644 --- a/ee/apps/den-api/src/billing/polar.ts +++ b/ee/apps/den-api/src/billing/polar.ts @@ -15,12 +15,6 @@ type PolarCustomerSession = { customer_portal_url?: string } -type PolarCustomer = { - id?: string - email?: string - external_id?: string | null -} - type PolarListResource = { items?: T[] } @@ -119,6 +113,10 @@ export type CloudWorkerBillingStatus = { hasActivePlan: boolean checkoutRequired: boolean checkoutUrl: string | null + activeWorkerSubscriptions: number + workerCheckoutUrl: string | null + workerCheckoutRequired: boolean + workerPrice: CloudWorkerBillingPrice | null portalUrl: string | null price: CloudWorkerBillingPrice | null subscription: CloudWorkerBillingSubscription | null @@ -139,6 +137,7 @@ type CloudAccessInput = { userId: string email: string name: string + orgId?: string | null } type BillingStatusOptions = { @@ -163,6 +162,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null } +function getExternalCustomerId(input: CloudAccessInput) { + return input.orgId?.trim() || input.userId +} + async function polarFetch(path: string, init: RequestInit = {}) { const headers = new Headers(init.headers) headers.set("Authorization", `Bearer ${env.polar.accessToken}`) @@ -216,75 +219,21 @@ async function getCustomerStateByExternalId(externalCustomerId: string): Promise return payload } -async function getCustomerStateById(customerId: string): Promise { - const encodedCustomerId = encodeURIComponent(customerId) - const { response, payload, text } = await polarFetchJson(`/v1/customers/${encodedCustomerId}/state`, { - method: "GET", - }) - - if (response.status === 404) { - return null - } - - if (!response.ok) { - throw new Error(`Polar customer state lookup by ID failed (${response.status}): ${text.slice(0, 400)}`) - } - - return payload -} - -async function getCustomerByEmail(email: string): Promise { - const normalizedEmail = email.trim().toLowerCase() - if (!normalizedEmail) { - return null - } - - const encodedEmail = encodeURIComponent(normalizedEmail) - const { response, payload, text } = await polarFetchJson>(`/v1/customers/?email=${encodedEmail}`, { - method: "GET", - }) - - if (!response.ok) { - throw new Error(`Polar customer lookup by email failed (${response.status}): ${text.slice(0, 400)}`) - } - - const customers = payload?.items ?? [] - const exact = customers.find((customer) => customer.email?.trim().toLowerCase() === normalizedEmail) - return exact ?? customers[0] ?? null -} - -async function linkCustomerExternalId(customer: PolarCustomer, externalCustomerId: string): Promise { - if (!customer.id) { - return - } - - if (typeof customer.external_id === "string" && customer.external_id.length > 0) { - return - } - - const encodedCustomerId = encodeURIComponent(customer.id) - await polarFetch(`/v1/customers/${encodedCustomerId}`, { - method: "PATCH", - body: JSON.stringify({ - external_id: externalCustomerId, - }), - }) -} - -function hasRequiredBenefit(state: PolarCustomerState | null) { - if (!state?.granted_benefits || !env.polar.benefitId) { +function hasBenefit(state: PolarCustomerState | null, benefitId: string | undefined) { + if (!state?.granted_benefits || !benefitId) { return false } - return state.granted_benefits.some((grant) => grant.benefit_id === env.polar.benefitId) + return state.granted_benefits.some((grant) => grant.benefit_id === benefitId) } -async function createCheckoutSession(input: CloudAccessInput): Promise { +async function createCheckoutSessionForProduct(input: CloudAccessInput, productId: string): Promise { + const externalCustomerId = getExternalCustomerId(input) const payload = { - products: [env.polar.productId], + products: [productId], success_url: env.polar.successUrl, return_url: env.polar.returnUrl, - external_customer_id: input.userId, + external_customer_id: externalCustomerId, customer_email: input.email, customer_name: input.name, } @@ -325,8 +274,9 @@ async function evaluateCloudWorkerAccess( assertPaywallConfig() - const externalState = await getCustomerStateByExternalId(input.userId) - if (hasRequiredBenefit(externalState)) { + const externalCustomerId = getExternalCustomerId(input) + const externalState = await getCustomerStateByExternalId(externalCustomerId) + if (hasBenefit(externalState, env.polar.benefitId)) { return { featureGateEnabled: true, hasActivePlan: true, @@ -334,24 +284,34 @@ async function evaluateCloudWorkerAccess( } } - const customer = await getCustomerByEmail(input.email) - if (customer?.id) { - const emailState = await getCustomerStateById(customer.id) - if (hasRequiredBenefit(emailState)) { - await linkCustomerExternalId(customer, input.userId).catch(() => undefined) - return { - featureGateEnabled: true, - hasActivePlan: true, - checkoutUrl: null, - } - } - } - + const productId = env.polar.productId return { featureGateEnabled: true, hasActivePlan: false, - checkoutUrl: options.includeCheckoutUrl ? await createCheckoutSession(input) : null, + checkoutUrl: options.includeCheckoutUrl && productId ? await createCheckoutSessionForProduct(input, productId) : null, + } +} + +async function getActiveWorkerSubscriptionCount(input: CloudAccessInput): Promise { + if (!env.polar.workerProductId) { + return 0 + } + + const subscriptions = await listSubscriptionsByExternalCustomer(getExternalCustomerId(input), { + activeOnly: true, + limit: 100, + productId: env.polar.workerProductId, + }) + + return subscriptions.filter((subscription) => isActiveSubscriptionStatus(subscription.status)).length +} + +async function createWorkerCheckoutSession(input: CloudAccessInput): Promise { + if (!env.polar.workerProductId) { + return null } + + return createCheckoutSessionForProduct(input, env.polar.workerProductId) } function normalizeRecurringInterval(value: string | null | undefined): string | null { @@ -419,12 +379,12 @@ async function getSubscriptionById(subscriptionId: string): Promise { const params = new URLSearchParams() params.set("external_customer_id", externalCustomerId) - if (env.polar.productId) { - params.set("product_id", env.polar.productId) + if (options.productId) { + params.set("product_id", options.productId) } params.set("limit", String(options.limit ?? 1)) params.set("sorting", "-started_at") @@ -458,21 +418,26 @@ async function listSubscriptionsByExternalCustomer( } async function getPrimarySubscriptionForCustomer(externalCustomerId: string): Promise { - const active = await listSubscriptionsByExternalCustomer(externalCustomerId, { activeOnly: true, limit: 1 }) + const active = await listSubscriptionsByExternalCustomer(externalCustomerId, { + activeOnly: true, + limit: 1, + productId: env.polar.productId, + }) if (active[0]) { return active[0] } - const recent = await listSubscriptionsByExternalCustomer(externalCustomerId, { activeOnly: false, limit: 1 }) + const recent = await listSubscriptionsByExternalCustomer(externalCustomerId, { + activeOnly: false, + limit: 1, + productId: env.polar.productId, + }) return recent[0] ?? null } async function listRecentOrdersByExternalCustomer(externalCustomerId: string, limit = 6): Promise { const params = new URLSearchParams() params.set("external_customer_id", externalCustomerId) - if (env.polar.productId) { - params.set("product_id", env.polar.productId) - } params.set("limit", String(limit)) params.set("sorting", "-created_at") @@ -649,6 +614,27 @@ export async function requireCloudWorkerAccess(input: CloudAccessInput): Promise } } +export async function requireAdditionalCloudWorkerAccess(input: CloudAccessInput & { ownedWorkerCount: number }): Promise { + if (!env.polar.featureGateEnabled || !env.polar.workerProductId) { + return { allowed: true } + } + + const activeWorkerSubscriptions = await getActiveWorkerSubscriptionCount(input) + if (input.ownedWorkerCount < activeWorkerSubscriptions) { + return { allowed: true } + } + + const checkoutUrl = await createWorkerCheckoutSession(input) + if (!checkoutUrl) { + throw new Error("Polar worker checkout URL unavailable") + } + + return { + allowed: false, + checkoutUrl, + } +} + export async function getCloudWorkerBillingStatus( input: CloudAccessInput, options: BillingStatusOptions = {}, @@ -665,6 +651,10 @@ export async function getCloudWorkerBillingStatus( hasActivePlan: true, checkoutRequired: false, checkoutUrl: null, + activeWorkerSubscriptions: 0, + workerCheckoutUrl: null, + workerCheckoutRequired: false, + workerPrice: null, portalUrl: null, price: null, subscription: null, @@ -676,11 +666,19 @@ export async function getCloudWorkerBillingStatus( await sendSubscribedToDenEvent(input) } + const [activeWorkerSubscriptions, workerCheckoutUrl, workerPrice] = await Promise.all([ + getActiveWorkerSubscriptionCount(input).catch(() => 0), + evaluation.hasActivePlan && options.includeCheckoutUrl + ? createWorkerCheckoutSession(input).catch(() => null) + : Promise.resolve(null), + env.polar.workerProductId ? getProductBillingPrice(env.polar.workerProductId).catch(() => null) : Promise.resolve(null), + ]) + const [subscriptionResult, priceResult, portalResult, invoicesResult] = await Promise.all([ - getPrimarySubscriptionForCustomer(input.userId).catch(() => null), + getPrimarySubscriptionForCustomer(getExternalCustomerId(input)).catch(() => null), env.polar.productId ? getProductBillingPrice(env.polar.productId).catch(() => null) : Promise.resolve(null), - includePortalUrl ? createCustomerPortalUrl(input.userId).catch(() => null) : Promise.resolve(null), - includeInvoices ? listBillingInvoices(input.userId).catch(() => []) : Promise.resolve([]), + includePortalUrl ? createCustomerPortalUrl(getExternalCustomerId(input)).catch(() => null) : Promise.resolve(null), + includeInvoices ? listBillingInvoices(getExternalCustomerId(input)).catch(() => []) : Promise.resolve([]), ]) const subscription = toBillingSubscription(subscriptionResult) @@ -693,6 +691,10 @@ export async function getCloudWorkerBillingStatus( hasActivePlan: evaluation.hasActivePlan, checkoutRequired: evaluation.featureGateEnabled && !evaluation.hasActivePlan, checkoutUrl: evaluation.checkoutUrl, + activeWorkerSubscriptions, + workerCheckoutUrl, + workerCheckoutRequired: evaluation.hasActivePlan && activeWorkerSubscriptions <= 0, + workerPrice, portalUrl, price: productPrice ?? toBillingPriceFromSubscription(subscription), subscription, @@ -732,24 +734,15 @@ export async function getCloudWorkerAdminBillingStatus( let paidByBenefit = false if (env.polar.benefitId) { - const externalState = await getCustomerStateByExternalId(input.userId) - if (hasRequiredBenefit(externalState)) { + const externalCustomerId = getExternalCustomerId(input) + const externalState = await getCustomerStateByExternalId(externalCustomerId) + if (hasBenefit(externalState, env.polar.benefitId)) { paidByBenefit = true note = "Benefit granted via external customer id." - } else { - const customer = await getCustomerByEmail(input.email) - if (customer?.id) { - const emailState = await getCustomerStateById(customer.id) - if (hasRequiredBenefit(emailState)) { - paidByBenefit = true - note = "Benefit granted via matching customer email." - await linkCustomerExternalId(customer, input.userId).catch(() => undefined) - } - } } } - const subscription = env.polar.productId ? await getPrimarySubscriptionForCustomer(input.userId) : null + const subscription = env.polar.productId ? await getPrimarySubscriptionForCustomer(getExternalCustomerId(input)) : null const normalizedSubscription = toBillingSubscription(subscription) const paidBySubscription = isActiveSubscriptionStatus(normalizedSubscription?.status) @@ -789,9 +782,10 @@ export async function setCloudWorkerSubscriptionCancellation( assertPaywallConfig() - const activeSubscriptions = await listSubscriptionsByExternalCustomer(input.userId, { + const activeSubscriptions = await listSubscriptionsByExternalCustomer(getExternalCustomerId(input), { activeOnly: true, limit: 1, + productId: env.polar.productId, }) const active = activeSubscriptions[0] if (!active?.id) { diff --git a/ee/apps/den-api/src/env.ts b/ee/apps/den-api/src/env.ts index ce84d9b88..37177a24d 100644 --- a/ee/apps/den-api/src/env.ts +++ b/ee/apps/den-api/src/env.ts @@ -50,6 +50,8 @@ const EnvSchema = z.object({ POLAR_ACCESS_TOKEN: z.string().optional(), POLAR_PRODUCT_ID: z.string().optional(), POLAR_BENEFIT_ID: z.string().optional(), + POLAR_WORKER_PRODUCT_ID: z.string().optional(), + POLAR_WORKER_BENEFIT_ID: z.string().optional(), POLAR_SUCCESS_URL: z.string().optional(), POLAR_RETURN_URL: z.string().optional(), DAYTONA_API_URL: z.string().optional(), @@ -208,6 +210,8 @@ export const env = { accessToken: parsed.POLAR_ACCESS_TOKEN, productId: parsed.POLAR_PRODUCT_ID, benefitId: parsed.POLAR_BENEFIT_ID, + workerProductId: parsed.POLAR_WORKER_PRODUCT_ID, + workerBenefitId: parsed.POLAR_WORKER_BENEFIT_ID, successUrl: parsed.POLAR_SUCCESS_URL, returnUrl: parsed.POLAR_RETURN_URL, }, diff --git a/ee/apps/den-api/src/routes/org/core.ts b/ee/apps/den-api/src/routes/org/core.ts index 469f9c77c..f00083540 100644 --- a/ee/apps/den-api/src/routes/org/core.ts +++ b/ee/apps/den-api/src/routes/org/core.ts @@ -101,9 +101,19 @@ export function registerOrgCoreRoutes { + async (c) => { + const session = c.get("session") + const organizationContext = c.get("organizationContext") + + if (session?.id) { + await setSessionActiveOrganization( + normalizeDenTypeId("session", session.id), + organizationContext.organization.id, + ) + } + return c.json({ - ...c.get("organizationContext"), + ...organizationContext, currentMemberTeams: c.get("memberTeams") ?? [], }) }, diff --git a/ee/apps/den-api/src/routes/workers/billing.ts b/ee/apps/den-api/src/routes/workers/billing.ts index c39c2c22d..04478e55e 100644 --- a/ee/apps/den-api/src/routes/workers/billing.ts +++ b/ee/apps/den-api/src/routes/workers/billing.ts @@ -1,22 +1,27 @@ import type { Hono } from "hono" import { env } from "../../env.js" -import { jsonValidator, queryValidator, requireUserMiddleware } from "../../middleware/index.js" +import { jsonValidator, queryValidator, requireUserMiddleware, resolveUserOrganizationsMiddleware } from "../../middleware/index.js" import { getRequiredUserEmail } from "../../user.js" import type { WorkerRouteVariables } from "./shared.js" import { billingQuerySchema, billingSubscriptionSchema, getWorkerBilling, setWorkerBillingSubscription, queryIncludesFlag } from "./shared.js" export function registerWorkerBillingRoutes(app: Hono) { - app.get("/v1/workers/billing", requireUserMiddleware, queryValidator(billingQuerySchema), async (c) => { + app.get("/v1/workers/billing", requireUserMiddleware, resolveUserOrganizationsMiddleware, queryValidator(billingQuerySchema), async (c) => { const user = c.get("user") + const orgId = c.get("activeOrganizationId") const query = c.req.valid("query") const email = getRequiredUserEmail(user) if (!email) { return c.json({ error: "user_email_required" }, 400) } + if (!orgId) { + return c.json({ error: "organization_required" }, 409) + } const billing = await getWorkerBilling({ userId: user.id, + orgId, email, name: user.name ?? user.email ?? "OpenWork User", includeCheckoutUrl: queryIncludesFlag(query.includeCheckout), @@ -29,21 +34,28 @@ export function registerWorkerBillingRoutes { + app.post("/v1/workers/billing/subscription", requireUserMiddleware, resolveUserOrganizationsMiddleware, jsonValidator(billingSubscriptionSchema), async (c) => { const user = c.get("user") + const orgId = c.get("activeOrganizationId") const input = c.req.valid("json") const email = getRequiredUserEmail(user) if (!email) { return c.json({ error: "user_email_required" }, 400) } + if (!orgId) { + return c.json({ error: "organization_required" }, 409) + } const billingInput = { userId: user.id, + orgId, email, name: user.name ?? user.email ?? "OpenWork User", } @@ -65,6 +77,8 @@ export function registerWorkerBillingRoutes 0) { + if (input.destination === "cloud" && !env.devMode) { const email = getRequiredUserEmail(user) if (!email) { return c.json({ error: "user_email_required" }, 400) } - const access = await requireCloudAccessOrPayment({ + const baseAccess = await requireCloudAccessOrPayment({ userId: user.id, + orgId, email, name: user.name ?? user.email ?? "OpenWork User", }) - if (!access.allowed) { + if (!baseAccess.allowed) { return c.json({ error: "payment_required", - message: "Additional cloud workers require an active Den Cloud plan.", + message: "OpenWork Cloud billing is required before launching workers.", polar: { - checkoutUrl: access.checkoutUrl, + checkoutUrl: baseAccess.checkoutUrl, productId: env.polar.productId, benefitId: env.polar.benefitId, }, }, 402) } + + const ownedWorkerCount = await countOrgCloudWorkers(orgId) + const workerAccess = await requireAdditionalCloudCapacityOrPayment({ + userId: user.id, + orgId, + email, + name: user.name ?? user.email ?? "OpenWork User", + ownedWorkerCount, + }) + if (!workerAccess.allowed) { + return c.json({ + error: "payment_required", + message: "No workers are included by default. Purchase a worker add-on to launch another hosted worker.", + polar: { + checkoutUrl: workerAccess.checkoutUrl, + productId: env.polar.workerProductId, + benefitId: env.polar.workerBenefitId, + }, + }, 402) + } } const workerId = createDenTypeId("worker") diff --git a/ee/apps/den-api/src/routes/workers/shared.ts b/ee/apps/den-api/src/routes/workers/shared.ts index 996104c51..2870b448f 100644 --- a/ee/apps/den-api/src/routes/workers/shared.ts +++ b/ee/apps/den-api/src/routes/workers/shared.ts @@ -12,7 +12,7 @@ import { } from "@openwork-ee/den-db/schema" import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid" import { z } from "zod" -import { getCloudWorkerBillingStatus, requireCloudWorkerAccess, setCloudWorkerSubscriptionCancellation } from "../../billing/polar.js" +import { getCloudWorkerBillingStatus, requireAdditionalCloudWorkerAccess, requireCloudWorkerAccess, setCloudWorkerSubscriptionCancellation } from "../../billing/polar.js" import { db } from "../../db.js" import { env } from "../../env.js" import type { UserOrganizationsContext } from "../../middleware/index.js" @@ -281,12 +281,11 @@ export async function fetchWorkerRuntimeJson(input: { return { ok: false as const, status: lastStatus, payload: lastPayload } } -export async function countUserCloudWorkers(userId: UserId) { +export async function countOrgCloudWorkers(orgId: OrgId) { const rows = await db .select({ id: WorkerTable.id }) .from(WorkerTable) - .where(and(eq(WorkerTable.created_by_user_id, userId), eq(WorkerTable.destination, "cloud"))) - .limit(2) + .where(and(eq(WorkerTable.org_id, orgId), eq(WorkerTable.destination, "cloud"))) return rows.length } @@ -379,14 +378,26 @@ export async function continueCloudProvisioning(input: { export async function requireCloudAccessOrPayment(input: { userId: UserId + orgId: OrgId email: string name: string }) { return requireCloudWorkerAccess(input) } +export async function requireAdditionalCloudCapacityOrPayment(input: { + userId: UserId + orgId: OrgId + email: string + name: string + ownedWorkerCount: number +}) { + return requireAdditionalCloudWorkerAccess(input) +} + export async function getWorkerBilling(input: { userId: UserId + orgId: OrgId email: string name: string includeCheckoutUrl: boolean @@ -396,6 +407,7 @@ export async function getWorkerBilling(input: { return getCloudWorkerBillingStatus( { userId: input.userId, + orgId: input.orgId, email: input.email, name: input.name, }, @@ -409,6 +421,7 @@ export async function getWorkerBilling(input: { export async function setWorkerBillingSubscription(input: { userId: UserId + orgId: OrgId email: string name: string cancelAtPeriodEnd: boolean @@ -416,6 +429,7 @@ export async function setWorkerBillingSubscription(input: { return setCloudWorkerSubscriptionCancellation( { userId: input.userId, + orgId: input.orgId, email: input.email, name: input.name, }, diff --git a/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx b/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx index 2fc483bee..8a825c581 100644 --- a/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx @@ -12,7 +12,7 @@ const MOCK_BILLING = process.env.NEXT_PUBLIC_DEN_MOCK_BILLING === "1"; const MOCK_CHECKOUT_URL = (process.env.NEXT_PUBLIC_DEN_MOCK_CHECKOUT_URL ?? "").trim() || null; function formatSubscriptionStatus(value: string | null | undefined) { - if (!value) return "Purchase required"; + if (!value) return "Plan required"; return value .split(/[_\s]+/) .filter(Boolean) @@ -35,7 +35,6 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: const router = useRouter(); const pathname = usePathname(); const handledReturnRef = useRef(false); - const redirectingRef = useRef(false); const [resuming, setResuming] = useState(false); const [redirectMessage, setRedirectMessage] = useState(null); const { @@ -46,10 +45,9 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: billingCheckoutBusy, billingError, effectiveCheckoutUrl, - onboardingPending, + effectiveWorkerCheckoutUrl, refreshBilling, refreshCheckoutReturn, - resolveUserLandingRoute, } = useDenFlow(); const mockMode = MOCK_BILLING && process.env.NODE_ENV !== "production"; @@ -59,9 +57,13 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: featureGateEnabled: true, hasActivePlan: false, checkoutRequired: true, - checkoutUrl: MOCK_CHECKOUT_URL, - portalUrl: null, - price: { amount: 5000, currency: "usd", recurringInterval: "month", recurringIntervalCount: 1 }, + checkoutUrl: MOCK_CHECKOUT_URL, + activeWorkerSubscriptions: 0, + workerCheckoutUrl: MOCK_CHECKOUT_URL, + workerCheckoutRequired: true, + workerPrice: { amount: 5000, currency: "usd", recurringInterval: "month", recurringIntervalCount: 1 }, + portalUrl: null, + price: { amount: 5000, currency: "usd", recurringInterval: "month", recurringIntervalCount: 1 }, subscription: null, invoices: [], productId: null, @@ -110,7 +112,11 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: return; } - if (!billingSummary?.hasActivePlan && !effectiveCheckoutUrl && !billingBusy && !billingCheckoutBusy) { + const needsCheckoutUrl = billingSummary?.hasActivePlan + ? !effectiveWorkerCheckoutUrl + : !effectiveCheckoutUrl; + + if (needsCheckoutUrl && !billingBusy && !billingCheckoutBusy) { void refreshBilling({ includeCheckout: true, quiet: true }); } }, [ @@ -118,33 +124,13 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: billingCheckoutBusy, billingSummary?.hasActivePlan, effectiveCheckoutUrl, + effectiveWorkerCheckoutUrl, refreshBilling, resuming, sessionHydrated, user, ]); - useEffect(() => { - if (!sessionHydrated || !user || resuming || onboardingPending || mockMode || redirectingRef.current) { - return; - } - - redirectingRef.current = true; - void resolveUserLandingRoute() - .then((target) => { - if (target && !isSamePathname(pathname, target)) { - setRedirectMessage("Redirecting to your workspace..."); - router.replace(target); - return; - } - - setRedirectMessage(null); - }) - .finally(() => { - redirectingRef.current = false; - }); - }, [mockMode, onboardingPending, pathname, resolveUserLandingRoute, resuming, router, sessionHydrated, user]); - if (!sessionHydrated || (!user && !mockMode)) { return (

OpenWork Cloud

-

Purchase worker access before launch.

+

+ {isWorkerCheckout ? "Add worker capacity to your org." : "Activate your team workspace."} +

- Workers are disabled by default. Add one hosted OpenWork worker for $50/month, then launch it from your dashboard. + {isWorkerCheckout + ? "Your base team plan is active. Purchase worker runtime separately whenever your org needs more hosted capacity." + : "OpenWork Cloud starts at $50/month for up to 5 seats. Worker runtime is purchased separately after your team workspace is active."}

{checkoutHref ? ( - Purchase worker — $50/month + {isWorkerCheckout ? "Purchase worker add-on — $50/month" : "Start team plan — $50/month"} ) : ( )} @@ -201,9 +199,9 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:
- $50/month per worker + {isWorkerCheckout ? "$50/month per worker" : "$50/month for 5 seats"} - {planAmountLabel} billed monthly + {isWorkerCheckout ? workerAmountLabel : planAmountLabel} billed monthly {user?.email ?? "Signed in"}
@@ -225,7 +223,7 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: OpenWork Cloud

Share your setup across your team.

- Manage your team's setup, invite teammates, and keep everything in sync. + Manage your team's setup, invite up to 5 seats, and add hosted workers only when you need runtime.

@@ -279,7 +277,9 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:

Billing status

{subscriptionStatus}

- {billingSummary.hasActivePlan ? "Your worker billing is active." : "Purchase a worker to enable hosted launches."} + {billingSummary.hasActivePlan + ? "Your base team plan is active. Add worker capacity whenever you need more hosted runtime." + : "Purchase the base team plan to unlock OpenWork Cloud."}

@@ -303,7 +303,12 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:
{checkoutHref && !billingSummary.hasActivePlan ? ( - Purchase worker + Purchase base plan + + ) : null} + {checkoutHref && billingSummary.hasActivePlan ? ( + + Purchase worker add-on ) : null} {billingSummary.portalUrl ? ( diff --git a/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx b/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx index 1ecf7e9e3..b825d5f23 100644 --- a/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx @@ -190,6 +190,7 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean isSelectedWorkerFailed, ownedWorkerCount, billingSummary, + effectiveWorkerCheckoutUrl, refreshWorkers, checkWorkerStatus, generateWorkerToken, @@ -234,6 +235,8 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean const webDisabled = !openworkAppConnectUrl || !isReady; const desktopDisabled = !openworkDeepLink || !isReady; const showConnectionHint = !openworkDeepLink || !hasWorkspaceScopedUrl; + const workerAllowance = billingSummary?.activeWorkerSubscriptions ?? 0; + const workerCapacityRemaining = Math.max(workerAllowance - ownedWorkerCount, 0); const mainContent = (
@@ -557,16 +560,37 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean

{billingSummary?.featureGateEnabled ? billingSummary.hasActivePlan - ? "Your account has active worker billing." - : "Workers stay disabled until you purchase one for $50/month." + ? workerAllowance > 0 + ? `${workerAllowance} worker subscription${workerAllowance === 1 ? "" : "s"} active for this org. ${workerCapacityRemaining} remaining.` + : "Your team plan is active, but no workers are included for this org by default." + : "Activate the base team plan before purchasing workers." : "Billing gates are disabled in this environment."}

- - Open billing - + {billingSummary?.hasActivePlan ? ( + effectiveWorkerCheckoutUrl ? ( + + Purchase worker + + ) : ( + + Open billing + + ) + ) : ( + + Open billing + + )}
@@ -575,13 +599,23 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean

No workers yet

-

Purchase your first worker to unlock connection details and runtime controls.

- - Purchase worker billing - +

Your base plan includes 0 workers. Purchase one for $50/month to unlock hosted runtime controls.

+ {effectiveWorkerCheckoutUrl ? ( + + Purchase first worker + + ) : ( + + Open billing + + )}
)} @@ -666,8 +700,8 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean Signed in as {user.email}
{billingSummary?.featureGateEnabled && !billingSummary.hasActivePlan - ? "Purchase required before the next launch." - : `${ownedWorkerCount} worker${ownedWorkerCount === 1 ? "" : "s"} in your account.`} + ? "Base plan required before the next launch." + : `${ownedWorkerCount} worker${ownedWorkerCount === 1 ? "" : "s"} in this org · ${workerAllowance} purchased.`}
diff --git a/ee/apps/den-web/app/(den)/_lib/den-flow.ts b/ee/apps/den-web/app/(den)/_lib/den-flow.ts index 8a98f2406..fba692def 100644 --- a/ee/apps/den-web/app/(den)/_lib/den-flow.ts +++ b/ee/apps/den-web/app/(den)/_lib/den-flow.ts @@ -43,6 +43,10 @@ export type BillingSummary = { hasActivePlan: boolean; checkoutRequired: boolean; checkoutUrl: string | null; + activeWorkerSubscriptions: number; + workerCheckoutUrl: string | null; + workerCheckoutRequired: boolean; + workerPrice: BillingPrice | null; portalUrl: string | null; price: BillingPrice | null; subscription: BillingSubscription | null; @@ -593,6 +597,10 @@ export function getBillingSummary(payload: unknown): BillingSummary | null { hasActivePlan, checkoutRequired, checkoutUrl: typeof billing.checkoutUrl === "string" ? billing.checkoutUrl : null, + activeWorkerSubscriptions: typeof billing.activeWorkerSubscriptions === "number" ? billing.activeWorkerSubscriptions : 0, + workerCheckoutUrl: typeof billing.workerCheckoutUrl === "string" ? billing.workerCheckoutUrl : null, + workerCheckoutRequired: billing.workerCheckoutRequired === true, + workerPrice: getBillingPrice(billing.workerPrice), portalUrl: typeof billing.portalUrl === "string" ? billing.portalUrl : null, price: getBillingPrice(billing.price), subscription: getBillingSubscription(billing.subscription), diff --git a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx index 9ca62a29e..171df3475 100644 --- a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx +++ b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx @@ -96,6 +96,7 @@ type DenFlowContextValue = { billingSubscriptionBusy: boolean; billingError: string | null; effectiveCheckoutUrl: string | null; + effectiveWorkerCheckoutUrl: string | null; refreshBilling: (options?: { includeCheckout?: boolean; quiet?: boolean }) => Promise; handleSubscriptionCancellation: (cancelAtPeriodEnd: boolean) => Promise; refreshCheckoutReturn: (sessionTokenPresent: boolean) => Promise; @@ -266,17 +267,18 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { activeWorker?.workerName ?? null, { autoConnect: true } ); - const ownedWorkerCount = workers.filter((item) => item.isMine).length; + const ownedWorkerCount = workers.length; + const workerAllowance = billingSummary?.activeWorkerSubscriptions ?? 0; const additionalWorkerNeedsPlan = Boolean( user && - ownedWorkerCount > 0 && billingSummary?.featureGateEnabled && - !billingSummary.hasActivePlan + ownedWorkerCount >= workerAllowance ); const selectedWorkerStatus = activeWorker?.status ?? selectedWorker?.status ?? "unknown"; const selectedStatusMeta = getWorkerStatusMeta(selectedWorkerStatus); const isSelectedWorkerFailed = selectedWorkerStatus.trim().toLowerCase() === "failed"; const effectiveCheckoutUrl = checkoutUrl ?? billingSummary?.checkoutUrl ?? null; + const effectiveWorkerCheckoutUrl = checkoutUrl ?? billingSummary?.workerCheckoutUrl ?? null; const onboardingPending = Boolean(onboardingIntent?.shouldLaunch && !onboardingIntent.completed); const onboardingDecisionBusy = onboardingPending && !billingLoadedOnce && (billingBusy || billingCheckoutBusy || !sessionHydrated); @@ -1324,13 +1326,12 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { return current; } - return { - ...current, - hasActivePlan: false, - checkoutRequired: true, - checkoutUrl: url ?? current.checkoutUrl - }; - }); + return { + ...current, + workerCheckoutRequired: true, + workerCheckoutUrl: url ?? current.workerCheckoutUrl + }; + }); setLaunchStatus("Payment is required. Complete checkout and return to continue launch."); setLaunchError(url ? null : "Checkout URL missing from paywall response."); appendEvent("warning", "Paywall required", url ? "Checkout URL generated" : "Checkout URL missing"); @@ -2015,7 +2016,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { return; } - if (ownedWorkerCount > 0) { + if (ownedWorkerCount >= workerAllowance) { markOnboardingComplete(); return; } @@ -2031,7 +2032,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { onboardingAutoLaunchKeyRef.current = autoLaunchKey; markOnboardingComplete(); - }, [billingSummary?.featureGateEnabled, billingSummary?.hasActivePlan, launchBusy, onboardingIntent?.workerName, onboardingPending, ownedWorkerCount, user?.id]); + }, [billingSummary?.featureGateEnabled, billingSummary?.hasActivePlan, billingSummary?.activeWorkerSubscriptions, launchBusy, onboardingIntent?.workerName, onboardingPending, ownedWorkerCount, workerAllowance, user?.id]); useEffect(() => { if (!user) { @@ -2078,6 +2079,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { billingSubscriptionBusy, billingError, effectiveCheckoutUrl, + effectiveWorkerCheckoutUrl, refreshBilling, handleSubscriptionCancellation, refreshCheckoutReturn, diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx index 95cd8d9a4..6860db9d1 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/billing-dashboard-screen.tsx @@ -49,7 +49,19 @@ export function BillingDashboardScreen() { } const billingPrice = billingSummary?.price ?? null; + const workerPrice = billingSummary?.workerPrice ?? null; const subscription = billingSummary?.subscription ?? null; + const basePlanAmount = subscription?.amount ?? billingPrice?.amount ?? null; + const workerSubscriptionCount = billingSummary?.activeWorkerSubscriptions ?? 0; + const workerRecurringTotal = + workerPrice?.amount !== null && workerPrice?.amount !== undefined + ? workerPrice.amount * workerSubscriptionCount + : null; + const estimatedMonthlyTotal = + basePlanAmount !== null || workerRecurringTotal !== null + ? (basePlanAmount ?? 0) + (workerRecurringTotal ?? 0) + : null; + const totalCurrency = subscription?.currency ?? billingPrice?.currency ?? workerPrice?.currency ?? null; const planAmountLabel = billingPrice ? `${formatMoneyMinor(billingPrice.amount, billingPrice.currency)} · ${formatRecurringInterval( billingPrice.recurringInterval, @@ -60,14 +72,13 @@ export function BillingDashboardScreen() { ? formatSubscriptionStatus(subscription.status) : billingSummary?.hasActivePlan ? "Active" - : "Purchase required"; + : "Plan required"; const nextBillingDate = subscription?.currentPeriodEnd ? formatIsoDate(subscription.currentPeriodEnd) : "Not available"; - const nextPaymentAmount = subscription?.amount - ? formatMoneyMinor(subscription.amount, subscription.currency) - : billingPrice - ? formatMoneyMinor(billingPrice.amount, billingPrice.currency) + const nextPaymentAmount = + estimatedMonthlyTotal !== null + ? formatMoneyMinor(estimatedMonthlyTotal, totalCurrency) : "Not available"; return ( @@ -91,8 +102,8 @@ export function BillingDashboardScreen() {

{billingSummary?.hasActivePlan - ? `This workspace's plan is currently ${statusLabel.toLowerCase()} and renews on ${nextBillingDate}.` - : "Workers are $50/month each. Purchase a worker to enable hosted launches for your team."} + ? `This organization's plan is currently ${statusLabel.toLowerCase()} and renews on ${nextBillingDate}.` + : "Start your OpenWork Cloud base plan when your team is ready to share templates and cloud workflows."}

@@ -103,17 +114,34 @@ export function BillingDashboardScreen() {
-

Plan cost

+

Base plan cost

{planAmountLabel}
-

Next billing date

+

Included seats

+
5 seats
+
+ +
+

Purchased workers

+
{workerSubscriptionCount}
+
+ +
+

Worker add-on price

+
+ {workerPrice ? `${formatMoneyMinor(workerPrice.amount, workerPrice.currency)} · ${formatRecurringInterval(workerPrice.recurringInterval, workerPrice.recurringIntervalCount)}` : "$50.00 · month"} +
+
+ +
+

Base plan renews

{nextBillingDate}
-

Next payment amount

+

Estimated monthly total

{nextPaymentAmount}
@@ -144,7 +172,17 @@ export function BillingDashboardScreen() { rel="noreferrer" className="rounded-full bg-gray-900 px-5 py-2.5 text-[14px] font-medium text-white transition-colors hover:bg-gray-800" > - Purchase worker + Purchase base plan + + ) : null} + + {billingSummary?.hasActivePlan && billingSummary?.workerCheckoutUrl ? ( + + Purchase worker add-on ) : null} @@ -184,27 +222,6 @@ export function BillingDashboardScreen() { -
-

Pricing

-
-
-

Solo

-

$0

-

Free forever · open source

-
-
-

Cloud worker

-

$50/month

-

Per worker · 5 seats included

-
-
-

Enterprise

-

Custom

-

Windows included · talk to us

-
-
-
-

Invoices

diff --git a/ee/apps/landing/app/pricing/page.tsx b/ee/apps/landing/app/pricing/page.tsx index 55bd05a25..f6496f04c 100644 --- a/ee/apps/landing/app/pricing/page.tsx +++ b/ee/apps/landing/app/pricing/page.tsx @@ -7,7 +7,7 @@ import { getGithubData } from "../../lib/github"; export const metadata = { title: "OpenWork — Pricing", description: - "Free desktop app, cloud workers from $50/month, and enterprise licensing." + "Free solo desktop usage, annual Windows support, cloud teams starting at 5 seats, and worker add-ons billed separately." }; export default async function PricingPage() { @@ -30,18 +30,17 @@ export default async function PricingPage() { />
-
+

Pricing

+

+ Start solo for free. Purchase Windows support when you need it. OpenWork Cloud starts at 5 seats, then add hosted workers separately when you want runtime. Talk to us for enterprise licensing. +

- +
diff --git a/ee/apps/landing/components/den-hero.tsx b/ee/apps/landing/components/den-hero.tsx index 874fb1027..60d16f75a 100644 --- a/ee/apps/landing/components/den-hero.tsx +++ b/ee/apps/landing/components/den-hero.tsx @@ -14,7 +14,9 @@ export function DenHero(props: DenHeroProps) { Agents that never sleep

- Cloud gives you a personal cloud workspace for long-running tasks, background automation, and the same agent workflows you already use locally in OpenWork, without keeping your own machine awake. + OpenWork Cloud gives your team a shared workspace for long-running tasks, + background automation, and the same agent workflows you already use locally, + with hosted workers added only when you need runtime.

@@ -27,8 +29,8 @@ export function DenHero(props: DenHeroProps) { Get started
- $50/mo per worker - Free for a limited time + $50/mo for 5 seats + $50/mo per worker add-on
diff --git a/ee/apps/landing/components/landing-home.tsx b/ee/apps/landing/components/landing-home.tsx index 48d3eb48d..6e337d7b6 100644 --- a/ee/apps/landing/components/landing-home.tsx +++ b/ee/apps/landing/components/landing-home.tsx @@ -111,9 +111,9 @@ export function LandingHome(props: Props) {
Solo free forever - Workers $50/month + Windows support $99/year - Enterprise talk to us + Cloud $50/month for 5 seats
@@ -269,8 +269,8 @@ export function LandingHome(props: Props) {

Hosted sandboxed workers

- Workers are disabled by default. Purchase one for $50/month when - you need hosted runtime. + Cloud starts at $50/month for 5 seats. Workers are disabled by + default and added separately for $50/month each.

Learn more diff --git a/ee/apps/landing/components/pricing-grid.tsx b/ee/apps/landing/components/pricing-grid.tsx index 459f1be2c..5adc34895 100644 --- a/ee/apps/landing/components/pricing-grid.tsx +++ b/ee/apps/landing/components/pricing-grid.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowUpRight, Cloud, Download, Shield, CornerRightDown } from "lucide-react"; +import { ArrowUpRight, Cloud, Download, Monitor, Shield } from "lucide-react"; import { ResponsiveGrain } from "./responsive-grain"; type PricingGridProps = { @@ -27,11 +27,9 @@ type PricingCard = { function PricingCardView({ card }: { card: PricingCard }) { return ( -
- {/* ── Header card ── */} -
- {/* Shader layer — hidden by default, revealed on hover */} -
+
+
+
-
+
-
+

{card.title}

{card.isCustomPricing ? ( -
{card.price}
+
{card.price}
) : (
- {card.price} - + {card.price} + {card.priceSub}
@@ -67,7 +65,7 @@ function PricingCardView({ card }: { card: PricingCard }) { {card.ctaLabel} @@ -75,7 +73,6 @@ function PricingCardView({ card }: { card: PricingCard }) {
- {/* ── Features list ── */}
{card.features.map((feature, idx) => { @@ -83,9 +80,9 @@ function PricingCardView({ card }: { card: PricingCard }) { return (
- + {feature.text}
); @@ -93,7 +90,6 @@ function PricingCardView({ card }: { card: PricingCard }) {
- {/* ── Footer ── */}
{card.footer}
@@ -121,19 +117,37 @@ export function PricingGrid(props: PricingGridProps) { gradientShape: "wave", }, { - id: "cloud-workers", - title: "Cloud workers", + id: "windows-support", + title: "Windows support", + price: "$99", + priceSub: "per year · 1 seat", + ctaLabel: "Purchase Windows support", + href: props.windowsCheckoutUrl, + external: /^https?:\/\//.test(props.windowsCheckoutUrl), + features: [ + { text: "1 Windows seat", icon: Monitor }, + { text: "Binary access", icon: Monitor }, + { text: "1 year of updates", icon: Monitor }, + ], + footer: "Manual fulfillment in phase one", + gradientColors: ["#7C3AED", "#E11D48", "#9333EA", "#1F2937"], + gradientBack: "#111827", + gradientShape: "corners", + }, + { + id: "cloud-teams", + title: "Cloud teams", price: "$50", - priceSub: "per month · per worker", - ctaLabel: "Purchase worker", + priceSub: "per month · 5 seats", + ctaLabel: "Start cloud plan", href: "https://app.openworklabs.com/checkout", external: true, features: [ { text: "5 seats included", icon: Cloud }, - { text: "Hosted OpenWork worker", icon: Cloud }, + { text: "0 workers included by default", icon: Cloud }, { text: "$50 per additional worker", icon: Cloud }, ], - footer: "Workers disabled by default", + footer: "Base plan first, then add worker capacity as needed", gradientColors: ["#2563EB", "#0284C7", "#0EA5E9", "#0F172A"], gradientBack: "#0C1220", gradientShape: "ripple", @@ -163,22 +177,30 @@ export function PricingGrid(props: PricingGridProps) {
{props.showHeader !== false ? (
-

- Pricing -

+
+
+ Pricing +
+

+ Gray by default. Clear when you hover. +

+
+

+ Solo stays free forever. Windows is annual. Cloud starts at 5 seats, and workers are added separately. Enterprise starts with a conversation. +

) : null} -
+
{cards.map((card) => ( -
+
))}

- Prices exclude taxes. + Prices exclude taxes. Windows delivery is manual in phase one.

); From baead522dd1f0ceb55cd65ab4b9ed2eabd0f86fd Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Wed, 1 Apr 2026 17:28:15 -0700 Subject: [PATCH 06/10] fix(den): fail closed when worker billing is unset Require worker Polar config before selling or launching paid worker capacity so missing env vars block launches instead of silently allowing unpaid workers. --- ee/apps/den-api/src/billing/polar.ts | 61 +++++++++++---- ee/apps/den-api/src/routes/workers/billing.ts | 55 +++++++++----- ee/apps/den-api/src/routes/workers/core.ts | 75 ++++++++++--------- 3 files changed, 124 insertions(+), 67 deletions(-) diff --git a/ee/apps/den-api/src/billing/polar.ts b/ee/apps/den-api/src/billing/polar.ts index 9639a011c..e0f7b054a 100644 --- a/ee/apps/den-api/src/billing/polar.ts +++ b/ee/apps/den-api/src/billing/polar.ts @@ -202,6 +202,25 @@ function assertPaywallConfig() { } } +function assertWorkerPaywallConfig() { + if (!env.polar.featureGateEnabled) { + return + } + + if (!env.polar.accessToken) { + throw new Error("POLAR_ACCESS_TOKEN is required when POLAR_FEATURE_GATE_ENABLED=true") + } + if (!env.polar.workerProductId) { + throw new Error("POLAR_WORKER_PRODUCT_ID is required when POLAR_FEATURE_GATE_ENABLED=true") + } + if (!env.polar.successUrl) { + throw new Error("POLAR_SUCCESS_URL is required when POLAR_FEATURE_GATE_ENABLED=true") + } + if (!env.polar.returnUrl) { + throw new Error("POLAR_RETURN_URL is required when POLAR_FEATURE_GATE_ENABLED=true") + } +} + async function getCustomerStateByExternalId(externalCustomerId: string): Promise { const encodedExternalId = encodeURIComponent(externalCustomerId) const { response, payload, text } = await polarFetchJson(`/v1/customers/external/${encodedExternalId}/state`, { @@ -293,9 +312,7 @@ async function evaluateCloudWorkerAccess( } async function getActiveWorkerSubscriptionCount(input: CloudAccessInput): Promise { - if (!env.polar.workerProductId) { - return 0 - } + assertWorkerPaywallConfig() const subscriptions = await listSubscriptionsByExternalCustomer(getExternalCustomerId(input), { activeOnly: true, @@ -307,11 +324,13 @@ async function getActiveWorkerSubscriptionCount(input: CloudAccessInput): Promis } async function createWorkerCheckoutSession(input: CloudAccessInput): Promise { - if (!env.polar.workerProductId) { - return null + assertWorkerPaywallConfig() + const workerProductId = env.polar.workerProductId + if (!workerProductId) { + throw new Error("POLAR_WORKER_PRODUCT_ID is required when POLAR_FEATURE_GATE_ENABLED=true") } - return createCheckoutSessionForProduct(input, env.polar.workerProductId) + return createCheckoutSessionForProduct(input, workerProductId) } function normalizeRecurringInterval(value: string | null | undefined): string | null { @@ -615,10 +634,12 @@ export async function requireCloudWorkerAccess(input: CloudAccessInput): Promise } export async function requireAdditionalCloudWorkerAccess(input: CloudAccessInput & { ownedWorkerCount: number }): Promise { - if (!env.polar.featureGateEnabled || !env.polar.workerProductId) { + if (!env.polar.featureGateEnabled) { return { allowed: true } } + assertWorkerPaywallConfig() + const activeWorkerSubscriptions = await getActiveWorkerSubscriptionCount(input) if (input.ownedWorkerCount < activeWorkerSubscriptions) { return { allowed: true } @@ -666,13 +687,25 @@ export async function getCloudWorkerBillingStatus( await sendSubscribedToDenEvent(input) } - const [activeWorkerSubscriptions, workerCheckoutUrl, workerPrice] = await Promise.all([ - getActiveWorkerSubscriptionCount(input).catch(() => 0), - evaluation.hasActivePlan && options.includeCheckoutUrl - ? createWorkerCheckoutSession(input).catch(() => null) - : Promise.resolve(null), - env.polar.workerProductId ? getProductBillingPrice(env.polar.workerProductId).catch(() => null) : Promise.resolve(null), - ]) + let activeWorkerSubscriptions = 0 + let workerCheckoutUrl: string | null = null + let workerPrice: CloudWorkerBillingPrice | null = null + + if (evaluation.hasActivePlan) { + assertWorkerPaywallConfig() + const workerProductId = env.polar.workerProductId + if (!workerProductId) { + throw new Error("POLAR_WORKER_PRODUCT_ID is required when POLAR_FEATURE_GATE_ENABLED=true") + } + + ;[activeWorkerSubscriptions, workerCheckoutUrl, workerPrice] = await Promise.all([ + getActiveWorkerSubscriptionCount(input), + options.includeCheckoutUrl ? createWorkerCheckoutSession(input).catch(() => null) : Promise.resolve(null), + getProductBillingPrice(workerProductId).catch(() => null), + ]) + } else if (env.polar.workerProductId) { + workerPrice = await getProductBillingPrice(env.polar.workerProductId).catch(() => null) + } const [subscriptionResult, priceResult, portalResult, invoicesResult] = await Promise.all([ getPrimarySubscriptionForCustomer(getExternalCustomerId(input)).catch(() => null), diff --git a/ee/apps/den-api/src/routes/workers/billing.ts b/ee/apps/den-api/src/routes/workers/billing.ts index 04478e55e..bbea49131 100644 --- a/ee/apps/den-api/src/routes/workers/billing.ts +++ b/ee/apps/den-api/src/routes/workers/billing.ts @@ -19,15 +19,23 @@ export function registerWorkerBillingRoutes Date: Wed, 1 Apr 2026 17:33:42 -0700 Subject: [PATCH 07/10] feat(ui): add shared seeded paper gradients (#1288) * feat(ui): add shared seeded paper gradients Centralize the Paper mesh and grain wrappers so React and Solid apps can reuse the same deterministic seed-based visuals without repeating shader config. Add a standalone demo surface and update existing consumers so the shared package is easier to validate and evolve. * fix(ui): resolve shared package from source Point the shared UI package exports at source files so Next builds do not depend on a prebuilt dist directory. Add Next transpilation for @openwork/ui in Landing and Den Web so monorepo and Vercel builds resolve the package consistently. --------- Co-authored-by: src-opn --- apps/app/package.json | 4 + apps/share/package.json | 1 - apps/ui-demo/index.html | 12 + apps/ui-demo/package.json | 24 + apps/ui-demo/src/app.tsx | 216 +++++ apps/ui-demo/src/main.tsx | 10 + apps/ui-demo/src/styles.css | 343 +++++++ apps/ui-demo/tsconfig.json | 17 + apps/ui-demo/vite.config.ts | 19 + .../app/(den)/_components/auth-screen.tsx | 5 +- .../_components/background-agents-screen.tsx | 5 +- .../custom-llm-providers-screen.tsx | 5 +- ee/apps/den-web/next.config.js | 1 + ee/apps/den-web/package.json | 4 +- .../landing/components/responsive-grain.tsx | 50 +- ee/apps/landing/next.config.js | 1 + ee/apps/landing/package.json | 3 +- package.json | 1 + packages/ui/README.md | 39 + packages/ui/package.json | 38 + packages/ui/src/common/paper.ts | 416 +++++++++ packages/ui/src/react/index.ts | 17 + .../ui/src/react/paper/grain-gradient.tsx | 101 ++ packages/ui/src/react/paper/mesh-gradient.tsx | 61 ++ packages/ui/src/solid/index.ts | 17 + .../ui/src/solid/paper/grain-gradient.tsx | 125 +++ packages/ui/src/solid/paper/mesh-gradient.tsx | 104 +++ packages/ui/src/solid/paper/shader-mount.tsx | 115 +++ packages/ui/tsconfig.react.json | 19 + packages/ui/tsconfig.solid.json | 20 + packages/ui/tsup.config.ts | 46 + pnpm-lock.yaml | 872 ++++++------------ 32 files changed, 2059 insertions(+), 652 deletions(-) create mode 100644 apps/ui-demo/index.html create mode 100644 apps/ui-demo/package.json create mode 100644 apps/ui-demo/src/app.tsx create mode 100644 apps/ui-demo/src/main.tsx create mode 100644 apps/ui-demo/src/styles.css create mode 100644 apps/ui-demo/tsconfig.json create mode 100644 apps/ui-demo/vite.config.ts create mode 100644 packages/ui/README.md create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/common/paper.ts create mode 100644 packages/ui/src/react/index.ts create mode 100644 packages/ui/src/react/paper/grain-gradient.tsx create mode 100644 packages/ui/src/react/paper/mesh-gradient.tsx create mode 100644 packages/ui/src/solid/index.ts create mode 100644 packages/ui/src/solid/paper/grain-gradient.tsx create mode 100644 packages/ui/src/solid/paper/mesh-gradient.tsx create mode 100644 packages/ui/src/solid/paper/shader-mount.tsx create mode 100644 packages/ui/tsconfig.react.json create mode 100644 packages/ui/tsconfig.solid.json create mode 100644 packages/ui/tsup.config.ts diff --git a/apps/app/package.json b/apps/app/package.json index 3fe3a46bd..3341a3eaf 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -6,10 +6,13 @@ "scripts": { "dev": "OPENWORK_DEV_MODE=1 vite", "dev:windows": "vite", + "prebuild": "pnpm --dir ../../packages/ui build", "build": "vite build", "dev:web": "OPENWORK_DEV_MODE=1 vite", + "prebuild:web": "pnpm --dir ../../packages/ui build", "build:web": "vite build", "preview": "vite preview", + "pretypecheck": "pnpm --dir ../../packages/ui build", "typecheck": "tsc -p tsconfig.json --noEmit", "test:health": "node scripts/health.mjs", "test:mention-send": "node scripts/mention-send.mjs", @@ -32,6 +35,7 @@ "bump:set": "node scripts/bump-version.mjs --set" }, "dependencies": { + "@openwork/ui": "workspace:*", "@codemirror/commands": "^6.8.0", "@codemirror/lang-markdown": "^6.3.3", "@codemirror/language": "^6.11.0", diff --git a/apps/share/package.json b/apps/share/package.json index 79a26f4e2..1d7083eb0 100644 --- a/apps/share/package.json +++ b/apps/share/package.json @@ -11,7 +11,6 @@ "test:e2e": "playwright test" }, "dependencies": { - "@paper-design/shaders-react": "0.0.71", "@vercel/blob": "^0.27.0", "botid": "^1.5.11", "jsonc-parser": "^3.3.1", diff --git a/apps/ui-demo/index.html b/apps/ui-demo/index.html new file mode 100644 index 000000000..66f28a9a4 --- /dev/null +++ b/apps/ui-demo/index.html @@ -0,0 +1,12 @@ + + + + + + OpenWork UI Demo + + +
+ + + diff --git a/apps/ui-demo/package.json b/apps/ui-demo/package.json new file mode 100644 index 000000000..2cb9bde60 --- /dev/null +++ b/apps/ui-demo/package.json @@ -0,0 +1,24 @@ +{ + "name": "@openwork/ui-demo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "pnpm --dir ../../packages/ui build && vite --host 0.0.0.0 --port 3333 --strictPort", + "build": "pnpm --dir ../../packages/ui build && vite build", + "preview": "vite preview --host 0.0.0.0 --port 3333 --strictPort", + "typecheck": "pnpm --dir ../../packages/ui build && tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@openwork/ui": "workspace:*", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "^5.0.4", + "typescript": "^5.9.3", + "vite": "^7.1.12" + } +} diff --git a/apps/ui-demo/src/app.tsx b/apps/ui-demo/src/app.tsx new file mode 100644 index 000000000..76a3ff727 --- /dev/null +++ b/apps/ui-demo/src/app.tsx @@ -0,0 +1,216 @@ +import { + PaperGrainGradient, + PaperMeshGradient, + getSeededPaperGrainGradientConfig, + getSeededPaperMeshGradientConfig, +} from "@openwork/ui/react" +import { useMemo, useState } from "react" + +const sampleIds = [ + "om_01kmhbscaze02vp04ykqa4tcsb", + "om_01kmhbscazf4cjf1bssx6v9q9", + "ow_01kmj2wc68r1zk4n8v7j6v1n2k", +] + +export function App() { + const [seed, setSeed] = useState(sampleIds[0]) + const normalizedSeed = seed.trim() || sampleIds[0] + const parsedSeed = parseTypeId(normalizedSeed) + const meshConfig = useMemo(() => getSeededPaperMeshGradientConfig(normalizedSeed), [normalizedSeed]) + const grainConfig = useMemo(() => getSeededPaperGrainGradientConfig(normalizedSeed), [normalizedSeed]) + + return ( +
+
+
+ +
+
+ OpenWork UI demo +

Seeded Paper gradients on their own dev surface

+

+ Type a TypeID-like string, inspect the deterministic values derived from it, and preview + the gradients that `@openwork/ui/react` will render anywhere else in the repo. +

+
+ +
+ Deterministic + Same seed, same result. +

Useful for stable identity-driven art direction across apps.

+
+
+ +
+
+ + setSeed(event.target.value)} + spellCheck={false} + /> + +
+ {sampleIds.map((sampleId) => ( + + ))} +
+
+ +
+ + + + +
+
+ +
+ } + /> + + } + /> +
+ +
+
+ Determinism check +
+ + + + + + +
+

+ These two cards use the same seed and should always match. +

+
+ +
+ Import paths +
+ @openwork/ui/react + @openwork/ui/solid +
+
{`import { PaperMeshGradient, PaperGrainGradient } from "@openwork/ui/react"
+
+
+`}
+
+
+
+ ) +} + +function GradientCard({ + title, + subtitle, + colors, + config, + surface, +}: { + title: string + subtitle: string + colors: string[] + config: Record + surface: React.ReactNode +}) { + return ( +
+
+ {surface} +
+
+ @openwork/ui/react +

{title}

+

{subtitle}

+
+
+ +
+
+ Colors +
+ {colors.map((color) => ( +
+ + {color} +
+ ))} +
+
+ +
+ Calculated values +
{JSON.stringify(config, null, 2)}
+
+
+
+ ) +} + +function MiniPreview({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+ {title} +
{children}
+
+ ) +} + +function SeedMeta({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +function parseTypeId(value: string) { + const separatorIndex = value.indexOf("_") + + if (separatorIndex === -1) { + return { + prefix: null, + suffix: value, + suffixAnchor: value.slice(0, 5) || null, + suffixTail: value.slice(5) || null, + } + } + + const prefix = value.slice(0, separatorIndex) || null + const suffix = value.slice(separatorIndex + 1) || null + + return { + prefix, + suffix, + suffixAnchor: suffix?.slice(0, 5) || null, + suffixTail: suffix?.slice(5) || null, + } +} diff --git a/apps/ui-demo/src/main.tsx b/apps/ui-demo/src/main.tsx new file mode 100644 index 000000000..8624da889 --- /dev/null +++ b/apps/ui-demo/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react" +import ReactDOM from "react-dom/client" +import { App } from "./app" +import "./styles.css" + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +) diff --git a/apps/ui-demo/src/styles.css b/apps/ui-demo/src/styles.css new file mode 100644 index 000000000..69007a899 --- /dev/null +++ b/apps/ui-demo/src/styles.css @@ -0,0 +1,343 @@ +:root { + color-scheme: light; + font-family: "IBM Plex Sans", "Inter", system-ui, sans-serif; + background: + radial-gradient(circle at top left, rgba(57, 181, 74, 0.16), transparent 28%), + radial-gradient(circle at top right, rgba(39, 98, 255, 0.14), transparent 30%), + linear-gradient(180deg, #f6f1e8 0%, #efe7d7 100%); + color: #1f2c2b; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; +} + +button, +input, +textarea, +select { + font: inherit; +} + +code, +pre, +.sample-chip, +.seed-input { + font-family: "IBM Plex Mono", "SFMono-Regular", ui-monospace, monospace; +} + +.app-shell { + position: relative; + min-height: 100vh; + padding: 32px; + overflow: hidden; +} + +.ambient { + position: fixed; + pointer-events: none; + border-radius: 999px; + filter: blur(70px); + opacity: 0.45; +} + +.ambient-a { + top: -120px; + left: -80px; + width: 320px; + height: 320px; + background: rgba(76, 175, 80, 0.22); +} + +.ambient-b { + right: -80px; + bottom: 80px; + width: 280px; + height: 280px; + background: rgba(59, 130, 246, 0.2); +} + +.panel { + position: relative; + border: 1px solid rgba(24, 30, 28, 0.08); + background: rgba(255, 251, 245, 0.82); + backdrop-filter: blur(18px); + border-radius: 28px; + box-shadow: 0 24px 80px -48px rgba(29, 24, 17, 0.45); +} + +.hero-card { + display: grid; + gap: 24px; + grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.8fr); + padding: 32px; +} + +.hero-copy h1 { + margin: 10px 0 0; + font-size: clamp(2.4rem, 6vw, 4.5rem); + line-height: 0.92; + letter-spacing: -0.08em; +} + +.hero-copy p, +.rule-card p, +.support-copy, +.surface-copy p { + margin: 0; + color: #516160; + line-height: 1.7; +} + +.hero-copy p { + margin-top: 18px; + max-width: 62ch; +} + +.rule-card { + align-self: end; + padding: 20px; + border-radius: 24px; + background: #16201f; + color: #f0f7f3; +} + +.rule-card p { + margin-top: 8px; + color: rgba(240, 247, 243, 0.74); +} + +.eyebrow { + display: inline-block; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.muted { + color: #6d7a79; +} + +.on-dark { + color: rgba(255, 255, 255, 0.68); +} + +.controls-grid, +.footer-grid { + display: grid; + gap: 24px; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.95fr); + margin-top: 24px; +} + +.input-panel, +.seed-meta-grid, +.code-panel { + padding: 24px; +} + +.seed-input { + width: 100%; + margin-top: 10px; + padding: 15px 16px; + border-radius: 20px; + border: 1px solid rgba(22, 32, 31, 0.12); + background: rgba(255, 255, 255, 0.92); + color: #182321; + font-size: 0.92rem; + outline: none; + transition: border-color 120ms ease, box-shadow 120ms ease; +} + +.seed-input:focus { + border-color: #287d75; + box-shadow: 0 0 0 5px rgba(40, 125, 117, 0.12); +} + +.sample-list { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +.sample-chip { + border: 1px solid rgba(22, 32, 31, 0.1); + background: rgba(255, 255, 255, 0.75); + color: #425251; + border-radius: 999px; + padding: 9px 14px; + cursor: pointer; +} + +.sample-chip.active { + background: #1f6978; + color: white; + border-color: #1f6978; +} + +.seed-meta-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: #16201f; +} + +.seed-meta-card { + padding: 18px; + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); +} + +.seed-meta-card code { + display: block; + margin-top: 8px; + color: #f4fbf7; + word-break: break-all; + line-height: 1.6; +} + +.preview-grid { + display: grid; + gap: 24px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 24px; +} + +.preview-card { + overflow: hidden; +} + +.gradient-surface { + position: relative; + min-height: 340px; + background: #101818; +} + +.gradient-fill { + position: absolute; + inset: 0; +} + +.surface-overlay { + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(10, 18, 18, 0.08), rgba(10, 18, 18, 0.34)); +} + +.surface-copy { + position: absolute; + inset-inline: 0; + bottom: 0; + padding: 24px; + color: white; +} + +.surface-copy h2 { + margin: 10px 0 0; + font-size: 2rem; + letter-spacing: -0.05em; +} + +.surface-copy p { + margin-top: 10px; + color: rgba(255, 255, 255, 0.78); +} + +.details-stack { + display: grid; + gap: 20px; + padding: 24px; +} + +.swatch-list, +.pill-stack { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 14px; +} + +.swatch-pill, +.import-pill { + display: inline-flex; + align-items: center; + gap: 10px; + border-radius: 999px; + border: 1px solid rgba(22, 32, 31, 0.08); + background: rgba(248, 243, 235, 0.9); + padding: 10px 14px; +} + +.swatch-dot { + width: 14px; + height: 14px; + border-radius: 999px; + border: 1px solid rgba(0, 0, 0, 0.12); +} + +pre { + overflow-x: auto; + margin: 14px 0 0; + border-radius: 22px; + background: #16201f; + color: #dcebe4; + padding: 18px; + font-size: 0.8rem; + line-height: 1.7; +} + +.mini-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 16px; +} + +.mini-surface { + position: relative; + min-height: 180px; + margin-top: 8px; + overflow: hidden; + border-radius: 22px; + background: #111918; +} + +.support-copy { + margin-top: 16px; +} + +@media (max-width: 980px) { + .hero-card, + .controls-grid, + .preview-grid, + .footer-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .app-shell { + padding: 18px; + } + + .hero-card, + .input-panel, + .seed-meta-grid, + .code-panel, + .details-stack { + padding: 18px; + } + + .seed-meta-grid, + .mini-grid { + grid-template-columns: 1fr; + } +} diff --git a/apps/ui-demo/tsconfig.json b/apps/ui-demo/tsconfig.json new file mode 100644 index 000000000..2957d0dfd --- /dev/null +++ b/apps/ui-demo/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/ui-demo/vite.config.ts b/apps/ui-demo/vite.config.ts new file mode 100644 index 000000000..4f3f02a3e --- /dev/null +++ b/apps/ui-demo/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vite" +import react from "@vitejs/plugin-react" + +export default defineConfig({ + plugins: [react()], + server: { + host: "0.0.0.0", + port: 3333, + strictPort: true, + }, + preview: { + host: "0.0.0.0", + port: 3333, + strictPort: true, + }, + build: { + target: "es2022", + }, +}) diff --git a/ee/apps/den-web/app/(den)/_components/auth-screen.tsx b/ee/apps/den-web/app/(den)/_components/auth-screen.tsx index 60de4d313..b4667e8d1 100644 --- a/ee/apps/den-web/app/(den)/_components/auth-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/auth-screen.tsx @@ -1,6 +1,7 @@ "use client"; -import { Dithering, MeshGradient } from "@paper-design/shaders-react"; +import { PaperMeshGradient } from "@openwork/ui/react"; +import { Dithering } from "@paper-design/shaders-react"; import { ArrowRight, CheckCircle2 } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; @@ -184,7 +185,7 @@ export function AuthScreen() { colorFront="#FEFEFE" style={{ backgroundColor: "#142033", width: "100%", height: "100%" }} > - - - (null); - const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); - - useEffect(() => { - if (!containerRef.current) return; - - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - setDimensions({ - width: entry.contentRect.width, - height: entry.contentRect.height - }); - } - }); - - observer.observe(containerRef.current); - return () => observer.disconnect(); - }, []); - return ( -
- {dimensions.width > 0 && dimensions.height > 0 ? ( - - ) : null} -
+ seed={props.seed} + colors={props.colors} + colorBack={props.colorBack} + softness={props.softness} + intensity={props.intensity} + noise={props.noise} + shape={props.shape} + speed={props.speed} + /> ); } diff --git a/ee/apps/landing/next.config.js b/ee/apps/landing/next.config.js index f0c4686bc..3bd3d6380 100644 --- a/ee/apps/landing/next.config.js +++ b/ee/apps/landing/next.config.js @@ -5,6 +5,7 @@ const mintlifyOrigin = "https://differentai.mintlify.dev"; const nextConfig = { reactStrictMode: true, + transpilePackages: ["@openwork/ui"], async rewrites() { return [ { diff --git a/ee/apps/landing/package.json b/ee/apps/landing/package.json index b15973bc0..c7ef53502 100644 --- a/ee/apps/landing/package.json +++ b/ee/apps/landing/package.json @@ -4,12 +4,13 @@ "version": "0.0.0", "scripts": { "dev": "OPENWORK_DEV_MODE=1 next dev --hostname 0.0.0.0", + "prebuild": "pnpm --dir ../../../packages/ui build", "build": "next build", "start": "next start --hostname 0.0.0.0", "lint": "next lint" }, "dependencies": { - "@paper-design/shaders-react": "0.0.71", + "@openwork/ui": "workspace:*", "botid": "^1.5.11", "framer-motion": "^12.35.1", "lucide-react": "^0.577.0", diff --git a/package.json b/package.json index 9da017288..d14d067f2 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev:windows": ".\\scripts\\dev-windows.cmd", "dev:windows:x64": ".\\scripts\\dev-windows.cmd x64", "dev:ui": "OPENWORK_DEV_MODE=1 pnpm --filter @openwork/app dev", + "dev:ui-demo": "pnpm --filter @openwork/ui-demo dev", "dev:story": "OPENWORK_DEV_MODE=1 pnpm --filter @openwork/story-book dev", "dev:web": "OPENWORK_DEV_MODE=1 pnpm --filter @openwork-ee/den-web dev", "dev:web-local": "pnpm dev:den-local", diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 000000000..4c21a033c --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,39 @@ +# @openwork/ui + +Shared UI primitives for OpenWork apps. + +This package intentionally ships two framework-specific entrypoints: + +- `@openwork/ui/react` for React apps like `ee/apps/den-web` +- `@openwork/ui/solid` for Solid apps like `apps/app` + +The public API should stay aligned across both entrypoints. If you add a new component, add both implementations in the same task unless there is a documented blocker. + +## Paper components + +The first shared components live under the `paper` namespace and wrap Paper Design shaders with OpenWork-specific defaults and deterministic seed support. + +Current components: + +- `PaperMeshGradient` +- `PaperGrainGradient` + +Both accept a `seed` prop. Pass a TypeID-like string such as `om_01kmhbscaze02vp04ykqa4tcsb` and the component will deterministically derive colors and shader params from it. The same seed always produces the same result. + +Explicit props still work and override the seeded values, so the merge order is: + +1. OpenWork defaults +2. Seed-derived values from `seed` +3. Explicit props passed by the caller + +## Layout convention + +These components default to `fill={true}`, which means they render at `width: 100%` and `height: 100%`. Put them inside a sized container and they will fill it without needing manual width or height props. + +## Agent notes + +- Shared seed logic lives in `src/common/paper.ts` +- React wrappers live in `src/react/paper/*` +- Solid wrappers live in `src/solid/paper/*` +- Keep the framework prop names aligned unless there is a hard runtime mismatch +- Prefer extending the existing seed helpers instead of inventing per-app one-off shader configs diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 000000000..efedcd9c8 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,38 @@ +{ + "name": "@openwork/ui", + "private": true, + "type": "module", + "exports": { + "./react": { + "types": "./src/react/index.ts", + "development": "./src/react/index.ts", + "default": "./src/react/index.ts" + }, + "./solid": { + "types": "./src/solid/index.ts", + "development": "./src/solid/index.ts", + "default": "./src/solid/index.ts" + } + }, + "files": [ + "dist", + "src", + "README.md" + ], + "scripts": { + "build": "tsup" + }, + "dependencies": { + "@paper-design/shaders": "0.0.72", + "@paper-design/shaders-react": "0.0.72" + }, + "peerDependencies": { + "react": "^18 || ^19", + "solid-js": "^1.9.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "tsup": "^8.5.0", + "typescript": "^5.6.3" + } +} diff --git a/packages/ui/src/common/paper.ts b/packages/ui/src/common/paper.ts new file mode 100644 index 000000000..37469a001 --- /dev/null +++ b/packages/ui/src/common/paper.ts @@ -0,0 +1,416 @@ +import type { + GrainGradientParams, + GrainGradientShape, + MeshGradientParams, +} from "@paper-design/shaders" + +export type PaperMeshGradientConfig = Required< + Pick< + MeshGradientParams, + "colors" | "distortion" | "swirl" | "grainMixer" | "grainOverlay" | "speed" | "frame" + > +> + +export type PaperGrainGradientConfig = Required< + Pick< + GrainGradientParams, + "colorBack" | "colors" | "softness" | "intensity" | "noise" | "shape" | "speed" | "frame" + > +> + +export type SeededPaperOption = { + seed?: string +} + +export const paperMeshGradientDefaults: PaperMeshGradientConfig = { + colors: ["#e0eaff", "#241d9a", "#f75092", "#9f50d3"], + distortion: 0.8, + swirl: 0.1, + grainMixer: 0, + grainOverlay: 0, + speed: 0.1, + frame: 0, +} + +export const paperGrainGradientDefaults: PaperGrainGradientConfig = { + colors: ["#7300ff", "#eba8ff", "#00bfff", "#2b00ff"], + colorBack: "#000000", + softness: 0.5, + intensity: 0.5, + noise: 0.25, + shape: "ripple", + speed: 0.4, + frame: 0, +} + +const grainShapes: GrainGradientShape[] = [ + "corners", + "wave", + "dots", + "truchet", + "ripple", + "blob", + "sphere", +] + +const meshPaletteFamilies = [ + ["#e0eaff", "#241d9a", "#f75092", "#9f50d3"], + ["#ddfff5", "#006c67", "#35d8c0", "#8cff7a"], + ["#ffe5c2", "#8a2500", "#ff7b39", "#ffd166"], + ["#f5f7ff", "#0d1b52", "#3f8cff", "#00c2ff"], + ["#fff2f2", "#6f1237", "#ff4d6d", "#ffb703"], + ["#f0ffe1", "#254d00", "#8cc63f", "#00a76f"], + ["#f5edff", "#44206b", "#b5179e", "#7209b7"], + ["#f4f1ea", "#3a2f1f", "#927c55", "#d0c2a8"], +] + +const grainPaletteFamilies = [ + ["#7300ff", "#eba8ff", "#00bfff", "#2b00ff"], + ["#0df2c1", "#0b7cff", "#74efff", "#1a2cff"], + ["#ff7a18", "#ffd166", "#ff4d6d", "#5f0f40"], + ["#8dff6a", "#1f7a1f", "#d7ff70", "#00c48c"], + ["#f6a6ff", "#7027c9", "#ff66c4", "#20115b"], + ["#b9ecff", "#006494", "#00a6a6", "#072ac8"], + ["#f7f0d6", "#8c5e34", "#d68c45", "#4e342e"], + ["#ffd9f5", "#ff006e", "#8338ec", "#3a0ca3"], +] + +const paletteModes = [ + { + hueOffsets: [0, 22, 182, 238], + saturations: [0.92, 0.7, 0.84, 0.74], + lightnesses: [0.82, 0.28, 0.6, 0.5], + }, + { + hueOffsets: [0, 118, 242, 304], + saturations: [0.88, 0.76, 0.82, 0.7], + lightnesses: [0.8, 0.42, 0.58, 0.48], + }, + { + hueOffsets: [0, 44, 156, 214], + saturations: [0.94, 0.78, 0.86, 0.72], + lightnesses: [0.78, 0.4, 0.6, 0.46], + }, + { + hueOffsets: [0, 76, 184, 326], + saturations: [0.86, 0.8, 0.78, 0.76], + lightnesses: [0.82, 0.52, 0.42, 0.58], + }, + { + hueOffsets: [0, 140, 196, 224], + saturations: [0.84, 0.7, 0.76, 0.88], + lightnesses: [0.86, 0.46, 0.36, 0.54], + }, + { + hueOffsets: [0, 162, 212, 342], + saturations: [0.9, 0.72, 0.8, 0.82], + lightnesses: [0.8, 0.38, 0.52, 0.56], + }, +] + +type MeshGradientOverrides = SeededPaperOption & Partial +type GrainGradientOverrides = SeededPaperOption & Partial + +export function getSeededPaperMeshGradientConfig(seed: string): PaperMeshGradientConfig { + const random = createRandom(seed, "mesh") + + return { + colors: createSeededPalette(paperMeshGradientDefaults.colors, seed, "mesh-colors", { + families: meshPaletteFamilies, + hueShift: 42, + saturationShift: 0.18, + lightnessShift: 0.14, + baseBlend: [0.08, 0.2], + }), + distortion: roundTo(clamp(0.58 + random() * 0.32, 0, 1), 3), + swirl: roundTo(clamp(0.03 + random() * 0.28, 0, 1), 3), + grainMixer: roundTo(clamp(random() * 0.18, 0, 1), 3), + grainOverlay: roundTo(clamp(random() * 0.12, 0, 1), 3), + speed: roundTo(0.05 + random() * 0.11, 3), + frame: Math.round(random() * 240000), + } +} + +export function getSeededPaperGrainGradientConfig(seed: string): PaperGrainGradientConfig { + const random = createRandom(seed, "grain") + const colors = createSeededPalette(paperGrainGradientDefaults.colors, seed, "grain-colors", { + families: grainPaletteFamilies, + hueShift: 58, + saturationShift: 0.22, + lightnessShift: 0.18, + baseBlend: [0.04, 0.14], + }) + const anchorColor = colors[Math.floor(random() * colors.length)] ?? colors[0] + + return { + colors, + colorBack: createSeededBackground(anchorColor, seed, "grain-background"), + softness: roundTo(clamp(0.22 + random() * 0.56, 0, 1), 3), + intensity: roundTo(clamp(0.2 + random() * 0.6, 0, 1), 3), + noise: roundTo(clamp(0.12 + random() * 0.34, 0, 1), 3), + shape: grainShapes[Math.floor(random() * grainShapes.length)] ?? paperGrainGradientDefaults.shape, + speed: roundTo(0.2 + random() * 0.6, 3), + frame: Math.round(random() * 320000), + } +} + +export function resolvePaperMeshGradientConfig( + options: MeshGradientOverrides = {}, +): PaperMeshGradientConfig { + const seeded = options.seed ? getSeededPaperMeshGradientConfig(options.seed) : paperMeshGradientDefaults + + return { + colors: options.colors ?? seeded.colors, + distortion: options.distortion ?? seeded.distortion, + swirl: options.swirl ?? seeded.swirl, + grainMixer: options.grainMixer ?? seeded.grainMixer, + grainOverlay: options.grainOverlay ?? seeded.grainOverlay, + speed: options.speed ?? seeded.speed, + frame: options.frame ?? seeded.frame, + } +} + +export function resolvePaperGrainGradientConfig( + options: GrainGradientOverrides = {}, +): PaperGrainGradientConfig { + const seeded = options.seed ? getSeededPaperGrainGradientConfig(options.seed) : paperGrainGradientDefaults + + return { + colors: options.colors ?? seeded.colors, + colorBack: options.colorBack ?? seeded.colorBack, + softness: options.softness ?? seeded.softness, + intensity: options.intensity ?? seeded.intensity, + noise: options.noise ?? seeded.noise, + shape: options.shape ?? seeded.shape, + speed: options.speed ?? seeded.speed, + frame: options.frame ?? seeded.frame, + } +} + +function buildSeedSource(seed: string) { + const trimmedSeed = seed.trim() + const separatorIndex = trimmedSeed.indexOf("_") + + if (separatorIndex === -1) { + return trimmedSeed + } + + const prefix = trimmedSeed.slice(0, separatorIndex) + const suffix = trimmedSeed.slice(separatorIndex + 1) + const suffixTail = suffix.slice(5) || suffix + + return `${trimmedSeed}|${prefix}|${suffix}|${suffixTail}` +} + +function createSeededPalette( + baseColors: string[], + seed: string, + namespace: string, + options: { + families: string[][] + hueShift: number + saturationShift: number + lightnessShift: number + baseBlend: [number, number] + }, +) { + const familyRandom = createRandom(seed, `${namespace}:family`) + const primaryIndex = Math.floor(familyRandom() * options.families.length) + const secondaryOffset = 1 + Math.floor(familyRandom() * (options.families.length - 1)) + const secondaryIndex = (primaryIndex + secondaryOffset) % options.families.length + const primary = options.families[primaryIndex] ?? baseColors + const secondary = options.families[secondaryIndex] ?? [...baseColors].reverse() + const primaryShift = Math.floor(familyRandom() * primary.length) + const secondaryShift = Math.floor(familyRandom() * secondary.length) + const paletteMode = paletteModes[Math.floor(familyRandom() * paletteModes.length)] ?? paletteModes[0] + const baseHue = familyRandom() * 360 + + return baseColors.map((color, index) => { + const random = createRandom(seed, `${namespace}:${index}`) + const primaryColor = primary[(index + primaryShift) % primary.length] ?? color + const secondaryColor = secondary[(index + secondaryShift) % secondary.length] ?? primaryColor + const proceduralColor = hslToHex( + (baseHue + paletteMode.hueOffsets[index % paletteMode.hueOffsets.length] + (random() * 2 - 1) * 18 + 360) % 360, + clamp(paletteMode.saturations[index % paletteMode.saturations.length] + (random() * 2 - 1) * 0.08, 0, 1), + clamp(paletteMode.lightnesses[index % paletteMode.lightnesses.length] + (random() * 2 - 1) * 0.08, 0, 1), + ) + const mixedFamilyColor = mixHexColors(primaryColor, secondaryColor, 0.18 + random() * 0.64) + const remixedFamilyColor = mixHexColors( + mixedFamilyColor, + primary[(index + secondaryShift + 1) % primary.length] ?? mixedFamilyColor, + random() * 0.32, + ) + const proceduralFamilyColor = mixHexColors(proceduralColor, remixedFamilyColor, 0.22 + random() * 0.34) + const [minBaseBlend, maxBaseBlend] = options.baseBlend + const blendedBaseColor = mixHexColors( + proceduralFamilyColor, + color, + minBaseBlend + random() * (maxBaseBlend - minBaseBlend), + ) + + return adjustHexColor(blendedBaseColor, { + hueShift: (random() * 2 - 1) * options.hueShift + (random() * 2 - 1) * 14, + saturationShift: (random() * 2 - 1) * options.saturationShift + 0.06, + lightnessShift: (random() * 2 - 1) * options.lightnessShift, + }) + }) +} + +function createSeededBackground(baseColor: string, seed: string, namespace: string) { + const [red, green, blue] = hexToRgb(baseColor) + const [hue] = rgbToHsl(red, green, blue) + const random = createRandom(seed, namespace) + + return hslToHex( + hue, + clamp(0.18 + random() * 0.18, 0, 1), + clamp(0.03 + random() * 0.09, 0, 1), + ) +} + +function adjustHexColor( + hex: string, + adjustments: { hueShift: number; saturationShift: number; lightnessShift: number }, +) { + const [red, green, blue] = hexToRgb(hex) + const [hue, saturation, lightness] = rgbToHsl(red, green, blue) + + return hslToHex( + (hue + adjustments.hueShift + 360) % 360, + clamp(saturation + adjustments.saturationShift, 0, 1), + clamp(lightness + adjustments.lightnessShift, 0, 1), + ) +} + +function mixHexColors(colorA: string, colorB: string, amount: number) { + const [redA, greenA, blueA] = hexToRgb(colorA) + const [redB, greenB, blueB] = hexToRgb(colorB) + const mixAmount = clamp(amount, 0, 1) + + return rgbToHex( + Math.round(redA + (redB - redA) * mixAmount), + Math.round(greenA + (greenB - greenA) * mixAmount), + Math.round(blueA + (blueB - blueA) * mixAmount), + ) +} + +function createRandom(seed: string, namespace: string) { + return mulberry32(hashString(`${buildSeedSource(seed)}::${namespace}`)) +} + +function hashString(input: string) { + let hash = 2166136261 + + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + + return hash >>> 0 +} + +function mulberry32(seed: number) { + return function nextRandom() { + let value = seed += 0x6d2b79f5 + value = Math.imul(value ^ (value >>> 15), value | 1) + value ^= value + Math.imul(value ^ (value >>> 7), value | 61) + return ((value ^ (value >>> 14)) >>> 0) / 4294967296 + } +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function roundTo(value: number, precision: number) { + const power = 10 ** precision + return Math.round(value * power) / power +} + +function hexToRgb(hex: string): [number, number, number] { + const normalized = hex.replace(/^#/, "") + const expanded = normalized.length === 3 + ? normalized.split("").map((part) => `${part}${part}`).join("") + : normalized + + if (expanded.length !== 6) { + throw new Error(`Unsupported hex color: ${hex}`) + } + + const value = Number.parseInt(expanded, 16) + + return [ + (value >> 16) & 255, + (value >> 8) & 255, + value & 255, + ] +} + +function rgbToHsl(red: number, green: number, blue: number): [number, number, number] { + const normalizedRed = red / 255 + const normalizedGreen = green / 255 + const normalizedBlue = blue / 255 + const max = Math.max(normalizedRed, normalizedGreen, normalizedBlue) + const min = Math.min(normalizedRed, normalizedGreen, normalizedBlue) + const lightness = (max + min) / 2 + + if (max === min) { + return [0, 0, lightness] + } + + const delta = max - min + const saturation = lightness > 0.5 + ? delta / (2 - max - min) + : delta / (max + min) + + let hue = 0 + + switch (max) { + case normalizedRed: + hue = (normalizedGreen - normalizedBlue) / delta + (normalizedGreen < normalizedBlue ? 6 : 0) + break + case normalizedGreen: + hue = (normalizedBlue - normalizedRed) / delta + 2 + break + default: + hue = (normalizedRed - normalizedGreen) / delta + 4 + break + } + + return [hue * 60, saturation, lightness] +} + +function hslToHex(hue: number, saturation: number, lightness: number) { + if (saturation === 0) { + const value = Math.round(lightness * 255) + return rgbToHex(value, value, value) + } + + const hueToRgb = (p: number, q: number, t: number) => { + let normalizedT = t + + if (normalizedT < 0) normalizedT += 1 + if (normalizedT > 1) normalizedT -= 1 + if (normalizedT < 1 / 6) return p + (q - p) * 6 * normalizedT + if (normalizedT < 1 / 2) return q + if (normalizedT < 2 / 3) return p + (q - p) * (2 / 3 - normalizedT) * 6 + return p + } + + const normalizedHue = hue / 360 + const q = lightness < 0.5 + ? lightness * (1 + saturation) + : lightness + saturation - lightness * saturation + const p = 2 * lightness - q + const red = hueToRgb(p, q, normalizedHue + 1 / 3) + const green = hueToRgb(p, q, normalizedHue) + const blue = hueToRgb(p, q, normalizedHue - 1 / 3) + + return rgbToHex(Math.round(red * 255), Math.round(green * 255), Math.round(blue * 255)) +} + +function rgbToHex(red: number, green: number, blue: number) { + return `#${[red, green, blue] + .map((value) => value.toString(16).padStart(2, "0")) + .join("")}` +} diff --git a/packages/ui/src/react/index.ts b/packages/ui/src/react/index.ts new file mode 100644 index 000000000..5945ca552 --- /dev/null +++ b/packages/ui/src/react/index.ts @@ -0,0 +1,17 @@ +export { + getSeededPaperGrainGradientConfig, + getSeededPaperMeshGradientConfig, + paperGrainGradientDefaults, + paperMeshGradientDefaults, + resolvePaperGrainGradientConfig, + resolvePaperMeshGradientConfig, +} from "../common/paper" +export type { + PaperGrainGradientConfig, + PaperMeshGradientConfig, + SeededPaperOption, +} from "../common/paper" +export { PaperGrainGradient } from "./paper/grain-gradient" +export type { PaperGrainGradientProps } from "./paper/grain-gradient" +export { PaperMeshGradient } from "./paper/mesh-gradient" +export type { PaperMeshGradientProps } from "./paper/mesh-gradient" diff --git a/packages/ui/src/react/paper/grain-gradient.tsx b/packages/ui/src/react/paper/grain-gradient.tsx new file mode 100644 index 000000000..a247eecdc --- /dev/null +++ b/packages/ui/src/react/paper/grain-gradient.tsx @@ -0,0 +1,101 @@ +"use client" + +import { + defaultObjectSizing, + defaultPatternSizing, + type GrainGradientShape, +} from "@paper-design/shaders" +import { GrainGradient, type GrainGradientProps } from "@paper-design/shaders-react" +import { resolvePaperGrainGradientConfig } from "../../common/paper" + +export interface PaperGrainGradientProps + extends Omit< + GrainGradientProps, + "colorBack" | "colors" | "softness" | "intensity" | "noise" | "shape" | "speed" | "frame" + > { + seed?: string + fill?: boolean + colorBack?: string + colors?: string[] + softness?: number + intensity?: number + noise?: number + shape?: GrainGradientProps["shape"] + speed?: number + frame?: number +} + +export function PaperGrainGradient({ + seed, + fill = true, + colorBack, + colors, + softness, + intensity, + noise, + shape, + speed, + frame, + fit, + rotation, + scale, + originX, + originY, + offsetX, + offsetY, + worldWidth, + worldHeight, + width, + height, + ...props +}: PaperGrainGradientProps) { + const resolved = resolvePaperGrainGradientConfig({ + seed, + colorBack, + colors, + softness, + intensity, + noise, + shape, + speed, + frame, + }) + + const sizingDefaults = getSizingDefaults(resolved.shape) + + return ( + + ) +} + +function getSizingDefaults(shape: GrainGradientShape) { + switch (shape) { + case "wave": + case "dots": + case "truchet": + return defaultPatternSizing + default: + return defaultObjectSizing + } +} diff --git a/packages/ui/src/react/paper/mesh-gradient.tsx b/packages/ui/src/react/paper/mesh-gradient.tsx new file mode 100644 index 000000000..da9fe68ae --- /dev/null +++ b/packages/ui/src/react/paper/mesh-gradient.tsx @@ -0,0 +1,61 @@ +"use client" + +import { MeshGradient, type MeshGradientProps } from "@paper-design/shaders-react" +import { resolvePaperMeshGradientConfig } from "../../common/paper" + +export interface PaperMeshGradientProps + extends Omit< + MeshGradientProps, + "colors" | "distortion" | "swirl" | "grainMixer" | "grainOverlay" | "speed" | "frame" + > { + seed?: string + fill?: boolean + colors?: string[] + distortion?: number + swirl?: number + grainMixer?: number + grainOverlay?: number + speed?: number + frame?: number +} + +export function PaperMeshGradient({ + seed, + fill = true, + colors, + distortion, + swirl, + grainMixer, + grainOverlay, + speed, + frame, + width, + height, + ...props +}: PaperMeshGradientProps) { + const resolved = resolvePaperMeshGradientConfig({ + seed, + colors, + distortion, + swirl, + grainMixer, + grainOverlay, + speed, + frame, + }) + + return ( + + ) +} diff --git a/packages/ui/src/solid/index.ts b/packages/ui/src/solid/index.ts new file mode 100644 index 000000000..5945ca552 --- /dev/null +++ b/packages/ui/src/solid/index.ts @@ -0,0 +1,17 @@ +export { + getSeededPaperGrainGradientConfig, + getSeededPaperMeshGradientConfig, + paperGrainGradientDefaults, + paperMeshGradientDefaults, + resolvePaperGrainGradientConfig, + resolvePaperMeshGradientConfig, +} from "../common/paper" +export type { + PaperGrainGradientConfig, + PaperMeshGradientConfig, + SeededPaperOption, +} from "../common/paper" +export { PaperGrainGradient } from "./paper/grain-gradient" +export type { PaperGrainGradientProps } from "./paper/grain-gradient" +export { PaperMeshGradient } from "./paper/mesh-gradient" +export type { PaperMeshGradientProps } from "./paper/mesh-gradient" diff --git a/packages/ui/src/solid/paper/grain-gradient.tsx b/packages/ui/src/solid/paper/grain-gradient.tsx new file mode 100644 index 000000000..81c057a2f --- /dev/null +++ b/packages/ui/src/solid/paper/grain-gradient.tsx @@ -0,0 +1,125 @@ +import { + defaultObjectSizing, + defaultPatternSizing, + getShaderColorFromString, + getShaderNoiseTexture, + grainGradientFragmentShader, + GrainGradientShapes, + ShaderFitOptions, + type GrainGradientParams, +} from "@paper-design/shaders" +import type { JSX } from "solid-js" +import { resolvePaperGrainGradientConfig } from "../../common/paper" +import { SolidShaderMount } from "./shader-mount" + +type SharedGrainProps = Pick< + GrainGradientParams, + "fit" | "rotation" | "scale" | "originX" | "originY" | "offsetX" | "offsetY" | "worldWidth" | "worldHeight" +> + +export interface PaperGrainGradientProps + extends Omit, "ref">, + Partial { + ref?: (element: HTMLDivElement) => void + seed?: string + fill?: boolean + colorBack?: string + colors?: string[] + softness?: number + intensity?: number + noise?: number + shape?: GrainGradientParams["shape"] + speed?: number + frame?: number + minPixelRatio?: number + maxPixelCount?: number + webGlContextAttributes?: WebGLContextAttributes + width?: string | number + height?: string | number +} + +export function PaperGrainGradient({ + seed, + fill = true, + colorBack, + colors, + softness, + intensity, + noise, + shape, + speed, + frame, + fit, + rotation, + scale, + originX, + originY, + offsetX, + offsetY, + worldWidth, + worldHeight, + minPixelRatio, + maxPixelCount, + webGlContextAttributes, + width, + height, + ...props +}: PaperGrainGradientProps) { + const resolved = resolvePaperGrainGradientConfig({ + seed, + colorBack, + colors, + softness, + intensity, + noise, + shape, + speed, + frame, + }) + + const sizingDefaults = getSizingDefaults(resolved.shape) + + return ( + + ) +} + +function getSizingDefaults(shape: NonNullable) { + switch (shape) { + case "wave": + case "dots": + case "truchet": + return defaultPatternSizing + default: + return defaultObjectSizing + } +} diff --git a/packages/ui/src/solid/paper/mesh-gradient.tsx b/packages/ui/src/solid/paper/mesh-gradient.tsx new file mode 100644 index 000000000..234c6f42a --- /dev/null +++ b/packages/ui/src/solid/paper/mesh-gradient.tsx @@ -0,0 +1,104 @@ +import { + defaultObjectSizing, + getShaderColorFromString, + meshGradientFragmentShader, + ShaderFitOptions, + type MeshGradientParams, +} from "@paper-design/shaders" +import type { JSX } from "solid-js" +import { resolvePaperMeshGradientConfig } from "../../common/paper" +import { SolidShaderMount } from "./shader-mount" + +type SharedMeshProps = Pick< + MeshGradientParams, + "fit" | "rotation" | "scale" | "originX" | "originY" | "offsetX" | "offsetY" | "worldWidth" | "worldHeight" +> + +export interface PaperMeshGradientProps + extends Omit, "ref">, + Partial { + ref?: (element: HTMLDivElement) => void + seed?: string + fill?: boolean + colors?: string[] + distortion?: number + swirl?: number + grainMixer?: number + grainOverlay?: number + speed?: number + frame?: number + minPixelRatio?: number + maxPixelCount?: number + webGlContextAttributes?: WebGLContextAttributes + width?: string | number + height?: string | number +} + +export function PaperMeshGradient({ + seed, + fill = true, + colors, + distortion, + swirl, + grainMixer, + grainOverlay, + speed, + frame, + fit = defaultObjectSizing.fit, + rotation = defaultObjectSizing.rotation, + scale = defaultObjectSizing.scale, + originX = defaultObjectSizing.originX, + originY = defaultObjectSizing.originY, + offsetX = defaultObjectSizing.offsetX, + offsetY = defaultObjectSizing.offsetY, + worldWidth = defaultObjectSizing.worldWidth, + worldHeight = defaultObjectSizing.worldHeight, + minPixelRatio, + maxPixelCount, + webGlContextAttributes, + width, + height, + ...props +}: PaperMeshGradientProps) { + const resolved = resolvePaperMeshGradientConfig({ + seed, + colors, + distortion, + swirl, + grainMixer, + grainOverlay, + speed, + frame, + }) + + return ( + + ) +} diff --git a/packages/ui/src/solid/paper/shader-mount.tsx b/packages/ui/src/solid/paper/shader-mount.tsx new file mode 100644 index 000000000..c8c0d4fe3 --- /dev/null +++ b/packages/ui/src/solid/paper/shader-mount.tsx @@ -0,0 +1,115 @@ +import { ShaderMount, type ShaderMountUniforms } from "@paper-design/shaders" +import { createEffect, onCleanup, onMount, splitProps, type JSX } from "solid-js" + +type SolidShaderMountProps = Omit, "ref"> & { + ref?: (element: HTMLDivElement) => void + fragmentShader: string + uniforms: ShaderMountUniforms + speed?: number + frame?: number + minPixelRatio?: number + maxPixelCount?: number + webGlContextAttributes?: WebGLContextAttributes + width?: string | number + height?: string | number +} + +export function SolidShaderMount(props: SolidShaderMountProps) { + const [local, rest] = splitProps(props, [ + "ref", + "fragmentShader", + "uniforms", + "speed", + "frame", + "minPixelRatio", + "maxPixelCount", + "webGlContextAttributes", + "width", + "height", + "style", + ]) + + let element: HTMLDivElement | undefined + let shaderMount: ShaderMount | undefined + + onMount(() => { + if (!element) { + return + } + + shaderMount = new ShaderMount( + element, + local.fragmentShader, + local.uniforms, + local.webGlContextAttributes, + local.speed, + local.frame, + local.minPixelRatio, + local.maxPixelCount, + ) + + onCleanup(() => { + shaderMount?.dispose() + shaderMount = undefined + }) + }) + + createEffect(() => { + shaderMount?.setUniforms(local.uniforms) + }) + + createEffect(() => { + shaderMount?.setSpeed(local.speed) + }) + + createEffect(() => { + if (local.frame !== undefined) { + shaderMount?.setFrame(local.frame) + } + }) + + createEffect(() => { + shaderMount?.setMinPixelRatio(local.minPixelRatio) + }) + + createEffect(() => { + shaderMount?.setMaxPixelCount(local.maxPixelCount) + }) + + return ( +
{ + element = node + local.ref?.(node) + }} + style={mergeStyle(local.style, local.width, local.height)} + /> + ) +} + +function mergeStyle( + style: JSX.CSSProperties | string | undefined, + width: string | number | undefined, + height: string | number | undefined, +) { + if (typeof style === "string") { + return [ + width !== undefined ? `width:${toCssSize(width)}` : "", + height !== undefined ? `height:${toCssSize(height)}` : "", + style, + ] + .filter(Boolean) + .join(";") + } + + return { + ...(style ?? {}), + ...(width !== undefined ? { width: toCssSize(width) } : {}), + ...(height !== undefined ? { height: toCssSize(height) } : {}), + } +} + +function toCssSize(value: string | number) { + return typeof value === "number" ? `${value}px` : value +} diff --git a/packages/ui/tsconfig.react.json b/packages/ui/tsconfig.react.json new file mode 100644 index 000000000..5c8688165 --- /dev/null +++ b/packages/ui/tsconfig.react.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "jsx": "react-jsx" + }, + "include": [ + "src/common/**/*", + "src/react/**/*" + ] +} diff --git a/packages/ui/tsconfig.solid.json b/packages/ui/tsconfig.solid.json new file mode 100644 index 000000000..de511d1be --- /dev/null +++ b/packages/ui/tsconfig.solid.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "jsx": "preserve", + "jsxImportSource": "solid-js/h" + }, + "include": [ + "src/common/**/*", + "src/solid/**/*" + ] +} diff --git a/packages/ui/tsup.config.ts b/packages/ui/tsup.config.ts new file mode 100644 index 000000000..88fe74670 --- /dev/null +++ b/packages/ui/tsup.config.ts @@ -0,0 +1,46 @@ +import { defineConfig } from "tsup" + +export default defineConfig([ + { + entry: { + "react/index": "src/react/index.ts", + }, + tsconfig: "./tsconfig.react.json", + format: ["esm"], + dts: { + tsconfig: "./tsconfig.react.json", + }, + clean: true, + target: "es2022", + platform: "browser", + sourcemap: false, + splitting: false, + treeshake: true, + external: ["react", "react/jsx-runtime"], + esbuildOptions(options) { + options.jsx = "automatic" + options.jsxImportSource = "react" + }, + }, + { + entry: { + "solid/index": "src/solid/index.ts", + }, + tsconfig: "./tsconfig.solid.json", + format: ["esm"], + dts: { + tsconfig: "./tsconfig.solid.json", + }, + clean: false, + target: "es2022", + platform: "browser", + sourcemap: false, + splitting: false, + treeshake: true, + external: ["solid-js", "solid-js/jsx-runtime"], + esbuildOptions(options) { + options.jsx = "automatic" + options.jsxImportSource = "solid-js/h" + }, + }, +]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35b9f67c2..38943a658 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: '@opencode-ai/sdk': specifier: ^1.1.31 version: 1.1.39 + '@openwork/ui': + specifier: workspace:* + version: link:../../packages/ui '@radix-ui/colors': specifier: ^3.0.0 version: 3.0.0 @@ -218,9 +221,6 @@ importers: apps/share: dependencies: - '@paper-design/shaders-react': - specifier: 0.0.71 - version: 0.0.71(@types/react@18.2.79)(react@19.2.4) '@vercel/blob': specifier: ^0.27.0 version: 0.27.3 @@ -353,6 +353,34 @@ importers: specifier: ^2.11.0 version: 2.11.10(solid-js@1.9.9)(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + apps/ui-demo: + dependencies: + '@openwork/ui': + specifier: workspace:* + version: link:../../packages/ui + react: + specifier: 19.2.4 + version: 19.2.4 + react-dom: + specifier: 19.2.4 + version: 19.2.4(react@19.2.4) + devDependencies: + '@types/react': + specifier: 19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^5.0.4 + version: 5.2.0(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.1.12 + version: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + ee/apps/den-api: dependencies: '@daytonaio/sdk': @@ -396,60 +424,14 @@ importers: specifier: ^5.5.4 version: 5.9.3 - ee/apps/den-controller: - dependencies: - '@daytonaio/sdk': - specifier: ^0.150.0 - version: 0.150.0(ws@8.19.0) - '@openwork-ee/den-db': - specifier: workspace:* - version: link:../../packages/den-db - '@openwork-ee/utils': - specifier: workspace:* - version: link:../../packages/utils - better-auth: - specifier: ^1.4.18 - version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.11)(mysql2@3.17.4))(mysql2@3.17.4)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10) - better-call: - specifier: ^1.1.8 - version: 1.1.8(zod@4.3.6) - cors: - specifier: ^2.8.5 - version: 2.8.6 - dotenv: - specifier: ^16.4.5 - version: 16.6.1 - express: - specifier: ^4.19.2 - version: 4.22.1 - mysql2: - specifier: ^3.11.3 - version: 3.17.4 - zod: - specifier: ^4.3.6 - version: 4.3.6 - devDependencies: - '@types/cors': - specifier: ^2.8.17 - version: 2.8.19 - '@types/express': - specifier: ^4.17.21 - version: 4.17.25 - '@types/node': - specifier: ^20.11.30 - version: 20.12.12 - tsx: - specifier: ^4.15.7 - version: 4.21.0 - typescript: - specifier: ^5.5.4 - version: 5.9.3 - ee/apps/den-web: dependencies: + '@openwork/ui': + specifier: workspace:* + version: link:../../../packages/ui '@paper-design/shaders-react': - specifier: 0.0.71 - version: 0.0.71(@types/react@19.2.14)(react@19.2.4) + specifier: 0.0.72 + version: 0.0.72(@types/react@19.2.14)(react@19.2.4) lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.4) @@ -521,9 +503,9 @@ importers: ee/apps/landing: dependencies: - '@paper-design/shaders-react': - specifier: 0.0.71 - version: 0.0.71(@types/react@18.2.79)(react@18.2.0) + '@openwork/ui': + specifier: workspace:* + version: link:../../../packages/ui botid: specifier: ^1.5.11 version: 1.5.11(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0) @@ -612,6 +594,31 @@ importers: specifier: ^5.5.4 version: 5.9.3 + packages/ui: + dependencies: + '@paper-design/shaders': + specifier: 0.0.72 + version: 0.0.72 + '@paper-design/shaders-react': + specifier: 0.0.72 + version: 0.0.72(@types/react@19.2.14)(react@19.2.4) + react: + specifier: ^18 || ^19 + version: 19.2.4 + solid-js: + specifier: ^1.9.0 + version: 1.9.9 + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + tsup: + specifier: ^8.5.0 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + packages: '@alloc/quick-lru@5.2.0': @@ -791,6 +798,10 @@ packages: resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.6': resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==} engines: {node: '>=6.9.0'} @@ -803,10 +814,18 @@ packages: resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==} engines: {node: '>=6.9.0'} + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + '@babel/generator@7.28.6': resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} @@ -882,6 +901,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} @@ -900,6 +924,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.28.6': resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} engines: {node: '>=6.9.0'} @@ -920,10 +956,18 @@ packages: resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.6': resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@better-auth/core@1.4.18': resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==} peerDependencies: @@ -2188,8 +2232,8 @@ packages: peerDependencies: solid-js: 1.9.9 - '@paper-design/shaders-react@0.0.71': - resolution: {integrity: sha512-kTqjIlyZcpkwqJie+3ldEDscTtx1oOi8eRBD5QgWKI21GaNn/SSg26092M5zzqr3e8dVANv0ktS2ICSjbMFKbw==} + '@paper-design/shaders-react@0.0.72': + resolution: {integrity: sha512-q6KwquL93ZVNcuSM7pqzW0z/VLjnDVb/NSpYyGJBxf7MEHHCXx37E+zw/Px6RZLt3SGCUerIDDCGCMT385oW0w==} peerDependencies: '@types/react': ^18 || ^19 react: ^18 || ^19 @@ -2197,8 +2241,8 @@ packages: '@types/react': optional: true - '@paper-design/shaders@0.0.71': - resolution: {integrity: sha512-brCt05YxxyjBrhnE3l1wJJHcFXsM8aE4lmpd9TMQp+p0dMU3F+OWkJZL9m/RC1Tt7om5xr0Wg7d0HYm+b9NYZA==} + '@paper-design/shaders@0.0.72': + resolution: {integrity: sha512-rk2BFuV5ood2DaivbxJC2jQMzaB434isDUzdUQ85Cy0OWnUMuxl8kyGMR74TDPyjo3EvcHIyreNLkJdRG+GfSA==} '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2245,6 +2289,9 @@ packages: '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rollup/rollup-android-arm-eabi@4.55.1': resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} cpu: [arm] @@ -2876,30 +2923,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - - '@types/cors@2.8.19': - resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/express-serve-static-core@4.19.8': - resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} - - '@types/express@4.17.25': - resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} - - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - - '@types/mime@1.3.5': - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - '@types/minimatch@5.1.2': resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} @@ -2918,12 +2944,6 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - '@types/qs@6.14.0': - resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} - - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react-dom@18.2.25': resolution: {integrity: sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==} @@ -2941,15 +2961,6 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} - '@types/send@0.17.6': - resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} - - '@types/send@1.2.1': - resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - - '@types/serve-static@1.15.10': - resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} - '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -2957,6 +2968,12 @@ packages: resolution: {integrity: sha512-WizeAxzOTmv0JL7wOaxvLIU/KdBcrclM1ZUOdSlIZAxsTTTe1jsyBthStLby0Ueh7FnmKYAjLz26qRJTk5SDkQ==} engines: {node: '>=16.14'} + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@webgpu/types@0.1.69': resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} @@ -2964,10 +2981,6 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -2999,9 +3012,6 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} @@ -3139,10 +3149,6 @@ packages: bmp-ts@1.0.9: resolution: {integrity: sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==} - body-parser@1.20.4: - resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - botid@1.5.11: resolution: {integrity: sha512-KOO1A3+/vFVJk5aFoG3sNwiogKFPVR+x4aw4gQ1b2e0XoE+i5xp48/EZn+WqR07jRHeDGwHWQUOtV5WVm7xiww==} peerDependencies: @@ -3219,10 +3225,6 @@ packages: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -3231,10 +3233,6 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -3290,28 +3288,9 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-signature@1.0.7: - resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -3323,14 +3302,6 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3351,14 +3322,6 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -3481,19 +3444,12 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - enhanced-resolve@5.18.4: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} @@ -3542,13 +3498,6 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -3570,10 +3519,6 @@ packages: resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} engines: {node: '>=0.10.0'} - express@4.22.1: - resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} - engines: {node: '>= 0.10.0'} - fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -3605,10 +3550,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - finalhandler@1.3.2: - resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} - engines: {node: '>= 0.8'} - find-babel-config@2.1.2: resolution: {integrity: sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==} @@ -3635,10 +3576,6 @@ packages: forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -3656,10 +3593,6 @@ packages: react-dom: optional: true - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -3751,14 +3684,6 @@ packages: html-entities@2.3.3: resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} - - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -3775,10 +3700,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -4002,25 +3923,14 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - merge-anything@5.1.7: resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} engines: {node: '>=12.13'} - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -4033,11 +3943,6 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -4075,9 +3980,6 @@ packages: motion-utils@12.29.2: resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4101,10 +4003,6 @@ packages: resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} engines: {node: ^20.0.0 || >=22.0.0} - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - next@14.2.5: resolution: {integrity: sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==} engines: {node: '>=18.17.0'} @@ -4194,10 +4092,6 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - omggif@1.0.10: resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} @@ -4205,10 +4099,6 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -4256,10 +4146,6 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -4275,9 +4161,6 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -4427,31 +4310,15 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} - engines: {node: '>=0.6'} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - raw-body@2.5.3: - resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} - engines: {node: '>= 0.8'} - react-dom@18.2.0: resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -4462,6 +4329,10 @@ packages: peerDependencies: react: ^19.2.4 + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} @@ -4571,10 +4442,6 @@ packages: engines: {node: '>=10'} hasBin: true - send@0.19.2: - resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} - engines: {node: '>= 0.8.0'} - seroval-plugins@1.3.3: resolution: {integrity: sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==} engines: {node: '>=10'} @@ -4585,16 +4452,9 @@ packages: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} - serve-static@1.16.3: - resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} - engines: {node: '>= 0.8.0'} - set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4603,22 +4463,6 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - simple-xml-to-json@1.2.3: resolution: {integrity: sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA==} engines: {node: '>=20.12.2'} @@ -4664,10 +4508,6 @@ packages: resolution: {integrity: sha512-AzlMO+t51v6cFvKZ+Oe9DJnL1OXEH5s9bEy6di5aOrUpcP7PCzI/wIeXF0u3zg0L89gwnceoKxrLId0ZpYnNXw==} engines: {node: '>=18.0'} - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - stream-browserify@3.0.0: resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} @@ -4778,10 +4618,6 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - token-types@4.2.1: resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==} engines: {node: '>=10'} @@ -4827,10 +4663,6 @@ packages: resolution: {integrity: sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ==} hasBin: true - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - typeid-js@1.2.0: resolution: {integrity: sha512-t76ZucAnvGC60ea/HjVsB0TSoB0cw9yjnfurUgtInXQWUI/VcrlZGpO23KN3iSe8yOGUgb1zr7W7uEzJ3hSljA==} @@ -4864,10 +4696,6 @@ packages: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -4880,18 +4708,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - vite-plugin-solid@2.11.10: resolution: {integrity: sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw==} peerDependencies: @@ -4942,6 +4762,46 @@ packages: yaml: optional: true + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitefu@1.1.1: resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==} peerDependencies: @@ -5497,6 +5357,12 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.28.6': {} '@babel/core@7.28.0': @@ -5539,6 +5405,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/generator@7.28.6': dependencies: '@babel/parser': 7.28.6 @@ -5547,6 +5433,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.28.6 @@ -5610,6 +5504,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.6 + transitivePeerDependencies: + - supports-color + '@babel/helper-optimise-call-expression@7.27.1': dependencies: '@babel/types': 7.28.6 @@ -5647,6 +5550,10 @@ snapshots: dependencies: '@babel/types': 7.28.6 + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -5670,6 +5577,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -5710,11 +5627,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.28.6': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)': dependencies: '@better-auth/utils': 0.3.0 @@ -6884,28 +6818,14 @@ snapshots: - typescript - web-tree-sitter - '@paper-design/shaders-react@0.0.71(@types/react@18.2.79)(react@18.2.0)': - dependencies: - '@paper-design/shaders': 0.0.71 - react: 18.2.0 - optionalDependencies: - '@types/react': 18.2.79 - - '@paper-design/shaders-react@0.0.71(@types/react@18.2.79)(react@19.2.4)': + '@paper-design/shaders-react@0.0.72(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@paper-design/shaders': 0.0.71 - react: 19.2.4 - optionalDependencies: - '@types/react': 18.2.79 - - '@paper-design/shaders-react@0.0.71(@types/react@19.2.14)(react@19.2.4)': - dependencies: - '@paper-design/shaders': 0.0.71 + '@paper-design/shaders': 0.0.72 react: 19.2.4 optionalDependencies: '@types/react': 19.2.14 - '@paper-design/shaders@0.0.71': {} + '@paper-design/shaders@0.0.72': {} '@pinojs/redact@0.4.0': {} @@ -6940,6 +6860,8 @@ snapshots: '@radix-ui/colors@3.0.0': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rollup/rollup-android-arm-eabi@4.55.1': optional: true @@ -7610,39 +7532,8 @@ snapshots: dependencies: '@babel/types': 7.28.6 - '@types/body-parser@1.19.6': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 20.12.12 - - '@types/connect@3.4.38': - dependencies: - '@types/node': 20.12.12 - - '@types/cors@2.8.19': - dependencies: - '@types/node': 20.12.12 - '@types/estree@1.0.8': {} - '@types/express-serve-static-core@4.19.8': - dependencies: - '@types/node': 20.12.12 - '@types/qs': 6.14.0 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 - - '@types/express@4.17.25': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 4.19.8 - '@types/qs': 6.14.0 - '@types/serve-static': 1.15.10 - - '@types/http-errors@2.0.5': {} - - '@types/mime@1.3.5': {} - '@types/minimatch@5.1.2': {} '@types/node@16.9.1': {} @@ -7661,10 +7552,6 @@ snapshots: '@types/prop-types@15.7.15': {} - '@types/qs@6.14.0': {} - - '@types/range-parser@1.2.7': {} - '@types/react-dom@18.2.25': dependencies: '@types/react': 18.2.79 @@ -7684,21 +7571,6 @@ snapshots: '@types/retry@0.12.0': {} - '@types/send@0.17.6': - dependencies: - '@types/mime': 1.3.5 - '@types/node': 20.12.12 - - '@types/send@1.2.1': - dependencies: - '@types/node': 20.12.12 - - '@types/serve-static@1.15.10': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 20.12.12 - '@types/send': 0.17.6 - '@types/ws@8.18.1': dependencies: '@types/node': 20.12.12 @@ -7711,6 +7583,18 @@ snapshots: throttleit: 2.1.0 undici: 5.29.0 + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@webgpu/types@0.1.69': optional: true @@ -7718,11 +7602,6 @@ snapshots: dependencies: event-target-shim: 5.0.1 - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -7746,8 +7625,6 @@ snapshots: arg@5.0.2: {} - array-flatten@1.1.1: {} - async-retry@1.3.3: dependencies: retry: 0.13.1 @@ -7862,23 +7739,6 @@ snapshots: bmp-ts@1.0.9: {} - body-parser@1.20.4: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.14.2 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - botid@1.5.11(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0): optionalDependencies: next: 14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -7958,8 +7818,6 @@ snapshots: dependencies: streamsearch: 1.1.0 - bytes@3.1.2: {} - cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -7967,11 +7825,6 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - camelcase-css@2.0.1: {} caniuse-lite@1.0.30001764: {} @@ -8022,33 +7875,14 @@ snapshots: consola@3.4.2: {} - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 - - content-type@1.0.5: {} - convert-source-map@2.0.0: {} - cookie-signature@1.0.7: {} - - cookie@0.7.2: {} - - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - crelt@1.0.6: {} cssesc@3.0.0: {} csstype@3.2.3: {} - debug@2.6.9: - dependencies: - ms: 2.0.0 - debug@4.4.3: dependencies: ms: 2.1.3 @@ -8059,10 +7893,6 @@ snapshots: denque@2.1.0: {} - depd@2.0.0: {} - - destroy@1.2.0: {} - detect-libc@2.1.2: {} didyoumean@1.2.2: {} @@ -8098,14 +7928,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - ee-first@1.1.1: {} - electron-to-chromium@1.5.267: {} emoji-regex@8.0.0: {} - encodeurl@2.0.0: {} - enhanced-resolve@5.18.4: dependencies: graceful-fs: 4.2.11 @@ -8220,10 +8046,6 @@ snapshots: escalade@3.2.0: {} - escape-html@1.0.3: {} - - etag@1.8.1: {} - event-target-shim@5.0.1: {} eventemitter3@4.0.7: {} @@ -8238,42 +8060,6 @@ snapshots: dependencies: homedir-polyfill: 1.0.3 - express@4.22.1: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.4 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.0.7 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.2 - fresh: 0.5.2 - http-errors: 2.0.1 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.12 - proxy-addr: 2.0.7 - qs: 6.14.2 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 - setprototypeof: 1.2.0 - statuses: 2.0.2 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8309,18 +8095,6 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.2: - dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - find-babel-config@2.1.2: dependencies: json5: 2.2.3 @@ -8347,8 +8121,6 @@ snapshots: forwarded-parse@2.1.2: {} - forwarded@0.2.0: {} - fraction.js@4.3.7: {} framer-motion@12.35.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): @@ -8360,8 +8132,6 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - fresh@0.5.2: {} - fs.realpath@1.0.0: {} fsevents@2.3.2: @@ -8456,18 +8226,6 @@ snapshots: html-entities@2.3.3: {} - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -8487,8 +8245,6 @@ snapshots: inherits@2.0.4: {} - ipaddr.js@1.9.1: {} - is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -8671,18 +8427,12 @@ snapshots: math-intrinsics@1.1.0: {} - media-typer@0.3.0: {} - merge-anything@5.1.7: dependencies: is-what: 4.1.16 - merge-descriptors@1.0.3: {} - merge2@1.4.1: {} - methods@1.1.2: {} - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -8694,8 +8444,6 @@ snapshots: dependencies: mime-db: 1.52.0 - mime@1.6.0: {} - mime@3.0.0: {} minimatch@10.1.1: @@ -8729,8 +8477,6 @@ snapshots: motion-utils@12.29.2: {} - ms@2.0.0: {} - ms@2.1.3: {} mysql2@3.17.4: @@ -8758,8 +8504,6 @@ snapshots: nanostores@1.1.0: {} - negotiator@0.6.3: {} - next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@next/env': 14.2.5 @@ -8853,16 +8597,10 @@ snapshots: object-hash@3.0.0: {} - object-inspect@1.13.4: {} - omggif@1.0.10: {} on-exit-leak-free@2.1.2: {} - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - p-finally@1.0.0: {} p-limit@2.3.0: @@ -8906,8 +8644,6 @@ snapshots: dependencies: entities: 6.0.1 - parseurl@1.3.3: {} - path-exists@3.0.0: {} path-expression-matcher@1.1.3: {} @@ -8919,8 +8655,6 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 - path-to-regexp@0.1.12: {} - pathe@2.0.3: {} peek-readable@4.1.0: {} @@ -9063,30 +8797,12 @@ snapshots: '@types/node': 20.12.12 long: 5.3.2 - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - proxy-from-env@1.1.0: {} - qs@6.14.2: - dependencies: - side-channel: 1.1.0 - queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} - range-parser@1.2.1: {} - - raw-body@2.5.3: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - react-dom@18.2.0(react@18.2.0): dependencies: loose-envify: 1.4.0 @@ -9098,6 +8814,8 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-refresh@0.18.0: {} + react@18.2.0: dependencies: loose-envify: 1.4.0 @@ -9216,43 +8934,14 @@ snapshots: semver@7.7.4: {} - send@0.19.2: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.1 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - seroval-plugins@1.3.3(seroval@1.3.2): dependencies: seroval: 1.3.2 seroval@1.3.2: {} - serve-static@1.16.3: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.2 - transitivePeerDependencies: - - supports-color - set-cookie-parser@2.7.2: {} - setprototypeof@1.2.0: {} - sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -9286,34 +8975,6 @@ snapshots: shell-quote@1.8.3: {} - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - simple-xml-to-json@1.2.3: {} solid-js@1.9.10: @@ -9360,8 +9021,6 @@ snapshots: stage-js@1.0.0-alpha.17: optional: true - statuses@2.0.2: {} - stream-browserify@3.0.0: dependencies: inherits: 2.0.4 @@ -9483,8 +9142,6 @@ snapshots: dependencies: is-number: 7.0.0 - toidentifier@1.0.1: {} - token-types@4.2.1: dependencies: '@tokenizer/token': 0.3.0 @@ -9542,11 +9199,6 @@ snapshots: '@turbo/windows-64': 2.8.20 '@turbo/windows-arm64': 2.8.20 - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - typeid-js@1.2.0: dependencies: uuid: 10.0.0 @@ -9569,8 +9221,6 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 - unpipe@1.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -9583,12 +9233,8 @@ snapshots: util-deprecate@1.0.2: {} - utils-merge@1.0.1: {} - uuid@10.0.0: {} - vary@1.1.2: {} - vite-plugin-solid@2.11.10(solid-js@1.9.9)(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@babel/core': 7.28.6 @@ -9618,6 +9264,22 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.55.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.4.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + tsx: 4.21.0 + yaml: 2.8.2 + vitefu@1.1.1(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: vite: 6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) From 00464ef045be253ae8a456b85aca5d7076e6db11 Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 1 Apr 2026 17:43:18 -0700 Subject: [PATCH 08/10] fix(den-web): stop forcing checkout on uncertain signup routing (#1290) Only send auth and checkout-return flows to /checkout when billing explicitly says the base plan is required. If billing is still loading or temporarily unavailable, fall back to the org dashboard instead of forcing a checkout screen. --- .../(den)/_providers/den-flow-provider.tsx | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx index 171df3475..561c0736c 100644 --- a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx +++ b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx @@ -344,6 +344,14 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { }); } + function shouldRouteToCheckout(summary: BillingSummary | null | undefined) { + if (!summary) { + return false; + } + + return summary.featureGateEnabled && summary.checkoutRequired && !summary.hasActivePlan; + } + function setAuthMode(mode: AuthMode) { setAuthModeState(mode); setVerificationRequired(false); @@ -1057,11 +1065,8 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { persistOnboardingIntent(intent); const summary = await refreshBilling({ includeCheckout: true, quiet: true }); - if (!summary) { - return "checkout" as const; - } - return !summary.featureGateEnabled || summary.hasActivePlan ? ("dashboard" as const) : ("checkout" as const); + return shouldRouteToCheckout(summary) ? ("checkout" as const) : ("dashboard" as const); } async function resolveUserLandingRoute() { @@ -1084,11 +1089,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { billingSummary ?? (billingBusy || billingCheckoutBusy ? null : await refreshBilling({ includeCheckout: true, quiet: true })); - if (!summary) { - return "/checkout"; - } - - return !summary.featureGateEnabled || summary.hasActivePlan ? (dashboardRoute ?? "/") : "/checkout"; + return shouldRouteToCheckout(summary) ? "/checkout" : (dashboardRoute ?? "/"); } async function submitAuth(event: FormEvent) { @@ -1744,15 +1745,10 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { } const summary = await refreshBilling({ includeCheckout: false, quiet: true }); - if (!summary) { - return "/checkout" as const; - } - - if (!summary.featureGateEnabled || summary.hasActivePlan) { - return (await resolveDashboardRoute()) ?? "/"; - } - return "/checkout" as const; + return shouldRouteToCheckout(summary) + ? ("/checkout" as const) + : ((await resolveDashboardRoute()) ?? "/"); } function selectWorker(item: WorkerListItem) { From b68312afb66fbe748bd728bf750393a74165217d Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 1 Apr 2026 17:46:15 -0700 Subject: [PATCH 09/10] fix(den): streamline org invite signup (#1291) --- .../app/(den)/_components/auth-panel.tsx | 370 ++++++++++++++++++ .../app/(den)/_components/auth-screen.tsx | 315 +-------------- .../app/(den)/_components/join-org-screen.tsx | 73 ++-- 3 files changed, 418 insertions(+), 340 deletions(-) create mode 100644 ee/apps/den-web/app/(den)/_components/auth-panel.tsx diff --git a/ee/apps/den-web/app/(den)/_components/auth-panel.tsx b/ee/apps/den-web/app/(den)/_components/auth-panel.tsx new file mode 100644 index 000000000..b775c113b --- /dev/null +++ b/ee/apps/den-web/app/(den)/_components/auth-panel.tsx @@ -0,0 +1,370 @@ +"use client"; + +import { ArrowRight, CheckCircle2 } from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { isSamePathname } from "../_lib/client-route"; +import type { AuthMode } from "../_lib/den-flow"; +import { useDenFlow } from "../_providers/den-flow-provider"; + +function getDesktopGrant(url: string | null) { + if (!url) return null; + try { + const parsed = new URL(url); + const grant = parsed.searchParams.get("grant")?.trim() ?? ""; + return grant || null; + } catch { + return null; + } +} + +function GitHubLogo() { + return ( + + ); +} + +function GoogleLogo() { + return ( + + ); +} + +function SocialButton({ + children, + onClick, + disabled, +}: { + children: ReactNode; + onClick: () => void; + disabled: boolean; +}) { + return ( + + ); +} + +export function AuthPanel({ + panelTitle, + panelCopy, + prefilledEmail, + prefillKey, + initialMode = "sign-up", + lockEmail = false, + hideSocialAuth = false, + hideEmailField = false, + eyebrow = "Account", +}: { + panelTitle?: string; + panelCopy?: string; + prefilledEmail?: string; + prefillKey?: string; + initialMode?: AuthMode; + lockEmail?: boolean; + hideSocialAuth?: boolean; + hideEmailField?: boolean; + eyebrow?: string; +}) { + const router = useRouter(); + const pathname = usePathname(); + const prefillRef = useRef(null); + const [copiedDesktopField, setCopiedDesktopField] = useState<"link" | "code" | null>(null); + const { + authMode, + setAuthMode, + email, + setEmail, + password, + setPassword, + verificationCode, + setVerificationCode, + verificationRequired, + authBusy, + authInfo, + authError, + desktopAuthRequested, + desktopRedirectUrl, + desktopRedirectBusy, + showAuthFeedback, + submitAuth, + submitVerificationCode, + resendVerificationCode, + cancelVerification, + beginSocialAuth, + resolveUserLandingRoute, + } = useDenFlow(); + + const desktopGrant = getDesktopGrant(desktopRedirectUrl); + const resolvedPanelTitle = verificationRequired + ? "Verify your email." + : panelTitle ?? (authMode === "sign-up" ? "Create your Cloud account." : "Sign in to Cloud."); + const resolvedPanelCopy = verificationRequired + ? "Enter the six-digit code from your inbox to finish setup." + : panelCopy ?? ( + authMode === "sign-up" + ? "Start with email, GitHub, or Google." + : "Welcome back. Keep your team setup in sync across Cloud and desktop." + ); + const showLockedEmailSummary = Boolean(prefilledEmail && lockEmail && hideEmailField); + + useEffect(() => { + const key = prefillKey ?? prefilledEmail?.trim() ?? null; + if (!key || prefillRef.current === key) { + return; + } + + prefillRef.current = key; + setAuthMode(initialMode); + setEmail(prefilledEmail?.trim() ?? ""); + setPassword(""); + setVerificationCode(""); + }, [initialMode, prefillKey, prefilledEmail, setAuthMode, setEmail, setPassword, setVerificationCode]); + + const copyDesktopValue = async (field: "link" | "code", value: string | null) => { + if (!value) return; + await navigator.clipboard.writeText(value); + setCopiedDesktopField(field); + window.setTimeout(() => { + setCopiedDesktopField((current) => (current === field ? null : current)); + }, 1800); + }; + + return ( +
+
+

+ {eyebrow} +

+

{resolvedPanelTitle}

+

{resolvedPanelCopy}

+
+ + {desktopAuthRequested ? ( +
+ Finish auth here and we'll send you back into the OpenWork desktop app. + {desktopRedirectUrl ? ( +
+
+ + + {desktopGrant ? ( + + ) : null} +
+

+ If OpenWork does not open automatically, copy the sign-in link or one-time code and paste it into the OpenWork desktop app. +

+
+ ) : null} +
+ ) : null} + +
{ + const next = verificationRequired + ? await submitVerificationCode(event) + : await submitAuth(event); + if (next === "dashboard" || next === "join-org") { + const target = await resolveUserLandingRoute(); + if (target && !isSamePathname(pathname, target)) { + router.replace(target); + } + } else if (next === "checkout" && !isSamePathname(pathname, "/checkout")) { + router.replace("/checkout"); + } + }} + > + {!verificationRequired && !hideSocialAuth ? ( + <> + void beginSocialAuth("github")} + disabled={authBusy || desktopRedirectBusy} + > + + Continue with GitHub + + + void beginSocialAuth("google")} + disabled={authBusy || desktopRedirectBusy} + > + + Continue with Google + + + + + ) : null} + + {showLockedEmailSummary ? ( +
+

+ Invited email +

+

{prefilledEmail}

+
+ ) : null} + + {!hideEmailField ? ( + + ) : null} + + {!verificationRequired ? ( + + ) : ( + + )} + + + + {verificationRequired ? ( +
+ + +
+ ) : null} +
+ + {!verificationRequired ? ( +
+

{authMode === "sign-in" ? "Need an account?" : "Already have an account?"}

+ +
+ ) : null} + + {showAuthFeedback ? ( +
+

{authInfo}

+ {authError ?

{authError}

: null} + {!authError && verificationRequired ? ( +
+ + Waiting for your verification code +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/ee/apps/den-web/app/(den)/_components/auth-screen.tsx b/ee/apps/den-web/app/(den)/_components/auth-screen.tsx index b4667e8d1..f4dbd3de4 100644 --- a/ee/apps/den-web/app/(den)/_components/auth-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/auth-screen.tsx @@ -2,44 +2,11 @@ import { PaperMeshGradient } from "@openwork/ui/react"; import { Dithering } from "@paper-design/shaders-react"; -import { ArrowRight, CheckCircle2 } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import { isSamePathname } from "../_lib/client-route"; import { useDenFlow } from "../_providers/den-flow-provider"; - -function getDesktopGrant(url: string | null) { - if (!url) return null; - try { - const parsed = new URL(url); - const grant = parsed.searchParams.get("grant")?.trim() ?? ""; - return grant || null; - } catch { - return null; - } -} - -function GitHubLogo() { - return ( - - ); -} - -function GoogleLogo() { - return ( - - ); -} +import { AuthPanel } from "./auth-panel"; function FeatureCard({ title, body }: { title: string; body: string }) { return ( @@ -50,27 +17,6 @@ function FeatureCard({ title, body }: { title: string; body: string }) { ); } -function SocialButton({ - children, - onClick, - disabled, -}: { - children: React.ReactNode; - onClick: () => void; - disabled: boolean; -}) { - return ( - - ); -} - function LoadingPanel({ title, body }: { title: string; body: string }) { return (
@@ -92,45 +38,9 @@ export function AuthScreen() { const router = useRouter(); const pathname = usePathname(); const routingRef = useRef(false); - const [copiedDesktopField, setCopiedDesktopField] = useState<"link" | "code" | null>(null); - const { - authMode, - setAuthMode, - email, - setEmail, - password, - setPassword, - verificationCode, - setVerificationCode, - verificationRequired, - authBusy, - authInfo, - authError, - user, - sessionHydrated, - desktopAuthRequested, - desktopRedirectUrl, - desktopRedirectBusy, - showAuthFeedback, - submitAuth, - submitVerificationCode, - resendVerificationCode, - cancelVerification, - beginSocialAuth, - resolveUserLandingRoute, - } = useDenFlow(); - const desktopGrant = getDesktopGrant(desktopRedirectUrl); + const { user, sessionHydrated, desktopAuthRequested, resolveUserLandingRoute } = useDenFlow(); const hasResolvedSession = sessionHydrated && Boolean(user) && !desktopAuthRequested; - const copyDesktopValue = async (field: "link" | "code", value: string | null) => { - if (!value) return; - await navigator.clipboard.writeText(value); - setCopiedDesktopField(field); - window.setTimeout(() => { - setCopiedDesktopField((current) => (current === field ? null : current)); - }, 1800); - }; - useEffect(() => { if (!hasResolvedSession || routingRef.current) { return; @@ -148,18 +58,6 @@ export function AuthScreen() { }); }, [hasResolvedSession, pathname, resolveUserLandingRoute, router]); - const panelTitle = verificationRequired - ? "Verify your email." - : authMode === "sign-up" - ? "Create your Cloud account." - : "Sign in to Cloud."; - - const panelCopy = verificationRequired - ? "Enter the six-digit code from your inbox to finish setup." - : authMode === "sign-up" - ? "Start with email, GitHub, or Google." - : "Welcome back. Keep your team setup in sync across Cloud and desktop."; - if (!sessionHydrated) { return (
@@ -241,212 +139,7 @@ export function AuthScreen() { body="We found your account and are sending you to the right Cloud destination now." /> ) : ( -
-
-

- Account -

-

{panelTitle}

-

{panelCopy}

-
- - {desktopAuthRequested ? ( -
- Finish auth here and we'll send you back into the OpenWork desktop app. - {desktopRedirectUrl ? ( -
-
- - - {desktopGrant ? ( - - ) : null} -
-

- If OpenWork does not open automatically, copy the sign-in link or one-time code and paste it into the OpenWork desktop app. -

-
- ) : null} -
- ) : null} - -
{ - const next = verificationRequired - ? await submitVerificationCode(event) - : await submitAuth(event); - if (next === "dashboard" || next === "join-org") { - const target = await resolveUserLandingRoute(); - if (target && !isSamePathname(pathname, target)) { - router.replace(target); - } - } else if (next === "checkout" && !isSamePathname(pathname, "/checkout")) { - router.replace("/checkout"); - } - }} - > - {!verificationRequired ? ( - <> - void beginSocialAuth("github")} - disabled={authBusy || desktopRedirectBusy} - > - - Continue with GitHub - - - void beginSocialAuth("google")} - disabled={authBusy || desktopRedirectBusy} - > - - Continue with Google - - - - - ) : null} - - - - {!verificationRequired ? ( - - ) : ( - - )} - - - - {verificationRequired ? ( -
- - -
- ) : null} -
- - {!verificationRequired ? ( -
-

{authMode === "sign-in" ? "Need an account?" : "Already have an account?"}

- -
- ) : null} - - {showAuthFeedback ? ( -
-

{authInfo}

- {authError ?

{authError}

: null} - {!authError && verificationRequired ? ( -
- - Waiting for your verification code -
- ) : null} -
- ) : null} -
+ )}
diff --git a/ee/apps/den-web/app/(den)/_components/join-org-screen.tsx b/ee/apps/den-web/app/(den)/_components/join-org-screen.tsx index 34e70e321..e56055add 100644 --- a/ee/apps/den-web/app/(den)/_components/join-org-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/join-org-screen.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { getErrorMessage, requestJson } from "../_lib/den-flow"; import { PENDING_ORG_INVITATION_STORAGE_KEY, @@ -13,6 +13,7 @@ import { type DenInvitationPreview, } from "../_lib/den-org"; import { useDenFlow } from "../_providers/den-flow-provider"; +import { AuthPanel } from "./auth-panel"; function LoadingCard({ title, body }: { title: string; body: string }) { return ( @@ -51,22 +52,6 @@ export function JoinOrgScreen({ invitationId }: { invitationId: string }) { const [joinBusy, setJoinBusy] = useState(false); const [joinError, setJoinError] = useState(null); - const signUpHref = useMemo(() => { - if (!invitationId) { - return "/?mode=sign-up"; - } - - return `/?mode=sign-up&invite=${encodeURIComponent(invitationId)}`; - }, [invitationId]); - - const signInHref = useMemo(() => { - if (!invitationId) { - return "/?mode=sign-in"; - } - - return `/?mode=sign-in&invite=${encodeURIComponent(invitationId)}`; - }, [invitationId]); - const invitedEmailMatches = preview && user ? preview.invitation.email.trim().toLowerCase() === user.email.trim().toLowerCase() : false; @@ -173,6 +158,9 @@ export function JoinOrgScreen({ invitationId }: { invitationId: string }) { async function handleSwitchAccount() { await signOut(); + if (typeof window !== "undefined" && invitationId) { + window.sessionStorage.setItem(PENDING_ORG_INVITATION_STORAGE_KEY, invitationId); + } router.replace(getJoinOrgRoute(invitationId)); } @@ -197,6 +185,45 @@ export function JoinOrgScreen({ invitationId }: { invitationId: string }) { ); } + if (preview.invitation.status === "pending" && !user) { + return ( +
+
+
+

OpenWork Cloud

+
+

You've been invited to

+

{preview.organization.name}

+
+

Role: {formatRoleLabel(preview.invitation.role)}

+
+ +
+
+

Invited email

+

{preview.invitation.email}

+
+

+ Set a password for this invited email to create your OpenWork Cloud account, or switch to sign in if you already use it. +

+
+
+ + +
+ ); + } + const showAcceptAction = preview.invitation.status === "pending" && Boolean(user) && invitedEmailMatches; return ( @@ -228,18 +255,6 @@ export function JoinOrgScreen({ invitationId }: { invitationId: string }) {
- ) : !user ? ( -
-

Create an account or sign in first, then come back here to confirm the invitation.

-
- - Create account to continue - - - Sign in instead - -
-
) : !invitedEmailMatches ? (

From 662bfd093deee1c01a4d83410ec27f537f1bf361 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Wed, 1 Apr 2026 18:11:24 -0700 Subject: [PATCH 10/10] fix(den): align cloud onboarding surfaces --- .../app/(den)/_components/auth-panel.tsx | 155 +++++---- .../app/(den)/_components/auth-screen.tsx | 40 ++- .../app/(den)/_components/checkout-screen.tsx | 294 +++++++++--------- .../app/(den)/_components/join-org-screen.tsx | 219 +++++++------ ee/apps/den-web/app/globals.css | 66 ++++ 5 files changed, 435 insertions(+), 339 deletions(-) diff --git a/ee/apps/den-web/app/(den)/_components/auth-panel.tsx b/ee/apps/den-web/app/(den)/_components/auth-panel.tsx index b775c113b..a4c845cb9 100644 --- a/ee/apps/den-web/app/(den)/_components/auth-panel.tsx +++ b/ee/apps/den-web/app/(den)/_components/auth-panel.tsx @@ -7,6 +7,14 @@ import { isSamePathname } from "../_lib/client-route"; import type { AuthMode } from "../_lib/den-flow"; import { useDenFlow } from "../_providers/den-flow-provider"; +type PanelContent = { + title: string; + copy: string; + submitLabel: string; + togglePrompt?: string; + toggleActionLabel?: string; +}; + function getDesktopGrant(url: string | null) { if (!url) return null; try { @@ -52,7 +60,7 @@ function SocialButton({ return ( ) : null}

-

+

If OpenWork does not open automatically, copy the sign-in link or one-time code and paste it into the OpenWork desktop app.

@@ -195,7 +225,7 @@ export function AuthPanel({ ) : null}
{ const next = verificationRequired ? await submitVerificationCode(event) @@ -228,33 +258,24 @@ export function AuthPanel({ Continue with Google -