fix(lab): complete offer-a-service audit — data preservation, rendering, stats, input handling

- 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.
This commit is contained in:
SkateSpots 2026-09-27 09:38:03 +07:00
parent 4be5f64549
commit f73a360983
15 changed files with 210 additions and 39 deletions

View file

@ -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

View file

@ -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<string | undefined>(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"
/>
<p className="mt-1 text-xs text-gray-500">
@ -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}
/>
<div className="mt-1 flex justify-end">
<span className="text-[10px] text-gray-500">{bio.length}/3000</span>
</div>
</div>
</CardContent>
</Card>

View file

@ -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<CrewFocus, 'mixed'> {
/** Translate the Lab page's ServiceType into a crew focus scene (or null). */
function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed'> | 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;

View file

@ -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<CrewFocus, 'mixed'>, limit = 30) {
export function useCrewsByFocus(focus: Exclude<CrewFocus, 'mixed'> | 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);

View file

@ -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,

View file

@ -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")
*/

View file

@ -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;

View file

@ -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<string | undefined>(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"
/>
<p className="text-xs text-gray-500">
@ -415,7 +422,8 @@ export default function BecomeBuilderPage() {
<div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Builds</Label>
<Textarea id="bio" placeholder="Your crew, notable builds, how you approach a project..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
<Textarea id="bio" placeholder="Your crew, notable builds, how you approach a project..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
<p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
</div>
{/* Individual vs Collective — defaults to collective for builders */}

View file

@ -24,6 +24,7 @@ import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
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 { normalizeUrlInput } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
@ -73,6 +74,7 @@ export default function BecomeCoachPage() {
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [titleInput, setTitleInput] = useState('');
const [prefilled, setPrefilled] = useState(false);
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
// Prefill from an existing profile so this is an edit, not a blind overwrite.
const { data: existingProfile } = useMyLabProfileEvent(SKATE_COACH_KIND, 'coach-profile');
@ -82,6 +84,8 @@ export default function BecomeCoachPage() {
const tag = (n: string) => existingProfile.tags.find(([t]) => t === n)?.[1];
const tags = (n: string) => existingProfile.tags.filter(([t]) => t === n).map(([, v]) => v);
setTitleInput(tag('title') ?? '');
setExistingGeohash(tag('g'));
setGoPro(existingProfile.tags.some(([t]) => t === 'featured'));
setBio(existingProfile.content ?? '');
setLocation(tag('location') ?? '');
setImage(tag('image') ?? null);
@ -186,6 +190,9 @@ export default function BecomeCoachPage() {
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 skill of selectedSkills) tags.push(['t', skill]);
@ -194,7 +201,7 @@ export default function BecomeCoachPage() {
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 (website.trim()) tags.push(['website', normalizeUrlInput(website)]);
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
@ -353,7 +360,7 @@ export default function BecomeCoachPage() {
value={titleInput}
onChange={(e) => setTitleInput(e.target.value)}
placeholder={getDisplayName()}
maxLength={120}
maxLength={100}
className="bg-gray-800 border-gray-700"
/>
<p className="text-xs text-gray-500">
@ -363,7 +370,8 @@ export default function BecomeCoachPage() {
<div className="space-y-2">
<Label htmlFor="bio">Bio / Coaching Philosophy</Label>
<Textarea id="bio" placeholder="Your background, teaching style..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
<Textarea id="bio" placeholder="Your background, teaching style..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
<p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
</div>
<EntityTypeField

View file

@ -30,6 +30,7 @@ import {
} from '@/lib/types';
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { normalizeUrlInput } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
@ -82,6 +83,7 @@ export default function BecomeEditorPage() {
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [titleInput, setTitleInput] = useState('');
const [prefilled, setPrefilled] = useState(false);
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
// Prefill from an existing profile so this is an edit, not a blind overwrite.
const { data: existingProfile } = useMyLabProfileEvent(SKATE_EDITOR_KIND, 'editor-profile');
@ -94,6 +96,8 @@ export default function BecomeEditorPage() {
const skillKeys = new Set(Object.keys(EDITOR_SKILL_LABELS));
const sel = tags('t').filter((v) => v !== 'skateeditor');
setTitleInput(tag('title') ?? '');
setExistingGeohash(tag('g'));
setGoPro(existingProfile.tags.some(([t]) => t === 'featured'));
setBio(existingProfile.content ?? '');
setLocation(tag('location') ?? '');
setImage(tag('image') ?? null);
@ -186,6 +190,9 @@ export default function BecomeEditorPage() {
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 editing without re-locating.
tags.push(['g', existingGeohash]);
}
for (const s of selectedServices) tags.push(['t', s]);
for (const s of selectedSkills) tags.push(['t', s]);
@ -194,9 +201,9 @@ export default function BecomeEditorPage() {
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]);
@ -333,7 +340,7 @@ export default function BecomeEditorPage() {
value={titleInput}
onChange={(e) => setTitleInput(e.target.value)}
placeholder={getDisplayName()}
maxLength={120}
maxLength={100}
className="bg-gray-800 border-gray-700"
/>
<p className="text-xs text-gray-500">
@ -363,7 +370,8 @@ export default function BecomeEditorPage() {
{/* Bio */}
<div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Work</Label>
<Textarea id="bio" placeholder="Your editing style, background, notable projects…" value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
<Textarea id="bio" placeholder="Your editing style, background, notable projects…" value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
<p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
</div>
<EntityTypeField

View file

@ -24,6 +24,7 @@ import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { SKATE_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
import { normalizeUrlInput } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
@ -77,6 +78,7 @@ export default function BecomeFilmerPage() {
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [titleInput, setTitleInput] = useState('');
const [prefilled, setPrefilled] = useState(false);
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
// Prefill from an existing profile so this is an edit, not a blind overwrite.
const { data: existingProfile } = useMyLabProfileEvent(SKATE_FILMER_KIND, 'filming-profile');
@ -89,6 +91,8 @@ export default function BecomeFilmerPage() {
const skillKeys = new Set(Object.keys(FILMING_SKILL_LABELS));
const sel = tags('t').filter((v) => v !== 'skatefilmer');
setTitleInput(tag('title') ?? '');
setExistingGeohash(tag('g'));
setGoPro(existingProfile.tags.some(([t]) => t === 'featured'));
setBio(existingProfile.content ?? '');
setLocation(tag('location') ?? '');
setImage(tag('image') ?? null);
@ -205,6 +209,9 @@ export default function BecomeFilmerPage() {
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 service of selectedServices) tags.push(['t', service]);
@ -214,9 +221,9 @@ export default function BecomeFilmerPage() {
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]);
@ -375,7 +382,7 @@ export default function BecomeFilmerPage() {
value={titleInput}
onChange={(e) => setTitleInput(e.target.value)}
placeholder={getDisplayName()}
maxLength={120}
maxLength={100}
className="bg-gray-800 border-gray-700"
/>
<p className="text-xs text-gray-500">
@ -385,7 +392,8 @@ export default function BecomeFilmerPage() {
<div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Work</Label>
<Textarea id="bio" placeholder="Your background, style, equipment..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
<Textarea id="bio" placeholder="Your background, style, equipment..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
<p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
</div>
<EntityTypeField

View file

@ -36,6 +36,7 @@ import {
PHOTO_STYLE_LABELS,
isValidNoffer,
} from '@/lib/types';
import { normalizeUrlInput } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Aperture } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
@ -125,6 +126,7 @@ export default function BecomePhotographerPage() {
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [titleInput, setTitleInput] = useState('');
const [prefilled, setPrefilled] = useState(false);
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
// Prefill from an existing profile so this is an edit, not a blind overwrite.
const { data: existingProfile } = useMyLabProfileEvent(SKATE_PHOTOGRAPHER_KIND, 'photography-profile');
@ -142,6 +144,8 @@ export default function BecomePhotographerPage() {
const styleK = keys(PHOTO_STYLE_LABELS);
const sel = tags('t').filter((v) => v !== 'skatephotographer');
setTitleInput(tag('title') ?? '');
setExistingGeohash(tag('g'));
setGoPro(existingProfile.tags.some(([t]) => t === 'featured'));
setBio(existingProfile.content ?? '');
setLocation(tag('location') ?? '');
setImage(tag('image') ?? null);
@ -255,6 +259,9 @@ export default function BecomePhotographerPage() {
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]);
}
// All selections become queryable `t` tags
@ -275,9 +282,9 @@ export default function BecomePhotographerPage() {
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]);
@ -426,7 +433,7 @@ export default function BecomePhotographerPage() {
value={titleInput}
onChange={(e) => setTitleInput(e.target.value)}
placeholder={getDisplayName()}
maxLength={120}
maxLength={100}
className="bg-gray-800 border-gray-700"
/>
<p className="text-xs text-gray-500">
@ -436,7 +443,8 @@ export default function BecomePhotographerPage() {
<div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Work</Label>
<Textarea id="bio" placeholder="Your background, style, what you love shooting..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
<Textarea id="bio" placeholder="Your background, style, what you love shooting..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
<p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
</div>
{/* Individual vs Collective */}

View file

@ -102,6 +102,9 @@ export default function CoachDetailPage() {
const isTattoo = profile?.profileType === 'tattoo';
const isTrainer = profile?.profileType === 'trainer';
const isMusician = profile?.profileType === 'musician';
const isEditor = profile?.profileType === 'editor';
const isJudge = profile?.profileType === 'judge';
const isOrganizer = profile?.profileType === 'organizer';
useSeoMeta({
title: profile ? `${profile.title} - SkateSpots` : 'Profile - SkateSpots',
@ -326,6 +329,72 @@ export default function CoachDetailPage() {
</div>
)}
{/* Editor — hardware & software */}
{isEditor && (pget('hardware') || pget('software')) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{pget('hardware') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1">Hardware</h4>
<p className="text-sm text-gray-300">{pget('hardware')}</p>
</div>
)}
{pget('software') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1">Software</h4>
<p className="text-sm text-gray-300">{pget('software')}</p>
</div>
)}
</div>
)}
{/* Judge — events judged */}
{isJudge && pget('events_judged') && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
<Gavel className={`h-4 w-4 ${accent.text}`} /> Events Judged
</h3>
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
{pget('events_judged')}
</p>
</div>
)}
{/* Organizer — events organized */}
{isOrganizer && pget('events_hosted') && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
<Tent className={`h-4 w-4 ${accent.text}`} /> Events Organized
</h3>
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
{pget('events_hosted')}
</p>
</div>
)}
{/* Spots — places this provider works / skates */}
{'spotRefs' in profile && profile.spotRefs && profile.spotRefs.length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
<MapPin className={`h-4 w-4 ${accent.text}`} /> Spots
</h3>
<div className="flex flex-wrap gap-2">
{profile.spotRefs.map((ref) => {
const [kind, pk, ...rest] = ref.split(':');
if (kind !== '30818' || !pk || rest.length === 0) return null;
return (
<Link
key={ref}
to={`/spot/${pk}/${rest.join(':')}`}
className={`text-xs font-bold px-3 py-1.5 rounded-full border ${accent.border} text-gray-200 hover:bg-white/5`}
>
<MapPin className="inline h-3 w-3 mr-1" /> View spot
</Link>
);
})}
</div>
</div>
)}
{/* Builder — service area & insured */}
{isBuilder && (pget('serviceArea') || pbool('insured')) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">

View file

@ -13,7 +13,7 @@ import { cn } from '@/lib/utils';
const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z';
import { SKATE_RELAY } from '@/lib/types';
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts';
import { MapPin, Store, Calendar, GraduationCap, Video, Scissors, Route, Users, Users2, Camera, Hammer, TrendingUp, Radio, Zap, ExternalLink, Trophy, FlaskConical, Palette, SprayCan, PenTool, Dumbbell, Music, Gavel } from 'lucide-react';
import { MapPin, Store, Calendar, GraduationCap, Video, Scissors, Route, Users, Users2, Camera, Hammer, TrendingUp, Radio, Zap, ExternalLink, Trophy, FlaskConical, Palette, SprayCan, PenTool, Dumbbell, Music, Gavel, Tent } from 'lucide-react';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@ -22,12 +22,12 @@ function labCraftTotal(s: {
totalCoaches: number; totalFilmers: number; totalEditors: number;
totalPhotographers: number; totalBuilders: number; totalArtists: number;
totalGraffiti: number; totalTattoo: number; totalTrainers: number;
totalMusicians: number; totalJudges: number;
totalMusicians: number; totalJudges: number; totalOrganizers: number;
}): number {
return (
s.totalCoaches + s.totalFilmers + s.totalEditors + s.totalPhotographers +
s.totalBuilders + s.totalArtists + s.totalGraffiti + s.totalTattoo +
s.totalTrainers + s.totalMusicians + s.totalJudges
s.totalTrainers + s.totalMusicians + s.totalJudges + s.totalOrganizers
);
}
@ -261,6 +261,7 @@ export default function CommunityStatsPage() {
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label="Trainers" color="#34d399" loading={isLoading} to="/lab?craft=training" />
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label="Musicians" color="#818cf8" loading={isLoading} to="/lab?craft=music" />
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label="Judges" color="#fbbf24" loading={isLoading} to="/lab?craft=judging" />
<StatCard icon={Tent} value={stats?.totalOrganizers ?? 0} label="Organizers" color="#22d3ee" loading={isLoading} to="/lab?craft=organizing" />
</div>
</section>

View file

@ -370,7 +370,7 @@ function OfferFAB() {
{/* Dropdown */}
{open && (
<div className="absolute right-0 top-full mt-2 w-52 rounded-xl border border-white/10 bg-black/95 shadow-2xl shadow-black/60 backdrop-blur z-50 overflow-hidden">
<div className="p-1.5 space-y-0.5">
<div className="p-1.5 space-y-0.5 max-h-[70vh] overflow-y-auto">
{items.map(({ to, label, color, icon: Icon }) => (
<Link
key={to}