feat: add Support settings with contribution tiers and toggleable goal

Add comprehensive support/donation system with user control:

**Settings System:**
- Add `showMonthlyGoal` toggle to AppearanceSettings
- Default enabled, controls goal visibility across UI
- Persisted to localStorage with settings migration

**Settings Page - Support Tab:**
- Change wording to "Fund grimoire development"
- Add 6 contribution tiers with icons:
  - 210 (Coffee), 2100 (Pizza), 21000 (Gift)
  - 42000 (Heart), 210000 (Star), 1M (Crown)
- Larger tier buttons displayed in 3-column grid
- Each button pre-selects amount in zap window
- Add "Show monthly goal" toggle with switch
- Blur goal section when disabled
- Use HeartCrack icon on tab when disabled
- Remove all "sats" suffixes from amounts
- Trophy icon for #1 contributor

**Zap Command Enhancement:**
- Add `-a, --amount <sats>` flag to pre-select amount
- Parse and validate amount parameter
- Pass defaultAmount to ZapWindow via props
- Update ZapWindow to use defaultAmount for initial selection

**Conditional UI Display:**
- Hide support section in user menu when disabled
- Hide goal progress in welcome page when disabled
- Controlled by showMonthlyGoal setting

All changes verified with tests passing and successful build.
This commit is contained in:
Claude
2026-01-21 22:59:30 +00:00
parent 4ec2dfef62
commit e079fa99ab
6 changed files with 127 additions and 38 deletions

View File

