From 940d7c351501cadc9a91223cf60bf344b3a20fa7 Mon Sep 17 00:00:00 2001 From: Jack Date: Tue, 12 May 2026 21:29:56 +0800 Subject: [PATCH] feat(console): add Go referral UI --- .../console/app/src/component/go-referral.css | 330 ++++++++++++++ .../console/app/src/component/go-referral.tsx | 420 ++++++++++++++++++ packages/console/app/src/i18n/en.ts | 47 ++ packages/console/app/src/i18n/zh.ts | 47 ++ packages/console/app/src/routes/go/index.css | 13 + packages/console/app/src/routes/go/index.tsx | 20 +- .../console/app/src/routes/workspace/[id].css | 18 + .../console/app/src/routes/workspace/[id].tsx | 6 + .../routes/workspace/[id]/go/invite/index.tsx | 31 ++ .../workspace/[id]/go/lite-section.module.css | 12 + .../routes/workspace/[id]/go/lite-section.tsx | 16 +- 11 files changed, 955 insertions(+), 5 deletions(-) create mode 100644 packages/console/app/src/component/go-referral.css create mode 100644 packages/console/app/src/component/go-referral.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/go/invite/index.tsx diff --git a/packages/console/app/src/component/go-referral.css b/packages/console/app/src/component/go-referral.css new file mode 100644 index 0000000000..400899a583 --- /dev/null +++ b/packages/console/app/src/component/go-referral.css @@ -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; + } +} diff --git a/packages/console/app/src/component/go-referral.tsx b/packages/console/app/src/component/go-referral.tsx new file mode 100644 index 0000000000..4ace3fefb0 --- /dev/null +++ b/packages/console/app/src/component/go-referral.tsx @@ -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) { + 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 ( +
+ +
+ {props.summary.inviteUrl} + +
+
+ ) +} + +function ReferralProgress(props: { summary: GoReferralSummary }) { + const i18n = useI18n() + const progress = createMemo(() => Math.min(100, (props.summary.validInviteCount / props.summary.maxInviteCount) * 100)) + + return ( +
+
+ {i18n.t("workspace.referral.progress.title")} + + {i18n.t("workspace.referral.progress.value", { + count: props.summary.validInviteCount, + total: props.summary.maxInviteCount, + })} + +
+
+
+
+
+ + {(milestone) => ( +
+ {milestoneLabel(milestone.inviteCount, i18n)} + {formatCurrency(milestone.amount)} +
+ )} +
+
+

+ + {i18n.t("workspace.referral.progress.next", { + count: Math.max(0, props.summary.nextInviteCount! - props.summary.validInviteCount), + amount: formatCurrency(props.summary.nextReward!), + })} + +

+
+ ) +} + +export function GoReferralBanner(props: { href: string }) { + const i18n = useI18n() + + return ( +
+
+ {i18n.t("workspace.referral.banner.title")} +

{i18n.t("workspace.referral.banner.body")}

+
+ + {i18n.t("workspace.referral.banner.action")} + +
+ ) +} + +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 ( +
+
+ + {i18n.t("workspace.referral.apply.title", { amount: formatCurrency(available()) })} + +

+ 0} fallback={i18n.t("workspace.referral.apply.noUsage")}> + {i18n.t("workspace.referral.apply.body")} + +

+
+ + setConfirming(false)} title={i18n.t("workspace.referral.apply.confirmTitle")}> +
+

+ {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())), + })} +

+
+ + +
+
+
+
+ ) +} + +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() + const [appliedCredits, setAppliedCredits] = createSignal>({}) + + 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 ( +
+
+

{i18n.t("workspace.referral.credits.title")}

+

{i18n.t("workspace.referral.credits.subtitle")}

+
+
+ + {(credit) => ( +
+
+ {formatCurrency(creditRemaining(credit) || credit.amount)} + {i18n.t(creditSourceKey(credit.source))} +
+
+ {i18n.t(creditStatusKey(credit.status))} + + {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())), + })} + +
+ +
+ )} +
+
+ setSelected(undefined)} title={i18n.t("workspace.referral.apply.confirmTitle")}> +
+

+ 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())), + })} + +

+
+ + +
+
+
+
+ ) +} + +export function GoReferralOverview(props: { summary: GoReferralSummary }) { + const i18n = useI18n() + + return ( +
+
+

{i18n.t("workspace.referral.overview.title")}

+

+ {i18n.t("workspace.referral.overview.subtitle", { + max: formatCurrency(props.summary.maxReward), + reward: formatCurrency(props.summary.invitedUserReward), + })} +

+
+ + + +
+ ) +} + +export function InvitationInstructions() { + const i18n = useI18n() + + return ( +
+ {i18n.t("workspace.referral.instructions.title")} +
    +
  1. {i18n.t("workspace.referral.instructions.share")}
  2. +
  3. {i18n.t("workspace.referral.instructions.subscribe")}
  4. +
  5. {i18n.t("workspace.referral.instructions.unlock")}
  6. +
+
+ ) +} diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index b7ef397be6..1751218a51 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -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", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index ced0060ca0..0eacc36fe7 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -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", diff --git a/packages/console/app/src/routes/go/index.css b/packages/console/app/src/routes/go/index.css index 25ae00e5f8..6a2b001dea 100644 --- a/packages/console/app/src/routes/go/index.css +++ b/packages/console/app/src/routes/go/index.css @@ -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); diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 71102c7227..f9c78dcd85 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -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() { +
+
+

{i18n.t("go.referral.title")}

+

+ {i18n.t("go.referral.note")} {i18n.t("go.referral.link")} +

+
+
+

{i18n.t("go.problem.title")}

diff --git a/packages/console/app/src/routes/workspace/[id].css b/packages/console/app/src/routes/workspace/[id].css index 5d19988b3f..beb1b90d72 100644 --- a/packages/console/app/src/routes/workspace/[id].css +++ b/packages/console/app/src/routes/workspace/[id].css @@ -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); + } } } } diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx index 7a8e1616f1..8ef0849079 100644 --- a/packages/console/app/src/routes/workspace/[id].tsx +++ b/packages/console/app/src/routes/workspace/[id].tsx @@ -22,6 +22,9 @@ export default function WorkspaceLayout(props: RouteSectionProps) { {i18n.t("workspace.nav.go")} + + {i18n.t("workspace.nav.invite")} + {i18n.t("workspace.nav.usage")} @@ -50,6 +53,9 @@ export default function WorkspaceLayout(props: RouteSectionProps) { {i18n.t("workspace.nav.go")} + + {i18n.t("workspace.nav.invite")} + {i18n.t("workspace.nav.usage")} diff --git a/packages/console/app/src/routes/workspace/[id]/go/invite/index.tsx b/packages/console/app/src/routes/workspace/[id]/go/invite/index.tsx new file mode 100644 index 0000000000..8ce9ffa358 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/go/invite/index.tsx @@ -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 ( +
+
+ +

{i18n.t("workspace.referral.page.subtitle")}

+
+ +
+ {i18n.t("workspace.lite.loading")}
}> + {(summary) => ( + <> + + + + )} + +
+ + ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css b/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css index 05daf43b7a..8f9df58405 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css @@ -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; diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index eba52b0e17..7842a43500 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -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) { } 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() { . +
@@ -246,6 +252,7 @@ export function LiteSection() {
+

{i18n.t("workspace.lite.subscription.useBalance")}

@@ -299,6 +306,9 @@ export function LiteSection() {
  • DeepSeek V4 Flash
  • {i18n.t("workspace.lite.promo.footer")}

    + +

    {i18n.t("workspace.referral.inviteApplied")}

    +