From f73a3609837b91846fb6d66c637926bf1f9db1bf Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 27 Sep 2026 09:38:03 +0700 Subject: [PATCH] =?UTF-8?q?fix(lab):=20complete=20offer-a-service=20audit?= =?UTF-8?q?=20=E2=80=94=20data=20preservation,=20rendering,=20stats,=20inp?= =?UTF-8?q?ut=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Preserve the geo 'g' tag and paid 'featured' tag when editing any Lab profile (all 7 craft roles via CraftProfileForm + 5 bespoke pages). - Render previously collected-but-hidden fields on the Lab profile page: judge 'events_judged', organizer 'events_hosted', editor hardware/software, and spot references. - Include Event Organizer (35013) in Community Stats + counts. - Cap Title inputs at 100 (was 120) so profiles no longer fail the parser and silently vanish; MIN_TITLE_LEN 2 -> 1 for 1-char display names. - Normalize bare-domain URLs (example.com) to https:// before storing instead of dropping/hiding them (new normalizeUrlInput helper; fixes extra url fields). - No crew strip for judging/organizing (was wrongly showing Photo & Film); scrollable Offer Services FAB; live bio counters. --- AGENTS.md | 9 ++++ src/components/CraftProfileForm.tsx | 25 +++++++--- src/components/LabCrewsStrip.tsx | 12 ++++- src/hooks/useCrews.ts | 5 +- src/hooks/useGrowthStats.ts | 8 +++- src/lib/sanitizeUrl.ts | 21 +++++++++ src/lib/types.ts | 5 +- src/pages/BecomeBuilderPage.tsx | 18 ++++++-- src/pages/BecomeCoachPage.tsx | 14 ++++-- src/pages/BecomeEditorPage.tsx | 18 ++++++-- src/pages/BecomeFilmerPage.tsx | 18 ++++++-- src/pages/BecomePhotographerPage.tsx | 18 ++++++-- src/pages/CoachDetailPage.tsx | 69 ++++++++++++++++++++++++++++ src/pages/CommunityStatsPage.tsx | 7 +-- src/pages/LabPage.tsx | 2 +- 15 files changed, 210 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c7b6ba2..97b916c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,15 @@ session with **no other memory of this project**. So: → `src/hooks/useTradeAttestations.ts` / `useTraderRecommendations.ts`; reputation UI in `TradeReputationCard` / `TradeRecommendationsCard` - `35000–35013` role profiles (skater/filmer/coach/…/event-organizer) · `35006` crews · `30003` spot collections + - **Lab offer flows** (`/become/*`): the 7 craft roles share + `CraftProfileForm`; coach/filmer/editor/photographer/builder are bespoke but + follow the same conventions. Forms **prefill from the existing profile** to + edit, and **preserve the `g` geohash + paid `featured` tag on edit**. Bare + domains (`example.com`) are coerced to `https://` by **`normalizeUrlInput()`** + (`src/lib/sanitizeUrl.ts`) before storage, so links aren't silently hidden by + `sanitizeUrl()` at render (the extra-field `url` type used to be dropped + outright). `MIN_TITLE_LEN` is **1**: a profile title falls back to the + display name, and a 1-char name must not fail validation and vanish. - `24899` spot chat · `2259` spot status · `32671` Sasquatch Pro · `7103` event updates - **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in diff --git a/src/components/CraftProfileForm.tsx b/src/components/CraftProfileForm.tsx index eb9395b..ab838ba 100644 --- a/src/components/CraftProfileForm.tsx +++ b/src/components/CraftProfileForm.tsx @@ -23,6 +23,7 @@ import { LabProUpgrade } from '@/components/LabProUpgrade'; import { usePlatformFee } from '@/hooks/usePlatformFee'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; import { isValidNoffer, type EntityType } from '@/lib/types'; +import { normalizeUrlInput } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react'; @@ -120,6 +121,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { const [isGettingLocation, setIsGettingLocation] = useState(false); const [titleInput, setTitleInput] = useState(''); const [prefilled, setPrefilled] = useState(false); + const [existingGeohash, setExistingGeohash] = useState(undefined); // Load + prefill an existing profile so this is an EDIT, not a blind overwrite. const { data: existingProfile } = useMyLabProfileEvent(kind, identifier); @@ -128,6 +130,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { setPrefilled(true); const tag = (n: string) => existingProfile.tags.find(([t]) => t === n)?.[1]; setTitleInput(tag('title') ?? ''); + setExistingGeohash(tag('g')); + setGoPro(existingProfile.tags.some(([t]) => t === 'featured')); setBio(existingProfile.content ?? ''); setLocation(tag('location') ?? ''); setSelected(existingProfile.tags.filter(([t, v]) => t === 't' && v !== selfTag).map(([, v]) => v)); @@ -227,6 +231,9 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { const lat = parseFloat(latitude); const lng = parseFloat(longitude); if (!isNaN(lat) && !isNaN(lng)) tags.push(['g', ngeohash.encode(lat, lng, 7)]); + } else if (existingGeohash) { + // Preserve the existing map pin when the user edits without re-locating. + tags.push(['g', existingGeohash]); } for (const s of selected) tags.push(['t', s]); @@ -236,9 +243,9 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { if (showRate && rate.trim()) tags.push(['rate', rate.trim()]); if (phone.trim()) tags.push(['phone', phone.trim()]); if (email.trim()) tags.push(['email', email.trim()]); - if (website.trim()) tags.push(['website', website.trim()]); - if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]); - if (social.trim()) tags.push(['social', social.trim()]); + if (website.trim()) tags.push(['website', normalizeUrlInput(website)]); + if (portfolio.trim()) tags.push(['portfolio', normalizeUrlInput(portfolio)]); + if (social.trim()) tags.push(['social', normalizeUrlInput(social)]); if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]); // Craft-specific extras @@ -247,8 +254,11 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { if (f.type === 'toggle') { if (extras[f.tag] === 'true') tags.push([f.tag, 'true']); } else if (v) { - if (f.type === 'url' && !/^https?:\/\//i.test(v)) continue; - tags.push([f.tag, v]); + // Coerce bare domains ("example.com") to https:// instead of silently + // dropping the field, then reject anything that still isn't a web URL. + const val = f.type === 'url' ? normalizeUrlInput(v) : v; + if (f.type === 'url' && !/^https?:\/\//i.test(val)) continue; + tags.push([f.tag, val]); } } @@ -371,7 +381,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { value={titleInput} onChange={(e) => setTitleInput(e.target.value)} placeholder={getDisplayName()} - maxLength={120} + maxLength={100} className="mt-1.5 bg-white/5 border-white/10" />

