wip: black

This commit is contained in:
Frank
2026-01-13 19:15:14 -05:00
parent eaf18d9915
commit 45fa4eda15
3 changed files with 107 additions and 109 deletions

View File

@@ -6,6 +6,7 @@ export const plans = [
{ id: "200", multiplier: "21x more usage than Black 20" }, { id: "200", multiplier: "21x more usage than Black 20" },
] as const ] as const
export type PlanID = (typeof plans)[number]["id"]
export type Plan = (typeof plans)[number] export type Plan = (typeof plans)[number]
export function PlanIcon(props: { plan: string }) { export function PlanIcon(props: { plan: string }) {

View File

@@ -22,7 +22,7 @@ export default function Black() {
<PlanIcon plan={plan.id} /> <PlanIcon plan={plan.id} />
</div> </div>
<p data-slot="price"> <p data-slot="price">
<span data-slot="amount">${plan.amount}</span> <span data-slot="period">per month</span> <span data-slot="amount">${plan.id}</span> <span data-slot="period">per month</span>
<Show when={plan.multiplier}> <Show when={plan.multiplier}>
<span data-slot="multiplier">{plan.multiplier}</span> <span data-slot="multiplier">{plan.multiplier}</span>
</Show> </Show>
@@ -43,7 +43,7 @@ export default function Black() {
<PlanIcon plan={plan().id} /> <PlanIcon plan={plan().id} />
</div> </div>
<p data-slot="price"> <p data-slot="price">
<span data-slot="amount">${plan().amount}</span>{" "} <span data-slot="amount">${plan().id}</span>{" "}
<span data-slot="period">per person billed monthly</span> <span data-slot="period">per person billed monthly</span>
<Show when={plan().multiplier}> <Show when={plan().multiplier}>
<span data-slot="multiplier">{plan().multiplier}</span> <span data-slot="multiplier">{plan().multiplier}</span>

View File

@@ -1,9 +1,9 @@
import { A, action, createAsync, query, redirect, useParams } from "@solidjs/router" import { A, action, createAsync, json, query, redirect, useParams } from "@solidjs/router"
import { Title } from "@solidjs/meta" import { Title } from "@solidjs/meta"
import { createEffect, createSignal, For, Show } from "solid-js" import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js"
import { type Stripe, type PaymentMethod, loadStripe } from "@stripe/stripe-js" import { type Stripe, type PaymentMethod, loadStripe } from "@stripe/stripe-js"
import { Elements, PaymentElement, useStripe, useElements, AddressElement } from "solid-stripe" import { Elements, PaymentElement, useStripe, useElements, AddressElement } from "solid-stripe"
import { PlanIcon, plans } from "../common" import { PlanID, plans } from "../common"
import { getActor, useAuthSession } from "~/context/auth" import { getActor, useAuthSession } from "~/context/auth"
import { withActor } from "~/context/auth.withActor" import { withActor } from "~/context/auth.withActor"
import { Actor } from "@opencode-ai/console-core/actor.js" import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -15,7 +15,7 @@ import { Modal } from "~/component/modal"
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js" import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Billing } from "@opencode-ai/console-core/billing.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 plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<PlanID, (typeof plans)[number]>
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!) const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!)
const getWorkspaces = query(async () => { const getWorkspaces = query(async () => {
@@ -34,6 +34,7 @@ const getWorkspaces = query(async () => {
paymentMethodID: BillingTable.paymentMethodID, paymentMethodID: BillingTable.paymentMethodID,
paymentMethodType: BillingTable.paymentMethodType, paymentMethodType: BillingTable.paymentMethodType,
paymentMethodLast4: BillingTable.paymentMethodLast4, paymentMethodLast4: BillingTable.paymentMethodLast4,
subscriptionID: BillingTable.subscriptionID,
}, },
}) })
.from(UserTable) .from(UserTable)
@@ -50,85 +51,80 @@ const getWorkspaces = query(async () => {
}) })
}, "black.subscribe.workspaces") }, "black.subscribe.workspaces")
const createSetupIntent = action(async (input: { plan: string; workspaceID: string }) => { const createSetupIntent = async (input: { plan: string; workspaceID: string }) => {
"use server" "use server"
const { plan, workspaceID } = input const { plan, workspaceID } = input
if (!plan || !["20", "100", "200"].includes(plan)) { if (!plan || !["20", "100", "200"].includes(plan)) return { error: "Invalid plan" }
return { error: "Invalid plan" } if (!workspaceID) return { error: "Workspace ID is required" }
}
if (!workspaceID) { return withActor(async () => {
return { error: "Workspace ID is required" } const session = await useAuthSession()
} const account = session.data.account?.[session.data.current ?? ""]
const email = account?.email
const actor = await getActor() const customer = await Database.use((tx) =>
if (actor.type === "public") { tx
return { error: "Unauthorized" } .select({
} customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
if (customer?.subscriptionID) {
return { error: "This workspace already has a subscription" }
}
const session = await useAuthSession() let customerID = customer?.customerID
const account = session.data.account?.[session.data.current ?? ""] if (!customerID) {
const email = account?.email const customer = await Billing.stripe().customers.create({
email,
metadata: {
workspaceID,
},
})
customerID = customer.id
}
const stripe = Billing.stripe() const intent = await Billing.stripe().setupIntents.create({
customer: customerID,
let customerID = await Database.use((tx) => payment_method_types: ["card"],
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: { metadata: {
workspaceID, workspaceID,
}, },
}) })
customerID = customer.id
}
const intent = await stripe.setupIntents.create({ return { clientSecret: intent.client_secret ?? undefined }
customer: customerID, }, workspaceID)
payment_method_types: ["card"], }
metadata: {
workspaceID,
},
})
return { clientSecret: intent.client_secret } const bookSubscription = async (input: {
}) workspaceID: string
plan: PlanID
const bookSubscription = action( paymentMethodID: string
async (input: { paymentMethodType: string
workspaceID: string paymentMethodLast4?: string
paymentMethodID: string }) => {
paymentMethodType: string "use server"
paymentMethodLast4?: string return withActor(
}) => { () =>
"use server" Database.use((tx) =>
const actor = await getActor() tx
if (actor.type === "public") { .update(BillingTable)
return { error: "Unauthorized" } .set({
} paymentMethodID: input.paymentMethodID,
paymentMethodType: input.paymentMethodType,
await Database.use((tx) => paymentMethodLast4: input.paymentMethodLast4,
tx subscriptionPlan: input.plan,
.update(BillingTable) timeSubscriptionBooked: new Date(),
.set({ })
paymentMethodID: input.paymentMethodID, .where(eq(BillingTable.workspaceID, input.workspaceID)),
paymentMethodType: input.paymentMethodType, ),
paymentMethodLast4: input.paymentMethodLast4, input.workspaceID,
timeSubscriptionBooked: new Date(), )
}) }
.where(eq(BillingTable.workspaceID, input.workspaceID)),
)
return { success: true }
},
)
interface SuccessData { interface SuccessData {
plan: string plan: string
@@ -136,7 +132,16 @@ interface SuccessData {
paymentMethodLast4?: string paymentMethodLast4?: string
} }
function PaymentSuccess(props: SuccessData) { function Failure(props: { message: string }) {
return (
<div data-slot="failure">
<p data-slot="title">Uh oh, something went wrong</p>
<p data-slot="message">{props.message}</p>
</div>
)
}
function Success(props: SuccessData) {
return ( return (
<div data-slot="success"> <div data-slot="success">
<p data-slot="title">You're on the OpenCode Black waitlist</p> <p data-slot="title">You're on the OpenCode Black waitlist</p>
@@ -169,10 +174,10 @@ function PaymentSuccess(props: SuccessData) {
) )
} }
function PaymentForm(props: { plan: string; workspaceID: string; onSuccess: (data: SuccessData) => void }) { function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data: SuccessData) => void }) {
const stripe = useStripe() const stripe = useStripe()
const elements = useElements() const elements = useElements()
const [error, setError] = createSignal<string | null>(null) const [error, setError] = createSignal<string | undefined>(undefined)
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const handleSubmit = async (e: Event) => { const handleSubmit = async (e: Event) => {
@@ -180,7 +185,7 @@ function PaymentForm(props: { plan: string; workspaceID: string; onSuccess: (dat
if (!stripe() || !elements()) return if (!stripe() || !elements()) return
setLoading(true) setLoading(true)
setError(null) setError(undefined)
const result = await elements()!.submit() const result = await elements()!.submit()
if (result.error) { if (result.error) {
@@ -211,6 +216,7 @@ function PaymentForm(props: { plan: string; workspaceID: string; onSuccess: (dat
await bookSubscription({ await bookSubscription({
workspaceID: props.workspaceID, workspaceID: props.workspaceID,
plan: props.plan,
paymentMethodID: pm.id, paymentMethodID: pm.id,
paymentMethodType: pm.type, paymentMethodType: pm.type,
paymentMethodLast4: pm.card?.last4, paymentMethodLast4: pm.card?.last4,
@@ -243,16 +249,14 @@ function PaymentForm(props: { plan: string; workspaceID: string; onSuccess: (dat
export default function BlackSubscribe() { export default function BlackSubscribe() {
const workspaces = createAsync(() => getWorkspaces()) const workspaces = createAsync(() => getWorkspaces())
const [selectedWorkspace, setSelectedWorkspace] = createSignal<string | null>(null) const [selectedWorkspace, setSelectedWorkspace] = createSignal<string | undefined>(undefined)
const [success, setSuccess] = createSignal<SuccessData | null>(null) const [success, setSuccess] = createSignal<SuccessData | undefined>(undefined)
const [failure, setFailure] = createSignal<string | undefined>(undefined)
const [clientSecret, setClientSecret] = createSignal<string | undefined>(undefined)
const [stripe, setStripe] = createSignal<Stripe | undefined>(undefined)
const params = useParams() const params = useParams()
const plan = params.plan || "200" const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
const planData = plansMap[plan] || plansMap["200"] const plan = planData.id
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 // Resolve stripe promise once
createEffect(() => { createEffect(() => {
@@ -275,27 +279,28 @@ export default function BlackSubscribe() {
if (!id) return if (!id) return
const ws = workspaces()?.find((w) => w.id === id) const ws = workspaces()?.find((w) => w.id === id)
if (ws?.billing.paymentMethodID) { if (ws?.billing?.subscriptionID) {
setFailure("This workspace already has a subscription")
return
}
if (ws?.billing?.paymentMethodID) {
setSuccess({ setSuccess({
plan, plan: planData.id,
paymentMethodType: ws.billing.paymentMethodType!, paymentMethodType: ws.billing.paymentMethodType!,
paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined, paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined,
}) })
return return
} }
setClientSecret(null)
setSetupError(null)
createSetupIntent({ plan, workspaceID: id }) createSetupIntent({ plan, workspaceID: id })
.then((data) => { .then((data) => {
if (data.clientSecret) { if (data.error) {
setFailure(data.error)
} else if ("clientSecret" in data) {
setClientSecret(data.clientSecret) setClientSecret(data.clientSecret)
} else if (data.error) {
setSetupError(data.error)
} }
}) })
.catch(() => setSetupError("Failed to initialize payment")) .catch(() => setFailure("Failed to initialize payment"))
}) })
// Keyboard navigation for workspace picker // Keyboard navigation for workspace picker
@@ -321,15 +326,13 @@ export default function BlackSubscribe() {
<Title>Subscribe to OpenCode Black</Title> <Title>Subscribe to OpenCode Black</Title>
<section data-slot="subscribe-form"> <section data-slot="subscribe-form">
<div data-slot="form-card"> <div data-slot="form-card">
<Show <Switch>
when={success()} <Match when={success()}>{(data) => <Success {...data()} />}</Match>
fallback={ <Match when={failure()}>{(data) => <Failure message={data()} />}</Match>
<Match when={true}>
<> <>
<div data-slot="plan-header"> <div data-slot="plan-header">
<p data-slot="title">Subscribe to OpenCode Black</p> <p data-slot="title">Subscribe to OpenCode Black</p>
<div data-slot="icon">
<PlanIcon plan={plan} />
</div>
<p data-slot="price"> <p data-slot="price">
<span data-slot="amount">${planData.id}</span> <span data-slot="period">per month</span> <span data-slot="amount">${planData.id}</span> <span data-slot="period">per month</span>
<Show when={planData.multiplier}> <Show when={planData.multiplier}>
@@ -340,10 +343,6 @@ export default function BlackSubscribe() {
<div data-slot="divider" /> <div data-slot="divider" />
<p data-slot="section-title">Payment method</p> <p data-slot="section-title">Payment method</p>
<Show when={setupError()}>
<p data-slot="error">{setupError()}</p>
</Show>
<Show <Show
when={clientSecret() && selectedWorkspace() && stripe()} when={clientSecret() && selectedWorkspace() && stripe()}
fallback={ fallback={
@@ -387,14 +386,12 @@ export default function BlackSubscribe() {
}, },
}} }}
> >
<PaymentForm plan={plan} workspaceID={selectedWorkspace()!} onSuccess={setSuccess} /> <IntentForm plan={plan} workspaceID={selectedWorkspace()!} onSuccess={setSuccess} />
</Elements> </Elements>
</Show> </Show>
</> </>
} </Match>
> </Switch>
{(data) => <PaymentSuccess {...data()} />}
</Show>
</div> </div>
{/* Workspace picker modal */} {/* Workspace picker modal */}