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:
parent
d465eabd87
commit
8056b02ad1
9 changed files with 229 additions and 77 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -8,6 +8,12 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { isValidNoffer } from '@/lib/types';
|
import { isValidNoffer } from '@/lib/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -21,6 +27,11 @@ interface ClinkPayButtonProps {
|
||||||
/** Button variant — defaults to a subtle outline so it stays secondary */
|
/** Button variant — defaults to a subtle outline so it stays secondary */
|
||||||
variant?: 'default' | 'outline' | 'secondary';
|
variant?: 'default' | 'outline' | 'secondary';
|
||||||
size?: 'default' | 'sm' | 'lg';
|
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,
|
className,
|
||||||
variant = 'outline',
|
variant = 'outline',
|
||||||
size = 'default',
|
size = 'default',
|
||||||
|
label = 'Tip in Bitcoin',
|
||||||
}: ClinkPayButtonProps) {
|
}: ClinkPayButtonProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
@ -90,31 +102,52 @@ export function ClinkPayButton({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<div className={cn('flex items-center gap-1.5', className)}>
|
||||||
type="button"
|
<Button
|
||||||
variant={variant}
|
type="button"
|
||||||
size={size}
|
variant={variant}
|
||||||
className={cn(
|
size={size}
|
||||||
'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="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"
|
||||||
className,
|
onClick={() => setOpen(true)}
|
||||||
)}
|
>
|
||||||
onClick={() => setOpen(true)}
|
<Zap className="h-4 w-4" />
|
||||||
>
|
{label}
|
||||||
<Zap className="h-4 w-4" />
|
</Button>
|
||||||
Pay via CLINK
|
<TooltipProvider delayDuration={150}>
|
||||||
</Button>
|
<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}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Zap className="h-5 w-5 text-indigo-500" />
|
<Zap className="h-5 w-5 text-indigo-500" />
|
||||||
Pay via CLINK
|
{label}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{recipientName
|
{recipientName
|
||||||
? `Send a self-custodial Lightning payment to ${recipientName}.`
|
? `Send a self-custodial Bitcoin (Lightning) payment to ${recipientName} — no middleman.`
|
||||||
: 'Send a self-custodial Lightning payment over Nostr.'}
|
: 'Send a self-custodial Bitcoin (Lightning) payment over Nostr — no middleman.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
CREW_FOCUS_DESCRIPTIONS,
|
CREW_FOCUS_DESCRIPTIONS,
|
||||||
CREW_FOCUS_TAG,
|
CREW_FOCUS_TAG,
|
||||||
CREW_LOOKING_FOR_OPTIONS,
|
CREW_LOOKING_FOR_OPTIONS,
|
||||||
|
SHOW_CREW_TREASURY,
|
||||||
isValidNoffer,
|
isValidNoffer,
|
||||||
type SkateCrew,
|
type SkateCrew,
|
||||||
type CrewFocus,
|
type CrewFocus,
|
||||||
|
|
@ -588,7 +589,8 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
</div>
|
</div>
|
||||||
</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="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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
@ -651,6 +653,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Members */}
|
{/* Members */}
|
||||||
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
@ -17,10 +17,12 @@ import {
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Switch } from '@/components/ui/switch';
|
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 { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
|
import { isValidNoffer } from '@/lib/types';
|
||||||
|
|
||||||
export const EditProfileForm: React.FC = () => {
|
export const EditProfileForm: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -30,6 +32,14 @@ export const EditProfileForm: React.FC = () => {
|
||||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||||
const { toast } = useToast();
|
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
|
// Initialize the form with default values
|
||||||
const form = useForm<NostrMetadata>({
|
const form = useForm<NostrMetadata>({
|
||||||
resolver: zodResolver(n.metadata()),
|
resolver: zodResolver(n.metadata()),
|
||||||
|
|
@ -56,6 +66,11 @@ export const EditProfileForm: React.FC = () => {
|
||||||
nip05: metadata.nip05 || '',
|
nip05: metadata.nip05 || '',
|
||||||
bot: metadata.bot || false,
|
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]);
|
}, [metadata, form]);
|
||||||
|
|
||||||
|
|
@ -89,9 +104,18 @@ export const EditProfileForm: React.FC = () => {
|
||||||
return;
|
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 {
|
try {
|
||||||
// Combine existing metadata with new values
|
// Combine existing metadata with new values
|
||||||
const data = { ...metadata, ...values };
|
const data: Record<string, unknown> = { ...metadata, ...values };
|
||||||
|
|
||||||
// Clean up empty values
|
// Clean up empty values
|
||||||
for (const key in data) {
|
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
|
// Publish the metadata event (kind 0) with retry logic
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
let lastError;
|
let lastError;
|
||||||
|
|
@ -257,6 +288,47 @@ export const EditProfileForm: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="bot"
|
name="bot"
|
||||||
|
|
|
||||||
|
|
@ -511,23 +511,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Messages Quick Access - Desktop */}
|
{/* Messages lives in the "More" menu — no standalone desktop icon. */}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
{/* Collections Quick Access - Desktop */}
|
{/* Collections Quick Access - Desktop */}
|
||||||
{user && (
|
{user && (
|
||||||
<Link to="/collections">
|
<Link to="/collections">
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const Checkbox = React.forwardRef<
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
className={cn("flex items-center justify-center text-current")}
|
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.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -931,6 +931,14 @@ export const BUILDER_SCALE_LABELS: Record<string, string> = {
|
||||||
|
|
||||||
// ----- Crews (community identity, map-anchored) -----
|
// ----- 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
|
// Crew vibe / focus tags
|
||||||
export const CREW_VIBE_LABELS: Record<string, string> = {
|
export const CREW_VIBE_LABELS: Record<string, string> = {
|
||||||
street: 'Street',
|
street: 'Street',
|
||||||
|
|
@ -943,6 +951,8 @@ export const CREW_VIBE_LABELS: Record<string, string> = {
|
||||||
girls: 'Girl / Woman',
|
girls: 'Girl / Woman',
|
||||||
youth: 'Youth / Groms',
|
youth: 'Youth / Groms',
|
||||||
adaptive: 'Adaptive',
|
adaptive: 'Adaptive',
|
||||||
|
punk: 'Punk',
|
||||||
|
hiphop: 'Rap / Hip-Hop',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useSeoMeta } from '@unhead/react';
|
import { useSeoMeta } from '@unhead/react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import ngeohash from 'ngeohash';
|
import ngeohash from 'ngeohash';
|
||||||
|
|
@ -60,6 +60,10 @@ export default function CreateEventPage() {
|
||||||
? crewParam.split(':')
|
? crewParam.split(':')
|
||||||
: ['', ''];
|
: ['', ''];
|
||||||
const { data: hostCrew } = useCrew(crewHostIdentifier, crewHostPubkey);
|
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 { user } = useCurrentUser();
|
||||||
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
|
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
|
||||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
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 = () => {
|
const getCurrentLocation = () => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
toast({
|
toast({
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ import { useCrew } from '@/hooks/useCrews';
|
||||||
import { useCrewFeed } from '@/hooks/useCrewFeed';
|
import { useCrewFeed } from '@/hooks/useCrewFeed';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
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 { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
|
|
@ -330,8 +330,8 @@ export default function CrewDetailPage() {
|
||||||
{/* Open call — looking for filmers/editors/members (Lab pipeline) */}
|
{/* Open call — looking for filmers/editors/members (Lab pipeline) */}
|
||||||
<CrewLookingForCard lookingFor={crew.lookingFor} />
|
<CrewLookingForCard lookingFor={crew.lookingFor} />
|
||||||
|
|
||||||
{/* Crew treasury — transparent, zap-funded NIP-75 goal */}
|
{/* Crew treasury — hidden for now (kept in code, re-enable by flipping SHOW_CREW_TREASURY) */}
|
||||||
{crew.goalRef && <CrewTreasuryCard goalId={crew.goalRef} />}
|
{SHOW_CREW_TREASURY && crew.goalRef && <CrewTreasuryCard goalId={crew.goalRef} />}
|
||||||
|
|
||||||
{/* Crew-hosted events (jams / comps / meetups) */}
|
{/* Crew-hosted events (jams / comps / meetups) */}
|
||||||
{((crewEvents && crewEvents.length > 0) || canManage) && (
|
{((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">
|
<div className="pt-3 border-t border-gray-700 space-y-2">
|
||||||
{/* Zap the whole crew — split across founder + confirmed members */}
|
{/* Zap the whole crew — split across founder + confirmed members */}
|
||||||
{crewZapRecipients.length > 1 && (
|
{crewZapRecipients.length > 1 && (
|
||||||
|
|
@ -550,6 +523,53 @@ export default function CrewDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 & venue and it's booked.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Members */}
|
{/* Members */}
|
||||||
{hasMembers && (
|
{hasMembers && (
|
||||||
<CollectiveMembers
|
<CollectiveMembers
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
|
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useUserSkateSpots } from '@/hooks/useSkateSpots';
|
import { useUserSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
|
|
@ -44,6 +45,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
const website = metadata?.website;
|
const website = metadata?.website;
|
||||||
const nip05 = metadata?.nip05;
|
const nip05 = metadata?.nip05;
|
||||||
const lud16 = metadata?.lud16;
|
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 npub = nip19.npubEncode(pubkey);
|
||||||
const isOwnProfile = user?.pubkey === pubkey;
|
const isOwnProfile = user?.pubkey === pubkey;
|
||||||
|
|
@ -315,18 +319,29 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Tip Card */}
|
{/* Tip Card */}
|
||||||
{!isOwnProfile && profileEvent && (
|
{!isOwnProfile && (profileEvent || nofferCode) && (
|
||||||
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-transparent border-2 border-[#ccff00]/30">
|
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-transparent border-2 border-[#ccff00]/30">
|
||||||
<CardContent className="p-4 text-center">
|
<CardContent className="p-4 text-center">
|
||||||
<Zap className="h-8 w-8 mx-auto mb-2 text-[#ccff00]" />
|
<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">
|
<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>
|
</p>
|
||||||
<ZapButton
|
<div className="space-y-2">
|
||||||
target={profileEvent}
|
{profileEvent && (
|
||||||
className="w-full justify-center bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue