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
|
`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
|
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.
|
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
|
- `24899` spot chat · `2259` spot status · `32671` Sasquatch Pro · `7103` event updates
|
||||||
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
|
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
|
||||||
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
|
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useState } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { PhotoDropzone } from '@/components/PhotoDropzone';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -39,16 +39,15 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
|
||||||
|
|
||||||
const spotRef = `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`;
|
const spotRef = `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`;
|
||||||
|
|
||||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFiles = async (files: File[]) => {
|
||||||
const files = e.target.files;
|
const file = files[0];
|
||||||
if (!files || files.length === 0) return;
|
if (!file) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const file = files[0];
|
|
||||||
const tags = await uploadFile(file);
|
const tags = await uploadFile(file);
|
||||||
const url = tags.find(([name]) => name === 'url')?.[1];
|
const url = tags.find(([name]) => name === 'url')?.[1];
|
||||||
const mimeType = tags.find(([name]) => name === 'm')?.[1];
|
const mimeType = tags.find(([name]) => name === 'm')?.[1];
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
setUploadedImage({ url, mimeType });
|
setUploadedImage({ url, mimeType });
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -63,8 +62,6 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
e.target.value = '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
|
@ -166,31 +163,14 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="border-dashed">
|
<PhotoDropzone
|
||||||
<CardContent className="py-8">
|
onFiles={handleFiles}
|
||||||
<label className="flex flex-col items-center cursor-pointer">
|
uploading={isUploading}
|
||||||
<input
|
multiple={false}
|
||||||
type="file"
|
label="Upload a photo"
|
||||||
accept="image/*"
|
hint="Drag & drop or click · JPG, PNG, WebP"
|
||||||
className="hidden"
|
className="py-10"
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Caption */}
|
{/* Caption */}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import { LabProUpgrade } from '@/components/LabProUpgrade';
|
||||||
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
||||||
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
|
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
|
||||||
import { isValidNoffer, type EntityType } from '@/lib/types';
|
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 { cn } from '@/lib/utils';
|
||||||
import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react';
|
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);
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (contactError) {
|
||||||
|
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Enforce required skill sections
|
// Enforce required skill sections
|
||||||
for (const section of requiredSections) {
|
for (const section of requiredSections) {
|
||||||
const keys = section.entries.map(([k]) => k);
|
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={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" />
|
<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>
|
</div>
|
||||||
|
{contactError && <p className="text-xs text-red-400">{contactError}</p>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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;
|
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 {
|
export function sanitizeEmail(value: string | undefined | null): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
// Basic email pattern
|
if (isValidEmail(trimmed)) {
|
||||||
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
|
|
||||||
return `mailto:${trimmed}`;
|
return `mailto:${trimmed}`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import {
|
||||||
isValidNoffer,
|
isValidNoffer,
|
||||||
type EntityType,
|
type EntityType,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { normalizeUrlInput } from '@/lib/sanitizeUrl';
|
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Hammer } from 'lucide-react';
|
import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Hammer } from 'lucide-react';
|
||||||
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
||||||
|
|
@ -216,9 +216,24 @@ export default function BecomeBuilderPage() {
|
||||||
e.target.value = '';
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (contactError) {
|
||||||
|
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedServices.length === 0) {
|
if (selectedServices.length === 0) {
|
||||||
toast({ title: 'Build types required', description: 'Select at least one thing you build.', variant: 'destructive' });
|
toast({ title: 'Build types required', description: 'Select at least one thing you build.', variant: 'destructive' });
|
||||||
return;
|
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" />
|
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
|
||||||
<PaymentReadinessNote />
|
<PaymentReadinessNote />
|
||||||
<NofferField value={noffer} onChange={setNoffer} />
|
<NofferField value={noffer} onChange={setNoffer} />
|
||||||
<div className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5">
|
<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 { EntityTypeField } from '@/components/EntityTypeField';
|
||||||
import { CrewLinkField } from '@/components/CrewLinkField';
|
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 { 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 { cn } from '@/lib/utils';
|
||||||
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
|
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
|
||||||
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
||||||
|
|
@ -162,9 +162,22 @@ export default function BecomeCoachPage() {
|
||||||
e.target.value = '';
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (contactError) {
|
||||||
|
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedSkills.length === 0) {
|
if (selectedSkills.length === 0) {
|
||||||
toast({ title: 'Skills required', description: 'Select at least one skill.', variant: 'destructive' });
|
toast({ title: 'Skills required', description: 'Select at least one skill.', variant: 'destructive' });
|
||||||
return;
|
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" />
|
<Input id="instagram" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
|
||||||
|
|
||||||
<PaymentReadinessNote />
|
<PaymentReadinessNote />
|
||||||
<NofferField value={noffer} onChange={setNoffer} />
|
<NofferField value={noffer} onChange={setNoffer} />
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import {
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { EntityTypeField } from '@/components/EntityTypeField';
|
import { EntityTypeField } from '@/components/EntityTypeField';
|
||||||
import { CrewLinkField } from '@/components/CrewLinkField';
|
import { CrewLinkField } from '@/components/CrewLinkField';
|
||||||
import { normalizeUrlInput } from '@/lib/sanitizeUrl';
|
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor } from 'lucide-react';
|
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor } from 'lucide-react';
|
||||||
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
||||||
|
|
@ -166,9 +166,24 @@ export default function BecomeEditorPage() {
|
||||||
e.target.value = '';
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (contactError) {
|
||||||
|
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedServices.length === 0) {
|
if (selectedServices.length === 0) {
|
||||||
toast({ title: 'Services required', description: 'Select at least one edit type.', variant: 'destructive' });
|
toast({ title: 'Services required', description: 'Select at least one edit type.', variant: 'destructive' });
|
||||||
return;
|
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="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" />
|
<Input placeholder="Instagram / Social handle" 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 />
|
<PaymentReadinessNote />
|
||||||
<NofferField value={noffer} onChange={setNoffer} />
|
<NofferField value={noffer} onChange={setNoffer} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
|
||||||
import { EntityTypeField } from '@/components/EntityTypeField';
|
import { EntityTypeField } from '@/components/EntityTypeField';
|
||||||
import { CrewLinkField } from '@/components/CrewLinkField';
|
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 { 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 { cn } from '@/lib/utils';
|
||||||
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
|
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
|
||||||
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
||||||
|
|
@ -176,9 +176,24 @@ export default function BecomeFilmerPage() {
|
||||||
e.target.value = '';
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (contactError) {
|
||||||
|
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedServices.length === 0) {
|
if (selectedServices.length === 0) {
|
||||||
toast({ title: 'Services required', description: 'Select at least one service.', variant: 'destructive' });
|
toast({ title: 'Services required', description: 'Select at least one service.', variant: 'destructive' });
|
||||||
return;
|
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" />
|
<Input id="social" placeholder="@username" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
|
||||||
|
|
||||||
<PaymentReadinessNote />
|
<PaymentReadinessNote />
|
||||||
<NofferField value={noffer} onChange={setNoffer} />
|
<NofferField value={noffer} onChange={setNoffer} />
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ import {
|
||||||
PHOTO_STYLE_LABELS,
|
PHOTO_STYLE_LABELS,
|
||||||
isValidNoffer,
|
isValidNoffer,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { normalizeUrlInput } from '@/lib/sanitizeUrl';
|
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Aperture } from 'lucide-react';
|
import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Aperture } from 'lucide-react';
|
||||||
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
||||||
|
|
@ -227,9 +227,24 @@ export default function BecomePhotographerPage() {
|
||||||
e.target.value = '';
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (contactError) {
|
||||||
|
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedServices.length === 0) {
|
if (selectedServices.length === 0) {
|
||||||
toast({ title: 'Services required', description: 'Select at least one service you offer.', variant: 'destructive' });
|
toast({ title: 'Services required', description: 'Select at least one service you offer.', variant: 'destructive' });
|
||||||
return;
|
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" />
|
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
|
||||||
<PaymentReadinessNote />
|
<PaymentReadinessNote />
|
||||||
<NofferField value={noffer} onChange={setNoffer} />
|
<NofferField value={noffer} onChange={setNoffer} />
|
||||||
<div className="p-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5">
|
<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 { useTranslation } from '@/contexts/language';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { QualityMeter } from '@/components/QualityMeter';
|
import { QualityMeter } from '@/components/QualityMeter';
|
||||||
|
import { PhotoDropzone } from '@/components/PhotoDropzone';
|
||||||
import { TagInput } from '@/components/TagInput';
|
import { TagInput } from '@/components/TagInput';
|
||||||
import { SKATE_SPOT_KIND } from '@/lib/types';
|
import { SKATE_SPOT_KIND } from '@/lib/types';
|
||||||
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
|
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
MapPin,
|
MapPin,
|
||||||
Upload,
|
|
||||||
X,
|
X,
|
||||||
Loader2,
|
Loader2,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
|
@ -154,19 +154,25 @@ export default function CreateSpotPage() {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
// Upload one or more selected/dropped photos. Uploads run in sequence so we
|
||||||
const files = e.target.files;
|
// don't hammer Blossom with parallel requests.
|
||||||
if (!files || files.length === 0) return;
|
const uploadImageFiles = async (files: File[]) => {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
let added = 0;
|
||||||
try {
|
try {
|
||||||
const file = files[0];
|
for (const file of files) {
|
||||||
const tags = await uploadFile(file);
|
const tags = await uploadFile(file);
|
||||||
const url = tags[0]?.[1];
|
const url = tags[0]?.[1];
|
||||||
if (url) {
|
if (url) {
|
||||||
setImages((prev) => [...prev, url]);
|
setImages((prev) => (prev.includes(url) ? prev : [...prev, url]));
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added > 0) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Image uploaded',
|
title: added > 1 ? `${added} photos uploaded` : 'Image uploaded',
|
||||||
description: 'Your image has been uploaded successfully.',
|
description:
|
||||||
|
added > 1 ? 'Your photos have been added.' : 'Your image has been uploaded successfully.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -176,9 +182,6 @@ export default function CreateSpotPage() {
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the input
|
|
||||||
e.target.value = '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle camera capture
|
// Handle camera capture
|
||||||
|
|
@ -713,24 +716,14 @@ export default function CreateSpotPage() {
|
||||||
<Camera className="h-6 w-6 text-[#ff0040] mb-1" />
|
<Camera className="h-6 w-6 text-[#ff0040] mb-1" />
|
||||||
<span className="text-xs text-[#ff0040] font-bold">Take Photo</span>
|
<span className="text-xs text-[#ff0040] font-bold">Take Photo</span>
|
||||||
</button>
|
</button>
|
||||||
{/* File upload button */}
|
{/* File upload / drop zone — accepts several photos at once */}
|
||||||
<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">
|
<PhotoDropzone
|
||||||
<input
|
onFiles={uploadImageFiles}
|
||||||
type="file"
|
uploading={isUploading}
|
||||||
accept="image/*"
|
label={t('createSpot.uploadPhotos')}
|
||||||
className="hidden"
|
hint={t('createSpot.dragDrop')}
|
||||||
onChange={handleImageUpload}
|
className="aspect-video"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,13 @@ import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useSkateSpot } from '@/hooks/useSkateSpots';
|
import { useSkateSpot } from '@/hooks/useSkateSpots';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
|
import { PhotoDropzone } from '@/components/PhotoDropzone';
|
||||||
import { TagInput } from '@/components/TagInput';
|
import { TagInput } from '@/components/TagInput';
|
||||||
import { SKATE_SPOT_KIND } from '@/lib/types';
|
import { SKATE_SPOT_KIND } from '@/lib/types';
|
||||||
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
|
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
MapPin,
|
MapPin,
|
||||||
Upload,
|
|
||||||
X,
|
X,
|
||||||
Loader2,
|
Loader2,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
|
@ -152,19 +152,24 @@ export default function EditSpotPage() {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
// Upload one or more selected/dropped photos, in sequence.
|
||||||
const files = e.target.files;
|
const uploadImageFiles = async (files: File[]) => {
|
||||||
if (!files || files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
|
let added = 0;
|
||||||
try {
|
try {
|
||||||
const file = files[0];
|
for (const file of files) {
|
||||||
const tags = await uploadFile(file);
|
const tags = await uploadFile(file);
|
||||||
const url = tags[0]?.[1];
|
const url = tags[0]?.[1];
|
||||||
if (url) {
|
if (url) {
|
||||||
setImages((prev) => [...prev, url]);
|
setImages((prev) => (prev.includes(url) ? prev : [...prev, url]));
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added > 0) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Image uploaded',
|
title: added > 1 ? `${added} photos uploaded` : 'Image uploaded',
|
||||||
description: 'Your image has been uploaded successfully.',
|
description:
|
||||||
|
added > 1 ? 'Your photos have been added.' : 'Your image has been uploaded successfully.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -174,8 +179,6 @@ export default function EditSpotPage() {
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
e.target.value = '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeImage = (url: string) => {
|
const removeImage = (url: string) => {
|
||||||
|
|
@ -648,23 +651,13 @@ export default function EditSpotPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<PhotoDropzone
|
||||||
<input
|
onFiles={uploadImageFiles}
|
||||||
type="file"
|
uploading={isUploading}
|
||||||
accept="image/*"
|
label="Upload"
|
||||||
className="hidden"
|
hint="Drag & drop or click"
|
||||||
onChange={handleImageUpload}
|
className="aspect-video"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue