Add declared payment methods, PWYC and a direct booking fee for coaches

Coaching payments, kept honest and custody-free:

- payment_method (cash/lightning/cashu/bank/paypal/other) + payment_note:
  a declaration of what the coach accepts, not an integration. 'Cash on site'
  lets a walletless coach list without being nagged.
- pay_what_you_can flag (sliding scale).
- booking_fee + booking_fee_currency: a small fee to confirm a booking, paid
  DIRECTLY to the coach's kind-0 lud16 via the existing PayWithSats rail
  (mounted only while the sheet is open). Only offered for sats amounts when
  the coach has a Lightning address; copy states plainly that SkateSpots does
  not hold or refund it.

Deliberately NOT added: fiat/card rails (no KYC/custody) and a refundable
deposit (needs the native-only, unverified Cashu escrow). NIP.md documents the
model; AGENTS.md records the decisions.
This commit is contained in:
SkateSpots 2026-10-04 10:27:53 +07:00
parent fa0b9cfe1c
commit 080a6edf8b
8 changed files with 258 additions and 3 deletions

View file

@ -86,6 +86,16 @@ session with **no other memory of this project**. So:
a neutral avatar card (it was a red error-looking box), and chips use
**`min-h-0`** — the global mobile `button { min-height:44px }` rule turned
every chip into a 44px slab otherwise.
**Payments (2026-10-04):** coaches **declare** accepted methods
(`payment_method`: cash/lightning/cashu/bank/paypal/other) + an optional
`payment_note`, a `pay_what_you_can` flag, and an optional `booking_fee`
(+`booking_fee_currency`). A booking fee is **paid directly to the coach's
kind-0 `lud16`** via the existing `PayWithSats` (NWC→WebLN→Cashu) — mounted
only while open — and is **not escrowed/refundable through us** (copy says so).
In-app payment is offered only for `sats` when the coach has a Lightning
address. Fiat/card and a refundable deposit are deliberately **out of scope**
(no custody/KYC; escrow stays native-only/unverified). No tag named `payment`
— that's the marketplace offer pointer.
- **Photos**: `PhotoDropzone` (`src/components/PhotoDropzone.tsx`) is the shared
image input — **drag & drop + multi-select**, emits `File[]` to its caller
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and

18
NIP.md
View file

@ -2072,6 +2072,11 @@ client-side.
| `rate_amount` | number | Optional structured rate amount (with `rate_currency` + `rate_unit`) |
| `rate_currency` | `sats`, `USD`, `EUR`, `GBP` | Currency for `rate_amount` |
| `rate_unit` | `hour`, `session`, `half-day`, `day`, `camp` | Billing unit for `rate_amount` |
| `payment_method` | `cash`, `lightning`, `cashu`, `bank`, `paypal`, `other` | Repeatable — declared accepted payment methods |
| `payment_note` | free-form | Optional note about payment (e.g. "first session free") |
| `booking_fee` | number | Optional fee to confirm a booking (with `booking_fee_currency`) |
| `booking_fee_currency` | `sats`, `USD`, `EUR`, `GBP` | Currency for `booking_fee` |
| `pay_what_you_can` | `"true"` | Sliding-scale / pay-what-you-can pricing |
The legacy freeform `rate` tag is still supported and shown when no structured
rate is present; when both exist, `rate` is treated as a note beneath the price.
@ -2081,6 +2086,19 @@ ages, credentials, availability, languages), populated only from values present
in the loaded profiles. Filtering is client-side, consistent with the tags not
being relay-indexed.
#### Payment methods & booking fee
`payment_method` is a **declaration, not an integration** — it just tells a
client how the coach accepts payment (including `cash` on site). The app never
holds funds, never collects fiat/KYC, and never signs a payment event.
A `booking_fee` is a small amount **paid directly to the coach** (their kind-0
`lud16` Lightning address) to confirm a booking; in-app payment is offered only
when the amount is in `sats` and the coach has a Lightning address. It is **not
escrowed and not refundable through SkateSpots** — clients must say so plainly.
A genuinely refundable deposit would require the Cashu escrow rail (native-only,
unverified), which is deliberately **not** wired into coach bookings.
> **Trust note:** `cert` tags are **self-attested**. SkateSpots does not verify
> them — only a verified NIP-05 domain (or a NIP-32 moderation verdict) is a real
> signal. Clients must label credentials as self-attested, never as verified.

