diff --git a/AGENTS.md b/AGENTS.md index 4905586..5f1a041 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,10 @@ session with **no other memory of this project**. So: `skillSections` + `extraFields`, and per-craft `rateUnits` (billing units) are set in `becomeRoles.tsx`. `/lab` filters read `parseLabExtras` for all crafts. ⚠️ A new craft-specific freeform `extraField` is **invisible** unless the - detail page has a matching `pget('…')` section — add both together. + detail page has a matching section — declare it in **`src/lib/craftFields.ts`** + (`CRAFT_DETAIL_FIELDS`) as well as the form. A small per-craft set is in place + (deliverables/turnaround, deposit/minimum, travel, lead_time, …); toggle tags + store `"true"` and render as a localized "Yes". - **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 2d5c5bd..44823ec 100644 --- a/NIP.md +++ b/NIP.md @@ -1912,6 +1912,23 @@ block (`LabExtrasSection`) renders for every craft: > are deliberately out of scope. No tag named `payment` — that's the marketplace > offer pointer. +**Craft-specific detail fields.** Each craft may add a couple of freeform tags +for context; they're stored verbatim and rendered by `src/lib/craftFields.ts` +(so a field with no entry there is invisible): + +- **Artist**: `deliverables`, `turnaround` +- **Graffiti**: `surfaces`, `max_size` +- **Musician**: `set_length`, `tech_rider` +- **Judge**: `travel`, `certifications` +- **Organizer**: `insurance`, `permits` +- **Tattoo**: `deposit`, `minimum` +- **Trainer**: `modalities` +- **Barber**: `duration` +- **Filmer**: `turnaround`, `deliverables`, `drone` +- **Editor**: `turnaround`, `deliverables` +- **Photographer**: `turnaround`, `deliverables` +- **Builder**: `lead_time`, `warranty` + ### Pro / Featured Listings (`featured` tag) Lab profiles, calendar events (kind 31923), and skate tours (kind 30823) diff --git a/src/components/CraftProfileForm.tsx b/src/components/CraftProfileForm.tsx index c8d210d..61742df 100644 --- a/src/components/CraftProfileForm.tsx +++ b/src/components/CraftProfileForm.tsx @@ -45,6 +45,8 @@ export interface ExtraField { /** Nostr tag name to write, e.g. "studio", "spotify". */ tag: string; label: string; + /** Optional i18n key for the label (takes precedence over `label`). */ + labelKey?: string; placeholder?: string; /** "text" (default) | "url" | "toggle" */ type?: 'text' | 'url' | 'toggle'; @@ -479,7 +481,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { f.type === 'toggle' ? (
- + {f.helper &&

{f.helper}

}
) : (
- + = { accentChipHover: 'hover:border-fuchsia-500/60 hover:text-fuchsia-400', icon: Palette, rateUnits: pickRateUnits('piece', 'project', 'hour', 'day'), + extraFields: [ + { tag: 'deliverables', label: 'Deliverables', labelKey: 'labUi.craftFields.deliverables', placeholder: 'e.g. print-ready files, source files, deck graphics' }, + { tag: 'turnaround', label: 'Turnaround', labelKey: 'labUi.craftFields.turnaround', placeholder: 'e.g. 3–5 days' }, + ], rateLabel: 'Rate', portfolioLabel: 'Portfolio / Shop link', skillSections: [ @@ -118,6 +122,8 @@ export const BECOME_ROLES: Record = { ], extraFields: [ { tag: 'crew', label: 'Graffiti Crew', placeholder: 'Your writing crew (optional)', helper: 'Separate from your skate crew — the writers you paint with.' }, + { tag: 'surfaces', label: 'Surfaces / Walls', labelKey: 'labUi.craftFields.surfaces', placeholder: 'e.g. concrete, brick, permitted walls' }, + { tag: 'max_size', label: 'Max Size', labelKey: 'labUi.craftFields.maxSize', placeholder: 'e.g. up to 10 m' }, ], }, }, @@ -150,6 +156,8 @@ export const BECOME_ROLES: Record = { { tag: 'bandcamp', label: 'Bandcamp', type: 'url', placeholder: 'https://…bandcamp.com' }, { tag: 'soundcloud', label: 'SoundCloud', type: 'url', placeholder: 'https://soundcloud.com/…' }, { tag: 'youtube', label: 'YouTube', type: 'url', placeholder: 'https://youtube.com/…' }, + { tag: 'set_length', label: 'Set Length', labelKey: 'labUi.craftFields.setLength', placeholder: 'e.g. 30–45 min' }, + { tag: 'tech_rider', label: 'Tech Rider', labelKey: 'labUi.craftFields.techRider', placeholder: 'e.g. PA, mics, backline' }, ], }, }, @@ -179,6 +187,8 @@ export const BECOME_ROLES: Record = { ], extraFields: [ { tag: 'events_judged', label: 'Events Judged', placeholder: 'e.g. 40+ contests, 3 World Tour stops', helper: 'Notable events, comps or tours you have officiated.' }, + { tag: 'travel', label: 'Travel', labelKey: 'labUi.craftFields.travel', placeholder: 'e.g. regional / nationwide / international' }, + { tag: 'certifications', label: 'Certifications', labelKey: 'labUi.craftFields.certifications', placeholder: 'e.g. national judging course' }, ], }, }, @@ -208,6 +218,8 @@ export const BECOME_ROLES: Record = { ], extraFields: [ { tag: 'events_hosted', label: 'Events Organized', placeholder: 'e.g. 25 jams, 3 city comps', helper: 'Notable events, comps or meetups you have run.' }, + { tag: 'insurance', label: 'Insurance', labelKey: 'labUi.craftFields.insurance', placeholder: 'e.g. public liability included' }, + { tag: 'permits', label: 'Permits', labelKey: 'labUi.craftFields.permits', placeholder: 'e.g. council permits handled' }, ], }, }, @@ -237,6 +249,8 @@ export const BECOME_ROLES: Record = { extraFields: [ { tag: 'studio', label: 'Studio / Shop', placeholder: 'Studio name (optional)' }, { tag: 'books_open', label: 'Books Open', type: 'toggle', helper: 'Are you currently taking bookings?' }, + { tag: 'deposit', label: 'Deposit', labelKey: 'labUi.craftFields.deposit', placeholder: 'e.g. 50 EUR non-refundable' }, + { tag: 'minimum', label: 'Minimum', labelKey: 'labUi.craftFields.minimum', placeholder: 'e.g. 80 EUR' }, ], }, }, @@ -267,6 +281,7 @@ export const BECOME_ROLES: Record = { extraFields: [ { tag: 'credentials', label: 'Certifications', placeholder: 'e.g. BSc Physiotherapy, CSCS…', helper: 'List real qualifications — skaters trust verifiable credentials.' }, { tag: 'remote', label: 'Remote / Online', type: 'toggle', helper: 'Do you offer online coaching or consultations?' }, + { tag: 'modalities', label: 'Modalities', labelKey: 'labUi.craftFields.modalities', placeholder: 'e.g. hands-on, S&C, dry needling' }, ], }, }, @@ -297,6 +312,7 @@ export const BECOME_ROLES: Record = { extraFields: [ { tag: 'studio', label: 'Shop / Studio', placeholder: 'Shop or studio name (optional)' }, { tag: 'books_open', label: 'Books Open', type: 'toggle', helper: 'Are you currently taking bookings?' }, + { tag: 'duration', label: 'Appointment Length', labelKey: 'labUi.craftFields.duration', placeholder: 'e.g. 30–45 min' }, ], }, }, diff --git a/src/lib/craftFields.ts b/src/lib/craftFields.ts new file mode 100644 index 0000000..21ced44 --- /dev/null +++ b/src/lib/craftFields.ts @@ -0,0 +1,64 @@ +import type { LabProfileType } from '@/hooks/useLabProfiles'; + +/** One craft-specific freeform field shown on the Lab detail page. */ +export interface CraftDetailField { + tag: string; + labelKey: string; +} + +/** + * Craft-specific freeform fields to surface on a provider's detail page. + * + * Keep this in sync with the `extraFields` declared in `becomeRoles.tsx` (and + * the bespoke become pages): a field with no entry here is stored but invisible. + * Toggle tags (value `"true"`) render as a localized "Yes". + */ +export const CRAFT_DETAIL_FIELDS: Partial> = { + artist: [ + { tag: 'deliverables', labelKey: 'labUi.craftFields.deliverables' }, + { tag: 'turnaround', labelKey: 'labUi.craftFields.turnaround' }, + ], + graffiti: [ + { tag: 'surfaces', labelKey: 'labUi.craftFields.surfaces' }, + { tag: 'max_size', labelKey: 'labUi.craftFields.maxSize' }, + ], + musician: [ + { tag: 'set_length', labelKey: 'labUi.craftFields.setLength' }, + { tag: 'tech_rider', labelKey: 'labUi.craftFields.techRider' }, + ], + judge: [ + { tag: 'travel', labelKey: 'labUi.craftFields.travel' }, + { tag: 'certifications', labelKey: 'labUi.craftFields.certifications' }, + ], + organizer: [ + { tag: 'insurance', labelKey: 'labUi.craftFields.insurance' }, + { tag: 'permits', labelKey: 'labUi.craftFields.permits' }, + ], + tattoo: [ + { tag: 'deposit', labelKey: 'labUi.craftFields.deposit' }, + { tag: 'minimum', labelKey: 'labUi.craftFields.minimum' }, + ], + trainer: [ + { tag: 'modalities', labelKey: 'labUi.craftFields.modalities' }, + ], + barber: [ + { tag: 'duration', labelKey: 'labUi.craftFields.duration' }, + ], + filmer: [ + { tag: 'turnaround', labelKey: 'labUi.craftFields.turnaround' }, + { tag: 'deliverables', labelKey: 'labUi.craftFields.deliverables' }, + { tag: 'drone', labelKey: 'labUi.craftFields.drone' }, + ], + editor: [ + { tag: 'turnaround', labelKey: 'labUi.craftFields.turnaround' }, + { tag: 'deliverables', labelKey: 'labUi.craftFields.deliverables' }, + ], + photographer: [ + { tag: 'turnaround', labelKey: 'labUi.craftFields.turnaround' }, + { tag: 'deliverables', labelKey: 'labUi.craftFields.deliverables' }, + ], + builder: [ + { tag: 'lead_time', labelKey: 'labUi.craftFields.leadTime' }, + { tag: 'warranty', labelKey: 'labUi.craftFields.warranty' }, + ], +}; diff --git a/src/pages/BecomeBuilderPage.tsx b/src/pages/BecomeBuilderPage.tsx index 3f1bfcc..fab3a35 100644 --- a/src/pages/BecomeBuilderPage.tsx +++ b/src/pages/BecomeBuilderPage.tsx @@ -132,6 +132,8 @@ export default function BecomeBuilderPage() { const [titleInput, setTitleInput] = useState(''); const [prefilled, setPrefilled] = useState(false); const [extraTags, setExtraTags] = useState([]); + const [lead_time, setLeadTime] = useState(''); + const [warranty, setWarranty] = useState(''); const [existingGeohash, setExistingGeohash] = useState(undefined); // Prefill from an existing profile so this is an edit, not a blind overwrite. @@ -154,6 +156,8 @@ export default function BecomeBuilderPage() { setImage(tag('image') ?? null); setExperience(tag('experience') ?? ''); setRate(tag('rate') ?? ''); + setLeadTime(tag('lead_time') ?? ''); + setWarranty(tag('warranty') ?? ''); setPhone(tag('phone') ?? ''); setEmail(tag('email') ?? ''); setWebsite(tag('website') ?? ''); @@ -296,6 +300,8 @@ export default function BecomeBuilderPage() { tags.push(['entity', entityType]); for (const extra of extraTags) tags.push(extra); + if (lead_time.trim()) tags.push(['lead_time', lead_time.trim()]); + if (warranty.trim()) tags.push(['warranty', warranty.trim()]); if (entityType === 'collective') { for (const pk of members) tags.push(['p', pk]); if (crewRef) tags.push(['a', crewRef]); @@ -621,6 +627,20 @@ export default function BecomeBuilderPage() {
{/* Optional Pro upgrade — free to list without it */} + + + +
+ + setLeadTime(e.target.value)} placeholder="e.g. 2–4 weeks" className="bg-gray-800 border-gray-700" /> +
+
+ + setWarranty(e.target.value)} placeholder="e.g. 12 months on ramps" className="bg-gray-800 border-gray-700" /> +
+
+
+ ([]); + const [turnaround, setTurnaround] = useState(''); + const [deliverables, setDeliverables] = useState(''); const [existingGeohash, setExistingGeohash] = useState(undefined); // Prefill from an existing profile so this is an edit, not a blind overwrite. @@ -108,6 +110,8 @@ export default function BecomeEditorPage() { setImage(tag('image') ?? null); setExperience(tag('experience') ?? ''); setRate(tag('rate') ?? ''); + setTurnaround(tag('turnaround') ?? ''); + setDeliverables(tag('deliverables') ?? ''); setPhone(tag('phone') ?? ''); setEmail(tag('email') ?? ''); setWebsite(tag('website') ?? ''); @@ -228,6 +232,8 @@ export default function BecomeEditorPage() { tags.push(['entity', entityType]); for (const extra of extraTags) tags.push(extra); + if (turnaround.trim()) tags.push(['turnaround', turnaround.trim()]); + if (deliverables.trim()) tags.push(['deliverables', deliverables.trim()]); if (entityType === 'collective') { for (const pk of members) tags.push(['p', pk]); if (crewRef) tags.push(['a', crewRef]); @@ -515,6 +521,20 @@ export default function BecomeEditorPage() { {/* Optional Pro upgrade — free to list without it */}
+ + + +
+ + setTurnaround(e.target.value)} placeholder="e.g. 2–5 days" className="bg-gray-800 border-gray-700" /> +
+
+ + setDeliverables(e.target.value)} placeholder="e.g. 4K master, vertical cut, captions" className="bg-gray-800 border-gray-700" /> +
+
+
+ ([]); + const [turnaround, setTurnaround] = useState(''); + const [deliverables, setDeliverables] = useState(''); + const [drone, setDrone] = useState(''); const [existingGeohash, setExistingGeohash] = useState(undefined); // Prefill from an existing profile so this is an edit, not a blind overwrite. @@ -103,6 +106,9 @@ export default function BecomeFilmerPage() { setImage(tag('image') ?? null); setExperience(tag('experience') ?? ''); setRate(tag('rate') ?? ''); + setTurnaround(tag('turnaround') ?? ''); + setDeliverables(tag('deliverables') ?? ''); + setDrone(tag('drone') ?? ''); setPhone(tag('phone') ?? ''); setEmail(tag('email') ?? ''); setWebsite(tag('website') ?? ''); @@ -248,6 +254,9 @@ export default function BecomeFilmerPage() { tags.push(['entity', entityType]); for (const extra of extraTags) tags.push(extra); + if (turnaround.trim()) tags.push(['turnaround', turnaround.trim()]); + if (deliverables.trim()) tags.push(['deliverables', deliverables.trim()]); + if (drone.trim()) tags.push(['drone', drone.trim()]); if (entityType === 'collective') { for (const pk of members) tags.push(['p', pk]); if (crewRef) tags.push(['a', crewRef]); @@ -584,6 +593,24 @@ export default function BecomeFilmerPage() {
{/* Optional Pro upgrade — free to list without it */} + + + +
+ + setTurnaround(e.target.value)} placeholder="e.g. 1–2 weeks" className="bg-gray-800 border-gray-700" /> +
+
+ + setDeliverables(e.target.value)} placeholder="e.g. master + raw, 4K" className="bg-gray-800 border-gray-700" /> +
+
+ + setDrone(e.target.value)} placeholder="e.g. FPV, Mavic 3, none" className="bg-gray-800 border-gray-700" /> +
+
+
+ ([]); + const [turnaround, setTurnaround] = useState(''); + const [deliverables, setDeliverables] = useState(''); const [existingGeohash, setExistingGeohash] = useState(undefined); // Prefill from an existing profile so this is an edit, not a blind overwrite. @@ -167,6 +169,8 @@ export default function BecomePhotographerPage() { setImage(tag('image') ?? null); setExperience(tag('experience') ?? ''); setRate(tag('rate') ?? ''); + setTurnaround(tag('turnaround') ?? ''); + setDeliverables(tag('deliverables') ?? ''); setPhone(tag('phone') ?? ''); setEmail(tag('email') ?? ''); setWebsite(tag('website') ?? ''); @@ -320,6 +324,8 @@ export default function BecomePhotographerPage() { tags.push(['entity', entityType]); for (const extra of extraTags) tags.push(extra); + if (turnaround.trim()) tags.push(['turnaround', turnaround.trim()]); + if (deliverables.trim()) tags.push(['deliverables', deliverables.trim()]); if (entityType === 'collective') { for (const pk of members) tags.push(['p', pk]); if (crewRef) tags.push(['a', crewRef]); @@ -650,6 +656,20 @@ export default function BecomePhotographerPage() {
{/* Optional Pro upgrade — free to list without it */} + + + +
+ + setTurnaround(e.target.value)} placeholder="e.g. 48 hours" className="bg-gray-800 border-gray-700" /> +
+
+ + setDeliverables(e.target.value)} placeholder="e.g. hi-res edits, RAWs, prints" className="bg-gray-800 border-gray-700" /> +
+
+
+ + {/* Craft-specific freeform details (see lib/craftFields) */} + {(() => { + const fields = (CRAFT_DETAIL_FIELDS[profile.profileType] ?? []).filter((f) => pget(f.tag)); + if (fields.length === 0) return null; + return ( +
+ {fields.map((f) => ( +
+

{t(f.labelKey)}

+

+ {pget(f.tag) === 'true' ? t('common.yes') : pget(f.tag)} +

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