feat(F-059): completed feature

This commit is contained in:
chattie
2026-08-19 15:32:56 +02:00
parent 4cdb5fb487
commit 72ba84456c
32 changed files with 369 additions and 57 deletions

View File

@@ -79,9 +79,9 @@ export default async function BrandPage({ params }: Props) {
{products.map((product) => (
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
<div className="aspect-square relative bg-white flex items-center justify-center">
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
) : (
<span className="text-5xl">🌿</span>
)}

View File

@@ -87,9 +87,9 @@ export default async function CategoryPage({ params }: Props) {
{products.map((product) => (
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
<div className="aspect-square relative bg-white flex items-center justify-center">
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
) : (
<span className="text-5xl">🌿</span>
)}

View File

@@ -100,13 +100,16 @@ export default async function ProductPage({ params }: Props) {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Image */}
<div>
<div className="relative aspect-square max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 flex items-center justify-center overflow-hidden">
{/* Detail image: bound the box at max-h 500px and let the image keep
its natural aspect ratio with `object-contain`. Non-square photos
render fully inside the box, never cropped. */}
<div className="relative aspect-[5/7] max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 flex items-center justify-center overflow-hidden">
{product.images?.[0] ? (
<Image
src={product.images[0].url}
alt={product.name}
fill
className="object-cover"
className="object-contain"
priority
sizes="(max-width: 1024px) 100vw, 50vw"
/>

View File

@@ -48,9 +48,19 @@ export default async function ProductsPage() {
return (
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
<div className="aspect-square relative bg-white flex items-center justify-center">
{/* Image container: bounded box (max 5:7 aspect ratio to match
backend thumbnail sizing). The image preserves its source
aspect ratio via `object-contain`, so non-square images
display without cropping. */}
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
<Image
src={product.images[0].url}
alt={product.name}
fill
className="object-contain"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
/>
) : (
<span className="text-5xl">🌿</span>
)}

View File

@@ -91,9 +91,9 @@ export default async function SearchPage({ searchParams }: Props) {
return (
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
<div className="aspect-square relative bg-white flex items-center justify-center">
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
) : (
<span className="text-5xl">🌿</span>
)}

View File

@@ -17,6 +17,14 @@ import path from 'node:path';
export const dynamic = 'force-dynamic';
const THUMB_SIZES: Readonly<Record<string, number>> = { '40': 40, '200': 200 };
/**
* Maximum height ratio (h/w) for cached thumbnails. The thumbnail is scaled
* to fit inside a `width × width * MAX_HEIGHT_RATIO` bounding box without
* cropping, so a non-square source produces a non-square thumbnail that
* preserves the original aspect ratio. The frontend renders the result with
* `object-contain`.
*/
const MAX_HEIGHT_RATIO = 1.4;
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
const CONTENT_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
'.jpg': 'image/jpeg',
@@ -55,7 +63,10 @@ async function findOriginal(filename: string): Promise<{ root: string; buffer: B
async function buildThumbnail(source: Buffer, width: number): Promise<Buffer | null> {
try {
const { default: sharp } = await import('sharp');
return await sharp(source).resize({ width, withoutEnlargement: true }).toBuffer();
const maxHeight = Math.round(width * MAX_HEIGHT_RATIO);
return await sharp(source)
.resize({ width, height: maxHeight, fit: 'inside', withoutEnlargement: true })
.toBuffer();
} catch {
return null;
}

View File

@@ -12,10 +12,18 @@ function CartItemRow({ item }: { item: CartItem }) {
return (
<div className="flex gap-4 py-4 border-b border-gray-100 last:border-0">
{/* Image */}
<div className="w-20 h-20 bg-gray-50 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
{/* Image: bounded box (80x112 max, 5:7 ratio matching the backend
thumbnail). The image preserves its source aspect ratio via
`object-contain` so non-square images are not cropped. */}
<div className="w-20 max-h-28 bg-gray-50 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
{item.imageUrl ? (
<Image src={item.imageUrl} alt={item.productName} width={80} height={80} className="object-cover" />
<Image
src={item.imageUrl.replace('/uploads/', '/uploads/40/')}
alt={item.productName}
width={80}
height={112}
className="object-contain"
/>
) : (
<span className="text-3xl">🌿</span>
)}

View File

@@ -244,7 +244,7 @@ export default function CheckoutClient() {
<div key={item.variantId} className="flex gap-3">
<div className="w-12 h-12 bg-white rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
{item.imageUrl
? <Image src={item.imageUrl} alt={item.productName} width={48} height={48} className="object-cover" />
? <Image src={item.imageUrl.replace('/uploads/', '/uploads/40/')} alt={item.productName} width={40} height={56} className="object-contain" />
: <span className="text-2xl">🌿</span>
}
</div>

View File

@@ -34,9 +34,9 @@ export default async function FeaturedProducts() {
{products.map((product) => (
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-colors">
<div className="aspect-square relative bg-white flex items-center justify-center">
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
) : (
<span className="text-5xl">🌿</span>
)}

View File

@@ -40,14 +40,14 @@ function LiveSearchResults({
onClick={onClose}
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors"
>
<div className="w-10 h-10 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
<div className="w-10 h-14 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
{product.images?.[0] ? (
<Image
src={product.images[0].url}
src={product.images[0].url.replace('/uploads/', '/uploads/40/')}
alt={product.name}
width={40}
height={40}
className="object-cover w-full h-full"
height={56}
className="object-contain"
/>
) : (
<span className="text-lg">🌿</span>