Explorar el Código

Sviluppata funzionalità per il carrello di immagini per le cards

Sviluppo_Carrello_Immagini
Lorenzo Pollutri hace 4 horas
padre
commit
7d5d31afe6
Se han modificado 3 ficheros con 264 adiciones y 111 borrados
  1. +80
    -13
      app/admin/page.tsx
  2. +183
    -88
      components/PublicGrid.tsx
  3. +1
    -10
      types/index.ts

+ 80
- 13
app/admin/page.tsx Ver fichero

@@ -33,12 +33,12 @@ export default function AdminDashboard() {
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: string, isPortal = false) => {
if (!e.target.files?.[0]) return;
setUploading(prev => ({ ...prev, [field]: true }));
const formData = new FormData();
formData.append('file', e.target.files[0]);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.url) {
if (isPortal) {
setPortal(prev => ({ ...prev, [field]: data.url }));
@@ -49,6 +49,36 @@ export default function AdminDashboard() {
setUploading(prev => ({ ...prev, [field]: false }));
};
const handleUploadExtraImage = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(prev => ({ ...prev, extraImages: true }));
const uploaded: string[] = [];
for (const file of Array.from(files)) {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.url) uploaded.push(data.url);
}
setIsEditing(prev => ({
...prev,
extraImages: [...(prev?.extraImages || []), ...uploaded],
}));
setUploading(prev => ({ ...prev, extraImages: false }));
// Reset input so the same file can be re-selected if needed
e.target.value = '';
};
const removeExtraImage = (index: number) => {
setIsEditing(prev => ({
...prev,
extraImages: (prev?.extraImages || []).filter((_, i) => i !== index),
}));
};
const handleSaveCard = async () => {
if (!isEditing) return;
const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2);
@@ -315,22 +345,59 @@ export default function AdminDashboard() {
</div>
</div>
<div className="space-y-5">
{/* Cover Image */}
<div>
<label className="block text-sm font-semibold text-gray-800 mb-1">Cover Image</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:bg-gray-50 transition-colors">
<label className="block text-sm font-semibold text-gray-800 mb-1">
Cover Image <span className="text-gray-400 font-normal text-xs">(shown in grid)</span>
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
<input type="file" accept="image/*" onChange={e => handleUpload(e, 'imageUrl')} className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 cursor-pointer" />
{uploading['imageUrl'] && <p className="mt-2 text-sm text-blue-600 font-medium">Uploading image...</p>}
{uploading['imageUrl'] && <p className="mt-2 text-sm text-blue-600 font-medium">Uploading...</p>}
</div>
{isEditing.imageUrl && (
<div className="mt-4 relative rounded-lg overflow-hidden border border-gray-200 group">
<img src={isEditing.imageUrl} className="w-full h-40 object-cover" alt="Preview" />
<button
onClick={() => setIsEditing({...isEditing, imageUrl: ''})}
<div className="mt-3 relative rounded-lg overflow-hidden border border-gray-200 group">
<img src={isEditing.imageUrl} className="w-full h-32 object-cover" alt="Cover preview" />
<button
onClick={() => setIsEditing({...isEditing, imageUrl: ''})}
className="absolute top-2 right-2 bg-red-500 text-white w-8 h-8 rounded-full text-sm font-bold shadow opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-600"
title="Remove Image"
>
</button>
title="Remove cover image"
>✕</button>
</div>
)}
</div>
{/* Gallery Images */}
<div>
<label className="block text-sm font-semibold text-gray-800 mb-1">
Gallery Images <span className="text-gray-400 font-normal text-xs">(optional, shown in detail modal)</span>
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
<input
type="file"
accept="image/*"
multiple
onChange={handleUploadExtraImage}
className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-purple-50 file:text-purple-700 hover:file:bg-purple-100 cursor-pointer"
/>
{uploading['extraImages'] && <p className="mt-2 text-sm text-purple-600 font-medium">Uploading...</p>}
</div>
{/* Thumbnails strip */}
{(isEditing.extraImages || []).length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{(isEditing.extraImages || []).map((url, i) => (
<div key={url + i} className="relative group w-20 h-20 rounded-lg overflow-hidden border border-gray-200 shrink-0">
<img src={url} className="w-full h-full object-cover" alt={`Gallery ${i + 1}`} />
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
onClick={() => removeExtraImage(i)}
className="bg-red-500 text-white w-7 h-7 rounded-full text-xs font-bold hover:bg-red-600"
title="Remove"
>✕</button>
</div>
<span className="absolute bottom-0 left-0 right-0 text-center text-white text-[10px] bg-black/50 pb-0.5">{i + 1}</span>
</div>
))}
</div>
)}
</div>


