mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 03:45:23 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 奖励",
|
||||
|
||||
47
packages/console/app/src/lib/format-reset-time.ts
Normal file
47
packages/console/app/src/lib/format-reset-time.ts
Normal 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)}`
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user