@@ -5,6 +5,7 @@ import { Progress } from "./ui/progress";
import { MONTHLY_GOAL_SATS } from "@/services/supporters";
import { useLiveQuery } from "dexie-react-hooks";
import db from "@/services/db";
import { useSettings } from "@/hooks/useSettings";
interface GrimoireWelcomeProps {
onLaunchCommand: () => void;
@@ -32,6 +33,8 @@ export function GrimoireWelcome({
onLaunchCommand,
onExecuteCommand,
}: GrimoireWelcomeProps) {
const { settings } = useSettings();
// Calculate monthly donations reactively from DB (last 30 days)
const monthlyDonations =
useLiveQuery(async () => {
@@ -135,7 +138,7 @@ export function GrimoireWelcome({
<div className="text-xs text-muted-foreground mt-0.5">
{description}
</div>
{showProgress && (
{showProgress && settings?.appearance?.showMonthlyGoal && (
<div className="mt-2 flex flex-col gap-1">
<Progress value={goalProgress} className="h-1" />
<div className="flex items-center justify-between text-[10px]">

View File

@@ -9,7 +9,18 @@ import {
import { Switch } from "./ui/switch";
import { useSettings } from "@/hooks/useSettings";
import { useTheme } from "@/lib/themes";
import { Palette, FileEdit, Heart, Trophy } from "lucide-react";
import {
Palette,
FileEdit,
Heart,
HeartCrack,
Trophy,
Coffee,
Pizza,
Gift,
Star,
Crown,
} from "lucide-react";
import { Button } from "./ui/button";
import { Progress } from "./ui/progress";
import { useLiveQuery } from "dexie-react-hooks";
@@ -61,14 +72,22 @@ export function SettingsViewer() {
return amount.toLocaleString();
}
// Contribution tiers
const contributionTiers = [210, 2100, 21000, 42000, 210000];
// Contribution tiers with icons
const contributionTiers = [
{ amount: 210, icon: Coffee },
{ amount: 2100, icon: Pizza },
{ amount: 21000, icon: Gift },
{ amount: 42000, icon: Heart },
{ amount: 210000, icon: Star },
{ amount: 1000000, icon: Crown },
];
function openSupportWindow() {
function openSupportWindow(amount: number) {
addWindow(
"zap",
{
recipientPubkey: GRIMOIRE_DONATE_PUBKEY,
defaultAmount: amount,
},
"Support Grimoire",
);
@@ -88,7 +107,11 @@ export function SettingsViewer() {
Post
</TabsTrigger>
<TabsTrigger value="support" className="gap-2">
<Heart className="h-4 w-4" />
{settings?.appearance?.showMonthlyGoal ? (
<Heart className="h-4 w-4" />
) : (
<HeartCrack className="h-4 w-4" />
)}
Support
</TabsTrigger>
</TabsList>
@@ -189,12 +212,36 @@ export function SettingsViewer() {
<div>
<h3 className="text-lg font-semibold mb-1">Support Grimoire</h3>
<p className="text-sm text-muted-foreground">
Help support development
Fund grimoire development
</p>
</div>
{/* Show Monthly Goal Toggle */}
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<label
htmlFor="show-monthly-goal"
className="text-base font-medium cursor-pointer"
>
Show monthly goal
</label>
<p className="text-xs text-muted-foreground">
Display donation progress in UI
</p>
</div>
<Switch
id="show-monthly-goal"
checked={settings?.appearance?.showMonthlyGoal ?? true}
onCheckedChange={(checked: boolean) =>
updateSetting("appearance", "showMonthlyGoal", checked)
}
/>
</div>
{/* Monthly Goal Progress */}
<div className="space-y-3">
<div
className={`space-y-3 ${!settings?.appearance?.showMonthlyGoal ? "blur-sm pointer-events-none" : ""}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Monthly Goal</span>
<span className="text-sm text-muted-foreground">
@@ -219,14 +266,18 @@ export function SettingsViewer() {
<div className="space-y-3">
<h4 className="text-sm font-medium">Contribute</h4>
<div className="grid grid-cols-3 gap-2">
{contributionTiers.map((amount) => (
{contributionTiers.map(({ amount, icon: Icon }) => (
<Button
key={amount}
variant="outline"
size="sm"
onClick={openSupportWindow}
size="default"
onClick={() => openSupportWindow(amount)}
className="flex-col h-auto py-3 gap-1"
>
{formatAmount(amount)}
<Icon className="h-5 w-5" />
<span className="text-sm font-semibold">
{formatAmount(amount)}
</span>
</Button>
))}
</div>

View File

@@ -70,6 +70,8 @@ export interface ZapWindowProps {
customTags?: string[][];
/** Relays where the zap receipt should be published */
relays?: string[];
/** Optional default amount in sats to pre-select */
defaultAmount?: number;
}
// Default preset amounts in sats
@@ -99,6 +101,7 @@ export function ZapWindow({
onClose,
customTags,
relays: propsRelays,
defaultAmount,
}: ZapWindowProps) {
// Load event if we have a pointer - supports both EventPointer and AddressPointer
const event = useNostrEvent(eventPointer || addressPointer);
@@ -133,7 +136,9 @@ export function ZapWindow({
// Cache LNURL data for recipient's Lightning address
const { data: lnurlData } = useLnurlCache(recipientProfile?.lud16);
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
const [selectedAmount, setSelectedAmount] = useState<number | null>(
defaultAmount || null,
);
const [customAmount, setCustomAmount] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [isPayingWithWallet, setIsPayingWithWallet] = useState(false);

View File

@@ -49,6 +49,7 @@ import {
GRIMOIRE_LIGHTNING_ADDRESS,
} from "@/lib/grimoire-members";
import { MONTHLY_GOAL_SATS } from "@/services/supporters";
import { useSettings } from "@/hooks/useSettings";
function UserAvatar({ pubkey }: { pubkey: string }) {
const profile = useProfile(pubkey);
@@ -83,6 +84,7 @@ export default function UserMenu() {
const account = use$(accounts.active$);
const { state, addWindow, disconnectNWC, toggleWalletBalancesBlur } =
useGrimoire();
const { settings } = useSettings();
const relays = state.activeAccount?.relays;
const blossomServers = state.activeAccount?.blossomServers;
const nwcConnection = state.nwcConnection;
@@ -494,29 +496,33 @@ export default function UserMenu() {
</DropdownMenuItem>
{/* Support Grimoire */}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-crosshair flex-col items-stretch p-2"
onClick={openDonate}
>
<div className="flex items-center gap-2 mb-3">
<Zap className="size-4 text-yellow-500" />
<span className="text-sm font-medium">Support Grimoire</span>
</div>
<Progress value={goalProgress} className="h-1.5 mb-1" />
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
<span className="text-foreground font-medium">
{formatSats(monthlyDonations)}
</span>
{" / "}
{formatSats(MONTHLY_GOAL_SATS)}
</span>
<span className="text-muted-foreground">
{goalProgress.toFixed(0)}%
</span>
</div>
</DropdownMenuItem>
{settings?.appearance?.showMonthlyGoal && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-crosshair flex-col items-stretch p-2"
onClick={openDonate}
>
<div className="flex items-center gap-2 mb-3">
<Zap className="size-4 text-yellow-500" />
<span className="text-sm font-medium">Support Grimoire</span>
</div>
<Progress value={goalProgress} className="h-1.5 mb-1" />
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
<span className="text-foreground font-medium">
{formatSats(monthlyDonations)}
</span>
{" / "}
{formatSats(MONTHLY_GOAL_SATS)}
</span>
<span className="text-muted-foreground">
{goalProgress.toFixed(0)}%
</span>
</div>
</DropdownMenuItem>
</>
)}
{/* Logout at bottom for logged in users */}
{account && (

View File

@@ -22,6 +22,8 @@ export interface ParsedZapCommand {
customTags?: string[][];
/** Relays where the zap receipt should be published */
relays?: string[];
/** Optional default amount in sats to pre-select */
defaultAmount?: number;
}
/**
@@ -33,6 +35,7 @@ export interface ParsedZapCommand {
* - `zap <profile> <event>` - Zap a specific person for a specific event
*
* Options:
* - `-a, --amount <sats>` - Pre-select amount in sats
* - `-T, --tag <type> <value> [relay]` - Add custom tag (can be repeated)
* - `-r, --relay <url>` - Add relay for zap receipt publication (can be repeated)
*
@@ -53,12 +56,29 @@ export async function parseZapCommand(
const positionalArgs: string[] = [];
const customTags: string[][] = [];
const relays: string[] = [];
let defaultAmount: number | undefined;
let i = 0;
while (i < args.length) {
const arg = args[i];
if (arg === "-T" || arg === "--tag") {
if (arg === "-a" || arg === "--amount") {
// Parse amount: -a <sats>
const amountStr = args[i + 1];
if (!amountStr) {
throw new Error("Amount option requires a value: -a <sats>");
}
const amount = parseInt(amountStr, 10);
if (isNaN(amount) || amount <= 0) {
throw new Error(
`Invalid amount: ${amountStr}. Must be a positive number.`,
);
}
defaultAmount = amount;
i += 2;
} else if (arg === "-T" || arg === "--tag") {
// Parse tag: -T <type> <value> [relay-hint]
// Minimum 2 values after -T (type and value), optional relay hint
const tagType = args[i + 1];
@@ -122,7 +142,7 @@ export async function parseZapCommand(
const firstArg = positionalArgs[0];
const secondArg = positionalArgs[1];
// Build result with optional custom tags and relays
// Build result with optional custom tags, relays, and amount
const buildResult = (
recipientPubkey: string,
pointer?: EventPointer | AddressPointer,
@@ -138,6 +158,7 @@ export async function parseZapCommand(
}
if (customTags.length > 0) result.customTags = customTags;
if (relays.length > 0) result.relays = relays;
if (defaultAmount !== undefined) result.defaultAmount = defaultAmount;
return result;
};

View File

@@ -37,6 +37,8 @@ export interface AppearanceSettings {
animationsEnabled: boolean;
/** Accent color (hue value 0-360) */
accentHue: number;
/** Show monthly donation goal in UI (user menu, welcome page, settings) */
showMonthlyGoal: boolean;
}
/**
@@ -153,6 +155,7 @@ const DEFAULT_APPEARANCE_SETTINGS: AppearanceSettings = {
fontSizeMultiplier: 1.0,
animationsEnabled: true,
accentHue: 280, // Purple
showMonthlyGoal: true,
};
const DEFAULT_RELAY_SETTINGS: RelaySettings = {