diff --git a/NIP.md b/NIP.md index 71307c9..b04570b 100644 --- a/NIP.md +++ b/NIP.md @@ -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. diff --git a/src/components/ClinkPayButton.tsx b/src/components/ClinkPayButton.tsx new file mode 100644 index 0000000..86c348a --- /dev/null +++ b/src/components/ClinkPayButton.tsx @@ -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(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 ( + <> + + + + + + + + Pay via CLINK + + + {recipientName + ? `Send a self-custodial Lightning payment to ${recipientName}.` + : 'Send a self-custodial Lightning payment over Nostr.'} + + + +
+ {qrDataUrl ? ( +
+ CLINK Offer QR code +
+ ) : ( +
+ +
+ )} + +
+ + {shortCode} + + +
+ + + +

+ + + Scan with a CLINK-compatible wallet (e.g. ShockWallet). No custodian, + no web server — the invoice is requested directly over Nostr. + +

+
+
+
+ + ); +} diff --git a/src/components/NofferField.tsx b/src/components/NofferField.tsx new file mode 100644 index 0000000..4f37627 --- /dev/null +++ b/src/components/NofferField.tsx @@ -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 ( +
+ + onChange(e.target.value)} + spellCheck={false} + autoCapitalize="none" + className="bg-gray-800 border-gray-700 font-mono text-sm" + /> + {showError ? ( +

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

+ ) : showValid ? ( +

+ + Valid CLINK code — a "Pay via CLINK" button will show on your profile. +

+ ) : ( +

+ Paste a self-custodial CLINK payment code (e.g. from ShockWallet / Lightning.Pub) to accept + Nostr-native Lightning payments — no web server or custodian.{' '} + + Learn more + +

+ )} +
+ ); +} diff --git a/src/hooks/useQRCodeScanner.ts b/src/hooks/useQRCodeScanner.ts index 40fbf96..e95dbda 100644 --- a/src/hooks/useQRCodeScanner.ts +++ b/src/hooks/useQRCodeScanner.ts @@ -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 }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index f52ffb0..fa20a14 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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, diff --git a/src/pages/BecomeCoachPage.tsx b/src/pages/BecomeCoachPage.tsx index b95d838..f909ea2 100644 --- a/src/pages/BecomeCoachPage.tsx +++ b/src/pages/BecomeCoachPage.tsx @@ -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([]); 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() { + +

💬 Students can message you directly through SkateSpots' encrypted messaging diff --git a/src/pages/BecomeEditorPage.tsx b/src/pages/BecomeEditorPage.tsx index 51058d7..990c497 100644 --- a/src/pages/BecomeEditorPage.tsx +++ b/src/pages/BecomeEditorPage.tsx @@ -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() { setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" /> setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />

+ diff --git a/src/pages/BecomeFilmerPage.tsx b/src/pages/BecomeFilmerPage.tsx index 0d41c63..871f797 100644 --- a/src/pages/BecomeFilmerPage.tsx +++ b/src/pages/BecomeFilmerPage.tsx @@ -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([]); 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() { + +

💬 Clients can message you directly through SkateSpots' encrypted messaging diff --git a/src/pages/BecomePhotographerPage.tsx b/src/pages/BecomePhotographerPage.tsx index 048155e..8cd9ecf 100644 --- a/src/pages/BecomePhotographerPage.tsx +++ b/src/pages/BecomePhotographerPage.tsx @@ -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([]); 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() { setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />

+

💬 Riders can message you directly through SkateSpots' encrypted messaging

diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index 627ecba..a3401c9 100644 --- a/src/pages/CoachDetailPage.tsx +++ b/src/pages/CoachDetailPage.tsx @@ -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" /> + + {/* CLINK self-custodial payment (only when a valid noffer is present) */} + {'noffer' in profile && profile.noffer && ( + + )} diff --git a/src/pages/CreateShopPage.tsx b/src/pages/CreateShopPage.tsx index 03ad43b..ee1ac4c 100644 --- a/src/pages/CreateShopPage.tsx +++ b/src/pages/CreateShopPage.tsx @@ -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([]); 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() { /> + {/* CLINK self-custodial payments (optional) */} + + {/* Images */}
diff --git a/src/pages/EditShopPage.tsx b/src/pages/EditShopPage.tsx index 5c6154b..ebd6af6 100644 --- a/src/pages/EditShopPage.tsx +++ b/src/pages/EditShopPage.tsx @@ -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([]); 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() { />
+ {/* CLINK self-custodial payments (optional) */} + + {/* Images */}
diff --git a/src/pages/ShopDetailPage.tsx b/src/pages/ShopDetailPage.tsx index 5a5dd54..17d522d 100644 --- a/src/pages/ShopDetailPage.tsx +++ b/src/pages/ShopDetailPage.tsx @@ -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 && ( +
+ +
+ )}