mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
* 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>
97 lines
4.2 KiB
TypeScript
97 lines
4.2 KiB
TypeScript
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)}`;
|
|
}
|