diff --git a/frontend/Dockerfile b/frontend/Dockerfile index e69ba84..3d22905 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -12,10 +12,14 @@ COPY . . # Build args ARG DIRECTUS_API_TOKEN +ARG NEXT_PUBLIC_DIRECTUS_ASSET_URL=http://katheryn-cms:8055 +ARG NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN # Set environment for build ENV NEXT_PUBLIC_DIRECTUS_URL=https://katheryn-cms.jeffemmett.com ENV DIRECTUS_API_TOKEN=${DIRECTUS_API_TOKEN} +ENV NEXT_PUBLIC_DIRECTUS_ASSET_URL=${NEXT_PUBLIC_DIRECTUS_ASSET_URL} +ENV NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN=${NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN} ENV NEXT_TELEMETRY_DISABLED=1 # Build the application diff --git a/frontend/docker-compose.yml b/frontend/docker-compose.yml index 1f152ff..507d7c1 100644 --- a/frontend/docker-compose.yml +++ b/frontend/docker-compose.yml @@ -5,6 +5,8 @@ services: dockerfile: Dockerfile args: - DIRECTUS_API_TOKEN=katheryn-frontend-readonly-8591de0316ded82fab45328cf1e49cb1 + - NEXT_PUBLIC_DIRECTUS_ASSET_URL=http://katheryn-cms:8055 + - NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN=katheryn-frontend-readonly-8591de0316ded82fab45328cf1e49cb1 container_name: katheryn-frontend restart: unless-stopped env_file: diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 05ec20f..e06fa5e 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -15,6 +15,12 @@ const nextConfig: NextConfig = { port: '8055', pathname: '/assets/**', }, + { + protocol: 'http', + hostname: 'katheryn-cms', + port: '8055', + pathname: '/assets/**', + }, { protocol: 'https', hostname: 'images.squarespace-cdn.com', diff --git a/frontend/src/app/store/[slug]/artwork-detail-client.tsx b/frontend/src/app/store/[slug]/artwork-detail-client.tsx new file mode 100644 index 0000000..5804a4e --- /dev/null +++ b/frontend/src/app/store/[slug]/artwork-detail-client.tsx @@ -0,0 +1,259 @@ +'use client'; + +import { useState } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Artwork, getAssetUrl } from '@/lib/directus'; +import { useCart } from '@/context/cart-context'; + +interface ArtworkDetailClientProps { + artwork: Artwork; + relatedWorks: Artwork[]; + mainImageUrl: string; + galleryImageUrls: string[]; + thumbnailUrl: string; +} + +export default function ArtworkDetailClient({ + artwork, + relatedWorks, + mainImageUrl, + galleryImageUrls, + thumbnailUrl, +}: ArtworkDetailClientProps) { + const [selectedImage, setSelectedImage] = useState(0); + const { addItem, isInCart } = useCart(); + const inCart = isInCart(artwork.id); + const isSold = artwork.status === 'sold'; + const isAvailable = artwork.status === 'published' && artwork.price && artwork.price > 0; + + const handleAddToCart = () => { + if (artwork && isAvailable && !inCart) { + addItem(artwork); + } + }; + + const galleryImages = artwork.gallery || []; + + return ( +
+ {/* Breadcrumb */} +
+
+ +
+
+ + {/* Main content */} +
+
+ {/* Images */} +
+
+ {artwork.image ? ( + {artwork.title} + ) : ( +
+ No image available +
+ )} + {isSold && ( +
+ + Sold + +
+ )} +
+ + {/* Thumbnail gallery */} + {galleryImages.length > 0 && ( +
+ + {galleryImageUrls.map((url, index) => ( + + ))} +
+ )} +
+ + {/* Details */} +
+

{artwork.title}

+ + {artwork.year && ( +

{artwork.year}

+ )} + + {/* Price */} +
+ {isSold ? ( +

Sold

+ ) : artwork.price ? ( +

{artwork.currency === 'GBP' ? '£' : '$'}{artwork.price.toLocaleString()}

+ ) : ( +

Price on request

+ )} +
+ + {/* Add to cart */} + {isAvailable && !isSold && ( +
+ +
+ )} + + {/* Enquire button for sold or POA items */} + {(isSold || !artwork.price) && ( +
+ + Enquire About This Work + +
+ )} + + {/* Details */} +
+ {artwork.medium && ( +
+ Medium + {artwork.medium} +
+ )} + {artwork.dimensions && ( +
+ Dimensions + {artwork.dimensions} +
+ )} + {artwork.series && typeof artwork.series === 'object' && ( +
+ Series + + {artwork.series.name} + +
+ )} +
+ + {/* Description */} + {artwork.description && ( +
+

+ About This Work +

+
+
+ )} + + {/* Shipping info */} +
+
+ + + +
+

