diff options
Diffstat (limited to 'packages/console/app')
| -rw-r--r-- | packages/console/app/package.json | 2 | ||||
| -rw-r--r-- | packages/console/app/src/config.ts | 6 | ||||
| -rw-r--r-- | packages/console/app/src/routes/api/black/setup-intent.ts | 30 | ||||
| -rw-r--r-- | packages/console/app/src/routes/auth/[...callback].ts (renamed from packages/console/app/src/routes/auth/callback.ts) | 3 | ||||
| -rw-r--r-- | packages/console/app/src/routes/auth/authorize.ts | 12 | ||||
| -rw-r--r-- | packages/console/app/src/routes/black.css | 79 | ||||
| -rw-r--r-- | packages/console/app/src/routes/black/common.tsx | 6 | ||||
| -rw-r--r-- | packages/console/app/src/routes/black/index.tsx | 2 | ||||
| -rw-r--r-- | packages/console/app/src/routes/black/subscribe.tsx | 244 | ||||
| -rw-r--r-- | packages/console/app/src/routes/black/subscribe/[plan].tsx | 437 |
10 files changed, 527 insertions, 294 deletions
diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 23171daac..b70fedead 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -6,7 +6,7 @@ "scripts": { "typecheck": "tsgo --noEmit", "dev": "vite dev --host 0.0.0.0", - "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev", + "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev", "build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json", "start": "vite start" }, diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index 4396e5117..4ebb2c71a 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -26,10 +26,4 @@ export const config = { commits: "6,500", monthlyUsers: "650,000", }, - - // Stripe - stripe: { - publishableKey: - "pk_live_51OhXSKEclFNgdHcR9dDfYGwQeKuPfKo0IjA5kWBQIXKMFhE8QFd9bYLdPZC6klRKEgEkxJYSKuZg9U3FKHdLnF4300F9qLqMgP", - }, } as const diff --git a/packages/console/app/src/routes/api/black/setup-intent.ts b/packages/console/app/src/routes/api/black/setup-intent.ts deleted file mode 100644 index eb5571616..000000000 --- a/packages/console/app/src/routes/api/black/setup-intent.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { APIEvent } from "@solidjs/start/server" -import { Billing } from "@opencode-ai/console-core/billing.js" - -export async function POST(event: APIEvent) { - try { - const body = (await event.request.json()) as { plan: string } - const plan = body.plan - - if (!plan || !["20", "100", "200"].includes(plan)) { - return Response.json({ error: "Invalid plan" }, { status: 400 }) - } - - const amount = parseInt(plan) * 100 - - const intent = await Billing.stripe().setupIntents.create({ - payment_method_types: ["card"], - metadata: { - plan, - amount: amount.toString(), - }, - }) - - return Response.json({ - clientSecret: intent.client_secret, - }) - } catch (error) { - console.error("Error creating setup intent:", error) - return Response.json({ error: "Internal server error" }, { status: 500 }) - } -} diff --git a/packages/console/app/src/routes/auth/callback.ts b/packages/console/app/src/routes/auth/[...callback].ts index 9b7296791..36a9c5194 100644 --- a/packages/console/app/src/routes/auth/callback.ts +++ b/packages/console/app/src/routes/auth/[...callback].ts @@ -5,6 +5,7 @@ import { useAuthSession } from "~/context/auth" export async function GET(input: APIEvent) { const url = new URL(input.request.url) + try { const code = url.searchParams.get("code") if (!code) throw new Error("No code found") @@ -27,7 +28,7 @@ export async function GET(input: APIEvent) { current: id, } }) - return redirect("/auth") + return redirect(url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "")) } catch (e: any) { return new Response( JSON.stringify({ diff --git a/packages/console/app/src/routes/auth/authorize.ts b/packages/console/app/src/routes/auth/authorize.ts index 6be94b146..0f0651ae3 100644 --- a/packages/console/app/src/routes/auth/authorize.ts +++ b/packages/console/app/src/routes/auth/authorize.ts @@ -3,12 +3,8 @@ import { AuthClient } from "~/context/auth" export async function GET(input: APIEvent) { const url = new URL(input.request.url) - // TODO - // input.request.url http://localhost:3001/auth/authorize?continue=/black/subscribe - const result = await AuthClient.authorize( - new URL("/callback/subscribe?foo=bar", input.request.url).toString(), - "code", - ) - // result.url https://auth.frank.dev.opencode.ai/authorize?client_id=app&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2Fauth%2Fcallback&response_type=code&state=0d3fc834-bcbc-42dc-83ab-c25c2c43c7e3 - return Response.redirect(result.url + "&continue=" + url.searchParams.get("continue"), 302) + const cont = url.searchParams.get("continue") ?? "" + const callbackUrl = new URL(`./callback${cont}`, input.request.url) + const result = await AuthClient.authorize(callbackUrl.toString(), "code") + return Response.redirect(result.url, 302) } diff --git a/packages/console/app/src/routes/black.css b/packages/console/app/src/routes/black.css index dfb188ed0..72bf7a657 100644 --- a/packages/console/app/src/routes/black.css +++ b/packages/console/app/src/routes/black.css @@ -460,6 +460,39 @@ font-weight: 400; } + [data-slot="tax-id-section"] { + display: flex; + flex-direction: column; + gap: 8px; + + [data-slot="label"] { + color: rgba(255, 255, 255, 0.59); + font-size: 14px; + } + + [data-slot="input"] { + width: 100%; + height: 44px; + padding: 0 12px; + background: #1a1a1a; + border: 1px solid rgba(255, 255, 255, 0.17); + border-radius: 4px; + color: #ffffff; + font-family: var(--font-mono); + font-size: 14px; + outline: none; + transition: border-color 0.15s ease; + + &::placeholder { + color: rgba(255, 255, 255, 0.39); + } + + &:focus { + border-color: rgba(255, 255, 255, 0.35); + } + } + } + [data-slot="checkout-form"] { display: flex; flex-direction: column; @@ -500,6 +533,52 @@ text-align: center; } + [data-slot="success"] { + display: flex; + flex-direction: column; + gap: 24px; + + [data-slot="title"] { + color: rgba(255, 255, 255, 0.92); + font-size: 18px; + font-weight: 400; + margin: 0; + } + + [data-slot="details"] { + display: flex; + flex-direction: column; + gap: 16px; + + > div { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + } + + dt { + color: rgba(255, 255, 255, 0.59); + font-size: 14px; + font-weight: 400; + } + + dd { + color: rgba(255, 255, 255, 0.92); + font-size: 14px; + font-weight: 400; + margin: 0; + text-align: right; + } + } + + [data-slot="charge-notice"] { + color: #d4a500; + font-size: 14px; + text-align: left; + } + } + [data-slot="loading"] { display: flex; justify-content: center; diff --git a/packages/console/app/src/routes/black/common.tsx b/packages/console/app/src/routes/black/common.tsx index c1184bd20..dd643bbc5 100644 --- a/packages/console/app/src/routes/black/common.tsx +++ b/packages/console/app/src/routes/black/common.tsx @@ -1,9 +1,9 @@ import { Match, Switch } from "solid-js" export const plans = [ - { id: "20", amount: 20, multiplier: null }, - { id: "100", amount: 100, multiplier: "6x more usage than Black 20" }, - { id: "200", amount: 200, multiplier: "21x more usage than Black 20" }, + { id: "20", multiplier: null }, + { id: "100", multiplier: "6x more usage than Black 20" }, + { id: "200", multiplier: "21x more usage than Black 20" }, ] as const export type Plan = (typeof plans)[number] diff --git a/packages/console/app/src/routes/black/index.tsx b/packages/console/app/src/routes/black/index.tsx index 2b452c812..57d27a793 100644 --- a/packages/console/app/src/routes/black/index.tsx +++ b/packages/console/app/src/routes/black/index.tsx @@ -62,7 +62,7 @@ export default function Black() { <button type="button" onClick={() => setSelected(null)} data-slot="cancel"> Cancel </button> - <a href={`/black/subscribe?plan=${plan().id}`} data-slot="continue"> + <a href={`/black/subscribe/${plan().id}`} data-slot="continue"> Continue </a> </div> diff --git a/packages/console/app/src/routes/black/subscribe.tsx b/packages/console/app/src/routes/black/subscribe.tsx deleted file mode 100644 index 00ce19ef6..000000000 --- a/packages/console/app/src/routes/black/subscribe.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { A, createAsync, query, redirect, useSearchParams } from "@solidjs/router" -import { Title } from "@solidjs/meta" -import { createEffect, createSignal, For, onMount, Show } from "solid-js" -import { loadStripe } from "@stripe/stripe-js" -import { Elements, PaymentElement, useStripe, useElements } from "solid-stripe" -import { config } from "~/config" -import { PlanIcon, plans } from "./common" -import { getActor } from "~/context/auth" -import { withActor } from "~/context/auth.withActor" -import { Actor } from "@opencode-ai/console-core/actor.js" -import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js" -import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" -import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" -import { createList } from "solid-list" -import { Modal } from "~/component/modal" - -const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<string, (typeof plans)[number]> - -const getWorkspaces = query(async () => { - "use server" - const actor = await getActor() - if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe") - return withActor(async () => { - return Database.use((tx) => - tx - .select({ - id: WorkspaceTable.id, - name: WorkspaceTable.name, - slug: WorkspaceTable.slug, - }) - .from(UserTable) - .innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id)) - .where( - and( - eq(UserTable.accountID, Actor.account()), - isNull(WorkspaceTable.timeDeleted), - isNull(UserTable.timeDeleted), - ), - ), - ) - }) -}, "black.subscribe.workspaces") - -function CheckoutForm(props: { plan: string; amount: number }) { - const stripe = useStripe() - const elements = useElements() - const [error, setError] = createSignal<string | null>(null) - const [loading, setLoading] = createSignal(false) - - const handleSubmit = async (e: Event) => { - e.preventDefault() - if (!stripe() || !elements()) return - - setLoading(true) - setError(null) - - const result = await elements()!.submit() - if (result.error) { - setError(result.error.message ?? "An error occurred") - setLoading(false) - return - } - - const { error: confirmError } = await stripe()!.confirmSetup({ - elements: elements()!, - confirmParams: { - return_url: `${window.location.origin}/black/success?plan=${props.plan}`, - }, - }) - - if (confirmError) { - setError(confirmError.message ?? "An error occurred") - } - setLoading(false) - } - - return ( - <form onSubmit={handleSubmit} data-slot="checkout-form"> - <PaymentElement /> - <Show when={error()}> - <p data-slot="error">{error()}</p> - </Show> - <button type="submit" disabled={loading() || !stripe() || !elements()} data-slot="submit-button"> - {loading() ? "Processing..." : `Subscribe $${props.amount}`} - </button> - <p data-slot="charge-notice">You will only be charged when your subscription is activated</p> - </form> - ) -} - -export default function BlackSubscribe() { - const workspaces = createAsync(() => getWorkspaces()) - const [selectedWorkspace, setSelectedWorkspace] = createSignal<string | null>(null) - - const [params] = useSearchParams() - const plan = (params.plan as string) || "200" - const planData = plansMap[plan] || plansMap["200"] - - const [clientSecret, setClientSecret] = createSignal<string | null>(null) - const [stripePromise] = createSignal(loadStripe(config.stripe.publishableKey)) - - // Auto-select if only one workspace - createEffect(() => { - const ws = workspaces() - if (ws?.length === 1 && !selectedWorkspace()) { - setSelectedWorkspace(ws[0].id) - } - }) - - // Keyboard navigation for workspace picker - const { active, setActive, onKeyDown } = createList({ - items: () => workspaces()?.map((w) => w.id) ?? [], - initialActive: null, - }) - - const handleSelectWorkspace = (id: string) => { - setSelectedWorkspace(id) - } - - onMount(async () => { - const response = await fetch("/api/black/setup-intent", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ plan }), - }) - const data = await response.json() - if (data.clientSecret) { - setClientSecret(data.clientSecret) - } - }) - - let listRef: HTMLUListElement | undefined - - // Show workspace picker if multiple workspaces and none selected - const showWorkspacePicker = () => { - const ws = workspaces() - return ws && ws.length > 1 && !selectedWorkspace() - } - - return ( - <> - <Title>Subscribe to OpenCode Black</Title> - <section data-slot="subscribe-form"> - <div data-slot="form-card"> - <div data-slot="plan-header"> - <p data-slot="title">Subscribe to OpenCode Black</p> - <div data-slot="icon"> - <PlanIcon plan={plan} /> - </div> - <p data-slot="price"> - <span data-slot="amount">${planData.amount}</span> <span data-slot="period">per month</span> - <Show when={planData.multiplier}> - <span data-slot="multiplier">{planData.multiplier}</span> - </Show> - </p> - </div> - <div data-slot="divider" /> - <p data-slot="section-title">Add payment method</p> - <Show - when={clientSecret()} - fallback={ - <div data-slot="loading"> - <p>Loading payment form...</p> - </div> - } - > - <Elements - stripe={stripePromise()} - options={{ - clientSecret: clientSecret()!, - appearance: { - theme: "night", - variables: { - colorPrimary: "#ffffff", - colorBackground: "#1a1a1a", - colorText: "#ffffff", - colorTextSecondary: "#999999", - colorDanger: "#ff6b6b", - fontFamily: "JetBrains Mono, monospace", - borderRadius: "4px", - spacingUnit: "4px", - }, - rules: { - ".Input": { - backgroundColor: "#1a1a1a", - border: "1px solid rgba(255, 255, 255, 0.17)", - color: "#ffffff", - }, - ".Input:focus": { - borderColor: "rgba(255, 255, 255, 0.35)", - boxShadow: "none", - }, - ".Label": { - color: "rgba(255, 255, 255, 0.59)", - fontSize: "14px", - marginBottom: "8px", - }, - }, - }, - }} - > - <CheckoutForm plan={plan} amount={planData.amount} /> - </Elements> - </Show> - </div> - - {/* Workspace picker modal */} - <Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title="Select a workspace for this plan"> - <div data-slot="workspace-picker"> - <ul - ref={listRef} - data-slot="workspace-list" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" && active()) { - handleSelectWorkspace(active()!) - } else { - onKeyDown(e) - } - }} - > - <For each={workspaces()}> - {(workspace) => ( - <li - data-slot="workspace-item" - data-active={active() === workspace.id} - onMouseEnter={() => setActive(workspace.id)} - onClick={() => handleSelectWorkspace(workspace.id)} - > - <span data-slot="selected-icon">[*]</span> - <span>{workspace.name || workspace.slug}</span> - </li> - )} - </For> - </ul> - </div> - </Modal> - <p data-slot="fine-print"> - Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A> - </p> - </section> - </> - ) -} diff --git a/packages/console/app/src/routes/black/subscribe/[plan].tsx b/packages/console/app/src/routes/black/subscribe/[plan].tsx new file mode 100644 index 000000000..e0992a1e2 --- /dev/null +++ b/packages/console/app/src/routes/black/subscribe/[plan].tsx @@ -0,0 +1,437 @@ +import { A, action, createAsync, query, redirect, useParams } from "@solidjs/router" +import { Title } from "@solidjs/meta" +import { createEffect, createSignal, For, Show } from "solid-js" +import { type Stripe, type PaymentMethod, loadStripe } from "@stripe/stripe-js" +import { Elements, PaymentElement, useStripe, useElements, AddressElement } from "solid-stripe" +import { PlanIcon, plans } from "../common" +import { getActor, useAuthSession } from "~/context/auth" +import { withActor } from "~/context/auth.withActor" +import { Actor } from "@opencode-ai/console-core/actor.js" +import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js" +import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" +import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" +import { createList } from "solid-list" +import { Modal } from "~/component/modal" +import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js" +import { Billing } from "@opencode-ai/console-core/billing.js" + +const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<string, (typeof plans)[number]> +const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!) + +const getWorkspaces = query(async () => { + "use server" + const actor = await getActor() + if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe") + return withActor(async () => { + return Database.use((tx) => + tx + .select({ + id: WorkspaceTable.id, + name: WorkspaceTable.name, + slug: WorkspaceTable.slug, + billing: { + customerID: BillingTable.customerID, + paymentMethodID: BillingTable.paymentMethodID, + paymentMethodType: BillingTable.paymentMethodType, + paymentMethodLast4: BillingTable.paymentMethodLast4, + }, + }) + .from(UserTable) + .innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id)) + .innerJoin(BillingTable, eq(WorkspaceTable.id, BillingTable.workspaceID)) + .where( + and( + eq(UserTable.accountID, Actor.account()), + isNull(WorkspaceTable.timeDeleted), + isNull(UserTable.timeDeleted), + ), + ), + ) + }) +}, "black.subscribe.workspaces") + +const createSetupIntent = action(async (input: { plan: string; workspaceID: string }) => { + "use server" + const { plan, workspaceID } = input + + if (!plan || !["20", "100", "200"].includes(plan)) { + return { error: "Invalid plan" } + } + + if (!workspaceID) { + return { error: "Workspace ID is required" } + } + + const actor = await getActor() + if (actor.type === "public") { + return { error: "Unauthorized" } + } + + const session = await useAuthSession() + const account = session.data.account?.[session.data.current ?? ""] + const email = account?.email + + const stripe = Billing.stripe() + + let customerID = await Database.use((tx) => + tx + .select({ customerID: BillingTable.customerID }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, workspaceID)) + .then((rows) => rows[0].customerID), + ) + if (!customerID) { + const customer = await stripe.customers.create({ + email, + metadata: { + workspaceID, + }, + }) + customerID = customer.id + } + + const intent = await stripe.setupIntents.create({ + customer: customerID, + payment_method_types: ["card"], + metadata: { + workspaceID, + }, + }) + + return { clientSecret: intent.client_secret } +}) + +const bookSubscription = action( + async (input: { + workspaceID: string + paymentMethodID: string + paymentMethodType: string + paymentMethodLast4?: string + }) => { + "use server" + const actor = await getActor() + if (actor.type === "public") { + return { error: "Unauthorized" } + } + + await Database.use((tx) => + tx + .update(BillingTable) + .set({ + paymentMethodID: input.paymentMethodID, + paymentMethodType: input.paymentMethodType, + paymentMethodLast4: input.paymentMethodLast4, + timeSubscriptionBooked: new Date(), + }) + .where(eq(BillingTable.workspaceID, input.workspaceID)), + ) + + return { success: true } + }, +) + +interface SuccessData { + plan: string + paymentMethodType: string + paymentMethodLast4?: string +} + +function PaymentSuccess(props: SuccessData) { + return ( + <div data-slot="success"> + <p data-slot="title">You're on the OpenCode Black waitlist</p> + <dl data-slot="details"> + <div> + <dt>Subscription plan</dt> + <dd>OpenCode Black {props.plan}</dd> + </div> + <div> + <dt>Amount</dt> + <dd>${props.plan} per month</dd> + </div> + <div> + <dt>Payment method</dt> + <dd> + <Show when={props.paymentMethodLast4} fallback={<span>{props.paymentMethodType}</span>}> + <span> + {props.paymentMethodType} - {props.paymentMethodLast4} + </span> + </Show> + </dd> + </div> + <div> + <dt>Date joined</dt> + <dd>{new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</dd> + </div> + </dl> + <p data-slot="charge-notice">Your card will be charged when your subscription is activated</p> + </div> + ) +} + +function PaymentForm(props: { plan: string; workspaceID: string; onSuccess: (data: SuccessData) => void }) { + const stripe = useStripe() + const elements = useElements() + const [error, setError] = createSignal<string | null>(null) + const [loading, setLoading] = createSignal(false) + + const handleSubmit = async (e: Event) => { + e.preventDefault() + if (!stripe() || !elements()) return + + setLoading(true) + setError(null) + + const result = await elements()!.submit() + if (result.error) { + setError(result.error.message ?? "An error occurred") + setLoading(false) + return + } + + const { error: confirmError, setupIntent } = await stripe()!.confirmSetup({ + elements: elements()!, + confirmParams: { + expand: ["setup_intent.payment_method"], + payment_method_data: { + allow_redisplay: "always", + }, + }, + redirect: "if_required", + }) + + if (confirmError) { + setError(confirmError.message ?? "An error occurred") + setLoading(false) + return + } + + if (setupIntent?.status === "succeeded") { + const pm = setupIntent.payment_method as PaymentMethod + + await bookSubscription({ + workspaceID: props.workspaceID, + paymentMethodID: pm.id, + paymentMethodType: pm.type, + paymentMethodLast4: pm.card?.last4, + }) + + props.onSuccess({ + plan: props.plan, + paymentMethodType: pm.type, + paymentMethodLast4: pm.card?.last4, + }) + } + + setLoading(false) + } + + return ( + <form onSubmit={handleSubmit} data-slot="checkout-form"> + <PaymentElement /> + <AddressElement options={{ mode: "billing" }} /> + <Show when={error()}> + <p data-slot="error">{error()}</p> + </Show> + <button type="submit" disabled={loading() || !stripe() || !elements()} data-slot="submit-button"> + {loading() ? "Processing..." : `Subscribe $${props.plan}`} + </button> + <p data-slot="charge-notice">You will only be charged when your subscription is activated</p> + </form> + ) +} + +export default function BlackSubscribe() { + const workspaces = createAsync(() => getWorkspaces()) + const [selectedWorkspace, setSelectedWorkspace] = createSignal<string | null>(null) + const [success, setSuccess] = createSignal<SuccessData | null>(null) + + const params = useParams() + const plan = params.plan || "200" + const planData = plansMap[plan] || plansMap["200"] + + const [clientSecret, setClientSecret] = createSignal<string | null>(null) + const [setupError, setSetupError] = createSignal<string | null>(null) + const [stripe, setStripe] = createSignal<Stripe | null>(null) + + // Resolve stripe promise once + createEffect(() => { + stripePromise.then((s) => { + if (s) setStripe(s) + }) + }) + + // Auto-select if only one workspace + createEffect(() => { + const ws = workspaces() + if (ws?.length === 1 && !selectedWorkspace()) { + setSelectedWorkspace(ws[0].id) + } + }) + + // Fetch setup intent when workspace is selected (unless workspace already has payment method) + createEffect(() => { + const id = selectedWorkspace() + if (!id) return + + const ws = workspaces()?.find((w) => w.id === id) + if (ws?.billing.paymentMethodID) { + setSuccess({ + plan, + paymentMethodType: ws.billing.paymentMethodType!, + paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined, + }) + return + } + + setClientSecret(null) + setSetupError(null) + + createSetupIntent({ plan, workspaceID: id }) + .then((data) => { + if (data.clientSecret) { + setClientSecret(data.clientSecret) + } else if (data.error) { + setSetupError(data.error) + } + }) + .catch(() => setSetupError("Failed to initialize payment")) + }) + + // Keyboard navigation for workspace picker + const { active, setActive, onKeyDown } = createList({ + items: () => workspaces()?.map((w) => w.id) ?? [], + initialActive: null, + }) + + const handleSelectWorkspace = (id: string) => { + setSelectedWorkspace(id) + } + + let listRef: HTMLUListElement | undefined + + // Show workspace picker if multiple workspaces and none selected + const showWorkspacePicker = () => { + const ws = workspaces() + return ws && ws.length > 1 && !selectedWorkspace() + } + + return ( + <> + <Title>Subscribe to OpenCode Black</Title> + <section data-slot="subscribe-form"> + <div data-slot="form-card"> + <Show + when={success()} + fallback={ + <> + <div data-slot="plan-header"> + <p data-slot="title">Subscribe to OpenCode Black</p> + <div data-slot="icon"> + <PlanIcon plan={plan} /> + </div> + <p data-slot="price"> + <span data-slot="amount">${planData.id}</span> <span data-slot="period">per month</span> + <Show when={planData.multiplier}> + <span data-slot="multiplier">{planData.multiplier}</span> + </Show> + </p> + </div> + <div data-slot="divider" /> + <p data-slot="section-title">Payment method</p> + + <Show when={setupError()}> + <p data-slot="error">{setupError()}</p> + </Show> + + <Show + when={clientSecret() && selectedWorkspace() && stripe()} + fallback={ + <div data-slot="loading"> + <p>{selectedWorkspace() ? "Loading payment form..." : "Select a workspace to continue"}</p> + </div> + } + > + <Elements + stripe={stripe()!} + options={{ + clientSecret: clientSecret()!, + appearance: { + theme: "night", + variables: { + colorPrimary: "#ffffff", + colorBackground: "#1a1a1a", + colorText: "#ffffff", + colorTextSecondary: "#999999", + colorDanger: "#ff6b6b", + fontFamily: "JetBrains Mono, monospace", + borderRadius: "4px", + spacingUnit: "4px", + }, + rules: { + ".Input": { + backgroundColor: "#1a1a1a", + border: "1px solid rgba(255, 255, 255, 0.17)", + color: "#ffffff", + }, + ".Input:focus": { + borderColor: "rgba(255, 255, 255, 0.35)", + boxShadow: "none", + }, + ".Label": { + color: "rgba(255, 255, 255, 0.59)", + fontSize: "14px", + marginBottom: "8px", + }, + }, + }, + }} + > + <PaymentForm plan={plan} workspaceID={selectedWorkspace()!} onSuccess={setSuccess} /> + </Elements> + </Show> + </> + } + > + {(data) => <PaymentSuccess {...data()} />} + </Show> + </div> + + {/* Workspace picker modal */} + <Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title="Select a workspace for this plan"> + <div data-slot="workspace-picker"> + <ul + ref={listRef} + data-slot="workspace-list" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" && active()) { + handleSelectWorkspace(active()!) + } else { + onKeyDown(e) + } + }} + > + <For each={workspaces()}> + {(workspace) => ( + <li + data-slot="workspace-item" + data-active={active() === workspace.id} + onMouseEnter={() => setActive(workspace.id)} + onClick={() => handleSelectWorkspace(workspace.id)} + > + <span data-slot="selected-icon">[*]</span> + <span>{workspace.name || workspace.slug}</span> + </li> + )} + </For> + </ul> + </div> + </Modal> + <p data-slot="fine-print"> + Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A> + </p> + </section> + </> + ) +} |
