feat(console): add Go referral UI

This commit is contained in:
Jack
2026-05-12 21:29:56 +08:00
parent 8feb4a31c7
commit 940d7c3515
11 changed files with 955 additions and 5 deletions

View File

@@ -0,0 +1,330 @@
[data-component="go-referral-banner"],
[data-component="go-credit-apply-card"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-surface);
@media (max-width: 40rem) {
align-items: flex-start;
flex-direction: column;
}
strong {
display: block;
margin-bottom: var(--space-1);
color: var(--color-text);
font-size: var(--font-size-sm);
font-weight: 600;
}
p {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: 1.5;
}
}
[data-component="go-referral-banner"],
[data-component="go-credit-apply-card"],
[data-component="go-referral-modal"],
[data-component="go-credit-confirm"],
[data-component="go-referral-section"] {
a[data-color],
button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-3) var(--space-4);
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);
font-family: var(--font-sans);
font-weight: 500;
line-height: 1;
cursor: pointer;
text-decoration: none;
transition: all 0.15s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:hover:not(:disabled) {
background-color: var(--color-surface-hover);
border-color: var(--color-accent);
}
&[data-color="primary"] {
background-color: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-text);
&:hover:not(:disabled) {
background-color: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
}
}
}
[data-component="go-referral-modal"],
[data-component="go-credit-confirm"] {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-width: min(34rem, calc(100vw - var(--space-8)));
[data-slot="loading"] {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: 1.5;
text-align: center;
}
[data-slot="modal-actions"] {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
}
[data-slot="invite-link-box"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
label {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
> div {
display: flex;
align-items: center;
gap: var(--space-2);
@media (max-width: 40rem) {
align-items: stretch;
flex-direction: column;
}
}
code {
flex: 1;
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
[data-slot="referral-progress"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-slot="progress-header"] {
display: flex;
justify-content: space-between;
gap: var(--space-3);
color: var(--color-text);
font-size: var(--font-size-sm);
span {
color: var(--color-text-muted);
}
}
[data-slot="progress-track"] {
height: 8px;
overflow: hidden;
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-surface);
}
[data-slot="progress-fill"] {
height: 100%;
border-radius: var(--border-radius-sm);
background-color: var(--color-accent);
transition: width 0.3s ease;
}
[data-slot="milestones"] {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-2);
@media (max-width: 40rem) {
grid-template-columns: 1fr;
}
}
[data-slot="milestone"] {
display: flex;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
&[data-status="unlocked"] {
border-color: var(--color-accent);
color: var(--color-text);
background-color: var(--color-bg);
}
strong {
color: var(--color-text);
font-weight: 600;
}
}
[data-slot="next-reward"] {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: 1.5;
}
[data-slot="instructions"] {
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
strong {
display: block;
margin-bottom: var(--space-3);
color: var(--color-text);
font-size: var(--font-size-sm);
font-weight: 600;
}
ol {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin: 0;
padding: 0 0 0 1.35rem;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: 1.5;
}
li::marker {
color: var(--color-text);
font-weight: 600;
}
}
[data-component="go-referral-section"] {
[data-slot="credit-summary"] {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
@media (max-width: 40rem) {
grid-template-columns: 1fr;
}
> div {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-surface);
}
span {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
strong {
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 600;
}
}
[data-slot="credit-list"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-slot="credit-row"] {
display: grid;
grid-template-columns: minmax(9rem, 1fr) minmax(14rem, 2fr) auto;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
@media (max-width: 48rem) {
grid-template-columns: 1fr;
align-items: stretch;
}
}
[data-slot="credit-main"],
[data-slot="credit-meta"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
[data-slot="credit-main"] {
strong {
color: var(--color-text);
font-size: var(--font-size-md);
font-weight: 600;
}
span {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
}
[data-slot="credit-meta"] {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: 1.5;
}
}
[data-component="go-credit-confirm"] {
p {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
line-height: 1.6;
}
}

View File

@@ -0,0 +1,420 @@
import { A, action, json, query, useAction, useSubmission } from "@solidjs/router"
import { createMemo, createSignal, For, Show } from "solid-js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { withActor } from "~/context/auth.withActor"
import { config } from "~/config"
import { Modal } from "~/component/modal"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import "./go-referral.css"
type CreditStatus = "pending" | "available" | "used" | "expired" | "revoked"
export type GoReferralCredit = {
id: string
amount: number
remaining: number
status: CreditStatus
source: "referral" | "invitee"
issuedAt: string
availableAt: string
expiresAt: string
usedAt?: string
}
export type GoReferralMilestone = {
inviteCount: number
amount: number
status: "unlocked" | "locked"
}
export type GoReferralSummary = {
inviteCode: string
inviteUrl: string
validInviteCount: number
maxInviteCount: number
nextInviteCount?: number
nextReward?: number
invitedUserReward: number
maxReward: number
creditAvailable: number
creditPending: number
creditUsed: number
workspaceMonthlyUsage: number
milestones: GoReferralMilestone[]
credits: GoReferralCredit[]
}
export const queryGoReferral = query(async (workspaceID: string) => {
"use server"
return withActor(() => {
const account = Actor.account()
const code = account.replace(/[^a-zA-Z0-9]/g, "").slice(-8).toUpperCase()
const now = Date.now()
const day = 24 * 60 * 60 * 1000
const credits: GoReferralCredit[] = [
{
id: "demo-referral-1",
amount: 1000,
remaining: 1000,
status: "available",
source: "referral",
issuedAt: new Date(now - 8 * day).toISOString(),
availableAt: new Date(now - 7 * day).toISOString(),
expiresAt: new Date(now + 82 * day).toISOString(),
},
{
id: "demo-invitee-1",
amount: 1000,
remaining: 1000,
status: "pending",
source: "invitee",
issuedAt: new Date(now - 6 * 60 * 60 * 1000).toISOString(),
availableAt: new Date(now + 18 * 60 * 60 * 1000).toISOString(),
expiresAt: new Date(now + 90 * day).toISOString(),
},
{
id: "demo-referral-used",
amount: 500,
remaining: 0,
status: "used",
source: "referral",
issuedAt: new Date(now - 30 * day).toISOString(),
availableAt: new Date(now - 29 * day).toISOString(),
expiresAt: new Date(now + 60 * day).toISOString(),
usedAt: new Date(now - 3 * day).toISOString(),
},
]
return {
inviteCode: code,
inviteUrl: `${config.baseUrl}/go?invite=${code}`,
validInviteCount: 2,
maxInviteCount: 5,
nextInviteCount: 3,
nextReward: 2000,
invitedUserReward: 1000,
maxReward: 6000,
creditAvailable: credits
.filter((credit) => credit.status === "available")
.reduce((total, credit) => total + credit.remaining, 0),
creditPending: credits
.filter((credit) => credit.status === "pending")
.reduce((total, credit) => total + credit.remaining, 0),
creditUsed: credits.filter((credit) => credit.status === "used").reduce((total, credit) => total + credit.amount, 0),
workspaceMonthlyUsage: 1240,
milestones: [
{ inviteCount: 1, amount: 1000, status: "unlocked" },
{ inviteCount: 3, amount: 2000, status: "locked" },
{ inviteCount: 5, amount: 3000, status: "locked" },
],
credits,
} satisfies GoReferralSummary
}, workspaceID)
}, "go.referral.get")
export const applyGoReferralCredit = action(async (workspaceID: string, amount: number, creditID?: string) => {
"use server"
return json(
await withActor(
() => ({
error: undefined,
data: { amount, creditID },
}),
workspaceID,
),
{ revalidate: [queryGoReferral.key] },
)
}, "go.referral.credit.apply")
function formatCurrency(amount: number) {
if (amount % 100 === 0) return `$${amount / 100}`
return `$${(amount / 100).toFixed(2)}`
}
function formatDate(value: string, locale: string) {
return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric" }).format(new Date(value))
}
function creditStatusKey(status: CreditStatus) {
if (status === "pending") return "workspace.referral.credit.status.pending" as const
if (status === "available") return "workspace.referral.credit.status.available" as const
if (status === "used") return "workspace.referral.credit.status.used" as const
if (status === "expired") return "workspace.referral.credit.status.expired" as const
return "workspace.referral.credit.status.revoked" as const
}
function creditSourceKey(source: GoReferralCredit["source"]) {
if (source === "invitee") return "workspace.referral.credit.source.invitee" as const
return "workspace.referral.credit.source.referral" as const
}
function milestoneLabel(inviteCount: number, i18n: ReturnType<typeof useI18n>) {
if (inviteCount === 1) return i18n.t("workspace.referral.milestone.one")
if (inviteCount === 3) return i18n.t("workspace.referral.milestone.three")
return i18n.t("workspace.referral.milestone.five")
}
function CopyInviteLink(props: { summary: GoReferralSummary }) {
const i18n = useI18n()
const [copied, setCopied] = createSignal(false)
async function copy() {
if (typeof navigator !== "object") return
await navigator.clipboard.writeText(props.summary.inviteUrl)
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
}
return (
<div data-slot="invite-link-box">
<label>{i18n.t("workspace.referral.inviteLink")}</label>
<div>
<code>{props.summary.inviteUrl}</code>
<button type="button" data-color="primary" onClick={copy}>
{copied() ? i18n.t("workspace.referral.copied") : i18n.t("workspace.referral.copyLink")}
</button>
</div>
</div>
)
}
function ReferralProgress(props: { summary: GoReferralSummary }) {
const i18n = useI18n()
const progress = createMemo(() => Math.min(100, (props.summary.validInviteCount / props.summary.maxInviteCount) * 100))
return (
<div data-slot="referral-progress">
<div data-slot="progress-header">
<strong>{i18n.t("workspace.referral.progress.title")}</strong>
<span>
{i18n.t("workspace.referral.progress.value", {
count: props.summary.validInviteCount,
total: props.summary.maxInviteCount,
})}
</span>
</div>
<div data-slot="progress-track">
<div data-slot="progress-fill" style={{ width: `${progress()}%` }} />
</div>
<div data-slot="milestones">
<For each={props.summary.milestones}>
{(milestone) => (
<div data-slot="milestone" data-status={milestone.status}>
<span>{milestoneLabel(milestone.inviteCount, i18n)}</span>
<strong>{formatCurrency(milestone.amount)}</strong>
</div>
)}
</For>
</div>
<p data-slot="next-reward">
<Show
when={props.summary.nextInviteCount && props.summary.nextReward}
fallback={i18n.t("workspace.referral.progress.complete")}
>
{i18n.t("workspace.referral.progress.next", {
count: Math.max(0, props.summary.nextInviteCount! - props.summary.validInviteCount),
amount: formatCurrency(props.summary.nextReward!),
})}
</Show>
</p>
</div>
)
}
export function GoReferralBanner(props: { href: string }) {
const i18n = useI18n()
return (
<div data-component="go-referral-banner">
<div>
<strong>{i18n.t("workspace.referral.banner.title")}</strong>
<p>{i18n.t("workspace.referral.banner.body")}</p>
</div>
<A href={props.href} data-color="primary">
{i18n.t("workspace.referral.banner.action")}
</A>
</div>
)
}
export function GoCreditApplyCard(props: { workspaceID: string; summary?: GoReferralSummary }) {
const i18n = useI18n()
const apply = useAction(applyGoReferralCredit)
const submission = useSubmission(applyGoReferralCredit)
const [confirming, setConfirming] = createSignal(false)
const [applied, setApplied] = createSignal(0)
const available = createMemo(() => Math.max(0, (props.summary?.creditAvailable ?? 0) - applied()))
const applyAmount = createMemo(() => Math.min(available(), props.summary?.workspaceMonthlyUsage ?? 0))
async function onApply() {
const amount = applyAmount()
if (!amount) return
const result = await apply(props.workspaceID, amount)
if (result.data) {
setApplied((value) => value + amount)
setConfirming(false)
}
}
return (
<div data-component="go-credit-apply-card">
<div>
<strong>
{i18n.t("workspace.referral.apply.title", { amount: formatCurrency(available()) })}
</strong>
<p>
<Show when={applyAmount() > 0} fallback={i18n.t("workspace.referral.apply.noUsage")}>
{i18n.t("workspace.referral.apply.body")}
</Show>
</p>
</div>
<button type="button" disabled={!applyAmount()} onClick={() => setConfirming(true)}>
{i18n.t("workspace.referral.apply.action")}
</button>
<Modal open={confirming()} onClose={() => setConfirming(false)} title={i18n.t("workspace.referral.apply.confirmTitle")}>
<div data-component="go-credit-confirm">
<p>
{i18n.t("workspace.referral.apply.confirmBody", {
amount: formatCurrency(applyAmount()),
usage: formatCurrency(props.summary?.workspaceMonthlyUsage ?? 0),
usageAfter: formatCurrency(Math.max(0, (props.summary?.workspaceMonthlyUsage ?? 0) - applyAmount())),
})}
</p>
<div data-slot="modal-actions">
<button type="button" onClick={() => setConfirming(false)}>
{i18n.t("common.cancel")}
</button>
<button type="button" data-color="primary" disabled={submission.pending} onClick={onApply}>
{submission.pending ? i18n.t("workspace.lite.loading") : i18n.t("workspace.referral.apply.confirmAction")}
</button>
</div>
</div>
</Modal>
</div>
)
}
export function GoReferralCredits(props: { workspaceID: string; summary: GoReferralSummary }) {
const i18n = useI18n()
const language = useLanguage()
const apply = useAction(applyGoReferralCredit)
const submission = useSubmission(applyGoReferralCredit)
const [selected, setSelected] = createSignal<GoReferralCredit>()
const [appliedCredits, setAppliedCredits] = createSignal<Record<string, number>>({})
const creditRemaining = (credit: GoReferralCredit) => Math.max(0, credit.remaining - (appliedCredits()[credit.id] ?? 0))
const selectedAmount = createMemo(() => {
const credit = selected()
if (!credit) return 0
return Math.min(creditRemaining(credit), props.summary.workspaceMonthlyUsage)
})
async function onApply() {
const credit = selected()
const amount = selectedAmount()
if (!credit || !amount) return
const result = await apply(props.workspaceID, amount, credit.id)
if (result.data) {
setAppliedCredits((value) => ({ ...value, [credit.id]: (value[credit.id] ?? 0) + amount }))
setSelected(undefined)
}
}
return (
<section data-component="go-referral-section">
<div data-slot="section-title">
<h2>{i18n.t("workspace.referral.credits.title")}</h2>
<p>{i18n.t("workspace.referral.credits.subtitle")}</p>
</div>
<div data-slot="credit-list">
<For each={props.summary.credits}>
{(credit) => (
<div data-slot="credit-row" data-status={credit.status}>
<div data-slot="credit-main">
<strong>{formatCurrency(creditRemaining(credit) || credit.amount)}</strong>
<span>{i18n.t(creditSourceKey(credit.source))}</span>
</div>
<div data-slot="credit-meta">
<span>{i18n.t(creditStatusKey(credit.status))}</span>
<span>
{credit.status === "pending"
? i18n.t("workspace.referral.credit.availableOn", {
date: formatDate(credit.availableAt, language.tag(language.locale())),
})
: i18n.t("workspace.referral.credit.expiresOn", {
date: formatDate(credit.expiresAt, language.tag(language.locale())),
})}
</span>
</div>
<button
type="button"
disabled={credit.status !== "available" || creditRemaining(credit) <= 0 || props.summary.workspaceMonthlyUsage <= 0}
onClick={() => setSelected(credit)}
>
{i18n.t("workspace.referral.apply.action")}
</button>
</div>
)}
</For>
</div>
<Modal open={!!selected()} onClose={() => setSelected(undefined)} title={i18n.t("workspace.referral.apply.confirmTitle")}>
<div data-component="go-credit-confirm">
<p>
<Show when={selectedAmount() > 0} fallback={i18n.t("workspace.referral.apply.noUsage")}>
{i18n.t("workspace.referral.apply.confirmBody", {
amount: formatCurrency(selectedAmount()),
usage: formatCurrency(props.summary.workspaceMonthlyUsage),
usageAfter: formatCurrency(Math.max(0, props.summary.workspaceMonthlyUsage - selectedAmount())),
})}
</Show>
</p>
<div data-slot="modal-actions">
<button type="button" onClick={() => setSelected(undefined)}>
{i18n.t("common.cancel")}
</button>
<button type="button" data-color="primary" disabled={submission.pending || !selectedAmount()} onClick={onApply}>
{submission.pending ? i18n.t("workspace.lite.loading") : i18n.t("workspace.referral.apply.confirmAction")}
</button>
</div>
</div>
</Modal>
</section>
)
}
export function GoReferralOverview(props: { summary: GoReferralSummary }) {
const i18n = useI18n()
return (
<section data-component="go-referral-section">
<div data-slot="section-title">
<h2>{i18n.t("workspace.referral.overview.title")}</h2>
<p>
{i18n.t("workspace.referral.overview.subtitle", {
max: formatCurrency(props.summary.maxReward),
reward: formatCurrency(props.summary.invitedUserReward),
})}
</p>
</div>
<InvitationInstructions />
<CopyInviteLink summary={props.summary} />
<ReferralProgress summary={props.summary} />
</section>
)
}
export function InvitationInstructions() {
const i18n = useI18n()
return (
<div data-slot="instructions">
<strong>{i18n.t("workspace.referral.instructions.title")}</strong>
<ol>
<li>{i18n.t("workspace.referral.instructions.share")}</li>
<li>{i18n.t("workspace.referral.instructions.subscribe")}</li>
<li>{i18n.t("workspace.referral.instructions.unlock")}</li>
</ol>
</div>
)
}

View File

@@ -258,6 +258,9 @@ export const dict = {
"go.cta.text": "Subscribe to Go",
"go.cta.price": "$10/month",
"go.cta.promo": "$5 first month",
"go.referral.title": "Invite friends",
"go.referral.link": "Invite",
"go.referral.note": "Friends receive $10 Go credit after subscribing. You can earn up to $60 in Go credit.",
"go.pricing.body": "Use with any agent. $5 first month, then $10/month. Top up credit if needed. Cancel any time.",
"go.graph.free": "Free",
"go.graph.freePill": "Big Pickle and free models",
@@ -420,6 +423,7 @@ export const dict = {
"workspace.nav.zen": "Zen",
"workspace.nav.go": "Go",
"workspace.nav.invite": "Invite",
"workspace.nav.usage": "Usage",
"workspace.nav.apiKeys": "API Keys",
"workspace.nav.members": "Members",
@@ -662,6 +666,49 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Other payment methods",
"workspace.lite.promo.selectMethod": "Select payment method",
"workspace.referral.page.subtitle": "Invite friends to Go and manage your usage credits.",
"workspace.referral.inviteApplied": "Invite applied. You will receive Go credit after subscribing.",
"workspace.referral.banner.title": "Invite friends to Go",
"workspace.referral.banner.body": "Earn up to $60 in Go usage credits when friends subscribe.",
"workspace.referral.banner.action": "Invite",
"workspace.referral.inviteLink": "Invite link",
"workspace.referral.copyLink": "Copy link",
"workspace.referral.copied": "Copied",
"workspace.referral.overview.title": "Invite friends to Go",
"workspace.referral.overview.subtitle": "Friends who subscribe with your link receive {{reward}} Go credit. You can earn up to {{max}}.",
"workspace.referral.instructions.title": "Invitation instructions",
"workspace.referral.instructions.share": "Share your invite link.",
"workspace.referral.instructions.subscribe": "Your friend subscribes to Go and receives $10 Go credit.",
"workspace.referral.instructions.unlock": "Unlock up to $60 in Go credit at 1, 3, and 5 valid invites.",
"workspace.referral.progress.title": "Progress",
"workspace.referral.progress.value": "{{count}} / {{total}} valid invites",
"workspace.referral.progress.next": "{{count}} more valid invite(s) to unlock {{amount}}.",
"workspace.referral.progress.complete": "All invite rewards unlocked.",
"workspace.referral.milestone.one": "1 valid invite",
"workspace.referral.milestone.three": "3 valid invites",
"workspace.referral.milestone.five": "5 valid invites",
"workspace.referral.credits.title": "Go credits",
"workspace.referral.credits.subtitle": "Use credits to reduce Go usage.",
"workspace.referral.credits.available": "Available",
"workspace.referral.credits.pending": "Pending",
"workspace.referral.credits.used": "Used",
"workspace.referral.credit.source.referral": "Earned by inviting a friend",
"workspace.referral.credit.source.invitee": "Received from an invite",
"workspace.referral.credit.status.pending": "Pending",
"workspace.referral.credit.status.available": "Available",
"workspace.referral.credit.status.used": "Used",
"workspace.referral.credit.status.expired": "Expired",
"workspace.referral.credit.status.revoked": "Revoked",
"workspace.referral.credit.availableOn": "Available on {{date}}",
"workspace.referral.credit.expiresOn": "Expires on {{date}}",
"workspace.referral.apply.title": "{{amount}} Go credit available",
"workspace.referral.apply.body": "Apply credits to reduce this workspace's current Go usage.",
"workspace.referral.apply.noUsage": "Use Go first, then apply credits to reduce your monthly usage.",
"workspace.referral.apply.action": "Apply credit",
"workspace.referral.apply.confirmTitle": "Apply Go credit",
"workspace.referral.apply.confirmBody": "Apply {{amount}} to current Go usage of {{usage}}. Usage after credit: {{usageAfter}}.",
"workspace.referral.apply.confirmAction": "Apply",
"download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
"download.hero.title": "Download OpenCode",

View File

@@ -251,6 +251,9 @@ export const dict = {
"go.cta.text": "订阅 Go",
"go.cta.price": "$10/月",
"go.cta.promo": "首月 $5",
"go.referral.title": "邀请好友",
"go.referral.link": "邀请",
"go.referral.note": "好友订阅后可获得 $10 Go 用量抵扣。您最多可获得 $60 Go 用量抵扣。",
"go.pricing.body": "可配合任何代理使用。首月 $5之后 $10/月。如有需要可充值。随时取消。",
"go.graph.free": "免费",
"go.graph.freePill": "Big Pickle 和免费模型",
@@ -406,6 +409,7 @@ export const dict = {
"workspace.nav.zen": "Zen",
"workspace.nav.go": "Go",
"workspace.nav.invite": "邀请",
"workspace.nav.usage": "使用量",
"workspace.nav.apiKeys": "API 密钥",
"workspace.nav.members": "成员",
@@ -643,6 +647,49 @@ export const dict = {
"workspace.lite.promo.otherMethods": "其他付款方式",
"workspace.lite.promo.selectMethod": "选择付款方式",
"workspace.referral.page.subtitle": "邀请好友使用 Go并管理您的用量抵扣。",
"workspace.referral.inviteApplied": "已应用邀请。订阅后将获得 Go 用量抵扣。",
"workspace.referral.banner.title": "邀请好友使用 Go",
"workspace.referral.banner.body": "好友订阅后,您最多可获得 $60 Go 用量抵扣。",
"workspace.referral.banner.action": "邀请",
"workspace.referral.inviteLink": "邀请链接",
"workspace.referral.copyLink": "复制链接",
"workspace.referral.copied": "已复制",
"workspace.referral.overview.title": "邀请好友使用 Go",
"workspace.referral.overview.subtitle": "通过您的链接订阅的好友可获得 {{reward}} Go 用量抵扣。您最多可获得 {{max}}。",
"workspace.referral.instructions.title": "邀请说明",
"workspace.referral.instructions.share": "分享您的邀请链接。",
"workspace.referral.instructions.subscribe": "好友订阅 Go 后获得 $10 Go 用量抵扣。",
"workspace.referral.instructions.unlock": "在达到 1、3、5 个有效邀请时,最多解锁 $60 Go 用量抵扣。",
"workspace.referral.progress.title": "进度",
"workspace.referral.progress.value": "{{count}} / {{total}} 个有效邀请",
"workspace.referral.progress.next": "再完成 {{count}} 个有效邀请可解锁 {{amount}}。",
"workspace.referral.progress.complete": "已解锁全部邀请奖励。",
"workspace.referral.milestone.one": "1 个有效邀请",
"workspace.referral.milestone.three": "3 个有效邀请",
"workspace.referral.milestone.five": "5 个有效邀请",
"workspace.referral.credits.title": "Go 抵扣",
"workspace.referral.credits.subtitle": "使用抵扣减少 Go 用量。",
"workspace.referral.credits.available": "可用",
"workspace.referral.credits.pending": "待生效",
"workspace.referral.credits.used": "已使用",
"workspace.referral.credit.source.referral": "邀请好友获得",
"workspace.referral.credit.source.invitee": "通过邀请获得",
"workspace.referral.credit.status.pending": "待生效",
"workspace.referral.credit.status.available": "可用",
"workspace.referral.credit.status.used": "已使用",
"workspace.referral.credit.status.expired": "已过期",
"workspace.referral.credit.status.revoked": "已撤销",
"workspace.referral.credit.availableOn": "{{date}} 可用",
"workspace.referral.credit.expiresOn": "{{date}} 过期",
"workspace.referral.apply.title": "{{amount}} Go 抵扣可用",
"workspace.referral.apply.body": "应用抵扣以减少当前工作区的 Go 用量。",
"workspace.referral.apply.noUsage": "先使用 Go再应用抵扣减少本月用量。",
"workspace.referral.apply.action": "使用抵扣",
"workspace.referral.apply.confirmTitle": "使用 Go 抵扣",
"workspace.referral.apply.confirmBody": "将 {{amount}} 抵扣当前 Go 用量 {{usage}}。抵扣后用量为 {{usageAfter}}。",
"workspace.referral.apply.confirmAction": "使用",
"download.title": "OpenCode | 下载",
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
"download.hero.title": "下载 OpenCode",

View File

@@ -807,6 +807,19 @@ body {
}
}
[data-component="referral"] {
border-top: 1px solid var(--color-border-weak);
padding: var(--vertical-padding) var(--padding);
background: var(--color-background);
a {
color: var(--color-text-strong);
text-decoration: underline;
text-underline-offset: var(--space-1);
text-decoration-thickness: 1px;
}
}
[data-component="problem"] {
border-top: 1px solid var(--color-border-weak);
padding: var(--vertical-padding) var(--padding);

View File

@@ -1,5 +1,5 @@
import "./index.css"
import { createAsync, query } from "@solidjs/router"
import { createAsync, query, useLocation } from "@solidjs/router"
import { Title, Meta } from "@solidjs/meta"
import { For, createMemo, createSignal, onCleanup, onMount } from "solid-js"
//import { HttpHeader } from "@solidjs/start"
@@ -224,8 +224,15 @@ function LimitsGraph(props: { href: string }) {
}
export default function Home() {
const location = useLocation()
const workspaceID = createAsync(() => checkLoggedIn())
const subscribeUrl = createMemo(() => (workspaceID() ? `/workspace/${workspaceID()}/go` : "/auth"))
const inviteCode = createMemo(() => new URLSearchParams(location.search).get("invite") ?? undefined)
const subscribeUrl = createMemo(() => {
const invite = inviteCode() ? `?invite=${encodeURIComponent(inviteCode()!)}` : ""
if (workspaceID()) return `/workspace/${workspaceID()}/go${invite}`
return "/auth"
})
const referralUrl = createMemo(() => (workspaceID() ? `/workspace/${workspaceID()}/go/invite` : "/auth"))
const i18n = useI18n()
const language = useLanguage()
return (
@@ -380,6 +387,15 @@ export default function Home() {
<LimitsGraph href={language.route("/docs/go/#usage-limits")} />
</section>
<section data-component="referral">
<div data-slot="section-title">
<h3>{i18n.t("go.referral.title")}</h3>
<p>
{i18n.t("go.referral.note")} <a href={referralUrl()}>{i18n.t("go.referral.link")}</a>
</p>
</div>
</section>
<section data-component="problem">
<div data-slot="section-title">
<h3>{i18n.t("go.problem.title")}</h3>

View File

@@ -58,6 +58,18 @@
border-radius: 0 2px 2px 0;
}
}
&[data-nav-child] {
margin-top: calc(-1 * var(--space-2));
margin-left: var(--space-5);
padding-top: var(--space-2);
padding-bottom: var(--space-2);
font-size: var(--font-size-xs);
&.active::before {
display: none;
}
}
}
}
}
@@ -121,6 +133,12 @@
border-radius: 2px 2px 0 0;
}
}
&[data-nav-child] {
padding-left: var(--space-2);
padding-right: var(--space-2);
font-size: var(--font-size-xs);
}
}
}
}

View File

@@ -22,6 +22,9 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
<A href={`/workspace/${params.id}/go`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.go")}
</A>
<A href={`/workspace/${params.id}/go/invite`} activeClass="active" data-nav-button data-nav-child>
{i18n.t("workspace.nav.invite")}
</A>
<A href={`/workspace/${params.id}/usage`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.usage")}
</A>
@@ -50,6 +53,9 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
<A href={`/workspace/${params.id}/go`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.go")}
</A>
<A href={`/workspace/${params.id}/go/invite`} activeClass="active" data-nav-button data-nav-child>
{i18n.t("workspace.nav.invite")}
</A>
<A href={`/workspace/${params.id}/usage`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.usage")}
</A>

View File

@@ -0,0 +1,31 @@
import { createAsync, useParams } from "@solidjs/router"
import { Show } from "solid-js"
import { IconGo } from "~/component/icon"
import { GoReferralCredits, GoReferralOverview, queryGoReferral } from "~/component/go-referral"
import { useI18n } from "~/context/i18n"
export default function () {
const params = useParams()
const i18n = useI18n()
const referral = createAsync(() => queryGoReferral(params.id!))
return (
<div data-page="workspace-[id]">
<section data-component="header-section">
<IconGo />
<p>{i18n.t("workspace.referral.page.subtitle")}</p>
</section>
<div data-slot="sections">
<Show when={referral()} fallback={<section>{i18n.t("workspace.lite.loading")}</section>}>
{(summary) => (
<>
<GoReferralOverview summary={summary()} />
<GoReferralCredits workspaceID={params.id!} summary={summary()} />
</>
)}
</Show>
</div>
</div>
)
}

View File

@@ -174,6 +174,18 @@
}
}
[data-slot="invite-applied"] {
width: fit-content;
margin-top: var(--space-4);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-surface);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: 1.5;
}
[data-slot="promo-models-title"] {
font-size: var(--font-size-md);
font-weight: 600;

View File

@@ -1,4 +1,4 @@
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
import { action, useParams, useAction, useSubmission, json, query, createAsync, useLocation } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { createMemo, For, Show } from "solid-js"
import { Modal } from "~/component/modal"
@@ -14,6 +14,7 @@ import styles from "./lite-section.module.css"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error"
import { GoCreditApplyCard, GoReferralBanner, queryGoReferral } from "~/component/go-referral"
import { IconAlipay, IconUpi } from "~/component/icon"
@@ -82,8 +83,9 @@ function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
}
const createLiteCheckoutUrl = action(
async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi", inviteCode?: string) => {
"use server"
void inviteCode
return json(
await withActor(
() =>
@@ -142,11 +144,14 @@ const setLiteUseBalance = action(async (form: FormData) => {
export function LiteSection() {
const params = useParams()
const location = useLocation()
const i18n = useI18n()
const language = useLanguage()
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const isBlack = createMemo(() => billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked)
const lite = createAsync(() => queryLiteSubscription(params.id!))
const referral = createAsync(() => queryGoReferral(params.id!))
const inviteCode = createMemo(() => new URLSearchParams(location.search).get("invite") ?? undefined)
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
const checkoutAction = useAction(createLiteCheckoutUrl)
@@ -171,7 +176,7 @@ export function LiteSection() {
async function onClickSubscribe(method?: "alipay" | "upi") {
setStore("loading", method ?? "checkout")
const result = await checkoutAction(params.id!, window.location.href, window.location.href, method)
const result = await checkoutAction(params.id!, window.location.href, window.location.href, method, inviteCode())
if (result.data) {
window.location.href = result.data
return
@@ -206,6 +211,7 @@ export function LiteSection() {
</a>
.
</div>
<GoReferralBanner href={`/workspace/${params.id}/go/invite`} />
<div data-slot="usage">
<div data-slot="usage-item">
<div data-slot="usage-header">
@@ -246,6 +252,7 @@ export function LiteSection() {
</span>
</div>
</div>
<GoCreditApplyCard workspaceID={params.id!} summary={referral()} />
<form action={setLiteUseBalance} method="post" data-slot="setting-row">
<p>{i18n.t("workspace.lite.subscription.useBalance")}</p>
<input type="hidden" name="workspaceID" value={params.id} />
@@ -299,6 +306,9 @@ export function LiteSection() {
<li>DeepSeek V4 Flash</li>
</ul>
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
<Show when={inviteCode()}>
<p data-slot="invite-applied">{i18n.t("workspace.referral.inviteApplied")}</p>
</Show>
<div data-slot="subscribe-actions">
<button
data-slot="subscribe-button"