+ 183
- 88
components/PublicGrid.tsx Ver fichero

@@ -1,88 +1,183 @@
'use client';
import { useState, useEffect } from 'react';
import { Card } from '@/types';
export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) {
const [activeCard, setActiveCard] = useState<Card | null>(null);
// Prevent background scrolling when modal is open
useEffect(() => {
if (activeCard) document.body.style.overflow = 'hidden';
else document.body.style.overflow = 'unset';
return () => { document.body.style.overflow = 'unset'; }
}, [activeCard]);
// Tailwind classes mapping based on the admin's chosen max columns
const gridClasses: Record<number, string> = {
3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1', // ADDED THIS LINE
4: 'xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
5: 'xl:grid-cols-5 lg:grid-cols-4 md:grid-cols-3 grid-cols-1',
6: 'xl:grid-cols-6 lg:grid-cols-4 md:grid-cols-3 grid-cols-2',
7: 'xl:grid-cols-7 lg:grid-cols-5 md:grid-cols-4 grid-cols-2',
8: 'xl:grid-cols-8 lg:grid-cols-6 md:grid-cols-4 grid-cols-2',
};
const activeGridClass = gridClasses[maxCols] || gridClasses[5];
return (
<>
<div className={`grid gap-4 ${activeGridClass}`}>
{cards.map((card) => (
<div
key={card.id}
onClick={() => setActiveCard(card)}
className="group relative cursor-pointer overflow-hidden rounded-xl shadow-md aspect-square bg-gray-200 transition-all duration-300 hover:shadow-xl hover:-translate-y-1"
>
{card.imageUrl ? (
<img src={card.imageUrl} alt={card.title} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-100 to-gray-200 text-gray-400">No Image</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent flex flex-col justify-end p-5 text-white">
<h3 className="text-xl font-bold drop-shadow-md">{card.title}</h3>
<p className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 line-clamp-2 mt-1 text-gray-200 drop-shadow">
{card.shortDescription}
</p>
</div>
</div>
))}
</div>
{/* Improved Modal Pop-up */}
{activeCard && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4 transition-opacity"
onClick={() => setActiveCard(null)} // Click outside to close
>
<div
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl animate-in fade-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()} // Prevent clicks inside modal from closing it
>
<div className="relative h-72 w-full bg-gray-100">
{activeCard.imageUrl && (
<img src={activeCard.imageUrl} className="w-full h-full object-cover rounded-t-2xl" alt="" />
)}
{/* Improved Close Button */}
<button
onClick={() => setActiveCard(null)}
className="absolute top-4 right-4 bg-black/60 text-white w-10 h-10 flex items-center justify-center rounded-full hover:bg-black hover:scale-110 transition-all shadow-lg"
title="Close"
>
</button>
</div>
<div className="p-8">
<div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div>
<h2 className="text-3xl font-bold mb-4 text-gray-900">{activeCard.title}</h2>
{activeCard.fullContent ? (
<div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} />
) : (
<p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p>
)}
</div>
</div>
</div>
)}
</>
);
}
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Card } from '@/types';

function ImageCarousel({ images }: { images: string[] }) {
const [current, setCurrent] = useState(0);
const touchStartX = useRef<number | null>(null);

const prev = useCallback(() => setCurrent(i => (i - 1 + images.length) % images.length), [images.length]);
const next = useCallback(() => setCurrent(i => (i + 1) % images.length), [images.length]);

useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') prev();
if (e.key === 'ArrowRight') next();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [prev, next]);

// Reset to first image when a new card is shown
useEffect(() => { setCurrent(0); }, [images]);

const onTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
};
const onTouchEnd = (e: React.TouchEvent) => {
if (touchStartX.current === null) return;
const delta = e.changedTouches[0].clientX - touchStartX.current;
if (Math.abs(delta) > 50) delta < 0 ? next() : prev();
touchStartX.current = null;
};

if (images.length === 0) {
return <div className="h-72 w-full bg-gray-100 flex items-center justify-center text-gray-400 rounded-t-2xl">No Image</div>;
}

return (
<div
className="relative h-72 w-full bg-black overflow-hidden rounded-t-2xl select-none"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{/* Images */}
{images.map((src, i) => (
<img
key={src}
src={src}
alt=""
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${i === current ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
/>
))}

{/* Arrows — only if more than one image */}
{images.length > 1 && (
<>
<button
onClick={(e) => { e.stopPropagation(); prev(); }}
className="absolute left-3 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-9 h-9 rounded-full flex items-center justify-center transition-colors shadow-lg z-10"
aria-label="Previous image"
>
</button>
<button
onClick={(e) => { e.stopPropagation(); next(); }}
className="absolute right-3 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-9 h-9 rounded-full flex items-center justify-center transition-colors shadow-lg z-10"
aria-label="Next image"
>
</button>

{/* Dot indicators */}
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10">
{images.map((_, i) => (
<button
key={i}
onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
className={`rounded-full transition-all duration-200 ${i === current ? 'w-5 h-2 bg-white' : 'w-2 h-2 bg-white/50 hover:bg-white/80'}`}
aria-label={`Go to image ${i + 1}`}
/>
))}
</div>

{/* Counter badge */}
<div className="absolute top-3 right-3 bg-black/50 text-white text-xs font-semibold px-2 py-0.5 rounded-full z-10">
{current + 1} / {images.length}
</div>
</>
)}
</div>
);
}

export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) {
const [activeCard, setActiveCard] = useState<Card | null>(null);

useEffect(() => {
if (activeCard) document.body.style.overflow = 'hidden';
else document.body.style.overflow = 'unset';
return () => { document.body.style.overflow = 'unset'; };
}, [activeCard]);

const gridClasses: Record<number, string> = {
3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
4: 'xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
5: 'xl:grid-cols-5 lg:grid-cols-4 md:grid-cols-3 grid-cols-1',
6: 'xl:grid-cols-6 lg:grid-cols-4 md:grid-cols-3 grid-cols-2',
7: 'xl:grid-cols-7 lg:grid-cols-5 md:grid-cols-4 grid-cols-2',
8: 'xl:grid-cols-8 lg:grid-cols-6 md:grid-cols-4 grid-cols-2',
};

const activeGridClass = gridClasses[maxCols] || gridClasses[5];

return (
<>
<div className={`grid gap-4 ${activeGridClass}`}>
{cards.map((card) => (
<div
key={card.id}
onClick={() => setActiveCard(card)}
className="group relative cursor-pointer overflow-hidden rounded-xl shadow-md aspect-square bg-gray-200 transition-all duration-300 hover:shadow-xl hover:-translate-y-1"
>
{card.imageUrl ? (
<img src={card.imageUrl} alt={card.title} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-100 to-gray-200 text-gray-400">No Image</div>
)}
{/* Gallery badge */}
{card.extraImages && card.extraImages.length > 0 && (
<div className="absolute top-2 right-2 bg-black/60 text-white text-xs font-semibold px-1.5 py-0.5 rounded-full flex items-center gap-1">
<span>⊞</span>
<span>{1 + card.extraImages.length}</span>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent flex flex-col justify-end p-5 text-white">
<h3 className="text-xl font-bold drop-shadow-md">{card.title}</h3>
<p className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 line-clamp-2 mt-1 text-gray-200 drop-shadow">
{card.shortDescription}
</p>
</div>
</div>
))}
</div>

{activeCard && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4"
onClick={() => setActiveCard(null)}
>
<div
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl animate-in fade-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
<div className="relative">
<ImageCarousel
images={[
...(activeCard.imageUrl ? [activeCard.imageUrl] : []),
...(activeCard.extraImages || []),
]}
/>
<button
onClick={() => setActiveCard(null)}
className="absolute top-4 right-4 bg-black/60 text-white w-10 h-10 flex items-center justify-center rounded-full hover:bg-black hover:scale-110 transition-all shadow-lg z-20"
title="Close"
>
</button>
</div>
<div className="p-8">
<div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div>
<h2 className="text-3xl font-bold mb-4 text-gray-900">{activeCard.title}</h2>
{activeCard.fullContent ? (
<div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} />
) : (
<p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p>
)}
</div>
</div>
</div>
)}
</>
);
}

+ 1
- 10
types/index.ts Ver fichero

@@ -1,20 +1,11 @@
export type CardType = 'INFO_PAGE' | 'EXTERNAL_LINK' | 'IMAGE_GALLERY' | 'SERVICE_REQUEST';
export interface Portal {
id: string;
tenantId: string;
title: string;
welcomeText: string;
heroImageUrl: string;
logoUrl: string;
themeColor: string;
}
export interface Card {
id: string;
portalId: string;
title: string;
imageUrl: string;
extraImages?: string[];
shortDescription: string;
fullContent: string;
cardType: CardType;


Cargando…
Cancelar
Guardar