From 8056b02ad1046e41968c5224c081e49b41cb7cb7 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 30 Jul 2026 20:29:44 +0700 Subject: [PATCH] Crew band gig booking, hide treasury, CLINK on profiles, friendlier CLINK, remove desktop messages icon - Add "punk" + "rap/hip-hop" crew vibe presets - Hide Crew Treasury behind SHOW_CREW_TREASURY flag (detail page + form), code kept - Improve crew band section into a dedicated card with a "Book them for a gig" action that deep-links to a Concert event hosted by the crew - CreateEventPage: prefill event type + title from ?type= / ?title= params - CLINK (a): expose CLINK tip code on user profiles (EditProfileForm field + "Tip in Bitcoin" button on profile view, stored as custom kind 0 field) - CLINK (c): relabel "Pay via CLINK" -> "Tip in Bitcoin" with a "what's this?" tooltip explaining it in plain language - Remove standalone desktop Messages icon (kept in the More menu) Co-authored-by: shakespeare.diy --- src/components/ClinkPayButton.tsx | 67 ++++++++++++++++++------- src/components/CrewForm.tsx | 5 +- src/components/EditProfileForm.tsx | 78 +++++++++++++++++++++++++++-- src/components/Layout.tsx | 18 +------ src/components/ui/checkbox.tsx | 2 +- src/lib/types.ts | 10 ++++ src/pages/CreateEventPage.tsx | 17 ++++++- src/pages/CrewDetailPage.tsx | 80 +++++++++++++++++++----------- src/pages/NIP19Page.tsx | 29 ++++++++--- 9 files changed, 229 insertions(+), 77 deletions(-) diff --git a/src/components/ClinkPayButton.tsx b/src/components/ClinkPayButton.tsx index 86c348a..b6c8b39 100644 --- a/src/components/ClinkPayButton.tsx +++ b/src/components/ClinkPayButton.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Zap, Copy, Check, ExternalLink, ShieldCheck } from 'lucide-react'; +import { Zap, Copy, Check, ExternalLink, ShieldCheck, HelpCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -8,6 +8,12 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { useToast } from '@/hooks/useToast'; import { isValidNoffer } from '@/lib/types'; import { cn } from '@/lib/utils'; @@ -21,6 +27,11 @@ interface ClinkPayButtonProps { /** Button variant — defaults to a subtle outline so it stays secondary */ variant?: 'default' | 'outline' | 'secondary'; size?: 'default' | 'sm' | 'lg'; + /** + * Friendlier button label. Defaults to "Tip in Bitcoin" — CLINK is jargon, + * so we lead with what it does and explain the tech in the tooltip/dialog. + */ + label?: string; } /** @@ -38,6 +49,7 @@ export function ClinkPayButton({ className, variant = 'outline', size = 'default', + label = 'Tip in Bitcoin', }: ClinkPayButtonProps) { const [open, setOpen] = useState(false); const [copied, setCopied] = useState(false); @@ -90,31 +102,52 @@ export function ClinkPayButton({ return ( <> - +
+ + + + + + + +

Pay directly with Bitcoin over Nostr

+

+ CLINK is a self-custodial payment code — no app account, no middleman. + Scan it with a compatible Lightning wallet and the tip goes straight to + them. +

+
+
+
+
- Pay via CLINK + {label} {recipientName - ? `Send a self-custodial Lightning payment to ${recipientName}.` - : 'Send a self-custodial Lightning payment over Nostr.'} + ? `Send a self-custodial Bitcoin (Lightning) payment to ${recipientName} — no middleman.` + : 'Send a self-custodial Bitcoin (Lightning) payment over Nostr — no middleman.'} diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index a5f9469..4ed44b3 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -26,6 +26,7 @@ import { CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, CREW_LOOKING_FOR_OPTIONS, + SHOW_CREW_TREASURY, isValidNoffer, type SkateCrew, type CrewFocus, @@ -588,7 +589,8 @@ export function CrewForm({ existing }: CrewFormProps) { - {/* Crew treasury (NIP-75 zap goal) */} + {/* Crew treasury (NIP-75 zap goal) — hidden for now via SHOW_CREW_TREASURY */} + {SHOW_CREW_TREASURY && (
@@ -651,6 +653,7 @@ export function CrewForm({ existing }: CrewFormProps) {
)}
+ )} {/* Members */}
diff --git a/src/components/EditProfileForm.tsx b/src/components/EditProfileForm.tsx index f81a140..a27cd02 100644 --- a/src/components/EditProfileForm.tsx +++ b/src/components/EditProfileForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -17,10 +17,12 @@ import { import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Switch } from '@/components/ui/switch'; -import { Loader2, Upload } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Loader2, Upload, Zap, ShieldCheck, AlertCircle } from 'lucide-react'; import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify'; import { useQueryClient } from '@tanstack/react-query'; import { useUploadFile } from '@/hooks/useUploadFile'; +import { isValidNoffer } from '@/lib/types'; export const EditProfileForm: React.FC = () => { const queryClient = useQueryClient(); @@ -30,6 +32,14 @@ export const EditProfileForm: React.FC = () => { const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { toast } = useToast(); + // CLINK Offer (noffer1...) — a self-custodial Bitcoin tip code. Not part of + // the standard NostrMetadata schema, so we keep it in its own state and merge + // it into the kind 0 content JSON on save (preserved as a custom field). + const [noffer, setNoffer] = useState(''); + const nofferTrimmed = noffer.trim(); + const nofferInvalid = nofferTrimmed.length > 0 && !isValidNoffer(nofferTrimmed); + const nofferValid = isValidNoffer(nofferTrimmed); + // Initialize the form with default values const form = useForm({ resolver: zodResolver(n.metadata()), @@ -56,6 +66,11 @@ export const EditProfileForm: React.FC = () => { nip05: metadata.nip05 || '', bot: metadata.bot || false, }); + // Prefill the custom CLINK code from the raw metadata. + const existingNoffer = (metadata as Record).noffer; + if (typeof existingNoffer === 'string') { + setNoffer(existingNoffer); + } } }, [metadata, form]); @@ -89,9 +104,18 @@ export const EditProfileForm: React.FC = () => { return; } + if (nofferInvalid) { + toast({ + title: 'Invalid CLINK code', + description: 'That doesn\'t look like a valid noffer1… code. Clear it or paste a valid one.', + variant: 'destructive', + }); + return; + } + try { // Combine existing metadata with new values - const data = { ...metadata, ...values }; + const data: Record = { ...metadata, ...values }; // Clean up empty values for (const key in data) { @@ -100,6 +124,13 @@ export const EditProfileForm: React.FC = () => { } } + // Merge the custom CLINK offer code (or remove it when cleared). + if (nofferValid) { + data.noffer = nofferTrimmed.toLowerCase(); + } else { + delete data.noffer; + } + // Publish the metadata event (kind 0) with retry logic let attempts = 0; let lastError; @@ -257,6 +288,47 @@ export const EditProfileForm: React.FC = () => { />
+ {/* CLINK Offer — self-custodial Bitcoin tip code (custom metadata field) */} +
+ + setNoffer(e.target.value)} + spellCheck={false} + autoCapitalize="none" + className="font-mono text-sm" + /> + {nofferInvalid ? ( +

+ + That doesn't look like a valid noffer1… code. +

+ ) : nofferValid ? ( +

+ + Valid — a "Tip in Bitcoin" button will show on your profile. +

+ ) : ( +

+ Paste a self-custodial CLINK payment code (from e.g. ShockWallet / Lightning.Pub) + so anyone can tip you directly in Bitcoin over Nostr — no middleman.{' '} + + Learn more + +

+ )} +
+
- {/* Messages Quick Access - Desktop */} - {user && ( - - - - )} + {/* Messages lives in the "More" menu — no standalone desktop icon. */} {/* Collections Quick Access - Desktop */} {user && ( diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index ddbdd01..2629221 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -19,7 +19,7 @@ const Checkbox = React.forwardRef< - + )) diff --git a/src/lib/types.ts b/src/lib/types.ts index c9bbc29..a2fc030 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -931,6 +931,14 @@ export const BUILDER_SCALE_LABELS: Record = { // ----- Crews (community identity, map-anchored) ----- +/** + * Feature flag: the Crew Treasury (NIP-75 zap goal) is hidden for now. + * The code path is kept intact — flip this to `true` to re-enable the + * treasury card on the crew detail page and the treasury section in the + * crew form. + */ +export const SHOW_CREW_TREASURY = false; + // Crew vibe / focus tags export const CREW_VIBE_LABELS: Record = { street: 'Street', @@ -943,6 +951,8 @@ export const CREW_VIBE_LABELS: Record = { girls: 'Girl / Woman', youth: 'Youth / Groms', adaptive: 'Adaptive', + punk: 'Punk', + hiphop: 'Rap / Hip-Hop', }; /** diff --git a/src/pages/CreateEventPage.tsx b/src/pages/CreateEventPage.tsx index f0bcb20..82794a2 100644 --- a/src/pages/CreateEventPage.tsx +++ b/src/pages/CreateEventPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import ngeohash from 'ngeohash'; @@ -60,6 +60,10 @@ export default function CreateEventPage() { ? crewParam.split(':') : ['', '']; const { data: hostCrew } = useCrew(crewHostIdentifier, crewHostPubkey); + // Optional deep-link prefill: `?type=concert&title=...` — used by e.g. the + // "Book for a gig" action on a crew that has a band. + const prefillType = searchParams.get('type') ?? ''; + const prefillTitle = searchParams.get('title') ?? ''; const { user } = useCurrentUser(); const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); @@ -140,6 +144,17 @@ export default function CreateEventPage() { ); }; + // Prefill from deep-link params once on mount (e.g. "Book for a gig"). + useEffect(() => { + if (prefillType && EVENT_TYPE_LABELS[prefillType]) { + setSelectedTypes((prev) => (prev.includes(prefillType) ? prev : [...prev, prefillType])); + } + if (prefillTitle) { + setTitle((prev) => prev || prefillTitle); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const getCurrentLocation = () => { if (!navigator.geolocation) { toast({ diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 6c2f36f..70aea0e 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -45,7 +45,7 @@ import { useCrew } from '@/hooks/useCrews'; import { useCrewFeed } from '@/hooks/useCrewFeed'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; -import { SKATE_CREW_KIND, SKATE_SPOT_KIND, CREW_VIBE_LABELS } from '@/lib/types'; +import { SKATE_CREW_KIND, SKATE_SPOT_KIND, CREW_VIBE_LABELS, SHOW_CREW_TREASURY } from '@/lib/types'; import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; import NotFound from './NotFound'; @@ -330,8 +330,8 @@ export default function CrewDetailPage() { {/* Open call — looking for filmers/editors/members (Lab pipeline) */} - {/* Crew treasury — transparent, zap-funded NIP-75 goal */} - {crew.goalRef && } + {/* Crew treasury — hidden for now (kept in code, re-enable by flipping SHOW_CREW_TREASURY) */} + {SHOW_CREW_TREASURY && crew.goalRef && } {/* Crew-hosted events (jams / comps / meetups) */} {((crewEvents && crewEvents.length > 0) || canManage) && ( @@ -504,33 +504,6 @@ export default function CrewDetailPage() { ); })} - {/* Crew band / music project */} - {crew.bandName && ( - crew.bandUrl && sanitizeUrl(crew.bandUrl) ? ( - - - - Band · - {crew.bandName} - - - ) : ( -
- - - Band · - {crew.bandName} - -
- ) - )} -
{/* Zap the whole crew — split across founder + confirmed members */} {crewZapRecipients.length > 1 && ( @@ -550,6 +523,53 @@ export default function CrewDetailPage() { + {/* Crew Band — many crews double as a local band. Shown as its own card, + with a "Book for a gig" action that deep-links to create a Concert + event hosted by this crew (connects bands → the Events calendar). */} + {crew.bandName && ( + + + + Crew Band + + + +
+
+ 🎸 +
+
+

{crew.bandName}

+

Live music · skate & sound

+
+
+ + {crew.bandUrl && sanitizeUrl(crew.bandUrl) && ( + + Listen to the tunes + + )} + + + Book them for a gig + +

+ Sets up a music event hosted by this crew on the Events calendar — + sort the date & venue and it's booked. +

+
+
+ )} + {/* Members */} {hasMembers && ( | undefined)?.noffer; + const nofferCode = typeof noffer === 'string' ? noffer : undefined; const npub = nip19.npubEncode(pubkey); const isOwnProfile = user?.pubkey === pubkey; @@ -315,18 +319,29 @@ function ProfilePage({ pubkey }: { pubkey: string }) { {/* Tip Card */} - {!isOwnProfile && profileEvent && ( + {!isOwnProfile && (profileEvent || nofferCode) && ( -

Support this mapper

+

Support this skater

- Send a zap to thank them for their contributions + Send a tip to thank them for their contributions

- +
+ {profileEvent && ( + + )} + {nofferCode && ( + + )} +
)}