@jonsoc/console-app 1.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.opencode/agent/css.md +149 -0
  2. package/README.md +32 -0
  3. package/package.json +49 -0
  4. package/public/apple-touch-icon-v3.png +1 -0
  5. package/public/apple-touch-icon.png +1 -0
  6. package/public/email +1 -0
  7. package/public/favicon-96x96-v3.png +1 -0
  8. package/public/favicon-96x96.png +1 -0
  9. package/public/favicon-v3.ico +1 -0
  10. package/public/favicon-v3.svg +1 -0
  11. package/public/favicon.ico +1 -0
  12. package/public/favicon.svg +1 -0
  13. package/public/opencode-brand-assets.zip +0 -0
  14. package/public/robots.txt +6 -0
  15. package/public/site.webmanifest +1 -0
  16. package/public/social-share-black.png +1 -0
  17. package/public/social-share-zen.png +1 -0
  18. package/public/social-share.png +1 -0
  19. package/public/theme.json +182 -0
  20. package/public/web-app-manifest-192x192.png +1 -0
  21. package/public/web-app-manifest-512x512.png +1 -0
  22. package/script/generate-sitemap.ts +103 -0
  23. package/src/app.css +1 -0
  24. package/src/app.tsx +27 -0
  25. package/src/asset/black/hero.png +0 -0
  26. package/src/asset/brand/opencode-brand-assets.zip +0 -0
  27. package/src/asset/brand/opencode-logo-dark.png +0 -0
  28. package/src/asset/brand/opencode-logo-dark.svg +16 -0
  29. package/src/asset/brand/opencode-logo-light.png +0 -0
  30. package/src/asset/brand/opencode-logo-light.svg +16 -0
  31. package/src/asset/brand/opencode-wordmark-dark.png +0 -0
  32. package/src/asset/brand/opencode-wordmark-dark.svg +30 -0
  33. package/src/asset/brand/opencode-wordmark-light.png +0 -0
  34. package/src/asset/brand/opencode-wordmark-light.svg +30 -0
  35. package/src/asset/brand/opencode-wordmark-simple-dark.png +0 -0
  36. package/src/asset/brand/opencode-wordmark-simple-dark.svg +22 -0
  37. package/src/asset/brand/opencode-wordmark-simple-light.png +0 -0
  38. package/src/asset/brand/opencode-wordmark-simple-light.svg +22 -0
  39. package/src/asset/brand/preview-opencode-dark.png +0 -0
  40. package/src/asset/brand/preview-opencode-logo-dark.png +0 -0
  41. package/src/asset/brand/preview-opencode-logo-light.png +0 -0
  42. package/src/asset/brand/preview-opencode-wordmark-dark.png +0 -0
  43. package/src/asset/brand/preview-opencode-wordmark-light.png +0 -0
  44. package/src/asset/brand/preview-opencode-wordmark-simple-dark.png +0 -0
  45. package/src/asset/brand/preview-opencode-wordmark-simple-light.png +0 -0
  46. package/src/asset/lander/avatar-adam.png +0 -0
  47. package/src/asset/lander/avatar-david.png +0 -0
  48. package/src/asset/lander/avatar-dax.png +0 -0
  49. package/src/asset/lander/avatar-frank.png +0 -0
  50. package/src/asset/lander/avatar-jay.png +0 -0
  51. package/src/asset/lander/brand-assets-dark.svg +10 -0
  52. package/src/asset/lander/brand-assets-light.svg +10 -0
  53. package/src/asset/lander/brand.png +0 -0
  54. package/src/asset/lander/check.svg +3 -0
  55. package/src/asset/lander/copy.svg +3 -0
  56. package/src/asset/lander/desktop-app-icon.png +0 -0
  57. package/src/asset/lander/dock.png +0 -0
  58. package/src/asset/lander/logo-dark.svg +11 -0
  59. package/src/asset/lander/logo-light.svg +11 -0
  60. package/src/asset/lander/opencode-comparison-min.mp4 +0 -0
  61. package/src/asset/lander/opencode-comparison-poster.png +0 -0
  62. package/src/asset/lander/opencode-desktop-icon.png +0 -0
  63. package/src/asset/lander/opencode-logo-dark.svg +11 -0
  64. package/src/asset/lander/opencode-logo-light.svg +11 -0
  65. package/src/asset/lander/opencode-min.mp4 +0 -0
  66. package/src/asset/lander/opencode-poster.png +0 -0
  67. package/src/asset/lander/opencode-wordmark-dark.svg +25 -0
  68. package/src/asset/lander/opencode-wordmark-light.svg +25 -0
  69. package/src/asset/lander/screenshot-github.png +0 -0
  70. package/src/asset/lander/screenshot-splash.png +0 -0
  71. package/src/asset/lander/screenshot-vscode.png +0 -0
  72. package/src/asset/lander/screenshot.png +0 -0
  73. package/src/asset/lander/wordmark-dark.svg +3 -0
  74. package/src/asset/lander/wordmark-light.svg +3 -0
  75. package/src/asset/logo-ornate-dark.svg +18 -0
  76. package/src/asset/logo-ornate-light.svg +18 -0
  77. package/src/asset/logo.svg +18 -0
  78. package/src/asset/zen-ornate-dark.svg +8 -0
  79. package/src/asset/zen-ornate-light.svg +8 -0
  80. package/src/component/dropdown.css +80 -0
  81. package/src/component/dropdown.tsx +79 -0
  82. package/src/component/email-signup.tsx +48 -0
  83. package/src/component/faq.tsx +33 -0
  84. package/src/component/footer.tsx +38 -0
  85. package/src/component/header-context-menu.css +63 -0
  86. package/src/component/header.tsx +279 -0
  87. package/src/component/icon.tsx +257 -0
  88. package/src/component/legal.tsx +20 -0
  89. package/src/component/modal.css +66 -0
  90. package/src/component/modal.tsx +24 -0
  91. package/src/component/spotlight.css +15 -0
  92. package/src/component/spotlight.tsx +820 -0
  93. package/src/config.ts +29 -0
  94. package/src/context/auth.session.ts +0 -0
  95. package/src/context/auth.ts +116 -0
  96. package/src/context/auth.withActor.ts +7 -0
  97. package/src/entry-client.tsx +4 -0
  98. package/src/entry-server.tsx +30 -0
  99. package/src/global.d.ts +5 -0
  100. package/src/lib/github.ts +38 -0
  101. package/src/middleware.ts +5 -0
  102. package/src/routes/[...404].css +130 -0
  103. package/src/routes/[...404].tsx +38 -0
  104. package/src/routes/api/enterprise.ts +47 -0
  105. package/src/routes/auth/[...callback].ts +41 -0
  106. package/src/routes/auth/authorize.ts +10 -0
  107. package/src/routes/auth/index.ts +12 -0
  108. package/src/routes/auth/logout.ts +17 -0
  109. package/src/routes/auth/status.ts +7 -0
  110. package/src/routes/bench/[id].tsx +365 -0
  111. package/src/routes/bench/index.tsx +86 -0
  112. package/src/routes/bench/submission.ts +29 -0
  113. package/src/routes/black/common.tsx +62 -0
  114. package/src/routes/black/index.tsx +108 -0
  115. package/src/routes/black/subscribe/[plan].tsx +449 -0
  116. package/src/routes/black/workspace.css +214 -0
  117. package/src/routes/black/workspace.tsx +229 -0
  118. package/src/routes/black.css +828 -0
  119. package/src/routes/black.tsx +285 -0
  120. package/src/routes/brand/index.css +555 -0
  121. package/src/routes/brand/index.tsx +252 -0
  122. package/src/routes/changelog/index.css +477 -0
  123. package/src/routes/changelog/index.tsx +147 -0
  124. package/src/routes/debug/index.ts +13 -0
  125. package/src/routes/desktop-feedback.ts +5 -0
  126. package/src/routes/discord.ts +5 -0
  127. package/src/routes/docs/[...path].ts +20 -0
  128. package/src/routes/docs/index.ts +20 -0
  129. package/src/routes/download/[platform].ts +38 -0
  130. package/src/routes/download/index.css +750 -0
  131. package/src/routes/download/index.tsx +482 -0
  132. package/src/routes/download/types.ts +4 -0
  133. package/src/routes/enterprise/index.css +578 -0
  134. package/src/routes/enterprise/index.tsx +251 -0
  135. package/src/routes/index.css +1251 -0
  136. package/src/routes/index.tsx +840 -0
  137. package/src/routes/legal/privacy-policy/index.css +343 -0
  138. package/src/routes/legal/privacy-policy/index.tsx +1512 -0
  139. package/src/routes/legal/terms-of-service/index.css +254 -0
  140. package/src/routes/legal/terms-of-service/index.tsx +512 -0
  141. package/src/routes/openapi.json.ts +7 -0
  142. package/src/routes/s/[id].ts +20 -0
  143. package/src/routes/stripe/webhook.ts +532 -0
  144. package/src/routes/t/[...path].tsx +20 -0
  145. package/src/routes/temp.tsx +172 -0
  146. package/src/routes/user-menu.css +18 -0
  147. package/src/routes/user-menu.tsx +32 -0
  148. package/src/routes/workspace/[id]/billing/billing-section.module.css +185 -0
  149. package/src/routes/workspace/[id]/billing/billing-section.tsx +240 -0
  150. package/src/routes/workspace/[id]/billing/black-section.module.css +142 -0
  151. package/src/routes/workspace/[id]/billing/black-section.tsx +269 -0
  152. package/src/routes/workspace/[id]/billing/black-waitlist-section.module.css +23 -0
  153. package/src/routes/workspace/[id]/billing/index.tsx +32 -0
  154. package/src/routes/workspace/[id]/billing/monthly-limit-section.module.css +96 -0
  155. package/src/routes/workspace/[id]/billing/monthly-limit-section.tsx +133 -0
  156. package/src/routes/workspace/[id]/billing/payment-section.module.css +93 -0
  157. package/src/routes/workspace/[id]/billing/payment-section.tsx +122 -0
  158. package/src/routes/workspace/[id]/billing/reload-section.module.css +261 -0
  159. package/src/routes/workspace/[id]/billing/reload-section.tsx +213 -0
  160. package/src/routes/workspace/[id]/graph-section.module.css +145 -0
  161. package/src/routes/workspace/[id]/graph-section.tsx +475 -0
  162. package/src/routes/workspace/[id]/index.tsx +81 -0
  163. package/src/routes/workspace/[id]/keys/index.tsx +11 -0
  164. package/src/routes/workspace/[id]/keys/key-section.module.css +197 -0
  165. package/src/routes/workspace/[id]/keys/key-section.tsx +176 -0
  166. package/src/routes/workspace/[id]/members/index.tsx +11 -0
  167. package/src/routes/workspace/[id]/members/member-section.module.css +249 -0
  168. package/src/routes/workspace/[id]/members/member-section.tsx +343 -0
  169. package/src/routes/workspace/[id]/members/role-dropdown.css +72 -0
  170. package/src/routes/workspace/[id]/members/role-dropdown.tsx +43 -0
  171. package/src/routes/workspace/[id]/model-section.module.css +173 -0
  172. package/src/routes/workspace/[id]/model-section.tsx +174 -0
  173. package/src/routes/workspace/[id]/new-user-section.module.css +143 -0
  174. package/src/routes/workspace/[id]/new-user-section.tsx +104 -0
  175. package/src/routes/workspace/[id]/provider-section.module.css +138 -0
  176. package/src/routes/workspace/[id]/provider-section.tsx +188 -0
  177. package/src/routes/workspace/[id]/settings/index.tsx +11 -0
  178. package/src/routes/workspace/[id]/settings/settings-section.module.css +94 -0
  179. package/src/routes/workspace/[id]/settings/settings-section.tsx +122 -0
  180. package/src/routes/workspace/[id]/usage-section.module.css +185 -0
  181. package/src/routes/workspace/[id]/usage-section.tsx +200 -0
  182. package/src/routes/workspace/[id].css +308 -0
  183. package/src/routes/workspace/[id].tsx +62 -0
  184. package/src/routes/workspace/common.tsx +120 -0
  185. package/src/routes/workspace-picker.css +74 -0
  186. package/src/routes/workspace-picker.tsx +122 -0
  187. package/src/routes/workspace.css +107 -0
  188. package/src/routes/workspace.tsx +38 -0
  189. package/src/routes/zen/index.css +866 -0
  190. package/src/routes/zen/index.tsx +343 -0
  191. package/src/routes/zen/util/dataDumper.ts +44 -0
  192. package/src/routes/zen/util/error.ts +13 -0
  193. package/src/routes/zen/util/handler.ts +784 -0
  194. package/src/routes/zen/util/logger.ts +12 -0
  195. package/src/routes/zen/util/provider/anthropic.ts +752 -0
  196. package/src/routes/zen/util/provider/google.ts +75 -0
  197. package/src/routes/zen/util/provider/openai-compatible.ts +546 -0
  198. package/src/routes/zen/util/provider/openai.ts +630 -0
  199. package/src/routes/zen/util/provider/provider.ts +210 -0
  200. package/src/routes/zen/util/rateLimiter.ts +41 -0
  201. package/src/routes/zen/util/stickyProviderTracker.ts +16 -0
  202. package/src/routes/zen/util/trialLimiter.ts +49 -0
  203. package/src/routes/zen/v1/chat/completions.ts +11 -0
  204. package/src/routes/zen/v1/messages.ts +11 -0
  205. package/src/routes/zen/v1/models/[model].ts +13 -0
  206. package/src/routes/zen/v1/models.ts +60 -0
  207. package/src/routes/zen/v1/responses.ts +11 -0
  208. package/src/style/base.css +21 -0
  209. package/src/style/component/button.css +102 -0
  210. package/src/style/index.css +8 -0
  211. package/src/style/reset.css +76 -0
  212. package/src/style/token/color.css +91 -0
  213. package/src/style/token/font.css +21 -0
  214. package/src/style/token/space.css +46 -0
  215. package/sst-env.d.ts +9 -0
  216. package/tsconfig.json +21 -0
  217. package/vite.config.ts +25 -0
