core: show Go reward usage relief before applying

Users can preview how a Go referral reward lowers current usage limits before they spend it, and invite codes normalize consistently across entry points.

Modal buttons now keep the same disabled, hover, and primary actions in Go rewards, workspace creation, and payment method dialogs.
This commit is contained in:
vimtor
2026-05-17 09:57:18 +02:00
parent c60219e229
commit ffe08caae9
13 changed files with 261 additions and 347 deletions

View File

@@ -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;

View File

@@ -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
</div>
</div>
<CopyInviteLink summary={props.summary} />
<InvitationInstructions rewardAmount={props.summary.rewardAmount} />
<div data-slot="instructions">
<ol>
<li>{i18n.t("workspace.referral.instructions.share")}</li>
<li>{i18n.t("workspace.referral.instructions.subscribe")}</li>
<li>{i18n.t("workspace.referral.instructions.claim")}</li>
<li>{i18n.t("workspace.referral.instructions.apply", { amount: formatCurrency(props.summary.rewardAmount) })}</li>
</ol>
</div>
</div>
<div data-slot="rewards-title">
<h2>{i18n.t("workspace.referral.rewards.title")}</h2>
@@ -327,9 +251,9 @@ export function GoReferralSection(props: { workspaceID: string; summary: GoRefer
<tbody>
<For each={props.summary.rewards}>
{(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 (
<tr data-status={reward.status} data-source={reward.source}>
<td data-slot="referral-amount">{formatCurrency(reward.amount)}</td>
@@ -346,8 +270,8 @@ export function GoReferralSection(props: { workspaceID: string; summary: GoRefer
disabled={reward.status !== "available" || !props.summary.hasActiveGo || submission.pending}
onClick={() => setSelected(reward)}
>
<Show when={!applied()} fallback={i18n.t("workspace.referral.reward.status.applied")}>
{pending()
<Show when={!applied} fallback={i18n.t("workspace.referral.reward.status.applied")}>
{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
<div data-slot="usage-preview-after" style={{ width: `${props.usage.afterPercent}%` }} />
</div>
<span data-slot="usage-preview-reset">
{i18n.t("workspace.lite.subscription.resetsIn")} {formatResetTime(props.usage.resetInSec, i18n)}
{i18n.t("workspace.lite.subscription.resetsIn")} {formatResetTime(props.usage.resetInSec, i18n, liteResetTimeKeys)}
</span>
</div>
)
}
function InvitationInstructions(props: { rewardAmount: number }) {
const i18n = useI18n()
return (
<div data-slot="instructions">
<ol>
<li>{i18n.t("workspace.referral.instructions.share")}</li>
<li>{i18n.t("workspace.referral.instructions.subscribe")}</li>
<li>{i18n.t("workspace.referral.instructions.claim")}</li>
<li>{i18n.t("workspace.referral.instructions.apply", { amount: formatCurrency(props.rewardAmount) })}</li>
</ol>
</div>
)
}

View File

@@ -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"] {

View File

@@ -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",

View File

@@ -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 奖励",

View File

@@ -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<typeof useI18n>, 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)}`
}

View File

@@ -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) {

View File

@@ -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%;
}

View File

@@ -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() {
</DropdownItem>
)}
</For>
<button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}>
<button data-slot="create-item" type="button" onClick={() => setShowForm(true)}>
{i18n.t("workspace.createNew")}
</button>
</Dropdown>
<Modal open={store.showForm} onClose={() => setStore("showForm", false)} title={i18n.t("workspace.modal.title")}>
<Modal open={showForm()} onClose={() => setShowForm(false)} title={i18n.t("workspace.modal.title")}>
<div data-component="workspace-create-modal">
<form data-slot="create-form" action={createWorkspace} method="post">
<div data-slot="create-input-group">
@@ -110,7 +103,7 @@ export function WorkspacePicker() {
required
/>
<div data-slot="button-group">
<button type="button" data-color="ghost" onClick={() => setStore("showForm", false)}>
<button type="button" data-color="ghost" onClick={() => setShowForm(false)}>
{i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>

View File

@@ -13,6 +13,7 @@ import styles from "./black-section.module.css"
import waitlistStyles from "./black-waitlist-section.module.css"
import { useI18n } from "~/context/i18n"
import { formError } from "~/lib/form-error"
import { blackResetTimeKeys, formatResetTime } from "~/lib/format-reset-time"
const querySubscription = query(async (workspaceID: string) => {
"use server"
@@ -52,20 +53,6 @@ const querySubscription = query(async (workspaceID: string) => {
}, workspaceID)
}, "subscription.get")
function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
return `${days} ${days === 1 ? i18n.t("workspace.black.time.day") : i18n.t("workspace.black.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.time.hours")}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours >= 1)
return `${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
if (minutes === 0) return i18n.t("workspace.black.time.fewSeconds")
return `${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
}
const cancelWaitlist = action(async (workspaceID: string) => {
"use server"
return json(
@@ -209,7 +196,7 @@ export function BlackSection() {
</div>
<span data-slot="reset-time">
{i18n.t("workspace.black.subscription.resetsIn")}{" "}
{formatResetTime(sub().rollingUsage.resetInSec, i18n)}
{formatResetTime(sub().rollingUsage.resetInSec, i18n, blackResetTimeKeys)}
</span>
</div>
<div data-slot="usage-item">
@@ -222,7 +209,7 @@ export function BlackSection() {
</div>
<span data-slot="reset-time">
{i18n.t("workspace.black.subscription.resetsIn")}{" "}
{formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
{formatResetTime(sub().weeklyUsage.resetInSec, i18n, blackResetTimeKeys)}
</span>
</div>
</div>

View File

@@ -215,45 +215,6 @@
}
.paymentMethodModal {
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="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="modal-actions"] {
display: flex;
gap: var(--space-3);

View File

@@ -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 { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
import { IconAlipay, IconUpi } from "~/component/icon"
@@ -67,20 +68,6 @@ export const queryLiteSubscription = query(async (workspaceID: string) => {
}, workspaceID)
}, "lite.subscription.get")
export function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
return `${days} ${days === 1 ? i18n.t("workspace.lite.time.day") : i18n.t("workspace.lite.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours >= 1)
return `${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
if (minutes === 0) return i18n.t("workspace.lite.time.fewSeconds")
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
}
const createLiteCheckoutUrl = action(
async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
"use server"
@@ -140,6 +127,25 @@ const setLiteUseBalance = action(async (form: FormData) => {
)
}, "setLiteUseBalance")
function LiteUsageItem(props: { label: string; usage: { usagePercent: number; resetInSec: number } }) {
const i18n = useI18n()
return (
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{props.label}</span>
<span data-slot="usage-value">{props.usage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${props.usage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")} {formatResetTime(props.usage.resetInSec, i18n, liteResetTimeKeys)}
</span>
</div>
)
}
export function LiteSection() {
const params = useParams()
const i18n = useI18n()
@@ -207,44 +213,9 @@ export function LiteSection() {
.
</div>
<div data-slot="usage">
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.rollingUsage")}</span>
<span data-slot="usage-value">{sub().rollingUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().rollingUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
{formatResetTime(sub().rollingUsage.resetInSec, i18n)}
</span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.weeklyUsage")}</span>
<span data-slot="usage-value">{sub().weeklyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().weeklyUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")} {formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
</span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.monthlyUsage")}</span>
<span data-slot="usage-value">{sub().monthlyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().monthlyUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
{formatResetTime(sub().monthlyUsage.resetInSec, i18n)}
</span>
</div>
<LiteUsageItem label={i18n.t("workspace.lite.subscription.rollingUsage")} usage={sub().rollingUsage} />
<LiteUsageItem label={i18n.t("workspace.lite.subscription.weeklyUsage")} usage={sub().weeklyUsage} />
<LiteUsageItem label={i18n.t("workspace.lite.subscription.monthlyUsage")} usage={sub().monthlyUsage} />
</div>
<form action={setLiteUseBalance} method="post" data-slot="setting-row">
<p>{i18n.t("workspace.lite.subscription.useBalance")}</p>
@@ -330,7 +301,7 @@ export function LiteSection() {
onClose={() => setStore("showModal", false)}
title={i18n.t("workspace.lite.promo.selectMethod")}
>
<div class={styles.paymentMethodModal} data-component="lite-payment-method-modal">
<div class={styles.paymentMethodModal}>
<div data-slot="modal-actions">
<button
type="button"

View File

@@ -1,5 +1,4 @@
import { z } from "zod"
import { ulid } from "ulid"
import { and, desc, eq, isNull, sql, Database } from "./drizzle"
import { Actor } from "./actor"
import { Identifier } from "./identifier"
@@ -11,12 +10,15 @@ import { WorkspaceTable } from "./schema/workspace.sql"
import { centsToMicroCents, microCentsToCents } from "./util/price"
import { fn } from "./util/fn"
import { Billing } from "./billing"
import { LiteData } from "./lite"
import { Subscription } from "./subscription"
import { ulid } from "ulid"
export namespace Referral {
export const REWARD_AMOUNT = centsToMicroCents(500)
const CODE_LENGTH = 10
export const CODE_LENGTH = 10
function normalizeCode(code?: string) {
export function normalizeCode(code?: string | null) {
return code?.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, CODE_LENGTH)
}
@@ -24,7 +26,7 @@ export namespace Referral {
return ulid().slice(-CODE_LENGTH)
}
export async function ensureCode(workspaceID = Actor.workspace()) {
async function ensureCode(workspaceID = Actor.workspace()) {
return Database.transaction(async (tx) => {
const existing = await tx
.select({ code: WorkspaceTable.referralCode })
@@ -148,7 +150,7 @@ export namespace Referral {
timeCreated: referral.timeCreated,
timeApplied: null,
})),
].sort((a, b) => new Date(b.timeCreated).getTime() - new Date(a.timeCreated).getTime())
]
return {
inviteCode: code.code,
validInviteCount: rows.invites.length,
@@ -203,6 +205,79 @@ export namespace Referral {
})
})
export const usagePreview = fn(z.object({ referralID: z.string() }), async (input) => {
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, Actor.workspace()),
eq(ReferralRewardTable.referralID, input.referralID),
isNull(ReferralRewardTable.timeApplied),
isNull(ReferralRewardTable.timeDeleted),
isNull(LiteTable.timeDeleted),
),
)
.then((rows) => rows[0]),
)
if (!row) return null
const limits = LiteData.getLimits()
return {
rollingUsage: usagePreviewItem(
Subscription.analyzeRollingUsage({
limit: limits.rollingLimit,
window: limits.rollingWindow,
usage: row.rollingUsage ?? 0,
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
Subscription.analyzeRollingUsage({
limit: limits.rollingLimit,
window: limits.rollingWindow,
usage: Math.max(0, (row.rollingUsage ?? 0) - row.rewardAmount),
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
),
weeklyUsage: usagePreviewItem(
Subscription.analyzeWeeklyUsage({
limit: limits.weeklyLimit,
usage: row.weeklyUsage ?? 0,
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
}),
Subscription.analyzeWeeklyUsage({
limit: limits.weeklyLimit,
usage: Math.max(0, (row.weeklyUsage ?? 0) - row.rewardAmount),
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
}),
),
monthlyUsage: usagePreviewItem(
Subscription.analyzeMonthlyUsage({
limit: limits.monthlyLimit,
usage: row.monthlyUsage ?? 0,
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
timeSubscribed: row.timeCreated,
}),
Subscription.analyzeMonthlyUsage({
limit: limits.monthlyLimit,
usage: Math.max(0, (row.monthlyUsage ?? 0) - row.rewardAmount),
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
timeSubscribed: row.timeCreated,
}),
),
}
})
export async function createFromAccount(input: {
accountID: string
inviteCode?: string
@@ -305,8 +380,24 @@ export namespace Referral {
amount: REWARD_AMOUNT,
},
])
.onDuplicateKeyUpdate({
set: {
amount: sql`${ReferralRewardTable.amount}`,
},
})
return { status: "created" as const }
})
}
function usagePreviewItem(
before: { usagePercent: number; resetInSec: number },
after: { usagePercent: number; resetInSec: number },
) {
return {
beforePercent: before.usagePercent,
afterPercent: after.usagePercent,
resetInSec: after.resetInSec,
}
}
}