diff --git a/AGENTS.md b/AGENTS.md index 5f1a041..d48ec7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,19 @@ session with **no other memory of this project**. So: (`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". + - **Lab offer/publish UX (2026-10-04):** the create→post loop got its missing + pieces. **`components/LabProfilePreview.tsx`** is a live "how it will look" + preview fed a draft from the form (title/bio/image/location/experience/rate/ + skills/contact + the shared enrichment tags); wired into `CraftProfileForm` + and all 4 bespoke pages + coach via a **Preview** button next to Submit. + `parseLabExtras` now accepts any `{ tags }` (not just a full event) so drafts + preview. On a **first** publish the form redirects with `?published=1`, and + the profile shows a one-time "You're live — share it" banner. The owner + "Manage" card has an **Edit profile** link (`/become/`), every profile + has a **Share** button (`ShareProfileDialog` with a `path` override), and + `/lab` shows a **"Your Lab profiles"** strip. `main.tsx`'s clean-link shim now + also rewrites `/coach//`. Still open: draft/autosave and a + multi-step layout. - **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/src/components/CraftProfileForm.tsx b/src/components/CraftProfileForm.tsx index 183b5e5..39631bd 100644 --- a/src/components/CraftProfileForm.tsx +++ b/src/components/CraftProfileForm.tsx @@ -17,6 +17,7 @@ import { LoginArea } from '@/components/auth/LoginArea'; import { NofferField } from '@/components/NofferField'; import { PaymentReadinessNote } from '@/components/PaymentReadinessNote'; import { LabExtrasFields } from '@/components/LabExtrasFields'; +import { LabProfilePreview } from '@/components/LabProfilePreview'; import { EntityTypeField } from '@/components/EntityTypeField'; import { CrewLinkField } from '@/components/CrewLinkField'; import { LabProUpgrade } from '@/components/LabProUpgrade'; @@ -27,7 +28,7 @@ import type { LabOption } from '@/lib/labExtras'; import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl'; import { useTranslation } from '@/contexts/language'; import { cn } from '@/lib/utils'; -import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react'; +import { Upload, X, Loader2, ArrowLeft, MapPin, Eye } from 'lucide-react'; /** A group of selectable tag chips (e.g. "Genre", "Instruments"). */ export interface SkillSection { @@ -133,6 +134,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { const [prefilled, setPrefilled] = useState(false); const [existingGeohash, setExistingGeohash] = useState(undefined); const [extraTags, setExtraTags] = useState([]); + const [previewOpen, setPreviewOpen] = useState(false); // Load + prefill an existing profile so this is an EDIT, not a blind overwrite. const { data: existingProfile } = useMyLabProfileEvent(kind, identifier); @@ -334,6 +336,12 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { const hasNip05 = metadata?.nip05; const entityCoord = `${kind}:${user.pubkey}:${identifier}`; + const previewSkills = skillSections.flatMap((s) => + s.entries + .filter(([k]) => selected.includes(k)) + .map(([k, label]) => ({ label: s.keyMap?.[k] ? t(s.keyMap[k]) : label })), + ); + return ( <>
@@ -594,16 +602,47 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { {/* Pro upgrade (optional) */} - {/* Submit */} - + {/* Preview + Submit */} +
+ + +
+ + ); } diff --git a/src/components/LabProfilePreview.tsx b/src/components/LabProfilePreview.tsx new file mode 100644 index 0000000..de262bc --- /dev/null +++ b/src/components/LabProfilePreview.tsx @@ -0,0 +1,215 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import { ImageWithFallback } from '@/components/ImageWithFallback'; +import { useTranslation } from '@/contexts/language'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { + parseLabExtras, + labOptionLabel, + PAYMENT_METHODS, + AVAILABILITY_OPTIONS, + RATE_CURRENCIES, + ALL_RATE_UNITS, +} from '@/lib/labExtras'; +import { Banknote, Eye, Languages as LanguagesIcon, MapPin } from 'lucide-react'; + +export interface LabPreviewSkill { + label: string; + description?: string; +} + +export interface LabPreviewDraft { + title: string; + bio: string; + image?: string | null; + location?: string; + experience?: string; + rate?: string; + skills?: LabPreviewSkill[]; + contact?: { + phone?: string; + email?: string; + website?: string; + instagram?: string; + portfolio?: string; + social?: string; + noffer?: string; + }; + /** The form's shared enrichment tags (languages/availability/rate/payments/photo). */ + extraTags?: string[][]; +} + +interface LabProfilePreviewProps { + open: boolean; + onOpenChange: (open: boolean) => void; + draft: LabPreviewDraft; + heading: string; + noun: string; + accentText: string; + accentChip: string; + icon: React.ComponentType<{ className?: string }>; +} + +/** + * A "how it will look" preview of a Lab provider profile, built from the form's + * current draft — the create flow is long and otherwise blind until publish. + * Deliberately a faithful *card*, not the full detail page (which fetches the + * published event). + */ +export function LabProfilePreview({ + open, + onOpenChange, + draft, + heading, + noun, + accentText, + accentChip, + icon: Icon, +}: LabProfilePreviewProps) { + const { t } = useTranslation(); + const extras = parseLabExtras({ tags: draft.extraTags ?? [] }); + + const rateAmount = extras.rateAmount; + const rateDisplay = rateAmount + ? `${rateAmount} ${labOptionLabel(RATE_CURRENCIES, extras.rateCurrency ?? 'sats', t)}` + : draft.rate; + const unitKey = extras.rateUnit ? ALL_RATE_UNITS.find((u) => u.value === extras.rateUnit)?.key : undefined; + const contact = draft.contact ?? {}; + const skills = (draft.skills ?? []).slice(0, 8); + + return ( + + + + + {t('labUi.preview.title')} + + + {t('labUi.preview.desc', { noun })} + + + +
+ {/* Header */} +
+
+ {draft.image ? ( + + ) : ( +
+ +
+ )} +
+
+
+

+ {draft.title || t('labUi.preview.untitled')} +

+ + {heading} + +
+ {draft.location && ( +

+ {draft.location} +

+ )} +
+
+ + {/* Bio */} +

+ {draft.bio || {t('labUi.preview.noBio')}} +

+ + {/* Skills */} + {skills.length > 0 && ( +
+ {skills.map((s) => ( + + {s.label} + + ))} +
+ )} + + {/* Rate + experience */} + {(rateDisplay || draft.experience) && ( +
+ {rateDisplay && ( + + 💰 {rateDisplay}{unitKey ? ` · ${t(unitKey)}` : ''} + + )} + {draft.experience && ( + {t('labUi.preview.years', { count: draft.experience })} + )} +
+ )} + + {/* Languages / availability / payments */} + {(extras.languages.length > 0 || extras.availability.length > 0 || extras.paymentMethods.length > 0 || extras.payWhatYouCan) && ( +
+ {extras.languages.length > 0 && ( +

+ {extras.languages.join(', ')} +

+ )} + {extras.availability.length > 0 && ( +
+ {extras.availability.map((v) => ( + + {labOptionLabel(AVAILABILITY_OPTIONS, v, t)} + + ))} +
+ )} + {(extras.paymentMethods.length > 0 || extras.payWhatYouCan) && ( +
+ + {extras.paymentMethods.map((m) => ( + {labOptionLabel(PAYMENT_METHODS, m, t)} + ))} + {extras.payWhatYouCan && ( + {t('labUi.detail.payWhatYouCan')} + )} +
+ )} +
+ )} + + {/* Gallery preview */} + {extras.photos.length > 0 && ( +
+ {extras.photos.slice(0, 8).map((url) => ( +
+ +
+ ))} +
+ )} + + {/* Contact */} + {(sanitizeUrl(contact.website) || sanitizeUrl(contact.portfolio) || sanitizeUrl(contact.social) || contact.email || contact.phone || contact.instagram) && ( +
+ {contact.phone && 📞 {contact.phone}} + {contact.email && ✉️ {contact.email}} + {sanitizeUrl(contact.website) && 🔗 {t('labUi.become.website')}} + {contact.instagram && 📷 {contact.instagram}} + {sanitizeUrl(contact.portfolio) && 🎨 {t('labUi.preview.portfolio')}} +
+ )} + +

{t('labUi.preview.note')}

+
+
+
+ ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c31fc20..ec6ab23 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1995,6 +1995,16 @@ labUi: { offerPhotography: 'Offer Photography', offerServices: 'Offer Services', yourProfiles: 'Your Lab profiles', + preview: { + button: 'Preview', + title: 'Live preview', + desc: 'This is how your {{noun}} profile will look once published.', + untitled: 'Your name', + noBio: 'No bio yet — add a line about what you offer.', + years: '{{count}} years experience', + portfolio: 'Portfolio', + note: 'Preview only — nothing is published yet.', + }, offerTattoo: 'Offer Tattoo', offerTraining: 'Offer Training / Physio', organizeEvents: 'Organize Events', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index e2d79d5..2652cfb 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -1995,6 +1995,16 @@ labUi: { offerPhotography: 'Ofrecer fotografía', offerServices: 'Ofrecer servicios', yourProfiles: 'Tus perfiles del Lab', + preview: { + button: 'Vista previa', + title: 'Vista previa en vivo', + desc: 'Así se verá tu perfil de {{noun}} cuando lo publiques.', + untitled: 'Tu nombre', + noBio: 'Aún no hay descripción — añade una línea sobre lo que ofreces.', + years: '{{count}} años de experiencia', + portfolio: 'Portafolio', + note: 'Solo vista previa — aún no se ha publicado nada.', + }, offerTattoo: 'Ofrecer tatuajes', offerTraining: 'Ofrecer entrenamiento / fisio', organizeEvents: 'Organizar eventos', diff --git a/src/lib/labExtras.ts b/src/lib/labExtras.ts index 489b781..770f86d 100644 --- a/src/lib/labExtras.ts +++ b/src/lib/labExtras.ts @@ -12,7 +12,6 @@ * Craft-specific *context* stays in each form's `skillSections` + `extraFields`; * this is the craft-agnostic enrichment (how to reach them, how to pay, media). */ -import type { NostrEvent } from '@nostrify/nostrify'; import { COACH_PAYMENT_METHODS } from '@/lib/coachOptions'; /** A stored value + its i18n key. */ @@ -92,8 +91,9 @@ export interface LabExtras { certNote?: string; } -/** Read the shared extras off any Lab provider event (craft-agnostic). */ -export function parseLabExtras(event: NostrEvent): LabExtras { +/** Read the shared extras off any Lab provider event (craft-agnostic). Accepts + * anything with a `tags` array, so draft/preview tag lists work too. */ +export function parseLabExtras(event: { tags: string[][] }): LabExtras { const tag = (n: string) => event.tags.find(([t]) => t === n)?.[1]; const vals = (n: string, lower = true): string[] => [ ...new Set( diff --git a/src/pages/BecomeBuilderPage.tsx b/src/pages/BecomeBuilderPage.tsx index 792725f..402a8dd 100644 --- a/src/pages/BecomeBuilderPage.tsx +++ b/src/pages/BecomeBuilderPage.tsx @@ -42,11 +42,12 @@ import { import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl'; import { useTranslation } from '@/contexts/language'; import { cn } from '@/lib/utils'; -import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Hammer } from 'lucide-react'; +import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Hammer, Eye } from 'lucide-react'; import { LabProUpgrade } from '@/components/LabProUpgrade'; import { usePlatformFee } from '@/hooks/usePlatformFee'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; import { LabExtrasFields } from '@/components/LabExtrasFields'; +import { LabProfilePreview } from '@/components/LabProfilePreview'; import { pickRateUnits } from '@/lib/labExtras'; const SERVICE_ENTRIES = Object.entries(BUILDER_SERVICE_LABELS); @@ -132,6 +133,7 @@ export default function BecomeBuilderPage() { const [titleInput, setTitleInput] = useState(''); const [prefilled, setPrefilled] = useState(false); const [extraTags, setExtraTags] = useState([]); + const [previewOpen, setPreviewOpen] = useState(false); const [lead_time, setLeadTime] = useState(''); const [warranty, setWarranty] = useState(''); const [existingGeohash, setExistingGeohash] = useState(undefined); @@ -652,6 +654,15 @@ export default function BecomeBuilderPage() { + + +
+ + +
+ + ); } diff --git a/src/pages/BecomeEditorPage.tsx b/src/pages/BecomeEditorPage.tsx index 3b64d90..2275e08 100644 --- a/src/pages/BecomeEditorPage.tsx +++ b/src/pages/BecomeEditorPage.tsx @@ -33,11 +33,12 @@ import { CrewLinkField } from '@/components/CrewLinkField'; import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl'; import { useTranslation } from '@/contexts/language'; import { cn } from '@/lib/utils'; -import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor } from 'lucide-react'; +import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor, Eye } from 'lucide-react'; import { LabProUpgrade } from '@/components/LabProUpgrade'; import { usePlatformFee } from '@/hooks/usePlatformFee'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; import { LabExtrasFields } from '@/components/LabExtrasFields'; +import { LabProfilePreview } from '@/components/LabProfilePreview'; import { pickRateUnits } from '@/lib/labExtras'; const SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS); @@ -88,6 +89,7 @@ export default function BecomeEditorPage() { const [titleInput, setTitleInput] = useState(''); const [prefilled, setPrefilled] = useState(false); const [extraTags, setExtraTags] = useState([]); + const [previewOpen, setPreviewOpen] = useState(false); const [turnaround, setTurnaround] = useState(''); const [deliverables, setDeliverables] = useState(''); const [existingGeohash, setExistingGeohash] = useState(undefined); @@ -550,6 +552,14 @@ export default function BecomeEditorPage() {
+
+ + ); } diff --git a/src/pages/BecomeFilmerPage.tsx b/src/pages/BecomeFilmerPage.tsx index 0365d18..5e962b8 100644 --- a/src/pages/BecomeFilmerPage.tsx +++ b/src/pages/BecomeFilmerPage.tsx @@ -27,11 +27,12 @@ import { FILMING_SERVICE_KEYS, FILMING_SERVICE_DESC_KEYS, FILMING_SKILL_KEYS, FI import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl'; import { useTranslation } from '@/contexts/language'; import { cn } from '@/lib/utils'; -import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react'; +import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Eye } from 'lucide-react'; import { LabProUpgrade } from '@/components/LabProUpgrade'; import { usePlatformFee } from '@/hooks/usePlatformFee'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; import { LabExtrasFields } from '@/components/LabExtrasFields'; +import { LabProfilePreview } from '@/components/LabProfilePreview'; import { pickRateUnits } from '@/lib/labExtras'; const SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS); @@ -83,6 +84,7 @@ export default function BecomeFilmerPage() { const [titleInput, setTitleInput] = useState(''); const [prefilled, setPrefilled] = useState(false); const [extraTags, setExtraTags] = useState([]); + const [previewOpen, setPreviewOpen] = useState(false); const [turnaround, setTurnaround] = useState(''); const [deliverables, setDeliverables] = useState(''); const [drone, setDrone] = useState(''); @@ -622,6 +624,15 @@ export default function BecomeFilmerPage() { + + +