From aeab589b5fd3c96a53bbcc8614f4d09b500928b2 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 14 May 2026 16:30:57 +0200 Subject: [PATCH] update go referral component and add usage preview --- bun.lock | 4 +- package.json | 2 +- .../console/app/src/component/go-referral.css | 201 +++++++++++---- .../console/app/src/component/go-referral.tsx | 239 ++++++++++++++---- packages/console/app/src/i18n/en.ts | 19 +- packages/console/app/src/i18n/zh.ts | 11 +- .../routes/workspace/[id]/go/lite-section.tsx | 2 +- .../app/src/routes/zen/util/handler.ts | 4 + packages/console/core/drizzle.config.ts | 2 + packages/console/core/script/update-models.ts | 2 +- 10 files changed, 368 insertions(+), 118 deletions(-) diff --git a/bun.lock b/bun.lock index 2a79552b9e..aa87c959df 100644 --- a/bun.lock +++ b/bun.lock @@ -745,7 +745,7 @@ "cross-spawn": "7.0.6", "diff": "8.0.2", "dompurify": "3.3.1", - "drizzle-kit": "1.0.0-beta.19-d95b7a4", + "drizzle-kit": "1.0.0-beta.22", "drizzle-orm": "1.0.0-beta.19-d95b7a4", "effect": "4.0.0-beta.65", "fuzzysort": "3.1.0", @@ -2972,7 +2972,7 @@ "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], - "drizzle-kit": ["drizzle-kit@1.0.0-beta.19-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="], + "drizzle-kit": ["drizzle-kit@1.0.0-beta.22", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-9HTZuQRljQKTgCx4UhiGn8KYYfHGk4+B/bRR1714W67kz0qgJvdrG527i8rQD8uUyET9UTGR1u8syySJD4znGw=="], "drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="], diff --git a/package.json b/package.json index f1cc7da5c3..6205d8773b 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", "dompurify": "3.3.1", - "drizzle-kit": "1.0.0-beta.19-d95b7a4", + "drizzle-kit": "1.0.0-beta.22", "drizzle-orm": "1.0.0-beta.19-d95b7a4", "effect": "4.0.0-beta.65", "ai": "6.0.168", diff --git a/packages/console/app/src/component/go-referral.css b/packages/console/app/src/component/go-referral.css index 3f09687530..9a86e37b5a 100644 --- a/packages/console/app/src/component/go-referral.css +++ b/packages/console/app/src/component/go-referral.css @@ -54,6 +54,80 @@ line-height: 1.6; } + [data-slot="usage-preview"] { + display: flex; + flex-direction: column; + gap: var(--space-5); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg-surface); + } + + [data-slot="usage-preview-item"] { + display: flex; + flex-direction: column; + gap: var(--space-2); + } + + [data-slot="usage-preview-header"] { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + } + + [data-slot="usage-preview-label"] { + color: var(--color-text); + font-size: var(--font-size-sm); + font-weight: 500; + } + + [data-slot="usage-preview-value"] { + display: inline-flex; + align-items: center; + gap: var(--space-1); + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + white-space: nowrap; + } + + [data-slot="usage-preview-after-value"] { + color: var(--color-accent); + font-weight: 600; + } + + [data-slot="usage-preview-progress"] { + position: relative; + height: 8px; + overflow: hidden; + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + } + + [data-slot="usage-preview-before"], + [data-slot="usage-preview-after"] { + position: absolute; + top: 0; + bottom: 0; + left: 0; + border-radius: var(--border-radius-sm); + } + + [data-slot="usage-preview-before"] { + background-color: var(--color-border); + } + + [data-slot="usage-preview-after"] { + background-color: var(--color-accent); + } + + [data-slot="usage-preview-reset"] { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + } + [data-slot="modal-actions"] { display: flex; justify-content: flex-end; @@ -130,11 +204,6 @@ list-style-position: inside; line-height: 1.5; } - - li::marker { - color: var(--color-text); - font-weight: 600; - } } [data-component="go-referral-section"] { @@ -210,10 +279,8 @@ } } - [data-slot="reward-list"] { - display: flex; - flex-direction: column; - gap: var(--space-2); + [data-slot="referrals-table"] { + overflow-x: auto; } [data-component="empty-state"] { @@ -225,48 +292,80 @@ font-size: var(--font-size-sm); } - [data-slot="reward-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); - - &[data-status="available"] { - background-color: var(--color-bg-surface); - } - - @media (max-width: 48rem) { - grid-template-columns: 1fr; - align-items: stretch; - } - } - - [data-slot="reward-main"], - [data-slot="reward-meta"] { - display: flex; - flex-direction: column; - gap: var(--space-1); - } - - [data-slot="reward-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="reward-meta"] { - color: var(--color-text-muted); + [data-slot="referrals-table-element"] { + width: 100%; + border-collapse: collapse; font-size: var(--font-size-sm); - line-height: 1.5; + + thead { + border-bottom: 1px solid var(--color-border); + } + + th { + padding: var(--space-3) var(--space-4); + text-align: left; + font-weight: normal; + color: var(--color-text-muted); + text-transform: uppercase; + + &:nth-child(1) { + width: 120px; + } + + &:nth-child(3) { + width: 180px; + } + + &:nth-child(4) { + width: 140px; + } + } + + td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-muted); + color: var(--color-text-muted); + font-family: var(--font-mono); + + &[data-slot="referral-amount"] { + color: var(--color-text); + font-weight: 500; + } + + &[data-slot="referral-source"] { + color: var(--color-text-secondary); + font-family: var(--font-sans); + } + + &[data-slot="referral-action"] { + text-align: right; + font-family: var(--font-sans); + white-space: nowrap; + + button { + min-width: 96px; + } + } + } + + tbody tr { + &[data-status="applied"] { + td:not([data-slot="referral-action"]) { + opacity: 0.68; + } + } + + &:last-child td { + border-bottom: none; + } + } + + @media (max-width: 40rem) { + th, + td { + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-xs); + } + } } } diff --git a/packages/console/app/src/component/go-referral.tsx b/packages/console/app/src/component/go-referral.tsx index 8a11a1b8a2..b4b12bc90c 100644 --- a/packages/console/app/src/component/go-referral.tsx +++ b/packages/console/app/src/component/go-referral.tsx @@ -1,13 +1,18 @@ -import { action, json, query, useAction, useSubmission } from "@solidjs/router" +import { action, createAsync, json, query, useAction, useSubmission } from "@solidjs/router" import { createMemo, createSignal, For, 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 { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section" +import { formatResetTime, queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section" import "./go-referral.css" export type GoReferralReward = { @@ -29,6 +34,24 @@ export type GoReferralSummary = { rewards: GoReferralReward[] } +type AnalyzedUsage = { + status: "ok" | "rate-limited" + resetInSec: number + usagePercent: number +} + +type GoReferralUsagePreview = { + rollingUsage: GoReferralUsagePreviewItem + weeklyUsage: GoReferralUsagePreviewItem + monthlyUsage: GoReferralUsagePreviewItem +} + +type GoReferralUsagePreviewItem = { + beforePercent: number + afterPercent: number + resetInSec: number +} + export const queryGoReferral = query(async (workspaceID: string) => { "use server" return withActor(async () => { @@ -40,6 +63,81 @@ export const queryGoReferral = query(async (workspaceID: string) => { }, workspaceID) }, "go.referral.get") +export const queryGoReferralUsagePreview = query(async (workspaceID: string, rewardID?: string) => { + "use server" + if (!rewardID) 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.id, rewardID), + 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) +}, "go.referral.usagePreview") + export const applyGoReferralReward = action(async (workspaceID: string, rewardID: string) => { "use server" return json( @@ -50,10 +148,18 @@ export const applyGoReferralReward = action(async (workspaceID: string, rewardID .catch((e) => ({ error: e.message as string, data: undefined })), workspaceID, ), - { revalidate: [queryGoReferral.key, queryLiteSubscription.key] }, + { revalidate: [queryGoReferral.key, queryGoReferralUsagePreview.key, queryLiteSubscription.key] }, ) }, "go.referral.reward.apply") +function usagePreview(before: AnalyzedUsage, after: AnalyzedUsage) { + return { + beforePercent: before.usagePercent, + afterPercent: after.usagePercent, + resetInSec: after.resetInSec, + } +} + function formatCurrency(amount: number) { if (amount % 100 === 0) return `$${amount / 100}` return `$${(amount / 100).toFixed(2)}` @@ -106,6 +212,7 @@ export function GoReferralSection(props: { workspaceID: string; summary: GoRefer const apply = useAction(applyGoReferralReward) const submission = useSubmission(applyGoReferralReward) const [selected, setSelected] = createSignal() + const preview = createAsync(() => queryGoReferralUsagePreview(props.workspaceID, selected()?.id)) const appliedCount = createMemo(() => props.summary.rewards.filter((reward) => reward.timeApplied).length) async function onApply() { @@ -128,7 +235,7 @@ export function GoReferralSection(props: { workspaceID: string; summary: GoRefer
- {i18n.t("workspace.referral.stats.validInvites")} + {i18n.t("workspace.referral.stats.invites")} {props.summary.validInviteCount}
@@ -156,50 +263,47 @@ export function GoReferralSection(props: { workspaceID: string; summary: GoRefer when={props.summary.rewards.length > 0} fallback={
{i18n.t("workspace.referral.rewards.empty")}
} > -
- - {(reward) => { - const applied = createMemo(() => !!reward.timeApplied) - return ( -
-
- {formatCurrency(reward.amount)} - {i18n.t(rewardSourceKey(reward.source))} -
-
- - {applied() - ? i18n.t("workspace.referral.reward.status.applied") - : i18n.t("workspace.referral.reward.status.available")} - - - {applied() && reward.timeApplied - ? i18n.t("workspace.referral.reward.appliedOn", { - date: formatDate(reward.timeApplied, language.tag(language.locale())), - }) - : i18n.t("workspace.referral.reward.earnedOn", { - date: formatDate(reward.timeCreated, language.tag(language.locale())), - })} - -
- -
- ) - }} -
+
+ + + + + + + + + + + + {(reward) => { + const applied = createMemo(() => !!reward.timeApplied) + const earnedAt = createMemo(() => formatDate(reward.timeCreated, language.tag(language.locale()))) + return ( + + + + + + + ) + }} + + +
{i18n.t("workspace.referral.table.reward")}{i18n.t("workspace.referral.table.referral")}{i18n.t("workspace.referral.table.earned")}
{formatCurrency(reward.amount)}{i18n.t(rewardSourceKey(reward.source))} + {earnedAt()} + + +
setSelected(undefined)} title={i18n.t("workspace.referral.apply.confirmTitle")}> @@ -209,6 +313,9 @@ export function GoReferralSection(props: { workspaceID: string; summary: GoRefer amount: formatCurrency(selected()?.amount ?? 0), })}

+ {i18n.t("workspace.lite.loading")}

}> + {(usage) => } +