feat: payload cms

This commit is contained in:
2026-02-26 01:32:22 +01:00
parent 1963a93123
commit 7d65237ee9
67 changed files with 3179 additions and 760 deletions

View File

@@ -35,7 +35,6 @@ export interface PostFrontmatter {
focalX?: number;
focalY?: number;
category?: string;
locale: string;
public?: boolean;
}
@@ -65,9 +64,9 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
collection: 'posts',
where: {
slug: { equals: slug },
locale: { equals: locale },
...(!isDev ? { _status: { equals: 'published' } } : {}),
},
locale: locale as any,
draft: isDev,
limit: 1,
});
@@ -83,7 +82,6 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
date: doc.date,
excerpt: doc.excerpt || '',
category: doc.category || '',
locale: doc.locale,
featuredImage:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
@@ -113,11 +111,9 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
const { docs } = await payload.find({
collection: 'posts',
where: {
locale: {
equals: locale,
},
...(!isDev ? { _status: { equals: 'published' } } : {}),
},
locale: locale as any,
sort: '-date',
draft: isDev,
limit: 100,
@@ -125,7 +121,7 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
console.log(`[Payload] getAllPosts for ${locale}: Found ${docs.length} docs`);
return docs.map((doc) => {
const posts = docs.map((doc) => {
return {
slug: doc.slug,
frontmatter: {
@@ -133,7 +129,6 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
date: doc.date,
excerpt: doc.excerpt || '',
category: doc.category || '',
locale: doc.locale,
featuredImage:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
@@ -151,6 +146,9 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
content: doc.content as any,
};
});
// Integrity check: only show posts with a featured image in listings/sitemap
return posts.filter((p) => !!p.frontmatter.featuredImage);
} catch (error) {
console.error(`[Payload] getAllPosts failed for ${locale}:`, error);
return [];

View File

@@ -33,6 +33,17 @@ interface SendEmailOptions {
export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions) {
const recipients = to || config.mail.recipients;
const logger = getServerAppServices().logger.child({ component: 'mailer' });
if (!recipients) {
logger.error('No email recipients configured (MAIL_RECIPIENTS is empty and no "to" provided)', { subject });
return { success: false as const, error: 'No recipients configured' };
}
if (!config.mail.from) {
logger.error('MAIL_FROM is not configured — cannot send email', { subject, recipients });
return { success: false as const, error: 'MAIL_FROM is not configured' };
}
const mailOptions = {
from: config.mail.from,
@@ -42,7 +53,6 @@ export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions
html,
};
const logger = getServerAppServices().logger.child({ component: 'mailer' });
try {
const info = await getTransporter().sendMail(mailOptions);

View File

@@ -5,7 +5,9 @@ export interface PageFrontmatter {
title: string;
excerpt: string;
featuredImage: string | null;
locale: string;
focalX?: number;
focalY?: number;
layout?: 'default' | 'fullBleed';
public?: boolean;
}
@@ -15,6 +17,30 @@ export interface PageMdx {
content: any; // Lexical AST Document
}
function mapDoc(doc: any): PageMdx {
return {
slug: doc.slug,
frontmatter: {
title: doc.title,
excerpt: doc.excerpt || '',
featuredImage:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
: null,
focalX:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.focalX
: 50,
focalY:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.focalY
: 50,
layout: doc.layout || 'default',
} as PageFrontmatter,
content: doc.content as any,
};
}
export async function getPageBySlug(slug: string, locale: string): Promise<PageMdx | null> {
try {
const payload = await getPayload({ config: configPromise });
@@ -23,30 +49,14 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageM
collection: 'pages' as any,
where: {
slug: { equals: slug },
locale: { equals: locale },
},
locale: locale as any,
limit: 1,
});
const docs = result.docs as any[];
if (!docs || docs.length === 0) return null;
const doc = docs[0];
return {
slug: doc.slug,
frontmatter: {
title: doc.title,
excerpt: doc.excerpt || '',
locale: doc.locale,
featuredImage:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
: null,
} as PageFrontmatter,
content: doc.content as any, // Native Lexical Editor State
};
return mapDoc(docs[0]);
} catch (error) {
console.error(`[Payload] getPageBySlug failed for ${slug}:`, error);
return null;
@@ -59,31 +69,11 @@ export async function getAllPages(locale: string): Promise<PageMdx[]> {
const result = await payload.find({
collection: 'pages' as any,
where: {
locale: {
equals: locale,
},
},
locale: locale as any,
limit: 100,
});
const docs = result.docs as any[];
return docs.map((doc: any) => {
return {
slug: doc.slug,
frontmatter: {
title: doc.title,
excerpt: doc.excerpt || '',
locale: doc.locale,
featuredImage:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
: null,
} as PageFrontmatter,
content: doc.content as any,
};
});
return (result.docs as any[]).map(mapDoc);
} catch (error) {
console.error(`[Payload] getAllPages failed for ${locale}:`, error);
return [];
@@ -96,30 +86,29 @@ export async function getAllPagesMetadata(locale: string): Promise<Partial<PageM
const result = await payload.find({
collection: 'pages' as any,
where: {
locale: {
equals: locale,
},
},
locale: locale as any,
limit: 100,
});
const docs = result.docs as any[];
return docs.map((doc: any) => {
return {
slug: doc.slug,
frontmatter: {
title: doc.title,
excerpt: doc.excerpt || '',
locale: doc.locale,
featuredImage:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
: null,
} as PageFrontmatter,
};
});
return (result.docs as any[]).map((doc: any) => ({
slug: doc.slug,
frontmatter: {
title: doc.title,
excerpt: doc.excerpt || '',
featuredImage:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
: null,
focalX:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.focalX
: 50,
focalY:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.focalY
: 50,
} as PageFrontmatter,
}));
} catch (error) {
console.error(`[Payload] getAllPagesMetadata failed for ${locale}:`, error);
return [];

View File

@@ -8,11 +8,12 @@ export interface ProductFrontmatter {
description: string;
categories: string[];
images: string[];
locale: string;
focalX?: number;
focalY?: number;
isFallback?: boolean;
}
export interface ProductMdx {
export interface ProductData {
slug: string;
frontmatter: ProductFrontmatter;
content: any; // Lexical AST from Payload
@@ -21,36 +22,24 @@ export interface ProductMdx {
export async function getProductMetadata(
slug: string,
locale: string,
): Promise<Partial<ProductMdx> | null> {
): Promise<Partial<ProductData> | null> {
const payload = await getPayload({ config: configPromise });
const fileSlug = await mapSlugToFileSlug(slug, locale);
let result = await payload.find({
const isDev = process.env.NODE_ENV === 'development';
const result = await payload.find({
collection: 'products',
where: {
and: [{ slug: { equals: fileSlug } }, { locale: { equals: locale } }],
and: [
{ slug: { equals: fileSlug } },
...(!isDev ? [{ _status: { equals: 'published' } }] : []),
],
},
depth: 1, // To auto-resolve Media relation (images array)
locale: locale as any,
depth: 1,
limit: 1,
});
let isFallback = false;
if (result.docs.length === 0 && locale !== 'en') {
// Fallback to English
result = await payload.find({
collection: 'products',
where: {
and: [{ slug: { equals: fileSlug } }, { locale: { equals: 'en' } }],
},
depth: 1,
limit: 1,
});
if (result.docs.length > 0) {
isFallback = true;
}
}
if (result.docs.length > 0) {
const doc = result.docs[0];
@@ -69,8 +58,6 @@ export async function getProductMetadata(
description: doc.description,
categories: Array.isArray(doc.categories) ? doc.categories.map((c: any) => c.category) : [],
images: resolvedImages,
locale: doc.locale,
isFallback,
},
};
}
@@ -78,37 +65,25 @@ export async function getProductMetadata(
return null;
}
export async function getProductBySlug(slug: string, locale: string): Promise<ProductMdx | null> {
export async function getProductBySlug(slug: string, locale: string): Promise<ProductData | null> {
try {
const payload = await getPayload({ config: configPromise });
const fileSlug = await mapSlugToFileSlug(slug, locale);
let result = await payload.find({
const isDev = process.env.NODE_ENV === 'development';
const result = await payload.find({
collection: 'products',
where: {
and: [{ slug: { equals: fileSlug } }, { locale: { equals: locale } }],
and: [
{ slug: { equals: fileSlug } },
...(!isDev ? [{ _status: { equals: 'published' } }] : []),
],
},
depth: 1, // Auto-resolve Media logic
locale: locale as any,
depth: 1,
limit: 1,
});
let isFallback = false;
if (result.docs.length === 0 && locale !== 'en') {
// Fallback to English
result = await payload.find({
collection: 'products',
where: {
and: [{ slug: { equals: fileSlug } }, { locale: { equals: 'en' } }],
},
depth: 1,
limit: 1,
});
if (result.docs.length > 0) {
isFallback = true;
}
}
if (result.docs.length > 0) {
const doc = result.docs[0];
@@ -129,10 +104,16 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
? doc.categories.map((c: any) => c.category)
: [],
images: resolvedImages,
locale: doc.locale,
isFallback,
focalX:
Array.isArray(doc.images) && doc.images.length > 0 && typeof doc.images[0] === 'object'
? doc.images[0].focalX
: 50,
focalY:
Array.isArray(doc.images) && doc.images.length > 0 && typeof doc.images[0] === 'object'
? doc.images[0].focalY
: 50,
},
content: doc.content, // Lexical payload instead of raw MDX String
content: doc.content,
};
}
@@ -146,14 +127,14 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
export async function getAllProductSlugs(locale: string): Promise<string[]> {
try {
const payload = await getPayload({ config: configPromise });
const isDev = process.env.NODE_ENV === 'development';
const result = await payload.find({
collection: 'products',
where: {
locale: {
equals: locale,
},
...(!isDev ? { _status: { equals: 'published' } } : {}),
},
pagination: false, // get all docs
locale: locale as any,
pagination: false,
});
return result.docs.map((doc) => doc.slug);
@@ -163,7 +144,7 @@ export async function getAllProductSlugs(locale: string): Promise<string[]> {
}
}
export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
export async function getAllProducts(locale: string): Promise<ProductData[]> {
try {
const payload = await getPayload({ config: configPromise });
@@ -174,13 +155,15 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
description: true,
categories: true,
images: true,
locale: true,
} as const;
// Get products for this locale
const isDev = process.env.NODE_ENV === 'development';
const result = await payload.find({
collection: 'products',
where: { locale: { equals: locale } },
where: {
...(!isDev ? { _status: { equals: 'published' } } : {}),
},
locale: locale as any,
depth: 1,
pagination: false,
select: selectFields,
@@ -188,7 +171,7 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
console.log(`[Payload] getAllProducts for ${locale}: Found ${result.docs.length} docs`);
let products: ProductMdx[] = result.docs.map((doc) => {
let products: ProductData[] = result.docs.map((doc) => {
const resolvedImages = ((doc.images as any[]) || [])
.map((img) => (typeof img === 'string' ? img : img.url))
.filter(Boolean) as string[];
@@ -205,55 +188,21 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
description: doc.description ? String(doc.description) : '',
categories: plainCategories,
images: resolvedImages,
locale: String(doc.locale),
focalX:
Array.isArray(doc.images) && doc.images.length > 0 && typeof doc.images[0] === 'object'
? doc.images[0].focalX
: 50,
focalY:
Array.isArray(doc.images) && doc.images.length > 0 && typeof doc.images[0] === 'object'
? doc.images[0].focalY
: 50,
},
content: null,
};
});
// Also include English fallbacks for slugs not in this locale
if (locale !== 'en') {
const localeSlugs = new Set(products.map((p) => p.slug));
const enResult = await payload.find({
collection: 'products',
where: { locale: { equals: 'en' } },
depth: 1,
pagination: false,
select: selectFields,
});
console.log(
`[Payload] getAllProducts (en fallbacks) for ${locale}: Found ${enResult.docs.length} docs`,
);
const fallbacks = enResult.docs
.filter((doc) => !localeSlugs.has(doc.slug))
.map((doc) => {
const resolvedImages = ((doc.images as any[]) || [])
.map((img) => (typeof img === 'string' ? img : img.url))
.filter(Boolean) as string[];
const plainCategories = Array.isArray(doc.categories)
? doc.categories.map((c: any) => String(c.category))
: [];
return {
slug: String(doc.slug),
frontmatter: {
title: String(doc.title),
sku: doc.sku ? String(doc.sku) : '',
description: doc.description ? String(doc.description) : '',
categories: plainCategories,
images: resolvedImages,
locale: String(doc.locale),
isFallback: true,
},
content: null,
};
});
products = [...products, ...fallbacks];
}
// Filter out products with 0 images (data integrity check to prevent 404s)
products = products.filter((p) => p.frontmatter.images && p.frontmatter.images.length > 0);
return products;
} catch (error) {
@@ -262,7 +211,7 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
}
}
export async function getAllProductsMetadata(locale: string): Promise<Partial<ProductMdx>[]> {
export async function getAllProductsMetadata(locale: string): Promise<Partial<ProductData>[]> {
const products = await getAllProducts(locale);
return products.map((p) => ({
slug: p.slug,