mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-27 21:51:49 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3a3062a41 |
@@ -544,6 +544,17 @@ export const dict = {
|
||||
"workspace.settings.updating": "Updating...",
|
||||
"workspace.settings.save": "Save",
|
||||
"workspace.settings.edit": "Edit",
|
||||
"workspace.settings.invoiceDetails.title": "Invoice details",
|
||||
"workspace.settings.invoiceDetails.subtitle": "These details appear in the Bill to section of your invoices.",
|
||||
"workspace.settings.invoiceDetails.empty": "Enable billing to add invoice details.",
|
||||
"workspace.settings.invoiceDetails.name": "Company or legal name",
|
||||
"workspace.settings.invoiceDetails.address1": "Address line 1",
|
||||
"workspace.settings.invoiceDetails.address2": "Address line 2 (optional)",
|
||||
"workspace.settings.invoiceDetails.city": "City",
|
||||
"workspace.settings.invoiceDetails.state": "State or region (optional)",
|
||||
"workspace.settings.invoiceDetails.postalCode": "Postal code",
|
||||
"workspace.settings.invoiceDetails.country": "Country code",
|
||||
"workspace.settings.invoiceDetails.saving": "Saving...",
|
||||
|
||||
"workspace.billing.title": "Billing",
|
||||
"workspace.billing.subtitle.beforeLink": "Manage payment methods.",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { SettingsSection } from "./settings-section"
|
||||
import { InvoiceDetailsSection, SettingsSection } from "./settings-section"
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
<div data-slot="sections">
|
||||
<SettingsSection />
|
||||
<InvoiceDetailsSection />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -91,4 +91,79 @@
|
||||
margin-top: calc(var(--space-1) * -1);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="empty-message"] {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot="invoice-summary"] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
|
||||
p {
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="invoice-form"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
|
||||
&[data-wide] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
min-width: 0;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-alpha);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="form-actions"],
|
||||
[data-slot="form-error"] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
[data-slot="form-actions"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
[data-slot="form-error"] {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { formError, localizeError } from "~/lib/form-error"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
|
||||
const getWorkspaceInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
@@ -47,6 +49,39 @@ const updateWorkspace = action(async (form: FormData) => {
|
||||
)
|
||||
}, "workspace.update")
|
||||
|
||||
const getInvoiceDetails = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
if (Actor.userRole() !== "admin") return { isAdmin: false, details: null }
|
||||
return { isAdmin: true, details: await Billing.getInvoiceDetails() }
|
||||
}, workspaceID)
|
||||
}, "workspace.invoiceDetails.get")
|
||||
|
||||
const updateInvoiceDetails = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Billing.updateInvoiceDetails({
|
||||
name: String(form.get("name") ?? ""),
|
||||
line1: String(form.get("line1") ?? ""),
|
||||
line2: String(form.get("line2") ?? ""),
|
||||
city: String(form.get("city") ?? ""),
|
||||
state: String(form.get("state") ?? ""),
|
||||
postalCode: String(form.get("postalCode") ?? ""),
|
||||
country: String(form.get("country") ?? ""),
|
||||
})
|
||||
.then(() => ({ error: undefined }))
|
||||
.catch((e) => ({ error: e.message as string })),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: getInvoiceDetails.key },
|
||||
)
|
||||
}, "workspace.invoiceDetails.update")
|
||||
|
||||
export function SettingsSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
@@ -124,3 +159,122 @@ export function SettingsSection() {
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function InvoiceDetailsSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const invoiceInfo = createAsync(() => getInvoiceDetails(params.id!))
|
||||
const submission = useSubmission(updateInvoiceDetails)
|
||||
const [store, setStore] = createStore({ show: false })
|
||||
|
||||
createEffect(() => {
|
||||
if (!submission.pending && submission.result && !submission.result.error) setStore("show", false)
|
||||
})
|
||||
|
||||
function show() {
|
||||
while (true) {
|
||||
submission.clear()
|
||||
if (!submission.result) break
|
||||
}
|
||||
setStore("show", true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={invoiceInfo()?.isAdmin}>
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.settings.invoiceDetails.title")}</h2>
|
||||
<p>{i18n.t("workspace.settings.invoiceDetails.subtitle")}</p>
|
||||
</div>
|
||||
<div data-slot="section-content">
|
||||
<Show
|
||||
when={invoiceInfo()?.details}
|
||||
fallback={<p data-slot="empty-message">{i18n.t("workspace.settings.invoiceDetails.empty")}</p>}
|
||||
>
|
||||
{(details) => (
|
||||
<Show
|
||||
when={!store.show}
|
||||
fallback={
|
||||
<form action={updateInvoiceDetails} method="post" data-slot="invoice-form">
|
||||
<label>
|
||||
<span>{i18n.t("workspace.settings.invoiceDetails.name")}</span>
|
||||
<input required name="name" value={details().name} autocomplete="organization" />
|
||||
</label>
|
||||
<label data-wide>
|
||||
<span>{i18n.t("workspace.settings.invoiceDetails.address1")}</span>
|
||||
<input required name="line1" value={details().address.line1} autocomplete="address-line1" />
|
||||
</label>
|
||||
<label data-wide>
|
||||
<span>{i18n.t("workspace.settings.invoiceDetails.address2")}</span>
|
||||
<input name="line2" value={details().address.line2} autocomplete="address-line2" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{i18n.t("workspace.settings.invoiceDetails.city")}</span>
|
||||
<input required name="city" value={details().address.city} autocomplete="address-level2" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{i18n.t("workspace.settings.invoiceDetails.state")}</span>
|
||||
<input name="state" value={details().address.state} autocomplete="address-level1" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{i18n.t("workspace.settings.invoiceDetails.postalCode")}</span>
|
||||
<input
|
||||
required
|
||||
name="postalCode"
|
||||
value={details().address.postalCode}
|
||||
autocomplete="postal-code"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{i18n.t("workspace.settings.invoiceDetails.country")}</span>
|
||||
<input
|
||||
required
|
||||
name="country"
|
||||
value={details().address.country}
|
||||
autocomplete="country-code"
|
||||
minlength="2"
|
||||
maxlength="2"
|
||||
placeholder="US"
|
||||
/>
|
||||
</label>
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<div data-slot="form-actions">
|
||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||
{submission.pending
|
||||
? i18n.t("workspace.settings.invoiceDetails.saving")
|
||||
: i18n.t("workspace.settings.save")}
|
||||
</button>
|
||||
<button type="reset" data-color="ghost" onClick={() => setStore("show", false)}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
<Show when={submission.result?.error}>
|
||||
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
|
||||
</Show>
|
||||
</form>
|
||||
}
|
||||
>
|
||||
<div data-slot="invoice-summary">
|
||||
<div>
|
||||
<p>{details().name}</p>
|
||||
<p>{details().address.line1}</p>
|
||||
<Show when={details().address.line2}>{(line) => <p>{line()}</p>}</Show>
|
||||
<p>
|
||||
{[details().address.city, details().address.state, details().address.postalCode]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</p>
|
||||
<p>{details().address.country}</p>
|
||||
</div>
|
||||
<button data-color="primary" onClick={show}>
|
||||
{i18n.t("workspace.settings.edit")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,59 @@ export namespace Billing {
|
||||
)
|
||||
}
|
||||
|
||||
export const getInvoiceDetails = async () => {
|
||||
Actor.assertAdmin()
|
||||
const billing = await get()
|
||||
if (!billing?.customerID) return null
|
||||
|
||||
const customer = await stripe().customers.retrieve(billing.customerID)
|
||||
if (customer.deleted) return null
|
||||
return {
|
||||
name: customer.name ?? "",
|
||||
address: {
|
||||
line1: customer.address?.line1 ?? "",
|
||||
line2: customer.address?.line2 ?? "",
|
||||
city: customer.address?.city ?? "",
|
||||
state: customer.address?.state ?? "",
|
||||
postalCode: customer.address?.postal_code ?? "",
|
||||
country: customer.address?.country ?? "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const updateInvoiceDetails = fn(
|
||||
z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
line1: z.string().trim().min(1).max(255),
|
||||
line2: z.string().trim().max(255),
|
||||
city: z.string().trim().min(1).max(255),
|
||||
state: z.string().trim().max(255),
|
||||
postalCode: z.string().trim().min(1).max(32),
|
||||
country: z
|
||||
.string()
|
||||
.trim()
|
||||
.length(2)
|
||||
.transform((value) => value.toUpperCase()),
|
||||
}),
|
||||
async (input) => {
|
||||
Actor.assertAdmin()
|
||||
const billing = await get()
|
||||
if (!billing?.customerID) throw new Error("Enable billing before updating invoice details.")
|
||||
|
||||
await stripe().customers.update(billing.customerID, {
|
||||
name: input.name,
|
||||
address: {
|
||||
line1: input.line1,
|
||||
line2: input.line2,
|
||||
city: input.city,
|
||||
state: input.state,
|
||||
postal_code: input.postalCode,
|
||||
country: input.country,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const payments = async () => {
|
||||
return await Database.use((tx) =>
|
||||
tx
|
||||
|
||||
Reference in New Issue
Block a user