fix(payments): round out profile CLINK and Lightning methods

This commit is contained in:
SkateSpots 2026-10-30 13:35:18 +07:00
parent 22eb46c62b
commit b39c2ec386
9 changed files with 62 additions and 22 deletions

View file

@ -28,6 +28,7 @@ import { LabDraftBanner } from '@/components/LabDraftBanner';
import { LabProfileProgress } from '@/components/LabProfileProgress'; import { LabProfileProgress } from '@/components/LabProfileProgress';
import { FormWizard, WizardStep } from '@/components/FormWizard'; import { FormWizard, WizardStep } from '@/components/FormWizard';
import { isValidNoffer, type EntityType } from '@/lib/types'; import { isValidNoffer, type EntityType } from '@/lib/types';
import { normalizeClinkCode } from '@/lib/clink';
import type { LabOption } from '@/lib/labExtras'; import type { LabOption } from '@/lib/labExtras';
import { labProfilePathFromKind } from '@/lib/labRoutes'; import { labProfilePathFromKind } from '@/lib/labRoutes';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl'; import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
@ -327,7 +328,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
if (website.trim()) tags.push(['website', normalizeUrlInput(website)]); if (website.trim()) tags.push(['website', normalizeUrlInput(website)]);
if (portfolio.trim()) tags.push(['portfolio', normalizeUrlInput(portfolio)]); if (portfolio.trim()) tags.push(['portfolio', normalizeUrlInput(portfolio)]);
if (social.trim()) tags.push(['social', normalizeUrlInput(social)]); if (social.trim()) tags.push(['social', normalizeUrlInput(social)]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]); if (isValidNoffer(noffer.trim())) tags.push(['noffer', normalizeClinkCode(noffer)]);
// Craft-specific extras // Craft-specific extras
for (const f of extraFields) { for (const f of extraFields) {

View file

@ -23,6 +23,7 @@ import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker'; import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
import { NofferField } from '@/components/NofferField'; import { NofferField } from '@/components/NofferField';
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, CREW_LOOKING_FOR_OPTIONS, CREW_ROLE_META, CREW_ROLE_ORDER, SHOW_CREW_TREASURY, isValidNoffer, type SkateCrew, type CrewFocus, type CrewRole } from '@/lib/types'; import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, CREW_LOOKING_FOR_OPTIONS, CREW_ROLE_META, CREW_ROLE_ORDER, SHOW_CREW_TREASURY, isValidNoffer, type SkateCrew, type CrewFocus, type CrewRole } from '@/lib/types';
import { normalizeClinkCode } from '@/lib/clink';
import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS, CREW_FOCUS_DESC_KEYS, CREW_LOOKING_FOR_KEYS, CREW_ROLE_KEYS } from '@/lib/labelKeys'; import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS, CREW_FOCUS_DESC_KEYS, CREW_LOOKING_FOR_KEYS, CREW_ROLE_KEYS } from '@/lib/labelKeys';
import { useCrewGoal } from '@/hooks/useCrewGoal'; import { useCrewGoal } from '@/hooks/useCrewGoal';
import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator'; import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator';
@ -571,7 +572,7 @@ export function CrewForm({ existing }: CrewFormProps) {
if (s.trim()) tags.push(['social', s.trim()]); if (s.trim()) tags.push(['social', s.trim()]);
} }
for (const handle of nip05s) tags.push(['nip05', handle]); for (const handle of nip05s) tags.push(['nip05', handle]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]); if (isValidNoffer(noffer.trim())) tags.push(['noffer', normalizeClinkCode(noffer)]);
// Members: admins get an `admin` role marker on their `p` tag. // Members: admins get an `admin` role marker on their `p` tag.
for (const pk of members) { for (const pk of members) {

View file

@ -30,6 +30,7 @@ import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
import { SkateNip05Input } from '@/components/SkateNip05Input'; import { SkateNip05Input } from '@/components/SkateNip05Input';
import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05'; import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05';
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types'; import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
import { normalizeClinkCode } from '@/lib/clink';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
export const EditProfileForm: React.FC = () => { export const EditProfileForm: React.FC = () => {
@ -52,7 +53,7 @@ export const EditProfileForm: React.FC = () => {
// Lightning address (LUD-16). Part of NostrMetadata but not in the default // Lightning address (LUD-16). Part of NostrMetadata but not in the default
// form fields, so we manage it here and merge it on save. // form fields, so we manage it here and merge it on save.
const [lud16, setLud16] = useState(''); const [lud16, setLud16] = useState('');
const lud16Trimmed = lud16.trim(); const lud16Trimmed = lud16.trim().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, '');
const lud16Invalid = lud16Trimmed.length > 0 && !isValidLightningAddress(lud16Trimmed); const lud16Invalid = lud16Trimmed.length > 0 && !isValidLightningAddress(lud16Trimmed);
// Bitcoin Silent Payment address (BIP-352). Custom kind-0 field. // Bitcoin Silent Payment address (BIP-352). Custom kind-0 field.
@ -224,7 +225,7 @@ export const EditProfileForm: React.FC = () => {
// Merge the custom CLINK offer code (or remove it when cleared). // Merge the custom CLINK offer code (or remove it when cleared).
if (nofferValid) { if (nofferValid) {
data.noffer = nofferTrimmed.toLowerCase(); data.noffer = normalizeClinkCode(nofferTrimmed);
} else { } else {
delete data.noffer; delete data.noffer;
} }
@ -493,6 +494,7 @@ export const EditProfileForm: React.FC = () => {
value={lud16} value={lud16}
onChange={setLud16} onChange={setLud16}
placeholder="you@yourdomain.com" placeholder="you@yourdomain.com"
inputMode="email"
status={ status={
lud16Invalid lud16Invalid
? { kind: 'error', text: t('settingsUi.editProfile.lud16Invalid') } ? { kind: 'error', text: t('settingsUi.editProfile.lud16Invalid') }
@ -713,7 +715,8 @@ const FieldRow: React.FC<{
onChange: (v: string) => void; onChange: (v: string) => void;
placeholder: string; placeholder: string;
status: { kind: 'hint' | 'ok' | 'error'; text: string }; status: { kind: 'hint' | 'ok' | 'error'; text: string };
}> = ({ id, icon, iconClass, label, value, onChange, placeholder, status }) => ( inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
}> = ({ id, icon, iconClass, label, value, onChange, placeholder, status, inputMode }) => (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={id} className="flex items-center gap-2 text-sm font-semibold"> <Label htmlFor={id} className="flex items-center gap-2 text-sm font-semibold">
<span className={`h-6 w-6 rounded-lg flex items-center justify-center ${iconClass}`}>{icon}</span> <span className={`h-6 w-6 rounded-lg flex items-center justify-center ${iconClass}`}>{icon}</span>
@ -726,6 +729,7 @@ const FieldRow: React.FC<{
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
spellCheck={false} spellCheck={false}
autoCapitalize="none" autoCapitalize="none"
inputMode={inputMode}
className="font-mono text-sm" className="font-mono text-sm"
/> />
{status.kind === 'error' ? ( {status.kind === 'error' ? (

View file

@ -26,7 +26,7 @@ export function isValidLightningAddress(address: string): boolean {
return false; return false;
} }
const trimmed = address.trim(); const trimmed = address.trim().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, '');
const parts = trimmed.split('@'); const parts = trimmed.split('@');
if (parts.length !== 2) { if (parts.length !== 2) {
return false; return false;
@ -60,7 +60,8 @@ export async function resolveLightningAddress(
throw new Error('Invalid Lightning address format. Expected: username@domain.com'); throw new Error('Invalid Lightning address format. Expected: username@domain.com');
} }
const [username, domain] = lightningAddress.split('@'); const normalized = lightningAddress.trim().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, '');
const [username, domain] = normalized.split('@');
const lnurlEndpoint = `https://${domain}/.well-known/lnurlp/${username}`; const lnurlEndpoint = `https://${domain}/.well-known/lnurlp/${username}`;
const response = await fetch(lnurlEndpoint, { const response = await fetch(lnurlEndpoint, {

View file

@ -587,7 +587,8 @@ export function isValidSilentPayment(value: string | undefined | null): value is
*/ */
export function isValidLightningAddress(value: string | undefined | null): value is string { export function isValidLightningAddress(value: string | undefined | null): value is string {
if (!value) return false; if (!value) return false;
return /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value.trim()); const normalized = value.trim().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, '');
return /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(normalized);
} }
// ─── Skater profile (kind 35000) ─────────────────────────────────────────── // ─── Skater profile (kind 35000) ───────────────────────────────────────────

View file

@ -15,6 +15,7 @@ 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, isValidNoffer } from '@/lib/types'; import { SKATE_SHOP_KIND, isValidNoffer } from '@/lib/types';
import { normalizeClinkCode } from '@/lib/clink';
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions'; import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
import { NofferField } from '@/components/NofferField'; import { NofferField } from '@/components/NofferField';
import { import {
@ -221,7 +222,7 @@ export default function CreateShopPage() {
} }
if (isValidNoffer(noffer.trim())) { if (isValidNoffer(noffer.trim())) {
tags.push(['noffer', noffer.trim().toLowerCase()]); tags.push(['noffer', normalizeClinkCode(noffer)]);
} }
for (const handle of nip05s) tags.push(['nip05', handle]); for (const handle of nip05s) tags.push(['nip05', handle]);

View file

@ -16,6 +16,7 @@ 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, isValidNoffer } from '@/lib/types'; import { SKATE_SHOP_KIND, isValidNoffer } from '@/lib/types';
import { normalizeClinkCode } from '@/lib/clink';
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions'; import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
import { NofferField } from '@/components/NofferField'; import { NofferField } from '@/components/NofferField';
import { import {
@ -266,7 +267,7 @@ export default function EditShopPage() {
} }
if (isValidNoffer(noffer.trim())) { if (isValidNoffer(noffer.trim())) {
tags.push(['noffer', noffer.trim().toLowerCase()]); tags.push(['noffer', normalizeClinkCode(noffer)]);
} }
for (const handle of nip05s) tags.push(['nip05', handle]); for (const handle of nip05s) tags.push(['nip05', handle]);

View file

@ -14,6 +14,7 @@ import { TrustFlaggedNotice } from '@/components/TrustFlaggedNotice';
import { TradeReputationCard } from '@/components/TradeReputationCard'; import { TradeReputationCard } from '@/components/TradeReputationCard';
import { ListingTradeActions } from '@/components/ListingTradeActions'; import { ListingTradeActions } from '@/components/ListingTradeActions';
import { ListingOffersPanel } from '@/components/ListingOffersPanel'; import { ListingOffersPanel } from '@/components/ListingOffersPanel';
import { ClinkPayButton } from '@/components/ClinkPayButton';
import { OfferDialog } from '@/components/OfferDialog'; import { OfferDialog } from '@/components/OfferDialog';
import { MarkSoldDialog } from '@/components/MarkSoldDialog'; import { MarkSoldDialog } from '@/components/MarkSoldDialog';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
@ -24,7 +25,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useSellerInfo } from '@/hooks/useSellerInfo'; import { useSellerInfo } from '@/hooks/useSellerInfo';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { formatPrice, GEAR_CONDITIONS, GEAR_CATEGORIES } from '@/lib/types'; import { formatPrice, GEAR_CONDITIONS, GEAR_CATEGORIES, isValidNoffer } from '@/lib/types';
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS } from '@/lib/labelKeys'; import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS } from '@/lib/labelKeys';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -123,6 +124,9 @@ export default function ListingDetailPage() {
: null; : null;
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : t('marketUi.listingDetail.unknown')); const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : t('marketUi.listingDetail.unknown'));
const npub = pubkey ? nip19.npubEncode(pubkey) : ''; const npub = pubkey ? nip19.npubEncode(pubkey) : '';
const sellerNoffer = typeof (metadata as Record<string, unknown> | undefined)?.noffer === 'string'
? ((metadata as Record<string, unknown>).noffer as string)
: undefined;
const lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing })); const lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing }));
@ -337,6 +341,13 @@ export default function ListingDetailPage() {
<Card className="bg-black/90 border-white/10"> <Card className="bg-black/90 border-white/10">
<CardContent className="p-4"> <CardContent className="p-4">
<ListingTradeActions item={item} /> <ListingTradeActions item={item} />
{isValidNoffer(sellerNoffer) && (
<ClinkPayButton
noffer={sellerNoffer}
recipientName={displayName}
className="mt-2 w-full justify-center"
/>
)}
{user && item.priceType !== 'fixed' && ( {user && item.priceType !== 'fixed' && (
<Button <Button
variant="outline" variant="outline"

View file

@ -33,7 +33,7 @@ import { EventCard } from '@/components/EventCard';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS, NEWSLETTER_KIND } from '@/lib/types'; import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS, NEWSLETTER_KIND, isValidNoffer, isValidLightningAddress } from '@/lib/types';
import { NewsletterIssueView } from '@/components/NewsletterIssueView'; import { NewsletterIssueView } from '@/components/NewsletterIssueView';
import NotFound from './NotFound'; import NotFound from './NotFound';
import { MapPin, Store, MessageCircle, Globe, Zap, ExternalLink, Copy, Check, ShieldCheck, ShieldAlert, Loader2, Instagram, Share2, Users, Crown, Bot, Pencil, Package, Tag, ArrowRightLeft, Bitcoin, Award, MessageSquareLock, PersonStanding, Sparkles, Home, Clock, Server, Plus, UserPlus, UserCheck, Handshake } from 'lucide-react'; import { MapPin, Store, MessageCircle, Globe, Zap, ExternalLink, Copy, Check, ShieldCheck, ShieldAlert, Loader2, Instagram, Share2, Users, Crown, Bot, Pencil, Package, Tag, ArrowRightLeft, Bitcoin, Award, MessageSquareLock, PersonStanding, Sparkles, Home, Clock, Server, Plus, UserPlus, UserCheck, Handshake } from 'lucide-react';
@ -150,6 +150,8 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
const raw = metadata as Record<string, unknown> | undefined; const raw = metadata as Record<string, unknown> | undefined;
const noffer = raw?.noffer; const noffer = raw?.noffer;
const nofferCode = typeof noffer === 'string' ? noffer : undefined; const nofferCode = typeof noffer === 'string' ? noffer : undefined;
const hasClink = isValidNoffer(nofferCode);
const hasLightning = isValidLightningAddress(lud16) || !!metadata?.lud06;
// Extra custom profile fields. // Extra custom profile fields.
const silentPayment = typeof raw?.bitcoin_silent_payment === 'string' ? raw.bitcoin_silent_payment : undefined; const silentPayment = typeof raw?.bitcoin_silent_payment === 'string' ? raw.bitcoin_silent_payment : undefined;
const userBadges = parseBadges(metadata); const userBadges = parseBadges(metadata);
@ -160,6 +162,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
// one-tap "Add" instead of just a link. // one-tap "Add" instead of just a link.
const cordnPointer = cordnUrl ? parseCoordinatorInput(cordnUrl) : null; const cordnPointer = cordnUrl ? parseCoordinatorInput(cordnUrl) : null;
const [spCopied, setSpCopied] = useState(false); const [spCopied, setSpCopied] = useState(false);
const [lud16Copied, setLud16Copied] = useState(false);
const [shareOpen, setShareOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false);
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
@ -892,14 +895,30 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
); );
})()} })()}
{lud16 && ( {hasLightning && lud16 && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-gray-900"> <button
<Zap className="h-4 w-4 text-yellow-400 flex-shrink-0" /> type="button"
<div className="min-w-0"> onClick={() => {
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">{t('walletUi.profile.lightning')}</p> void navigator.clipboard.writeText(lud16).then(() => {
<span className="text-gray-300 text-sm truncate block">{lud16}</span> setLud16Copied(true);
setTimeout(() => setLud16Copied(false), 2000);
}).catch(() => undefined);
}}
className="flex w-full items-center gap-3 rounded-lg bg-gray-900 p-3 text-left transition-colors hover:bg-gray-800"
>
<Zap className="h-4 w-4 flex-shrink-0 text-yellow-400" />
<div className="min-w-0 flex-1">
<p className="text-[10px] font-bold uppercase tracking-wide text-gray-500">
{t('walletUi.profile.lightning')} {lud16Copied && <span className="text-green-400">· {t('common.copied')}</span>}
</p>
<span className="block truncate text-sm text-gray-300">{lud16}</span>
</div> </div>
</div> {lud16Copied ? (
<Check className="h-3.5 w-3.5 flex-shrink-0 text-green-400" />
) : (
<Copy className="h-3.5 w-3.5 flex-shrink-0 text-gray-500" />
)}
</button>
)} )}
{/* Bitcoin Silent Payment address — copyable */} {/* Bitcoin Silent Payment address — copyable */}
@ -1052,7 +1071,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
)} )}
{/* Tip Card */} {/* Tip Card */}
{!isOwnProfile && (profileEvent || nofferCode) && ( {!isOwnProfile && (hasLightning || hasClink) && (
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-transparent border-2 border-[#ccff00]/30"> <Card className="bg-gradient-to-br from-[#ccff00]/10 to-transparent border-2 border-[#ccff00]/30">
<CardContent className="p-4 text-center"> <CardContent className="p-4 text-center">
<Zap className="h-8 w-8 mx-auto mb-2 text-[#ccff00]" /> <Zap className="h-8 w-8 mx-auto mb-2 text-[#ccff00]" />
@ -1061,13 +1080,13 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
{t('walletUi.profile.supportSkaterDesc')} {t('walletUi.profile.supportSkaterDesc')}
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{profileEvent && ( {profileEvent && hasLightning && (
<ZapButton <ZapButton
target={profileEvent} target={profileEvent}
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"
/> />
)} )}
{nofferCode && ( {hasClink && nofferCode && (
<ClinkPayButton <ClinkPayButton
noffer={nofferCode} noffer={nofferCode}
recipientName={displayName} recipientName={displayName}