Add optional CLINK (noffer) self-custodial payments to Lab profiles + shops

Phase 1 CLINK integration (recognize & display only):
- types.ts: optional noffer field + isValidNoffer guard on SkateCoach,
  SkateFilmer, SkateEditor, SkatePhotographer, SkateShop; parsers read it
- NofferField: reusable opt-in input with live validation, added to all 4
  Become-* Lab forms and both Create/Edit shop forms
- ClinkPayButton: secondary "Pay via CLINK" action (QR + copy + nostr: deep
  link), rendered on CoachDetailPage and ShopDetailPage only when a valid
  noffer is present; never replaces the existing zap / Lightning Address flow
- useQRCodeScanner: classify scanned noffer1/ndebit1 CLINK codes
- NIP.md: document the noffer tag on Lab kinds + shops and a CLINK section

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 12:34:50 +07:00
parent adeab62aa4
commit feddbaf5ef
13 changed files with 390 additions and 5 deletions

44
NIP.md
View file

@ -48,6 +48,7 @@ Skate shops use the NIP-15 marketplace "stall" kind (30017) extended with skateb
| `website` | Shop website URL |
| `instagram` | Instagram handle |
| `hours` | Hours of operation (freeform text) |
| `noffer` | Optional CLINK Offer code (`noffer1…`) for self-custodial payments — see the CLINK Payments section |
| `imeta` | Image metadata per NIP-92 |
| `lat` | Latitude (decimal) |
| `lon` | Longitude (decimal) |
@ -1450,7 +1451,8 @@ They share a common base schema and are distinguished by kind and a self-tag.
#### Shared Optional Tags
`location`, `g` (geohash), `image`, `experience`, `rate`, `phone`, `email`,
`website`, `a` (spot references to kind 30818), `alt`.
`website`, `noffer` (CLINK payment code — see below), `a` (spot references to
kind 30818), `alt`.
### Photographer (kind 35004)
@ -1510,3 +1512,43 @@ Photographer-specific optional tags:
"limit": 100
}
```
## CLINK Payments (`noffer` tag)
SkateSpots supports an **optional, opt-in** self-custodial payment code on Lab
provider profiles (kinds 35001–35004) and skate shop stalls (kind 30017). This
is layered on top of — never a replacement for — the existing Lightning Address
(`lud16` from kind 0) and zap flows.
[CLINK](https://clinkme.dev) (Common Lightning Interface for Nostr Keys) defines
Nostr-native Lightning standards. A **CLINK Offer** is a reusable, static payment
code encoded as a bech32 string with an `noffer1…` prefix (CLINK kind 21001),
analogous to an LNURL-Pay / Lightning Address but requiring no web server or
custodian.
### The `noffer` tag
| Tag | Value | Description |
|-----|-------|-------------|
| `noffer` | `noffer1…` | A CLINK Offer code the provider/shop controls. |
```json
["noffer", "noffer1qqs..."]
```
### Client behavior
- The tag is **optional**. Its absence changes nothing.
- Clients **MUST** validate the `noffer1…` prefix before rendering a payment
affordance. SkateSpots stores it only if it matches
`/^noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}$/`.
- SkateSpots does **not** decode or transact the offer itself. It surfaces a
"Pay via CLINK" affordance (QR of the code, copy button, and a `nostr:noffer1…`
deep link) so a CLINK-compatible wallet (e.g. ShockWallet, Lightning.Pub) can
request an invoice directly over Nostr.
- The QR scanner also classifies scanned `noffer1…` (and `ndebit1…`) strings so
they are recognized as CLINK codes.
> Only `noffer` (CLINK Offers) is supported in this release. `ndebit` (CLINK
> Debits) and `nmanage` (CLINK Manage) are recognized by the scanner but not yet
> actioned in-app.

View file

@ -0,0 +1,175 @@
import { useState, useEffect } from 'react';
import { Zap, Copy, Check, ExternalLink, ShieldCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useToast } from '@/hooks/useToast';
import { isValidNoffer } from '@/lib/types';
import { cn } from '@/lib/utils';
interface ClinkPayButtonProps {
/** The CLINK Offer code (noffer1...) */
noffer: string;
/** Display name of the recipient, e.g. the provider or shop name */
recipientName?: string;
className?: string;
/** Button variant — defaults to a subtle outline so it stays secondary */
variant?: 'default' | 'outline' | 'secondary';
size?: 'default' | 'sm' | 'lg';
}
/**
* A self-custodial "Pay via CLINK" button.
*
* CLINK Offers (noffer1...) are Nostr-native, self-custodial static payment
* codes (see https://clinkme.dev). This component does NOT transact — it hands
* the code off to a CLINK-compatible wallet via QR, copy, or a nostr: deep link.
* It only renders when a valid noffer is present, so it is purely additive and
* never replaces the existing Lightning Address / zap flow.
*/
export function ClinkPayButton({
noffer,
recipientName,
className,
variant = 'outline',
size = 'default',
}: ClinkPayButtonProps) {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const { toast } = useToast();
const valid = isValidNoffer(noffer);
const nostrUri = `nostr:${noffer}`;
useEffect(() => {
if (!open || !valid) return;
let cancelled = false;
(async () => {
try {
const { default: QRCode } = await import('qrcode');
const url = await QRCode.toDataURL(noffer.toUpperCase(), {
width: 260,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
});
if (!cancelled) setQrDataUrl(url);
} catch {
if (!cancelled) setQrDataUrl(null);
}
})();
return () => {
cancelled = true;
};
}, [open, noffer, valid]);
// Don't render anything if the code is invalid — keeps the feature opt-in.
if (!valid) return null;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(noffer);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast({ title: 'Copied', description: 'CLINK code copied to clipboard.' });
} catch {
toast({
title: 'Copy failed',
description: 'Could not access the clipboard.',
variant: 'destructive',
});
}
};
const shortCode = `${noffer.slice(0, 12)}…${noffer.slice(-6)}`;
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>
<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
</DialogTitle>
<DialogDescription>
{recipientName
? `Send a self-custodial Lightning payment to ${recipientName}.`
: 'Send a self-custodial Lightning payment over Nostr.'}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-2">
{qrDataUrl ? (
<div className="rounded-2xl bg-white p-3 shadow-sm ring-1 ring-black/5">
<img
src={qrDataUrl}
alt="CLINK Offer QR code"
className="h-[220px] w-[220px]"
/>
</div>
) : (
<div className="flex h-[220px] w-[220px] items-center justify-center rounded-2xl bg-muted">
<Zap className="h-8 w-8 animate-pulse text-muted-foreground" />
</div>
)}
<div className="flex w-full items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2">
<code className="flex-1 truncate text-xs text-muted-foreground">
{shortCode}
</code>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={handleCopy}
aria-label="Copy CLINK code"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<Button asChild className="w-full gap-2 bg-indigo-600 hover:bg-indigo-700">
<a href={nostrUri}>
<ExternalLink className="h-4 w-4" />
Open in CLINK wallet
</a>
</Button>
<p className="flex items-start gap-1.5 text-center text-xs text-muted-foreground">
<ShieldCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-indigo-500" />
<span>
Scan with a CLINK-compatible wallet (e.g. ShockWallet). No custodian,
no web server — the invoice is requested directly over Nostr.
</span>
</p>
</div>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,62 @@
import { Zap, ShieldCheck, AlertCircle } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { isValidNoffer } from '@/lib/types';
interface NofferFieldProps {
value: string;
onChange: (value: string) => void;
}
/**
* Optional CLINK Offer (noffer1...) input for provider / shop forms.
* Self-custodial, Nostr-native static payment code — see https://clinkme.dev.
* Purely additive: leaving it blank changes nothing.
*/
export function NofferField({ value, onChange }: NofferFieldProps) {
const trimmed = value.trim();
const showError = trimmed.length > 0 && !isValidNoffer(trimmed);
const showValid = isValidNoffer(trimmed);
return (
<div className="space-y-2 rounded-lg border border-indigo-500/30 bg-indigo-500/5 p-4">
<Label htmlFor="noffer" className="flex items-center gap-2 text-indigo-400">
<Zap className="h-4 w-4" />
CLINK Offer Code <span className="text-xs font-normal text-gray-500">(optional, advanced)</span>
</Label>
<Input
id="noffer"
placeholder="noffer1..."
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
autoCapitalize="none"
className="bg-gray-800 border-gray-700 font-mono text-sm"
/>
{showError ? (
<p className="flex items-center gap-1.5 text-xs text-red-400">
<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>
) : showValid ? (
<p className="flex items-center gap-1.5 text-xs text-green-400">
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
Valid CLINK code — a "Pay via CLINK" button will show on your profile.
</p>
) : (
<p className="text-xs text-gray-500">
Paste a self-custodial CLINK payment code (e.g. from ShockWallet / Lightning.Pub) to accept
Nostr-native Lightning payments — no web server or custodian.{' '}
<a
href="https://clinkme.dev"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-400 underline hover:text-indigo-300"
>
Learn more
</a>
</p>
)}
</div>
);
}

View file

@ -8,6 +8,8 @@ export type QRScanResult =
| { type: 'cashu_token'; value: string }
| { type: 'bitcoin_address'; value: string }
| { type: 'npub'; value: string }
| { type: 'clink_offer'; value: string }
| { type: 'clink_debit'; value: string }
| { type: 'unknown'; value: string };
/**
@ -44,6 +46,18 @@ export function classifyQRCode(decodedText: string): QRScanResult {
return { type: 'npub', value: npubMatch[1] };
}
// CLINK Offer code: noffer1... or nostr:noffer1... (self-custodial static payment code)
const nofferMatch = trimmed.match(/^(?:nostr:)?(noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,})$/i);
if (nofferMatch) {
return { type: 'clink_offer', value: nofferMatch[1].toLowerCase() };
}
// CLINK Debit authorization: ndebit1... or nostr:ndebit1...
const ndebitMatch = trimmed.match(/^(?:nostr:)?(ndebit1[023456789acdefghjklmnpqrstuvwxyz]{20,})$/i);
if (ndebitMatch) {
return { type: 'clink_debit', value: ndebitMatch[1].toLowerCase() };
}
return { type: 'unknown', value: trimmed };
}

View file

@ -53,6 +53,17 @@ export const MARKETPLACE_ITEM_KIND = 30402; // NIP-99 Classified Listings
export const SPOT_COLLECTION_KIND = 30003; // NIP-51 Bookmark sets for spot collections
export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots
/**
* Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial
* static payment code (see https://github.com/shocknet/clink, kind 21001).
* We only check the bech32-style prefix and character set; the app does not
* decode or transact — it hands the code off to a compatible wallet.
*/
export function isValidNoffer(value: string | undefined | null): value is string {
if (!value) return false;
return /^noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}$/.test(value.trim().toLowerCase());
}
export interface SkateSpot {
id: string;
pubkey: string;
@ -85,6 +96,7 @@ export interface SkateShop {
website?: string;
instagram?: string;
hours?: string;
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
createdAt: number;
event: NostrEvent;
}
@ -230,6 +242,8 @@ export function parseSkateShop(event: NostrEvent): SkateShop | null {
const website = event.tags.find(([name]) => name === 'website')?.[1];
const instagram = event.tags.find(([name]) => name === 'instagram')?.[1];
const hours = event.tags.find(([name]) => name === 'hours')?.[1];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
return {
id: event.id,
@ -245,6 +259,7 @@ export function parseSkateShop(event: NostrEvent): SkateShop | null {
website,
instagram,
hours,
noffer,
createdAt: event.created_at,
event,
};
@ -666,6 +681,7 @@ export interface SkateCoach {
email?: string;
website?: string;
instagram?: string;
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
createdAt: number;
event: NostrEvent;
@ -689,6 +705,7 @@ export interface SkateFilmer {
portfolio?: string; // Reel / portfolio link (Vimeo, YouTube, IG)
social?: string; // Social media handle or link
gear?: string; // Freeform "what's in the bag" (cameras, lenses, rigs)
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
createdAt: number;
event: NostrEvent;
@ -713,6 +730,7 @@ export interface SkateEditor {
social?: string;
hardware?: string; // e.g. "MacBook Pro M3, Sony FX3"
software?: string; // e.g. "DaVinci Resolve, Premiere"
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
createdAt: number;
event: NostrEvent;
}
@ -735,6 +753,7 @@ export interface SkatePhotographer {
portfolio?: string; // Portfolio link (Instagram, personal site, etc.)
social?: string;
gear?: string; // Freeform "what's in the bag" (bodies, lenses, lights)
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
createdAt: number;
event: NostrEvent;
@ -821,6 +840,8 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
const email = event.tags.find(([name]) => name === 'email')?.[1];
const website = event.tags.find(([name]) => name === 'website')?.[1];
const instagram = event.tags.find(([name]) => name === 'instagram')?.[1];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
// Extract spot references (a tags pointing to kind 30818 spots)
const spotRefs = event.tags
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
@ -842,6 +863,7 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
email,
website,
instagram,
noffer,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
createdAt: event.created_at,
event,
@ -869,6 +891,8 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null {
const portfolio = event.tags.find(([name]) => name === 'portfolio')?.[1];
const social = event.tags.find(([name]) => name === 'social')?.[1];
const gear = event.tags.find(([name]) => name === 'gear')?.[1];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
// Extract spot references (a tags pointing to kind 30818 spots)
const spotRefs = event.tags
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
@ -892,6 +916,7 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null {
portfolio,
social,
gear,
noffer,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
createdAt: event.created_at,
event,
@ -920,6 +945,8 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null {
const social = event.tags.find(([name]) => name === 'social')?.[1];
const hardware = event.tags.find(([name]) => name === 'hardware')?.[1];
const software = event.tags.find(([name]) => name === 'software')?.[1];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
return {
id: event.id,
@ -940,6 +967,7 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null {
social,
hardware,
software,
noffer,
createdAt: event.created_at,
event,
};
@ -966,6 +994,8 @@ export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | n
const portfolio = event.tags.find(([name]) => name === 'portfolio')?.[1];
const social = event.tags.find(([name]) => name === 'social')?.[1];
const gear = event.tags.find(([name]) => name === 'gear')?.[1];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
const spotRefs = event.tags
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
.map(([, value]) => value);
@ -988,6 +1018,7 @@ export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | n
portfolio,
social,
gear,
noffer,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
createdAt: event.created_at,
event,

View file

@ -18,7 +18,8 @@ import { useWallet } from '@/hooks/useWallet';
import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea';
import { SpotPicker } from '@/components/SpotPicker';
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS } from '@/lib/types';
import { NofferField } from '@/components/NofferField';
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer } from '@/lib/types';
import { cn } from '@/lib/utils';
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
@ -58,6 +59,7 @@ export default function BecomeCoachPage() {
const [email, setEmail] = useState('');
const [website, setWebsite] = useState('');
const [instagram, setInstagram] = useState('');
const [noffer, setNoffer] = useState('');
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -151,6 +153,7 @@ export default function BecomeCoachPage() {
if (email.trim()) tags.push(['email', email.trim()]);
if (website.trim()) tags.push(['website', website.trim()]);
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
// Add spot references
for (const spotId of selectedSpots) {
@ -368,6 +371,8 @@ export default function BecomeCoachPage() {
</div>
</div>
<NofferField value={noffer} onChange={setNoffer} />
<div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5">
<p className="text-sm text-gray-300">
💬 Students can message you directly through SkateSpots' encrypted messaging

View file

@ -17,12 +17,14 @@ import { useToast } from '@/hooks/useToast';
import { useWallet } from '@/hooks/useWallet';
import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea';
import { NofferField } from '@/components/NofferField';
import {
SKATE_EDITOR_KIND,
EDITOR_SERVICE_LABELS,
EDITOR_SERVICE_DESCRIPTIONS,
EDITOR_SKILL_LABELS,
EDITOR_SKILL_DESCRIPTIONS,
isValidNoffer,
} from '@/lib/types';
import { cn } from '@/lib/utils';
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor, Zap } from 'lucide-react';
@ -65,6 +67,7 @@ export default function BecomeEditorPage() {
const [website, setWebsite] = useState('');
const [portfolio, setPortfolio] = useState('');
const [social, setSocial] = useState('');
const [noffer, setNoffer] = useState('');
const [hardware, setHardware] = useState('');
const [software, setSoftware] = useState('');
const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -152,6 +155,7 @@ export default function BecomeEditorPage() {
if (website.trim()) tags.push(['website', website.trim()]);
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
if (social.trim()) tags.push(['social', social.trim()]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
if (hardware.trim()) tags.push(['hardware', hardware.trim()]);
if (software.trim()) tags.push(['software', software.trim()]);
@ -392,6 +396,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="Instagram / Social handle" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<NofferField value={noffer} onChange={setNoffer} />
</div>
</CardContent>
</Card>

View file

@ -18,7 +18,8 @@ import { useWallet } from '@/hooks/useWallet';
import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea';
import { SpotPicker } from '@/components/SpotPicker';
import { SKATE_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS } from '@/lib/types';
import { NofferField } from '@/components/NofferField';
import { SKATE_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, isValidNoffer } from '@/lib/types';
import { cn } from '@/lib/utils';
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
@ -61,6 +62,7 @@ export default function BecomeFilmerPage() {
const [website, setWebsite] = useState('');
const [portfolio, setPortfolio] = useState('');
const [social, setSocial] = useState('');
const [noffer, setNoffer] = useState('');
const [gear, setGear] = useState('');
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -166,6 +168,7 @@ export default function BecomeFilmerPage() {
if (website.trim()) tags.push(['website', website.trim()]);
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
if (social.trim()) tags.push(['social', social.trim()]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
if (gear.trim()) tags.push(['gear', gear.trim()]);
// Add spot references
@ -426,6 +429,8 @@ export default function BecomeFilmerPage() {
</div>
</div>
<NofferField value={noffer} onChange={setNoffer} />
<div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5">
<p className="text-sm text-gray-300">
💬 Clients can message you directly through SkateSpots' encrypted messaging

View file

@ -18,6 +18,7 @@ import { useWallet } from '@/hooks/useWallet';
import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea';
import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField';
import {
SKATE_PHOTOGRAPHER_KIND,
SKATE_SPOT_KIND,
@ -28,6 +29,7 @@ import {
PHOTO_LIGHTING_LABELS,
PHOTO_SUPPORT_LABELS,
PHOTO_STYLE_LABELS,
isValidNoffer,
} from '@/lib/types';
import { cn } from '@/lib/utils';
import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap, Aperture } from 'lucide-react';
@ -108,6 +110,7 @@ export default function BecomePhotographerPage() {
const [website, setWebsite] = useState('');
const [portfolio, setPortfolio] = useState('');
const [social, setSocial] = useState('');
const [noffer, setNoffer] = useState('');
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -216,6 +219,7 @@ export default function BecomePhotographerPage() {
if (website.trim()) tags.push(['website', website.trim()]);
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
if (social.trim()) tags.push(['social', social.trim()]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
for (const spotId of selectedSpots) {
const [spotPubkey, spotIdentifier] = spotId.split(':');
@ -472,6 +476,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" />
</div>
</div>
<NofferField value={noffer} onChange={setNoffer} />
<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>
</div>

View file

@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { ZapButton } from '@/components/ZapButton';
import { ClinkPayButton } from '@/components/ClinkPayButton';
import { TrustBadge } from '@/components/TrustBadge';
import { useLabProfile } from '@/hooks/useLabProfiles';
import { useAuthor } from '@/hooks/useAuthor';
@ -310,6 +311,15 @@ export default function CoachDetailPage() {
className="w-full justify-center"
/>
</div>
{/* CLINK self-custodial payment (only when a valid noffer is present) */}
{'noffer' in profile && profile.noffer && (
<ClinkPayButton
noffer={profile.noffer}
recipientName={profile.title}
className="w-full justify-center"
/>
)}
</CardContent>
</Card>

View file

@ -14,7 +14,8 @@ import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { ShopBountyCard } from '@/components/ShopBountyCard';
import { LoginArea } from '@/components/auth/LoginArea';
import { SKATE_SHOP_KIND } from '@/lib/types';
import { SKATE_SHOP_KIND, isValidNoffer } from '@/lib/types';
import { NofferField } from '@/components/NofferField';
import {
Store,
Upload,
@ -48,6 +49,7 @@ export default function CreateShopPage() {
const [email, setEmail] = useState('');
const [website, setWebsite] = useState('');
const [instagram, setInstagram] = useState('');
const [noffer, setNoffer] = useState('');
const [hours, setHours] = useState('');
const [images, setImages] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -209,6 +211,10 @@ export default function CreateShopPage() {
tags.push(['hours', hours.trim()]);
}
if (isValidNoffer(noffer.trim())) {
tags.push(['noffer', noffer.trim().toLowerCase()]);
}
for (const imageUrl of images) {
// Use imeta tag for NIP-92 compatibility
tags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
@ -434,6 +440,9 @@ export default function CreateShopPage() {
/>
</div>
{/* CLINK self-custodial payments (optional) */}
<NofferField value={noffer} onChange={setNoffer} />
{/* Images */}
<div className="space-y-2">
<Label>Shop Photos</Label>

View file

@ -15,7 +15,8 @@ import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { useShop } from '@/hooks/useShops';
import { LoginArea } from '@/components/auth/LoginArea';
import { SKATE_SHOP_KIND } from '@/lib/types';
import { SKATE_SHOP_KIND, isValidNoffer } from '@/lib/types';
import { NofferField } from '@/components/NofferField';
import {
Store,
Upload,
@ -76,6 +77,7 @@ export default function EditShopPage() {
const [website, setWebsite] = useState('');
const [instagram, setInstagram] = useState('');
const [hours, setHours] = useState('');
const [noffer, setNoffer] = useState('');
const [images, setImages] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [initialized, setInitialized] = useState(false);
@ -96,6 +98,7 @@ export default function EditShopPage() {
setWebsite(shop.website || '');
setInstagram(shop.instagram || '');
setHours(shop.hours || '');
setNoffer(shop.noffer || '');
setImages(shop.images);
// Decode geohash to get coordinates
@ -251,6 +254,10 @@ export default function EditShopPage() {
tags.push(['hours', hours.trim()]);
}
if (isValidNoffer(noffer.trim())) {
tags.push(['noffer', noffer.trim().toLowerCase()]);
}
for (const imageUrl of images) {
// Use imeta tag for NIP-92 compatibility
tags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
@ -501,6 +508,9 @@ export default function EditShopPage() {
/>
</div>
{/* CLINK self-custodial payments (optional) */}
<NofferField value={noffer} onChange={setNoffer} />
{/* Images */}
<div className="space-y-2">
<Label>Shop Photos</Label>

View file

@ -11,6 +11,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { ZapButton } from '@/components/ZapButton';
import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CommentsSection } from '@/components/comments/CommentsSection';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { OfficialBadge } from '@/components/OfficialBadge';
@ -454,6 +455,17 @@ export default function ShopDetailPage() {
target={shop.event}
className="w-full justify-center bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
/>
{/* CLINK self-custodial payment (only when the shop published a valid noffer) */}
{shop.noffer && (
<div className="mt-3">
<ClinkPayButton
noffer={shop.noffer}
recipientName={shop.name}
className="w-full justify-center"
/>
</div>
)}
</CardContent>
</Card>
</div>