From cda2af2589ddef9265ca2db379ecd4ab556f6be8 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 23 Feb 2026 23:01:23 -0500 Subject: wip: zen lite --- packages/console/app/src/i18n/ar.ts | 1 + packages/console/app/src/i18n/br.ts | 1 + packages/console/app/src/i18n/da.ts | 1 + packages/console/app/src/i18n/de.ts | 1 + packages/console/app/src/i18n/en.ts | 1 + packages/console/app/src/i18n/es.ts | 1 + packages/console/app/src/i18n/fr.ts | 1 + packages/console/app/src/i18n/it.ts | 1 + packages/console/app/src/i18n/ja.ts | 1 + packages/console/app/src/i18n/ko.ts | 1 + packages/console/app/src/i18n/no.ts | 1 + packages/console/app/src/i18n/pl.ts | 1 + packages/console/app/src/i18n/ru.ts | 1 + packages/console/app/src/i18n/th.ts | 1 + packages/console/app/src/i18n/tr.ts | 1 + packages/console/app/src/i18n/zh.ts | 1 + packages/console/app/src/i18n/zht.ts | 1 + packages/console/app/src/routes/black.css | 13 + .../app/src/routes/black/_subscribe/[plan].tsx | 477 +++++++++++++++++++++ packages/console/app/src/routes/black/index.tsx | 122 +++--- .../app/src/routes/black/subscribe/[plan].tsx | 477 --------------------- 21 files changed, 571 insertions(+), 535 deletions(-) create mode 100644 packages/console/app/src/routes/black/_subscribe/[plan].tsx delete mode 100644 packages/console/app/src/routes/black/subscribe/[plan].tsx (limited to 'packages/console') diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index b595af2e5..5520df87f 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -243,6 +243,7 @@ export const dict = { "black.hero.title": "الوصول إلى أفضل نماذج البرمجة في العالم", "black.hero.subtitle": "بما في ذلك Claude، GPT، Gemini والمزيد", "black.title": "OpenCode Black | الأسعار", + "black.paused": "التسجيل في خطة Black متوقف مؤقتًا.", "black.plan.icon20": "خطة Black 20", "black.plan.icon100": "خطة Black 100", "black.plan.icon200": "خطة Black 200", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index ad30d05dc..d03522683 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -247,6 +247,7 @@ export const dict = { "black.hero.title": "Acesse os melhores modelos de codificação do mundo", "black.hero.subtitle": "Incluindo Claude, GPT, Gemini e mais", "black.title": "OpenCode Black | Preços", + "black.paused": "A inscrição no plano Black está temporariamente pausada.", "black.plan.icon20": "Plano Black 20", "black.plan.icon100": "Plano Black 100", "black.plan.icon200": "Plano Black 200", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index bca212229..94826c438 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -245,6 +245,7 @@ export const dict = { "black.hero.title": "Få adgang til verdens bedste kodningsmodeller", "black.hero.subtitle": "Inklusive Claude, GPT, Gemini og mere", "black.title": "OpenCode Black | Priser", + "black.paused": "Black-plantilmelding er midlertidigt sat på pause.", "black.plan.icon20": "Black 20-plan", "black.plan.icon100": "Black 100-plan", "black.plan.icon200": "Black 200-plan", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index c54b124cd..cfa207066 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -247,6 +247,7 @@ export const dict = { "black.hero.title": "Zugriff auf die weltweit besten Coding-Modelle", "black.hero.subtitle": "Einschließlich Claude, GPT, Gemini und mehr", "black.title": "OpenCode Black | Preise", + "black.paused": "Die Anmeldung zum Black-Plan ist vorübergehend pausiert.", "black.plan.icon20": "Black 20 Plan", "black.plan.icon100": "Black 100 Plan", "black.plan.icon200": "Black 200 Plan", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 7ee737e66..8e096dd5c 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -239,6 +239,7 @@ export const dict = { "black.hero.title": "Access all the world's best coding models", "black.hero.subtitle": "Including Claude, GPT, Gemini and more", "black.title": "OpenCode Black | Pricing", + "black.paused": "Black plan enrollment is temporarily paused.", "black.plan.icon20": "Black 20 plan", "black.plan.icon100": "Black 100 plan", "black.plan.icon200": "Black 200 plan", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 946469ca3..c8579462e 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -248,6 +248,7 @@ export const dict = { "black.hero.title": "Accede a los mejores modelos de codificación del mundo", "black.hero.subtitle": "Incluyendo Claude, GPT, Gemini y más", "black.title": "OpenCode Black | Precios", + "black.paused": "La inscripción al plan Black está temporalmente pausada.", "black.plan.icon20": "Plan Black 20", "black.plan.icon100": "Plan Black 100", "black.plan.icon200": "Plan Black 200", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index e653a6c74..ccb0a8cc6 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -251,6 +251,7 @@ export const dict = { "black.hero.title": "Accédez aux meilleurs modèles de code au monde", "black.hero.subtitle": "Y compris Claude, GPT, Gemini et plus", "black.title": "OpenCode Black | Tarification", + "black.paused": "L'inscription au plan Black est temporairement suspendue.", "black.plan.icon20": "Forfait Black 20", "black.plan.icon100": "Forfait Black 100", "black.plan.icon200": "Forfait Black 200", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 5d1c84a2d..21162f699 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -246,6 +246,7 @@ export const dict = { "black.hero.title": "Accedi ai migliori modelli di coding al mondo", "black.hero.subtitle": "Inclusi Claude, GPT, Gemini e altri", "black.title": "OpenCode Black | Prezzi", + "black.paused": "L'iscrizione al piano Black è temporaneamente sospesa.", "black.plan.icon20": "Piano Black 20", "black.plan.icon100": "Piano Black 100", "black.plan.icon200": "Piano Black 200", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index dbc2554a7..1f2746f2b 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -244,6 +244,7 @@ export const dict = { "black.hero.title": "世界最高峰のコーディングモデルすべてにアクセス", "black.hero.subtitle": "Claude、GPT、Gemini などを含む", "black.title": "OpenCode Black | 料金", + "black.paused": "Blackプランの登録は一時的に停止しています。", "black.plan.icon20": "Black 20 プラン", "black.plan.icon100": "Black 100 プラン", "black.plan.icon200": "Black 200 プラン", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 4c0882f3b..5a5f9bc71 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -241,6 +241,7 @@ export const dict = { "black.hero.title": "세계 최고의 코딩 모델에 액세스하세요", "black.hero.subtitle": "Claude, GPT, Gemini 등 포함", "black.title": "OpenCode Black | 가격", + "black.paused": "Black 플랜 등록이 일시적으로 중단되었습니다.", "black.plan.icon20": "Black 20 플랜", "black.plan.icon100": "Black 100 플랜", "black.plan.icon200": "Black 200 플랜", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index a87a2493a..abd9ba086 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -245,6 +245,7 @@ export const dict = { "black.hero.title": "Få tilgang til verdens beste kodemodeller", "black.hero.subtitle": "Inkludert Claude, GPT, Gemini og mer", "black.title": "OpenCode Black | Priser", + "black.paused": "Black-planregistrering er midlertidig satt på pause.", "black.plan.icon20": "Black 20-plan", "black.plan.icon100": "Black 100-plan", "black.plan.icon200": "Black 200-plan", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 0466f6410..eeb7ce2b0 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -246,6 +246,7 @@ export const dict = { "black.hero.title": "Dostęp do najlepszych na świecie modeli kodujących", "black.hero.subtitle": "W tym Claude, GPT, Gemini i inne", "black.title": "OpenCode Black | Cennik", + "black.paused": "Rejestracja planu Black jest tymczasowo wstrzymana.", "black.plan.icon20": "Plan Black 20", "black.plan.icon100": "Plan Black 100", "black.plan.icon200": "Plan Black 200", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 86058638a..1f752fd59 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -249,6 +249,7 @@ export const dict = { "black.hero.title": "Доступ к лучшим моделям для кодинга в мире", "black.hero.subtitle": "Включая Claude, GPT, Gemini и другие", "black.title": "OpenCode Black | Цены", + "black.paused": "Регистрация на план Black временно приостановлена.", "black.plan.icon20": "План Black 20", "black.plan.icon100": "План Black 100", "black.plan.icon200": "План Black 200", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 4646183dd..8196bd9c1 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -244,6 +244,7 @@ export const dict = { "black.hero.title": "เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก", "black.hero.subtitle": "รวมถึง Claude, GPT, Gemini และอื่นๆ อีกมากมาย", "black.title": "OpenCode Black | ราคา", + "black.paused": "การสมัครแผน Black หยุดชั่วคราว", "black.plan.icon20": "แผน Black 20", "black.plan.icon100": "แผน Black 100", "black.plan.icon200": "แผน Black 200", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 9ff33dfee..7ee8b6a75 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -247,6 +247,7 @@ export const dict = { "black.hero.title": "Dünyanın en iyi kodlama modellerine erişin", "black.hero.subtitle": "Claude, GPT, Gemini ve daha fazlası dahil", "black.title": "OpenCode Black | Fiyatlandırma", + "black.paused": "Black plan kaydı geçici olarak duraklatıldı.", "black.plan.icon20": "Black 20 planı", "black.plan.icon100": "Black 100 planı", "black.plan.icon200": "Black 200 planı", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index cf3e6b0f9..e5fae6f3f 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -234,6 +234,7 @@ export const dict = { "black.hero.title": "访问全球顶尖编程模型", "black.hero.subtitle": "包括 Claude, GPT, Gemini 等", "black.title": "OpenCode Black | 定价", + "black.paused": "Black 订阅已暂时暂停注册。", "black.plan.icon20": "Black 20 计划", "black.plan.icon100": "Black 100 计划", "black.plan.icon200": "Black 200 计划", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index 8adc9e16e..79582b0ce 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -234,6 +234,7 @@ export const dict = { "black.hero.title": "存取全球最佳編碼模型", "black.hero.subtitle": "包括 Claude、GPT、Gemini 等", "black.title": "OpenCode Black | 定價", + "black.paused": "Black 訂閱暫時暫停註冊。", "black.plan.icon20": "Black 20 方案", "black.plan.icon100": "Black 100 方案", "black.plan.icon200": "Black 200 方案", diff --git a/packages/console/app/src/routes/black.css b/packages/console/app/src/routes/black.css index 66bffea59..4031a78fc 100644 --- a/packages/console/app/src/routes/black.css +++ b/packages/console/app/src/routes/black.css @@ -335,6 +335,19 @@ } } + [data-slot="paused"] { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.59); + font-size: 18px; + font-style: normal; + font-weight: 400; + line-height: 160%; + padding: 120px 20px; + } + [data-slot="pricing-card"] { display: flex; flex-direction: column; 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..644d87d9b --- /dev/null +++ b/packages/console/app/src/routes/black/_subscribe/[plan].tsx @@ -0,0 +1,477 @@ +import { A, createAsync, query, redirect, useParams } from "@solidjs/router" +import { Title } from "@solidjs/meta" +import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js" +import { type Stripe, type PaymentMethod, loadStripe } from "@stripe/stripe-js" +import { Elements, PaymentElement, useStripe, useElements, AddressElement } from "solid-stripe" +import { PlanID, 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" +import { useI18n } from "~/context/i18n" +import { useLanguage } from "~/context/language" +import { formError } from "~/lib/form-error" + +const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record +const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!) + +const getWorkspaces = query(async (plan: string) => { + "use server" + const actor = await getActor() + if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe/" + plan) + 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, + subscriptionID: BillingTable.subscriptionID, + timeSubscriptionBooked: BillingTable.timeSubscriptionBooked, + }, + }) + .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 = async (input: { plan: string; workspaceID: string }) => { + "use server" + const { plan, workspaceID } = input + + if (!plan || !["20", "100", "200"].includes(plan)) return { error: formError.invalidPlan } + if (!workspaceID) return { error: formError.workspaceRequired } + + return withActor(async () => { + const session = await useAuthSession() + const account = session.data.account?.[session.data.current ?? ""] + const email = account?.email + + const customer = await Database.use((tx) => + tx + .select({ + customerID: BillingTable.customerID, + subscriptionID: BillingTable.subscriptionID, + }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, workspaceID)) + .then((rows) => rows[0]), + ) + if (customer?.subscriptionID) { + return { error: formError.alreadySubscribed } + } + + let customerID = customer?.customerID + if (!customerID) { + const customer = await Billing.stripe().customers.create({ + email, + metadata: { + workspaceID, + }, + }) + customerID = customer.id + await Database.use((tx) => + tx + .update(BillingTable) + .set({ + customerID, + }) + .where(eq(BillingTable.workspaceID, workspaceID)), + ) + } + + const intent = await Billing.stripe().setupIntents.create({ + customer: customerID, + payment_method_types: ["card"], + metadata: { + workspaceID, + }, + }) + + return { clientSecret: intent.client_secret ?? undefined } + }, workspaceID) +} + +const bookSubscription = async (input: { + workspaceID: string + plan: PlanID + paymentMethodID: string + paymentMethodType: string + paymentMethodLast4?: string +}) => { + "use server" + return withActor( + () => + Database.use((tx) => + tx + .update(BillingTable) + .set({ + paymentMethodID: input.paymentMethodID, + paymentMethodType: input.paymentMethodType, + paymentMethodLast4: input.paymentMethodLast4, + subscriptionPlan: input.plan, + timeSubscriptionBooked: new Date(), + }) + .where(eq(BillingTable.workspaceID, input.workspaceID)), + ), + input.workspaceID, + ) +} + +interface SuccessData { + plan: string + paymentMethodType: string + paymentMethodLast4?: string +} + +function Failure(props: { message: string }) { + const i18n = useI18n() + + return ( +
+

+ {i18n.t("black.subscribe.failurePrefix")} {props.message} +

+
+ ) +} + +function Success(props: SuccessData) { + const i18n = useI18n() + + return ( +
+

{i18n.t("black.subscribe.success.title")}

+
+
+
{i18n.t("black.subscribe.success.subscriptionPlan")}
+
{i18n.t("black.subscribe.success.planName", { plan: props.plan })}
+
+
+
{i18n.t("black.subscribe.success.amount")}
+
{i18n.t("black.subscribe.success.amountValue", { plan: props.plan })}
+
+
+
{i18n.t("black.subscribe.success.paymentMethod")}
+
+ {props.paymentMethodType}}> + + {props.paymentMethodType} - {props.paymentMethodLast4} + + +
+
+
+
{i18n.t("black.subscribe.success.dateJoined")}
+
{new Date().toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
+
+
+

{i18n.t("black.subscribe.success.chargeNotice")}

+
+ ) +} + +function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data: SuccessData) => void }) { + const i18n = useI18n() + const stripe = useStripe() + const elements = useElements() + const [error, setError] = createSignal(undefined) + const [loading, setLoading] = createSignal(false) + + const handleSubmit = async (e: Event) => { + e.preventDefault() + if (!stripe() || !elements()) return + + setLoading(true) + setError(undefined) + + const result = await elements()!.submit() + if (result.error) { + setError(result.error.message ?? i18n.t("black.subscribe.error.generic")) + setLoading(false) + return + } + + const { error: confirmError, setupIntent } = await stripe()!.confirmSetup({ + elements: elements()!, + confirmParams: { + expand: ["payment_method"], + payment_method_data: { + allow_redisplay: "always", + }, + }, + redirect: "if_required", + }) + + if (confirmError) { + setError(confirmError.message ?? i18n.t("black.subscribe.error.generic")) + setLoading(false) + return + } + + if (setupIntent?.status === "succeeded") { + const pm = setupIntent.payment_method as PaymentMethod + + await bookSubscription({ + workspaceID: props.workspaceID, + plan: props.plan, + 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 ( +
+ + + +

{error()}

+
+ +

{i18n.t("black.subscribe.form.chargeNotice")}

+ + ) +} + +export default function BlackSubscribe() { + const params = useParams() + const i18n = useI18n() + const language = useLanguage() + const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"] + const plan = planData.id + + const workspaces = createAsync(() => getWorkspaces(plan)) + const [selectedWorkspace, setSelectedWorkspace] = createSignal(undefined) + const [success, setSuccess] = createSignal(undefined) + const [failure, setFailure] = createSignal(undefined) + const [clientSecret, setClientSecret] = createSignal(undefined) + const [stripe, setStripe] = createSignal(undefined) + + const formatError = (error: string) => { + if (error === formError.invalidPlan) return i18n.t("black.subscribe.error.invalidPlan") + if (error === formError.workspaceRequired) return i18n.t("black.subscribe.error.workspaceRequired") + if (error === formError.alreadySubscribed) return i18n.t("black.subscribe.error.alreadySubscribed") + if (error === "Invalid plan") return i18n.t("black.subscribe.error.invalidPlan") + if (error === "Workspace ID is required") return i18n.t("black.subscribe.error.workspaceRequired") + if (error === "This workspace already has a subscription") return i18n.t("black.subscribe.error.alreadySubscribed") + return error + } + + // 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(async () => { + const id = selectedWorkspace() + if (!id) return + + const ws = workspaces()?.find((w) => w.id === id) + if (ws?.billing?.subscriptionID) { + setFailure(i18n.t("black.subscribe.error.alreadySubscribed")) + return + } + if (ws?.billing?.paymentMethodID) { + if (!ws?.billing?.timeSubscriptionBooked) { + await bookSubscription({ + workspaceID: id, + plan: planData.id, + paymentMethodID: ws.billing.paymentMethodID!, + paymentMethodType: ws.billing.paymentMethodType!, + paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined, + }) + } + setSuccess({ + plan: planData.id, + paymentMethodType: ws.billing.paymentMethodType!, + paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined, + }) + return + } + + const result = await createSetupIntent({ plan, workspaceID: id }) + if (result.error) { + setFailure(formatError(result.error)) + } else if ("clientSecret" in result) { + setClientSecret(result.clientSecret) + } + }) + + // 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 ( + <> + {i18n.t("black.subscribe.title")} +
+
+ + {(data) => } + {(data) => } + + <> +
+

{i18n.t("black.subscribe.title")}

+

+ ${planData.id}{" "} + {i18n.t("black.price.perMonth")} + + {(multiplier) => {i18n.t(multiplier())}} + +

+
+
+

{i18n.t("black.subscribe.paymentMethod")}

+ + +

+ {selectedWorkspace() + ? i18n.t("black.subscribe.loadingPaymentForm") + : i18n.t("black.subscribe.selectWorkspaceToContinue")} +

+
+ } + > + + + + + +
+
+
+ + {/* Workspace picker modal */} + {}} title={i18n.t("black.workspace.selectPlan")}> +
+
    { + if (e.key === "Enter" && active()) { + handleSelectWorkspace(active()!) + } else { + onKeyDown(e) + } + }} + > + + {(workspace) => ( +
  • setActive(workspace.id)} + onClick={() => handleSelectWorkspace(workspace.id)} + > + [*] + {workspace.name || workspace.slug} +
  • + )} +
    +
+
+
+

+ {i18n.t("black.finePrint.beforeTerms")} ·{" "} + {i18n.t("black.finePrint.terms")} +

+
+ + ) +} diff --git a/packages/console/app/src/routes/black/index.tsx b/packages/console/app/src/routes/black/index.tsx index 72b196f57..382832e8f 100644 --- a/packages/console/app/src/routes/black/index.tsx +++ b/packages/console/app/src/routes/black/index.tsx @@ -5,6 +5,8 @@ import { PlanIcon, plans } from "./common" import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" +const paused = true + export default function Black() { const [params] = useSearchParams() const i18n = useI18n() @@ -42,72 +44,76 @@ export default function Black() { <> {i18n.t("black.title")}
- - -
- - {(plan) => ( - + )} + +
+
+ + {(plan) => ( +
+
- +

- ${plan.id}{" "} - {i18n.t("black.price.perMonth")} - + ${plan().id}{" "} + {i18n.t("black.price.perPersonBilledMonthly")} + {(multiplier) => {i18n.t(multiplier())}}

- - )} - -
- - - {(plan) => ( -
-
-
- -
-

- ${plan().id}{" "} - {i18n.t("black.price.perPersonBilledMonthly")} - - {(multiplier) => {i18n.t(multiplier())}} - -

-
    -
  • {i18n.t("black.terms.1")}
  • -
  • {i18n.t("black.terms.2")}
  • -
  • {i18n.t("black.terms.3")}
  • -
  • {i18n.t("black.terms.4")}
  • -
  • {i18n.t("black.terms.5")}
  • -
  • {i18n.t("black.terms.6")}
  • -
  • {i18n.t("black.terms.7")}
  • -
-
- - - {i18n.t("black.action.continue")} - +
    +
  • {i18n.t("black.terms.1")}
  • +
  • {i18n.t("black.terms.2")}
  • +
  • {i18n.t("black.terms.3")}
  • +
  • {i18n.t("black.terms.4")}
  • +
  • {i18n.t("black.terms.5")}
  • +
  • {i18n.t("black.terms.6")}
  • +
  • {i18n.t("black.terms.7")}
  • +
+
+ + + {i18n.t("black.action.continue")} + +
-
- )} -
- -

- {i18n.t("black.finePrint.beforeTerms")} ·{" "} - {i18n.t("black.finePrint.terms")} -

+ )} + + + + +

+ {i18n.t("black.finePrint.beforeTerms")} ·{" "} + {i18n.t("black.finePrint.terms")} +

+
) diff --git a/packages/console/app/src/routes/black/subscribe/[plan].tsx b/packages/console/app/src/routes/black/subscribe/[plan].tsx deleted file mode 100644 index 644d87d9b..000000000 --- a/packages/console/app/src/routes/black/subscribe/[plan].tsx +++ /dev/null @@ -1,477 +0,0 @@ -import { A, createAsync, query, redirect, useParams } from "@solidjs/router" -import { Title } from "@solidjs/meta" -import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js" -import { type Stripe, type PaymentMethod, loadStripe } from "@stripe/stripe-js" -import { Elements, PaymentElement, useStripe, useElements, AddressElement } from "solid-stripe" -import { PlanID, 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" -import { useI18n } from "~/context/i18n" -import { useLanguage } from "~/context/language" -import { formError } from "~/lib/form-error" - -const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record -const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!) - -const getWorkspaces = query(async (plan: string) => { - "use server" - const actor = await getActor() - if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe/" + plan) - 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, - subscriptionID: BillingTable.subscriptionID, - timeSubscriptionBooked: BillingTable.timeSubscriptionBooked, - }, - }) - .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 = async (input: { plan: string; workspaceID: string }) => { - "use server" - const { plan, workspaceID } = input - - if (!plan || !["20", "100", "200"].includes(plan)) return { error: formError.invalidPlan } - if (!workspaceID) return { error: formError.workspaceRequired } - - return withActor(async () => { - const session = await useAuthSession() - const account = session.data.account?.[session.data.current ?? ""] - const email = account?.email - - const customer = await Database.use((tx) => - tx - .select({ - customerID: BillingTable.customerID, - subscriptionID: BillingTable.subscriptionID, - }) - .from(BillingTable) - .where(eq(BillingTable.workspaceID, workspaceID)) - .then((rows) => rows[0]), - ) - if (customer?.subscriptionID) { - return { error: formError.alreadySubscribed } - } - - let customerID = customer?.customerID - if (!customerID) { - const customer = await Billing.stripe().customers.create({ - email, - metadata: { - workspaceID, - }, - }) - customerID = customer.id - await Database.use((tx) => - tx - .update(BillingTable) - .set({ - customerID, - }) - .where(eq(BillingTable.workspaceID, workspaceID)), - ) - } - - const intent = await Billing.stripe().setupIntents.create({ - customer: customerID, - payment_method_types: ["card"], - metadata: { - workspaceID, - }, - }) - - return { clientSecret: intent.client_secret ?? undefined } - }, workspaceID) -} - -const bookSubscription = async (input: { - workspaceID: string - plan: PlanID - paymentMethodID: string - paymentMethodType: string - paymentMethodLast4?: string -}) => { - "use server" - return withActor( - () => - Database.use((tx) => - tx - .update(BillingTable) - .set({ - paymentMethodID: input.paymentMethodID, - paymentMethodType: input.paymentMethodType, - paymentMethodLast4: input.paymentMethodLast4, - subscriptionPlan: input.plan, - timeSubscriptionBooked: new Date(), - }) - .where(eq(BillingTable.workspaceID, input.workspaceID)), - ), - input.workspaceID, - ) -} - -interface SuccessData { - plan: string - paymentMethodType: string - paymentMethodLast4?: string -} - -function Failure(props: { message: string }) { - const i18n = useI18n() - - return ( -
-

- {i18n.t("black.subscribe.failurePrefix")} {props.message} -

-
- ) -} - -function Success(props: SuccessData) { - const i18n = useI18n() - - return ( -
-

{i18n.t("black.subscribe.success.title")}

-
-
-
{i18n.t("black.subscribe.success.subscriptionPlan")}
-
{i18n.t("black.subscribe.success.planName", { plan: props.plan })}
-
-
-
{i18n.t("black.subscribe.success.amount")}
-
{i18n.t("black.subscribe.success.amountValue", { plan: props.plan })}
-
-
-
{i18n.t("black.subscribe.success.paymentMethod")}
-
- {props.paymentMethodType}}> - - {props.paymentMethodType} - {props.paymentMethodLast4} - - -
-
-
-
{i18n.t("black.subscribe.success.dateJoined")}
-
{new Date().toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
-
-
-

{i18n.t("black.subscribe.success.chargeNotice")}

-
- ) -} - -function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data: SuccessData) => void }) { - const i18n = useI18n() - const stripe = useStripe() - const elements = useElements() - const [error, setError] = createSignal(undefined) - const [loading, setLoading] = createSignal(false) - - const handleSubmit = async (e: Event) => { - e.preventDefault() - if (!stripe() || !elements()) return - - setLoading(true) - setError(undefined) - - const result = await elements()!.submit() - if (result.error) { - setError(result.error.message ?? i18n.t("black.subscribe.error.generic")) - setLoading(false) - return - } - - const { error: confirmError, setupIntent } = await stripe()!.confirmSetup({ - elements: elements()!, - confirmParams: { - expand: ["payment_method"], - payment_method_data: { - allow_redisplay: "always", - }, - }, - redirect: "if_required", - }) - - if (confirmError) { - setError(confirmError.message ?? i18n.t("black.subscribe.error.generic")) - setLoading(false) - return - } - - if (setupIntent?.status === "succeeded") { - const pm = setupIntent.payment_method as PaymentMethod - - await bookSubscription({ - workspaceID: props.workspaceID, - plan: props.plan, - 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 ( -
- - - -

{error()}

-
- -

{i18n.t("black.subscribe.form.chargeNotice")}

- - ) -} - -export default function BlackSubscribe() { - const params = useParams() - const i18n = useI18n() - const language = useLanguage() - const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"] - const plan = planData.id - - const workspaces = createAsync(() => getWorkspaces(plan)) - const [selectedWorkspace, setSelectedWorkspace] = createSignal(undefined) - const [success, setSuccess] = createSignal(undefined) - const [failure, setFailure] = createSignal(undefined) - const [clientSecret, setClientSecret] = createSignal(undefined) - const [stripe, setStripe] = createSignal(undefined) - - const formatError = (error: string) => { - if (error === formError.invalidPlan) return i18n.t("black.subscribe.error.invalidPlan") - if (error === formError.workspaceRequired) return i18n.t("black.subscribe.error.workspaceRequired") - if (error === formError.alreadySubscribed) return i18n.t("black.subscribe.error.alreadySubscribed") - if (error === "Invalid plan") return i18n.t("black.subscribe.error.invalidPlan") - if (error === "Workspace ID is required") return i18n.t("black.subscribe.error.workspaceRequired") - if (error === "This workspace already has a subscription") return i18n.t("black.subscribe.error.alreadySubscribed") - return error - } - - // 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(async () => { - const id = selectedWorkspace() - if (!id) return - - const ws = workspaces()?.find((w) => w.id === id) - if (ws?.billing?.subscriptionID) { - setFailure(i18n.t("black.subscribe.error.alreadySubscribed")) - return - } - if (ws?.billing?.paymentMethodID) { - if (!ws?.billing?.timeSubscriptionBooked) { - await bookSubscription({ - workspaceID: id, - plan: planData.id, - paymentMethodID: ws.billing.paymentMethodID!, - paymentMethodType: ws.billing.paymentMethodType!, - paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined, - }) - } - setSuccess({ - plan: planData.id, - paymentMethodType: ws.billing.paymentMethodType!, - paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined, - }) - return - } - - const result = await createSetupIntent({ plan, workspaceID: id }) - if (result.error) { - setFailure(formatError(result.error)) - } else if ("clientSecret" in result) { - setClientSecret(result.clientSecret) - } - }) - - // 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 ( - <> - {i18n.t("black.subscribe.title")} -
-
- - {(data) => } - {(data) => } - - <> -
-

{i18n.t("black.subscribe.title")}

-

- ${planData.id}{" "} - {i18n.t("black.price.perMonth")} - - {(multiplier) => {i18n.t(multiplier())}} - -

-
-
-

{i18n.t("black.subscribe.paymentMethod")}

- - -

- {selectedWorkspace() - ? i18n.t("black.subscribe.loadingPaymentForm") - : i18n.t("black.subscribe.selectWorkspaceToContinue")} -

-
- } - > - - - - - -
-
-
- - {/* Workspace picker modal */} - {}} title={i18n.t("black.workspace.selectPlan")}> -
-
    { - if (e.key === "Enter" && active()) { - handleSelectWorkspace(active()!) - } else { - onKeyDown(e) - } - }} - > - - {(workspace) => ( -
  • setActive(workspace.id)} - onClick={() => handleSelectWorkspace(workspace.id)} - > - [*] - {workspace.name || workspace.slug} -
  • - )} -
    -
-
-
-

- {i18n.t("black.finePrint.beforeTerms")} ·{" "} - {i18n.t("black.finePrint.terms")} -

-
- - ) -} -- cgit v1.2.3