View file

@ -2233,6 +2233,19 @@ labUi: {
perCamp: 'per camp',
rateNotesLabel: 'Rate notes (optional)',
rateNotesPlaceholder: 'Sliding scale, first session free, travel included…',
sectionPayments: 'Payments',
paymentMethodsHint: 'How clients can pay you. This is a statement — SkateSpots never holds funds.',
payMethodCash: 'Cash on site',
payMethodLightning: 'Lightning',
payMethodCashu: 'Cashu',
payMethodBank: 'Bank transfer',
payMethodPaypal: 'PayPal',
payMethodOther: 'Other',
payWhatYouCanLabel: 'Sliding scale / pay what you can',
bookingFeeLabel: 'Booking fee (optional)',
bookingFeeHint: 'A small fee paid directly to you to confirm a booking. SkateSpots does not hold or refund it.',
paymentNoteLabel: 'Payment note (optional)',
paymentNotePlaceholder: 'First session free, travel included, pay after the session…',
spotsDesc: 'Select spots where you offer coaching sessions',
spotsLabel: 'Spots Where You Coach',
tagline: 'Share your skateboarding knowledge',
@ -2546,6 +2559,12 @@ labUi: {
bookSession: 'Book a session',
portfolio: 'Portfolio',
watchReel: 'Watch on {{platform}}',
paymentsTitle: 'Payments',
payWhatYouCan: 'Pay what you can',
bookingFee: 'Booking fee',
payBookingFee: 'Pay booking fee',
bookingFeeNote: 'Paid directly to the coach. SkateSpots doesn’t hold or refund it.',
bookingFeeNoRail: 'This coach has no Lightning address set, so in-app payment isn’t available — contact them to arrange payment.',
serviceArea: 'Service Area',
licensedInsured: 'Licensed / Insured',
studio: 'Studio',

View file

@ -2233,6 +2233,19 @@ labUi: {
perCamp: 'por campus',
rateNotesLabel: 'Notas sobre la tarifa (opcional)',
rateNotesPlaceholder: 'Tarifa social, primera clase gratis, desplazamiento incluido…',
sectionPayments: 'Pagos',
paymentMethodsHint: 'Cómo pueden pagarte. Es una declaración — SkateSpots nunca retiene fondos.',
payMethodCash: 'Efectivo en el sitio',
payMethodLightning: 'Lightning',
payMethodCashu: 'Cashu',
payMethodBank: 'Transferencia bancaria',
payMethodPaypal: 'PayPal',
payMethodOther: 'Otro',
payWhatYouCanLabel: 'Tarifa social / paga lo que puedas',
bookingFeeLabel: 'Reserva (opcional)',
bookingFeeHint: 'Una pequeña cantidad pagada directamente a ti para confirmar la reserva. SkateSpots no la retiene ni la reembolsa.',
paymentNoteLabel: 'Nota de pago (opcional)',
paymentNotePlaceholder: 'Primera sesión gratis, desplazamiento incluido, pago después de la sesión…',
spotsDesc: 'Selecciona los spots donde ofreces clases',
spotsLabel: 'Spots donde entrenas',
tagline: 'Comparte tus conocimientos de skate',
@ -2546,6 +2559,12 @@ labUi: {
bookSession: 'Reservar una sesión',
portfolio: 'Portfolio',
watchReel: 'Ver en {{platform}}',
paymentsTitle: 'Pagos',
payWhatYouCan: 'Paga lo que puedas',
bookingFee: 'Reserva',
payBookingFee: 'Pagar reserva',
bookingFeeNote: 'Se paga directamente al entrenador. SkateSpots no lo retiene ni lo reembolsa.',
bookingFeeNoRail: 'Este entrenador no tiene dirección Lightning, así que el pago in-app no está disponible — contacta para acordar el pago.',
serviceArea: 'Zona de servicio',
licensedInsured: 'Con licencia / asegurado',
studio: 'Estudio',

View file

@ -100,3 +100,16 @@ export function coachRateUnitKey(unit: string | undefined): string | undefined {
export function coachRateCurrencyKey(currency: string | undefined): string | undefined {
return COACH_RATE_CURRENCIES.find((c) => c.value === currency)?.key;
}
/**
* Accepted payment methods a coach can *declare* (a statement, not an
* integration — the app never holds funds). `cash` covers settling in person.
*/
export const COACH_PAYMENT_METHODS: CoachOption[] = [
{ value: 'cash', key: 'labUi.coachForm.payMethodCash' },
{ value: 'lightning', key: 'labUi.coachForm.payMethodLightning' },
{ value: 'cashu', key: 'labUi.coachForm.payMethodCashu' },
{ value: 'bank', key: 'labUi.coachForm.payMethodBank' },
{ value: 'paypal', key: 'labUi.coachForm.payMethodPaypal' },
{ value: 'other', key: 'labUi.coachForm.payMethodOther' },
];

View file

@ -2359,6 +2359,16 @@ export interface SkateCoach {
rateCurrency?: string;
/** Structured rate billing unit (`hour` / `session` / …). */
rateUnit?: string;
/** Declared accepted payment methods (`cash` / `lightning` / `cashu` / …). */
paymentMethods: string[];
/** Optional free-form payment note. */
paymentNote?: string;
/** Optional booking fee amount (paid directly to the coach — never held by us). */
bookingFee?: string;
/** Currency for `bookingFee`. */
bookingFeeCurrency?: string;
/** Sliding scale / pay-what-you-can. */
payWhatYouCan?: boolean;
phone?: string;
email?: string;
website?: string;
@ -2699,6 +2709,11 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
const rateAmount = event.tags.find(([name]) => name === 'rate_amount')?.[1];
const rateCurrency = event.tags.find(([name]) => name === 'rate_currency')?.[1];
const rateUnit = event.tags.find(([name]) => name === 'rate_unit')?.[1];
const paymentMethods = values('payment_method');
const paymentNote = event.tags.find(([name]) => name === 'payment_note')?.[1];
const bookingFee = event.tags.find(([name]) => name === 'booking_fee')?.[1];
const bookingFeeCurrency = event.tags.find(([name]) => name === 'booking_fee_currency')?.[1];
const payWhatYouCan = event.tags.some(([name, value]) => name === 'pay_what_you_can' && value === 'true');
const experience = event.tags.find(([name]) => name === 'experience')?.[1];
const rate = event.tags.find(([name]) => name === 'rate')?.[1];
const phone = event.tags.find(([name]) => name === 'phone')?.[1];
@ -2746,6 +2761,11 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
rateAmount,
rateCurrency,
rateUnit,
paymentMethods,
paymentNote,
bookingFee,
bookingFeeCurrency,
payWhatYouCan,
crewRef: parseCrewRef(event),
featuredUntil: parseFeaturedUntil(event),
createdAt: event.created_at,

View file

@ -26,11 +26,11 @@ import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
import { COACH_SKILL_KEYS, COACH_SKILL_DESC_KEYS } from '@/lib/labelKeys';
import { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, COACH_AVAILABILITY, COACH_RATE_CURRENCIES, COACH_RATE_UNITS, type CoachOption } from '@/lib/coachOptions';
import { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, COACH_AVAILABILITY, COACH_RATE_CURRENCIES, COACH_RATE_UNITS, COACH_PAYMENT_METHODS, type CoachOption } from '@/lib/coachOptions';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Check } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
@ -126,6 +126,11 @@ export default function BecomeCoachPage() {
const [rateAmount, setRateAmount] = useState('');
const [rateCurrency, setRateCurrency] = useState<string>('sats');
const [rateUnit, setRateUnit] = useState<string>('hour');
const [paymentMethods, setPaymentMethods] = useState<string[]>([]);
const [paymentNote, setPaymentNote] = useState('');
const [bookingFee, setBookingFee] = useState('');
const [bookingFeeCurrency, setBookingFeeCurrency] = useState<string>('sats');
const [payWhatYouCan, setPayWhatYouCan] = useState(false);
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [website, setWebsite] = useState('');
@ -168,6 +173,11 @@ export default function BecomeCoachPage() {
setRateAmount(tag('rate_amount') ?? '');
setRateCurrency(tag('rate_currency') ?? 'sats');
setRateUnit(tag('rate_unit') ?? 'hour');
setPaymentMethods(tags('payment_method'));
setPaymentNote(tag('payment_note') ?? '');
setBookingFee(tag('booking_fee') ?? '');
setBookingFeeCurrency(tag('booking_fee_currency') ?? 'sats');
setPayWhatYouCan(existingProfile.tags.some(([t, v]) => t === 'pay_what_you_can' && v === 'true'));
setPhone(tag('phone') ?? '');
setEmail(tag('email') ?? '');
setWebsite(tag('website') ?? '');
@ -333,6 +343,13 @@ export default function BecomeCoachPage() {
tags.push(['rate_currency', rateCurrency]);
tags.push(['rate_unit', rateUnit]);
}
for (const m of paymentMethods) tags.push(['payment_method', m]);
if (paymentNote.trim()) tags.push(['payment_note', paymentNote.trim()]);
if (bookingFee.trim()) {
tags.push(['booking_fee', bookingFee.trim()]);
tags.push(['booking_fee_currency', bookingFeeCurrency]);
}
if (payWhatYouCan) tags.push(['pay_what_you_can', 'true']);
if (phone.trim()) tags.push(['phone', phone.trim()]);
if (email.trim()) tags.push(['email', email.trim()]);
if (website.trim()) tags.push(['website', normalizeUrlInput(website)]);
@ -703,6 +720,64 @@ export default function BecomeCoachPage() {
</div>
</div>
<FormSection title={t('labUi.coachForm.sectionPayments')} />
<OptionChips
label=""
hint={t('labUi.coachForm.paymentMethodsHint')}
options={COACH_PAYMENT_METHODS}
selected={paymentMethods}
onToggle={(v) => toggleValue(paymentMethods, setPaymentMethods, v)}
idPrefix="paymethod"
/>
<button
type="button"
onClick={() => setPayWhatYouCan((v) => !v)}
aria-pressed={payWhatYouCan}
className={cn(
'flex w-full min-h-0 items-center gap-2 rounded-lg border px-3 py-2.5 text-left text-sm font-bold transition-colors',
payWhatYouCan
? 'border-red-500 bg-red-500/10 text-red-300'
: 'border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700',
)}
>
<span className={cn('flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border', payWhatYouCan ? 'border-red-400 bg-red-500 text-white' : 'border-gray-600')}>
{payWhatYouCan && <Check className="h-3 w-3" />}
</span>
{t('labUi.coachForm.payWhatYouCanLabel')}
</button>
<div className="space-y-3">
<Label>{t('labUi.coachForm.bookingFeeLabel')}</Label>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
inputMode="numeric"
placeholder="2000"
value={bookingFee}
onChange={(e) => setBookingFee(e.target.value.replace(/[^0-9.]/g, ''))}
className="bg-gray-800 border-gray-700"
/>
<select
value={bookingFeeCurrency}
onChange={(e) => setBookingFeeCurrency(e.target.value)}
aria-label={t('labUi.coachForm.bookingFeeLabel')}
className="h-10 min-w-0 rounded-md border border-gray-700 bg-gray-800 px-2 text-sm text-white"
>
{COACH_RATE_CURRENCIES.map((c) => (
<option key={c.value} value={c.value}>{t(c.key)}</option>
))}
</select>
</div>
<p className="text-xs text-gray-500">{t('labUi.coachForm.bookingFeeHint')}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="payment-note" className="text-xs font-normal text-gray-400">{t('labUi.coachForm.paymentNoteLabel')}</Label>
<Input id="payment-note" placeholder={t('labUi.coachForm.paymentNotePlaceholder')} value={paymentNote} onChange={(e) => setPaymentNote(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<FormSection title={t('labUi.coachForm.sectionWhereYouCoach')} />
<div className="space-y-2">

View file

@ -41,7 +41,8 @@ import {
ORGANIZER_EVENT_KEYS, ORGANIZER_SERVICE_KEYS, ORGANIZER_SCALE_KEYS,
BARBER_SERVICE_KEYS, BARBER_SERVICE_DESC_KEYS, BARBER_STYLE_KEYS, BARBER_SETTING_KEYS,
} from '@/lib/labelKeys';
import { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, COACH_AVAILABILITY, coachOptionKey, coachRateCurrencyKey, coachRateUnitKey, type CoachOption } from '@/lib/coachOptions';
import { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, COACH_AVAILABILITY, COACH_PAYMENT_METHODS, coachOptionKey, coachRateCurrencyKey, coachRateUnitKey, type CoachOption } from '@/lib/coachOptions';
import { PayWithSats } from '@/components/PayWithSats';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { OfficialBadge } from '@/components/OfficialBadge';
@ -77,6 +78,8 @@ import {
Images,
Clapperboard,
ExternalLink,
Banknote,
Zap,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
@ -117,6 +120,7 @@ export default function CoachDetailPage() {
const { user } = useCurrentUser();
const navigate = useNavigate();
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [payOpen, setPayOpen] = useState(false);
// Gigs — only relevant for musicians. The hook is disabled for other types.
const { data: gigs = [] } = useGigs(
@ -208,6 +212,18 @@ export default function CoachDetailPage() {
? `${rateAmount} ${rateCurrencyKey ? t(rateCurrencyKey) : (pget('rateCurrency') ?? '')}${rateUnitKey ? ` · ${t(rateUnitKey)}` : ''}`.trim()
: pget('rate');
// Declared payment methods + optional booking fee (payable only in sats, and
// only when the coach has a Lightning address to receive it).
const paymentMethods = parr('paymentMethods');
const bookingFee = pget('bookingFee');
const bookingFeeCurrency = pget('bookingFeeCurrency') ?? 'sats';
const bookingFeeCurrencyKey = coachRateCurrencyKey(bookingFeeCurrency);
const bookingFeeDisplay = bookingFee
? `${bookingFee} ${bookingFeeCurrencyKey ? t(bookingFeeCurrencyKey) : bookingFeeCurrency}`.trim()
: undefined;
const bookingFeeSats = bookingFee && bookingFeeCurrency === 'sats' ? parseInt(bookingFee, 10) : NaN;
const canPayBookingFee = Number.isFinite(bookingFeeSats) && bookingFeeSats > 0 && !!author.data?.metadata?.lud16?.trim();
const TYPE_ICONS: Record<string, typeof GraduationCap> = {
coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors,
artist: Palette, graffiti: SprayCan, tattoo: Brush, trainer: Dumbbell, musician: Music, judge: Gavel,
@ -546,6 +562,71 @@ export default function CoachDetailPage() {
</div>
)}
{/* Payments — declared methods + optional direct booking fee */}
{isCoach && (paymentMethods.length > 0 || pbool('payWhatYouCan') || bookingFee || pget('paymentNote')) && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3 flex items-center gap-2">
<Banknote className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.paymentsTitle')}
</h3>
{(paymentMethods.length > 0 || pbool('payWhatYouCan')) && (
<div className="flex flex-wrap gap-2">
{paymentMethods.map((m) => {
const key = coachOptionKey(COACH_PAYMENT_METHODS, m);
return (
<span key={m} className={`inline-flex items-center gap-1.5 text-xs font-bold px-3 py-1.5 rounded-full border ${accent.border} text-gray-200`}>
{key ? t(key) : m}
</span>
);
})}
{pbool('payWhatYouCan') && (
<span className={`inline-flex items-center gap-1.5 text-xs font-bold px-3 py-1.5 rounded-full border ${accent.border} text-gray-200`}>
{t('labUi.detail.payWhatYouCan')}
</span>
)}
</div>
)}
{pget('paymentNote') && (
<p className="mt-2 text-sm text-gray-400">{pget('paymentNote')}</p>
)}
{bookingFee && (
<div className="mt-3 rounded-lg border border-white/10 bg-black/40 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-bold uppercase tracking-wider text-gray-500">{t('labUi.detail.bookingFee')}</p>
<p className="text-sm font-bold text-white">{bookingFeeDisplay}</p>
</div>
{canPayBookingFee && user?.pubkey !== profile.pubkey && (
<Button
size="sm"
onClick={() => setPayOpen(true)}
className={`gap-1.5 ${accent.bg} text-white font-bold hover:opacity-90`}
>
<Zap className="h-3.5 w-3.5" />
{t('labUi.detail.payBookingFee')}
</Button>
)}
</div>
<p className="mt-1.5 text-[11px] leading-relaxed text-gray-500">{t('labUi.detail.bookingFeeNote')}</p>
{!canPayBookingFee && (
<p className="mt-1 text-[11px] text-gray-500">{t('labUi.detail.bookingFeeNoRail')}</p>
)}
</div>
)}
{payOpen && canPayBookingFee && (
<PayWithSats
open={payOpen}
onOpenChange={setPayOpen}
sellerPubkey={profile.pubkey}
amountSats={bookingFeeSats}
/>
)}
</div>
)}
{/* In the Bag — photographer / filmer gear (freeform) */}
{(isPhotographer || isFilmer || isBuilder) && pget('gear') && (
<div className="mb-6">