Improve Directus integration and homepage dynamic events
- Add auth token to asset URLs for proper image access - Map Directus artwork fields (name->title, price_gbp/usd->price, notes->description) - Add currency field (GBP/USD) based on available price - Fetch upcoming events dynamically on homepage - Prefer artworks with images for featured display - Add proper error handling for artwork lookups Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5a477e31a2
commit
0e13f60d14
|
|
@ -231,7 +231,7 @@ export default function CheckoutPage() {
|
||||||
{item.artwork.medium && (
|
{item.artwork.medium && (
|
||||||
<p className="text-xs text-gray-500 mt-1">{item.artwork.medium}</p>
|
<p className="text-xs text-gray-500 mt-1">{item.artwork.medium}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm font-medium mt-2">£{item.price.toLocaleString()}</p>
|
<p className="text-sm font-medium mt-2">{item.artwork.currency === 'GBP' ? '£' : '$'}{item.price.toLocaleString()}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeItem(item.id)}
|
onClick={() => removeItem(item.id)}
|
||||||
className="text-xs text-gray-500 underline mt-2 hover:no-underline"
|
className="text-xs text-gray-500 underline mt-2 hover:no-underline"
|
||||||
|
|
@ -246,7 +246,7 @@ export default function CheckoutPage() {
|
||||||
<div className="border-t border-gray-200 mt-6 pt-6 space-y-2">
|
<div className="border-t border-gray-200 mt-6 pt-6 space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span>Subtotal</span>
|
<span>Subtotal</span>
|
||||||
<span>£{subtotal.toLocaleString()}</span>
|
<span>${subtotal.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm text-gray-500">
|
<div className="flex justify-between text-sm text-gray-500">
|
||||||
<span>Shipping</span>
|
<span>Shipping</span>
|
||||||
|
|
@ -257,7 +257,7 @@ export default function CheckoutPage() {
|
||||||
<div className="border-t border-gray-200 mt-6 pt-6">
|
<div className="border-t border-gray-200 mt-6 pt-6">
|
||||||
<div className="flex justify-between text-lg font-medium">
|
<div className="flex justify-between text-lg font-medium">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span>£{subtotal.toLocaleString()}</span>
|
<span>${subtotal.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
+ shipping (calculated at next step)
|
+ shipping (calculated at next step)
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,9 @@ export const revalidate = 60;
|
||||||
|
|
||||||
async function getEventsList(): Promise<{ upcoming: Event[]; past: Event[] }> {
|
async function getEventsList(): Promise<{ upcoming: Event[]; past: Event[] }> {
|
||||||
try {
|
try {
|
||||||
const events = await getEvents({ status: 'published' });
|
const allEvents = await getEvents();
|
||||||
const now = new Date();
|
const upcoming = allEvents.filter((e) => e.status === 'published');
|
||||||
|
const past = allEvents.filter((e) => e.status === 'past');
|
||||||
const upcoming = events.filter(
|
|
||||||
(e) => !e.end_date || new Date(e.end_date) >= now
|
|
||||||
);
|
|
||||||
const past = events.filter((e) => e.end_date && new Date(e.end_date) < now);
|
|
||||||
|
|
||||||
return { upcoming, past };
|
return { upcoming, past };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { getArtworks, Artwork } from '@/lib/directus';
|
import { getArtworks, getEvents, Artwork, Event } from '@/lib/directus';
|
||||||
import { ArtworkCard } from '@/components/artwork-card';
|
import { ArtworkCard } from '@/components/artwork-card';
|
||||||
import { WisdomWordsCarousel } from '@/components/wisdom-words-carousel';
|
import { WisdomWordsCarousel } from '@/components/wisdom-words-carousel';
|
||||||
|
|
||||||
|
|
@ -8,16 +8,41 @@ export const revalidate = 60;
|
||||||
|
|
||||||
async function getFeaturedArtworks(): Promise<Artwork[]> {
|
async function getFeaturedArtworks(): Promise<Artwork[]> {
|
||||||
try {
|
try {
|
||||||
const artworks = await getArtworks({ status: 'published', limit: 6 });
|
// Fetch more than needed and prefer artworks with images
|
||||||
return artworks;
|
const artworks = await getArtworks({ status: 'published', limit: 50 });
|
||||||
|
const withImages = artworks.filter((a) => a.image);
|
||||||
|
// Return up to 6 artworks that have images, or fall back to whatever is available
|
||||||
|
return withImages.length >= 6 ? withImages.slice(0, 6) : artworks.slice(0, 6);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching artworks:', error);
|
console.error('Error fetching artworks:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getUpcomingEvents(): Promise<Event[]> {
|
||||||
|
try {
|
||||||
|
const events = await getEvents({ status: 'published', limit: 4 });
|
||||||
|
return events;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching events:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventDate(start?: string, end?: string): string {
|
||||||
|
if (!start) return '';
|
||||||
|
const s = new Date(start);
|
||||||
|
const opts: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||||
|
if (!end) return s.toLocaleDateString('en-GB', opts);
|
||||||
|
const e = new Date(end);
|
||||||
|
return `${s.toLocaleDateString('en-GB', opts)} - ${e.toLocaleDateString('en-GB', opts)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const artworks = await getFeaturedArtworks();
|
const [artworks, upcomingEvents] = await Promise.all([
|
||||||
|
getFeaturedArtworks(),
|
||||||
|
getUpcomingEvents(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Wisdom words gallery - all quote images from original site
|
// Wisdom words gallery - all quote images from original site
|
||||||
const wisdomImages = [
|
const wisdomImages = [
|
||||||
|
|
@ -305,41 +330,27 @@ export default async function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Event 1 */}
|
{upcomingEvents.length > 0 ? (
|
||||||
<div className="border-b border-gray-200 pb-8">
|
upcomingEvents.map((event, index) => (
|
||||||
<p className="text-sm text-gray-500 uppercase tracking-wider mb-2">Feb 2, 2026</p>
|
<div key={event.id} className={index < upcomingEvents.length - 1 ? 'border-b border-gray-200 pb-8' : 'pb-8'}>
|
||||||
<h3 className="font-serif text-xl text-[#222]">Secret Screening</h3>
|
<p className="text-sm text-gray-500 uppercase tracking-wider mb-2">
|
||||||
<p className="mt-2 text-gray-600">
|
{formatEventDate(event.start_date, event.end_date)}
|
||||||
An exclusive preview event for the creative community.
|
</p>
|
||||||
</p>
|
<h3 className="font-serif text-xl text-[#222]">{event.title}</h3>
|
||||||
<Link href="/events" className="mt-3 inline-block text-sm text-[#222] underline underline-offset-4 hover:no-underline">
|
{event.location && (
|
||||||
Learn More
|
<p className="mt-1 text-sm text-gray-500">{event.location}</p>
|
||||||
</Link>
|
)}
|
||||||
</div>
|
{event.description && (
|
||||||
|
<p className="mt-2 text-gray-600 line-clamp-2">{event.description}</p>
|
||||||
{/* Event 2 */}
|
)}
|
||||||
<div className="border-b border-gray-200 pb-8">
|
<Link href="/events" className="mt-3 inline-block text-sm text-[#222] underline underline-offset-4 hover:no-underline">
|
||||||
<p className="text-sm text-gray-500 uppercase tracking-wider mb-2">Apr 4-11, 2026</p>
|
Learn More
|
||||||
<h3 className="font-serif text-xl text-[#222]">UnEarthing Templer Way at Birdwood House</h3>
|
</Link>
|
||||||
<p className="mt-2 text-gray-600">
|
</div>
|
||||||
A week-long creative retreat exploring art and nature.
|
))
|
||||||
</p>
|
) : (
|
||||||
<Link href="/events" className="mt-3 inline-block text-sm text-[#222] underline underline-offset-4 hover:no-underline">
|
<p className="text-center text-gray-500 py-8">No upcoming events at the moment.</p>
|
||||||
Learn More
|
)}
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Event 3 */}
|
|
||||||
<div className="pb-8">
|
|
||||||
<p className="text-sm text-gray-500 uppercase tracking-wider mb-2">Apr 16-23, 2026</p>
|
|
||||||
<h3 className="font-serif text-xl text-[#222]">UNEARTHING TEMPLER WAY EXHIBITION</h3>
|
|
||||||
<p className="mt-2 text-gray-600">
|
|
||||||
Exhibition showcasing works created during the retreat.
|
|
||||||
</p>
|
|
||||||
<Link href="/events" className="mt-3 inline-block text-sm text-[#222] underline underline-offset-4 hover:no-underline">
|
|
||||||
Learn More
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 text-center">
|
<div className="mt-12 text-center">
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,7 @@ export default function ArtworkDetailPage() {
|
||||||
{isSold ? (
|
{isSold ? (
|
||||||
<p className="text-xl font-medium text-gray-400">Sold</p>
|
<p className="text-xl font-medium text-gray-400">Sold</p>
|
||||||
) : artwork.price ? (
|
) : artwork.price ? (
|
||||||
<p className="text-2xl font-medium">£{artwork.price.toLocaleString()}</p>
|
<p className="text-2xl font-medium">{artwork.currency === 'GBP' ? '£' : '$'}{artwork.price.toLocaleString()}</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-lg text-gray-600">Price on request</p>
|
<p className="text-lg text-gray-600">Price on request</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -279,7 +279,7 @@ export default function ArtworkDetailPage() {
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
<h3 className="font-serif text-lg">{work.title}</h3>
|
<h3 className="font-serif text-lg">{work.title}</h3>
|
||||||
{work.price && (
|
{work.price && (
|
||||||
<p className="mt-1 text-sm">£{work.price.toLocaleString()}</p>
|
<p className="mt-1 text-sm">{work.currency === 'GBP' ? '£' : '$'}{work.price.toLocaleString()}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,9 @@ export function ArtworkCard({
|
||||||
{isSold ? (
|
{isSold ? (
|
||||||
<p className="mt-2 text-sm font-medium text-gray-400">Sold</p>
|
<p className="mt-2 text-sm font-medium text-gray-400">Sold</p>
|
||||||
) : artwork.price ? (
|
) : artwork.price ? (
|
||||||
<p className="mt-2 text-sm font-medium">£{artwork.price.toLocaleString()}</p>
|
<p className="mt-2 text-sm font-medium">
|
||||||
|
{artwork.currency === 'GBP' ? '£' : '$'}{artwork.price.toLocaleString()}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2 text-sm text-gray-500">Price on request</p>
|
<p className="mt-2 text-sm text-gray-500">Price on request</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ export function CartDrawer() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
£{item.price.toLocaleString()}
|
{item.artwork.currency === 'GBP' ? '£' : '$'}{item.price.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -110,7 +110,7 @@ export function CartDrawer() {
|
||||||
<div className="border-t border-gray-200 px-6 py-4">
|
<div className="border-t border-gray-200 px-6 py-4">
|
||||||
<div className="flex justify-between text-base font-medium">
|
<div className="flex justify-between text-base font-medium">
|
||||||
<span>Subtotal</span>
|
<span>Subtotal</span>
|
||||||
<span>£{subtotal.toLocaleString()}</span>
|
<span>${subtotal.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Shipping and taxes calculated at checkout
|
Shipping and taxes calculated at checkout
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export interface Artwork {
|
||||||
medium?: string;
|
medium?: string;
|
||||||
dimensions?: string;
|
dimensions?: string;
|
||||||
price?: number;
|
price?: number;
|
||||||
|
currency?: 'GBP' | 'USD';
|
||||||
description?: string;
|
description?: string;
|
||||||
image?: string | DirectusFile;
|
image?: string | DirectusFile;
|
||||||
gallery?: string[];
|
gallery?: string[];
|
||||||
|
|
@ -153,23 +154,45 @@ export const directus = createDirectus<any>(directusUrl)
|
||||||
.with(staticToken(apiToken))
|
.with(staticToken(apiToken))
|
||||||
.with(rest());
|
.with(rest());
|
||||||
|
|
||||||
// Helper to get asset URL
|
// Helper to get asset URL (includes auth token since Directus assets require authentication)
|
||||||
export function getAssetUrl(fileId: string | DirectusFile | undefined, options?: { width?: number; height?: number; quality?: number; format?: 'webp' | 'jpg' | 'png' }): string {
|
export function getAssetUrl(fileId: string | DirectusFile | undefined, options?: { width?: number; height?: number; quality?: number; format?: 'webp' | 'jpg' | 'png' }): string {
|
||||||
if (!fileId) return '/placeholder.jpg';
|
if (!fileId) return '/placeholder.jpg';
|
||||||
|
|
||||||
const id = typeof fileId === 'string' ? fileId : fileId.id;
|
const id = typeof fileId === 'string' ? fileId : fileId.id;
|
||||||
let url = `${directusUrl}/assets/${id}`;
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
// Auth token required for asset access
|
||||||
|
if (apiToken) params.append('access_token', apiToken);
|
||||||
|
|
||||||
if (options) {
|
if (options) {
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (options.width) params.append('width', options.width.toString());
|
if (options.width) params.append('width', options.width.toString());
|
||||||
if (options.height) params.append('height', options.height.toString());
|
if (options.height) params.append('height', options.height.toString());
|
||||||
if (options.quality) params.append('quality', options.quality.toString());
|
if (options.quality) params.append('quality', options.quality.toString());
|
||||||
if (options.format) params.append('format', options.format);
|
if (options.format) params.append('format', options.format);
|
||||||
if (params.toString()) url += `?${params.toString()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return url;
|
return `${directusUrl}/assets/${id}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map Directus artwork fields to frontend Artwork interface
|
||||||
|
// Directus has: name, price_gbp, price_usd, notes, creation_date
|
||||||
|
// Frontend expects: title, price, description, slug, year
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function mapArtwork(raw: any): Artwork {
|
||||||
|
const gbp = parseFloat(raw.price_gbp);
|
||||||
|
const usd = parseFloat(raw.price_usd);
|
||||||
|
const hasGbp = !isNaN(gbp) && gbp > 0;
|
||||||
|
const hasUsd = !isNaN(usd) && usd > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
title: raw.name || raw.title || '',
|
||||||
|
price: hasGbp ? gbp : hasUsd ? usd : (raw.price || 0),
|
||||||
|
currency: hasGbp ? 'GBP' : 'USD',
|
||||||
|
description: raw.notes || raw.description || '',
|
||||||
|
slug: raw.slug || String(raw.id),
|
||||||
|
year: raw.creation_date ? new Date(raw.creation_date).getFullYear() : raw.year,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// API functions with explicit return types
|
// API functions with explicit return types
|
||||||
|
|
@ -188,30 +211,33 @@ export async function getArtworks(options?: {
|
||||||
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
||||||
limit: options?.limit || -1,
|
limit: options?.limit || -1,
|
||||||
sort: ['-date_created'],
|
sort: ['-date_created'],
|
||||||
fields: options?.fields || ['*', { series: ['id', 'name', 'slug'] }],
|
fields: options?.fields || ['*'],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return result as Artwork[];
|
return (result as Artwork[]).map(mapArtwork);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getArtwork(idOrSlug: string): Promise<Artwork> {
|
export async function getArtwork(idOrSlug: string): Promise<Artwork> {
|
||||||
// Try to find by slug first, then by ID
|
// Try by ID first (since Directus artworks use numeric IDs as slugs)
|
||||||
const bySlug = await directus.request(
|
try {
|
||||||
readItems('artworks', {
|
const result = await directus.request(
|
||||||
filter: { slug: { _eq: idOrSlug } },
|
readItem('artworks', idOrSlug, {
|
||||||
limit: 1,
|
fields: ['*'],
|
||||||
fields: ['*', { series: ['*'] }],
|
})
|
||||||
})
|
);
|
||||||
);
|
return mapArtwork(result);
|
||||||
|
} catch {
|
||||||
if (bySlug.length > 0) return bySlug[0] as Artwork;
|
// Fall back to slug search
|
||||||
|
const bySlug = await directus.request(
|
||||||
const result = await directus.request(
|
readItems('artworks', {
|
||||||
readItem('artworks', idOrSlug, {
|
filter: { slug: { _eq: idOrSlug } },
|
||||||
fields: ['*', { series: ['*'] }],
|
limit: 1,
|
||||||
})
|
fields: ['*'],
|
||||||
);
|
})
|
||||||
return result as Artwork;
|
);
|
||||||
|
if (bySlug.length > 0) return mapArtwork(bySlug[0]);
|
||||||
|
throw new Error(`Artwork not found: ${idOrSlug}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSeries(options?: { limit?: number }): Promise<Series[]> {
|
export async function getSeries(options?: { limit?: number }): Promise<Series[]> {
|
||||||
|
|
@ -244,18 +270,24 @@ export async function getSeriesItem(idOrSlug: string): Promise<Series> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEvents(options?: { status?: string; limit?: number }): Promise<Event[]> {
|
export async function getEvents(options?: { status?: string; limit?: number }): Promise<Event[]> {
|
||||||
const filter: Record<string, unknown> = {};
|
try {
|
||||||
if (options?.status) filter.status = { _eq: options.status };
|
const filter: Record<string, unknown> = {};
|
||||||
|
if (options?.status) filter.status = { _eq: options.status };
|
||||||
|
|
||||||
const result = await directus.request(
|
const result = await directus.request(
|
||||||
readItems('events', {
|
readItems('events', {
|
||||||
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
||||||
limit: options?.limit || -1,
|
limit: options?.limit || -1,
|
||||||
sort: ['-start_date'],
|
sort: ['-start_date'],
|
||||||
fields: ['*'],
|
fields: ['*'],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return result as Event[];
|
return result as Event[];
|
||||||
|
} catch {
|
||||||
|
// Events collection may not exist yet
|
||||||
|
console.warn('Failed to fetch events - collection may not exist yet');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPages(): Promise<Page[]> {
|
export async function getPages(): Promise<Page[]> {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue