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:
parent
fa0b9cfe1c
commit
080a6edf8b
8 changed files with 258 additions and 3 deletions
10
AGENTS.md
10
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
|
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
|
**`min-h-0`** — the global mobile `button { min-height:44px }` rule turned
|
||||||
every chip into a 44px slab otherwise.
|
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
|
- **Photos**: `PhotoDropzone` (`src/components/PhotoDropzone.tsx`) is the shared
|
||||||
image input — **drag & drop + multi-select**, emits `File[]` to its caller
|
image input — **drag & drop + multi-select**, emits `File[]` to its caller
|
||||||
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and
|
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and
|
||||||
|
|
|
||||||
18
NIP.md
18
NIP.md
|
|
@ -2072,6 +2072,11 @@ client-side.
|
||||||
| `rate_amount` | number | Optional structured rate amount (with `rate_currency` + `rate_unit`) |
|
| `rate_amount` | number | Optional structured rate amount (with `rate_currency` + `rate_unit`) |
|
||||||
| `rate_currency` | `sats`, `USD`, `EUR`, `GBP` | Currency for `rate_amount` |
|
| `rate_currency` | `sats`, `USD`, `EUR`, `GBP` | Currency for `rate_amount` |
|
||||||
| `rate_unit` | `hour`, `session`, `half-day`, `day`, `camp` | Billing unit 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
|
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.
|
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
|
in the loaded profiles. Filtering is client-side, consistent with the tags not
|
||||||
being relay-indexed.
|
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
|
> **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
|
> 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.
|
> signal. Clients must label credentials as self-attested, never as verified.
|
||||||
|
|
|
||||||
|
|
@ -2233,6 +2233,19 @@ labUi: {
|
||||||
perCamp: 'per camp',
|
perCamp: 'per camp',
|
||||||
rateNotesLabel: 'Rate notes (optional)',
|
rateNotesLabel: 'Rate notes (optional)',
|
||||||
rateNotesPlaceholder: 'Sliding scale, first session free, travel included…',
|
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',
|
spotsDesc: 'Select spots where you offer coaching sessions',
|
||||||
spotsLabel: 'Spots Where You Coach',
|
spotsLabel: 'Spots Where You Coach',
|
||||||
tagline: 'Share your skateboarding knowledge',
|
tagline: 'Share your skateboarding knowledge',
|
||||||
|
|
@ -2546,6 +2559,12 @@ labUi: {
|
||||||
bookSession: 'Book a session',
|
bookSession: 'Book a session',
|
||||||
portfolio: 'Portfolio',
|
portfolio: 'Portfolio',
|
||||||
watchReel: 'Watch on {{platform}}',
|
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',
|
serviceArea: 'Service Area',
|
||||||
licensedInsured: 'Licensed / Insured',
|
licensedInsured: 'Licensed / Insured',
|
||||||
studio: 'Studio',
|
studio: 'Studio',
|
||||||
|
|
|
||||||
|
|
@ -2233,6 +2233,19 @@ labUi: {
|
||||||
perCamp: 'por campus',
|
perCamp: 'por campus',
|
||||||
rateNotesLabel: 'Notas sobre la tarifa (opcional)',
|
rateNotesLabel: 'Notas sobre la tarifa (opcional)',
|
||||||
rateNotesPlaceholder: 'Tarifa social, primera clase gratis, desplazamiento incluido…',
|
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',
|
spotsDesc: 'Selecciona los spots donde ofreces clases',
|
||||||
spotsLabel: 'Spots donde entrenas',
|
spotsLabel: 'Spots donde entrenas',
|
||||||
tagline: 'Comparte tus conocimientos de skate',
|
tagline: 'Comparte tus conocimientos de skate',
|
||||||
|
|
@ -2546,6 +2559,12 @@ labUi: {
|
||||||
bookSession: 'Reservar una sesión',
|
bookSession: 'Reservar una sesión',
|
||||||
portfolio: 'Portfolio',
|
portfolio: 'Portfolio',
|
||||||
watchReel: 'Ver en {{platform}}',
|
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',
|
serviceArea: 'Zona de servicio',
|
||||||
licensedInsured: 'Con licencia / asegurado',
|
licensedInsured: 'Con licencia / asegurado',
|
||||||
studio: 'Estudio',
|
studio: 'Estudio',
|
||||||
|
|
|
||||||
|
|
@ -100,3 +100,16 @@ export function coachRateUnitKey(unit: string | undefined): string | undefined {
|
||||||
export function coachRateCurrencyKey(currency: string | undefined): string | undefined {
|
export function coachRateCurrencyKey(currency: string | undefined): string | undefined {
|
||||||
return COACH_RATE_CURRENCIES.find((c) => c.value === currency)?.key;
|
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' },
|
||||||
|
];
|
||||||
|
|
|
||||||
|
|
@ -2359,6 +2359,16 @@ export interface SkateCoach {
|
||||||
rateCurrency?: string;
|
rateCurrency?: string;
|
||||||
/** Structured rate billing unit (`hour` / `session` / …). */
|
/** Structured rate billing unit (`hour` / `session` / …). */
|
||||||
rateUnit?: string;
|
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;
|
phone?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
website?: 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 rateAmount = event.tags.find(([name]) => name === 'rate_amount')?.[1];
|
||||||
const rateCurrency = event.tags.find(([name]) => name === 'rate_currency')?.[1];
|
const rateCurrency = event.tags.find(([name]) => name === 'rate_currency')?.[1];
|
||||||
const rateUnit = event.tags.find(([name]) => name === 'rate_unit')?.[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 experience = event.tags.find(([name]) => name === 'experience')?.[1];
|
||||||
const rate = event.tags.find(([name]) => name === 'rate')?.[1];
|
const rate = event.tags.find(([name]) => name === 'rate')?.[1];
|
||||||
const phone = event.tags.find(([name]) => name === 'phone')?.[1];
|
const phone = event.tags.find(([name]) => name === 'phone')?.[1];
|
||||||
|
|
@ -2746,6 +2761,11 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
|
||||||
rateAmount,
|
rateAmount,
|
||||||
rateCurrency,
|
rateCurrency,
|
||||||
rateUnit,
|
rateUnit,
|
||||||
|
paymentMethods,
|
||||||
|
paymentNote,
|
||||||
|
bookingFee,
|
||||||
|
bookingFeeCurrency,
|
||||||
|
payWhatYouCan,
|
||||||
crewRef: parseCrewRef(event),
|
crewRef: parseCrewRef(event),
|
||||||
featuredUntil: parseFeaturedUntil(event),
|
featuredUntil: parseFeaturedUntil(event),
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,11 @@ import { EntityTypeField } from '@/components/EntityTypeField';
|
||||||
import { CrewLinkField } from '@/components/CrewLinkField';
|
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 { 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_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 { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { cn } from '@/lib/utils';
|
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 { LabProUpgrade } from '@/components/LabProUpgrade';
|
||||||
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
||||||
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
|
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
|
||||||
|
|
@ -126,6 +126,11 @@ export default function BecomeCoachPage() {
|
||||||
const [rateAmount, setRateAmount] = useState('');
|
const [rateAmount, setRateAmount] = useState('');
|
||||||
const [rateCurrency, setRateCurrency] = useState<string>('sats');
|
const [rateCurrency, setRateCurrency] = useState<string>('sats');
|
||||||
const [rateUnit, setRateUnit] = useState<string>('hour');
|
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 [phone, setPhone] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [website, setWebsite] = useState('');
|
const [website, setWebsite] = useState('');
|
||||||
|
|
@ -168,6 +173,11 @@ export default function BecomeCoachPage() {
|
||||||
setRateAmount(tag('rate_amount') ?? '');
|
setRateAmount(tag('rate_amount') ?? '');
|
||||||
setRateCurrency(tag('rate_currency') ?? 'sats');
|
setRateCurrency(tag('rate_currency') ?? 'sats');
|
||||||
setRateUnit(tag('rate_unit') ?? 'hour');
|
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') ?? '');
|
setPhone(tag('phone') ?? '');
|
||||||
setEmail(tag('email') ?? '');
|
setEmail(tag('email') ?? '');
|
||||||
setWebsite(tag('website') ?? '');
|
setWebsite(tag('website') ?? '');
|
||||||
|
|
@ -333,6 +343,13 @@ export default function BecomeCoachPage() {
|
||||||
tags.push(['rate_currency', rateCurrency]);
|
tags.push(['rate_currency', rateCurrency]);
|
||||||
tags.push(['rate_unit', rateUnit]);
|
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 (phone.trim()) tags.push(['phone', phone.trim()]);
|
||||||
if (email.trim()) tags.push(['email', email.trim()]);
|
if (email.trim()) tags.push(['email', email.trim()]);
|
||||||
if (website.trim()) tags.push(['website', normalizeUrlInput(website)]);
|
if (website.trim()) tags.push(['website', normalizeUrlInput(website)]);
|
||||||
|
|
@ -703,6 +720,64 @@ export default function BecomeCoachPage() {
|
||||||
</div>
|
</div>
|
||||||
</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')} />
|
<FormSection title={t('labUi.coachForm.sectionWhereYouCoach')} />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,8 @@ import {
|
||||||
ORGANIZER_EVENT_KEYS, ORGANIZER_SERVICE_KEYS, ORGANIZER_SCALE_KEYS,
|
ORGANIZER_EVENT_KEYS, ORGANIZER_SERVICE_KEYS, ORGANIZER_SCALE_KEYS,
|
||||||
BARBER_SERVICE_KEYS, BARBER_SERVICE_DESC_KEYS, BARBER_STYLE_KEYS, BARBER_SETTING_KEYS,
|
BARBER_SERVICE_KEYS, BARBER_SERVICE_DESC_KEYS, BARBER_STYLE_KEYS, BARBER_SETTING_KEYS,
|
||||||
} from '@/lib/labelKeys';
|
} 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 { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { OfficialBadge } from '@/components/OfficialBadge';
|
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||||
|
|
@ -77,6 +78,8 @@ import {
|
||||||
Images,
|
Images,
|
||||||
Clapperboard,
|
Clapperboard,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Banknote,
|
||||||
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
|
|
@ -117,6 +120,7 @@ export default function CoachDetailPage() {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
const [payOpen, setPayOpen] = useState(false);
|
||||||
|
|
||||||
// Gigs — only relevant for musicians. The hook is disabled for other types.
|
// Gigs — only relevant for musicians. The hook is disabled for other types.
|
||||||
const { data: gigs = [] } = useGigs(
|
const { data: gigs = [] } = useGigs(
|
||||||
|
|
@ -208,6 +212,18 @@ export default function CoachDetailPage() {
|
||||||
? `${rateAmount} ${rateCurrencyKey ? t(rateCurrencyKey) : (pget('rateCurrency') ?? '')}${rateUnitKey ? ` · ${t(rateUnitKey)}` : ''}`.trim()
|
? `${rateAmount} ${rateCurrencyKey ? t(rateCurrencyKey) : (pget('rateCurrency') ?? '')}${rateUnitKey ? ` · ${t(rateUnitKey)}` : ''}`.trim()
|
||||||
: pget('rate');
|
: 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> = {
|
const TYPE_ICONS: Record<string, typeof GraduationCap> = {
|
||||||
coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors,
|
coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors,
|
||||||
artist: Palette, graffiti: SprayCan, tattoo: Brush, trainer: Dumbbell, musician: Music, judge: Gavel,
|
artist: Palette, graffiti: SprayCan, tattoo: Brush, trainer: Dumbbell, musician: Music, judge: Gavel,
|
||||||
|
|
@ -546,6 +562,71 @@ export default function CoachDetailPage() {
|
||||||
</div>
|
</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) */}
|
{/* In the Bag — photographer / filmer gear (freeform) */}
|
||||||
{(isPhotographer || isFilmer || isBuilder) && pget('gear') && (
|
{(isPhotographer || isFilmer || isBuilder) && pget('gear') && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue