mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 10:11:12 +02:00
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:
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user