fix(payments): round out profile CLINK and Lightning methods
This commit is contained in:
parent
22eb46c62b
commit
b39c2ec386
9 changed files with 62 additions and 22 deletions
|
|
@ -28,6 +28,7 @@ import { LabDraftBanner } from '@/components/LabDraftBanner';
|
|||
import { LabProfileProgress } from '@/components/LabProfileProgress';
|
||||
import { FormWizard, WizardStep } from '@/components/FormWizard';
|
||||
import { isValidNoffer, type EntityType } from '@/lib/types';
|
||||
import { normalizeClinkCode } from '@/lib/clink';
|
||||
import type { LabOption } from '@/lib/labExtras';
|
||||
import { labProfilePathFromKind } from '@/lib/labRoutes';
|
||||
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 (portfolio.trim()) tags.push(['portfolio', normalizeUrlInput(portfolio)]);
|
||||
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
|
||||
for (const f of extraFields) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
|
|||
import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
|
||||
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 { 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 { useCrewGoal } from '@/hooks/useCrewGoal';
|
||||
import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator';
|
||||
|
|
@ -571,7 +572,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
if (s.trim()) tags.push(['social', s.trim()]);
|
||||
}
|
||||
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.
|
||||
for (const pk of members) {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
|
|||
import { SkateNip05Input } from '@/components/SkateNip05Input';
|
||||
import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05';
|
||||
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
|
||||
import { normalizeClinkCode } from '@/lib/clink';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
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
|
||||
// form fields, so we manage it here and merge it on save.
|
||||
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);
|
||||
|
||||
// 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).
|
||||
if (nofferValid) {
|
||||
data.noffer = nofferTrimmed.toLowerCase();
|
||||
data.noffer = normalizeClinkCode(nofferTrimmed);
|
||||
} else {
|
||||
delete data.noffer;
|
||||
}
|
||||
|
|
@ -493,6 +494,7 @@ export const EditProfileForm: React.FC = () => {
|
|||
value={lud16}
|
||||
onChange={setLud16}
|
||||
placeholder="you@yourdomain.com"
|
||||
inputMode="email"
|
||||
status={
|
||||
lud16Invalid
|
||||
? { kind: 'error', text: t('settingsUi.editProfile.lud16Invalid') }
|
||||
|
|
@ -713,7 +715,8 @@ const FieldRow: React.FC<{
|
|||
onChange: (v: string) => void;
|
||||
placeholder: 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">
|
||||
<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>
|
||||
|
|
@ -726,6 +729,7 @@ const FieldRow: React.FC<{
|
|||
onChange={(e) => onChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
inputMode={inputMode}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{status.kind === 'error' ? (
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export function isValidLightningAddress(address: string): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
const trimmed = address.trim();
|
||||
const trimmed = address.trim().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, '');
|
||||
const parts = trimmed.split('@');
|
||||
if (parts.length !== 2) {
|
||||
return false;
|
||||
|
|
@ -60,7 +60,8 @@ export async function resolveLightningAddress(
|
|||
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 response = await fetch(lnurlEndpoint, {
|
||||
|
|
|
|||
|
|
@ -587,7 +587,8 @@ export function isValidSilentPayment(value: string | undefined | null): value is
|
|||
*/
|
||||
export function isValidLightningAddress(value: string | undefined | null): value is string {
|
||||
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) ───────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { useToast } from '@/hooks/useToast';
|
|||
import { ShopBountyCard } from '@/components/ShopBountyCard';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { SKATE_SHOP_KIND, isValidNoffer } from '@/lib/types';
|
||||
import { normalizeClinkCode } from '@/lib/clink';
|
||||
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
|
||||
import { NofferField } from '@/components/NofferField';
|
||||
import {
|
||||
|
|
@ -221,7 +222,7 @@ export default function CreateShopPage() {
|
|||
}
|
||||
|
||||
if (isValidNoffer(noffer.trim())) {
|
||||
tags.push(['noffer', noffer.trim().toLowerCase()]);
|
||||
tags.push(['noffer', normalizeClinkCode(noffer)]);
|
||||
}
|
||||
|
||||
for (const handle of nip05s) tags.push(['nip05', handle]);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { useToast } from '@/hooks/useToast';
|
|||
import { useShop } from '@/hooks/useShops';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { SKATE_SHOP_KIND, isValidNoffer } from '@/lib/types';
|
||||
import { normalizeClinkCode } from '@/lib/clink';
|
||||
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
|
||||
import { NofferField } from '@/components/NofferField';
|
||||
import {
|
||||
|
|
@ -266,7 +267,7 @@ export default function EditShopPage() {
|
|||
}
|
||||
|
||||
if (isValidNoffer(noffer.trim())) {
|
||||
tags.push(['noffer', noffer.trim().toLowerCase()]);
|
||||
tags.push(['noffer', normalizeClinkCode(noffer)]);
|
||||
}
|
||||
|
||||
for (const handle of nip05s) tags.push(['nip05', handle]);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { TrustFlaggedNotice } from '@/components/TrustFlaggedNotice';
|
|||
import { TradeReputationCard } from '@/components/TradeReputationCard';
|
||||
import { ListingTradeActions } from '@/components/ListingTradeActions';
|
||||
import { ListingOffersPanel } from '@/components/ListingOffersPanel';
|
||||
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||
import { OfferDialog } from '@/components/OfferDialog';
|
||||
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||
|
|
@ -24,7 +25,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
|||
import { useSellerInfo } from '@/hooks/useSellerInfo';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
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 { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -123,6 +124,9 @@ export default function ListingDetailPage() {
|
|||
: null;
|
||||
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : t('marketUi.listingDetail.unknown'));
|
||||
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 }));
|
||||
|
||||
|
|
@ -337,6 +341,13 @@ export default function ListingDetailPage() {
|
|||
<Card className="bg-black/90 border-white/10">
|
||||
<CardContent className="p-4">
|
||||
<ListingTradeActions item={item} />
|
||||
{isValidNoffer(sellerNoffer) && (
|
||||
<ClinkPayButton
|
||||
noffer={sellerNoffer}
|
||||
recipientName={displayName}
|
||||
className="mt-2 w-full justify-center"
|
||||
/>
|
||||
)}
|
||||
{user && item.priceType !== 'fixed' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { EventCard } from '@/components/EventCard';
|
|||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
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 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';
|
||||
|
|
@ -150,6 +150,8 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
const raw = metadata as Record<string, unknown> | undefined;
|
||||
const noffer = raw?.noffer;
|
||||
const nofferCode = typeof noffer === 'string' ? noffer : undefined;
|
||||
const hasClink = isValidNoffer(nofferCode);
|
||||
const hasLightning = isValidLightningAddress(lud16) || !!metadata?.lud06;
|
||||
// Extra custom profile fields.
|
||||
const silentPayment = typeof raw?.bitcoin_silent_payment === 'string' ? raw.bitcoin_silent_payment : undefined;
|
||||
const userBadges = parseBadges(metadata);
|
||||
|
|
@ -160,6 +162,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
// one-tap "Add" instead of just a link.
|
||||
const cordnPointer = cordnUrl ? parseCoordinatorInput(cordnUrl) : null;
|
||||
const [spCopied, setSpCopied] = useState(false);
|
||||
const [lud16Copied, setLud16Copied] = useState(false);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
|
@ -892,14 +895,30 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
);
|
||||
})()}
|
||||
|
||||
{lud16 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-gray-900">
|
||||
<Zap className="h-4 w-4 text-yellow-400 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">{t('walletUi.profile.lightning')}</p>
|
||||
<span className="text-gray-300 text-sm truncate block">{lud16}</span>
|
||||
{hasLightning && lud16 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(lud16).then(() => {
|
||||
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>
|
||||
{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 */}
|
||||
|
|
@ -1052,7 +1071,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
)}
|
||||
|
||||
{/* Tip Card */}
|
||||
{!isOwnProfile && (profileEvent || nofferCode) && (
|
||||
{!isOwnProfile && (hasLightning || hasClink) && (
|
||||
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-transparent border-2 border-[#ccff00]/30">
|
||||
<CardContent className="p-4 text-center">
|
||||
<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')}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{profileEvent && (
|
||||
{profileEvent && hasLightning && (
|
||||
<ZapButton
|
||||
target={profileEvent}
|
||||
className="w-full justify-center bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
|
||||
/>
|
||||
)}
|
||||
{nofferCode && (
|
||||
{hasClink && nofferCode && (
|
||||
<ClinkPayButton
|
||||
noffer={nofferCode}
|
||||
recipientName={displayName}
|
||||
|
|
|
|||
Loading…
Reference in a new issue