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:
parent
4be5f64549
commit
f73a360983
15 changed files with 210 additions and 39 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue