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.
This commit is contained in:
parent
f73a360983
commit
f5ce4002e9
12 changed files with 279 additions and 107 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>) => {
|
||||
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
|
|||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-8">
|
||||
<label className="flex flex-col items-center cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground mb-2" />
|
||||
<span className="text-sm text-muted-foreground">Uploading...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-10 w-10 text-muted-foreground mb-2" />
|
||||
<span className="text-sm text-muted-foreground">Click to upload a photo</span>
|
||||
<span className="text-xs text-muted-foreground mt-1">JPG, PNG, WebP</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PhotoDropzone
|
||||
onFiles={handleFiles}
|
||||
uploading={isUploading}
|
||||
multiple={false}
|
||||
label="Upload a photo"
|
||||
hint="Drag & drop or click · JPG, PNG, WebP"
|
||||
className="py-10"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Caption */}
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<Input value={social} onChange={(e) => setSocial(e.target.value)} placeholder="Social (Instagram, etc.)" className="bg-white/5 border-white/10" />
|
||||
<Input value={portfolio} onChange={(e) => setPortfolio(e.target.value)} placeholder={portfolioLabel} inputMode="url" className="bg-white/5 border-white/10 sm:col-span-2" />
|
||||
</div>
|
||||
{contactError && <p className="text-xs text-red-400">{contactError}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
100
src/components/PhotoDropzone.tsx
Normal file
100
src/components/PhotoDropzone.tsx
Normal file
|
|
@ -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<void>;
|
||||
/** 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 (
|
||||
<label
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
if (!busy) setDragging(true);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (!busy) setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (!busy) emit(e.dataTransfer.files);
|
||||
}}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed px-2 text-center cursor-pointer transition-colors',
|
||||
dragging ? 'border-[#ccff00] bg-[#ccff00]/10' : 'border-gray-600 hover:bg-muted/50 hover:border-gray-500',
|
||||
busy && 'opacity-60 cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple={multiple}
|
||||
className="hidden"
|
||||
disabled={busy}
|
||||
aria-label={inputLabel ?? label}
|
||||
onChange={(e) => {
|
||||
emit(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{busy ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[#ccff00]" />
|
||||
) : (
|
||||
<>
|
||||
<Upload className={cn('h-6 w-6 mb-1', dragging ? 'text-[#ccff00]' : 'text-muted-foreground')} />
|
||||
<span className={cn('text-xs font-bold', dragging ? 'text-[#ccff00]' : 'text-muted-foreground')}>
|
||||
{dragging ? 'Drop photos' : label}
|
||||
</span>
|
||||
{hint && <span className="mt-0.5 text-[10px] text-muted-foreground/80">{hint}</span>}
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
|
||||
<PaymentReadinessNote />
|
||||
<NofferField value={noffer} onChange={setNoffer} />
|
||||
<div className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5">
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Input id="instagram" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
|
||||
|
||||
<PaymentReadinessNote />
|
||||
<NofferField value={noffer} onChange={setNoffer} />
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Input placeholder="Website" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder="Instagram / Social handle" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
|
||||
<PaymentReadinessNote />
|
||||
<NofferField value={noffer} onChange={setNoffer} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Input id="social" placeholder="@username" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
|
||||
|
||||
<PaymentReadinessNote />
|
||||
<NofferField value={noffer} onChange={setNoffer} />
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
|
||||
<PaymentReadinessNote />
|
||||
<NofferField value={noffer} onChange={setNoffer} />
|
||||
<div className="p-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5">
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>) => {
|
||||
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() {
|
|||
<Camera className="h-6 w-6 text-[#ff0040] mb-1" />
|
||||
<span className="text-xs text-[#ff0040] font-bold">Take Photo</span>
|
||||
</button>
|
||||
{/* File upload button */}
|
||||
<label className="aspect-video rounded-lg border-2 border-dashed border-gray-600 flex flex-col items-center justify-center cursor-pointer hover:bg-muted/50 hover:border-gray-500 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-6 w-6 text-muted-foreground mb-1" />
|
||||
<span className="text-xs text-muted-foreground">{t('createSpot.uploadPhotos')}</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
{/* File upload / drop zone — accepts several photos at once */}
|
||||
<PhotoDropzone
|
||||
onFiles={uploadImageFiles}
|
||||
uploading={isUploading}
|
||||
label={t('createSpot.uploadPhotos')}
|
||||
hint={t('createSpot.dragDrop')}
|
||||
className="aspect-video"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>) => {
|
||||
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() {
|
|||
</button>
|
||||
</div>
|
||||
))}
|
||||
<label className="aspect-video rounded-lg border-2 border-dashed flex flex-col items-center justify-center cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-6 w-6 text-muted-foreground mb-1" />
|
||||
<span className="text-xs text-muted-foreground">Upload</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
<PhotoDropzone
|
||||
onFiles={uploadImageFiles}
|
||||
uploading={isUploading}
|
||||
label="Upload"
|
||||
hint="Drag & drop or click"
|
||||
className="aspect-video"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue