Lab: payment-readiness prompt on every provider form + drop downhill/longboard from judge

- New PaymentReadinessNote component: checks the logged-in user's Nostr profile
  (kind 0) for a Lightning address / Silent Payment and either confirms "you can
  get paid" or links them to Edit Profile to add one. Wired into CraftProfileForm
  (coach, filmer, editor, photographer, builder, artist, graffiti, tattoo,
  trainer, musician, judge) and the standalone coach/filmer/editor/photographer/
  builder forms, alongside the existing CLINK (noffer) field. Lightning address
  stays a single source of truth on kind 0 — never duplicated/stale per event.
- become-judge: removed the "Downhill / Longboard" event format (not part of the
  skateboarding activities this app covers).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 15:21:34 +07:00
parent 598362a196
commit a89fca8e28
8 changed files with 86 additions and 3 deletions

View file

@ -16,6 +16,7 @@ import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { NofferField } from '@/components/NofferField'; import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField'; import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField'; import { CrewLinkField } from '@/components/CrewLinkField';
import { LabProUpgrade } from '@/components/LabProUpgrade'; import { LabProUpgrade } from '@/components/LabProUpgrade';
@ -445,9 +446,13 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
</CardContent> </CardContent>
</Card> </Card>
{/* CLINK tip jar */} {/* Payments — Lightning address (from profile) + optional CLINK code */}
<Card className="bg-black/60 border-white/10"> <Card className="bg-black/60 border-white/10">
<CardContent className="p-5"> <CardContent className="p-5 space-y-4">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">
Getting Paid
</Label>
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} /> <NofferField value={noffer} onChange={setNoffer} />
</CardContent> </CardContent>
</Card> </Card>

View file

@ -0,0 +1,69 @@
import { Link } from 'react-router-dom';
import { Zap, CheckCircle2, AlertCircle } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { isValidLightningAddress, isValidSilentPayment } from '@/lib/types';
/**
* Payment-readiness note for provider / Lab / crew forms.
*
* Lightning address (lud16) and Bitcoin Silent Payment live on the user's Nostr
* profile (kind 0), NOT on each individual Lab event — so a single source of
* truth never goes stale across a person's many profiles. This note simply
* tells a provider whether that source is set up, and links them to Edit Profile
* if it isn't, so they always have a clear path to "get paid".
*/
export function PaymentReadinessNote() {
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const metadata = author.data?.metadata;
const lud16 = metadata?.lud16;
const hasLightning = isValidLightningAddress(lud16) || !!metadata?.lud06;
// Silent payment lives in a custom kind-0 field the app reads elsewhere.
const rawSp = (metadata as Record<string, unknown> | undefined)?.bitcoin_silent_payment;
const hasSilentPayment = typeof rawSp === 'string' && isValidSilentPayment(rawSp);
if (!user) return null;
const ready = hasLightning;
return (
<div
className={
ready
? 'space-y-1.5 rounded-lg border border-green-500/25 bg-green-500/5 p-4'
: 'space-y-1.5 rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-4'
}
>
<div className="flex items-center gap-2">
<Zap className={ready ? 'h-4 w-4 text-green-400' : 'h-4 w-4 text-yellow-400'} />
<span className={ready ? 'text-sm font-bold text-green-300' : 'text-sm font-bold text-yellow-300'}>
{ready ? 'You can get paid' : 'Set up payments to get paid'}
</span>
</div>
{ready ? (
<p className="flex items-start gap-1.5 text-xs text-gray-400">
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-green-400" />
<span>
Your Lightning address{lud16 ? <> (<span className="font-mono">{lud16}</span>)</> : ''} is on your
profile — people can zap and pay you directly.
{hasSilentPayment && ' A Bitcoin Silent Payment address is set too.'}
</span>
</p>
) : (
<p className="flex items-start gap-1.5 text-xs text-gray-400">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-400" />
<span>
Add a Lightning address (and optionally a Bitcoin Silent Payment address) to your{' '}
<Link to="/settings" className="text-yellow-400 underline hover:text-yellow-300">
Nostr profile
</Link>{' '}
so skaters can pay you. It's set once and works across every profile you have here.
</span>
</p>
)}
</div>
);
}

View file

@ -1684,7 +1684,6 @@ export const JUDGE_FORMAT_LABELS: Record<string, string> = {
mini_ramp: 'Mini Ramp', mini_ramp: 'Mini Ramp',
park: 'Park', park: 'Park',
street: 'Street', street: 'Street',
downhill: 'Downhill / Longboard',
freestyle: 'Freestyle', freestyle: 'Freestyle',
world_tour: 'World Destruction Tour', world_tour: 'World Destruction Tour',
am_series: 'Am Series / Qualifiers', am_series: 'Am Series / Qualifiers',

View file

@ -20,6 +20,7 @@ import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { SpotPicker } from '@/components/SpotPicker'; import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField'; import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField'; import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField'; import { CrewLinkField } from '@/components/CrewLinkField';
import { import {
@ -486,6 +487,7 @@ export default function BecomeBuilderPage() {
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" /> <Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
</div> </div>
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} /> <NofferField value={noffer} onChange={setNoffer} />
<div className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5"> <div className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5">
<p className="text-sm text-gray-300">💬 Clients can message you directly through SkateSpots' encrypted messaging</p> <p className="text-sm text-gray-300">💬 Clients can message you directly through SkateSpots' encrypted messaging</p>

View file

@ -19,6 +19,7 @@ import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { SpotPicker } from '@/components/SpotPicker'; import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField'; import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField'; import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField'; import { CrewLinkField } from '@/components/CrewLinkField';
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types'; import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
@ -401,6 +402,7 @@ export default function BecomeCoachPage() {
</div> </div>
</div> </div>
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} /> <NofferField value={noffer} onChange={setNoffer} />
<div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5"> <div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5">

View file

@ -18,6 +18,7 @@ import { useWallet } from '@/hooks/useWallet';
import { WalletModal } from '@/components/WalletModal'; import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { NofferField } from '@/components/NofferField'; import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { import {
SKATE_EDITOR_KIND, SKATE_EDITOR_KIND,
EDITOR_SERVICE_LABELS, EDITOR_SERVICE_LABELS,
@ -427,6 +428,7 @@ export default function BecomeEditorPage() {
<Input placeholder="Website" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" /> <Input placeholder="Website" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Instagram / Social handle" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" /> <Input placeholder="Instagram / Social handle" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} /> <NofferField value={noffer} onChange={setNoffer} />
</div> </div>
</CardContent> </CardContent>

View file

@ -19,6 +19,7 @@ import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { SpotPicker } from '@/components/SpotPicker'; import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField'; import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField'; import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField'; import { CrewLinkField } from '@/components/CrewLinkField';
import { SKATE_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types'; import { SKATE_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
@ -459,6 +460,7 @@ export default function BecomeFilmerPage() {
</div> </div>
</div> </div>
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} /> <NofferField value={noffer} onChange={setNoffer} />
<div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5"> <div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5">

View file

@ -19,6 +19,7 @@ import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { SpotPicker } from '@/components/SpotPicker'; import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField'; import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField'; import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField'; import { CrewLinkField } from '@/components/CrewLinkField';
import type { EntityType } from '@/lib/types'; import type { EntityType } from '@/lib/types';
@ -508,6 +509,7 @@ export default function BecomePhotographerPage() {
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" /> <Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
</div> </div>
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} /> <NofferField value={noffer} onChange={setNoffer} />
<div className="p-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5"> <div className="p-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5">
<p className="text-sm text-gray-300">💬 Riders can message you directly through SkateSpots' encrypted messaging</p> <p className="text-sm text-gray-300">💬 Riders can message you directly through SkateSpots' encrypted messaging</p>