sync
parent
3c5a256f0f
commit
f24251f89e
6
bun.lock
6
bun.lock
|
|
@ -84,10 +84,12 @@
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@solidjs/start": "catalog:",
|
"@solidjs/start": "catalog:",
|
||||||
|
"@stripe/stripe-js": "8.6.1",
|
||||||
"chart.js": "4.5.1",
|
"chart.js": "4.5.1",
|
||||||
"nitro": "3.0.1-alpha.1",
|
"nitro": "3.0.1-alpha.1",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
|
"solid-stripe": "0.8.1",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
|
|
@ -1652,6 +1654,8 @@
|
||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||||
|
|
||||||
|
"@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="],
|
||||||
|
|
||||||
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
|
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="],
|
||||||
|
|
@ -3528,6 +3532,8 @@
|
||||||
|
|
||||||
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
||||||
|
|
||||||
|
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
|
||||||
|
|
||||||
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
|
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,12 @@
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@solidjs/start": "catalog:",
|
"@solidjs/start": "catalog:",
|
||||||
|
"@stripe/stripe-js": "8.6.1",
|
||||||
"chart.js": "4.5.1",
|
"chart.js": "4.5.1",
|
||||||
"nitro": "3.0.1-alpha.1",
|
"nitro": "3.0.1-alpha.1",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
|
"solid-stripe": "0.8.1",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -26,4 +26,10 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,13 @@ import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { AuthClient } from "~/context/auth"
|
import { AuthClient } from "~/context/auth"
|
||||||
|
|
||||||
export async function GET(input: APIEvent) {
|
export async function GET(input: APIEvent) {
|
||||||
const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code")
|
const url = new URL(input.request.url)
|
||||||
return Response.redirect(result.url, 302)
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ 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)
|
||||||
|
console.log("=C=", input.request.url)
|
||||||
|
throw new Error("Not implemented")
|
||||||
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")
|
||||||
|
|
|
||||||
|
|
@ -36,24 +36,73 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
||||||
[data-slot="hero-black"] {
|
[data-slot="hero"] {
|
||||||
margin-top: 110px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 40px;
|
||||||
|
padding: 0 20px;
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
margin-top: 150px;
|
margin-top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: 18px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 160%;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: rgba(255, 255, 255, 0.59);
|
||||||
|
font-size: 15px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 160%;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="hero-black"] {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding: 0 20px;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
margin-top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 540px;
|
||||||
|
height: auto;
|
||||||
|
filter: drop-shadow(0 0 20px rgba(255, 255, 255, 0.1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="cta"] {
|
[data-slot="cta"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 32px;
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: -18px;
|
margin-top: -40px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
margin-top: 40px;
|
margin-top: -20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="heading"] {
|
[data-slot="heading"] {
|
||||||
|
|
@ -328,6 +377,211 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subscribe page styles */
|
||||||
|
[data-slot="subscribe-form"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: -18px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 540px;
|
||||||
|
padding: 0 20px;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="form-card"] {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="plan-header"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="title"] {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="icon"] {
|
||||||
|
color: rgba(255, 255, 255, 0.59);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="price"] {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="amount"] {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="period"] {
|
||||||
|
color: rgba(255, 255, 255, 0.59);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="multiplier"] {
|
||||||
|
color: rgba(255, 255, 255, 0.39);
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "·";
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="divider"] {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.17);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="section-title"] {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="checkout-form"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="error"] {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="submit-button"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #000;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="charge-notice"] {
|
||||||
|
color: #d4a500;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="loading"] {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: rgba(255, 255, 255, 0.59);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="fine-print"] {
|
||||||
|
color: rgba(255, 255, 255, 0.39);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(255, 255, 255, 0.39);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="workspace-picker"] {
|
||||||
|
[data-slot="workspace-list"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
align-self: stretch;
|
||||||
|
outline: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 240px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="workspace-item"] {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
padding: 8px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
align-self: stretch;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
[data-slot="selected-icon"] {
|
||||||
|
visibility: hidden;
|
||||||
|
color: rgba(255, 255, 255, 0.39);
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 160%;
|
||||||
|
}
|
||||||
|
|
||||||
|
span:last-child {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 160%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&[data-active="true"] {
|
||||||
|
background: #161616;
|
||||||
|
|
||||||
|
[data-slot="selected-icon"] {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="footer"] {
|
[data-component="footer"] {
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,42 @@
|
||||||
|
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" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type Plan = (typeof plans)[number]
|
||||||
|
|
||||||
|
export function PlanIcon(props: { plan: string }) {
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Match when={props.plan === "20"}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.plan === "100"}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.plan === "200"}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="2" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||||
|
<rect x="10" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||||
|
<rect x="18" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||||
|
<rect x="2" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||||
|
<rect x="10" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||||
|
<rect x="18" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||||
|
<rect x="2" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||||
|
<rect x="10" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||||
|
<rect x="18" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||||
|
</svg>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,244 @@
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue