From f5ce4002e90ed2d62ffc4cebe81ece72dfa15dc7 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 27 Sep 2026 09:46:37 +0700 Subject: [PATCH] feat(lab,spots): validate contact details + drag & drop photo upload - Lab forms: a non-empty email must be valid and website/portfolio must resolve to an https URL; invalid input shows an inline hint and blocks publish. Adds isValidEmail() (reused by sanitizeEmail). social/instagram are exempt since they may be bare @handles. - New PhotoDropzone component: drag & drop plus multi-select image input, emitting File[] to the caller. Wired into CreateSpotPage + EditSpotPage (drop several photos at once) and AddPhotoToSpot, replacing the old one-file-at-a-time inputs. - Spot photo uploads run sequentially and dedupe URLs. --- AGENTS.md | 8 +++ src/components/AddPhotoToSpot.tsx | 50 ++++---------- src/components/CraftProfileForm.tsx | 18 ++++- src/components/PhotoDropzone.tsx | 100 +++++++++++++++++++++++++++ src/lib/sanitizeUrl.ts | 8 ++- src/pages/BecomeBuilderPage.tsx | 18 ++++- src/pages/BecomeCoachPage.tsx | 16 ++++- src/pages/BecomeEditorPage.tsx | 18 ++++- src/pages/BecomeFilmerPage.tsx | 18 ++++- src/pages/BecomePhotographerPage.tsx | 18 ++++- src/pages/CreateSpotPage.tsx | 59 +++++++--------- src/pages/EditSpotPage.tsx | 55 +++++++-------- 12 files changed, 279 insertions(+), 107 deletions(-) create mode 100644 src/components/PhotoDropzone.tsx diff --git a/AGENTS.md b/AGENTS.md index 97b916c..012c7d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,14 @@ session with **no other memory of this project**. So: `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. + Contact fields are validated on submit: a non-empty email must satisfy + `isValidEmail()`, and website/portfolio must resolve through + `sanitizeUrl(normalizeUrlInput(...))`. Invalid input shows an inline hint and + blocks publish (`social` is exempt — it may be a bare `@handle`). + - **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 + `AddPhotoToSpot`, replacing the old one-file-at-a-time inputs. - `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/AddPhotoToSpot.tsx b/src/components/AddPhotoToSpot.tsx index 6a3fc70..286bb45 100644 --- a/src/components/AddPhotoToSpot.tsx +++ b/src/components/AddPhotoToSpot.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; -import { Camera, Upload, Loader2, X } from 'lucide-react'; +import { Camera, Loader2, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; -import { Card, CardContent } from '@/components/ui/card'; +import { PhotoDropzone } from '@/components/PhotoDropzone'; import { Dialog, DialogContent, @@ -39,16 +39,15 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho const spotRef = `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`; - const handleImageUpload = async (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files || files.length === 0) return; - + const handleFiles = async (files: File[]) => { + const file = files[0]; + if (!file) return; + try { - const file = files[0]; const tags = await uploadFile(file); const url = tags.find(([name]) => name === 'url')?.[1]; const mimeType = tags.find(([name]) => name === 'm')?.[1]; - + if (url) { setUploadedImage({ url, mimeType }); toast({ @@ -63,8 +62,6 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho variant: 'destructive', }); } - - e.target.value = ''; }; const handleSubmit = async () => { @@ -166,31 +163,14 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho ) : ( - - - - - + )} {/* Caption */} diff --git a/src/components/CraftProfileForm.tsx b/src/components/CraftProfileForm.tsx index ab838ba..b53ef26 100644 --- a/src/components/CraftProfileForm.tsx +++ b/src/components/CraftProfileForm.tsx @@ -23,7 +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 { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react'; @@ -205,9 +205,24 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { const requiredSections = skillSections.filter((s) => s.required); + // Contact details are optional, but if provided they must be usable — an + // invalid email/URL would otherwise be stored and then silently hidden. + const contactError = email.trim() && !isValidEmail(email) + ? 'Enter a valid email address.' + : website.trim() && !sanitizeUrl(normalizeUrlInput(website)) + ? 'Enter a valid website URL.' + : portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio)) + ? 'Enter a valid portfolio URL.' + : ''; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (contactError) { + toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' }); + return; + } + // Enforce required skill sections for (const section of requiredSections) { const keys = section.entries.map(([k]) => k); @@ -510,6 +525,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { setSocial(e.target.value)} placeholder="Social (Instagram, etc.)" className="bg-white/5 border-white/10" /> setPortfolio(e.target.value)} placeholder={portfolioLabel} inputMode="url" className="bg-white/5 border-white/10 sm:col-span-2" /> + {contactError &&

{contactError}

} diff --git a/src/components/PhotoDropzone.tsx b/src/components/PhotoDropzone.tsx new file mode 100644 index 0000000..832a68d --- /dev/null +++ b/src/components/PhotoDropzone.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { Loader2, Upload } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface PhotoDropzoneProps { + /** Called with the image files the user picked or dropped. */ + onFiles: (files: File[]) => void | Promise; + /** Show a spinner and block input while an upload is in flight. */ + uploading?: boolean; + /** Disable interaction (e.g. while the form is submitting). */ + disabled?: boolean; + /** Allow more than one file at a time. Defaults to true. */ + multiple?: boolean; + /** Primary tile label. */ + label?: string; + /** Secondary line under the label (hidden while dragging). */ + hint?: string; + /** Wrapper classes — pass sizing such as `aspect-video` here. */ + className?: string; + /** Accessible name for the file input. Defaults to `label`. */ + inputLabel?: string; +} + +const IMAGE_RE = /^image\//; + +/** + * A drop target for images that also works as click-to-browse. + * + * Users can select or drop several photos at once, instead of adding them one + * at a time. Collecting the files is all this does — uploading is the caller's + * responsibility via `onFiles`, so each screen keeps its own toasts/progress. + */ +export function PhotoDropzone({ + onFiles, + uploading = false, + disabled = false, + multiple = true, + label = 'Add Photos', + hint = 'Drag & drop or click', + className, + inputLabel, +}: PhotoDropzoneProps) { + const [dragging, setDragging] = useState(false); + const busy = uploading || disabled; + + const emit = (fileList: FileList | null) => { + if (!fileList) return; + const files = Array.from(fileList).filter((f) => IMAGE_RE.test(f.type)); + if (files.length > 0) void onFiles(files); + }; + + return ( + + ); +} diff --git a/src/lib/sanitizeUrl.ts b/src/lib/sanitizeUrl.ts index 92d1691..779c8e3 100644 --- a/src/lib/sanitizeUrl.ts +++ b/src/lib/sanitizeUrl.ts @@ -99,11 +99,15 @@ export function sanitizeTel(value: string | undefined | null): string | null { return digits.length >= 5 ? `tel:${digits}` : null; } +/** Basic email pattern: non-empty local@domain.tld. */ +export function isValidEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); +} + export function sanitizeEmail(value: string | undefined | null): string | null { if (!value) return null; const trimmed = value.trim(); - // Basic email pattern - if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) { + if (isValidEmail(trimmed)) { return `mailto:${trimmed}`; } return null; diff --git a/src/pages/BecomeBuilderPage.tsx b/src/pages/BecomeBuilderPage.tsx index a6a4aca..518f286 100644 --- a/src/pages/BecomeBuilderPage.tsx +++ b/src/pages/BecomeBuilderPage.tsx @@ -34,7 +34,7 @@ import { isValidNoffer, type EntityType, } from '@/lib/types'; -import { normalizeUrlInput } from '@/lib/sanitizeUrl'; +import { normalizeUrlInput, sanitizeUrl, isValidEmail } 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'; @@ -216,9 +216,24 @@ export default function BecomeBuilderPage() { e.target.value = ''; }; + // Optional, but if given it must be usable — a bad email/URL would otherwise + // be stored and then silently hidden. + const contactError = email.trim() && !isValidEmail(email) + ? 'Enter a valid email address.' + : website.trim() && !sanitizeUrl(normalizeUrlInput(website)) + ? 'Enter a valid website URL.' + : portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio)) + ? 'Enter a valid portfolio URL.' + : ''; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (contactError) { + toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' }); + return; + } + if (selectedServices.length === 0) { toast({ title: 'Build types required', description: 'Select at least one thing you build.', variant: 'destructive' }); return; @@ -546,6 +561,7 @@ export default function BecomeBuilderPage() { setSocial(e.target.value)} className="bg-gray-800 border-gray-700" /> + {contactError &&

{contactError}

}
diff --git a/src/pages/BecomeCoachPage.tsx b/src/pages/BecomeCoachPage.tsx index 7a4edd3..d792089 100644 --- a/src/pages/BecomeCoachPage.tsx +++ b/src/pages/BecomeCoachPage.tsx @@ -24,7 +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 { normalizeUrlInput, sanitizeUrl, isValidEmail } 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'; @@ -162,9 +162,22 @@ export default function BecomeCoachPage() { e.target.value = ''; }; + // Optional, but if given it must be usable — a bad email/URL would otherwise + // be stored and then silently hidden. + const contactError = email.trim() && !isValidEmail(email) + ? 'Enter a valid email address.' + : website.trim() && !sanitizeUrl(normalizeUrlInput(website)) + ? 'Enter a valid website URL.' + : ''; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (contactError) { + toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' }); + return; + } + if (selectedSkills.length === 0) { toast({ title: 'Skills required', description: 'Select at least one skill.', variant: 'destructive' }); return; @@ -449,6 +462,7 @@ export default function BecomeCoachPage() { setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
+ {contactError &&

{contactError}

} diff --git a/src/pages/BecomeEditorPage.tsx b/src/pages/BecomeEditorPage.tsx index 1dc6798..f103a24 100644 --- a/src/pages/BecomeEditorPage.tsx +++ b/src/pages/BecomeEditorPage.tsx @@ -30,7 +30,7 @@ import { } from '@/lib/types'; import { EntityTypeField } from '@/components/EntityTypeField'; import { CrewLinkField } from '@/components/CrewLinkField'; -import { normalizeUrlInput } from '@/lib/sanitizeUrl'; +import { normalizeUrlInput, sanitizeUrl, isValidEmail } 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'; @@ -166,9 +166,24 @@ export default function BecomeEditorPage() { e.target.value = ''; }; + // Optional, but if given it must be usable — a bad email/URL would otherwise + // be stored and then silently hidden. + const contactError = email.trim() && !isValidEmail(email) + ? 'Enter a valid email address.' + : website.trim() && !sanitizeUrl(normalizeUrlInput(website)) + ? 'Enter a valid website URL.' + : portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio)) + ? 'Enter a valid portfolio URL.' + : ''; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (contactError) { + toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' }); + return; + } + if (selectedServices.length === 0) { toast({ title: 'Services required', description: 'Select at least one edit type.', variant: 'destructive' }); return; @@ -485,6 +500,7 @@ export default function BecomeEditorPage() { setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" /> setSocial(e.target.value)} className="bg-gray-800 border-gray-700" /> + {contactError &&

{contactError}

} diff --git a/src/pages/BecomeFilmerPage.tsx b/src/pages/BecomeFilmerPage.tsx index b4b97cd..042688b 100644 --- a/src/pages/BecomeFilmerPage.tsx +++ b/src/pages/BecomeFilmerPage.tsx @@ -24,7 +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 { normalizeUrlInput, sanitizeUrl, isValidEmail } 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'; @@ -176,9 +176,24 @@ export default function BecomeFilmerPage() { e.target.value = ''; }; + // Optional, but if given it must be usable — a bad email/URL would otherwise + // be stored and then silently hidden. + const contactError = email.trim() && !isValidEmail(email) + ? 'Enter a valid email address.' + : website.trim() && !sanitizeUrl(normalizeUrlInput(website)) + ? 'Enter a valid website URL.' + : portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio)) + ? 'Enter a valid portfolio URL.' + : ''; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (contactError) { + toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' }); + return; + } + if (selectedServices.length === 0) { toast({ title: 'Services required', description: 'Select at least one service.', variant: 'destructive' }); return; @@ -513,6 +528,7 @@ export default function BecomeFilmerPage() { setSocial(e.target.value)} className="bg-gray-800 border-gray-700" /> + {contactError &&

{contactError}

} diff --git a/src/pages/BecomePhotographerPage.tsx b/src/pages/BecomePhotographerPage.tsx index edbd5b6..5cb52ee 100644 --- a/src/pages/BecomePhotographerPage.tsx +++ b/src/pages/BecomePhotographerPage.tsx @@ -36,7 +36,7 @@ import { PHOTO_STYLE_LABELS, isValidNoffer, } from '@/lib/types'; -import { normalizeUrlInput } from '@/lib/sanitizeUrl'; +import { normalizeUrlInput, sanitizeUrl, isValidEmail } 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'; @@ -227,9 +227,24 @@ export default function BecomePhotographerPage() { e.target.value = ''; }; + // Optional, but if given it must be usable — a bad email/URL would otherwise + // be stored and then silently hidden. + const contactError = email.trim() && !isValidEmail(email) + ? 'Enter a valid email address.' + : website.trim() && !sanitizeUrl(normalizeUrlInput(website)) + ? 'Enter a valid website URL.' + : portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio)) + ? 'Enter a valid portfolio URL.' + : ''; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (contactError) { + toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' }); + return; + } + if (selectedServices.length === 0) { toast({ title: 'Services required', description: 'Select at least one service you offer.', variant: 'destructive' }); return; @@ -572,6 +587,7 @@ export default function BecomePhotographerPage() { setSocial(e.target.value)} className="bg-gray-800 border-gray-700" /> + {contactError &&

{contactError}

}
diff --git a/src/pages/CreateSpotPage.tsx b/src/pages/CreateSpotPage.tsx index 3b7a239..eead418 100644 --- a/src/pages/CreateSpotPage.tsx +++ b/src/pages/CreateSpotPage.tsx @@ -15,13 +15,13 @@ import { useToast } from '@/hooks/useToast'; import { useTranslation } from '@/contexts/language'; import { LoginArea } from '@/components/auth/LoginArea'; import { QualityMeter } from '@/components/QualityMeter'; +import { PhotoDropzone } from '@/components/PhotoDropzone'; import { TagInput } from '@/components/TagInput'; import { SKATE_SPOT_KIND } from '@/lib/types'; import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags'; import { cn } from '@/lib/utils'; import { MapPin, - Upload, X, Loader2, ArrowLeft, @@ -154,19 +154,25 @@ export default function CreateSpotPage() { ); }; - const handleImageUpload = async (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files || files.length === 0) return; - + // Upload one or more selected/dropped photos. Uploads run in sequence so we + // don't hammer Blossom with parallel requests. + const uploadImageFiles = async (files: File[]) => { + if (files.length === 0) return; + let added = 0; try { - const file = files[0]; - const tags = await uploadFile(file); - const url = tags[0]?.[1]; - if (url) { - setImages((prev) => [...prev, url]); + for (const file of files) { + const tags = await uploadFile(file); + const url = tags[0]?.[1]; + if (url) { + setImages((prev) => (prev.includes(url) ? prev : [...prev, url])); + added += 1; + } + } + if (added > 0) { toast({ - title: 'Image uploaded', - description: 'Your image has been uploaded successfully.', + title: added > 1 ? `${added} photos uploaded` : 'Image uploaded', + description: + added > 1 ? 'Your photos have been added.' : 'Your image has been uploaded successfully.', }); } } catch (error) { @@ -176,9 +182,6 @@ export default function CreateSpotPage() { variant: 'destructive', }); } - - // Clear the input - e.target.value = ''; }; // Handle camera capture @@ -713,24 +716,14 @@ export default function CreateSpotPage() { Take Photo - {/* File upload button */} - + {/* File upload / drop zone — accepts several photos at once */} +
diff --git a/src/pages/EditSpotPage.tsx b/src/pages/EditSpotPage.tsx index fa51350..131199f 100644 --- a/src/pages/EditSpotPage.tsx +++ b/src/pages/EditSpotPage.tsx @@ -15,13 +15,13 @@ import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; import { useSkateSpot } from '@/hooks/useSkateSpots'; import { LoginArea } from '@/components/auth/LoginArea'; +import { PhotoDropzone } from '@/components/PhotoDropzone'; import { TagInput } from '@/components/TagInput'; import { SKATE_SPOT_KIND } from '@/lib/types'; import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags'; import { cn } from '@/lib/utils'; import { MapPin, - Upload, X, Loader2, ArrowLeft, @@ -152,19 +152,24 @@ export default function EditSpotPage() { ); }; - const handleImageUpload = async (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files || files.length === 0) return; - + // Upload one or more selected/dropped photos, in sequence. + const uploadImageFiles = async (files: File[]) => { + if (files.length === 0) return; + let added = 0; try { - const file = files[0]; - const tags = await uploadFile(file); - const url = tags[0]?.[1]; - if (url) { - setImages((prev) => [...prev, url]); + for (const file of files) { + const tags = await uploadFile(file); + const url = tags[0]?.[1]; + if (url) { + setImages((prev) => (prev.includes(url) ? prev : [...prev, url])); + added += 1; + } + } + if (added > 0) { toast({ - title: 'Image uploaded', - description: 'Your image has been uploaded successfully.', + title: added > 1 ? `${added} photos uploaded` : 'Image uploaded', + description: + added > 1 ? 'Your photos have been added.' : 'Your image has been uploaded successfully.', }); } } catch (error) { @@ -174,8 +179,6 @@ export default function EditSpotPage() { variant: 'destructive', }); } - - e.target.value = ''; }; const removeImage = (url: string) => { @@ -648,23 +651,13 @@ export default function EditSpotPage() { ))} - +