mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
feat(x): first-time-action credit rewards (#754)
* feat(x): first-time-action credit rewards
Reward signed-in free-tier users the first time they connect Gmail, send
an email, take meeting notes, set up a background agent, or build an app.
- core credits service: POST /v1/billing/credit-activations, once per
(user, code); local claimed cache in config/credit_activations.json
keyed by user; 409 reconciles claimed state; other failures retry on
the next occurrence
- triggers at the action success sites in main/core (google oauth
success, gmail:sendReply, meeting:summarize, bg-task create sites,
apps:create); grants broadcast to windows via credits:didActivate
- eligibility: signed-in free tier only (paid starter/pro excluded);
whole feature behind the PostHog credit-rewards flag (default ON,
flag acts as kill switch; ROWBOAT_CREDITS env override for dev)
- UI: sidebar 'Earn $X in credits' pill with checklist popover
(dismissible, rows navigate to each action), settings Account section
with earned state + bonus balance, confetti celebration on grant
- shared: credits catalog/types, credits:getState IPC, BillingInfo
gains store bucket; decodeJwtPayload moved to auth/jwt.ts for reuse
Backend catalog codes deployed separately (rowboatx-backend).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(x): fetch reward catalog from the API, hardcode only activity codes
Review feedback: names, descriptions, and credit amounts are backend-owned
and subject to change; the app now reads them from GET /v1/config
'creditActivations' instead of a hardcoded catalog. Only the activity codes
remain in the app — they anchor the trigger call sites and per-code UI
(icons, navigation).
- shared: drop CREDIT_ACTIVITIES; add CreditActivationCatalogEntrySchema;
RowboatApiConfig gains optional creditActivations
- core: getCreditsState joins the fetched catalog (unknown codes dropped,
API order preserved) with local claimed flags; celebration title comes
from the catalog with the code as fallback
- renderer: description now optional; settings section hides while the
API serves no catalog
Requires the API to serve creditActivations: [{code, displayName,
description?, credits}] on /v1/config; until then the rewards UI stays
hidden and activations still work.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): invite-a-friend referral rewards
Surfaces the backend's referral codes (GET /v1/referral, POST
/v1/referral/claims) in the app. The inviter's permanent code appears as
an "Invite friends" item in the sidebar rewards popover and the settings
rewards list, with copy button and claims progress ($5 per friend who
joins, up to 3). The invited side redeems via a self-gating "Have an
invite code?" entry in onboarding's completion step and in settings;
a successful claim fires the existing celebration and refreshes the
balance. Backend error copy (unknown code, own code, already claimed,
cap reached, account too old) is shown inline verbatim.
The one-lifetime-claim state is cached per user in the local claimed
store (pseudo-code referral_claimed), reconciled when the backend
answers "already claimed" from another install. Referral status is
cached for 60s and busted after a claim.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5ba7918bb7
commit
033cc6d351
22 changed files with 1550 additions and 16 deletions
|
|
@ -113,6 +113,7 @@ import { invalidateKnowledgeIndex } from '@x/core/dist/knowledge/knowledge_index
|
|||
import { versionHistory, voice } from '@x/core';
|
||||
import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js';
|
||||
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
||||
import { claimReferralCode, getCreditsState, maybeActivateCredit, subscribeCreditActivations } from '@x/core/dist/billing/credits.js';
|
||||
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
||||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
|
|
@ -662,6 +663,11 @@ export function emitOAuthEvent(event: { provider: string; success: boolean; erro
|
|||
// prompt, so any OAuth state change must rebuild it.
|
||||
invalidateCopilotInstructionsCache();
|
||||
broadcastToWindows('oauth:didConnect', event);
|
||||
// Google connect (both BYOK and rowboat-mode paths funnel through here) is
|
||||
// the "connected Gmail" first-time reward.
|
||||
if (event.provider === 'google' && event.success) {
|
||||
void maybeActivateCredit('first_gmail_connected');
|
||||
}
|
||||
}
|
||||
|
||||
async function requireCodeSession(sessionId: string): Promise<CodeSession> {
|
||||
|
|
@ -850,6 +856,10 @@ export function setupIpcHandlers() {
|
|||
// Forward knowledge commit events to renderer for panel refresh
|
||||
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
|
||||
|
||||
// Relay backend-confirmed credit grants (first-time-action rewards) to all
|
||||
// windows so the UI can update balances and celebrate.
|
||||
subscribeCreditActivations((event) => broadcastToWindows('credits:didActivate', event));
|
||||
|
||||
// Pre-warm the Gmail contact indices so the first compose-box keystroke is instant.
|
||||
// - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage).
|
||||
// - warmSentContacts(): kicks off a background Gmail API sync of the SENT label
|
||||
|
|
@ -992,7 +1002,11 @@ export function setupIpcHandlers() {
|
|||
return {};
|
||||
},
|
||||
'gmail:sendReply': async (_event, args) => {
|
||||
return sendThreadReply(args);
|
||||
const result = await sendThreadReply(args);
|
||||
if (!result.error) {
|
||||
void maybeActivateCredit('first_email_sent');
|
||||
}
|
||||
return result;
|
||||
},
|
||||
'gmail:saveDraft': async (_event, args) => {
|
||||
return saveThreadDraft(args);
|
||||
|
|
@ -1819,6 +1833,7 @@ export function setupIpcHandlers() {
|
|||
'apps:create': async (_event, args) => {
|
||||
const app = await appsIndexer.createApp(args);
|
||||
capture('app_created', { folder: app.folder });
|
||||
void maybeActivateCredit('first_app_built');
|
||||
return { app };
|
||||
},
|
||||
'apps:delete': async (_event, args) => {
|
||||
|
|
@ -2192,6 +2207,9 @@ export function setupIpcHandlers() {
|
|||
},
|
||||
'meeting:summarize': async (_event, args) => {
|
||||
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson);
|
||||
if (notes && notes.trim()) {
|
||||
void maybeActivateCredit('first_meeting_note');
|
||||
}
|
||||
return { notes };
|
||||
},
|
||||
'meeting-prep:resolve': async (_event, args) => {
|
||||
|
|
@ -2483,6 +2501,7 @@ export function setupIpcHandlers() {
|
|||
...(args.model ? { model: args.model } : {}),
|
||||
...(args.provider ? { provider: args.provider } : {}),
|
||||
});
|
||||
void maybeActivateCredit('first_bg_agent');
|
||||
return { success: true, slug };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
|
|
@ -2519,6 +2538,13 @@ export function setupIpcHandlers() {
|
|||
'billing:getInfo': async () => {
|
||||
return await getBillingInfo();
|
||||
},
|
||||
// First-time-action credit rewards
|
||||
'credits:getState': async () => {
|
||||
return await getCreditsState();
|
||||
},
|
||||
'referral:claim': async (_event, args) => {
|
||||
return await claimReferralCode(args.code);
|
||||
},
|
||||
'notifications:getSettings': async () => {
|
||||
return loadNotificationSettings();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ import { Button } from "@/components/ui/button"
|
|||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { UpdateCard } from "@/components/update-card"
|
||||
import { BillingErrorDialog } from "@/components/billing-error-dialog"
|
||||
import { CreditCelebration } from "@/components/credit-celebration"
|
||||
import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error"
|
||||
import { dispatchCreditExhausted, dispatchCreditReplenished } from "@/lib/credit-status"
|
||||
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
|
|
@ -7213,6 +7214,7 @@ function App() {
|
|||
</SidebarSectionProvider>
|
||||
<Toaster />
|
||||
<UpdateCard />
|
||||
<CreditCelebration />
|
||||
<BillingErrorDialog
|
||||
open={billingErrorOpen}
|
||||
match={billingErrorMatch}
|
||||
|
|
|
|||
109
apps/x/apps/renderer/src/components/credit-celebration.tsx
Normal file
109
apps/x/apps/renderer/src/components/credit-celebration.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { Sparkles } from "lucide-react"
|
||||
import { formatCreditsAsDollars, type CreditActivatedEvent } from "@x/shared/dist/credits.js"
|
||||
|
||||
// Deterministic pseudo-random confetti geometry — stable per piece index so a
|
||||
// re-render mid-animation doesn't reshuffle the burst.
|
||||
function confettiPieces(seed: number) {
|
||||
const COLORS = ["#f59e0b", "#10b981", "#3b82f6", "#ec4899", "#8b5cf6", "#ef4444"]
|
||||
return Array.from({ length: 28 }, (_, i) => {
|
||||
const t = Math.sin(seed * 997 + i * 131) * 0.5 + 0.5
|
||||
const u = Math.sin(seed * 613 + i * 379) * 0.5 + 0.5
|
||||
const angle = (i / 28) * Math.PI * 2
|
||||
const distance = 90 + t * 140
|
||||
return {
|
||||
id: i,
|
||||
x: Math.cos(angle) * distance,
|
||||
y: Math.sin(angle) * distance * 0.75 - 40 - u * 60,
|
||||
rotate: (t - 0.5) * 720,
|
||||
color: COLORS[i % COLORS.length],
|
||||
size: 5 + u * 5,
|
||||
round: i % 3 === 0,
|
||||
delay: t * 0.12,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-window celebration shown when the backend confirms a first-time-action
|
||||
* credit grant (`credits:didActivate`): a confetti burst plus a card naming
|
||||
* the action and the amount earned. Mount once near the app root.
|
||||
*/
|
||||
export function CreditCelebration() {
|
||||
const [event, setEvent] = useState<CreditActivatedEvent | null>(null)
|
||||
const [burst, setBurst] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
return window.ipc.on("credits:didActivate", (e) => {
|
||||
setEvent(e)
|
||||
setBurst((n) => n + 1)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!event) return
|
||||
const timer = window.setTimeout(() => setEvent(null), 4500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [event])
|
||||
|
||||
const pieces = useMemo(() => confettiPieces(burst), [burst])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{event && (
|
||||
<div className="pointer-events-none fixed inset-0 z-[200] flex items-center justify-center">
|
||||
{/* confetti burst */}
|
||||
<div className="absolute left-1/2 top-1/2">
|
||||
{pieces.map((p) => (
|
||||
<motion.span
|
||||
key={`${burst}-${p.id}`}
|
||||
className="absolute block"
|
||||
style={{
|
||||
width: p.size,
|
||||
height: p.size * (p.round ? 1 : 0.6),
|
||||
backgroundColor: p.color,
|
||||
borderRadius: p.round ? "9999px" : "1px",
|
||||
}}
|
||||
initial={{ x: 0, y: 0, opacity: 1, rotate: 0, scale: 0.6 }}
|
||||
animate={{
|
||||
x: p.x,
|
||||
y: [0, p.y, p.y + 260],
|
||||
opacity: [1, 1, 0],
|
||||
rotate: p.rotate,
|
||||
scale: 1,
|
||||
}}
|
||||
transition={{ duration: 2.2, delay: p.delay, ease: "easeOut", times: [0, 0.35, 1] }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* reward card */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.7, y: 24, opacity: 0 }}
|
||||
animate={{ scale: 1, y: 0, opacity: 1 }}
|
||||
exit={{ scale: 0.9, y: -12, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 22 }}
|
||||
className="relative flex items-center gap-3 rounded-2xl border bg-background/95 px-5 py-4 shadow-2xl backdrop-blur"
|
||||
>
|
||||
<motion.div
|
||||
className="flex size-10 items-center justify-center rounded-full bg-amber-500/15"
|
||||
animate={{ rotate: [0, -12, 12, 0] }}
|
||||
transition={{ duration: 0.9, delay: 0.15 }}
|
||||
>
|
||||
<Sparkles className="size-5 text-amber-500" />
|
||||
</motion.div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">
|
||||
{formatCreditsAsDollars(event.credits)} in credits earned!
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{event.title}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
90
apps/x/apps/renderer/src/components/invite-code-claim.tsx
Normal file
90
apps/x/apps/renderer/src/components/invite-code-claim.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { CheckCircle2, Loader2, Ticket } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCreditsState } from "@/hooks/use-credits-state"
|
||||
import { formatCreditsAsDollars } from "@x/shared/dist/credits.js"
|
||||
|
||||
/**
|
||||
* "Have an invite code?" entry: redeem another user's referral code — both
|
||||
* sides earn credits (the backend enforces one lifetime claim per account,
|
||||
* new accounts only). Self-gating: renders nothing unless the credit-rewards
|
||||
* feature is on, the user is eligible (signed-in, free tier), and this
|
||||
* account hasn't already redeemed a code. Shown in onboarding and in
|
||||
* settings → Account.
|
||||
*/
|
||||
export function InviteCodeClaim() {
|
||||
const state = useCreditsState()
|
||||
const [code, setCode] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [granted, setGranted] = useState<number | null>(null)
|
||||
|
||||
// referral state doubles as the claimed-by-me source; without it we can't
|
||||
// tell whether the entry still applies, so stay hidden
|
||||
if (!state || !state.enabled || !state.eligible || !state.referral) return null
|
||||
if (state.referral.claimedByMe && granted === null) return null
|
||||
|
||||
const submit = async () => {
|
||||
if (!code.trim() || submitting) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke("referral:claim", { code })
|
||||
if (result.ok) {
|
||||
setGranted(result.creditsGranted)
|
||||
} else {
|
||||
setError(result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to claim invite code:", err)
|
||||
setError("Could not apply the invite code. Please try again.")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (granted !== null) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-emerald-500/5 px-4 py-3">
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" />
|
||||
<p className="text-sm">
|
||||
Invite code applied — {formatCreditsAsDollars(granted)} in credits added to your account.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border px-4 py-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Ticket className="size-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Have an invite code?</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
setCode(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit()
|
||||
}}
|
||||
placeholder="ABC-DEF-GHJ"
|
||||
className="h-8 font-mono text-sm uppercase"
|
||||
maxLength={16}
|
||||
/>
|
||||
<Button size="sm" onClick={submit} disabled={!code.trim() || submitting}>
|
||||
{submitting ? <Loader2 className="size-4 animate-spin" /> : "Apply"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Enter a friend's code and you both earn {formatCreditsAsDollars(state.referral.refereeCredits)} in credits.
|
||||
</p>
|
||||
{error && <p className="mt-1 text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
import { InviteCodeClaim } from "@/components/invite-code-claim"
|
||||
import { setGoogleCredentials } from "@/lib/google-credentials-store"
|
||||
import { toast } from "sonner"
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
|
|
@ -1414,6 +1415,11 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* invite-code redemption (self-gating: signed-in, free-tier, unclaimed) */}
|
||||
<div className="mt-6 w-full max-w-sm text-left">
|
||||
<InviteCodeClaim />
|
||||
</div>
|
||||
|
||||
<Button onClick={handleComplete} size="lg" className="mt-8 w-full max-w-xs">
|
||||
Start Using Rowboat
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from "@/components/ui/alert-dialog"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { CreditRewards } from "@/components/settings/credit-rewards"
|
||||
import { toast } from "sonner"
|
||||
import { getBillingPlanData, type BillingUsageBucket } from "@x/shared/dist/billing.js"
|
||||
|
||||
|
|
@ -56,7 +57,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
const [disconnecting, setDisconnecting] = useState(false)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected)
|
||||
const { billing, isLoading: billingLoading, refresh: refreshBilling } = useBilling(isRowboatConnected)
|
||||
const currentPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
|
||||
const hasPaidSubscription = currentPlan?.category === 'starter' || currentPlan?.category === 'pro'
|
||||
|
||||
|
|
@ -100,6 +101,14 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
return cleanup
|
||||
}, [])
|
||||
|
||||
// A confirmed reward grant changes the bonus-credit balance; refetch so the
|
||||
// Earn-credits section shows the updated number while the dialog is open.
|
||||
useEffect(() => {
|
||||
return window.ipc.on('credits:didActivate', () => {
|
||||
refreshBilling()
|
||||
})
|
||||
}, [refreshBilling])
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
try {
|
||||
setConnecting(true)
|
||||
|
|
@ -229,6 +238,11 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
|
||||
<Separator />
|
||||
|
||||
{/* Earn Credits Section */}
|
||||
<CreditRewards store={billing?.store ?? null} />
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Payment Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
166
apps/x/apps/renderer/src/components/settings/credit-rewards.tsx
Normal file
166
apps/x/apps/renderer/src/components/settings/credit-rewards.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Check, Copy, Gift, UserPlus } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CREDIT_ACTIVITY_ICONS, useCreditsState } from "@/hooks/use-credits-state"
|
||||
import { InviteCodeClaim } from "@/components/invite-code-claim"
|
||||
import { formatCreditsAsDollars } from "@x/shared/dist/credits.js"
|
||||
import type { BillingStoreBucket } from "@x/shared/dist/billing.js"
|
||||
|
||||
interface CreditRewardsProps {
|
||||
// bonus-credit balance from billing info; null while billing is loading
|
||||
store: BillingStoreBucket | null
|
||||
}
|
||||
|
||||
/**
|
||||
* "Earn credits" settings section: the first-time actions that grant bonus
|
||||
* credits, which are done, and the current bonus balance. Claimed state
|
||||
* refreshes live when a `credits:didActivate` event arrives (the parent
|
||||
* refreshes the balance itself).
|
||||
*/
|
||||
export function CreditRewards({ store }: CreditRewardsProps) {
|
||||
const state = useCreditsState()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Hidden while loading, when the feature flag is off, when not eligible
|
||||
// (rewards are for signed-in free-tier users — not BYOK, not paid plans),
|
||||
// or when there is nothing to show (no reward catalog and no referral).
|
||||
if (!state || !state.enabled || !state.eligible) return null
|
||||
if (state.activities.length === 0 && !state.referral) return null
|
||||
|
||||
const referral = state.referral
|
||||
const inviteSlotsLeft = referral ? Math.max(0, referral.maxClaims - referral.claimsUsed) : 0
|
||||
// invites count as one list item, earned once every claim slot is used
|
||||
const totalCount = state.activities.length + (referral ? 1 : 0)
|
||||
const earnedCount =
|
||||
state.activities.filter((a) => a.claimed).length + (referral && inviteSlotsLeft === 0 ? 1 : 0)
|
||||
|
||||
const copyInviteCode = async () => {
|
||||
if (!referral) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(referral.code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch (error) {
|
||||
console.error("Failed to copy invite code:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gift className="size-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium">Earn credits</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Try these for the first time and we'll add bonus credits to your account.
|
||||
</p>
|
||||
<div className="rounded-lg border">
|
||||
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{earnedCount} of {totalCount} earned
|
||||
</p>
|
||||
{store && (
|
||||
<p className="text-xs font-medium tabular-nums">
|
||||
{formatCreditsAsDollars(store.availableCredits)} bonus credits available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{state.activities.map((activity) => {
|
||||
const Icon = CREDIT_ACTIVITY_ICONS[activity.code] ?? Gift
|
||||
return (
|
||||
<div key={activity.code} className="flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full",
|
||||
activity.claimed ? "bg-emerald-500/15" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
{activity.claimed ? (
|
||||
<Check className="size-4 text-emerald-600" />
|
||||
) : (
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn("text-sm font-medium", activity.claimed && "text-muted-foreground")}>
|
||||
{activity.title}
|
||||
</p>
|
||||
{activity.description && (
|
||||
<p className="text-xs text-muted-foreground">{activity.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium tabular-nums",
|
||||
activity.claimed
|
||||
? "bg-emerald-500/15 text-emerald-600"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{activity.claimed ? "Earned" : `+${formatCreditsAsDollars(activity.credits)}`}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{referral && (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full",
|
||||
inviteSlotsLeft === 0 ? "bg-emerald-500/15" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
{inviteSlotsLeft === 0 ? (
|
||||
<Check className="size-4 text-emerald-600" />
|
||||
) : (
|
||||
<UserPlus className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn("text-sm font-medium", inviteSlotsLeft === 0 && "text-muted-foreground")}>
|
||||
Invite friends
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{inviteSlotsLeft === 0
|
||||
? `All ${referral.maxClaims} invites used`
|
||||
: `You each get ${formatCreditsAsDollars(referral.referrerCredits)} when a friend signs up with your code · ${referral.claimsUsed} of ${referral.maxClaims} joined`}
|
||||
</p>
|
||||
{inviteSlotsLeft > 0 && (
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<code className="rounded-md bg-muted px-2 py-1 font-mono text-xs tracking-wider">
|
||||
{referral.code}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyInviteCode}
|
||||
className="flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="size-3 text-emerald-600" /> : <Copy className="size-3" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium tabular-nums",
|
||||
inviteSlotsLeft === 0
|
||||
? "bg-emerald-500/15 text-emerald-600"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{inviteSlotsLeft === 0
|
||||
? "Earned"
|
||||
: `+${formatCreditsAsDollars(referral.referrerCredits)} each`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<InviteCodeClaim />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ import { cn } from "@/lib/utils"
|
|||
import { getPinnedApps, onPinnedAppsChanged, unpinApp } from "@/lib/pinned-apps"
|
||||
import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
import { SidebarCreditRewards } from "@/components/sidebar-credit-rewards"
|
||||
import { MascotFaceIcon } from "@/components/talking-head"
|
||||
import { extractConferenceLink } from "@/lib/calendar-event"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
|
|
@ -1191,6 +1192,14 @@ export function SidebarContentPanel({
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SidebarContent>
|
||||
{/* First-time-action credit rewards (feature-flagged, signed-in only) */}
|
||||
<SidebarCreditRewards
|
||||
onOpenEmail={onOpenEmail}
|
||||
onOpenMeetings={onOpenMeetings}
|
||||
onOpenAgents={onOpenBgTasks}
|
||||
onOpenApps={onOpenApps}
|
||||
onConnectAccounts={() => setConnectionsSettingsOpen(true)}
|
||||
/>
|
||||
{/* Billing / upgrade CTA or Log in CTA */}
|
||||
{isRowboatConnected && billing ? (() => {
|
||||
const upgradeLabel = !billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage'
|
||||
|
|
|
|||
234
apps/x/apps/renderer/src/components/sidebar-credit-rewards.tsx
Normal file
234
apps/x/apps/renderer/src/components/sidebar-credit-rewards.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { Check, ChevronRight, Copy, Gift, UserPlus, X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { CREDIT_ACTIVITY_ICONS, useCreditsState } from "@/hooks/use-credits-state"
|
||||
import { formatCreditsAsDollars, type CreditActivityCode } from "@x/shared/dist/credits.js"
|
||||
|
||||
const DISMISSED_KEY = "rowboat.credit-rewards-card-dismissed"
|
||||
|
||||
interface SidebarCreditRewardsProps {
|
||||
onOpenEmail?: () => void
|
||||
onOpenMeetings?: () => void
|
||||
onOpenAgents?: () => void
|
||||
onOpenApps?: () => void
|
||||
onConnectAccounts: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent sidebar entry for first-time-action credit rewards: a compact
|
||||
* "Earn $X in credits" pill above the plan card that opens the checklist in
|
||||
* a popover. Rows navigate to where the action happens. Shown only when the
|
||||
* credit-rewards feature flag is on and the user is an eligible (signed-in,
|
||||
* free-tier) account; gone for good once every reward is earned or the user
|
||||
* dismisses it from the popover.
|
||||
*/
|
||||
export function SidebarCreditRewards({
|
||||
onOpenEmail,
|
||||
onOpenMeetings,
|
||||
onOpenAgents,
|
||||
onOpenApps,
|
||||
onConnectAccounts,
|
||||
}: SidebarCreditRewardsProps) {
|
||||
const state = useCreditsState()
|
||||
const [dismissed, setDismissed] = useState(() => localStorage.getItem(DISMISSED_KEY) === "1")
|
||||
const [open, setOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
localStorage.setItem(DISMISSED_KEY, "1")
|
||||
setDismissed(true)
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
if (dismissed || !state || !state.enabled || !state.eligible) return null
|
||||
|
||||
const referral = state.referral
|
||||
const inviteSlotsLeft = referral ? Math.max(0, referral.maxClaims - referral.claimsUsed) : 0
|
||||
const remaining = state.activities.filter((a) => !a.claimed)
|
||||
if (remaining.length === 0 && inviteSlotsLeft === 0) return null
|
||||
|
||||
// invites count as one checklist item, earned once every claim slot is used
|
||||
const totalCount = state.activities.length + (referral ? 1 : 0)
|
||||
const earnedCount =
|
||||
state.activities.length - remaining.length + (referral && inviteSlotsLeft === 0 ? 1 : 0)
|
||||
const remainingTotal =
|
||||
remaining.reduce((sum, a) => sum + a.credits, 0) +
|
||||
(referral ? inviteSlotsLeft * referral.referrerCredits : 0)
|
||||
|
||||
const copyInviteCode = async () => {
|
||||
if (!referral) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(referral.code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch (error) {
|
||||
console.error("Failed to copy invite code:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const actionFor = (code: CreditActivityCode): (() => void) | undefined => {
|
||||
switch (code) {
|
||||
case "first_gmail_connected": return onConnectAccounts
|
||||
case "first_email_sent": return onOpenEmail
|
||||
case "first_meeting_note": return onOpenMeetings
|
||||
case "first_bg_agent": return onOpenAgents
|
||||
case "first_app_built": return onOpenApps
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/40"
|
||||
>
|
||||
<Gift className="size-4 shrink-0 text-amber-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-sidebar-foreground">
|
||||
Earn {formatCreditsAsDollars(remainingTotal)} in credits
|
||||
</span>
|
||||
<span className="block text-[10px] text-sidebar-foreground/60">
|
||||
{earnedCount} of {totalCount} earned
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="size-3 shrink-0 text-sidebar-foreground/50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start" sideOffset={10} className="w-80 p-0">
|
||||
<PopoverArrow className="fill-popover" />
|
||||
<div className="flex items-start justify-between gap-2 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-amber-500/15">
|
||||
<Gift className="size-4 text-amber-500" />
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">Earn {formatCreditsAsDollars(remainingTotal)} in credits</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Try these firsts and we'll credit your account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="rounded-md p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Dismiss earn-credits checklist"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
{state.activities.map((activity) => {
|
||||
const Icon = CREDIT_ACTIVITY_ICONS[activity.code] ?? Gift
|
||||
const action = actionFor(activity.code)
|
||||
return (
|
||||
<button
|
||||
key={activity.code}
|
||||
type="button"
|
||||
disabled={activity.claimed || !action}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
action?.()
|
||||
}}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left",
|
||||
activity.claimed ? "opacity-60" : "transition-colors hover:bg-accent/60",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-6 shrink-0 items-center justify-center rounded-full",
|
||||
activity.claimed ? "bg-emerald-500/15" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
{activity.claimed ? (
|
||||
<Check className="size-3.5 text-emerald-600" />
|
||||
) : (
|
||||
<Icon className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-[13px]",
|
||||
activity.claimed ? "text-muted-foreground line-through" : "font-medium",
|
||||
)}
|
||||
>
|
||||
{activity.title}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-1.5 py-px text-[11px] font-medium tabular-nums",
|
||||
activity.claimed ? "text-muted-foreground" : "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{activity.claimed ? "Earned" : `+${formatCreditsAsDollars(activity.credits)}`}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{referral && (
|
||||
<div className="border-t p-2">
|
||||
<div className="flex items-center gap-2.5 px-2 py-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-6 shrink-0 items-center justify-center rounded-full",
|
||||
inviteSlotsLeft === 0 ? "bg-emerald-500/15" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
{inviteSlotsLeft === 0 ? (
|
||||
<Check className="size-3.5 text-emerald-600" />
|
||||
) : (
|
||||
<UserPlus className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[13px] font-medium">Invite friends</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
{referral.claimsUsed} of {referral.maxClaims} joined
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-1.5 py-px text-[11px] font-medium tabular-nums",
|
||||
inviteSlotsLeft === 0 ? "text-muted-foreground" : "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{inviteSlotsLeft === 0
|
||||
? "Earned"
|
||||
: `+${formatCreditsAsDollars(referral.referrerCredits)} each`}
|
||||
</span>
|
||||
</div>
|
||||
{inviteSlotsLeft > 0 && (
|
||||
<div className="mx-2 mb-1 flex items-center gap-2">
|
||||
<code className="flex-1 truncate rounded-md bg-muted px-2 py-1 text-center font-mono text-xs tracking-wider">
|
||||
{referral.code}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyInviteCode}
|
||||
className="flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="size-3 text-emerald-600" /> : <Copy className="size-3" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{inviteSlotsLeft > 0 && (
|
||||
<p className="mx-2 mb-1 text-[11px] leading-snug text-muted-foreground">
|
||||
Share your code — you each get {formatCreditsAsDollars(referral.referrerCredits)} when a
|
||||
friend signs up and enters it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
apps/x/apps/renderer/src/hooks/use-credits-state.ts
Normal file
49
apps/x/apps/renderer/src/hooks/use-credits-state.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { ElementType } from 'react'
|
||||
import { AtSign, Bot, LayoutGrid, NotebookPen, Send } from 'lucide-react'
|
||||
import type { CreditActivityCode, CreditsState } from '@x/shared/dist/credits.js'
|
||||
|
||||
export const CREDIT_ACTIVITY_ICONS: Record<CreditActivityCode, ElementType> = {
|
||||
first_gmail_connected: AtSign,
|
||||
first_email_sent: Send,
|
||||
first_meeting_note: NotebookPen,
|
||||
first_bg_agent: Bot,
|
||||
first_app_built: LayoutGrid,
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit-rewards state shared by every rewards surface (sidebar pill,
|
||||
* settings section). Fetches once on mount and refreshes when a grant is
|
||||
* confirmed (`credits:didActivate`) or when a connect event could change
|
||||
* eligibility/claimed state — rowboat sign-in/out and Google connects; other
|
||||
* providers can't affect rewards, so their events are ignored.
|
||||
*/
|
||||
export function useCreditsState() {
|
||||
const [state, setState] = useState<CreditsState | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setState(await window.ipc.invoke('credits:getState', null))
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch credit rewards state:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
const offActivated = window.ipc.on('credits:didActivate', () => {
|
||||
refresh()
|
||||
})
|
||||
const offOAuth = window.ipc.on('oauth:didConnect', (event) => {
|
||||
if (event.provider === 'rowboat' || event.provider === 'google') {
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
offActivated()
|
||||
offOAuth()
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
return state
|
||||
}
|
||||
|
|
@ -91,6 +91,24 @@ export function reset(): void {
|
|||
identifiedUserId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a PostHog feature flag for the current identity (rowboat user id
|
||||
* once identified, installation id before that). `defaultValue` is returned
|
||||
* when the flag can't be definitively evaluated — analytics disabled, flags
|
||||
* API unreachable, or the flag doesn't exist in the PostHog project.
|
||||
*/
|
||||
export async function isFeatureEnabled(key: string, defaultValue = false): Promise<boolean> {
|
||||
const ph = getClient();
|
||||
if (!ph) return defaultValue;
|
||||
try {
|
||||
const result = await ph.isFeatureEnabled(key, activeDistinctId());
|
||||
return result === undefined ? defaultValue : result;
|
||||
} catch (err) {
|
||||
console.error(`[Analytics] feature flag ${key} check failed:`, err);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export async function shutdown(): Promise<void> {
|
||||
if (!client) return;
|
||||
try {
|
||||
|
|
|
|||
18
apps/x/packages/core/src/auth/jwt.ts
Normal file
18
apps/x/packages/core/src/auth/jwt.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Decode a JWT's payload segment without verifying the signature — for
|
||||
* reading claims out of tokens we already trust (or only use as local cache
|
||||
* keys), never for authentication decisions.
|
||||
*/
|
||||
export function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
|
||||
const json = Buffer.from(padded + pad, 'base64').toString('utf-8');
|
||||
const parsed = JSON.parse(json);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,10 @@ export async function getBillingInfo(): Promise<BillingInfo> {
|
|||
availableCredits: number;
|
||||
usageDay: string;
|
||||
};
|
||||
// credit-store bucket; absent on API deployments that predate grants
|
||||
store?: {
|
||||
availableCredits: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -45,5 +49,8 @@ export async function getBillingInfo(): Promise<BillingInfo> {
|
|||
catalog: config.billing,
|
||||
monthly: body.billing.usage.monthly,
|
||||
daily: body.billing.usage.daily,
|
||||
store: {
|
||||
availableCredits: body.billing.usage.store?.availableCredits ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
284
apps/x/packages/core/src/billing/credits.test.ts
Normal file
284
apps/x/packages/core/src/billing/credits.test.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// A JWT whose payload decodes to { sub: "user-1" } (signature irrelevant —
|
||||
// the service only reads the sub claim as a local cache key).
|
||||
function fakeJwt(sub: string): string {
|
||||
const payload = Buffer.from(JSON.stringify({ sub }), "utf-8").toString("base64url");
|
||||
return `header.${payload}.sig`;
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
const CATALOG = {
|
||||
plans: [
|
||||
{ id: "free-1", category: "free", displayName: "Free", monthlyCredits: 0, dailyCredits: 0, monthlyPriceCents: null },
|
||||
{ id: "pro-1", category: "pro", displayName: "Pro", monthlyCredits: 0, dailyCredits: 0, monthlyPriceCents: 2000 },
|
||||
],
|
||||
};
|
||||
|
||||
// the activation catalog as served by GET /v1/config — display metadata is
|
||||
// backend-owned; the app only knows the codes
|
||||
const ACTIVATIONS = [
|
||||
{ code: "first_gmail_connected", displayName: "Connected Gmail", description: "Link your Google account", credits: 100 },
|
||||
{ code: "first_email_sent", displayName: "First email sent", credits: 100 },
|
||||
{ code: "first_bg_agent", displayName: "First background agent", credits: 100 },
|
||||
{ code: "first_app_built", displayName: "First app built", credits: 100 },
|
||||
{ code: "some_future_code", displayName: "Unknown to this app version", credits: 100 },
|
||||
];
|
||||
|
||||
function mockBilling(planId: string | null) {
|
||||
vi.doMock("./billing.js", () => ({
|
||||
getBillingInfo: vi.fn(async () => ({ subscriptionPlanId: planId, catalog: CATALOG })),
|
||||
}));
|
||||
}
|
||||
|
||||
// pass null to omit the creditActivations field entirely (pre-catalog API)
|
||||
function mockConfig(creditActivations: unknown[] | null = ACTIVATIONS) {
|
||||
vi.doMock("../config/rowboat.js", () => ({
|
||||
getRowboatConfig: vi.fn(async () => ({
|
||||
appUrl: "https://app.example",
|
||||
websocketApiUrl: "",
|
||||
supabaseUrl: "",
|
||||
billing: CATALOG,
|
||||
...(creditActivations ? { creditActivations } : {}),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rowboat-credits-test-"));
|
||||
process.env.ROWBOAT_WORKDIR = tmpDir;
|
||||
// enable the feature flag via the dev override (no PostHog in tests)
|
||||
process.env.ROWBOAT_CREDITS = "1";
|
||||
vi.resetModules();
|
||||
vi.doMock("../account/account.js", () => ({
|
||||
isSignedIn: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock("../auth/tokens.js", () => ({
|
||||
getAccessToken: vi.fn(async () => fakeJwt("user-1")),
|
||||
}));
|
||||
mockBilling("free-1");
|
||||
mockConfig();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ROWBOAT_WORKDIR;
|
||||
delete process.env.ROWBOAT_CREDITS;
|
||||
vi.doUnmock("../account/account.js");
|
||||
vi.doUnmock("../auth/tokens.js");
|
||||
vi.doUnmock("./billing.js");
|
||||
vi.doUnmock("../config/rowboat.js");
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function loadCredits() {
|
||||
return import("./credits.js");
|
||||
}
|
||||
|
||||
function grantResponse(amount: number) {
|
||||
return new Response(JSON.stringify({ grant: { amount } }), { status: 200 });
|
||||
}
|
||||
|
||||
describe("maybeActivateCredit", () => {
|
||||
it("activates once, notifies subscribers, and skips repeat attempts locally", async () => {
|
||||
const credits = await loadCredits();
|
||||
const seen: unknown[] = [];
|
||||
credits.subscribeCreditActivations((event) => seen.push(event));
|
||||
fetchMock.mockResolvedValueOnce(grantResponse(100));
|
||||
|
||||
const first = await credits.maybeActivateCredit("first_email_sent");
|
||||
// display name comes from the API catalog, not app code
|
||||
expect(first).toMatchObject({ code: "first_email_sent", title: "First email sent", credits: 100 });
|
||||
expect(seen).toHaveLength(1);
|
||||
|
||||
// second attempt short-circuits on the local claim — no network call
|
||||
const second = await credits.maybeActivateCredit("first_email_sent");
|
||||
expect(second).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.activities.find((a) => a.code === "first_email_sent")?.claimed).toBe(true);
|
||||
expect(state.activities.find((a) => a.code === "first_bg_agent")?.claimed).toBe(false);
|
||||
// catalog codes this app version doesn't know are dropped
|
||||
expect(state.activities.map((a) => a.code)).not.toContain("some_future_code");
|
||||
});
|
||||
|
||||
it("shows no activities when the API serves no reward catalog", async () => {
|
||||
mockConfig(null);
|
||||
const credits = await loadCredits();
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.eligible).toBe(true);
|
||||
expect(state.activities).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats 409 as already claimed without notifying", async () => {
|
||||
const credits = await loadCredits();
|
||||
const seen: unknown[] = [];
|
||||
credits.subscribeCreditActivations((event) => seen.push(event));
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Already activated" }), { status: 409 }));
|
||||
|
||||
expect(await credits.maybeActivateCredit("first_gmail_connected")).toBeNull();
|
||||
expect(seen).toHaveLength(0);
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.activities.find((a) => a.code === "first_gmail_connected")?.claimed).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the code unclaimed on 404/network failure so the next action retries", async () => {
|
||||
const credits = await loadCredits();
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Unknown activation code" }), { status: 404 }));
|
||||
expect(await credits.maybeActivateCredit("first_app_built")).toBeNull();
|
||||
|
||||
fetchMock.mockRejectedValueOnce(new Error("offline"));
|
||||
expect(await credits.maybeActivateCredit("first_app_built")).toBeNull();
|
||||
|
||||
// both failures left it retryable — a later attempt succeeds
|
||||
fetchMock.mockResolvedValueOnce(grantResponse(100));
|
||||
expect(await credits.maybeActivateCredit("first_app_built")).toMatchObject({ credits: 100 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("is enabled by default when no flag source is available", async () => {
|
||||
// no env override, no PostHog client in tests -> defaults on
|
||||
delete process.env.ROWBOAT_CREDITS;
|
||||
const credits = await loadCredits();
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing when the feature flag is off", async () => {
|
||||
process.env.ROWBOAT_CREDITS = "0";
|
||||
const credits = await loadCredits();
|
||||
expect(await credits.maybeActivateCredit("first_email_sent")).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing for paid plans — free tier only", async () => {
|
||||
mockBilling("pro-1");
|
||||
const credits = await loadCredits();
|
||||
expect(await credits.maybeActivateCredit("first_email_sent")).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.eligible).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing when signed out", async () => {
|
||||
vi.doMock("../account/account.js", () => ({
|
||||
isSignedIn: vi.fn(async () => false),
|
||||
}));
|
||||
const credits = await loadCredits();
|
||||
expect(await credits.maybeActivateCredit("first_email_sent")).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.eligible).toBe(false);
|
||||
expect(state.activities.every((a) => !a.claimed)).toBe(true);
|
||||
});
|
||||
|
||||
it("scopes claims to the signed-in user", async () => {
|
||||
const credits = await loadCredits();
|
||||
fetchMock.mockResolvedValueOnce(grantResponse(100));
|
||||
await credits.maybeActivateCredit("first_email_sent");
|
||||
|
||||
// switch accounts: same install, different sub
|
||||
vi.doMock("../auth/tokens.js", () => ({
|
||||
getAccessToken: vi.fn(async () => fakeJwt("user-2")),
|
||||
}));
|
||||
vi.resetModules();
|
||||
const creditsUser2 = await loadCredits();
|
||||
const state = await creditsUser2.getCreditsState();
|
||||
expect(state.activities.find((a) => a.code === "first_email_sent")?.claimed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const REFERRAL_STATUS = {
|
||||
code: "ABC-DEF-GHJ",
|
||||
claimsUsed: 1,
|
||||
maxClaims: 3,
|
||||
referrerCredits: 500,
|
||||
refereeCredits: 500,
|
||||
};
|
||||
|
||||
// route fetches by URL: GET /v1/referral -> status, POST /v1/referral/claims -> claim
|
||||
function mockReferralApi(claim: () => Response, status: () => Response = () => new Response(JSON.stringify(REFERRAL_STATUS), { status: 200 })) {
|
||||
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
if (String(url).endsWith("/v1/referral")) return status();
|
||||
if (String(url).endsWith("/v1/referral/claims") && init?.method === "POST") return claim();
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("referrals", () => {
|
||||
it("includes the referral status in state", async () => {
|
||||
mockReferralApi(() => new Response(null, { status: 500 }));
|
||||
const credits = await loadCredits();
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.referral).toEqual({ ...REFERRAL_STATUS, claimedByMe: false });
|
||||
});
|
||||
|
||||
it("omits referral when the status fetch fails", async () => {
|
||||
mockReferralApi(
|
||||
() => new Response(null, { status: 500 }),
|
||||
() => new Response(JSON.stringify({ error: "boom" }), { status: 500 }),
|
||||
);
|
||||
const credits = await loadCredits();
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.eligible).toBe(true);
|
||||
expect(state.referral).toBeUndefined();
|
||||
});
|
||||
|
||||
it("claims an invite code once, notifies, and marks the account", async () => {
|
||||
mockReferralApi(() => new Response(JSON.stringify({ creditsGranted: 500 }), { status: 200 }));
|
||||
const credits = await loadCredits();
|
||||
const seen: unknown[] = [];
|
||||
credits.subscribeCreditActivations((event) => seen.push(event));
|
||||
|
||||
const result = await credits.claimReferralCode(" abc-def-ghj ");
|
||||
expect(result).toEqual({ ok: true, creditsGranted: 500 });
|
||||
expect(seen).toEqual([{ code: "referral_claimed", title: "Invite code applied", credits: 500 }]);
|
||||
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.referral?.claimedByMe).toBe(true);
|
||||
|
||||
// a second attempt is refused locally, without a network call
|
||||
const fetchCalls = fetchMock.mock.calls.length;
|
||||
const second = await credits.claimReferralCode("XYZ-XYZ-XYZ");
|
||||
expect(second.ok).toBe(false);
|
||||
expect(fetchMock.mock.calls.length).toBe(fetchCalls);
|
||||
});
|
||||
|
||||
it("surfaces backend claim errors and stays retryable", async () => {
|
||||
mockReferralApi(() => new Response(JSON.stringify({ error: "Unknown referral code" }), { status: 404 }));
|
||||
const credits = await loadCredits();
|
||||
|
||||
const bad = await credits.claimReferralCode("BAD-BAD-BAD");
|
||||
expect(bad).toEqual({ ok: false, message: "Unknown referral code" });
|
||||
|
||||
// rejected claims don't mark the account — a valid code still works
|
||||
mockReferralApi(() => new Response(JSON.stringify({ creditsGranted: 500 }), { status: 200 }));
|
||||
expect((await credits.claimReferralCode("ABC-DEF-GHJ")).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("marks the account claimed on a definitive already-claimed answer", async () => {
|
||||
mockReferralApi(() =>
|
||||
new Response(JSON.stringify({ error: "This account has already claimed a referral code" }), { status: 409 }),
|
||||
);
|
||||
const credits = await loadCredits();
|
||||
|
||||
const result = await credits.claimReferralCode("ABC-DEF-GHJ");
|
||||
expect(result.ok).toBe(false);
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.referral?.claimedByMe).toBe(true);
|
||||
});
|
||||
});
|
||||
380
apps/x/packages/core/src/billing/credits.ts
Normal file
380
apps/x/packages/core/src/billing/credits.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { getAccessToken } from '../auth/tokens.js';
|
||||
import { decodeJwtPayload } from '../auth/jwt.js';
|
||||
import { isSignedIn } from '../account/account.js';
|
||||
import { isFeatureEnabled } from '../analytics/posthog.js';
|
||||
import { API_URL } from '../config/env.js';
|
||||
import { getBillingInfo } from './billing.js';
|
||||
import { getRowboatConfig } from '../config/rowboat.js';
|
||||
import { getBillingPlanData } from '@x/shared/dist/billing.js';
|
||||
import {
|
||||
CreditActivityCodeSchema,
|
||||
type CreditActivationCatalogEntry,
|
||||
type CreditActivityCode,
|
||||
type CreditsState,
|
||||
type ReferralClaimResult,
|
||||
type ReferralState,
|
||||
} from '@x/shared/dist/credits.js';
|
||||
|
||||
// The reward catalog (names, descriptions, amounts) is owned by the backend
|
||||
// and served via GET /v1/config `creditActivations` — the app hardcodes only
|
||||
// the activity codes it knows how to trigger. Entries with codes this app
|
||||
// version doesn't recognize are dropped (nothing here can fire them).
|
||||
type KnownCatalogEntry = CreditActivationCatalogEntry & { code: CreditActivityCode };
|
||||
|
||||
async function getActivityCatalog(): Promise<KnownCatalogEntry[]> {
|
||||
const config = await getRowboatConfig();
|
||||
return (config.creditActivations ?? []).filter(
|
||||
(entry): entry is KnownCatalogEntry => CreditActivityCodeSchema.safeParse(entry.code).success,
|
||||
);
|
||||
}
|
||||
|
||||
const CLAIMED_FILE = path.join(WorkDir, 'config', 'credit_activations.json');
|
||||
|
||||
const CREDITS_FLAG_KEY = 'credit-rewards';
|
||||
const FLAG_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
let flagCache: { value: boolean; fetchedAt: number } | null = null;
|
||||
|
||||
/**
|
||||
* Whether the credit-rewards feature is on for this user. Defaults ON; the
|
||||
* PostHog `credit-rewards` flag is a remote kill switch / rollout control —
|
||||
* create it and set release conditions to turn the feature off (or ramp it)
|
||||
* for non-matching users. Evaluated against the identified rowboat user and
|
||||
* cached briefly so reward triggers don't add a network hop. ROWBOAT_CREDITS
|
||||
* env var (1/0) overrides everything for development.
|
||||
*/
|
||||
export async function isCreditsEnabled(): Promise<boolean> {
|
||||
const override = process.env.ROWBOAT_CREDITS;
|
||||
if (override === '1' || override === 'true') return true;
|
||||
if (override === '0' || override === 'false') return false;
|
||||
|
||||
if (flagCache && Date.now() - flagCache.fetchedAt < FLAG_CACHE_TTL_MS) {
|
||||
return flagCache.value;
|
||||
}
|
||||
const value = await isFeatureEnabled(CREDITS_FLAG_KEY, true);
|
||||
flagCache = { value, fetchedAt: Date.now() };
|
||||
return value;
|
||||
}
|
||||
|
||||
const PLAN_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
let planCache: { userId: string; paid: boolean; fetchedAt: number } | null = null;
|
||||
|
||||
// Rewards target free-tier users; paid (starter/pro, including trials of
|
||||
// those plans) neither see the UI nor receive grants. Cached briefly since
|
||||
// UI refreshes can arrive in bursts and /v1/me isn't free; keyed by user so
|
||||
// an account switch on the same install can't reuse the previous account's
|
||||
// answer. Throws on network failure — callers treat that as "not eligible
|
||||
// right now" and retry later.
|
||||
async function hasPaidPlan(userId: string): Promise<boolean> {
|
||||
if (planCache && planCache.userId === userId && Date.now() - planCache.fetchedAt < PLAN_CACHE_TTL_MS) {
|
||||
return planCache.paid;
|
||||
}
|
||||
const billing = await getBillingInfo();
|
||||
const category = getBillingPlanData(billing.catalog, billing.subscriptionPlanId)?.category;
|
||||
const paid = category === 'starter' || category === 'pro';
|
||||
planCache = { userId, paid, fetchedAt: Date.now() };
|
||||
return paid;
|
||||
}
|
||||
|
||||
// Claimed-state cache, keyed by Rowboat user id so switching accounts on the
|
||||
// same install doesn't hide unclaimed rewards. This is only a cache: the
|
||||
// backend enforces once-per-customer via the grants table, and a lost file
|
||||
// merely means the next occurrence of the action re-attempts activation and
|
||||
// gets a 409 (which re-marks it claimed here). Besides activity codes, the
|
||||
// store holds the pseudo-code below marking that this account has redeemed
|
||||
// someone's invite code (the backend allows one lifetime claim per account).
|
||||
const REFERRAL_CLAIMED_KEY = 'referral_claimed';
|
||||
|
||||
interface ClaimedStore {
|
||||
[userId: string]: {
|
||||
[code: string]: { claimedAt: string };
|
||||
};
|
||||
}
|
||||
|
||||
function readClaimedStore(): ClaimedStore {
|
||||
try {
|
||||
if (fs.existsSync(CLAIMED_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(CLAIMED_FILE, 'utf-8')) as ClaimedStore;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Credits] Failed to read credit_activations.json:', err);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function markClaimed(userId: string, code: CreditActivityCode | typeof REFERRAL_CLAIMED_KEY): void {
|
||||
try {
|
||||
const store = readClaimedStore();
|
||||
store[userId] = store[userId] ?? {};
|
||||
if (store[userId][code]) return;
|
||||
store[userId][code] = { claimedAt: new Date().toISOString() };
|
||||
const dir = path.dirname(CLAIMED_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(CLAIMED_FILE, JSON.stringify(store, null, 2));
|
||||
} catch (err) {
|
||||
console.warn('[Credits] Failed to persist claimed state:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the Supabase user id (`sub` claim) from the Rowboat access token
|
||||
* without verification — we only use it as a local cache key.
|
||||
*/
|
||||
function userIdFromToken(accessToken: string): string | null {
|
||||
const sub = decodeJwtPayload(accessToken)?.sub;
|
||||
return typeof sub === 'string' ? sub : null;
|
||||
}
|
||||
|
||||
export interface CreditActivationSuccess {
|
||||
code: CreditActivityCode | typeof REFERRAL_CLAIMED_KEY;
|
||||
title: string;
|
||||
// granted amount as confirmed by the backend
|
||||
credits: number;
|
||||
}
|
||||
|
||||
type CreditActivationListener = (event: CreditActivationSuccess) => void;
|
||||
const activationListeners = new Set<CreditActivationListener>();
|
||||
|
||||
/**
|
||||
* Be notified whenever the backend confirms a new credit grant, regardless of
|
||||
* which call site triggered it. Main subscribes once and relays the event to
|
||||
* all renderer windows (`credits:didActivate`).
|
||||
*/
|
||||
export function subscribeCreditActivations(listener: CreditActivationListener): () => void {
|
||||
activationListeners.add(listener);
|
||||
return () => activationListeners.delete(listener);
|
||||
}
|
||||
|
||||
function notifyActivation(event: CreditActivationSuccess): void {
|
||||
for (const listener of activationListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (err) {
|
||||
console.warn('[Credits] Activation listener failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a first-time-action reward with the backend, at most once per user.
|
||||
*
|
||||
* Returns the granted amount when the backend confirms a new grant, and null
|
||||
* in every other case (not signed in, already claimed, unknown code, network
|
||||
* failure). Never throws — reward activation must not break the action that
|
||||
* triggered it. Failures other than "already claimed" leave the local state
|
||||
* untouched, so the next occurrence of the action retries; the backend's
|
||||
* uniqueness guarantee makes retries safe.
|
||||
*/
|
||||
export async function maybeActivateCredit(code: CreditActivityCode): Promise<CreditActivationSuccess | null> {
|
||||
try {
|
||||
if (!(await isSignedIn())) return null;
|
||||
const accessToken = await getAccessToken();
|
||||
const userId = userIdFromToken(accessToken);
|
||||
if (!userId) return null;
|
||||
|
||||
// decisive local short-circuit first: after the one-time claim, repeat
|
||||
// actions (every email send, agent create, …) must not pay the feature
|
||||
// flag or /v1/me lookups below
|
||||
if (readClaimedStore()[userId]?.[code]) return null;
|
||||
|
||||
if (!(await isCreditsEnabled())) return null;
|
||||
if (await hasPaidPlan(userId)) return null;
|
||||
|
||||
const response = await fetch(`${API_URL}/v1/billing/credit-activations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
if (response.status === 409) {
|
||||
// already granted on the backend (e.g. fresh install, second device)
|
||||
markClaimed(userId, code);
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
// 404 = code not in the backend catalog yet; anything else is transient.
|
||||
// Either way, don't mark claimed — retry on the next occurrence.
|
||||
console.warn(`[Credits] Activation of ${code} failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// validate the response shape BEFORE persisting the claim — a malformed
|
||||
// 200 must stay retryable (the retry then gets a 409 and reconciles)
|
||||
const body = await response.json() as { grant?: { amount?: number } };
|
||||
const amount = body?.grant?.amount;
|
||||
if (typeof amount !== 'number') {
|
||||
console.warn(`[Credits] Activation of ${code} returned an unexpected body`);
|
||||
return null;
|
||||
}
|
||||
markClaimed(userId, code);
|
||||
console.log(`[Credits] Activated ${code}: +${amount} credits`);
|
||||
// display name from the backend catalog; the code is a serviceable
|
||||
// fallback if the config fetch fails right now
|
||||
const title = await getActivityCatalog()
|
||||
.then((catalog) => catalog.find((entry) => entry.code === code)?.displayName)
|
||||
.catch(() => undefined);
|
||||
const success: CreditActivationSuccess = { code, title: title ?? code, credits: amount };
|
||||
notifyActivation(success);
|
||||
return success;
|
||||
} catch (err) {
|
||||
console.warn(`[Credits] Activation of ${code} errored:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const REFERRAL_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
type ReferralApiStatus = Omit<ReferralState, 'claimedByMe'>;
|
||||
|
||||
let referralCache: { userId: string; status: ReferralApiStatus; fetchedAt: number } | null = null;
|
||||
|
||||
/**
|
||||
* The user's own invite code and claim-slot usage (GET /v1/referral — the
|
||||
* backend creates the permanent code lazily on first fetch). Cached briefly
|
||||
* because state refreshes arrive in bursts; busted after a successful claim.
|
||||
* Returns null on any failure — the UI simply omits the invite row.
|
||||
*/
|
||||
async function fetchReferralStatus(accessToken: string, userId: string): Promise<ReferralApiStatus | null> {
|
||||
if (referralCache && referralCache.userId === userId && Date.now() - referralCache.fetchedAt < REFERRAL_CACHE_TTL_MS) {
|
||||
return referralCache.status;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/v1/referral`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn(`[Credits] Referral status fetch failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
const body = await response.json() as Partial<ReferralApiStatus>;
|
||||
const { code, claimsUsed, maxClaims, referrerCredits, refereeCredits } = body ?? {};
|
||||
if (
|
||||
typeof code !== 'string' ||
|
||||
typeof claimsUsed !== 'number' ||
|
||||
typeof maxClaims !== 'number' ||
|
||||
typeof referrerCredits !== 'number' ||
|
||||
typeof refereeCredits !== 'number'
|
||||
) {
|
||||
console.warn('[Credits] Referral status has an unexpected shape');
|
||||
return null;
|
||||
}
|
||||
const status: ReferralApiStatus = { code, claimsUsed, maxClaims, referrerCredits, refereeCredits };
|
||||
referralCache = { userId, status, fetchedAt: Date.now() };
|
||||
return status;
|
||||
} catch (err) {
|
||||
console.warn('[Credits] Referral status fetch errored:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem another user's invite code (POST /v1/referral/claims); the backend
|
||||
* grants both sides credits in one transaction and enforces all the rules
|
||||
* (one lifetime claim per account, new accounts only, per-code cap, no
|
||||
* self-claims). Failure messages are user-presentable — the backend's own
|
||||
* copy where it provided one.
|
||||
*/
|
||||
export async function claimReferralCode(rawCode: string): Promise<ReferralClaimResult> {
|
||||
const fallback: ReferralClaimResult = { ok: false, message: 'Could not apply the invite code. Please try again.' };
|
||||
try {
|
||||
const code = rawCode.trim();
|
||||
if (!code) return { ok: false, message: 'Enter an invite code.' };
|
||||
if (!(await isSignedIn())) return { ok: false, message: 'Sign in to Rowboat to use an invite code.' };
|
||||
const accessToken = await getAccessToken();
|
||||
const userId = userIdFromToken(accessToken);
|
||||
if (!userId) return { ok: false, message: 'Sign in to Rowboat to use an invite code.' };
|
||||
if (readClaimedStore()[userId]?.[REFERRAL_CLAIMED_KEY]) {
|
||||
return { ok: false, message: 'This account has already used an invite code.' };
|
||||
}
|
||||
if (!(await isCreditsEnabled())) return { ok: false, message: 'Invite codes are not available right now.' };
|
||||
|
||||
const response = await fetch(`${API_URL}/v1/referral/claims`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const body = await response.json().catch(() => null) as { creditsGranted?: number; error?: string } | null;
|
||||
|
||||
if (response.ok) {
|
||||
const amount = body?.creditsGranted;
|
||||
if (typeof amount !== 'number') {
|
||||
console.warn('[Credits] Referral claim returned an unexpected body');
|
||||
return fallback;
|
||||
}
|
||||
markClaimed(userId, REFERRAL_CLAIMED_KEY);
|
||||
// the inviter-side status (claimsUsed) is now stale for the invitee's
|
||||
// own code too — cheap to just refetch next time
|
||||
referralCache = null;
|
||||
console.log(`[Credits] Referral claim succeeded: +${amount} credits`);
|
||||
notifyActivation({ code: REFERRAL_CLAIMED_KEY, title: 'Invite code applied', credits: amount });
|
||||
return { ok: true, creditsGranted: amount };
|
||||
}
|
||||
|
||||
const message = typeof body?.error === 'string' ? body.error : undefined;
|
||||
// a definitive "already claimed" answer means the entry UI can hide for
|
||||
// good on this account (e.g. claimed earlier on another device)
|
||||
if (response.status === 409 && message?.toLowerCase().includes('already claimed')) {
|
||||
markClaimed(userId, REFERRAL_CLAIMED_KEY);
|
||||
}
|
||||
return { ok: false, message: message ?? fallback.message };
|
||||
} catch (err) {
|
||||
console.warn('[Credits] Referral claim errored:', err);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend reward catalog joined with the current user's claimed flags, for
|
||||
* the UI. Claimed flags come from the local cache (the /v1/me grants list
|
||||
* doesn't expose activation codes), so a fresh install shows an action as
|
||||
* unclaimed until it is performed again and the backend answers 409.
|
||||
*/
|
||||
export async function getCreditsState(): Promise<CreditsState> {
|
||||
const enabled = await isCreditsEnabled();
|
||||
let userId: string | null = null;
|
||||
let eligible = false;
|
||||
let catalog: KnownCatalogEntry[] = [];
|
||||
let referral: ReferralApiStatus | null = null;
|
||||
try {
|
||||
if (enabled && (await isSignedIn())) {
|
||||
const accessToken = await getAccessToken();
|
||||
userId = userIdFromToken(accessToken);
|
||||
// billing-fetch failures fall through to eligible=false — the UI
|
||||
// refreshes on the next credits/oauth event anyway
|
||||
eligible = userId != null && !(await hasPaidPlan(userId));
|
||||
if (eligible && userId) {
|
||||
catalog = await getActivityCatalog();
|
||||
referral = await fetchReferralStatus(accessToken, userId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// treat token/billing/config errors as not eligible right now
|
||||
eligible = false;
|
||||
}
|
||||
const claimed = userId ? readClaimedStore()[userId] ?? {} : {};
|
||||
return {
|
||||
enabled,
|
||||
eligible,
|
||||
// API order is preserved — the backend catalog decides display order too
|
||||
activities: catalog.map((entry) => ({
|
||||
code: entry.code,
|
||||
title: entry.displayName,
|
||||
...(entry.description !== undefined ? { description: entry.description } : {}),
|
||||
credits: entry.credits,
|
||||
claimed: !!claimed[entry.code],
|
||||
})),
|
||||
...(referral ? { referral: { ...referral, claimedByMe: !!claimed[REFERRAL_CLAIMED_KEY] } } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import path from 'path';
|
|||
import fs from 'fs/promises';
|
||||
import { CodeModeAgentStatus } from './types.js';
|
||||
import { isEngineProvisioned } from './acp/engine-provisioner.js';
|
||||
import { decodeJwtPayload } from '../auth/jwt.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
|
|
@ -40,20 +41,6 @@ export function commonInstallPaths(binary: string): string[] {
|
|||
].map(dir => path.join(dir, binary));
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
|
||||
const json = Buffer.from(padded + pad, 'base64').toString('utf-8');
|
||||
const parsed = JSON.parse(json);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Given the raw credentials JSON (from a file or the macOS Keychain), decide
|
||||
// whether it represents a usable signed-in state: a valid API key, an unexpired
|
||||
// access token, or a refresh token (which can mint a new access token).
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ export const backgroundTaskTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
...(input.model ? { model: input.model } : {}),
|
||||
...(input.provider ? { provider: input.provider } : {}),
|
||||
});
|
||||
const { maybeActivateCredit } = await import("../../../billing/credits.js");
|
||||
void maybeActivateCredit('first_bg_agent');
|
||||
return { success: true, slug: result.slug, ...(warning ? { warning } : {}) };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// Mirrors the backend's shared billing constant — credits are denominated so
|
||||
// that 100M credits == $1 of usage.
|
||||
export const CREDITS_PER_DOLLAR = 100_000_000;
|
||||
|
||||
export const BillingPlanCategorySchema = z.enum(['free', 'starter', 'pro']);
|
||||
export type BillingPlanCategory = z.infer<typeof BillingPlanCategorySchema>;
|
||||
|
||||
|
|
@ -29,6 +33,13 @@ export const BillingUsageBucketSchema = z.object({
|
|||
});
|
||||
export type BillingUsageBucket = z.infer<typeof BillingUsageBucketSchema>;
|
||||
|
||||
// Bonus/promotional credits granted outside the plan buckets (credit store).
|
||||
// Not sanctioned per period, so it carries a plain balance instead of a quota.
|
||||
export const BillingStoreBucketSchema = z.object({
|
||||
availableCredits: z.number(),
|
||||
});
|
||||
export type BillingStoreBucket = z.infer<typeof BillingStoreBucketSchema>;
|
||||
|
||||
export const BillingInfoSchema = z.object({
|
||||
userEmail: z.string().nullable(),
|
||||
userId: z.string().nullable(),
|
||||
|
|
@ -40,6 +51,7 @@ export const BillingInfoSchema = z.object({
|
|||
daily: BillingUsageBucketSchema.extend({
|
||||
usageDay: z.string(),
|
||||
}),
|
||||
store: BillingStoreBucketSchema,
|
||||
});
|
||||
export type BillingInfo = z.infer<typeof BillingInfoSchema>;
|
||||
|
||||
|
|
|
|||
97
apps/x/packages/shared/src/credits.ts
Normal file
97
apps/x/packages/shared/src/credits.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { z } from 'zod';
|
||||
import { CREDITS_PER_DOLLAR } from './billing.js';
|
||||
|
||||
// First-time-action credit rewards.
|
||||
//
|
||||
// Only the activity CODES are known to the app — they anchor the trigger call
|
||||
// sites (which action fires which code) and per-code UI like icons and
|
||||
// navigation. Everything else (display name, description, credit amount) is
|
||||
// the backend catalog's to define and comes from GET /v1/config
|
||||
// `creditActivations`; the app never hardcodes amounts or copy, so the
|
||||
// catalog can change without an app release. Codes present in the config but
|
||||
// unknown to this app version are ignored (nothing here can trigger them);
|
||||
// known codes absent from the config are treated as retired and hidden.
|
||||
export const CreditActivityCodeSchema = z.enum([
|
||||
'first_gmail_connected',
|
||||
'first_email_sent',
|
||||
'first_meeting_note',
|
||||
'first_bg_agent',
|
||||
'first_app_built',
|
||||
]);
|
||||
export type CreditActivityCode = z.infer<typeof CreditActivityCodeSchema>;
|
||||
|
||||
// One catalog entry as served by GET /v1/config — `code` is an open string
|
||||
// because the backend may serve codes this app version doesn't know yet.
|
||||
export const CreditActivationCatalogEntrySchema = z.object({
|
||||
code: z.string(),
|
||||
displayName: z.string(),
|
||||
description: z.string().optional(),
|
||||
credits: z.number(),
|
||||
});
|
||||
export type CreditActivationCatalogEntry = z.infer<typeof CreditActivationCatalogEntrySchema>;
|
||||
|
||||
export const CreditActivityStateSchema = z.object({
|
||||
code: CreditActivityCodeSchema,
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
credits: z.number(),
|
||||
claimed: z.boolean(),
|
||||
});
|
||||
export type CreditActivityState = z.infer<typeof CreditActivityStateSchema>;
|
||||
|
||||
// Referral ("invite friends") state, from GET /v1/referral. The code is the
|
||||
// caller's permanent invite code; the backend grants both sides credits per
|
||||
// successful claim, up to maxClaims claims of one code. `claimedByMe` is
|
||||
// whether THIS account has redeemed someone else's code (from the local
|
||||
// claimed cache — the backend rejects repeats either way).
|
||||
export const ReferralStateSchema = z.object({
|
||||
// canonical display form, e.g. ABC-DEF-GHJ
|
||||
code: z.string(),
|
||||
claimsUsed: z.number(),
|
||||
maxClaims: z.number(),
|
||||
// what the inviter earns per claim / what the invited person earns
|
||||
referrerCredits: z.number(),
|
||||
refereeCredits: z.number(),
|
||||
claimedByMe: z.boolean(),
|
||||
});
|
||||
export type ReferralState = z.infer<typeof ReferralStateSchema>;
|
||||
|
||||
// Result of redeeming an invite code (`referral:claim`). Failure messages are
|
||||
// already user-presentable (the backend's own copy where available).
|
||||
export const ReferralClaimResultSchema = z.union([
|
||||
z.object({ ok: z.literal(true), creditsGranted: z.number() }),
|
||||
z.object({ ok: z.literal(false), message: z.string() }),
|
||||
]);
|
||||
export type ReferralClaimResult = z.infer<typeof ReferralClaimResultSchema>;
|
||||
|
||||
export const CreditsStateSchema = z.object({
|
||||
// Rewards are feature-flagged (PostHog `credit-rewards`, ROWBOAT_CREDITS
|
||||
// env override in dev); when off, no UI shows and no activation fires.
|
||||
enabled: z.boolean(),
|
||||
// signed in to Rowboat AND on the free tier — rewards target free users,
|
||||
// so BYOK-only setups and paid plans (starter/pro) are excluded. All UI
|
||||
// surfaces gate on this.
|
||||
eligible: z.boolean(),
|
||||
activities: z.array(CreditActivityStateSchema),
|
||||
// absent when not eligible or the referral status fetch failed
|
||||
referral: ReferralStateSchema.optional(),
|
||||
});
|
||||
export type CreditsState = z.infer<typeof CreditsStateSchema>;
|
||||
|
||||
// Payload of the main → renderer `credits:didActivate` event, emitted after
|
||||
// the backend confirms a grant — either a first-time-action activation or a
|
||||
// redeemed invite code (`referral_claimed`).
|
||||
export const CreditActivatedEventSchema = z.object({
|
||||
code: z.union([CreditActivityCodeSchema, z.literal('referral_claimed')]),
|
||||
title: z.string(),
|
||||
// actual granted amount, from the backend response
|
||||
credits: z.number(),
|
||||
});
|
||||
export type CreditActivatedEvent = z.infer<typeof CreditActivatedEventSchema>;
|
||||
|
||||
/** Format a raw credit amount as a dollar string, e.g. 100_000_000 → "$1". */
|
||||
export function formatCreditsAsDollars(credits: number): string {
|
||||
const dollars = credits / CREDITS_PER_DOLLAR;
|
||||
const rounded = Math.round(dollars * 100) / 100;
|
||||
return Number.isInteger(rounded) ? `$${rounded}` : `$${rounded.toFixed(2)}`;
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ export * as frontmatter from './frontmatter.js';
|
|||
export * as bases from './bases.js';
|
||||
export * as browserControl from './browser-control.js';
|
||||
export * as billing from './billing.js';
|
||||
export * as credits from './credits.js';
|
||||
export * as notificationSettings from './notification-settings.js';
|
||||
export * as codeSessions from './code-sessions.js';
|
||||
export * as channels from './channels.js';
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { ZListToolkitsResponse } from './composio.js';
|
|||
import { AppSummarySchema, RegistryRecordSchema, RowboatAppManifestSchema } from './rowboat-app.js';
|
||||
import { BrowserStateSchema, DisplayMediaRequestSchema, HttpAuthRequestSchema } from './browser-control.js';
|
||||
import { BillingInfoSchema } from './billing.js';
|
||||
import { CreditActivatedEventSchema, CreditsStateSchema, ReferralClaimResultSchema } from './credits.js';
|
||||
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
||||
import { PermissionDecision, ApprovalPolicy, CodingAgent, type CodeRunFeedEvent } from './code-mode.js';
|
||||
import { NotificationSettingsSchema } from './notification-settings.js';
|
||||
|
|
@ -2426,6 +2427,23 @@ const ipcSchemas = {
|
|||
req: z.null(),
|
||||
res: BillingInfoSchema,
|
||||
},
|
||||
// First-time-action credit rewards (see shared/src/credits.ts)
|
||||
'credits:getState': {
|
||||
req: z.null(),
|
||||
res: CreditsStateSchema,
|
||||
},
|
||||
// Main → renderer: the backend confirmed a credit grant. All activation
|
||||
// triggers live in main/core (oauth success, gmail send, meeting summarize,
|
||||
// bg-task create, app create); the renderer only listens and celebrates.
|
||||
'credits:didActivate': {
|
||||
req: CreditActivatedEventSchema,
|
||||
res: z.null(),
|
||||
},
|
||||
// Redeem another user's invite (referral) code — both sides earn credits.
|
||||
'referral:claim': {
|
||||
req: z.object({ code: z.string() }),
|
||||
res: ReferralClaimResultSchema,
|
||||
},
|
||||
// Notification settings channels
|
||||
'notifications:getSettings': {
|
||||
req: z.null(),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import { z } from 'zod';
|
||||
import { BillingCatalogSchema } from './billing.js';
|
||||
import { CreditActivationCatalogEntrySchema } from './credits.js';
|
||||
|
||||
export const RowboatApiConfig = z.object({
|
||||
appUrl: z.string(),
|
||||
websocketApiUrl: z.string(),
|
||||
supabaseUrl: z.string(),
|
||||
billing: BillingCatalogSchema,
|
||||
// first-time-action reward catalog (non-archived entries); optional so the
|
||||
// app keeps working against API deployments that predate it — the rewards
|
||||
// UI just stays empty until the backend serves the catalog
|
||||
creditActivations: z.array(CreditActivationCatalogEntrySchema).optional(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue