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:
arkml 2026-07-20 14:59:25 +05:30 committed by GitHub
parent 5ba7918bb7
commit 033cc6d351
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1550 additions and 16 deletions

View file

@ -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();
},

View file

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

View 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>
)
}

View 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&apos;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>
)
}

View file

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

View file

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

View 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&apos;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>
)
}

View file

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

View 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&apos;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>
)
}

View 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
}

View file

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

View 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;
}
}

View file

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

View 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);
});
});

View 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] } } : {}),
};
}

View file

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

View file

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

View file

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

View 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)}`;
}

View file

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

View file

@ -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(),

View file

@ -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(),
});