@@ -389,6 +399,9 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { className="mt-1.5 bg-white/5 border-white/10 min-h-[100px] resize-none" maxLength={3000} /> +

+ {bio.length}/3000 +
diff --git a/src/components/LabCrewsStrip.tsx b/src/components/LabCrewsStrip.tsx index 7ad6c4f..c17166a 100644 --- a/src/components/LabCrewsStrip.tsx +++ b/src/components/LabCrewsStrip.tsx @@ -10,9 +10,14 @@ type ServiceType = | 'coaching' | 'filming' | 'editing' | 'photography' | 'building' | 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing'; -/** Translate the Lab page's ServiceType into a crew focus scene. */ -function focusForLabService(serviceType: ServiceType): Exclude { +/** Translate the Lab page's ServiceType into a crew focus scene (or null). */ +function focusForLabService(serviceType: ServiceType): Exclude | null { switch (serviceType) { + // No crew scene maps to judges or organizers — better to show nothing than + // to fall through to "Photo & Film" and imply a wrong association. + case 'judging': + case 'organizing': + return null; case 'art': case 'graffiti': case 'tattoo': @@ -70,6 +75,9 @@ export function LabCrewsStrip({ serviceType }: { serviceType: ServiceType }) { const focus = focusForLabService(serviceType); const { data: crews, isLoading } = useCrewsByFocus(focus, 20); + // No matching crew scene for this Lab tab — don't show a strip at all. + if (!focus) return null; + // Only render the section when there's something to show (or while loading). if (!isLoading && (!crews || crews.length === 0)) return null; diff --git a/src/hooks/useCrews.ts b/src/hooks/useCrews.ts index ad64df8..241009c 100644 --- a/src/hooks/useCrews.ts +++ b/src/hooks/useCrews.ts @@ -50,13 +50,14 @@ export function useCrews(limit = 200) { * Filters at the relay level via the crew-focus `t` tag, so The Lab pages can * show a "Crews in this scene" strip without scanning every crew. */ -export function useCrewsByFocus(focus: Exclude, limit = 30) { +export function useCrewsByFocus(focus: Exclude | null, limit = 30) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); - const focusTag = CREW_FOCUS_TAG[focus]; + const focusTag = focus ? CREW_FOCUS_TAG[focus] : ''; const hidden = useAdminModeration(); return useQuery({ queryKey: ['skate-crews-focus', focus, limit, getQueryKeySuffix()], + enabled: !!focus, select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden), queryFn: async () => { const signal = AbortSignal.timeout(10000); diff --git a/src/hooks/useGrowthStats.ts b/src/hooks/useGrowthStats.ts index 7061b45..3693b71 100644 --- a/src/hooks/useGrowthStats.ts +++ b/src/hooks/useGrowthStats.ts @@ -15,6 +15,7 @@ import { SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, + SKATE_ORGANIZER_KIND, SKATE_TOUR_KIND, SKATE_CREW_KIND, } from '@/lib/types'; @@ -64,6 +65,7 @@ export interface GrowthStats { totalTrainers: number; totalMusicians: number; totalJudges: number; + totalOrganizers: number; totalTours: number; totalCrews: number; /** All unique pubkeys that have published any content */ @@ -149,6 +151,7 @@ export function useGrowthStats() { trainerEvents, musicianEvents, judgeEvents, + organizerEvents, ] = await Promise.all([ querySkateContent([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []), @@ -168,6 +171,7 @@ export function useGrowthStats() { querySkateContent([{ kinds: [SKATE_TRAINER_KIND], limit: 500 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_MUSICIAN_KIND], limit: 500 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_JUDGE_KIND], limit: 500 }], { signal }).catch(() => []), + querySkateContent([{ kinds: [SKATE_ORGANIZER_KIND], limit: 500 }], { signal }).catch(() => []), ]); // Deduplicate addressable events (keep newest per pubkey+kind+d) @@ -198,8 +202,9 @@ export function useGrowthStats() { const trainers = dedup(trainerEvents); const musicians = dedup(musicianEvents); const judges = dedup(judgeEvents); + const organizers = dedup(organizerEvents); // Aggregate of the "other" crafts for active-mapper / total rollups. - const otherLab = [...artists, ...graffiti, ...tattoo, ...trainers, ...musicians, ...judges]; + const otherLab = [...artists, ...graffiti, ...tattoo, ...trainers, ...musicians, ...judges, ...organizers]; // ── Monthly bucketing ─────────────────────────────────────────────── @@ -320,6 +325,7 @@ export function useGrowthStats() { totalTrainers: trainers.length, totalMusicians: musicians.length, totalJudges: judges.length, + totalOrganizers: organizers.length, totalTours: tours.length, totalCrews: crews.length, totalMappers: allPubkeys.size, diff --git a/src/lib/sanitizeUrl.ts b/src/lib/sanitizeUrl.ts index 10ac5a8..92d1691 100644 --- a/src/lib/sanitizeUrl.ts +++ b/src/lib/sanitizeUrl.ts @@ -55,6 +55,27 @@ export function sanitizeCssString(value: string | undefined | null): string { return value.replace(/[^\p{L}\p{N} _\-'.]/gu, ''); } +/** + * Normalize a user-typed URL *before storing it* so bare domains still work. + * + * Users routinely type `example.com` or `instagram.com/me` with no scheme. + * `new URL()` rejects those, so `sanitizeUrl()` would hide the link and the + * user would think they'd provided it. Prefix `https://` for host-like values + * only (must contain a dot, no spaces/colons) so handles like `@skater` and + * already-schemed values (`https://…`, `mailto:…`) pass through untouched. + * + * Returns '' for empty input. Stored values are still sanitized again at render. + */ +export function normalizeUrlInput(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ''; + // Already has a scheme (https:, http:, mailto:, …) — leave it alone. + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return trimmed; + // Only invent a scheme for host-like values: domain.tld optionally + path. + if (!/^[\w-]+(\.[\w-]+)+(\/\S*)?$/i.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} + /** * Extracts just the hostname for display (e.g. "Opens: example.com") */ diff --git a/src/lib/types.ts b/src/lib/types.ts index a675636..e7c0420 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -13,7 +13,10 @@ export const SKATE_RELAY = 'wss://goskatespots.nostr1.com'; const MAX_TITLE_LEN = 100; const MAX_DESCRIPTION_LEN = 3000; const MAX_LOCATION_LEN = 200; -const MIN_TITLE_LEN = 2; +// 1, not 2: a profile's title falls back to the user's display name, and a +// single-character display name would otherwise make the whole profile fail +// validation and silently vanish. +const MIN_TITLE_LEN = 1; const MIN_GEOHASH_LEN = 4; // below 4 chars = ~2500km precision, useless for a spot const MAX_GEOHASH_LEN = 12; diff --git a/src/pages/BecomeBuilderPage.tsx b/src/pages/BecomeBuilderPage.tsx index 1b46ab2..a6a4aca 100644 --- a/src/pages/BecomeBuilderPage.tsx +++ b/src/pages/BecomeBuilderPage.tsx @@ -34,6 +34,7 @@ import { isValidNoffer, type EntityType, } from '@/lib/types'; +import { normalizeUrlInput } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Hammer } from 'lucide-react'; import { LabProUpgrade } from '@/components/LabProUpgrade'; @@ -118,6 +119,7 @@ export default function BecomeBuilderPage() { const [isGettingLocation, setIsGettingLocation] = useState(false); const [titleInput, setTitleInput] = useState(''); const [prefilled, setPrefilled] = useState(false); + const [existingGeohash, setExistingGeohash] = useState(undefined); // Prefill from an existing profile so this is an edit, not a blind overwrite. const { data: existingProfile } = useMyLabProfileEvent(SKATE_BUILDER_KIND, 'builder-profile'); @@ -132,6 +134,8 @@ export default function BecomeBuilderPage() { const scaleK = keys(BUILDER_SCALE_LABELS); const sel = tags('t').filter((v) => v !== 'skatebuilder'); setTitleInput(tag('title') ?? ''); + setExistingGeohash(tag('g')); + setGoPro(existingProfile.tags.some(([t]) => t === 'featured')); setBio(existingProfile.content ?? ''); setLocation(tag('location') ?? ''); setImage(tag('image') ?? null); @@ -240,6 +244,9 @@ export default function BecomeBuilderPage() { const geohash = ngeohash.encode(lat, lng, 7); tags.push(['g', geohash]); } + } else if (existingGeohash) { + // Preserve the existing map pin when editing without re-locating. + tags.push(['g', existingGeohash]); } for (const s of [...selectedServices, ...selectedSkills, ...selectedScale]) { @@ -254,9 +261,9 @@ export default function BecomeBuilderPage() { if (rate.trim()) tags.push(['rate', rate.trim()]); if (phone.trim()) tags.push(['phone', phone.trim()]); if (email.trim()) tags.push(['email', email.trim()]); - if (website.trim()) tags.push(['website', website.trim()]); - if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]); - if (social.trim()) tags.push(['social', social.trim()]); + if (website.trim()) tags.push(['website', normalizeUrlInput(website)]); + if (portfolio.trim()) tags.push(['portfolio', normalizeUrlInput(portfolio)]); + if (social.trim()) tags.push(['social', normalizeUrlInput(social)]); if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]); tags.push(['entity', entityType]); @@ -405,7 +412,7 @@ export default function BecomeBuilderPage() { value={titleInput} onChange={(e) => setTitleInput(e.target.value)} placeholder={getDisplayName()} - maxLength={120} + maxLength={100} className="bg-gray-800 border-gray-700" />

@@ -415,7 +422,8 @@ export default function BecomeBuilderPage() {

-