@@ -0,0 +1,449 @@
1
+ import { A, createAsync, query, redirect, useParams } from "@solidjs/router"
2
+ import { Title } from "@solidjs/meta"
3
+ import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js"
4
+ import { type Stripe, type PaymentMethod, loadStripe } from "@stripe/stripe-js"
5
+ import { Elements, PaymentElement, useStripe, useElements, AddressElement } from "solid-stripe"
6
+ import { PlanID, plans } from "../common"
7
+ import { getActor, useAuthSession } from "~/context/auth"
8
+ import { withActor } from "~/context/auth.withActor"
9
+ import { Actor } from "@jonsoc/console-core/actor.js"
10
+ import { and, Database, eq, isNull } from "@jonsoc/console-core/drizzle/index.js"
11
+ import { WorkspaceTable } from "@jonsoc/console-core/schema/workspace.sql.js"
12
+ import { UserTable } from "@jonsoc/console-core/schema/user.sql.js"
13
+ import { createList } from "solid-list"
14
+ import { Modal } from "~/component/modal"
15
+ import { BillingTable } from "@jonsoc/console-core/schema/billing.sql.js"
16
+ import { Billing } from "@jonsoc/console-core/billing.js"
17
+
18
+ const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<PlanID, (typeof plans)[number]>
19
+ const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!)
20
+
21
+ const getWorkspaces = query(async (plan: string) => {
22
+ "use server"
23
+ const actor = await getActor()
24
+ if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe/" + plan)
25
+ return withActor(async () => {
26
+ return Database.use((tx) =>
27
+ tx
28
+ .select({
29
+ id: WorkspaceTable.id,
30
+ name: WorkspaceTable.name,
31
+ slug: WorkspaceTable.slug,
32
+ billing: {
33
+ customerID: BillingTable.customerID,
34
+ paymentMethodID: BillingTable.paymentMethodID,
35
+ paymentMethodType: BillingTable.paymentMethodType,
36
+ paymentMethodLast4: BillingTable.paymentMethodLast4,
37
+ subscriptionID: BillingTable.subscriptionID,
38
+ timeSubscriptionBooked: BillingTable.timeSubscriptionBooked,
39
+ },
40
+ })
41
+ .from(UserTable)
42
+ .innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
43
+ .innerJoin(BillingTable, eq(WorkspaceTable.id, BillingTable.workspaceID))
44
+ .where(
45
+ and(
46
+ eq(UserTable.accountID, Actor.account()),
47
+ isNull(WorkspaceTable.timeDeleted),
48
+ isNull(UserTable.timeDeleted),
49
+ ),
50
+ ),
51
+ )
52
+ })
53
+ }, "black.subscribe.workspaces")
54
+
55
+ const createSetupIntent = async (input: { plan: string; workspaceID: string }) => {
56
+ "use server"
57
+ const { plan, workspaceID } = input
58
+
59
+ if (!plan || !["20", "100", "200"].includes(plan)) return { error: "Invalid plan" }
60
+ if (!workspaceID) return { error: "Workspace ID is required" }
61
+
62
+ return withActor(async () => {
63
+ const session = await useAuthSession()
64
+ const account = session.data.account?.[session.data.current ?? ""]
65
+ const email = account?.email
66
+
67
+ const customer = await Database.use((tx) =>
68
+ tx
69
+ .select({
70
+ customerID: BillingTable.customerID,
71
+ subscriptionID: BillingTable.subscriptionID,
72
+ })
73
+ .from(BillingTable)
74
+ .where(eq(BillingTable.workspaceID, workspaceID))
75
+ .then((rows) => rows[0]),
76
+ )
77
+ if (customer?.subscriptionID) {
78
+ return { error: "This workspace already has a subscription" }
79
+ }
80
+
81
+ let customerID = customer?.customerID
82
+ if (!customerID) {
83
+ const customer = await Billing.stripe().customers.create({
84
+ email,
85
+ metadata: {
86
+ workspaceID,
87
+ },
88
+ })
89
+ customerID = customer.id
90
+ await Database.use((tx) =>
91
+ tx
92
+ .update(BillingTable)
93
+ .set({
94
+ customerID,
95
+ })
96
+ .where(eq(BillingTable.workspaceID, workspaceID)),
97
+ )
98
+ }
99
+
100
+ const intent = await Billing.stripe().setupIntents.create({
101
+ customer: customerID,
102
+ payment_method_types: ["card"],
103
+ metadata: {
104
+ workspaceID,
105
+ },
106
+ })
107
+
108
+ return { clientSecret: intent.client_secret ?? undefined }
109
+ }, workspaceID)
110
+ }
111
+
112
+ const bookSubscription = async (input: {
113
+ workspaceID: string
114
+ plan: PlanID
115
+ paymentMethodID: string
116
+ paymentMethodType: string
117
+ paymentMethodLast4?: string
118
+ }) => {
119
+ "use server"
120
+ return withActor(
121
+ () =>
122
+ Database.use((tx) =>
123
+ tx
124
+ .update(BillingTable)
125
+ .set({
126
+ paymentMethodID: input.paymentMethodID,
127
+ paymentMethodType: input.paymentMethodType,
128
+ paymentMethodLast4: input.paymentMethodLast4,
129
+ subscriptionPlan: input.plan,
130
+ timeSubscriptionBooked: new Date(),
131
+ })
132
+ .where(eq(BillingTable.workspaceID, input.workspaceID)),
133
+ ),
134
+ input.workspaceID,
135
+ )
136
+ }
137
+
138
+ interface SuccessData {
139
+ plan: string
140
+ paymentMethodType: string
141
+ paymentMethodLast4?: string
142
+ }
143
+
144
+ function Failure(props: { message: string }) {
145
+ return (
146
+ <div data-slot="failure">
147
+ <p data-slot="message">Uh oh! {props.message}</p>
148
+ </div>
149
+ )
150
+ }
151
+
152
+ function Success(props: SuccessData) {
153
+ return (
154
+ <div data-slot="success">
155
+ <p data-slot="title">You're on the JonsOC Black waitlist</p>
156
+ <dl data-slot="details">
157
+ <div>
158
+ <dt>Subscription plan</dt>
159
+ <dd>JonsOC Black {props.plan}</dd>
160
+ </div>
161
+ <div>
162
+ <dt>Amount</dt>
163
+ <dd>${props.plan} per month</dd>
164
+ </div>
165
+ <div>
166
+ <dt>Payment method</dt>
167
+ <dd>
168
+ <Show when={props.paymentMethodLast4} fallback={<span>{props.paymentMethodType}</span>}>
169
+ <span>
170
+ {props.paymentMethodType} - {props.paymentMethodLast4}
171
+ </span>
172
+ </Show>
173
+ </dd>
174
+ </div>
175
+ <div>
176
+ <dt>Date joined</dt>
177
+ <dd>{new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</dd>
178
+ </div>
179
+ </dl>
180
+ <p data-slot="charge-notice">Your card will be charged when your subscription is activated</p>
181
+ </div>
182
+ )
183
+ }
184
+
185
+ function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data: SuccessData) => void }) {
186
+ const stripe = useStripe()
187
+ const elements = useElements()
188
+ const [error, setError] = createSignal<string | undefined>(undefined)
189
+ const [loading, setLoading] = createSignal(false)
190
+
191
+ const handleSubmit = async (e: Event) => {
192
+ e.preventDefault()
193
+ if (!stripe() || !elements()) return
194
+
195
+ setLoading(true)
196
+ setError(undefined)
197
+
198
+ const result = await elements()!.submit()
199
+ if (result.error) {
200
+ setError(result.error.message ?? "An error occurred")
201
+ setLoading(false)
202
+ return
203
+ }
204
+
205
+ const { error: confirmError, setupIntent } = await stripe()!.confirmSetup({
206
+ elements: elements()!,
207
+ confirmParams: {
208
+ expand: ["payment_method"],
209
+ payment_method_data: {
210
+ allow_redisplay: "always",
211
+ },
212
+ },
213
+ redirect: "if_required",
214
+ })
215
+
216
+ if (confirmError) {
217
+ setError(confirmError.message ?? "An error occurred")
218
+ setLoading(false)
219
+ return
220
+ }
221
+
222
+ if (setupIntent?.status === "succeeded") {
223
+ const pm = setupIntent.payment_method as PaymentMethod
224
+
225
+ await bookSubscription({
226
+ workspaceID: props.workspaceID,
227
+ plan: props.plan,
228
+ paymentMethodID: pm.id,
229
+ paymentMethodType: pm.type,
230
+ paymentMethodLast4: pm.card?.last4,
231
+ })
232
+
233
+ props.onSuccess({
234
+ plan: props.plan,
235
+ paymentMethodType: pm.type,
236
+ paymentMethodLast4: pm.card?.last4,
237
+ })
238
+ }
239
+
240
+ setLoading(false)
241
+ }
242
+
243
+ return (
244
+ <form onSubmit={handleSubmit} data-slot="checkout-form">
245
+ <PaymentElement />
246
+ <AddressElement options={{ mode: "billing" }} />
247
+ <Show when={error()}>
248
+ <p data-slot="error">{error()}</p>
249
+ </Show>
250
+ <button type="submit" disabled={loading() || !stripe() || !elements()} data-slot="submit-button">
251
+ {loading() ? "Processing..." : `Subscribe $${props.plan}`}
252
+ </button>
253
+ <p data-slot="charge-notice">You will only be charged when your subscription is activated</p>
254
+ </form>
255
+ )
256
+ }
257
+
258
+ export default function BlackSubscribe() {
259
+ const params = useParams()
260
+ const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
261
+ const plan = planData.id
262
+
263
+ const workspaces = createAsync(() => getWorkspaces(plan))
264
+ const [selectedWorkspace, setSelectedWorkspace] = createSignal<string | undefined>(undefined)
265
+ const [success, setSuccess] = createSignal<SuccessData | undefined>(undefined)
266
+ const [failure, setFailure] = createSignal<string | undefined>(undefined)
267
+ const [clientSecret, setClientSecret] = createSignal<string | undefined>(undefined)
268
+ const [stripe, setStripe] = createSignal<Stripe | undefined>(undefined)
269
+
270
+ // Resolve stripe promise once
271
+ createEffect(() => {
272
+ stripePromise.then((s) => {
273
+ if (s) setStripe(s)
274
+ })
275
+ })
276
+
277
+ // Auto-select if only one workspace
278
+ createEffect(() => {
279
+ const ws = workspaces()
280
+ if (ws?.length === 1 && !selectedWorkspace()) {
281
+ setSelectedWorkspace(ws[0].id)
282
+ }
283
+ })
284
+
285
+ // Fetch setup intent when workspace is selected (unless workspace already has payment method)
286
+ createEffect(async () => {
287
+ const id = selectedWorkspace()
288
+ if (!id) return
289
+
290
+ const ws = workspaces()?.find((w) => w.id === id)
291
+ if (ws?.billing?.subscriptionID) {
292
+ setFailure("This workspace already has a subscription")
293
+ return
294
+ }
295
+ if (ws?.billing?.paymentMethodID) {
296
+ if (!ws?.billing?.timeSubscriptionBooked) {
297
+ await bookSubscription({
298
+ workspaceID: id,
299
+ plan: planData.id,
300
+ paymentMethodID: ws.billing.paymentMethodID!,
301
+ paymentMethodType: ws.billing.paymentMethodType!,
302
+ paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined,
303
+ })
304
+ }
305
+ setSuccess({
306
+ plan: planData.id,
307
+ paymentMethodType: ws.billing.paymentMethodType!,
308
+ paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined,
309
+ })
310
+ return
311
+ }
312
+
313
+ const result = await createSetupIntent({ plan, workspaceID: id })
314
+ if (result.error) {
315
+ setFailure(result.error)
316
+ } else if ("clientSecret" in result) {
317
+ setClientSecret(result.clientSecret)
318
+ }
319
+ })
320
+
321
+ // Keyboard navigation for workspace picker
322
+ const { active, setActive, onKeyDown } = createList({
323
+ items: () => workspaces()?.map((w) => w.id) ?? [],
324
+ initialActive: null,
325
+ })
326
+
327
+ const handleSelectWorkspace = (id: string) => {
328
+ setSelectedWorkspace(id)
329
+ }
330
+
331
+ let listRef: HTMLUListElement | undefined
332
+
333
+ // Show workspace picker if multiple workspaces and none selected
334
+ const showWorkspacePicker = () => {
335
+ const ws = workspaces()
336
+ return ws && ws.length > 1 && !selectedWorkspace()
337
+ }
338
+
339
+ return (
340
+ <>
341
+ <Title>Subscribe to JonsOC Black</Title>
342
+ <section data-slot="subscribe-form">
343
+ <div data-slot="form-card">
344
+ <Switch>
345
+ <Match when={success()}>{(data) => <Success {...data()} />}</Match>
346
+ <Match when={failure()}>{(data) => <Failure message={data()} />}</Match>
347
+ <Match when={true}>
348
+ <>
349
+ <div data-slot="plan-header">
350
+ <p data-slot="title">Subscribe to JonsOC Black</p>
351
+ <p data-slot="price">
352
+ <span data-slot="amount">${planData.id}</span> <span data-slot="period">per month</span>
353
+ <Show when={planData.multiplier}>
354
+ <span data-slot="multiplier">{planData.multiplier}</span>
355
+ </Show>
356
+ </p>
357
+ </div>
358
+ <div data-slot="divider" />
359
+ <p data-slot="section-title">Payment method</p>
360
+
361
+ <Show
362
+ when={clientSecret() && selectedWorkspace() && stripe()}
363
+ fallback={
364
+ <div data-slot="loading">
365
+ <p>{selectedWorkspace() ? "Loading payment form..." : "Select a workspace to continue"}</p>
366
+ </div>
367
+ }
368
+ >
369
+ <Elements
370
+ stripe={stripe()!}
371
+ options={{
372
+ clientSecret: clientSecret()!,
373
+ appearance: {
374
+ theme: "night",
375
+ variables: {
376
+ colorPrimary: "#ffffff",
377
+ colorBackground: "#1a1a1a",
378
+ colorText: "#ffffff",
379
+ colorTextSecondary: "#999999",
380
+ colorDanger: "#ff6b6b",
381
+ fontFamily: "JetBrains Mono, monospace",
382
+ borderRadius: "4px",
383
+ spacingUnit: "4px",
384
+ },
385
+ rules: {
386
+ ".Input": {
387
+ backgroundColor: "#1a1a1a",
388
+ border: "1px solid rgba(255, 255, 255, 0.17)",
389
+ color: "#ffffff",
390
+ },
391
+ ".Input:focus": {
392
+ borderColor: "rgba(255, 255, 255, 0.35)",
393
+ boxShadow: "none",
394
+ },
395
+ ".Label": {
396
+ color: "rgba(255, 255, 255, 0.59)",
397
+ fontSize: "14px",
398
+ marginBottom: "8px",
399
+ },
400
+ },
401
+ },
402
+ }}
403
+ >
404
+ <IntentForm plan={plan} workspaceID={selectedWorkspace()!} onSuccess={setSuccess} />
405
+ </Elements>
406
+ </Show>
407
+ </>
408
+ </Match>
409
+ </Switch>
410
+ </div>
411
+
412
+ {/* Workspace picker modal */}
413
+ <Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title="Select a workspace for this plan">
414
+ <div data-slot="workspace-picker">
415
+ <ul
416
+ ref={listRef}
417
+ data-slot="workspace-list"
418
+ tabIndex={0}
419
+ onKeyDown={(e) => {
420
+ if (e.key === "Enter" && active()) {
421
+ handleSelectWorkspace(active()!)
422
+ } else {
423
+ onKeyDown(e)
424
+ }
425
+ }}
426
+ >
427
+ <For each={workspaces()}>
428
+ {(workspace) => (
429
+ <li
430
+ data-slot="workspace-item"
431
+ data-active={active() === workspace.id}
432
+ onMouseEnter={() => setActive(workspace.id)}
433
+ onClick={() => handleSelectWorkspace(workspace.id)}
434
+ >
435
+ <span data-slot="selected-icon">[*]</span>
436
+ <span>{workspace.name || workspace.slug}</span>
437
+ </li>
438
+ )}
439
+ </For>
440
+ </ul>
441
+ </div>
442
+ </Modal>
443
+ <p data-slot="fine-print">
444
+ Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
445
+ </p>
446
+ </section>
447
+ </>
448
+ )
449
+ }
@@ -0,0 +1,214 @@
1
+ [data-page="black"] {
2
+ background: #000;
3
+ min-height: 100vh;
4
+ display: flex;
5
+ flex-direction: column;
6
+ align-items: center;
7
+ justify-content: stretch;
8
+ font-family: var(--font-mono);
9
+ color: #fff;
10
+
11
+ [data-component="header-gradient"] {
12
+ position: absolute;
13
+ top: 0;
14
+ left: 0;
15
+ width: 100%;
16
+ height: 288px;
17
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%);
18
+ }
19
+
20
+ [data-component="header"] {
21
+ display: flex;
22
+ flex-direction: column;
23
+ align-items: center;
24
+ justify-content: center;
25
+ padding-top: 40px;
26
+ flex-shrink: 0;
27
+
28
+ /* [data-component="header-logo"] { */
29
+ /* } */
30
+ }
31
+
32
+ [data-component="content"] {
33
+ display: flex;
34
+ flex-direction: column;
35
+ align-items: center;
36
+ width: 100%;
37
+ flex-grow: 1;
38
+
39
+ [data-slot="hero-black"] {
40
+ margin-top: 110px;
41
+
42
+ @media (min-width: 768px) {
43
+ margin-top: 150px;
44
+ }
45
+ }
46
+
47
+ [data-slot="select-workspace"] {
48
+ display: flex;
49
+ margin-top: -24px;
50
+ width: 100%;
51
+ max-width: 480px;
52
+ height: 305px;
53
+ padding: 32px 20px 0 20px;
54
+ flex-direction: column;
55
+ align-items: flex-start;
56
+ gap: 24px;
57
+
58
+ border: 1px solid #303030;
59
+ background: #0a0a0a;
60
+ box-shadow:
61
+ 0 100px 80px 0 rgba(0, 0, 0, 0.04),
62
+ 0 41.778px 33.422px 0 rgba(0, 0, 0, 0.05),
63
+ 0 22.336px 17.869px 0 rgba(0, 0, 0, 0.06),
64
+ 0 12.522px 10.017px 0 rgba(0, 0, 0, 0.08),
65
+ 0 6.65px 5.32px 0 rgba(0, 0, 0, 0.09),
66
+ 0 2.767px 2.214px 0 rgba(0, 0, 0, 0.13);
67
+
68
+ [data-slot="select-workspace-title"] {
69
+ flex-shrink: 0;
70
+ align-self: stretch;
71
+ color: rgba(255, 255, 255, 0.59);
72
+ text-align: center;
73
+ font-size: 16px;
74
+ font-style: normal;
75
+ font-weight: 400;
76
+ line-height: 160%; /* 25.6px */
77
+ }
78
+
79
+ [data-slot="workspaces"] {
80
+ width: 100%;
81
+ padding: 0;
82
+ display: flex;
83
+ flex-direction: column;
84
+ align-items: flex-start;
85
+ gap: 8px;
86
+ align-self: stretch;
87
+ outline: none;
88
+ overflow-y: auto;
89
+ flex: 1;
90
+ min-height: 0;
91
+
92
+ scrollbar-width: none;
93
+ &::-webkit-scrollbar {
94
+ display: none;
95
+ }
96
+
97
+ [data-slot="workspace"] {
98
+ width: 100%;
99
+ display: flex;
100
+ padding: 8px 12px;
101
+ align-items: center;
102
+ gap: 8px;
103
+ align-self: stretch;
104
+ cursor: pointer;
105
+
106
+ [data-slot="selected-icon"] {
107
+ visibility: hidden;
108
+ color: rgba(255, 255, 255, 0.39);
109
+ font-family: "IBM Plex Mono";
110
+ font-size: 16px;
111
+ font-style: normal;
112
+ font-weight: 400;
113
+ line-height: 160%; /* 25.6px */
114
+ }
115
+
116
+ a {
117
+ color: rgba(255, 255, 255, 0.92);
118
+ font-size: 16px;
119
+ font-style: normal;
120
+ font-weight: 400;
121
+ line-height: 160%; /* 25.6px */
122
+ text-decoration: none;
123
+ }
124
+ }
125
+
126
+ [data-slot="workspace"]:hover,
127
+ [data-slot="workspace"][data-active="true"] {
128
+ background: #161616;
129
+
130
+ [data-slot="selected-icon"] {
131
+ visibility: visible;
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ [data-component="footer"] {
139
+ display: flex;
140
+ flex-direction: column;
141
+ width: 100%;
142
+ justify-content: center;
143
+ align-items: center;
144
+ gap: 24px;
145
+ flex-shrink: 0;
146
+
147
+ @media (min-width: 768px) {
148
+ height: 120px;
149
+ }
150
+
151
+ [data-slot="footer-content"] {
152
+ display: flex;
153
+ gap: 24px;
154
+ align-items: center;
155
+ justify-content: center;
156
+
157
+ @media (min-width: 768px) {
158
+ gap: 40px;
159
+ }
160
+
161
+ span,
162
+ a {
163
+ color: rgba(255, 255, 255, 0.39);
164
+ font-family: "JetBrains Mono Nerd Font";
165
+ font-size: 16px;
166
+ font-style: normal;
167
+ font-weight: 400;
168
+ line-height: normal;
169
+ text-decoration: none;
170
+ }
171
+
172
+ [data-slot="github-stars"] {
173
+ color: rgba(255, 255, 255, 0.25);
174
+ font-family: "JetBrains Mono Nerd Font";
175
+ font-size: 16px;
176
+ font-style: normal;
177
+ font-weight: 400;
178
+ line-height: normal;
179
+ }
180
+
181
+ [data-slot="anomaly"] {
182
+ display: none;
183
+
184
+ @media (min-width: 768px) {
185
+ display: block;
186
+ }
187
+ }
188
+ }
189
+ [data-slot="anomaly-alt"] {
190
+ color: rgba(255, 255, 255, 0.39);
191
+ font-family: "JetBrains Mono Nerd Font";
192
+ font-size: 16px;
193
+ font-style: normal;
194
+ font-weight: 400;
195
+ line-height: normal;
196
+ text-decoration: none;
197
+ margin-bottom: 24px;
198
+
199
+ a {
200
+ color: rgba(255, 255, 255, 0.39);
201
+ font-family: "JetBrains Mono Nerd Font";
202
+ font-size: 16px;
203
+ font-style: normal;
204
+ font-weight: 400;
205
+ line-height: normal;
206
+ text-decoration: none;
207
+ }
208
+
209
+ @media (min-width: 768px) {
210
+ display: none;
211
+ }
212
+ }
213
+ }
214
+ }