From 080a6edf8b133a2d3b2ace7159cba1f10fba7c2e Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 4 Oct 2026 10:27:53 +0700 Subject: [PATCH] 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. --- AGENTS.md | 10 +++++ NIP.md | 18 ++++++++ src/i18n/en.ts | 19 ++++++++ src/i18n/es.ts | 19 ++++++++ src/lib/coachOptions.ts | 13 ++++++ src/lib/types.ts | 20 +++++++++ src/pages/BecomeCoachPage.tsx | 79 ++++++++++++++++++++++++++++++++- src/pages/CoachDetailPage.tsx | 83 ++++++++++++++++++++++++++++++++++- 8 files changed, 258 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4cba25f..cc54092 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/NIP.md b/NIP.md index 2ab736d..4f5e3aa 100644 --- a/NIP.md +++ b/NIP.md @@ -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. diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 8175c67..2b9ccc2 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 7293383..5d67de6 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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', diff --git a/src/lib/coachOptions.ts b/src/lib/coachOptions.ts index 5768321..56190e2 100644 --- a/src/lib/coachOptions.ts +++ b/src/lib/coachOptions.ts @@ -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' }, +]; diff --git a/src/lib/types.ts b/src/lib/types.ts index ed26f8b..c1eb62a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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, diff --git a/src/pages/BecomeCoachPage.tsx b/src/pages/BecomeCoachPage.tsx index ec2a308..9260c00 100644 --- a/src/pages/BecomeCoachPage.tsx +++ b/src/pages/BecomeCoachPage.tsx @@ -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('sats'); const [rateUnit, setRateUnit] = useState('hour'); + const [paymentMethods, setPaymentMethods] = useState([]); + const [paymentNote, setPaymentNote] = useState(''); + const [bookingFee, setBookingFee] = useState(''); + const [bookingFeeCurrency, setBookingFeeCurrency] = useState('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() { + + + toggleValue(paymentMethods, setPaymentMethods, v)} + idPrefix="paymethod" + /> + + + +
+ +
+ setBookingFee(e.target.value.replace(/[^0-9.]/g, ''))} + className="bg-gray-800 border-gray-700" + /> + +
+

{t('labUi.coachForm.bookingFeeHint')}

+
+ +
+ + setPaymentNote(e.target.value)} className="bg-gray-800 border-gray-700" /> +
+
diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index 379e0bf..4c7403a 100644 --- a/src/pages/CoachDetailPage.tsx +++ b/src/pages/CoachDetailPage.tsx @@ -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(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 = { 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() {
)} + {/* Payments — declared methods + optional direct booking fee */} + {isCoach && (paymentMethods.length > 0 || pbool('payWhatYouCan') || bookingFee || pget('paymentNote')) && ( +
+

+ {t('labUi.detail.paymentsTitle')} +

+ + {(paymentMethods.length > 0 || pbool('payWhatYouCan')) && ( +
+ {paymentMethods.map((m) => { + const key = coachOptionKey(COACH_PAYMENT_METHODS, m); + return ( + + {key ? t(key) : m} + + ); + })} + {pbool('payWhatYouCan') && ( + + {t('labUi.detail.payWhatYouCan')} + + )} +
+ )} + + {pget('paymentNote') && ( +

{pget('paymentNote')}

+ )} + + {bookingFee && ( +
+
+
+

{t('labUi.detail.bookingFee')}

+

{bookingFeeDisplay}

+
+ {canPayBookingFee && user?.pubkey !== profile.pubkey && ( + + )} +
+

{t('labUi.detail.bookingFeeNote')}

+ {!canPayBookingFee && ( +

{t('labUi.detail.bookingFeeNoRail')}

+ )} +
+ )} + + {payOpen && canPayBookingFee && ( + + )} +
+ )} + {/* In the Bag — photographer / filmer gear (freeform) */} {(isPhotographer || isFilmer || isBuilder) && pget('gear') && (