The entire skate-spots codebase was accidentally copied into src/src/ and public/public/ instead of src/ and public/ directly. This left the real src/ directory as blank MKStack template stubs, explaining why the landing page showed the default template instead of SkateSpots. Changes made: - Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx, all pages, components, hooks, contexts, lib, i18n, test) - Copied all files from public/public/ up to public/ (images, manifest, etc.) - Deleted src/src/ and public/public/ nested directories - Removed HelloWorld.tsx template stub from pages/ - Retained the previously fixed index.css (Tailwind v3 directives) Full skate-spots feature set is now active: - All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.) - NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler - Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.) - Outfit Variable font, Amber pre-check in main.tsx - All hooks (useSkateSpots, useCoaches, useZaps, etc.) - i18n support (en/es) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
231 lines
6.2 KiB
TypeScript
231 lines
6.2 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import {
|
|
Zap,
|
|
Camera,
|
|
FileText,
|
|
MapPin,
|
|
Phone,
|
|
Globe,
|
|
Clock,
|
|
Check,
|
|
Circle,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface QualityCheckItem {
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
points: number;
|
|
completed: boolean;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
}
|
|
|
|
interface QualityMeterProps {
|
|
type: 'spot' | 'shop';
|
|
// Form state checks
|
|
hasLocation: boolean;
|
|
hasPhotos: boolean;
|
|
photoCount: number;
|
|
hasDescription: boolean;
|
|
descriptionLength: number;
|
|
// Shop-specific
|
|
hasPhone?: boolean;
|
|
hasWebsite?: boolean;
|
|
hasHours?: boolean;
|
|
// Spot-specific
|
|
hasSpotType?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
export function QualityMeter({
|
|
type,
|
|
hasLocation,
|
|
hasPhotos,
|
|
photoCount,
|
|
hasDescription,
|
|
descriptionLength,
|
|
hasPhone,
|
|
hasWebsite,
|
|
hasHours,
|
|
hasSpotType,
|
|
className,
|
|
}: QualityMeterProps) {
|
|
const checks = useMemo(() => {
|
|
const items: QualityCheckItem[] = [
|
|
{
|
|
id: 'location',
|
|
label: 'Location added',
|
|
points: 20,
|
|
completed: hasLocation,
|
|
icon: MapPin,
|
|
},
|
|
{
|
|
id: 'photo1',
|
|
label: 'At least 1 photo',
|
|
points: 20,
|
|
completed: hasPhotos,
|
|
icon: Camera,
|
|
},
|
|
{
|
|
id: 'photo3',
|
|
label: '3+ photos',
|
|
description: 'More angles = better',
|
|
points: 15,
|
|
completed: photoCount >= 3,
|
|
icon: Camera,
|
|
},
|
|
{
|
|
id: 'description',
|
|
label: 'Description',
|
|
points: 15,
|
|
completed: hasDescription && descriptionLength >= 20,
|
|
icon: FileText,
|
|
},
|
|
{
|
|
id: 'description-detailed',
|
|
label: 'Detailed description (100+ chars)',
|
|
points: 10,
|
|
completed: descriptionLength >= 100,
|
|
icon: FileText,
|
|
},
|
|
];
|
|
|
|
if (type === 'shop') {
|
|
items.push(
|
|
{
|
|
id: 'contact',
|
|
label: 'Contact info',
|
|
description: 'Phone or website',
|
|
points: 10,
|
|
completed: !!hasPhone || !!hasWebsite,
|
|
icon: Phone,
|
|
},
|
|
{
|
|
id: 'hours',
|
|
label: 'Opening hours',
|
|
points: 10,
|
|
completed: !!hasHours,
|
|
icon: Clock,
|
|
}
|
|
);
|
|
} else {
|
|
items.push({
|
|
id: 'spot-type',
|
|
label: 'Spot type selected',
|
|
points: 10,
|
|
completed: !!hasSpotType,
|
|
icon: MapPin,
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}, [type, hasLocation, hasPhotos, photoCount, hasDescription, descriptionLength, hasPhone, hasWebsite, hasHours, hasSpotType]);
|
|
|
|
const { score, maxScore, completedItems, totalItems } = useMemo(() => {
|
|
const completed = checks.filter(c => c.completed);
|
|
const total = checks.reduce((sum, c) => sum + c.points, 0);
|
|
const current = completed.reduce((sum, c) => sum + c.points, 0);
|
|
return {
|
|
score: current,
|
|
maxScore: total,
|
|
completedItems: completed.length,
|
|
totalItems: checks.length,
|
|
};
|
|
}, [checks]);
|
|
|
|
const percentage = Math.round((score / maxScore) * 100);
|
|
|
|
const getScoreColor = () => {
|
|
if (percentage >= 80) return 'text-green-400';
|
|
if (percentage >= 50) return 'text-[#ccff00]';
|
|
if (percentage >= 30) return 'text-yellow-400';
|
|
return 'text-orange-400';
|
|
};
|
|
|
|
const getProgressColor = () => {
|
|
if (percentage >= 80) return 'bg-green-500';
|
|
if (percentage >= 50) return 'bg-[#ccff00]';
|
|
if (percentage >= 30) return 'bg-yellow-500';
|
|
return 'bg-orange-500';
|
|
};
|
|
|
|
const getScoreLabel = () => {
|
|
if (percentage >= 80) return 'Excellent!';
|
|
if (percentage >= 50) return 'Good';
|
|
if (percentage >= 30) return 'Basic';
|
|
return 'Getting started';
|
|
};
|
|
|
|
return (
|
|
<Card className={cn('bg-gradient-to-br from-[#ccff00]/5 to-transparent border-[#ccff00]/20', className)}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Zap className="h-4 w-4 text-[#ccff00]" />
|
|
<span className="text-sm font-bold uppercase text-gray-300">Earning Potential</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn('text-sm font-bold', getScoreColor())}>
|
|
{getScoreLabel()}
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
{percentage}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="relative h-3 bg-gray-800 rounded-full overflow-hidden mb-4">
|
|
<div
|
|
className={cn('h-full transition-all duration-500 ease-out rounded-full', getProgressColor())}
|
|
style={{ width: `${percentage}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Checklist */}
|
|
<div className="space-y-2">
|
|
{checks.map((check) => {
|
|
const Icon = check.icon;
|
|
return (
|
|
<div
|
|
key={check.id}
|
|
className={cn(
|
|
'flex items-center gap-2 text-xs transition-all',
|
|
check.completed ? 'text-green-400' : 'text-gray-500'
|
|
)}
|
|
>
|
|
<div className="flex-shrink-0">
|
|
{check.completed ? (
|
|
<Check className="h-3.5 w-3.5" />
|
|
) : (
|
|
<Circle className="h-3.5 w-3.5" />
|
|
)}
|
|
</div>
|
|
<Icon className="h-3 w-3 flex-shrink-0" />
|
|
<span className={check.completed ? 'line-through opacity-70' : ''}>
|
|
{check.label}
|
|
</span>
|
|
<span className={cn(
|
|
'ml-auto text-[10px] font-bold',
|
|
check.completed ? 'text-green-400' : 'text-gray-600'
|
|
)}>
|
|
+{check.points}%
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Tip */}
|
|
<div className="mt-4 pt-3 border-t border-gray-800">
|
|
<p className="text-[10px] text-gray-500">
|
|
💡 Higher quality = More zaps from the community!
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|