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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-30 20:29:44 +07:00
parent d465eabd87
commit 8056b02ad1
9 changed files with 229 additions and 77 deletions

View file

@ -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 (
<>
<Button
type="button"
variant={variant}
size={size}
className={cn(
'gap-2 border-indigo-500/40 text-indigo-600 hover:bg-indigo-500/10 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300',
className,
)}
onClick={() => setOpen(true)}
>
<Zap className="h-4 w-4" />
Pay via CLINK
</Button>
<div className={cn('flex items-center gap-1.5', className)}>
<Button
type="button"
variant={variant}
size={size}
className="flex-1 gap-2 border-indigo-500/40 text-indigo-600 hover:bg-indigo-500/10 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
onClick={() => setOpen(true)}
>
<Zap className="h-4 w-4" />
{label}
</Button>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="What is CLINK?"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-indigo-500/10 hover:text-indigo-500"
>
<HelpCircle className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[220px] text-xs">
<p className="font-semibold">Pay directly with Bitcoin over Nostr</p>
<p className="mt-1 text-muted-foreground">
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.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Zap className="h-5 w-5 text-indigo-500" />
Pay via CLINK
{label}
</DialogTitle>
<DialogDescription>
{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.'}
</DialogDescription>
</DialogHeader>

View file

@ -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) {
</div>
</div>
{/* Crew treasury (NIP-75 zap goal) */}
{/* Crew treasury (NIP-75 zap goal) — hidden for now via SHOW_CREW_TREASURY */}
{SHOW_CREW_TREASURY && (
<div className="space-y-3 rounded-lg border border-orange-500/30 bg-orange-500/[0.04] p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
@ -651,6 +653,7 @@ export function CrewForm({ existing }: CrewFormProps) {
</div>
)}
</div>
)}
{/* Members */}
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">

View file

@ -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<NostrMetadata>({
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<string, unknown>).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<string, unknown> = { ...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 = () => {
/>
</div>
{/* CLINK Offer — self-custodial Bitcoin tip code (custom metadata field) */}
<div className="space-y-2 rounded-lg border border-indigo-500/30 bg-indigo-500/5 p-4">
<Label htmlFor="profile-noffer" className="flex items-center gap-2 text-indigo-600 dark:text-indigo-400">
<Zap className="h-4 w-4" />
Bitcoin Tip Code (CLINK) <span className="text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
id="profile-noffer"
placeholder="noffer1..."
value={noffer}
onChange={(e) => setNoffer(e.target.value)}
spellCheck={false}
autoCapitalize="none"
className="font-mono text-sm"
/>
{nofferInvalid ? (
<p className="flex items-center gap-1.5 text-xs text-red-500">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
That doesn't look like a valid <code className="mx-1">noffer1…</code> code.
</p>
) : nofferValid ? (
<p className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
Valid — a "Tip in Bitcoin" button will show on your profile.
</p>
) : (
<p className="text-xs text-muted-foreground">
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.{' '}
<a
href="https://clinkme.dev"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-500 underline hover:text-indigo-400"
>
Learn more
</a>
</p>
)}
</div>
<FormField
control={form.control}
name="bot"

View file

@ -511,23 +511,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
</nav>
<div className="flex items-center gap-3">
{/* Messages Quick Access - Desktop */}
{user && (
<Link to="/messages">
<Button
variant="ghost"
size="icon"
className={cn(
isActive('/messages')
? "text-[#ff0040] bg-[#ff0040]/10"
: "text-gray-400 hover:text-[#ff0040] hover:bg-[#ff0040]/10"
)}
title="Messages"
>
<MessageCircle className="h-5 w-5" />
</Button>
</Link>
)}
{/* Messages lives in the "More" menu — no standalone desktop icon. */}
{/* Collections Quick Access - Desktop */}
{user && (
<Link to="/collections">

View file

@ -19,7 +19,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
<Check className="h-3.5 w-3.5" strokeWidth={3} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))

View file

@ -931,6 +931,14 @@ export const BUILDER_SCALE_LABELS: Record<string, string> = {
// ----- 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<string, string> = {
street: 'Street',
@ -943,6 +951,8 @@ export const CREW_VIBE_LABELS: Record<string, string> = {
girls: 'Girl / Woman',
youth: 'Youth / Groms',
adaptive: 'Adaptive',
punk: 'Punk',
hiphop: 'Rap / Hip-Hop',
};
/**

View file

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

View file

@ -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) */}
<CrewLookingForCard lookingFor={crew.lookingFor} />
{/* Crew treasury — transparent, zap-funded NIP-75 goal */}
{crew.goalRef && <CrewTreasuryCard goalId={crew.goalRef} />}
{/* Crew treasury — hidden for now (kept in code, re-enable by flipping SHOW_CREW_TREASURY) */}
{SHOW_CREW_TREASURY && crew.goalRef && <CrewTreasuryCard goalId={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) ? (
<a
href={sanitizeUrl(crew.bandUrl)!}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm p-2 bg-purple-500/10 border border-purple-500/25 rounded hover:bg-purple-500/20 transition-colors"
title={`Listen: ${urlDomain(crew.bandUrl)}`}
>
<Music className="h-4 w-4 text-purple-400 shrink-0" />
<span className="truncate text-purple-200">
<span className="text-purple-400/70">Band · </span>
{crew.bandName}
</span>
</a>
) : (
<div className="flex items-center gap-2 text-sm p-2 bg-purple-500/10 border border-purple-500/25 rounded">
<Music className="h-4 w-4 text-purple-400 shrink-0" />
<span className="truncate text-purple-200">
<span className="text-purple-400/70">Band · </span>
{crew.bandName}
</span>
</div>
)
)}
<div className="pt-3 border-t border-gray-700 space-y-2">
{/* Zap the whole crew — split across founder + confirmed members */}
{crewZapRecipients.length > 1 && (
@ -550,6 +523,53 @@ export default function CrewDetailPage() {
</CardContent>
</Card>
{/* 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 && (
<Card className="bg-gradient-to-br from-purple-500/10 to-black/90 border-2 border-purple-500/40">
<CardHeader>
<CardTitle className="text-lg text-purple-300 uppercase font-black flex items-center gap-2">
<Music className="h-5 w-5 text-purple-400" /> Crew Band
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl bg-purple-500/20 text-2xl">
🎸
</div>
<div className="min-w-0">
<p className="truncate text-base font-black text-white">{crew.bandName}</p>
<p className="text-xs text-purple-300/70">Live music · skate & sound</p>
</div>
</div>
{crew.bandUrl && sanitizeUrl(crew.bandUrl) && (
<a
href={sanitizeUrl(crew.bandUrl)!}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 rounded-lg border border-purple-500/30 bg-purple-500/10 px-3 py-2 text-sm font-bold text-purple-200 transition-colors hover:bg-purple-500/20"
title={`Listen: ${urlDomain(crew.bandUrl)}`}
>
<Music className="h-4 w-4" /> Listen to the tunes
</a>
)}
<Link
to={`/create-event?crew=${crew.pubkey}:${crew.identifier}&type=concert&title=${encodeURIComponent(`${crew.bandName} live`)}`}
className="flex items-center justify-center gap-2 rounded-lg bg-purple-500 px-3 py-2 text-sm font-black uppercase text-white transition-colors hover:bg-purple-600"
>
<Calendar className="h-4 w-4" /> Book them for a gig
</Link>
<p className="text-center text-[11px] leading-snug text-gray-500">
Sets up a music event hosted by this crew on the Events calendar —
sort the date &amp; venue and it's booked.
</p>
</CardContent>
</Card>
)}
{/* Members */}
{hasMembers && (
<CollectiveMembers

View file

@ -7,6 +7,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ZapButton } from '@/components/ZapButton';
import { ClinkPayButton } from '@/components/ClinkPayButton';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUserSkateSpots } from '@/hooks/useSkateSpots';
@ -44,6 +45,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
const website = metadata?.website;
const nip05 = metadata?.nip05;
const lud16 = metadata?.lud16;
// Custom CLINK offer code stored in the kind 0 content JSON.
const noffer = (metadata as Record<string, unknown> | 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 }) {
</Card>
{/* Tip Card */}
{!isOwnProfile && profileEvent && (
{!isOwnProfile && (profileEvent || nofferCode) && (
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-transparent border-2 border-[#ccff00]/30">
<CardContent className="p-4 text-center">
<Zap className="h-8 w-8 mx-auto mb-2 text-[#ccff00]" />
<h3 className="font-bold text-white mb-1">Support this mapper</h3>
<h3 className="font-bold text-white mb-1">Support this skater</h3>
<p className="text-xs text-gray-400 mb-3">
Send a zap to thank them for their contributions
Send a tip to thank them for their contributions
</p>
<ZapButton
target={profileEvent}
className="w-full justify-center bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
/>
<div className="space-y-2">
{profileEvent && (
<ZapButton
target={profileEvent}
className="w-full justify-center bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
/>
)}
{nofferCode && (
<ClinkPayButton
noffer={nofferCode}
recipientName={displayName}
className="w-full"
/>
)}
</div>
</CardContent>
</Card>
)}