Secure Shipping

+

+ All artworks are carefully packaged and shipped with insurance. +

+
+
+
+
+
+
+ + {/* Related works */} + {relatedWorks.length > 0 && ( +
+
+

You May Also Like

+
+ {relatedWorks.map((work) => ( + +
+ {work.image && ( + {work.title} + )} +
+
+

{work.title}

+ {work.price && ( +

{work.currency === 'GBP' ? '£' : '$'}{work.price.toLocaleString()}

+ )} +
+ + ))} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/app/store/[slug]/page.tsx b/frontend/src/app/store/[slug]/page.tsx index e00fb95..9c82ff3 100644 --- a/frontend/src/app/store/[slug]/page.tsx +++ b/frontend/src/app/store/[slug]/page.tsx @@ -1,293 +1,62 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import Image from 'next/image'; -import Link from 'next/link'; -import { useParams } from 'next/navigation'; +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; import { getArtwork, getArtworks, getAssetUrl, Artwork } from '@/lib/directus'; -import { useCart } from '@/context/cart-context'; +import ArtworkDetailClient from './artwork-detail-client'; -export default function ArtworkDetailPage() { - const params = useParams(); - const slug = params.slug as string; - const [artwork, setArtwork] = useState(null); - const [relatedWorks, setRelatedWorks] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [selectedImage, setSelectedImage] = useState(0); +// Force dynamic rendering to ensure fresh data from Directus +export const dynamic = 'force-dynamic'; +export const revalidate = 0; - const { addItem, isInCart } = useCart(); - const inCart = artwork ? isInCart(artwork.id) : false; - const isSold = artwork?.status === 'sold'; - const isAvailable = artwork?.status === 'published' && artwork?.price && artwork.price > 0; +interface PageProps { + params: Promise<{ slug: string }>; +} - useEffect(() => { - async function loadArtwork() { - try { - setLoading(true); - const data = await getArtwork(slug); - setArtwork(data); +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params; + try { + const artwork = await getArtwork(slug); + return { + title: artwork.title, + description: artwork.description || `${artwork.title} by Katheryn Trenshaw`, + }; + } catch { + return { title: 'Artwork Not Found' }; + } +} - // Fetch related works - const allWorks = await getArtworks({ status: 'published', limit: 4 }); - setRelatedWorks(allWorks.filter((w) => w.id !== data.id).slice(0, 3)); - } catch (err) { - setError('Artwork not found'); - console.error(err); - } finally { - setLoading(false); - } - } - loadArtwork(); - }, [slug]); +export default async function ArtworkDetailPage({ params }: PageProps) { + const { slug } = await params; - const handleAddToCart = () => { - if (artwork && isAvailable && !inCart) { - addItem(artwork); - } - }; - - if (loading) { - return ( -
-
Loading...
-
- ); + let artwork; + try { + artwork = await getArtwork(slug); + } catch { + notFound(); } - if (error || !artwork) { - return ( -
-

Artwork not found

- - Back to Store - -
- ); + // Fetch related works server-side + let relatedWorks: Artwork[] = []; + try { + const allWorks = await getArtworks({ status: 'published', limit: 4 }); + relatedWorks = allWorks.filter((w) => w.id !== artwork.id).slice(0, 3); + } catch { + relatedWorks = []; } + // Pre-compute image URLs server-side (ensures token is always available) const mainImageUrl = getAssetUrl(artwork.image, { width: 1200, quality: 90, format: 'webp' }); - const galleryImages = artwork.gallery || []; + const thumbnailUrl = getAssetUrl(artwork.image, { width: 200 }); + const galleryImageUrls = (artwork.gallery || []).map((img) => + getAssetUrl(img, { width: 200 }) + ); return ( -
- {/* Breadcrumb */} -
-
- -
-
- - {/* Main content */} -
-
- {/* Images */} -
-
- {artwork.image ? ( - {artwork.title} - ) : ( -
- No image available -
- )} - {isSold && ( -
- - Sold - -
- )} -
- - {/* Thumbnail gallery */} - {galleryImages.length > 0 && ( -
- - {galleryImages.map((img, index) => ( - - ))} -
- )} -
- - {/* Details */} -
-

{artwork.title}

- - {artwork.year && ( -

{artwork.year}

- )} - - {/* Price */} -
- {isSold ? ( -

Sold

- ) : artwork.price ? ( -

{artwork.currency === 'GBP' ? '£' : '$'}{artwork.price.toLocaleString()}

- ) : ( -

Price on request

- )} -
- - {/* Add to cart */} - {isAvailable && !isSold && ( -
- -
- )} - - {/* Enquire button for sold or POA items */} - {(isSold || !artwork.price) && ( -
- - Enquire About This Work - -
- )} - - {/* Details */} -
- {artwork.medium && ( -
- Medium - {artwork.medium} -
- )} - {artwork.dimensions && ( -
- Dimensions - {artwork.dimensions} -
- )} - {artwork.series && typeof artwork.series === 'object' && ( -
- Series - - {artwork.series.name} - -
- )} -
- - {/* Description */} - {artwork.description && ( -
-

- About This Work -

-
-
- )} - - {/* Shipping info */} -
-
- - - -
-

Secure Shipping

-

- All artworks are carefully packaged and shipped with insurance. -

-
-
-
-
-
-
- - {/* Related works */} - {relatedWorks.length > 0 && ( -
-
-

You May Also Like

-
- {relatedWorks.map((work) => ( - -
- {work.image && ( - {work.title} - )} -
-
-

{work.title}

- {work.price && ( -

{work.currency === 'GBP' ? '£' : '$'}{work.price.toLocaleString()}

- )} -
- - ))} -
-
-
- )} -
+ ); } diff --git a/frontend/src/lib/directus.ts b/frontend/src/lib/directus.ts index 79ee202..57ca08b 100644 --- a/frontend/src/lib/directus.ts +++ b/frontend/src/lib/directus.ts @@ -180,15 +180,23 @@ export interface DirectusFile { } // Create the Directus client (untyped to avoid complex SDK type inference) -const directusUrl = process.env.NEXT_PUBLIC_DIRECTUS_URL || 'https://katheryn-cms.jeffemmett.com'; +// Use internal Docker URL for server-side API calls (bypasses Cloudflare Access) +const publicUrl = process.env.NEXT_PUBLIC_DIRECTUS_URL || 'https://katheryn-cms.jeffemmett.com'; +const directusUrl = process.env.DIRECTUS_INTERNAL_URL || publicUrl; const apiToken = process.env.DIRECTUS_API_TOKEN || ''; +// For asset URLs: use internal Docker URL to bypass Cloudflare Access, +// and use NEXT_PUBLIC_ token so it's available in client components +const assetBaseUrl = process.env.NEXT_PUBLIC_DIRECTUS_ASSET_URL || publicUrl; +const assetToken = process.env.NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN || apiToken; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export const directus = createDirectus(directusUrl) .with(staticToken(apiToken)) .with(rest()); // Helper to get asset URL (includes auth token since Directus assets require authentication) +// Uses internal Docker URL when available to bypass Cloudflare Access export function getAssetUrl(fileId: string | DirectusFile | undefined, options?: { width?: number; height?: number; quality?: number; format?: 'webp' | 'jpg' | 'png' }): string { if (!fileId) return '/placeholder.jpg'; @@ -196,7 +204,7 @@ export function getAssetUrl(fileId: string | DirectusFile | undefined, options?: const params = new URLSearchParams(); // Auth token required for asset access - if (apiToken) params.append('access_token', apiToken); + if (assetToken) params.append('access_token', assetToken); if (options) { if (options.width) params.append('width', options.width.toString()); @@ -205,7 +213,7 @@ export function getAssetUrl(fileId: string | DirectusFile | undefined, options?: if (options.format) params.append('format', options.format); } - return `${directusUrl}/assets/${id}?${params.toString()}`; + return `${assetBaseUrl}/assets/${id}?${params.toString()}`; } // Map Directus artwork fields to frontend Artwork interface