wip: black
parent
f3d4dd5099
commit
eaf18d9915
|
|
@ -122,6 +122,7 @@ const ZEN_MODELS = [
|
||||||
]
|
]
|
||||||
const ZEN_BLACK = new sst.Secret("ZEN_BLACK")
|
const ZEN_BLACK = new sst.Secret("ZEN_BLACK")
|
||||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||||
|
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
||||||
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||||
properties: { value: auth.url.apply((url) => url!) },
|
properties: { value: auth.url.apply((url) => url!) },
|
||||||
})
|
})
|
||||||
|
|
@ -177,6 +178,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||||
//VITE_DOCS_URL: web.url.apply((url) => url!),
|
//VITE_DOCS_URL: web.url.apply((url) => url!),
|
||||||
//VITE_API_URL: gateway.url.apply((url) => url!),
|
//VITE_API_URL: gateway.url.apply((url) => url!),
|
||||||
VITE_AUTH_URL: auth.url.apply((url) => url!),
|
VITE_AUTH_URL: auth.url.apply((url) => url!),
|
||||||
|
VITE_STRIPE_PUBLISHABLE_KEY: STRIPE_PUBLISHABLE_KEY.value,
|
||||||
},
|
},
|
||||||
transform: {
|
transform: {
|
||||||
server: {
|
server: {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"dev": "vite dev --host 0.0.0.0",
|
"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",
|
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||||
"start": "vite start"
|
"start": "vite start"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,4 @@ export const config = {
|
||||||
commits: "6,500",
|
commits: "6,500",
|
||||||
monthlyUsers: "650,000",
|
monthlyUsers: "650,000",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Stripe
|
|
||||||
stripe: {
|
|
||||||
publishableKey:
|
|
||||||
"pk_live_51OhXSKEclFNgdHcR9dDfYGwQeKuPfKo0IjA5kWBQIXKMFhE8QFd9bYLdPZC6klRKEgEkxJYSKuZg9U3FKHdLnF4300F9qLqMgP",
|
|
||||||
},
|
|
||||||
} as const
|
} as const
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useAuthSession } from "~/context/auth"
|
||||||
|
|
||||||
export async function GET(input: APIEvent) {
|
export async function GET(input: APIEvent) {
|
||||||
const url = new URL(input.request.url)
|
const url = new URL(input.request.url)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const code = url.searchParams.get("code")
|
const code = url.searchParams.get("code")
|
||||||
if (!code) throw new Error("No code found")
|
if (!code) throw new Error("No code found")
|
||||||
|
|
@ -27,7 +28,7 @@ export async function GET(input: APIEvent) {
|
||||||
current: id,
|
current: id,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return redirect("/auth")
|
return redirect(url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", ""))
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|
@ -3,12 +3,8 @@ import { AuthClient } from "~/context/auth"
|
||||||
|
|
||||||
export async function GET(input: APIEvent) {
|
export async function GET(input: APIEvent) {
|
||||||
const url = new URL(input.request.url)
|
const url = new URL(input.request.url)
|
||||||
// TODO
|
const cont = url.searchParams.get("continue") ?? ""
|
||||||
// input.request.url http://localhost:3001/auth/authorize?continue=/black/subscribe
|
const callbackUrl = new URL(`./callback${cont}`, input.request.url)
|
||||||
const result = await AuthClient.authorize(
|
const result = await AuthClient.authorize(callbackUrl.toString(), "code")
|
||||||
new URL("/callback/subscribe?foo=bar", input.request.url).toString(),
|
return Response.redirect(result.url, 302)
|
||||||
"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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -460,6 +460,39 @@
|
||||||
font-weight: 400;
|
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"] {
|
[data-slot="checkout-form"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -500,6 +533,52 @@
|
||||||
text-align: center;
|
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"] {
|
[data-slot="loading"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { Match, Switch } from "solid-js"
|
import { Match, Switch } from "solid-js"
|
||||||
|
|
||||||
export const plans = [
|
export const plans = [
|
||||||
{ id: "20", amount: 20, multiplier: null },
|
{ id: "20", multiplier: null },
|
||||||
{ id: "100", amount: 100, multiplier: "6x more usage than Black 20" },
|
{ id: "100", multiplier: "6x more usage than Black 20" },
|
||||||
{ id: "200", amount: 200, multiplier: "21x more usage than Black 20" },
|
{ id: "200", multiplier: "21x more usage than Black 20" },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type Plan = (typeof plans)[number]
|
export type Plan = (typeof plans)[number]
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export default function Black() {
|
||||||
<button type="button" onClick={() => setSelected(null)} data-slot="cancel">
|
<button type="button" onClick={() => setSelected(null)} data-slot="cancel">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<a href={`/black/subscribe?plan=${plan().id}`} data-slot="continue">
|
<a href={`/black/subscribe/${plan().id}`} data-slot="continue">
|
||||||
Continue
|
Continue
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `billing` ADD `time_subscription_booked` timestamp(3);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `billing` ADD `subscription_plan` enum('20','100','200');
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -358,6 +358,20 @@
|
||||||
"when": 1767931290031,
|
"when": 1767931290031,
|
||||||
"tag": "0050_bumpy_mephistopheles",
|
"tag": "0050_bumpy_mephistopheles",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 51,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1768341152722,
|
||||||
|
"tag": "0051_jazzy_green_goblin",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 52,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1768343920467,
|
||||||
|
"tag": "0052_aromatic_agent_zero",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { bigint, boolean, index, int, json, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||||
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
||||||
import { workspaceIndexes } from "./workspace.sql"
|
import { workspaceIndexes } from "./workspace.sql"
|
||||||
|
|
||||||
|
|
@ -23,6 +23,8 @@ export const BillingTable = mysqlTable(
|
||||||
timeReloadLockedTill: utc("time_reload_locked_till"),
|
timeReloadLockedTill: utc("time_reload_locked_till"),
|
||||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||||
subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }),
|
subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }),
|
||||||
|
subscriptionPlan: mysqlEnum("subscription_plan", ["20", "100", "200"] as const),
|
||||||
|
timeSubscriptionBooked: utc("time_subscription_booked"),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
...workspaceIndexes(table),
|
...workspaceIndexes(table),
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ declare module "sst" {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
"STRIPE_PUBLISHABLE_KEY": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
"STRIPE_SECRET_KEY": {
|
"STRIPE_SECRET_KEY": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ declare module "sst" {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
"STRIPE_PUBLISHABLE_KEY": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
"STRIPE_SECRET_KEY": {
|
"STRIPE_SECRET_KEY": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ declare module "sst" {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
"STRIPE_PUBLISHABLE_KEY": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
"STRIPE_SECRET_KEY": {
|
"STRIPE_SECRET_KEY": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ declare module "sst" {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
"STRIPE_PUBLISHABLE_KEY": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
"STRIPE_SECRET_KEY": {
|
"STRIPE_SECRET_KEY": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ declare module "sst" {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
"STRIPE_PUBLISHABLE_KEY": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
"STRIPE_SECRET_KEY": {
|
"STRIPE_SECRET_KEY": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,10 @@ declare module "sst" {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
"STRIPE_PUBLISHABLE_KEY": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
"STRIPE_SECRET_KEY": {
|
"STRIPE_SECRET_KEY": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue