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:
parent
adeab62aa4
commit
feddbaf5ef
13 changed files with 390 additions and 5 deletions
44
NIP.md
44
NIP.md
|
|
@ -48,6 +48,7 @@ Skate shops use the NIP-15 marketplace "stall" kind (30017) extended with skateb
|
||||||
| `website` | Shop website URL |
|
| `website` | Shop website URL |
|
||||||
| `instagram` | Instagram handle |
|
| `instagram` | Instagram handle |
|
||||||
| `hours` | Hours of operation (freeform text) |
|
| `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 |
|
| `imeta` | Image metadata per NIP-92 |
|
||||||
| `lat` | Latitude (decimal) |
|
| `lat` | Latitude (decimal) |
|
||||||
| `lon` | Longitude (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
|
#### Shared Optional Tags
|
||||||
`location`, `g` (geohash), `image`, `experience`, `rate`, `phone`, `email`,
|
`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)
|
### Photographer (kind 35004)
|
||||||
|
|
||||||
|
|
@ -1510,3 +1512,43 @@ Photographer-specific optional tags:
|
||||||
"limit": 100
|
"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.
|
||||||
|
|
|
||||||
175
src/components/ClinkPayButton.tsx
Normal file
175
src/components/ClinkPayButton.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/NofferField.tsx
Normal file
62
src/components/NofferField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@ export type QRScanResult =
|
||||||
| { type: 'cashu_token'; value: string }
|
| { type: 'cashu_token'; value: string }
|
||||||
| { type: 'bitcoin_address'; value: string }
|
| { type: 'bitcoin_address'; value: string }
|
||||||
| { type: 'npub'; value: string }
|
| { type: 'npub'; value: string }
|
||||||
|
| { type: 'clink_offer'; value: string }
|
||||||
|
| { type: 'clink_debit'; value: string }
|
||||||
| { type: 'unknown'; value: string };
|
| { type: 'unknown'; value: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -44,6 +46,18 @@ export function classifyQRCode(decodedText: string): QRScanResult {
|
||||||
return { type: 'npub', value: npubMatch[1] };
|
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 };
|
return { type: 'unknown', value: trimmed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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
|
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 {
|
export interface SkateSpot {
|
||||||
id: string;
|
id: string;
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
|
|
@ -85,6 +96,7 @@ export interface SkateShop {
|
||||||
website?: string;
|
website?: string;
|
||||||
instagram?: string;
|
instagram?: string;
|
||||||
hours?: string;
|
hours?: string;
|
||||||
|
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
}
|
}
|
||||||
|
|
@ -230,6 +242,8 @@ export function parseSkateShop(event: NostrEvent): SkateShop | null {
|
||||||
const website = event.tags.find(([name]) => name === 'website')?.[1];
|
const website = event.tags.find(([name]) => name === 'website')?.[1];
|
||||||
const instagram = event.tags.find(([name]) => name === 'instagram')?.[1];
|
const instagram = event.tags.find(([name]) => name === 'instagram')?.[1];
|
||||||
const hours = event.tags.find(([name]) => name === 'hours')?.[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 {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
|
|
@ -245,6 +259,7 @@ export function parseSkateShop(event: NostrEvent): SkateShop | null {
|
||||||
website,
|
website,
|
||||||
instagram,
|
instagram,
|
||||||
hours,
|
hours,
|
||||||
|
noffer,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
|
@ -666,6 +681,7 @@ export interface SkateCoach {
|
||||||
email?: string;
|
email?: string;
|
||||||
website?: string;
|
website?: string;
|
||||||
instagram?: string;
|
instagram?: string;
|
||||||
|
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
|
||||||
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
|
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
|
|
@ -689,6 +705,7 @@ export interface SkateFilmer {
|
||||||
portfolio?: string; // Reel / portfolio link (Vimeo, YouTube, IG)
|
portfolio?: string; // Reel / portfolio link (Vimeo, YouTube, IG)
|
||||||
social?: string; // Social media handle or link
|
social?: string; // Social media handle or link
|
||||||
gear?: string; // Freeform "what's in the bag" (cameras, lenses, rigs)
|
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"
|
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
|
|
@ -713,6 +730,7 @@ export interface SkateEditor {
|
||||||
social?: string;
|
social?: string;
|
||||||
hardware?: string; // e.g. "MacBook Pro M3, Sony FX3"
|
hardware?: string; // e.g. "MacBook Pro M3, Sony FX3"
|
||||||
software?: string; // e.g. "DaVinci Resolve, Premiere"
|
software?: string; // e.g. "DaVinci Resolve, Premiere"
|
||||||
|
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
}
|
}
|
||||||
|
|
@ -735,6 +753,7 @@ export interface SkatePhotographer {
|
||||||
portfolio?: string; // Portfolio link (Instagram, personal site, etc.)
|
portfolio?: string; // Portfolio link (Instagram, personal site, etc.)
|
||||||
social?: string;
|
social?: string;
|
||||||
gear?: string; // Freeform "what's in the bag" (bodies, lenses, lights)
|
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"
|
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
|
|
@ -821,6 +840,8 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
|
||||||
const email = event.tags.find(([name]) => name === 'email')?.[1];
|
const email = event.tags.find(([name]) => name === 'email')?.[1];
|
||||||
const website = event.tags.find(([name]) => name === 'website')?.[1];
|
const website = event.tags.find(([name]) => name === 'website')?.[1];
|
||||||
const instagram = event.tags.find(([name]) => name === 'instagram')?.[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)
|
// Extract spot references (a tags pointing to kind 30818 spots)
|
||||||
const spotRefs = event.tags
|
const spotRefs = event.tags
|
||||||
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
||||||
|
|
@ -842,6 +863,7 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
|
||||||
email,
|
email,
|
||||||
website,
|
website,
|
||||||
instagram,
|
instagram,
|
||||||
|
noffer,
|
||||||
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
|
|
@ -869,6 +891,8 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null {
|
||||||
const portfolio = event.tags.find(([name]) => name === 'portfolio')?.[1];
|
const portfolio = event.tags.find(([name]) => name === 'portfolio')?.[1];
|
||||||
const social = event.tags.find(([name]) => name === 'social')?.[1];
|
const social = event.tags.find(([name]) => name === 'social')?.[1];
|
||||||
const gear = event.tags.find(([name]) => name === 'gear')?.[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)
|
// Extract spot references (a tags pointing to kind 30818 spots)
|
||||||
const spotRefs = event.tags
|
const spotRefs = event.tags
|
||||||
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
||||||
|
|
@ -892,6 +916,7 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null {
|
||||||
portfolio,
|
portfolio,
|
||||||
social,
|
social,
|
||||||
gear,
|
gear,
|
||||||
|
noffer,
|
||||||
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
|
|
@ -920,6 +945,8 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null {
|
||||||
const social = event.tags.find(([name]) => name === 'social')?.[1];
|
const social = event.tags.find(([name]) => name === 'social')?.[1];
|
||||||
const hardware = event.tags.find(([name]) => name === 'hardware')?.[1];
|
const hardware = event.tags.find(([name]) => name === 'hardware')?.[1];
|
||||||
const software = event.tags.find(([name]) => name === 'software')?.[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 {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
|
|
@ -940,6 +967,7 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null {
|
||||||
social,
|
social,
|
||||||
hardware,
|
hardware,
|
||||||
software,
|
software,
|
||||||
|
noffer,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
|
@ -966,6 +994,8 @@ export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | n
|
||||||
const portfolio = event.tags.find(([name]) => name === 'portfolio')?.[1];
|
const portfolio = event.tags.find(([name]) => name === 'portfolio')?.[1];
|
||||||
const social = event.tags.find(([name]) => name === 'social')?.[1];
|
const social = event.tags.find(([name]) => name === 'social')?.[1];
|
||||||
const gear = event.tags.find(([name]) => name === 'gear')?.[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
|
const spotRefs = event.tags
|
||||||
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
||||||
.map(([, value]) => value);
|
.map(([, value]) => value);
|
||||||
|
|
@ -988,6 +1018,7 @@ export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | n
|
||||||
portfolio,
|
portfolio,
|
||||||
social,
|
social,
|
||||||
gear,
|
gear,
|
||||||
|
noffer,
|
||||||
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ 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 { SpotPicker } from '@/components/SpotPicker';
|
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 { cn } from '@/lib/utils';
|
||||||
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
|
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
|
||||||
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
||||||
|
|
@ -58,6 +59,7 @@ export default function BecomeCoachPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [website, setWebsite] = useState('');
|
const [website, setWebsite] = useState('');
|
||||||
const [instagram, setInstagram] = useState('');
|
const [instagram, setInstagram] = useState('');
|
||||||
|
const [noffer, setNoffer] = useState('');
|
||||||
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
||||||
|
|
@ -151,6 +153,7 @@ export default function BecomeCoachPage() {
|
||||||
if (email.trim()) tags.push(['email', email.trim()]);
|
if (email.trim()) tags.push(['email', email.trim()]);
|
||||||
if (website.trim()) tags.push(['website', website.trim()]);
|
if (website.trim()) tags.push(['website', website.trim()]);
|
||||||
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
|
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
|
||||||
|
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
||||||
|
|
||||||
// Add spot references
|
// Add spot references
|
||||||
for (const spotId of selectedSpots) {
|
for (const spotId of selectedSpots) {
|
||||||
|
|
@ -368,6 +371,8 @@ export default function BecomeCoachPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
<p className="text-sm text-gray-300">
|
<p className="text-sm text-gray-300">
|
||||||
💬 Students can message you directly through SkateSpots' encrypted messaging
|
💬 Students can message you directly through SkateSpots' encrypted messaging
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,14 @@ import { useToast } from '@/hooks/useToast';
|
||||||
import { useWallet } from '@/hooks/useWallet';
|
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 {
|
import {
|
||||||
SKATE_EDITOR_KIND,
|
SKATE_EDITOR_KIND,
|
||||||
EDITOR_SERVICE_LABELS,
|
EDITOR_SERVICE_LABELS,
|
||||||
EDITOR_SERVICE_DESCRIPTIONS,
|
EDITOR_SERVICE_DESCRIPTIONS,
|
||||||
EDITOR_SKILL_LABELS,
|
EDITOR_SKILL_LABELS,
|
||||||
EDITOR_SKILL_DESCRIPTIONS,
|
EDITOR_SKILL_DESCRIPTIONS,
|
||||||
|
isValidNoffer,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor, Zap } from 'lucide-react';
|
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 [website, setWebsite] = useState('');
|
||||||
const [portfolio, setPortfolio] = useState('');
|
const [portfolio, setPortfolio] = useState('');
|
||||||
const [social, setSocial] = useState('');
|
const [social, setSocial] = useState('');
|
||||||
|
const [noffer, setNoffer] = useState('');
|
||||||
const [hardware, setHardware] = useState('');
|
const [hardware, setHardware] = useState('');
|
||||||
const [software, setSoftware] = useState('');
|
const [software, setSoftware] = useState('');
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
@ -152,6 +155,7 @@ export default function BecomeEditorPage() {
|
||||||
if (website.trim()) tags.push(['website', website.trim()]);
|
if (website.trim()) tags.push(['website', website.trim()]);
|
||||||
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
|
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
|
||||||
if (social.trim()) tags.push(['social', social.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 (hardware.trim()) tags.push(['hardware', hardware.trim()]);
|
||||||
if (software.trim()) tags.push(['software', software.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="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>
|
||||||
|
<NofferField value={noffer} onChange={setNoffer} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ 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 { SpotPicker } from '@/components/SpotPicker';
|
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 { cn } from '@/lib/utils';
|
||||||
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
|
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
|
||||||
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
||||||
|
|
@ -61,6 +62,7 @@ export default function BecomeFilmerPage() {
|
||||||
const [website, setWebsite] = useState('');
|
const [website, setWebsite] = useState('');
|
||||||
const [portfolio, setPortfolio] = useState('');
|
const [portfolio, setPortfolio] = useState('');
|
||||||
const [social, setSocial] = useState('');
|
const [social, setSocial] = useState('');
|
||||||
|
const [noffer, setNoffer] = useState('');
|
||||||
const [gear, setGear] = useState('');
|
const [gear, setGear] = useState('');
|
||||||
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
@ -166,6 +168,7 @@ export default function BecomeFilmerPage() {
|
||||||
if (website.trim()) tags.push(['website', website.trim()]);
|
if (website.trim()) tags.push(['website', website.trim()]);
|
||||||
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
|
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
|
||||||
if (social.trim()) tags.push(['social', social.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()]);
|
if (gear.trim()) tags.push(['gear', gear.trim()]);
|
||||||
|
|
||||||
// Add spot references
|
// Add spot references
|
||||||
|
|
@ -426,6 +429,8 @@ export default function BecomeFilmerPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
<p className="text-sm text-gray-300">
|
<p className="text-sm text-gray-300">
|
||||||
💬 Clients can message you directly through SkateSpots' encrypted messaging
|
💬 Clients can message you directly through SkateSpots' encrypted messaging
|
||||||
|
|
|
||||||
|
|
@ -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 { SpotPicker } from '@/components/SpotPicker';
|
import { SpotPicker } from '@/components/SpotPicker';
|
||||||
|
import { NofferField } from '@/components/NofferField';
|
||||||
import {
|
import {
|
||||||
SKATE_PHOTOGRAPHER_KIND,
|
SKATE_PHOTOGRAPHER_KIND,
|
||||||
SKATE_SPOT_KIND,
|
SKATE_SPOT_KIND,
|
||||||
|
|
@ -28,6 +29,7 @@ import {
|
||||||
PHOTO_LIGHTING_LABELS,
|
PHOTO_LIGHTING_LABELS,
|
||||||
PHOTO_SUPPORT_LABELS,
|
PHOTO_SUPPORT_LABELS,
|
||||||
PHOTO_STYLE_LABELS,
|
PHOTO_STYLE_LABELS,
|
||||||
|
isValidNoffer,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap, Aperture } from 'lucide-react';
|
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 [website, setWebsite] = useState('');
|
||||||
const [portfolio, setPortfolio] = useState('');
|
const [portfolio, setPortfolio] = useState('');
|
||||||
const [social, setSocial] = useState('');
|
const [social, setSocial] = useState('');
|
||||||
|
const [noffer, setNoffer] = useState('');
|
||||||
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
||||||
|
|
@ -216,6 +219,7 @@ export default function BecomePhotographerPage() {
|
||||||
if (website.trim()) tags.push(['website', website.trim()]);
|
if (website.trim()) tags.push(['website', website.trim()]);
|
||||||
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
|
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
|
||||||
if (social.trim()) tags.push(['social', social.trim()]);
|
if (social.trim()) tags.push(['social', social.trim()]);
|
||||||
|
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
||||||
|
|
||||||
for (const spotId of selectedSpots) {
|
for (const spotId of selectedSpots) {
|
||||||
const [spotPubkey, spotIdentifier] = spotId.split(':');
|
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" />
|
<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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
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 { TrustBadge } from '@/components/TrustBadge';
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
import { useLabProfile } from '@/hooks/useLabProfiles';
|
import { useLabProfile } from '@/hooks/useLabProfiles';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
|
@ -310,6 +311,15 @@ export default function CoachDetailPage() {
|
||||||
className="w-full justify-center"
|
className="w-full justify-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { ShopBountyCard } from '@/components/ShopBountyCard';
|
import { ShopBountyCard } from '@/components/ShopBountyCard';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
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 {
|
import {
|
||||||
Store,
|
Store,
|
||||||
Upload,
|
Upload,
|
||||||
|
|
@ -48,6 +49,7 @@ export default function CreateShopPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [website, setWebsite] = useState('');
|
const [website, setWebsite] = useState('');
|
||||||
const [instagram, setInstagram] = useState('');
|
const [instagram, setInstagram] = useState('');
|
||||||
|
const [noffer, setNoffer] = useState('');
|
||||||
const [hours, setHours] = useState('');
|
const [hours, setHours] = useState('');
|
||||||
const [images, setImages] = useState<string[]>([]);
|
const [images, setImages] = useState<string[]>([]);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
@ -209,6 +211,10 @@ export default function CreateShopPage() {
|
||||||
tags.push(['hours', hours.trim()]);
|
tags.push(['hours', hours.trim()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isValidNoffer(noffer.trim())) {
|
||||||
|
tags.push(['noffer', noffer.trim().toLowerCase()]);
|
||||||
|
}
|
||||||
|
|
||||||
for (const imageUrl of images) {
|
for (const imageUrl of images) {
|
||||||
// Use imeta tag for NIP-92 compatibility
|
// Use imeta tag for NIP-92 compatibility
|
||||||
tags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
|
tags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
|
||||||
|
|
@ -434,6 +440,9 @@ export default function CreateShopPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* CLINK self-custodial payments (optional) */}
|
||||||
|
<NofferField value={noffer} onChange={setNoffer} />
|
||||||
|
|
||||||
{/* Images */}
|
{/* Images */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Shop Photos</Label>
|
<Label>Shop Photos</Label>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useShop } from '@/hooks/useShops';
|
import { useShop } from '@/hooks/useShops';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
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 {
|
import {
|
||||||
Store,
|
Store,
|
||||||
Upload,
|
Upload,
|
||||||
|
|
@ -76,6 +77,7 @@ export default function EditShopPage() {
|
||||||
const [website, setWebsite] = useState('');
|
const [website, setWebsite] = useState('');
|
||||||
const [instagram, setInstagram] = useState('');
|
const [instagram, setInstagram] = useState('');
|
||||||
const [hours, setHours] = useState('');
|
const [hours, setHours] = useState('');
|
||||||
|
const [noffer, setNoffer] = useState('');
|
||||||
const [images, setImages] = useState<string[]>([]);
|
const [images, setImages] = useState<string[]>([]);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
@ -96,6 +98,7 @@ export default function EditShopPage() {
|
||||||
setWebsite(shop.website || '');
|
setWebsite(shop.website || '');
|
||||||
setInstagram(shop.instagram || '');
|
setInstagram(shop.instagram || '');
|
||||||
setHours(shop.hours || '');
|
setHours(shop.hours || '');
|
||||||
|
setNoffer(shop.noffer || '');
|
||||||
setImages(shop.images);
|
setImages(shop.images);
|
||||||
|
|
||||||
// Decode geohash to get coordinates
|
// Decode geohash to get coordinates
|
||||||
|
|
@ -251,6 +254,10 @@ export default function EditShopPage() {
|
||||||
tags.push(['hours', hours.trim()]);
|
tags.push(['hours', hours.trim()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isValidNoffer(noffer.trim())) {
|
||||||
|
tags.push(['noffer', noffer.trim().toLowerCase()]);
|
||||||
|
}
|
||||||
|
|
||||||
for (const imageUrl of images) {
|
for (const imageUrl of images) {
|
||||||
// Use imeta tag for NIP-92 compatibility
|
// Use imeta tag for NIP-92 compatibility
|
||||||
tags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
|
tags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
|
||||||
|
|
@ -501,6 +508,9 @@ export default function EditShopPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* CLINK self-custodial payments (optional) */}
|
||||||
|
<NofferField value={noffer} onChange={setNoffer} />
|
||||||
|
|
||||||
{/* Images */}
|
{/* Images */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Shop Photos</Label>
|
<Label>Shop Photos</Label>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
|
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||||
import { CommentsSection } from '@/components/comments/CommentsSection';
|
import { CommentsSection } from '@/components/comments/CommentsSection';
|
||||||
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
|
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
|
||||||
import { OfficialBadge } from '@/components/OfficialBadge';
|
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||||
|
|
@ -454,6 +455,17 @@ export default function ShopDetailPage() {
|
||||||
target={shop.event}
|
target={shop.event}
|
||||||
className="w-full justify-center bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue