mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-20 19:06:22 +00:00
feat(go): referral support (#28345)
Co-authored-by: Jack <jack@anoma.ly>
This commit is contained in:
354
packages/console/app/src/component/go-referral.css
Normal file
354
packages/console/app/src/component/go-referral.css
Normal file
@@ -0,0 +1,354 @@
|
||||
[data-component="go-credit-confirm"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
min-width: min(34rem, calc(100vw - var(--space-8)));
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
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);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
[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;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="invite-link-box"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 2px solid var(--color-accent);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
padding: var(--space-2-5);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 130px;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
min-width: 96px;
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="instructions"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
ol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-md);
|
||||
list-style-position: inside;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="go-referral-section"] {
|
||||
[data-component="go-referral-overview"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-6);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg-surface);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="rewards-title"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding-top: var(--space-2);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.03125rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="referral-stats"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--space-6);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.025rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="referrals-table"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
[data-component="empty-state"] {
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg-surface);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
[data-slot="referrals-table-element"] {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
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);
|
||||
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-slot="referral-email"] {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.4;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
&[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;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-status="pending"] {
|
||||
td[data-slot="referral-amount"],
|
||||
td[data-slot="referral-date"] {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
td[data-slot="referral-source"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
&: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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
313
packages/console/app/src/component/go-referral.tsx
Normal file
313
packages/console/app/src/component/go-referral.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { action, createAsync, json, query, useAction, useSubmission } from "@solidjs/router"
|
||||
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 { 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, liteResetTimeKeys } from "~/lib/format-reset-time"
|
||||
import { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section"
|
||||
import "./go-referral.css"
|
||||
|
||||
type GoReferralSummary = Awaited<ReturnType<typeof Referral.summary>>
|
||||
type GoReferralReward = GoReferralSummary["rewards"][number]
|
||||
type GoReferralUsagePreview = NonNullable<Awaited<ReturnType<typeof Referral.usagePreview>>>
|
||||
type GoReferralUsagePreviewItem = GoReferralUsagePreview["rollingUsage"]
|
||||
|
||||
const emptyUsagePreview = {
|
||||
rollingUsage: { beforePercent: 0, afterPercent: 0, resetInSec: 0 },
|
||||
weeklyUsage: { beforePercent: 0, afterPercent: 0, resetInSec: 0 },
|
||||
monthlyUsage: { beforePercent: 0, afterPercent: 0, resetInSec: 0 },
|
||||
} satisfies GoReferralUsagePreview
|
||||
|
||||
export const queryGoReferral = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(() => Referral.summary(), workspaceID)
|
||||
}, "go.referral.get")
|
||||
|
||||
export const queryGoReferralUsagePreview = query(async (workspaceID: string, referralID?: string) => {
|
||||
"use server"
|
||||
if (!referralID) return null
|
||||
return withActor(() => Referral.usagePreview({ referralID }), workspaceID)
|
||||
}, "go.referral.usagePreview")
|
||||
|
||||
export const applyGoReferralReward = action(async (workspaceID: string, referralID: string) => {
|
||||
"use server"
|
||||
return json(
|
||||
await withActor(() => Referral.applyReward({ referralID }), workspaceID),
|
||||
{ revalidate: [queryGoReferral.key, queryGoReferralUsagePreview.key, queryLiteSubscription.key] },
|
||||
)
|
||||
}, "go.referral.reward.apply")
|
||||
|
||||
function currentUsagePreview(usage: { resetInSec: number; usagePercent: number }) {
|
||||
return {
|
||||
beforePercent: usage.usagePercent,
|
||||
afterPercent: usage.usagePercent,
|
||||
resetInSec: usage.resetInSec,
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
if (amount % 100 === 0) return `$${amount / 100}`
|
||||
return `$${(amount / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatDate(value: string | Date, locale: string) {
|
||||
return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric" }).format(new Date(value))
|
||||
}
|
||||
|
||||
function rewardTitleKey(reward: GoReferralReward) {
|
||||
if (reward.status === "pending" && reward.source === "invitee")
|
||||
return "workspace.referral.reward.source.pendingInvitee" as const
|
||||
if (reward.status === "pending") return "workspace.referral.reward.source.pendingInviter" as const
|
||||
if (reward.status === "applied") return "workspace.referral.reward.source.applied" as const
|
||||
return "workspace.referral.reward.source.available" as const
|
||||
}
|
||||
|
||||
function rewardPendingStatusKey(source: GoReferralReward["source"]) {
|
||||
if (source === "invitee") return "workspace.referral.reward.status.pendingInvitee" as const
|
||||
return "workspace.referral.reward.status.pendingInviter" as const
|
||||
}
|
||||
|
||||
function CopyInviteLink(props: { summary: GoReferralSummary }) {
|
||||
const i18n = useI18n()
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const event = getRequestEvent()
|
||||
const origin = event
|
||||
? new URL(event.request.url).origin
|
||||
: typeof window === "object"
|
||||
? window.location.origin
|
||||
: undefined
|
||||
const inviteUrl = createMemo(() => {
|
||||
const path = `/go?ref=${props.summary.referralCode}`
|
||||
if (!origin) return path
|
||||
return new URL(path, origin).toString()
|
||||
})
|
||||
|
||||
async function copy() {
|
||||
if (typeof navigator !== "object") return
|
||||
await navigator.clipboard.writeText(inviteUrl())
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1600)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="invite-link-box">
|
||||
<div>
|
||||
<code title={inviteUrl()}>{inviteUrl()}</code>
|
||||
<button type="button" data-color="primary" onClick={copy}>
|
||||
<Show
|
||||
when={copied()}
|
||||
fallback={
|
||||
<>
|
||||
<IconCopy style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.referral.copyLink")}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconCheck style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.referral.copied")}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GoReferralSection(props: { workspaceID: string; summary: GoReferralSummary }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const apply = useAction(applyGoReferralReward)
|
||||
const submission = useSubmission(applyGoReferralReward)
|
||||
const [selected, setSelected] = createSignal<GoReferralReward>()
|
||||
const [preview, setPreview] = createSignal<GoReferralUsagePreview | null>()
|
||||
const lite = createAsync(() => queryLiteSubscription(props.workspaceID))
|
||||
const displayPreview = createMemo(() => {
|
||||
const loaded = preview()
|
||||
if (loaded) return loaded
|
||||
const current = lite()
|
||||
if (!current) return emptyUsagePreview
|
||||
return {
|
||||
rollingUsage: currentUsagePreview(current.rollingUsage),
|
||||
weeklyUsage: currentUsagePreview(current.weeklyUsage),
|
||||
monthlyUsage: currentUsagePreview(current.monthlyUsage),
|
||||
} satisfies GoReferralUsagePreview
|
||||
})
|
||||
createEffect(() => {
|
||||
const reward = selected()
|
||||
if (!reward) {
|
||||
setPreview(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const request = { cancelled: false }
|
||||
setPreview(undefined)
|
||||
queryGoReferralUsagePreview(props.workspaceID, reward.id).then((result) => {
|
||||
if (request.cancelled) return
|
||||
setPreview(result)
|
||||
})
|
||||
onCleanup(() => {
|
||||
request.cancelled = true
|
||||
})
|
||||
})
|
||||
|
||||
async function onApply() {
|
||||
const reward = selected()
|
||||
if (!reward) return
|
||||
await apply(props.workspaceID, reward.id)
|
||||
setSelected(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<section data-component="go-referral-section">
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.referral.overview.title")}</h2>
|
||||
<p>
|
||||
{i18n.t("workspace.referral.overview.subtitle", {
|
||||
reward: formatCurrency(props.summary.rewardAmount),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div data-component="go-referral-overview">
|
||||
<div data-slot="referral-stats">
|
||||
<div>
|
||||
<span>{i18n.t("workspace.referral.stats.invites")}</span>
|
||||
<strong>{props.summary.inviteCount}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{i18n.t("workspace.referral.stats.earned")}</span>
|
||||
<strong>{formatCurrency(props.summary.totalEarned)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{i18n.t("workspace.referral.stats.applied")}</span>
|
||||
<strong>{formatCurrency(props.summary.totalApplied)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<CopyInviteLink summary={props.summary} />
|
||||
<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>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={props.summary.rewards.length > 0}
|
||||
fallback={<div data-component="empty-state">{i18n.t("workspace.referral.rewards.empty")}</div>}
|
||||
>
|
||||
<div data-slot="referrals-table">
|
||||
<table data-slot="referrals-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{i18n.t("workspace.referral.table.reward")}</th>
|
||||
<th>{i18n.t("workspace.referral.table.referral")}</th>
|
||||
<th>{i18n.t("workspace.referral.table.date")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.summary.rewards}>
|
||||
{(reward) => {
|
||||
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>
|
||||
<td data-slot="referral-source">
|
||||
<span>{i18n.t(rewardTitleKey(reward))}</span>
|
||||
<span data-slot="referral-email">{reward.email}</span>
|
||||
</td>
|
||||
<td data-slot="referral-date" title={earnedAt()}>
|
||||
{earnedAt()}
|
||||
</td>
|
||||
<td data-slot="referral-action">
|
||||
<button
|
||||
type="button"
|
||||
disabled={reward.status !== "available" || !props.summary.hasActiveGo || submission.pending}
|
||||
onClick={() => setSelected(reward)}
|
||||
>
|
||||
<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.preview")
|
||||
: i18n.t("workspace.referral.apply.noGo")}
|
||||
</Show>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
<Modal open={!!selected()} onClose={() => setSelected(undefined)} title={i18n.t("workspace.referral.apply.confirmTitle")}>
|
||||
<div data-component="go-credit-confirm">
|
||||
<p>
|
||||
{i18n.t("workspace.referral.apply.confirmBody", {
|
||||
amount: formatCurrency(selected()?.amount ?? 0),
|
||||
})}
|
||||
</p>
|
||||
<GoReferralUsagePreview preview={displayPreview()} />
|
||||
<div data-slot="modal-actions">
|
||||
<button type="button" onClick={() => setSelected(undefined)}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button type="button" data-color="primary" disabled={submission.pending} onClick={onApply}>
|
||||
{submission.pending ? i18n.t("workspace.lite.loading") : i18n.t("workspace.referral.apply.confirmAction")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function GoReferralUsagePreview(props: { preview: GoReferralUsagePreview }) {
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<div data-slot="usage-preview">
|
||||
<GoReferralUsagePreviewRow
|
||||
label={i18n.t("workspace.lite.subscription.rollingUsage")}
|
||||
usage={props.preview.rollingUsage}
|
||||
/>
|
||||
<GoReferralUsagePreviewRow
|
||||
label={i18n.t("workspace.lite.subscription.weeklyUsage")}
|
||||
usage={props.preview.weeklyUsage}
|
||||
/>
|
||||
<GoReferralUsagePreviewRow
|
||||
label={i18n.t("workspace.lite.subscription.monthlyUsage")}
|
||||
usage={props.preview.monthlyUsage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GoReferralUsagePreviewRow(props: { label: string; usage: GoReferralUsagePreviewItem }) {
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<div data-slot="usage-preview-item">
|
||||
<div data-slot="usage-preview-header">
|
||||
<span data-slot="usage-preview-label">{props.label}</span>
|
||||
<span data-slot="usage-preview-value">
|
||||
<span>{props.usage.beforePercent}%</span>
|
||||
<span aria-hidden="true">-></span>
|
||||
<span data-slot="usage-preview-after-value">{props.usage.afterPercent}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<div data-slot="usage-preview-progress">
|
||||
<div data-slot="usage-preview-before" style={{ width: `${props.usage.beforePercent}%` }} />
|
||||
<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, liteResetTimeKeys)}
|
||||
</span>
|
||||
</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"] {
|
||||
@@ -64,4 +119,16 @@
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-slot="content"][data-variant="black"] {
|
||||
background-color: #000;
|
||||
border-color: rgba(255, 255, 255, 0.17);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
[data-slot="title"] {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Dialog as Kobalte } from "@kobalte/core/dialog"
|
||||
import { JSX, Show } from "solid-js"
|
||||
import "./modal.css"
|
||||
|
||||
@@ -5,20 +6,41 @@ interface ModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
variant?: "black"
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export function Modal(props: ModalProps) {
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div data-component="modal" data-slot="overlay" onClick={props.onClose}>
|
||||
<div data-slot="content" onClick={(e) => e.stopPropagation()}>
|
||||
<Show when={props.title}>
|
||||
<h2 data-slot="title">{props.title}</h2>
|
||||
</Show>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
<Kobalte
|
||||
modal
|
||||
open={props.open}
|
||||
preventScroll={false}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) props.onClose()
|
||||
}}
|
||||
>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Overlay data-component="modal" data-slot="overlay" onClick={props.onClose}>
|
||||
<Kobalte.Content
|
||||
data-slot="content"
|
||||
data-variant={props.variant}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault()
|
||||
const target = e.currentTarget as HTMLElement | null
|
||||
target?.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
<Show when={props.title}>
|
||||
<Kobalte.Title data-slot="title">{props.title}</Kobalte.Title>
|
||||
</Show>
|
||||
{props.children}
|
||||
</Kobalte.Content>
|
||||
</Kobalte.Overlay>
|
||||
</Kobalte.Portal>
|
||||
</Kobalte>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -660,6 +660,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "طرق دفع أخرى",
|
||||
"workspace.lite.promo.selectMethod": "اختر طريقة الدفع",
|
||||
|
||||
"workspace.referral.copyLink": "نسخ الرابط",
|
||||
"workspace.referral.copied": "تم النسخ",
|
||||
"workspace.referral.overview.title": "ادعُ أصدقاءك إلى Go",
|
||||
"workspace.referral.overview.subtitle": "احصل على رصيد Go بقيمة $5 عند اشتراك صديق. وسيحصل هو أيضًا على $5.",
|
||||
"workspace.referral.stats.invites": "الدعوات",
|
||||
"workspace.referral.stats.earned": "المُكتسب",
|
||||
"workspace.referral.stats.applied": "المُطبَّق",
|
||||
"workspace.referral.instructions.share": "شارك رابط دعوتك",
|
||||
"workspace.referral.instructions.subscribe": "يشترك صديقك في Go",
|
||||
"workspace.referral.instructions.claim": "طبِّق رصيد $5 الخاص بك أدناه",
|
||||
"workspace.referral.rewards.title": "مكافآت الإحالة",
|
||||
"workspace.referral.rewards.subtitle": "تم تطبيق {{applied}} / {{total}} من المكافآت.",
|
||||
"workspace.referral.rewards.empty": "لا توجد مكافآت إحالة بعد.",
|
||||
"workspace.referral.table.reward": "المكافأة",
|
||||
"workspace.referral.table.referral": "الوصف",
|
||||
"workspace.referral.table.date": "التاريخ",
|
||||
"workspace.referral.reward.source.pendingInviter": "بانتظار اشتراكه",
|
||||
"workspace.referral.reward.source.pendingInvitee": "اشترك لإلغاء قفل المكافأة",
|
||||
"workspace.referral.reward.source.available": "المكافأة جاهزة للتطبيق",
|
||||
"workspace.referral.reward.source.applied": "تم تطبيق المكافأة",
|
||||
"workspace.referral.reward.status.applied": "مُطبَّق",
|
||||
"workspace.referral.reward.status.pendingInviter": "تطبيق",
|
||||
"workspace.referral.reward.status.pendingInvitee": "تطبيق",
|
||||
"workspace.referral.apply.noGo": "الاشتراك في Go",
|
||||
"workspace.referral.apply.preview": "معاينة",
|
||||
"workspace.referral.apply.action": "تطبيق",
|
||||
"workspace.referral.apply.confirmTitle": "تطبيق مكافأة Go",
|
||||
"workspace.referral.apply.confirmBody": "طبِّق {{amount}} لتقليل عدّادات استخدام Go الحالية في مساحة العمل هذه.",
|
||||
"workspace.referral.apply.confirmAction": "تطبيق",
|
||||
|
||||
"download.title": "OpenCode | تنزيل",
|
||||
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
|
||||
"download.hero.title": "تنزيل OpenCode",
|
||||
|
||||
@@ -670,6 +670,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Outros métodos de pagamento",
|
||||
"workspace.lite.promo.selectMethod": "Selecionar método de pagamento",
|
||||
|
||||
"workspace.referral.copyLink": "Copiar link",
|
||||
"workspace.referral.copied": "Copiado",
|
||||
"workspace.referral.overview.title": "Convide amigos para o Go",
|
||||
"workspace.referral.overview.subtitle": "Ganhe $5 em crédito Go quando um amigo assinar. Ele também ganha $5.",
|
||||
"workspace.referral.stats.invites": "Convites",
|
||||
"workspace.referral.stats.earned": "Ganhos",
|
||||
"workspace.referral.stats.applied": "Aplicados",
|
||||
"workspace.referral.instructions.share": "Compartilhe seu link de convite",
|
||||
"workspace.referral.instructions.subscribe": "Seu amigo assina o Go",
|
||||
"workspace.referral.instructions.claim": "Aplique seu crédito de $5 abaixo",
|
||||
"workspace.referral.rewards.title": "Recompensas de indicação",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} recompensas aplicadas.",
|
||||
"workspace.referral.rewards.empty": "Ainda não há recompensas de indicação.",
|
||||
"workspace.referral.table.reward": "Recompensa",
|
||||
"workspace.referral.table.referral": "Descrição",
|
||||
"workspace.referral.table.date": "Data",
|
||||
"workspace.referral.reward.source.pendingInviter": "Aguardando ele assinar",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Assine para desbloquear a recompensa",
|
||||
"workspace.referral.reward.source.available": "Recompensa pronta para usar",
|
||||
"workspace.referral.reward.source.applied": "Recompensa aplicada",
|
||||
"workspace.referral.reward.status.applied": "Aplicada",
|
||||
"workspace.referral.reward.status.pendingInviter": "Aplicar",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Aplicar",
|
||||
"workspace.referral.apply.noGo": "Assinar Go",
|
||||
"workspace.referral.apply.preview": "Visualizar",
|
||||
"workspace.referral.apply.action": "Aplicar",
|
||||
"workspace.referral.apply.confirmTitle": "Aplicar recompensa Go",
|
||||
"workspace.referral.apply.confirmBody": "Aplique {{amount}} para reduzir os contadores atuais de uso do Go deste workspace.",
|
||||
"workspace.referral.apply.confirmAction": "Aplicar",
|
||||
|
||||
"download.title": "OpenCode | Baixar",
|
||||
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
|
||||
"download.hero.title": "Baixar OpenCode",
|
||||
|
||||
@@ -666,6 +666,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
|
||||
"workspace.lite.promo.selectMethod": "Vælg betalingsmetode",
|
||||
|
||||
"workspace.referral.copyLink": "Kopiér link",
|
||||
"workspace.referral.copied": "Kopieret",
|
||||
"workspace.referral.overview.title": "Inviter venner til Go",
|
||||
"workspace.referral.overview.subtitle": "Få $5 i Go-kredit, når en ven abonnerer. De får også $5.",
|
||||
"workspace.referral.stats.invites": "Invitationer",
|
||||
"workspace.referral.stats.earned": "Optjent",
|
||||
"workspace.referral.stats.applied": "Brugt",
|
||||
"workspace.referral.instructions.share": "Del dit invitationslink",
|
||||
"workspace.referral.instructions.subscribe": "Din ven abonnerer på Go",
|
||||
"workspace.referral.instructions.claim": "Brug din $5-kredit nedenfor",
|
||||
"workspace.referral.rewards.title": "Henvisningsbelønninger",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} belønninger brugt.",
|
||||
"workspace.referral.rewards.empty": "Ingen henvisningsbelønninger endnu.",
|
||||
"workspace.referral.table.reward": "Belønning",
|
||||
"workspace.referral.table.referral": "Beskrivelse",
|
||||
"workspace.referral.table.date": "Dato",
|
||||
"workspace.referral.reward.source.pendingInviter": "Venter på, at de abonnerer",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Abonner for at låse belønningen op",
|
||||
"workspace.referral.reward.source.available": "Belønning klar til brug",
|
||||
"workspace.referral.reward.source.applied": "Belønning brugt",
|
||||
"workspace.referral.reward.status.applied": "Brugt",
|
||||
"workspace.referral.reward.status.pendingInviter": "Brug",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Brug",
|
||||
"workspace.referral.apply.noGo": "Abonner på Go",
|
||||
"workspace.referral.apply.preview": "Forhåndsvis",
|
||||
"workspace.referral.apply.action": "Brug",
|
||||
"workspace.referral.apply.confirmTitle": "Brug Go-belønning",
|
||||
"workspace.referral.apply.confirmBody": "Brug {{amount}} til at reducere dette workspaces nuværende Go-forbrugstællere.",
|
||||
"workspace.referral.apply.confirmAction": "Brug",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",
|
||||
"download.hero.title": "Download OpenCode",
|
||||
|
||||
@@ -669,6 +669,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Andere Zahlungsmethoden",
|
||||
"workspace.lite.promo.selectMethod": "Zahlungsmethode auswählen",
|
||||
|
||||
"workspace.referral.copyLink": "Link kopieren",
|
||||
"workspace.referral.copied": "Kopiert",
|
||||
"workspace.referral.overview.title": "Freunde zu Go einladen",
|
||||
"workspace.referral.overview.subtitle": "Erhalte $5 Go-Guthaben, wenn ein Freund abonniert. Er bekommt ebenfalls $5.",
|
||||
"workspace.referral.stats.invites": "Einladungen",
|
||||
"workspace.referral.stats.earned": "Verdient",
|
||||
"workspace.referral.stats.applied": "Eingelöst",
|
||||
"workspace.referral.instructions.share": "Teile deinen Einladungslink",
|
||||
"workspace.referral.instructions.subscribe": "Dein Freund abonniert Go",
|
||||
"workspace.referral.instructions.claim": "Löse unten dein $5-Guthaben ein",
|
||||
"workspace.referral.rewards.title": "Empfehlungsbelohnungen",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} Belohnungen eingelöst.",
|
||||
"workspace.referral.rewards.empty": "Noch keine Empfehlungsbelohnungen.",
|
||||
"workspace.referral.table.reward": "Belohnung",
|
||||
"workspace.referral.table.referral": "Beschreibung",
|
||||
"workspace.referral.table.date": "Datum",
|
||||
"workspace.referral.reward.source.pendingInviter": "Warten auf das Abo des Freundes",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Abonnieren, um Belohnung freizuschalten",
|
||||
"workspace.referral.reward.source.available": "Belohnung kann eingelöst werden",
|
||||
"workspace.referral.reward.source.applied": "Belohnung eingelöst",
|
||||
"workspace.referral.reward.status.applied": "Eingelöst",
|
||||
"workspace.referral.reward.status.pendingInviter": "Einlösen",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Einlösen",
|
||||
"workspace.referral.apply.noGo": "Go abonnieren",
|
||||
"workspace.referral.apply.preview": "Vorschau",
|
||||
"workspace.referral.apply.action": "Einlösen",
|
||||
"workspace.referral.apply.confirmTitle": "Go-Belohnung einlösen",
|
||||
"workspace.referral.apply.confirmBody": "Löse {{amount}} ein, um die aktuellen Go-Nutzungszähler dieses Workspace zu reduzieren.",
|
||||
"workspace.referral.apply.confirmAction": "Einlösen",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
|
||||
"download.hero.title": "OpenCode herunterladen",
|
||||
|
||||
@@ -662,6 +662,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Other payment methods",
|
||||
"workspace.lite.promo.selectMethod": "Select payment method",
|
||||
|
||||
"workspace.referral.copyLink": "Copy Link",
|
||||
"workspace.referral.copied": "Copied",
|
||||
"workspace.referral.overview.title": "Invite friends to Go",
|
||||
"workspace.referral.overview.subtitle": "Earn $5 in Go credit when a friend subscribes. They’ll get $5 too.",
|
||||
"workspace.referral.stats.invites": "Invites",
|
||||
"workspace.referral.stats.earned": "Earned",
|
||||
"workspace.referral.stats.applied": "Applied",
|
||||
"workspace.referral.instructions.share": "Share your invite link",
|
||||
"workspace.referral.instructions.subscribe": "Your friend subscribes to Go",
|
||||
"workspace.referral.instructions.claim": "Apply your $5 credit below",
|
||||
"workspace.referral.rewards.title": "Referral rewards",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} rewards applied.",
|
||||
"workspace.referral.rewards.empty": "No referral rewards yet.",
|
||||
"workspace.referral.table.reward": "Reward",
|
||||
"workspace.referral.table.referral": "Description",
|
||||
"workspace.referral.table.date": "Date",
|
||||
"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",
|
||||
"workspace.referral.reward.source.applied": "Reward applied",
|
||||
"workspace.referral.reward.status.applied": "Applied",
|
||||
"workspace.referral.reward.status.pendingInviter": "Apply",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Apply",
|
||||
"workspace.referral.apply.noGo": "Subscribe to Go",
|
||||
"workspace.referral.apply.preview": "Preview",
|
||||
"workspace.referral.apply.action": "Apply",
|
||||
"workspace.referral.apply.confirmTitle": "Apply Go reward",
|
||||
"workspace.referral.apply.confirmBody": "Apply {{amount}} to reduce this workspace's current Go usage counters.",
|
||||
"workspace.referral.apply.confirmAction": "Apply",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
|
||||
"download.hero.title": "Download OpenCode",
|
||||
|
||||
@@ -670,6 +670,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Otros métodos de pago",
|
||||
"workspace.lite.promo.selectMethod": "Seleccionar método de pago",
|
||||
|
||||
"workspace.referral.copyLink": "Copiar enlace",
|
||||
"workspace.referral.copied": "Copiado",
|
||||
"workspace.referral.overview.title": "Invita amigos a Go",
|
||||
"workspace.referral.overview.subtitle": "Gana $5 de crédito Go cuando un amigo se suscriba. Él también recibirá $5.",
|
||||
"workspace.referral.stats.invites": "Invitaciones",
|
||||
"workspace.referral.stats.earned": "Ganado",
|
||||
"workspace.referral.stats.applied": "Aplicado",
|
||||
"workspace.referral.instructions.share": "Comparte tu enlace de invitación",
|
||||
"workspace.referral.instructions.subscribe": "Tu amigo se suscribe a Go",
|
||||
"workspace.referral.instructions.claim": "Aplica tu crédito de $5 abajo",
|
||||
"workspace.referral.rewards.title": "Recompensas por referidos",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} recompensas aplicadas.",
|
||||
"workspace.referral.rewards.empty": "Aún no hay recompensas por referidos.",
|
||||
"workspace.referral.table.reward": "Recompensa",
|
||||
"workspace.referral.table.referral": "Descripción",
|
||||
"workspace.referral.table.date": "Fecha",
|
||||
"workspace.referral.reward.source.pendingInviter": "Esperando a que se suscriba",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Suscríbete para desbloquear la recompensa",
|
||||
"workspace.referral.reward.source.available": "Recompensa lista para aplicar",
|
||||
"workspace.referral.reward.source.applied": "Recompensa aplicada",
|
||||
"workspace.referral.reward.status.applied": "Aplicada",
|
||||
"workspace.referral.reward.status.pendingInviter": "Aplicar",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Aplicar",
|
||||
"workspace.referral.apply.noGo": "Suscribirse a Go",
|
||||
"workspace.referral.apply.preview": "Vista previa",
|
||||
"workspace.referral.apply.action": "Aplicar",
|
||||
"workspace.referral.apply.confirmTitle": "Aplicar recompensa de Go",
|
||||
"workspace.referral.apply.confirmBody": "Aplica {{amount}} para reducir los contadores actuales de uso de Go de este workspace.",
|
||||
"workspace.referral.apply.confirmAction": "Aplicar",
|
||||
|
||||
"download.title": "OpenCode | Descargar",
|
||||
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
|
||||
"download.hero.title": "Descargar OpenCode",
|
||||
|
||||
@@ -676,6 +676,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Autres méthodes de paiement",
|
||||
"workspace.lite.promo.selectMethod": "Sélectionner la méthode de paiement",
|
||||
|
||||
"workspace.referral.copyLink": "Copier le lien",
|
||||
"workspace.referral.copied": "Copié",
|
||||
"workspace.referral.overview.title": "Inviter des amis sur Go",
|
||||
"workspace.referral.overview.subtitle": "Gagnez $5 de crédit Go lorsqu'un ami s'abonne. Il recevra également $5.",
|
||||
"workspace.referral.stats.invites": "Invitations",
|
||||
"workspace.referral.stats.earned": "Gagné",
|
||||
"workspace.referral.stats.applied": "Utilisé",
|
||||
"workspace.referral.instructions.share": "Partagez votre lien d'invitation",
|
||||
"workspace.referral.instructions.subscribe": "Votre ami s'abonne à Go",
|
||||
"workspace.referral.instructions.claim": "Utilisez votre crédit de $5 ci-dessous",
|
||||
"workspace.referral.rewards.title": "Récompenses de parrainage",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} récompenses utilisées.",
|
||||
"workspace.referral.rewards.empty": "Aucune récompense de parrainage pour l'instant.",
|
||||
"workspace.referral.table.reward": "Récompense",
|
||||
"workspace.referral.table.referral": "Description",
|
||||
"workspace.referral.table.date": "Date",
|
||||
"workspace.referral.reward.source.pendingInviter": "En attente de son abonnement",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Abonnez-vous pour débloquer la récompense",
|
||||
"workspace.referral.reward.source.available": "Récompense prête à utiliser",
|
||||
"workspace.referral.reward.source.applied": "Récompense utilisée",
|
||||
"workspace.referral.reward.status.applied": "Utilisée",
|
||||
"workspace.referral.reward.status.pendingInviter": "Utiliser",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Utiliser",
|
||||
"workspace.referral.apply.noGo": "S'abonner à Go",
|
||||
"workspace.referral.apply.preview": "Aperçu",
|
||||
"workspace.referral.apply.action": "Utiliser",
|
||||
"workspace.referral.apply.confirmTitle": "Utiliser la récompense Go",
|
||||
"workspace.referral.apply.confirmBody": "Utilisez {{amount}} pour réduire les compteurs d'utilisation Go actuels de ce workspace.",
|
||||
"workspace.referral.apply.confirmAction": "Utiliser",
|
||||
|
||||
"download.title": "OpenCode | Téléchargement",
|
||||
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
|
||||
"download.hero.title": "Télécharger OpenCode",
|
||||
|
||||
@@ -668,6 +668,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Altri metodi di pagamento",
|
||||
"workspace.lite.promo.selectMethod": "Seleziona metodo di pagamento",
|
||||
|
||||
"workspace.referral.copyLink": "Copia link",
|
||||
"workspace.referral.copied": "Copiato",
|
||||
"workspace.referral.overview.title": "Invita amici su Go",
|
||||
"workspace.referral.overview.subtitle": "Guadagna $5 di credito Go quando un amico si abbona. Anche lui riceverà $5.",
|
||||
"workspace.referral.stats.invites": "Inviti",
|
||||
"workspace.referral.stats.earned": "Guadagnato",
|
||||
"workspace.referral.stats.applied": "Utilizzato",
|
||||
"workspace.referral.instructions.share": "Condividi il tuo link di invito",
|
||||
"workspace.referral.instructions.subscribe": "Il tuo amico si abbona a Go",
|
||||
"workspace.referral.instructions.claim": "Usa il tuo credito di $5 qui sotto",
|
||||
"workspace.referral.rewards.title": "Premi referral",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} premi utilizzati.",
|
||||
"workspace.referral.rewards.empty": "Nessun premio referral ancora.",
|
||||
"workspace.referral.table.reward": "Premio",
|
||||
"workspace.referral.table.referral": "Descrizione",
|
||||
"workspace.referral.table.date": "Data",
|
||||
"workspace.referral.reward.source.pendingInviter": "In attesa che si abboni",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Abbonati per sbloccare il premio",
|
||||
"workspace.referral.reward.source.available": "Premio pronto da utilizzare",
|
||||
"workspace.referral.reward.source.applied": "Premio utilizzato",
|
||||
"workspace.referral.reward.status.applied": "Utilizzato",
|
||||
"workspace.referral.reward.status.pendingInviter": "Utilizza",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Utilizza",
|
||||
"workspace.referral.apply.noGo": "Abbonati a Go",
|
||||
"workspace.referral.apply.preview": "Anteprima",
|
||||
"workspace.referral.apply.action": "Utilizza",
|
||||
"workspace.referral.apply.confirmTitle": "Utilizza premio Go",
|
||||
"workspace.referral.apply.confirmBody": "Utilizza {{amount}} per ridurre i contatori di utilizzo Go attuali di questo workspace.",
|
||||
"workspace.referral.apply.confirmAction": "Utilizza",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
|
||||
"download.hero.title": "Scarica OpenCode",
|
||||
|
||||
@@ -668,6 +668,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "その他の支払い方法",
|
||||
"workspace.lite.promo.selectMethod": "支払い方法を選択",
|
||||
|
||||
"workspace.referral.copyLink": "リンクをコピー",
|
||||
"workspace.referral.copied": "コピーしました",
|
||||
"workspace.referral.overview.title": "友達を Go に招待",
|
||||
"workspace.referral.overview.subtitle": "友達がサブスクライブすると $5 分の Go クレジットを獲得。友達にも $5 が付与されます。",
|
||||
"workspace.referral.stats.invites": "招待",
|
||||
"workspace.referral.stats.earned": "獲得",
|
||||
"workspace.referral.stats.applied": "適用済み",
|
||||
"workspace.referral.instructions.share": "招待リンクをシェア",
|
||||
"workspace.referral.instructions.subscribe": "友達が Go にサブスクライブ",
|
||||
"workspace.referral.instructions.claim": "下の $5 クレジットを適用",
|
||||
"workspace.referral.rewards.title": "リファラル特典",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} 件の特典を適用済み。",
|
||||
"workspace.referral.rewards.empty": "リファラル特典はまだありません。",
|
||||
"workspace.referral.table.reward": "特典",
|
||||
"workspace.referral.table.referral": "説明",
|
||||
"workspace.referral.table.date": "日付",
|
||||
"workspace.referral.reward.source.pendingInviter": "友達のサブスクライブ待ち",
|
||||
"workspace.referral.reward.source.pendingInvitee": "サブスクライブして特典をアンロック",
|
||||
"workspace.referral.reward.source.available": "特典は適用可能です",
|
||||
"workspace.referral.reward.source.applied": "特典を適用済み",
|
||||
"workspace.referral.reward.status.applied": "適用済み",
|
||||
"workspace.referral.reward.status.pendingInviter": "適用",
|
||||
"workspace.referral.reward.status.pendingInvitee": "適用",
|
||||
"workspace.referral.apply.noGo": "Goを購読する",
|
||||
"workspace.referral.apply.preview": "プレビュー",
|
||||
"workspace.referral.apply.action": "適用",
|
||||
"workspace.referral.apply.confirmTitle": "Go 特典を適用",
|
||||
"workspace.referral.apply.confirmBody": "{{amount}} を適用して、このワークスペースの現在の Go 使用カウンターを減らします。",
|
||||
"workspace.referral.apply.confirmAction": "適用",
|
||||
|
||||
"download.title": "OpenCode | ダウンロード",
|
||||
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
|
||||
"download.hero.title": "OpenCode をダウンロード",
|
||||
|
||||
@@ -660,6 +660,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "기타 결제 수단",
|
||||
"workspace.lite.promo.selectMethod": "결제 수단 선택",
|
||||
|
||||
"workspace.referral.copyLink": "링크 복사",
|
||||
"workspace.referral.copied": "복사됨",
|
||||
"workspace.referral.overview.title": "친구를 Go에 초대",
|
||||
"workspace.referral.overview.subtitle": "친구가 구독하면 $5의 Go 크레딧을 받으세요. 친구도 $5를 받습니다.",
|
||||
"workspace.referral.stats.invites": "초대",
|
||||
"workspace.referral.stats.earned": "획득",
|
||||
"workspace.referral.stats.applied": "사용",
|
||||
"workspace.referral.instructions.share": "초대 링크 공유",
|
||||
"workspace.referral.instructions.subscribe": "친구가 Go를 구독",
|
||||
"workspace.referral.instructions.claim": "아래에서 $5 크레딧 사용",
|
||||
"workspace.referral.rewards.title": "추천 보상",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}}개 보상 사용됨.",
|
||||
"workspace.referral.rewards.empty": "아직 추천 보상이 없습니다.",
|
||||
"workspace.referral.table.reward": "보상",
|
||||
"workspace.referral.table.referral": "설명",
|
||||
"workspace.referral.table.date": "날짜",
|
||||
"workspace.referral.reward.source.pendingInviter": "친구의 구독을 기다리는 중",
|
||||
"workspace.referral.reward.source.pendingInvitee": "구독하여 보상 잠금 해제",
|
||||
"workspace.referral.reward.source.available": "보상 사용 가능",
|
||||
"workspace.referral.reward.source.applied": "보상 사용됨",
|
||||
"workspace.referral.reward.status.applied": "사용됨",
|
||||
"workspace.referral.reward.status.pendingInviter": "사용",
|
||||
"workspace.referral.reward.status.pendingInvitee": "사용",
|
||||
"workspace.referral.apply.noGo": "Go 구독하기",
|
||||
"workspace.referral.apply.preview": "미리 보기",
|
||||
"workspace.referral.apply.action": "사용",
|
||||
"workspace.referral.apply.confirmTitle": "Go 보상 사용",
|
||||
"workspace.referral.apply.confirmBody": "{{amount}}를 사용하여 이 워크스페이스의 현재 Go 사용 카운터를 줄입니다.",
|
||||
"workspace.referral.apply.confirmAction": "사용",
|
||||
|
||||
"download.title": "OpenCode | 다운로드",
|
||||
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
|
||||
"download.hero.title": "OpenCode 다운로드",
|
||||
|
||||
@@ -667,6 +667,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
|
||||
"workspace.lite.promo.selectMethod": "Velg betalingsmetode",
|
||||
|
||||
"workspace.referral.copyLink": "Kopier lenke",
|
||||
"workspace.referral.copied": "Kopiert",
|
||||
"workspace.referral.overview.title": "Inviter venner til Go",
|
||||
"workspace.referral.overview.subtitle": "Få $5 i Go-kreditt når en venn abonnerer. De får også $5.",
|
||||
"workspace.referral.stats.invites": "Invitasjoner",
|
||||
"workspace.referral.stats.earned": "Opptjent",
|
||||
"workspace.referral.stats.applied": "Brukt",
|
||||
"workspace.referral.instructions.share": "Del invitasjonslenken din",
|
||||
"workspace.referral.instructions.subscribe": "Vennen din abonnerer på Go",
|
||||
"workspace.referral.instructions.claim": "Bruk $5-kreditten din nedenfor",
|
||||
"workspace.referral.rewards.title": "Henvisningsbelønninger",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} belønninger brukt.",
|
||||
"workspace.referral.rewards.empty": "Ingen henvisningsbelønninger ennå.",
|
||||
"workspace.referral.table.reward": "Belønning",
|
||||
"workspace.referral.table.referral": "Beskrivelse",
|
||||
"workspace.referral.table.date": "Dato",
|
||||
"workspace.referral.reward.source.pendingInviter": "Venter på at de abonnerer",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Abonner for å låse opp belønningen",
|
||||
"workspace.referral.reward.source.available": "Belønning klar til bruk",
|
||||
"workspace.referral.reward.source.applied": "Belønning brukt",
|
||||
"workspace.referral.reward.status.applied": "Brukt",
|
||||
"workspace.referral.reward.status.pendingInviter": "Bruk",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Bruk",
|
||||
"workspace.referral.apply.noGo": "Abonner på Go",
|
||||
"workspace.referral.apply.preview": "Forhåndsvis",
|
||||
"workspace.referral.apply.action": "Bruk",
|
||||
"workspace.referral.apply.confirmTitle": "Bruk Go-belønning",
|
||||
"workspace.referral.apply.confirmBody": "Bruk {{amount}} for å redusere dette workspacets nåværende Go-forbrukstellere.",
|
||||
"workspace.referral.apply.confirmAction": "Bruk",
|
||||
|
||||
"download.title": "OpenCode | Last ned",
|
||||
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
|
||||
"download.hero.title": "Last ned OpenCode",
|
||||
|
||||
@@ -668,6 +668,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Inne metody płatności",
|
||||
"workspace.lite.promo.selectMethod": "Wybierz metodę płatności",
|
||||
|
||||
"workspace.referral.copyLink": "Kopiuj link",
|
||||
"workspace.referral.copied": "Skopiowano",
|
||||
"workspace.referral.overview.title": "Zaproś znajomych do Go",
|
||||
"workspace.referral.overview.subtitle": "Zdobądź $5 kredytu Go, gdy znajomy się zasubskrybuje. On też dostanie $5.",
|
||||
"workspace.referral.stats.invites": "Zaproszenia",
|
||||
"workspace.referral.stats.earned": "Zdobyte",
|
||||
"workspace.referral.stats.applied": "Wykorzystane",
|
||||
"workspace.referral.instructions.share": "Udostępnij swój link z zaproszeniem",
|
||||
"workspace.referral.instructions.subscribe": "Twój znajomy subskrybuje Go",
|
||||
"workspace.referral.instructions.claim": "Wykorzystaj swój kredyt $5 poniżej",
|
||||
"workspace.referral.rewards.title": "Nagrody za polecenia",
|
||||
"workspace.referral.rewards.subtitle": "Wykorzystano {{applied}} / {{total}} nagród.",
|
||||
"workspace.referral.rewards.empty": "Brak nagród za polecenia.",
|
||||
"workspace.referral.table.reward": "Nagroda",
|
||||
"workspace.referral.table.referral": "Opis",
|
||||
"workspace.referral.table.date": "Data",
|
||||
"workspace.referral.reward.source.pendingInviter": "Oczekiwanie na jego subskrypcję",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Subskrybuj, aby odblokować nagrodę",
|
||||
"workspace.referral.reward.source.available": "Nagroda gotowa do wykorzystania",
|
||||
"workspace.referral.reward.source.applied": "Nagroda wykorzystana",
|
||||
"workspace.referral.reward.status.applied": "Wykorzystana",
|
||||
"workspace.referral.reward.status.pendingInviter": "Wykorzystaj",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Wykorzystaj",
|
||||
"workspace.referral.apply.noGo": "Subskrybuj Go",
|
||||
"workspace.referral.apply.preview": "Podgląd",
|
||||
"workspace.referral.apply.action": "Wykorzystaj",
|
||||
"workspace.referral.apply.confirmTitle": "Wykorzystaj nagrodę Go",
|
||||
"workspace.referral.apply.confirmBody": "Wykorzystaj {{amount}}, aby zmniejszyć aktualne liczniki użycia Go w tym workspace.",
|
||||
"workspace.referral.apply.confirmAction": "Wykorzystaj",
|
||||
|
||||
"download.title": "OpenCode | Pobierz",
|
||||
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
|
||||
"download.hero.title": "Pobierz OpenCode",
|
||||
|
||||
@@ -674,6 +674,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Другие способы оплаты",
|
||||
"workspace.lite.promo.selectMethod": "Выберите способ оплаты",
|
||||
|
||||
"workspace.referral.copyLink": "Копировать ссылку",
|
||||
"workspace.referral.copied": "Скопировано",
|
||||
"workspace.referral.overview.title": "Пригласите друзей в Go",
|
||||
"workspace.referral.overview.subtitle": "Получите $5 кредита Go, когда друг оформит подписку. Он тоже получит $5.",
|
||||
"workspace.referral.stats.invites": "Приглашения",
|
||||
"workspace.referral.stats.earned": "Заработано",
|
||||
"workspace.referral.stats.applied": "Использовано",
|
||||
"workspace.referral.instructions.share": "Поделитесь своей ссылкой-приглашением",
|
||||
"workspace.referral.instructions.subscribe": "Ваш друг оформляет подписку на Go",
|
||||
"workspace.referral.instructions.claim": "Используйте свой кредит $5 ниже",
|
||||
"workspace.referral.rewards.title": "Реферальные награды",
|
||||
"workspace.referral.rewards.subtitle": "Использовано {{applied}} / {{total}} наград.",
|
||||
"workspace.referral.rewards.empty": "Реферальных наград пока нет.",
|
||||
"workspace.referral.table.reward": "Награда",
|
||||
"workspace.referral.table.referral": "Описание",
|
||||
"workspace.referral.table.date": "Дата",
|
||||
"workspace.referral.reward.source.pendingInviter": "Ожидание его подписки",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Подпишитесь, чтобы разблокировать награду",
|
||||
"workspace.referral.reward.source.available": "Награда готова к применению",
|
||||
"workspace.referral.reward.source.applied": "Награда использована",
|
||||
"workspace.referral.reward.status.applied": "Использована",
|
||||
"workspace.referral.reward.status.pendingInviter": "Применить",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Применить",
|
||||
"workspace.referral.apply.noGo": "Подписаться на Go",
|
||||
"workspace.referral.apply.preview": "Предпросмотр",
|
||||
"workspace.referral.apply.action": "Применить",
|
||||
"workspace.referral.apply.confirmTitle": "Применить награду Go",
|
||||
"workspace.referral.apply.confirmBody": "Используйте {{amount}}, чтобы уменьшить текущие счётчики использования Go этого workspace.",
|
||||
"workspace.referral.apply.confirmAction": "Применить",
|
||||
|
||||
"download.title": "OpenCode | Скачать",
|
||||
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
|
||||
"download.hero.title": "Скачать OpenCode",
|
||||
|
||||
@@ -663,6 +663,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "วิธีการชำระเงินอื่นๆ",
|
||||
"workspace.lite.promo.selectMethod": "เลือกวิธีการชำระเงิน",
|
||||
|
||||
"workspace.referral.copyLink": "คัดลอกลิงก์",
|
||||
"workspace.referral.copied": "คัดลอกแล้ว",
|
||||
"workspace.referral.overview.title": "ชวนเพื่อนมาใช้ Go",
|
||||
"workspace.referral.overview.subtitle": "รับเครดิต Go $5 เมื่อเพื่อนสมัครสมาชิก เพื่อนก็จะได้รับ $5 เช่นกัน",
|
||||
"workspace.referral.stats.invites": "คำเชิญ",
|
||||
"workspace.referral.stats.earned": "ได้รับ",
|
||||
"workspace.referral.stats.applied": "ใช้แล้ว",
|
||||
"workspace.referral.instructions.share": "แชร์ลิงก์เชิญของคุณ",
|
||||
"workspace.referral.instructions.subscribe": "เพื่อนของคุณสมัครสมาชิก Go",
|
||||
"workspace.referral.instructions.claim": "ใช้เครดิต $5 ของคุณด้านล่าง",
|
||||
"workspace.referral.rewards.title": "รางวัลการแนะนำ",
|
||||
"workspace.referral.rewards.subtitle": "ใช้แล้ว {{applied}} / {{total}} รางวัล",
|
||||
"workspace.referral.rewards.empty": "ยังไม่มีรางวัลการแนะนำ",
|
||||
"workspace.referral.table.reward": "รางวัล",
|
||||
"workspace.referral.table.referral": "คำอธิบาย",
|
||||
"workspace.referral.table.date": "วันที่",
|
||||
"workspace.referral.reward.source.pendingInviter": "รอเพื่อนสมัครสมาชิก",
|
||||
"workspace.referral.reward.source.pendingInvitee": "สมัครสมาชิกเพื่อปลดล็อกรางวัล",
|
||||
"workspace.referral.reward.source.available": "รางวัลพร้อมใช้งาน",
|
||||
"workspace.referral.reward.source.applied": "ใช้รางวัลแล้ว",
|
||||
"workspace.referral.reward.status.applied": "ใช้แล้ว",
|
||||
"workspace.referral.reward.status.pendingInviter": "ใช้",
|
||||
"workspace.referral.reward.status.pendingInvitee": "ใช้",
|
||||
"workspace.referral.apply.noGo": "สมัครสมาชิก Go",
|
||||
"workspace.referral.apply.preview": "ดูตัวอย่าง",
|
||||
"workspace.referral.apply.action": "ใช้",
|
||||
"workspace.referral.apply.confirmTitle": "ใช้รางวัล Go",
|
||||
"workspace.referral.apply.confirmBody": "ใช้ {{amount}} เพื่อลดตัวนับการใช้งาน Go ปัจจุบันของ workspace นี้",
|
||||
"workspace.referral.apply.confirmAction": "ใช้",
|
||||
|
||||
"download.title": "OpenCode | ดาวน์โหลด",
|
||||
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
|
||||
"download.hero.title": "ดาวน์โหลด OpenCode",
|
||||
|
||||
@@ -670,6 +670,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Diğer ödeme yöntemleri",
|
||||
"workspace.lite.promo.selectMethod": "Ödeme yöntemini seçin",
|
||||
|
||||
"workspace.referral.copyLink": "Bağlantıyı Kopyala",
|
||||
"workspace.referral.copied": "Kopyalandı",
|
||||
"workspace.referral.overview.title": "Arkadaşlarını Go'ya davet et",
|
||||
"workspace.referral.overview.subtitle": "Bir arkadaşın abone olduğunda $5 Go kredisi kazan. O da $5 alacak.",
|
||||
"workspace.referral.stats.invites": "Davetler",
|
||||
"workspace.referral.stats.earned": "Kazanılan",
|
||||
"workspace.referral.stats.applied": "Kullanılan",
|
||||
"workspace.referral.instructions.share": "Davet bağlantını paylaş",
|
||||
"workspace.referral.instructions.subscribe": "Arkadaşın Go'ya abone olur",
|
||||
"workspace.referral.instructions.claim": "Aşağıdaki $5 kredini kullan",
|
||||
"workspace.referral.rewards.title": "Davet ödülleri",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} ödül kullanıldı.",
|
||||
"workspace.referral.rewards.empty": "Henüz davet ödülü yok.",
|
||||
"workspace.referral.table.reward": "Ödül",
|
||||
"workspace.referral.table.referral": "Açıklama",
|
||||
"workspace.referral.table.date": "Tarih",
|
||||
"workspace.referral.reward.source.pendingInviter": "Abone olması bekleniyor",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Ödülün kilidini açmak için abone ol",
|
||||
"workspace.referral.reward.source.available": "Ödül kullanıma hazır",
|
||||
"workspace.referral.reward.source.applied": "Ödül kullanıldı",
|
||||
"workspace.referral.reward.status.applied": "Kullanıldı",
|
||||
"workspace.referral.reward.status.pendingInviter": "Kullan",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Kullan",
|
||||
"workspace.referral.apply.noGo": "Go'ya Abone Ol",
|
||||
"workspace.referral.apply.preview": "Önizleme",
|
||||
"workspace.referral.apply.action": "Kullan",
|
||||
"workspace.referral.apply.confirmTitle": "Go ödülünü kullan",
|
||||
"workspace.referral.apply.confirmBody": "Bu workspace'in mevcut Go kullanım sayaçlarını azaltmak için {{amount}} kullan.",
|
||||
"workspace.referral.apply.confirmAction": "Kullan",
|
||||
|
||||
"download.title": "OpenCode | İndir",
|
||||
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
|
||||
"download.hero.title": "OpenCode'u İndir",
|
||||
|
||||
@@ -643,6 +643,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "其他付款方式",
|
||||
"workspace.lite.promo.selectMethod": "选择付款方式",
|
||||
|
||||
"workspace.referral.copyLink": "复制链接",
|
||||
"workspace.referral.copied": "已复制",
|
||||
"workspace.referral.overview.title": "邀请好友使用 Go",
|
||||
"workspace.referral.overview.subtitle": "好友订阅后,您可获得 $5 Go 抵用金,对方也可获得 $5。",
|
||||
"workspace.referral.stats.invites": "邀请",
|
||||
"workspace.referral.stats.earned": "已获得",
|
||||
"workspace.referral.stats.applied": "已使用",
|
||||
"workspace.referral.instructions.share": "分享您的邀请链接。",
|
||||
"workspace.referral.instructions.subscribe": "好友订阅 Go。",
|
||||
"workspace.referral.instructions.claim": "在下方使用您的 $5 抵用金。",
|
||||
"workspace.referral.rewards.title": "邀请奖励",
|
||||
"workspace.referral.rewards.subtitle": "已使用 {{applied}} / {{total}} 个奖励。",
|
||||
"workspace.referral.rewards.empty": "暂无邀请奖励。",
|
||||
"workspace.referral.table.reward": "奖励",
|
||||
"workspace.referral.table.referral": "描述",
|
||||
"workspace.referral.table.date": "日期",
|
||||
"workspace.referral.reward.source.pendingInviter": "等待对方订阅",
|
||||
"workspace.referral.reward.source.pendingInvitee": "订阅即可解锁奖励",
|
||||
"workspace.referral.reward.source.available": "奖励可使用",
|
||||
"workspace.referral.reward.source.applied": "奖励已使用",
|
||||
"workspace.referral.reward.status.applied": "已使用",
|
||||
"workspace.referral.reward.status.pendingInviter": "等待订阅",
|
||||
"workspace.referral.reward.status.pendingInvitee": "订阅后解锁",
|
||||
"workspace.referral.apply.noGo": "订阅 Go",
|
||||
"workspace.referral.apply.preview": "预览",
|
||||
"workspace.referral.apply.action": "使用",
|
||||
"workspace.referral.apply.confirmTitle": "使用 Go 奖励",
|
||||
"workspace.referral.apply.confirmBody": "使用 {{amount}} 抵扣当前工作区的 Go 用量计数。",
|
||||
"workspace.referral.apply.confirmAction": "使用",
|
||||
|
||||
"download.title": "OpenCode | 下载",
|
||||
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
|
||||
"download.hero.title": "下载 OpenCode",
|
||||
|
||||
@@ -643,6 +643,36 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "其他付款方式",
|
||||
"workspace.lite.promo.selectMethod": "選擇付款方式",
|
||||
|
||||
"workspace.referral.copyLink": "複製連結",
|
||||
"workspace.referral.copied": "已複製",
|
||||
"workspace.referral.overview.title": "邀請朋友使用 Go",
|
||||
"workspace.referral.overview.subtitle": "朋友訂閱後,您可獲得 $5 Go 抵用金,對方也可獲得 $5。",
|
||||
"workspace.referral.stats.invites": "邀請",
|
||||
"workspace.referral.stats.earned": "已獲得",
|
||||
"workspace.referral.stats.applied": "已使用",
|
||||
"workspace.referral.instructions.share": "分享您的邀請連結。",
|
||||
"workspace.referral.instructions.subscribe": "朋友訂閱 Go。",
|
||||
"workspace.referral.instructions.claim": "在下方使用您的 $5 抵用金。",
|
||||
"workspace.referral.rewards.title": "邀請獎勵",
|
||||
"workspace.referral.rewards.subtitle": "已使用 {{applied}} / {{total}} 個獎勵。",
|
||||
"workspace.referral.rewards.empty": "暫無邀請獎勵。",
|
||||
"workspace.referral.table.reward": "獎勵",
|
||||
"workspace.referral.table.referral": "描述",
|
||||
"workspace.referral.table.date": "日期",
|
||||
"workspace.referral.reward.source.pendingInviter": "等待對方訂閱",
|
||||
"workspace.referral.reward.source.pendingInvitee": "訂閱即可解鎖獎勵",
|
||||
"workspace.referral.reward.source.available": "獎勵可使用",
|
||||
"workspace.referral.reward.source.applied": "獎勵已使用",
|
||||
"workspace.referral.reward.status.applied": "已使用",
|
||||
"workspace.referral.reward.status.pendingInviter": "等待訂閱",
|
||||
"workspace.referral.reward.status.pendingInvitee": "訂閱後解鎖",
|
||||
"workspace.referral.apply.noGo": "訂閱 Go",
|
||||
"workspace.referral.apply.preview": "預覽",
|
||||
"workspace.referral.apply.action": "使用",
|
||||
"workspace.referral.apply.confirmTitle": "使用 Go 獎勵",
|
||||
"workspace.referral.apply.confirmBody": "使用 {{amount}} 抵扣目前工作區的 Go 用量計數。",
|
||||
"workspace.referral.apply.confirmAction": "使用",
|
||||
|
||||
"download.title": "OpenCode | 下載",
|
||||
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",
|
||||
"download.hero.title": "下載 OpenCode",
|
||||
|
||||
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)}`
|
||||
}
|
||||
28
packages/console/app/src/lib/referral-invite.ts
Normal file
28
packages/console/app/src/lib/referral-invite.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||
|
||||
const REFERRAL_COOKIE = "oc_referral"
|
||||
const REFERRAL_MAX_AGE = 60 * 60 * 24 * 30
|
||||
|
||||
export function normalizeReferralCode(code?: string | null) {
|
||||
return Referral.normalizeCode(code)
|
||||
}
|
||||
|
||||
export function referralCookie(code: string) {
|
||||
return `${REFERRAL_COOKIE}=${encodeURIComponent(code)}; Path=/; Max-Age=${REFERRAL_MAX_AGE}; SameSite=Lax; HttpOnly`
|
||||
}
|
||||
|
||||
export function clearReferralCookie() {
|
||||
return `${REFERRAL_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly`
|
||||
}
|
||||
|
||||
export function referralCodeFromCookieHeader(header: string | null) {
|
||||
if (!header) return undefined
|
||||
|
||||
return normalizeReferralCode(
|
||||
header
|
||||
.split(";")
|
||||
.map((x) => x.trim())
|
||||
.find((x) => x.startsWith(`${REFERRAL_COOKIE}=`))
|
||||
?.slice(`${REFERRAL_COOKIE}=`.length),
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { createMiddleware } from "@solidjs/start/middleware"
|
||||
import { LOCALE_HEADER, cookie, fromPathname, strip } from "~/lib/language"
|
||||
import { normalizeReferralCode, referralCookie } from "~/lib/referral-invite"
|
||||
|
||||
export default createMiddleware({
|
||||
onRequest(event) {
|
||||
const url = new URL(event.request.url)
|
||||
const locale = fromPathname(url.pathname)
|
||||
if (!locale) return
|
||||
if (locale) {
|
||||
url.pathname = strip(url.pathname)
|
||||
const request = new Request(url, event.request)
|
||||
request.headers.set(LOCALE_HEADER, locale)
|
||||
event.request = request
|
||||
event.response.headers.append("set-cookie", cookie(locale))
|
||||
}
|
||||
|
||||
url.pathname = strip(url.pathname)
|
||||
const request = new Request(url, event.request)
|
||||
request.headers.set(LOCALE_HEADER, locale)
|
||||
event.request = request
|
||||
event.response.headers.append("set-cookie", cookie(locale))
|
||||
const referralCode = normalizeReferralCode(url.searchParams.get("ref"))
|
||||
if (referralCode) event.response.headers.append("set-cookie", referralCookie(referralCode))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { redirect } from "@solidjs/router"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||
import { AuthClient } from "~/context/auth"
|
||||
import { useAuthSession } from "~/context/auth"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest, route } from "~/lib/language"
|
||||
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const url = new URL(input.request.url)
|
||||
@@ -17,6 +19,7 @@ export async function GET(input: APIEvent) {
|
||||
if (result.err) throw new Error(result.err.message)
|
||||
const decoded = AuthClient.decode(result.tokens.access, {} as any)
|
||||
if (decoded.err) throw new Error(decoded.err.message)
|
||||
const referralCode = referralCodeFromCookieHeader(input.request.headers.get("cookie"))
|
||||
const session = await useAuthSession()
|
||||
const id = decoded.subject.properties.accountID
|
||||
await session.update((value) => {
|
||||
@@ -32,8 +35,15 @@ export async function GET(input: APIEvent) {
|
||||
current: id,
|
||||
}
|
||||
})
|
||||
if (decoded.subject.properties.newAccount && referralCode) {
|
||||
await Referral.createFromAccount({ accountID: id, referralCode }).catch((error) => {
|
||||
console.error("Referral create failed", error)
|
||||
})
|
||||
}
|
||||
const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "")
|
||||
return redirect(route(locale, next))
|
||||
const response = redirect(route(locale, next))
|
||||
if (referralCode) response.headers.append("set-cookie", clearReferralCookie())
|
||||
return response
|
||||
} catch (e: any) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -701,64 +701,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="workspace-picker"] {
|
||||
[data-slot="workspace-list"] {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
outline: none;
|
||||
overflow-y: auto;
|
||||
max-height: 240px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="workspace-item"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
cursor: pointer;
|
||||
|
||||
[data-slot="selected-icon"] {
|
||||
visibility: hidden;
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
span:last-child {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-active="true"] {
|
||||
background: #161616;
|
||||
|
||||
[data-slot="selected-icon"] {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,3 +781,64 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="black-workspace-picker-modal"] {
|
||||
font-family: var(--font-mono);
|
||||
|
||||
[data-slot="workspace-list"] {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
outline: none;
|
||||
overflow-y: auto;
|
||||
max-height: 240px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="workspace-item"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
cursor: pointer;
|
||||
|
||||
[data-slot="selected-icon"] {
|
||||
visibility: hidden;
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
span:last-child {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-active="true"] {
|
||||
background: #161616;
|
||||
|
||||
[data-slot="selected-icon"] {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,8 +444,13 @@ export default function BlackSubscribe() {
|
||||
</div>
|
||||
|
||||
{/* Workspace picker modal */}
|
||||
<Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title={i18n.t("black.workspace.selectPlan")}>
|
||||
<div data-slot="workspace-picker">
|
||||
<Modal
|
||||
open={showWorkspacePicker() ?? false}
|
||||
onClose={() => {}}
|
||||
title={i18n.t("black.workspace.selectPlan")}
|
||||
variant="black"
|
||||
>
|
||||
<div data-component="black-workspace-picker-modal" data-slot="workspace-picker">
|
||||
<ul
|
||||
ref={listRef}
|
||||
data-slot="workspace-list"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./index.css"
|
||||
import { createAsync, query } from "@solidjs/router"
|
||||
import { createAsync, query, useLocation } from "@solidjs/router"
|
||||
import { Title, Meta } from "@solidjs/meta"
|
||||
import { For, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
//import { HttpHeader } from "@solidjs/start"
|
||||
@@ -224,8 +224,15 @@ function LimitsGraph(props: { href: string }) {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const location = useLocation()
|
||||
const workspaceID = createAsync(() => checkLoggedIn())
|
||||
const subscribeUrl = createMemo(() => (workspaceID() ? `/workspace/${workspaceID()}/go` : "/auth"))
|
||||
const referralCode = createMemo(() => new URLSearchParams(location.search).get("ref") ?? undefined)
|
||||
const subscribeUrl = createMemo(() => {
|
||||
const code = referralCode()
|
||||
const referral = code ? `?ref=${encodeURIComponent(code)}` : ""
|
||||
if (workspaceID()) return `/workspace/${workspaceID()}/go${referral}`
|
||||
return `/auth${referral}`
|
||||
})
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
||||
import { BlackData } from "@opencode-ai/console-core/black.js"
|
||||
import { User } from "@opencode-ai/console-core/user.js"
|
||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||
|
||||
export async function POST(input: APIEvent) {
|
||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||
@@ -174,6 +174,13 @@ export async function POST(input: APIEvent) {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await Referral.completeFromLiteSubscription({
|
||||
workspaceID,
|
||||
userID,
|
||||
}).catch((error) => {
|
||||
console.error("Referral sync failed", error)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
background-color: var(--color-bg-surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="workspace-create-modal"] {
|
||||
width: 100%;
|
||||
|
||||
[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,32 +85,34 @@ 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")}>
|
||||
<form data-slot="create-form" action={createWorkspace} method="post">
|
||||
<div data-slot="create-input-group">
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-slot="create-input"
|
||||
type="text"
|
||||
name="workspaceName"
|
||||
placeholder={i18n.t("workspace.modal.placeholder")}
|
||||
required
|
||||
/>
|
||||
<div data-slot="button-group">
|
||||
<button type="button" data-color="ghost" onClick={() => setStore("showForm", false)}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||
{submission.pending ? i18n.t("common.creating") : i18n.t("common.create")}
|
||||
</button>
|
||||
<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">
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-slot="create-input"
|
||||
type="text"
|
||||
name="workspaceName"
|
||||
placeholder={i18n.t("workspace.modal.placeholder")}
|
||||
required
|
||||
/>
|
||||
<div data-slot="button-group">
|
||||
<button type="button" data-color="ghost" onClick={() => setShowForm(false)}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||
{submission.pending ? i18n.t("common.creating") : i18n.t("common.create")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { createAsync, useParams } from "@solidjs/router"
|
||||
import { Show } from "solid-js"
|
||||
import { IconGo } from "~/component/icon"
|
||||
import { GoReferralSection, queryGoReferral } from "~/component/go-referral"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import { LiteSection } from "./lite-section"
|
||||
|
||||
export default function () {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const referral = createAsync(() => queryGoReferral(params.id!))
|
||||
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
@@ -24,6 +29,13 @@ export default function () {
|
||||
|
||||
<div data-slot="sections">
|
||||
<LiteSection />
|
||||
<Show when={referral()} fallback={<section>{i18n.t("workspace.lite.loading")}</section>}>
|
||||
{(summary) => (
|
||||
<Show when={summary().hasActiveGo || summary().rewards.length > 0}>
|
||||
<GoReferralSection workspaceID={params.id!} summary={summary()} />
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -212,6 +212,9 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.paymentMethodModal {
|
||||
[data-slot="modal-actions"] {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
|
||||
@@ -14,10 +14,11 @@ 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"
|
||||
|
||||
const queryLiteSubscription = query(async (workspaceID: string) => {
|
||||
export const queryLiteSubscription = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
const row = await Database.use((tx) =>
|
||||
@@ -67,20 +68,6 @@ const queryLiteSubscription = query(async (workspaceID: string) => {
|
||||
}, workspaceID)
|
||||
}, "lite.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.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,31 +301,33 @@ export function LiteSection() {
|
||||
onClose={() => setStore("showModal", false)}
|
||||
title={i18n.t("workspace.lite.promo.selectMethod")}
|
||||
>
|
||||
<div data-slot="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="method-button"
|
||||
data-color="ghost"
|
||||
disabled={checkoutSubmission.pending || busy()}
|
||||
onClick={() => onClickSubscribe("alipay")}
|
||||
>
|
||||
<Show when={store.loading !== "alipay"}>
|
||||
<IconAlipay style={{ width: "24px", height: "24px" }} />
|
||||
</Show>
|
||||
{store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="method-button"
|
||||
data-color="ghost"
|
||||
disabled={checkoutSubmission.pending || busy()}
|
||||
onClick={() => onClickSubscribe("upi")}
|
||||
>
|
||||
<Show when={store.loading !== "upi"}>
|
||||
<IconUpi style={{ width: "auto", height: "16px" }} />
|
||||
</Show>
|
||||
{store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
|
||||
</button>
|
||||
<div class={styles.paymentMethodModal}>
|
||||
<div data-slot="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="method-button"
|
||||
data-color="ghost"
|
||||
disabled={checkoutSubmission.pending || busy()}
|
||||
onClick={() => onClickSubscribe("alipay")}
|
||||
>
|
||||
<Show when={store.loading !== "alipay"}>
|
||||
<IconAlipay style={{ width: "24px", height: "24px" }} />
|
||||
</Show>
|
||||
{store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="method-button"
|
||||
data-color="ghost"
|
||||
disabled={checkoutSubmission.pending || busy()}
|
||||
onClick={() => onClickSubscribe("upi")}
|
||||
>
|
||||
<Show when={store.loading !== "upi"}>
|
||||
<IconUpi style={{ width: "auto", height: "16px" }} />
|
||||
</Show>
|
||||
{store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
CREATE TABLE `referral_code` (
|
||||
`id` varchar(30) NOT NULL,
|
||||
`workspace_id` varchar(30) NOT NULL,
|
||||
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
|
||||
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`time_deleted` timestamp(3),
|
||||
`code` varchar(10) NOT NULL,
|
||||
CONSTRAINT PRIMARY KEY(`workspace_id`,`id`),
|
||||
CONSTRAINT `referral_code_workspace_id` UNIQUE INDEX(`workspace_id`),
|
||||
CONSTRAINT `referral_code_code` UNIQUE INDEX(`code`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `referral_reward` (
|
||||
`id` varchar(30) NOT NULL,
|
||||
`workspace_id` varchar(30) NOT NULL,
|
||||
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
|
||||
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`time_deleted` timestamp(3),
|
||||
`referral_id` varchar(30) NOT NULL,
|
||||
`source` enum('inviter','invitee') NOT NULL,
|
||||
`amount` bigint NOT NULL,
|
||||
`applied_by_user_id` varchar(30),
|
||||
`time_applied` timestamp(3),
|
||||
CONSTRAINT PRIMARY KEY(`workspace_id`,`id`),
|
||||
CONSTRAINT `referral_reward_referral_source` UNIQUE INDEX(`referral_id`,`source`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `referral` (
|
||||
`id` varchar(30) NOT NULL,
|
||||
`workspace_id` varchar(30) NOT NULL,
|
||||
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
|
||||
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`time_deleted` timestamp(3),
|
||||
`inviter_workspace_id` varchar(30) NOT NULL,
|
||||
`invitee_account_id` varchar(30) NOT NULL,
|
||||
`invitee_user_id` varchar(30) NOT NULL,
|
||||
`referral_code_id` varchar(30) NOT NULL,
|
||||
`stripe_customer_id` varchar(255) NOT NULL,
|
||||
`stripe_subscription_id` varchar(255) NOT NULL,
|
||||
CONSTRAINT PRIMARY KEY(`workspace_id`,`id`),
|
||||
CONSTRAINT `referral_invitee_account_id` UNIQUE INDEX(`invitee_account_id`),
|
||||
CONSTRAINT `referral_stripe_subscription_id` UNIQUE INDEX(`stripe_subscription_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `referral_reward_workspace_time` ON `referral_reward` (`workspace_id`,`time_created`);--> statement-breakpoint
|
||||
CREATE INDEX `referral_inviter_workspace_id` ON `referral` (`inviter_workspace_id`);--> statement-breakpoint
|
||||
CREATE INDEX `referral_code_id` ON `referral` (`referral_code_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
DROP TABLE `referral_code`;--> statement-breakpoint
|
||||
DROP INDEX `referral_reward_referral_source` ON `referral_reward`;--> statement-breakpoint
|
||||
DROP INDEX `referral_stripe_subscription_id` ON `referral`;--> statement-breakpoint
|
||||
DROP INDEX `referral_inviter_workspace_id` ON `referral`;--> statement-breakpoint
|
||||
DROP INDEX `referral_code_id` ON `referral`;--> statement-breakpoint
|
||||
ALTER TABLE `referral_reward` DROP PRIMARY KEY;--> statement-breakpoint
|
||||
ALTER TABLE `referral` DROP PRIMARY KEY;--> statement-breakpoint
|
||||
ALTER TABLE `referral_reward` MODIFY COLUMN `workspace_id` varchar(30);--> statement-breakpoint
|
||||
ALTER TABLE `workspace` ADD `referral_code` varchar(16);--> statement-breakpoint
|
||||
ALTER TABLE `referral_reward` ADD PRIMARY KEY (`id`);--> statement-breakpoint
|
||||
ALTER TABLE `referral` ADD PRIMARY KEY (`id`);--> statement-breakpoint
|
||||
CREATE INDEX `referral_workspace_id` ON `referral` (`workspace_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `workspace_referral_code` ON `workspace` (`referral_code`);--> statement-breakpoint
|
||||
ALTER TABLE `referral_reward` DROP COLUMN `source`;--> statement-breakpoint
|
||||
ALTER TABLE `referral_reward` DROP COLUMN `applied_by_user_id`;--> statement-breakpoint
|
||||
ALTER TABLE `referral` DROP COLUMN `inviter_workspace_id`;--> statement-breakpoint
|
||||
ALTER TABLE `referral` DROP COLUMN `invitee_user_id`;--> statement-breakpoint
|
||||
ALTER TABLE `referral` DROP COLUMN `referral_code_id`;--> statement-breakpoint
|
||||
ALTER TABLE `referral` DROP COLUMN `stripe_customer_id`;--> statement-breakpoint
|
||||
ALTER TABLE `referral` DROP COLUMN `stripe_subscription_id`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
DROP INDEX `referral_reward_workspace_time` ON `referral_reward`;--> statement-breakpoint
|
||||
ALTER TABLE `referral_reward` DROP PRIMARY KEY;--> statement-breakpoint
|
||||
ALTER TABLE `referral_reward` ADD PRIMARY KEY (`workspace_id`,`referral_id`);--> statement-breakpoint
|
||||
ALTER TABLE `referral_reward` DROP COLUMN `id`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
DROP INDEX `referral_workspace_id` ON `referral`;--> statement-breakpoint
|
||||
ALTER TABLE `referral` DROP PRIMARY KEY;--> statement-breakpoint
|
||||
ALTER TABLE `referral` ADD PRIMARY KEY (`workspace_id`,`id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
UPDATE `workspace` SET `referral_code` = NULL WHERE CHAR_LENGTH(`referral_code`) > 10;--> statement-breakpoint
|
||||
ALTER TABLE `workspace` MODIFY COLUMN `referral_code` varchar(10);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
DROP INDEX `workspace_referral_code` ON `workspace`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `referral_code` ON `workspace` (`referral_code`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -155,6 +155,26 @@ export namespace Billing {
|
||||
return amountInMicroCents
|
||||
}
|
||||
|
||||
export const subtractLiteUsage = async (workspaceID: string, amountInMicroCents: number) => {
|
||||
await Database.transaction(async (tx) => {
|
||||
const lite = await tx
|
||||
.select({ id: LiteTable.id })
|
||||
.from(LiteTable)
|
||||
.where(and(eq(LiteTable.workspaceID, workspaceID), isNull(LiteTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!lite) throw new Error("Subscribe to Go before applying referral rewards")
|
||||
|
||||
await tx
|
||||
.update(LiteTable)
|
||||
.set({
|
||||
monthlyUsage: sql`GREATEST(0, COALESCE(${LiteTable.monthlyUsage}, 0) - ${amountInMicroCents})`,
|
||||
weeklyUsage: sql`GREATEST(0, COALESCE(${LiteTable.weeklyUsage}, 0) - ${amountInMicroCents})`,
|
||||
rollingUsage: sql`GREATEST(0, COALESCE(${LiteTable.rollingUsage}, 0) - ${amountInMicroCents})`,
|
||||
})
|
||||
.where(and(eq(LiteTable.workspaceID, workspaceID), isNull(LiteTable.timeDeleted)))
|
||||
})
|
||||
}
|
||||
|
||||
export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => {
|
||||
// validate coupon type
|
||||
await (async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ export namespace Identifier {
|
||||
model: "mod",
|
||||
payment: "pay",
|
||||
provider: "prv",
|
||||
referral: "ref",
|
||||
subscription: "sub",
|
||||
usage: "usg",
|
||||
user: "usr",
|
||||
|
||||
386
packages/console/core/src/referral.ts
Normal file
386
packages/console/core/src/referral.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { z } from "zod"
|
||||
import { and, desc, eq, isNull, sql, Database } from "./drizzle"
|
||||
import { Actor } from "./actor"
|
||||
import { Identifier } from "./identifier"
|
||||
import { LiteTable } from "./schema/billing.sql"
|
||||
import { ReferralRewardTable, ReferralTable } from "./schema/referral.sql"
|
||||
import { AuthTable } from "./schema/auth.sql"
|
||||
import { UserTable } from "./schema/user.sql"
|
||||
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)
|
||||
export const CODE_LENGTH = 10
|
||||
|
||||
export function normalizeCode(code?: string | null) {
|
||||
return code?.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, CODE_LENGTH)
|
||||
}
|
||||
|
||||
function generateCode() {
|
||||
return ulid().slice(-CODE_LENGTH)
|
||||
}
|
||||
|
||||
async function ensureCode(workspaceID = Actor.workspace()) {
|
||||
return Database.transaction(async (tx) => {
|
||||
const existing = await tx
|
||||
.select({ code: WorkspaceTable.referralCode })
|
||||
.from(WorkspaceTable)
|
||||
.where(and(eq(WorkspaceTable.id, workspaceID), isNull(WorkspaceTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!existing) throw new Error("Workspace not found")
|
||||
if (existing.code) return { code: existing.code }
|
||||
|
||||
for (const _ of Array.from({ length: 5 })) {
|
||||
await tx
|
||||
.update(WorkspaceTable)
|
||||
.set({ referralCode: generateCode() })
|
||||
.where(
|
||||
and(eq(WorkspaceTable.id, workspaceID), isNull(WorkspaceTable.referralCode), isNull(WorkspaceTable.timeDeleted)),
|
||||
)
|
||||
|
||||
const created = await tx
|
||||
.select({ code: WorkspaceTable.referralCode })
|
||||
.from(WorkspaceTable)
|
||||
.where(and(eq(WorkspaceTable.id, workspaceID), isNull(WorkspaceTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (created?.code) return { code: created.code }
|
||||
}
|
||||
|
||||
throw new Error("Failed to generate referral code")
|
||||
})
|
||||
}
|
||||
|
||||
export const summary = fn(z.void(), async () => {
|
||||
const workspaceID = Actor.workspace()
|
||||
const accountID = Actor.account()
|
||||
const code = await ensureCode(workspaceID)
|
||||
const rows = await Database.use(async (tx) => {
|
||||
const [rewards, invites, inviteeReferrals, inviteeRewards, lite] = await Promise.all([
|
||||
tx
|
||||
.select({
|
||||
referralID: ReferralRewardTable.referralID,
|
||||
workspaceID: ReferralRewardTable.workspaceID,
|
||||
referralWorkspaceID: ReferralTable.workspaceID,
|
||||
inviteeEmail: AuthTable.subject,
|
||||
amount: ReferralRewardTable.amount,
|
||||
timeCreated: ReferralRewardTable.timeCreated,
|
||||
timeApplied: ReferralRewardTable.timeApplied,
|
||||
})
|
||||
.from(ReferralRewardTable)
|
||||
.innerJoin(ReferralTable, eq(ReferralTable.id, ReferralRewardTable.referralID))
|
||||
.innerJoin(AuthTable, and(eq(AuthTable.accountID, ReferralTable.inviteeAccountID), eq(AuthTable.provider, "email")))
|
||||
.where(
|
||||
and(
|
||||
eq(ReferralRewardTable.workspaceID, workspaceID),
|
||||
isNull(ReferralRewardTable.timeDeleted),
|
||||
isNull(ReferralTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(ReferralRewardTable.timeCreated)),
|
||||
tx
|
||||
.select({ id: ReferralTable.id, inviteeEmail: AuthTable.subject, timeCreated: ReferralTable.timeCreated })
|
||||
.from(ReferralTable)
|
||||
.innerJoin(AuthTable, and(eq(AuthTable.accountID, ReferralTable.inviteeAccountID), eq(AuthTable.provider, "email")))
|
||||
.where(and(eq(ReferralTable.workspaceID, workspaceID), isNull(ReferralTable.timeDeleted))),
|
||||
tx
|
||||
.select({ id: ReferralTable.id, inviteeEmail: AuthTable.subject, timeCreated: ReferralTable.timeCreated })
|
||||
.from(ReferralTable)
|
||||
.innerJoin(AuthTable, and(eq(AuthTable.accountID, ReferralTable.inviteeAccountID), eq(AuthTable.provider, "email")))
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, accountID), isNull(ReferralTable.timeDeleted))),
|
||||
tx
|
||||
.select({ referralID: ReferralRewardTable.referralID })
|
||||
.from(ReferralRewardTable)
|
||||
.innerJoin(ReferralTable, eq(ReferralTable.id, ReferralRewardTable.referralID))
|
||||
.where(
|
||||
and(
|
||||
eq(ReferralTable.inviteeAccountID, accountID),
|
||||
isNull(ReferralRewardTable.timeDeleted),
|
||||
isNull(ReferralTable.timeDeleted),
|
||||
),
|
||||
),
|
||||
tx
|
||||
.select({ id: LiteTable.id })
|
||||
.from(LiteTable)
|
||||
.where(and(eq(LiteTable.workspaceID, workspaceID), isNull(LiteTable.timeDeleted)))
|
||||
.then((result) => result[0]),
|
||||
])
|
||||
|
||||
return { inviteeReferrals, inviteeRewards, invites, lite, rewards }
|
||||
})
|
||||
|
||||
const rewardReferralIDs = new Set(rows.rewards.map((reward) => reward.referralID))
|
||||
const inviteeRewardReferralIDs = new Set(rows.inviteeRewards.map((reward) => reward.referralID))
|
||||
const rewards = rows.rewards.map((reward) => ({
|
||||
id: reward.referralID,
|
||||
source: reward.workspaceID === reward.referralWorkspaceID ? ("inviter" as const) : ("invitee" as const),
|
||||
status: reward.timeApplied ? ("applied" as const) : ("available" as const),
|
||||
email: reward.inviteeEmail,
|
||||
amount: microCentsToCents(reward.amount),
|
||||
timeCreated: reward.timeCreated,
|
||||
timeApplied: reward.timeApplied,
|
||||
}))
|
||||
const pending = [
|
||||
...rows.invites
|
||||
.filter((referral) => !rewardReferralIDs.has(referral.id))
|
||||
.map((referral) => ({
|
||||
id: `${referral.id}:inviter`,
|
||||
source: "inviter" as const,
|
||||
status: "pending" as const,
|
||||
email: referral.inviteeEmail,
|
||||
amount: microCentsToCents(REWARD_AMOUNT),
|
||||
timeCreated: referral.timeCreated,
|
||||
timeApplied: null,
|
||||
})),
|
||||
...rows.inviteeReferrals
|
||||
.filter((referral) => !inviteeRewardReferralIDs.has(referral.id))
|
||||
.map((referral) => ({
|
||||
id: `${referral.id}:invitee`,
|
||||
source: "invitee" as const,
|
||||
status: "pending" as const,
|
||||
email: referral.inviteeEmail,
|
||||
amount: microCentsToCents(REWARD_AMOUNT),
|
||||
timeCreated: referral.timeCreated,
|
||||
timeApplied: null,
|
||||
})),
|
||||
]
|
||||
const allRewards = [...pending, ...rewards].sort(
|
||||
(a, b) => new Date(b.timeCreated).getTime() - new Date(a.timeCreated).getTime(),
|
||||
)
|
||||
return {
|
||||
referralCode: code.code,
|
||||
inviteCount: allRewards.length,
|
||||
hasActiveGo: !!rows.lite,
|
||||
rewardAmount: microCentsToCents(REWARD_AMOUNT),
|
||||
totalEarned: rewards.reduce((total, reward) => total + reward.amount, 0),
|
||||
totalApplied: rewards
|
||||
.filter((reward) => reward.timeApplied)
|
||||
.reduce((total, reward) => total + reward.amount, 0),
|
||||
rewards: allRewards,
|
||||
}
|
||||
})
|
||||
|
||||
export const applyReward = fn(z.object({ referralID: z.string() }), async (input) => {
|
||||
const workspaceID = Actor.workspace()
|
||||
|
||||
return Database.transaction(async (tx) => {
|
||||
const reward = await tx
|
||||
.select({ amount: ReferralRewardTable.amount, timeApplied: ReferralRewardTable.timeApplied })
|
||||
.from(ReferralRewardTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ReferralRewardTable.workspaceID, workspaceID),
|
||||
eq(ReferralRewardTable.referralID, input.referralID),
|
||||
isNull(ReferralRewardTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (!reward) throw new Error("Referral reward not found")
|
||||
if (reward.timeApplied) throw new Error("Referral reward already applied")
|
||||
|
||||
const update = await tx
|
||||
.update(ReferralRewardTable)
|
||||
.set({
|
||||
timeApplied: sql`now()`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(ReferralRewardTable.workspaceID, workspaceID),
|
||||
eq(ReferralRewardTable.referralID, input.referralID),
|
||||
isNull(ReferralRewardTable.timeApplied),
|
||||
isNull(ReferralRewardTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
if (update.rowsAffected === 0) throw new Error("Referral reward already applied")
|
||||
|
||||
await Billing.subtractLiteUsage(workspaceID, reward.amount)
|
||||
|
||||
return { amount: microCentsToCents(reward.amount) }
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
referralCode?: string
|
||||
}) {
|
||||
const referralCode = normalizeCode(input.referralCode)
|
||||
if (!referralCode) return
|
||||
|
||||
return Database.transaction(async (tx) => {
|
||||
const code = await tx
|
||||
.select({ workspaceID: WorkspaceTable.id })
|
||||
.from(WorkspaceTable)
|
||||
.where(and(eq(WorkspaceTable.referralCode, referralCode), isNull(WorkspaceTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!code) throw new Error("Referral code invalid")
|
||||
|
||||
const existingReferral = await tx
|
||||
.select({ id: ReferralTable.id })
|
||||
.from(ReferralTable)
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, input.accountID), isNull(ReferralTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (existingReferral) throw new Error("Referral already redeemed")
|
||||
|
||||
const selfReferral = await tx
|
||||
.select({ id: UserTable.id })
|
||||
.from(UserTable)
|
||||
.where(
|
||||
and(
|
||||
eq(UserTable.workspaceID, code.workspaceID),
|
||||
eq(UserTable.accountID, input.accountID),
|
||||
isNull(UserTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (selfReferral) throw new Error("Self-referral is not allowed")
|
||||
|
||||
const referralID = Identifier.create("referral")
|
||||
await tx
|
||||
.insert(ReferralTable)
|
||||
.ignore()
|
||||
.values({
|
||||
workspaceID: code.workspaceID,
|
||||
id: referralID,
|
||||
inviteeAccountID: input.accountID,
|
||||
})
|
||||
|
||||
const referral = await tx
|
||||
.select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID })
|
||||
.from(ReferralTable)
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, input.accountID), isNull(ReferralTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!referral) throw new Error("Referral not created")
|
||||
if (referral.id !== referralID) throw new Error("Referral already redeemed")
|
||||
})
|
||||
}
|
||||
|
||||
export async function completeFromLiteSubscription(input: {
|
||||
workspaceID: string
|
||||
userID: string
|
||||
}) {
|
||||
return Database.transaction(async (tx) => {
|
||||
const invitee = await tx
|
||||
.select({ accountID: UserTable.accountID })
|
||||
.from(UserTable)
|
||||
.where(
|
||||
and(eq(UserTable.workspaceID, input.workspaceID), eq(UserTable.id, input.userID), isNull(UserTable.timeDeleted)),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (!invitee?.accountID) throw new Error("Referral invitee account missing")
|
||||
|
||||
const referral = await tx
|
||||
.select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID })
|
||||
.from(ReferralTable)
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!referral) throw new Error("Referral not found")
|
||||
|
||||
const result = await tx
|
||||
.insert(ReferralRewardTable)
|
||||
.ignore()
|
||||
.values([
|
||||
{
|
||||
workspaceID: referral.workspaceID,
|
||||
referralID: referral.id,
|
||||
amount: REWARD_AMOUNT,
|
||||
},
|
||||
{
|
||||
workspaceID: input.workspaceID,
|
||||
referralID: referral.id,
|
||||
amount: REWARD_AMOUNT,
|
||||
},
|
||||
])
|
||||
|
||||
if (result.rowsAffected === 0) throw new Error("Referral already completed")
|
||||
})
|
||||
}
|
||||
|
||||
function usagePreviewItem(
|
||||
before: { usagePercent: number; resetInSec: number },
|
||||
after: { usagePercent: number; resetInSec: number },
|
||||
) {
|
||||
return {
|
||||
beforePercent: before.usagePercent,
|
||||
afterPercent: after.usagePercent,
|
||||
resetInSec: after.resetInSec,
|
||||
}
|
||||
}
|
||||
}
|
||||
28
packages/console/core/src/schema/referral.sql.ts
Normal file
28
packages/console/core/src/schema/referral.sql.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { bigint, mysqlTable, primaryKey, uniqueIndex } from "drizzle-orm/mysql-core"
|
||||
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
||||
import { workspaceIndexes } from "./workspace.sql"
|
||||
|
||||
export const ReferralTable = mysqlTable(
|
||||
"referral",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
inviteeAccountID: ulid("invitee_account_id").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
...workspaceIndexes(table),
|
||||
uniqueIndex("referral_invitee_account_id").on(table.inviteeAccountID),
|
||||
],
|
||||
)
|
||||
|
||||
export const ReferralRewardTable = mysqlTable(
|
||||
"referral_reward",
|
||||
{
|
||||
workspaceID: ulid("workspace_id").notNull(),
|
||||
referralID: ulid("referral_id").notNull(),
|
||||
...timestamps,
|
||||
amount: bigint("amount", { mode: "number" }).notNull(),
|
||||
timeApplied: utc("time_applied"),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.workspaceID, table.referralID] })],
|
||||
)
|
||||
@@ -6,10 +6,14 @@ export const WorkspaceTable = mysqlTable(
|
||||
{
|
||||
id: ulid("id").notNull().primaryKey(),
|
||||
slug: varchar("slug", { length: 255 }),
|
||||
referralCode: varchar("referral_code", { length: 10 }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [uniqueIndex("slug").on(table.slug)],
|
||||
(table) => [
|
||||
uniqueIndex("slug").on(table.slug),
|
||||
uniqueIndex("referral_code").on(table.referralCode),
|
||||
],
|
||||
)
|
||||
|
||||
export function workspaceIndexes(table: any) {
|
||||
|
||||
@@ -26,6 +26,7 @@ export const subjects = createSubjects({
|
||||
account: z.object({
|
||||
accountID: z.string(),
|
||||
email: z.string(),
|
||||
newAccount: z.boolean().optional(),
|
||||
}),
|
||||
user: z.object({
|
||||
userID: z.string(),
|
||||
@@ -142,6 +143,7 @@ export default {
|
||||
}
|
||||
|
||||
// Get account
|
||||
let newAccount = false
|
||||
const accountID = await (async () => {
|
||||
const matches = await Database.use(async (tx) =>
|
||||
tx
|
||||
@@ -166,6 +168,7 @@ export default {
|
||||
if (!accountID) {
|
||||
console.log("creating account for", email)
|
||||
accountID = await Account.create({})
|
||||
newAccount = true
|
||||
}
|
||||
|
||||
await Database.use(async (tx) =>
|
||||
@@ -215,7 +218,7 @@ export default {
|
||||
await Workspace.create({ name: "Default" })
|
||||
}
|
||||
})
|
||||
return ctx.subject("account", accountID, { accountID, email })
|
||||
return ctx.subject("account", accountID, { accountID, email, newAccount })
|
||||
},
|
||||
}).fetch(request, env, ctx)
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user