diff --git a/packages/console/app/src/component/go-referral.css b/packages/console/app/src/component/go-referral.css
index 96d0e47d7f..c9050e6cf9 100644
--- a/packages/console/app/src/component/go-referral.css
+++ b/packages/console/app/src/component/go-referral.css
@@ -1,46 +1,3 @@
-[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-credit-confirm"] {
display: flex;
flex-direction: column;
@@ -177,6 +134,9 @@
}
button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
gap: var(--space-2);
min-width: 130px;
white-space: nowrap;
diff --git a/packages/console/app/src/component/go-referral.tsx b/packages/console/app/src/component/go-referral.tsx
index 1897caf33f..6f0b305bb8 100644
--- a/packages/console/app/src/component/go-referral.tsx
+++ b/packages/console/app/src/component/go-referral.tsx
@@ -2,20 +2,16 @@ import { action, createAsync, json, query, useAction, useSubmission } from "@sol
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import { Referral } from "@opencode-ai/console-core/referral.js"
-import { Database, and, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
-import { LiteData } from "@opencode-ai/console-core/lite.js"
-import { LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
-import { ReferralRewardTable } from "@opencode-ai/console-core/schema/referral.sql.js"
-import { Subscription } from "@opencode-ai/console-core/subscription.js"
import { withActor } from "~/context/auth.withActor"
import { Modal } from "~/component/modal"
import { IconCheck, IconCopy } from "~/component/icon"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
-import { formatResetTime, queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section"
+import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
+import { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section"
import "./go-referral.css"
-export type GoReferralReward = {
+type GoReferralReward = {
id: string
amount: number
email: string
@@ -25,7 +21,7 @@ export type GoReferralReward = {
timeApplied: string | Date | null
}
-export type GoReferralSummary = {
+type GoReferralSummary = {
inviteCode: string
inviteUrl: string
validInviteCount: number
@@ -36,12 +32,6 @@ export type GoReferralSummary = {
rewards: GoReferralReward[]
}
-type AnalyzedUsage = {
- status: "ok" | "rate-limited"
- resetInSec: number
- usagePercent: number
-}
-
type GoReferralUsagePreview = {
rollingUsage: GoReferralUsagePreviewItem
weeklyUsage: GoReferralUsagePreviewItem
@@ -74,76 +64,7 @@ export const queryGoReferral = query(async (workspaceID: string) => {
export const queryGoReferralUsagePreview = query(async (workspaceID: string, referralID?: string) => {
"use server"
if (!referralID) return null
- return withActor(async () => {
- const row = await Database.use((tx) =>
- tx
- .select({
- rewardAmount: ReferralRewardTable.amount,
- rollingUsage: LiteTable.rollingUsage,
- weeklyUsage: LiteTable.weeklyUsage,
- monthlyUsage: LiteTable.monthlyUsage,
- timeRollingUpdated: LiteTable.timeRollingUpdated,
- timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
- timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
- timeCreated: LiteTable.timeCreated,
- })
- .from(ReferralRewardTable)
- .innerJoin(LiteTable, eq(LiteTable.workspaceID, ReferralRewardTable.workspaceID))
- .where(
- and(
- eq(ReferralRewardTable.workspaceID, workspaceID),
- eq(ReferralRewardTable.referralID, referralID),
- isNull(ReferralRewardTable.timeApplied),
- isNull(ReferralRewardTable.timeDeleted),
- isNull(LiteTable.timeDeleted),
- ),
- )
- .then((rows) => rows[0]),
- )
- if (!row) return null
-
- const limits = LiteData.getLimits()
- const rollingBefore = Subscription.analyzeRollingUsage({
- limit: limits.rollingLimit,
- window: limits.rollingWindow,
- usage: row.rollingUsage ?? 0,
- timeUpdated: row.timeRollingUpdated ?? new Date(),
- })
- const rollingAfter = Subscription.analyzeRollingUsage({
- limit: limits.rollingLimit,
- window: limits.rollingWindow,
- usage: Math.max(0, (row.rollingUsage ?? 0) - row.rewardAmount),
- timeUpdated: row.timeRollingUpdated ?? new Date(),
- })
- const weeklyBefore = Subscription.analyzeWeeklyUsage({
- limit: limits.weeklyLimit,
- usage: row.weeklyUsage ?? 0,
- timeUpdated: row.timeWeeklyUpdated ?? new Date(),
- })
- const weeklyAfter = Subscription.analyzeWeeklyUsage({
- limit: limits.weeklyLimit,
- usage: Math.max(0, (row.weeklyUsage ?? 0) - row.rewardAmount),
- timeUpdated: row.timeWeeklyUpdated ?? new Date(),
- })
- const monthlyBefore = Subscription.analyzeMonthlyUsage({
- limit: limits.monthlyLimit,
- usage: row.monthlyUsage ?? 0,
- timeUpdated: row.timeMonthlyUpdated ?? new Date(),
- timeSubscribed: row.timeCreated,
- })
- const monthlyAfter = Subscription.analyzeMonthlyUsage({
- limit: limits.monthlyLimit,
- usage: Math.max(0, (row.monthlyUsage ?? 0) - row.rewardAmount),
- timeUpdated: row.timeMonthlyUpdated ?? new Date(),
- timeSubscribed: row.timeCreated,
- })
-
- return {
- rollingUsage: usagePreview(rollingBefore, rollingAfter),
- weeklyUsage: usagePreview(weeklyBefore, weeklyAfter),
- monthlyUsage: usagePreview(monthlyBefore, monthlyAfter),
- } satisfies GoReferralUsagePreview
- }, workspaceID)
+ return withActor(() => Referral.usagePreview({ referralID }), workspaceID)
}, "go.referral.usagePreview")
export const applyGoReferralReward = action(async (workspaceID: string, referralID: string) => {
@@ -160,18 +81,14 @@ export const applyGoReferralReward = action(async (workspaceID: string, referral
)
}, "go.referral.reward.apply")
-function usagePreview(before: AnalyzedUsage, after: AnalyzedUsage) {
+function currentUsagePreview(usage: { resetInSec: number; usagePercent: number }) {
return {
- beforePercent: before.usagePercent,
- afterPercent: after.usagePercent,
- resetInSec: after.resetInSec,
+ beforePercent: usage.usagePercent,
+ afterPercent: usage.usagePercent,
+ resetInSec: usage.resetInSec,
}
}
-function currentUsagePreview(usage: AnalyzedUsage) {
- return usagePreview(usage, usage)
-}
-
function formatCurrency(amount: number) {
if (amount % 100 === 0) return `$${amount / 100}`
return `$${(amount / 100).toFixed(2)}`
@@ -299,7 +216,14 @@ export function GoReferralSection(props: { workspaceID: string; summary: GoRefer
-
+
+
+ - {i18n.t("workspace.referral.instructions.share")}
+ - {i18n.t("workspace.referral.instructions.subscribe")}
+ - {i18n.t("workspace.referral.instructions.claim")}
+ - {i18n.t("workspace.referral.instructions.apply", { amount: formatCurrency(props.summary.rewardAmount) })}
+
+
{i18n.t("workspace.referral.rewards.title")}
@@ -327,9 +251,9 @@ export function GoReferralSection(props: { workspaceID: string; summary: GoRefer
{(reward) => {
- const applied = createMemo(() => reward.status === "applied")
- const pending = createMemo(() => reward.status === "pending")
- const earnedAt = createMemo(() => formatDate(reward.timeCreated, language.tag(language.locale())))
+ const applied = reward.status === "applied"
+ const pending = reward.status === "pending"
+ const earnedAt = () => formatDate(reward.timeCreated, language.tag(language.locale()))
return (
| {formatCurrency(reward.amount)} |
@@ -346,8 +270,8 @@ export function GoReferralSection(props: { workspaceID: string; summary: GoRefer
disabled={reward.status !== "available" || !props.summary.hasActiveGo || submission.pending}
onClick={() => setSelected(reward)}
>
-
- {pending()
+
+ {pending
? i18n.t(rewardPendingStatusKey(reward.source))
: props.summary.hasActiveGo
? i18n.t("workspace.referral.apply.action")
@@ -424,23 +348,8 @@ function GoReferralUsagePreviewRow(props: { label: string; usage: GoReferralUsag
- {i18n.t("workspace.lite.subscription.resetsIn")} {formatResetTime(props.usage.resetInSec, i18n)}
+ {i18n.t("workspace.lite.subscription.resetsIn")} {formatResetTime(props.usage.resetInSec, i18n, liteResetTimeKeys)}
)
}
-
-function InvitationInstructions(props: { rewardAmount: number }) {
- const i18n = useI18n()
-
- return (
-
-
- - {i18n.t("workspace.referral.instructions.share")}
- - {i18n.t("workspace.referral.instructions.subscribe")}
- - {i18n.t("workspace.referral.instructions.claim")}
- - {i18n.t("workspace.referral.instructions.apply", { amount: formatCurrency(props.rewardAmount) })}
-
-
- )
-}
diff --git a/packages/console/app/src/component/modal.css b/packages/console/app/src/component/modal.css
index cc7708bba8..ab7f222b02 100644
--- a/packages/console/app/src/component/modal.css
+++ b/packages/console/app/src/component/modal.css
@@ -55,6 +55,61 @@
@media (prefers-color-scheme: dark) {
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
+
+ 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;
+ transition: all 0.15s ease;
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-surface-hover);
+ border-color: var(--color-accent);
+ }
+
+ &:active:not(:disabled) {
+ transform: translateY(1px);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+ }
+
+ &[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-color="ghost"] {
+ background-color: transparent;
+ border-color: transparent;
+ color: var(--color-text-muted);
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-surface-hover);
+ border-color: var(--color-border);
+ color: var(--color-text);
+ }
+ }
+ }
}
[data-slot="title"] {
diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts
index e2fe92efb5..ad5bc7d5fe 100644
--- a/packages/console/app/src/i18n/en.ts
+++ b/packages/console/app/src/i18n/en.ts
@@ -662,7 +662,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Other payment methods",
"workspace.lite.promo.selectMethod": "Select payment method",
- "workspace.referral.inviteLink": "Invite link",
"workspace.referral.copyLink": "Copy Link",
"workspace.referral.copied": "Copied",
"workspace.referral.overview.title": "Invite friends to Go",
@@ -680,8 +679,6 @@ export const dict = {
"workspace.referral.table.reward": "Reward",
"workspace.referral.table.referral": "Description",
"workspace.referral.table.date": "Date",
- "workspace.referral.reward.source.inviter": "Earned by inviting a friend",
- "workspace.referral.reward.source.invitee": "Received from an invite",
"workspace.referral.reward.source.pendingInviter": "Waiting for them to subscribe",
"workspace.referral.reward.source.pendingInvitee": "Subscribe to unlock reward",
"workspace.referral.reward.source.available": "Reward ready to apply",
@@ -689,9 +686,6 @@ export const dict = {
"workspace.referral.reward.status.applied": "Applied",
"workspace.referral.reward.status.pendingInviter": "Waiting for subscription",
"workspace.referral.reward.status.pendingInvitee": "Subscribe to unlock",
- "workspace.referral.reward.pending.inviter": "Reward unlocks when they subscribe to Go.",
- "workspace.referral.reward.pending.invitee": "Subscribe to Go to unlock this reward.",
- "workspace.referral.reward.earnedOn": "{{date}}",
"workspace.referral.apply.noGo": "Subscribe to Go",
"workspace.referral.apply.action": "Apply",
"workspace.referral.apply.confirmTitle": "Apply Go reward",
diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts
index 62a78a6f25..4fbb7d600e 100644
--- a/packages/console/app/src/i18n/zh.ts
+++ b/packages/console/app/src/i18n/zh.ts
@@ -643,7 +643,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "其他付款方式",
"workspace.lite.promo.selectMethod": "选择付款方式",
- "workspace.referral.inviteLink": "邀请链接",
"workspace.referral.copyLink": "复制链接",
"workspace.referral.copied": "已复制",
"workspace.referral.overview.title": "邀请好友使用 Go",
@@ -661,8 +660,6 @@ export const dict = {
"workspace.referral.table.reward": "奖励",
"workspace.referral.table.referral": "描述",
"workspace.referral.table.date": "日期",
- "workspace.referral.reward.source.inviter": "邀请好友获得",
- "workspace.referral.reward.source.invitee": "通过邀请获得",
"workspace.referral.reward.source.pendingInviter": "等待对方订阅",
"workspace.referral.reward.source.pendingInvitee": "订阅即可解锁奖励",
"workspace.referral.reward.source.available": "奖励可使用",
@@ -670,9 +667,6 @@ export const dict = {
"workspace.referral.reward.status.applied": "已使用",
"workspace.referral.reward.status.pendingInviter": "等待订阅",
"workspace.referral.reward.status.pendingInvitee": "订阅后解锁",
- "workspace.referral.reward.pending.inviter": "好友订阅 Go 后即可解锁奖励。",
- "workspace.referral.reward.pending.invitee": "订阅 Go 后即可解锁此奖励。",
- "workspace.referral.reward.earnedOn": "{{date}}",
"workspace.referral.apply.noGo": "订阅 Go",
"workspace.referral.apply.action": "使用",
"workspace.referral.apply.confirmTitle": "使用 Go 奖励",
diff --git a/packages/console/app/src/lib/format-reset-time.ts b/packages/console/app/src/lib/format-reset-time.ts
new file mode 100644
index 0000000000..2462a87a3f
--- /dev/null
+++ b/packages/console/app/src/lib/format-reset-time.ts
@@ -0,0 +1,47 @@
+import type { Key } from "~/i18n"
+import type { useI18n } from "~/context/i18n"
+
+type ResetTimeKeys = {
+ day: Key
+ days: Key
+ hour: Key
+ hours: Key
+ minute: Key
+ minutes: Key
+ fewSeconds: Key
+}
+
+export const liteResetTimeKeys = {
+ day: "workspace.lite.time.day",
+ days: "workspace.lite.time.days",
+ hour: "workspace.lite.time.hour",
+ hours: "workspace.lite.time.hours",
+ minute: "workspace.lite.time.minute",
+ minutes: "workspace.lite.time.minutes",
+ fewSeconds: "workspace.lite.time.fewSeconds",
+} satisfies ResetTimeKeys
+
+export const blackResetTimeKeys = {
+ day: "workspace.black.time.day",
+ days: "workspace.black.time.days",
+ hour: "workspace.black.time.hour",
+ hours: "workspace.black.time.hours",
+ minute: "workspace.black.time.minute",
+ minutes: "workspace.black.time.minutes",
+ fewSeconds: "workspace.black.time.fewSeconds",
+} satisfies ResetTimeKeys
+
+export function formatResetTime(seconds: number, i18n: ReturnType, keys: ResetTimeKeys) {
+ const days = Math.floor(seconds / 86400)
+ if (days >= 1) {
+ const hours = Math.floor((seconds % 86400) / 3600)
+ return `${days} ${days === 1 ? i18n.t(keys.day) : i18n.t(keys.days)} ${hours} ${hours === 1 ? i18n.t(keys.hour) : i18n.t(keys.hours)}`
+ }
+
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ if (hours >= 1)
+ return `${hours} ${hours === 1 ? i18n.t(keys.hour) : i18n.t(keys.hours)} ${minutes} ${minutes === 1 ? i18n.t(keys.minute) : i18n.t(keys.minutes)}`
+ if (minutes === 0) return i18n.t(keys.fewSeconds)
+ return `${minutes} ${minutes === 1 ? i18n.t(keys.minute) : i18n.t(keys.minutes)}`
+}
diff --git a/packages/console/app/src/lib/referral-invite.ts b/packages/console/app/src/lib/referral-invite.ts
index 835364d40c..f5bb96f065 100644
--- a/packages/console/app/src/lib/referral-invite.ts
+++ b/packages/console/app/src/lib/referral-invite.ts
@@ -1,8 +1,10 @@
+import { Referral } from "@opencode-ai/console-core/referral.js"
+
const INVITE_COOKIE = "opencode.go.invite"
const INVITE_MAX_AGE = 60 * 60 * 24 * 30
export function normalizeInviteCode(code?: string | null) {
- return code?.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 10)
+ return Referral.normalizeCode(code)
}
export function inviteCookie(code: string) {
diff --git a/packages/console/app/src/routes/workspace-picker.css b/packages/console/app/src/routes/workspace-picker.css
index e566e9665e..df7552fb75 100644
--- a/packages/console/app/src/routes/workspace-picker.css
+++ b/packages/console/app/src/routes/workspace-picker.css
@@ -39,56 +39,6 @@
[data-component="workspace-create-modal"] {
width: 100%;
- button {
- 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;
- cursor: pointer;
- transition: all 0.15s ease;
-
- &:hover:not(:disabled) {
- background-color: var(--color-surface-hover);
- border-color: var(--color-accent);
- }
-
- &:active {
- transform: translateY(1px);
- }
-
- &:disabled {
- opacity: 0.5;
- transform: none;
- }
-
- &[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-color="ghost"] {
- background-color: transparent;
- border-color: transparent;
- color: var(--color-text-muted);
-
- &:hover:not(:disabled) {
- background-color: var(--color-surface-hover);
- border-color: var(--color-border);
- color: var(--color-text);
- }
- }
- }
-
[data-slot="create-form"] {
width: 100%;
}
diff --git a/packages/console/app/src/routes/workspace-picker.tsx b/packages/console/app/src/routes/workspace-picker.tsx
index 16d0b8197b..83f5582ebe 100644
--- a/packages/console/app/src/routes/workspace-picker.tsx
+++ b/packages/console/app/src/routes/workspace-picker.tsx
@@ -1,6 +1,5 @@
import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router"
-import { For, createEffect } from "solid-js"
-import { createStore } from "solid-js/store"
+import { For, createEffect, createSignal } from "solid-js"
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"
@@ -51,9 +50,7 @@ export function WorkspacePicker() {
const i18n = useI18n()
const workspaces = createAsync(() => getWorkspaces())
const submission = useSubmission(createWorkspace)
- const [store, setStore] = createStore({
- showForm: false,
- })
+ const [showForm, setShowForm] = createSignal(false)
let inputRef: HTMLInputElement | undefined
const currentWorkspace = () => {
@@ -61,12 +58,8 @@ export function WorkspacePicker() {
return ws ? ws.name : i18n.t("workspace.select")
}
- const handleWorkspaceNew = () => {
- setStore("showForm", true)
- }
-
createEffect(() => {
- if (store.showForm && inputRef) {
+ if (showForm() && inputRef) {
setTimeout(() => inputRef?.focus(), 0)
}
})
@@ -79,7 +72,7 @@ export function WorkspacePicker() {
// Reset signals when workspace ID changes
createEffect(() => {
params.id
- setStore("showForm", false)
+ setShowForm(false)
})
return (
@@ -92,12 +85,12 @@ export function WorkspacePicker() {
)}
-