Frank 1 месяц назад
Родитель
Сommit
f24251f89e

+ 6 - 0
bun.lock

@@ -84,10 +84,12 @@
         "@solidjs/meta": "catalog:",
         "@solidjs/router": "catalog:",
         "@solidjs/start": "catalog:",
+        "@stripe/stripe-js": "8.6.1",
         "chart.js": "4.5.1",
         "nitro": "3.0.1-alpha.1",
         "solid-js": "catalog:",
         "solid-list": "0.3.0",
+        "solid-stripe": "0.8.1",
         "vite": "catalog:",
         "zod": "catalog:",
       },
@@ -1652,6 +1654,8 @@
 
     "@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
 
+    "@stripe/stripe-js": ["@stripe/[email protected]", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="],
+
     "@swc/helpers": ["@swc/[email protected]", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
 
     "@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
+
     "solid-use": ["[email protected]", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
 
     "source-map": ["[email protected]", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],

+ 2 - 0
packages/console/app/package.json

@@ -23,10 +23,12 @@
     "@solidjs/meta": "catalog:",
     "@solidjs/router": "catalog:",
     "@solidjs/start": "catalog:",
+    "@stripe/stripe-js": "8.6.1",
     "chart.js": "4.5.1",
     "nitro": "3.0.1-alpha.1",
     "solid-js": "catalog:",
     "solid-list": "0.3.0",
+    "solid-stripe": "0.8.1",
     "vite": "catalog:",
     "zod": "catalog:"
   },

+ 6 - 0
packages/console/app/src/config.ts

@@ -26,4 +26,10 @@ export const config = {
     commits: "6,500",
     monthlyUsers: "650,000",
   },
+
+  // Stripe
+  stripe: {
+    publishableKey:
+      "pk_live_51OhXSKEclFNgdHcR9dDfYGwQeKuPfKo0IjA5kWBQIXKMFhE8QFd9bYLdPZC6klRKEgEkxJYSKuZg9U3FKHdLnF4300F9qLqMgP",
+  },
 } as const

+ 30 - 0
packages/console/app/src/routes/api/black/setup-intent.ts

@@ -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 })
+  }
+}

+ 9 - 2
packages/console/app/src/routes/auth/authorize.ts

@@ -2,6 +2,13 @@ import type { APIEvent } from "@solidjs/start/server"
 import { AuthClient } from "~/context/auth"
 
 export async function GET(input: APIEvent) {
-  const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code")
-  return Response.redirect(result.url, 302)
+  const url = new URL(input.request.url)
+  // 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)
 }

+ 2 - 0
packages/console/app/src/routes/auth/callback.ts

@@ -5,6 +5,8 @@ import { useAuthSession } from "~/context/auth"
 
 export async function GET(input: APIEvent) {
   const url = new URL(input.request.url)
+  console.log("=C=", input.request.url)
+  throw new Error("Not implemented")
   try {
     const code = url.searchParams.get("code")
     if (!code) throw new Error("No code found")

+ 259 - 5
packages/console/app/src/routes/black/index.css → packages/console/app/src/routes/black.css

@@ -36,24 +36,73 @@
     width: 100%;
     flex-grow: 1;
 
+    [data-slot="hero"] {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      text-align: center;
+      gap: 8px;
+      margin-top: 40px;
+      padding: 0 20px;
+
+      @media (min-width: 768px) {
+        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: 110px;
+      margin-top: 40px;
+      padding: 0 20px;
 
       @media (min-width: 768px) {
-        margin-top: 150px;
+        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"] {
       display: flex;
       flex-direction: column;
-      gap: 32px;
+      gap: 16px;
       align-items: center;
       text-align: center;
-      margin-top: -18px;
+      margin-top: -40px;
+      width: 100%;
 
       @media (min-width: 768px) {
-        margin-top: 40px;
+        margin-top: -20px;
       }
 
       [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"] {

Разница между файлами не показана из-за своего большого размера
+ 103 - 0
packages/console/app/src/routes/black.tsx


+ 42 - 0
packages/console/app/src/routes/black/common.tsx

@@ -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>
+  )
+}

Разница между файлами не показана из-за своего большого размера
+ 4 - 135
packages/console/app/src/routes/black/index.tsx


+ 244 - 0
packages/console/app/src/routes/black/subscribe.tsx

@@ -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>
+    </>
+  )
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов