Initialize project with Payload CMS
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Failing after 32s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Failing after 32s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
This commit is contained in:
17
apps/website/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
17
apps/website/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import configPromise from '@payload-config';
|
||||
import { RootPage } from '@payloadcms/next/views';
|
||||
import { importMap } from '../importMap';
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[];
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config: configPromise, importMap, params, searchParams });
|
||||
|
||||
export default Page;
|
||||
1
apps/website/app/(payload)/admin/importMap.js
Normal file
1
apps/website/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1 @@
|
||||
export const importMap = {};
|
||||
14
apps/website/app/(payload)/api/[...slug]/route.ts
Normal file
14
apps/website/app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import config from '@payload-config';
|
||||
import {
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_DELETE,
|
||||
} from '@payloadcms/next/routes';
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
30
apps/website/app/(payload)/layout.tsx
Normal file
30
apps/website/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import configPromise from '@payload-config';
|
||||
import { RootLayout } from '@payloadcms/next/layouts';
|
||||
import React from 'react';
|
||||
|
||||
import '@payloadcms/next/css';
|
||||
import { handleServerFunctions } from '@payloadcms/next/layouts';
|
||||
import { importMap } from './admin/importMap';
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const serverFunction: any = async function (args: any) {
|
||||
'use server';
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config: configPromise,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => {
|
||||
return (
|
||||
<RootLayout config={configPromise} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
93
apps/website/app/actions/contact.ts
Normal file
93
apps/website/app/actions/contact.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
'use server';
|
||||
|
||||
import { sendEmail } from '@/lib/mail/mailer';
|
||||
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||
import React from 'react';
|
||||
|
||||
export async function sendContactFormAction(formData: FormData) {
|
||||
const name = formData.get('name') as string;
|
||||
const email = formData.get('email') as string;
|
||||
const message = formData.get('message') as string;
|
||||
const productName = formData.get('productName') as string | null;
|
||||
|
||||
if (!name || !email || !message) {
|
||||
console.warn('Missing required fields in contact form');
|
||||
return { success: false, error: 'Missing required fields' };
|
||||
}
|
||||
|
||||
// 1. Save to CMS
|
||||
try {
|
||||
const { getPayload } = await import('payload');
|
||||
const configPromise = (await import('@payload-config')).default;
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
await payload.create({
|
||||
collection: 'form-submissions',
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
type: productName ? 'product_quote' : 'contact',
|
||||
productName: productName || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Successfully saved form submission to Payload CMS');
|
||||
} catch (error) {
|
||||
console.error('Failed to store submission in Payload CMS', { error });
|
||||
}
|
||||
|
||||
// 2. Send Emails
|
||||
console.log('Sending branded emails', { email, productName });
|
||||
|
||||
const notificationSubject = productName
|
||||
? `Product Inquiry: ${productName}`
|
||||
: 'New Contact Form Submission';
|
||||
const confirmationSubject = 'Thank you for your inquiry';
|
||||
|
||||
try {
|
||||
// 2a. Send notification to Mintel/Client
|
||||
const notificationHtml = await render(
|
||||
React.createElement(ContactFormNotification, {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
productName: productName || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const notificationResult = await sendEmail({
|
||||
replyTo: email,
|
||||
subject: notificationSubject,
|
||||
html: notificationHtml,
|
||||
});
|
||||
|
||||
if (!notificationResult.success) {
|
||||
console.error('Notification email FAILED', { error: notificationResult.error });
|
||||
}
|
||||
|
||||
// 2b. Send confirmation to Customer
|
||||
const confirmationHtml = await render(
|
||||
React.createElement(ConfirmationMessage, {
|
||||
name,
|
||||
clientName: 'Cable Creations',
|
||||
}),
|
||||
);
|
||||
|
||||
const confirmationResult = await sendEmail({
|
||||
to: email,
|
||||
subject: confirmationSubject,
|
||||
html: confirmationHtml,
|
||||
});
|
||||
|
||||
if (!confirmationResult.success) {
|
||||
console.error('Confirmation email FAILED', { error: confirmationResult.error });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error('Failed to send branded emails', { error: errorMsg });
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
62
apps/website/lib/mail/mailer.ts
Normal file
62
apps/website/lib/mail/mailer.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
let transporterInstance: nodemailer.Transporter | null = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (transporterInstance) return transporterInstance;
|
||||
|
||||
if (!process.env.MAIL_HOST) {
|
||||
throw new Error('MAIL_HOST is not configured. Please check your environment variables.');
|
||||
}
|
||||
|
||||
transporterInstance = nodemailer.createTransport({
|
||||
host: process.env.MAIL_HOST,
|
||||
port: Number(process.env.MAIL_PORT) || 587,
|
||||
secure: Number(process.env.MAIL_PORT) === 465,
|
||||
auth: {
|
||||
user: process.env.MAIL_USERNAME,
|
||||
pass: process.env.MAIL_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
return transporterInstance;
|
||||
}
|
||||
|
||||
interface SendEmailOptions {
|
||||
to?: string | string[];
|
||||
replyTo?: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions) {
|
||||
const recipients = to || process.env.MAIL_RECIPIENTS;
|
||||
|
||||
if (!recipients) {
|
||||
console.error('No email recipients configured', { subject });
|
||||
return { success: false as const, error: 'No recipients configured' };
|
||||
}
|
||||
|
||||
if (!process.env.MAIL_FROM) {
|
||||
console.error('MAIL_FROM is not configured', { subject, recipients });
|
||||
return { success: false as const, error: 'MAIL_FROM is not configured' };
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.MAIL_FROM,
|
||||
to: recipients,
|
||||
replyTo,
|
||||
subject,
|
||||
html,
|
||||
};
|
||||
|
||||
try {
|
||||
const info = await getTransporter().sendMail(mailOptions);
|
||||
console.log('Email sent successfully', { messageId: info.messageId, subject, recipients });
|
||||
return { success: true, messageId: info.messageId };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error('Error sending email', { error: errorMsg, subject, recipients });
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NextConfig } from "next";
|
||||
import createMDX from "@next/mdx";
|
||||
import { withPayload } from "@payloadcms/next/withPayload";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
@@ -11,4 +12,4 @@ const withMDX = createMDX({
|
||||
});
|
||||
|
||||
// Merge MDX config with Next.js config
|
||||
export default withMDX(nextConfig);
|
||||
export default withPayload(withMDX(nextConfig));
|
||||
|
||||
@@ -6,27 +6,41 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"cms:migrate": "payload migrate",
|
||||
"cms:generate:types": "payload generate:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@mintel/mail": "^1.8.21",
|
||||
"@next/mdx": "^16.1.3",
|
||||
"@payloadcms/db-postgres": "^3.77.0",
|
||||
"@payloadcms/email-nodemailer": "^3.77.0",
|
||||
"@payloadcms/next": "^3.77.0",
|
||||
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||
"@payloadcms/ui": "^3.77.0",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"graphql": "^16.12.0",
|
||||
"next": "16.1.3",
|
||||
"nodemailer": "^7.0.12",
|
||||
"payload": "^3.77.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-mdx-frontmatter": "^5.2.0"
|
||||
"remark-mdx-frontmatter": "^5.2.0",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
66
apps/website/payload.config.ts
Normal file
66
apps/website/payload.config.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { buildConfig } from 'payload';
|
||||
import { postgresAdapter } from '@payloadcms/db-postgres';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
import sharp from 'sharp';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { nodemailerAdapter } from '@payloadcms/email-nodemailer';
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
sharp.cache(false);
|
||||
}
|
||||
|
||||
import { Users } from './src/payload/collections/Users';
|
||||
import { Media } from './src/payload/collections/Media';
|
||||
import { FormSubmissions } from './src/payload/collections/FormSubmissions';
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
meta: {
|
||||
titleSuffix: ' – Cable Creations',
|
||||
icons: [{ rel: 'icon', type: 'image/x-icon', url: '/favicon.ico' }],
|
||||
},
|
||||
},
|
||||
collections: [Users, Media, FormSubmissions],
|
||||
editor: lexicalEditor({}),
|
||||
secret: process.env.PAYLOAD_SECRET || 'fallback-secret-for-dev',
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString:
|
||||
process.env.DATABASE_URI ||
|
||||
process.env.POSTGRES_URI ||
|
||||
`postgresql://${process.env.PAYLOAD_DB_USER || 'payload'}:${process.env.PAYLOAD_DB_PASSWORD || 'payload'}@127.0.0.1:5432/${process.env.PAYLOAD_DB_NAME || 'payload'}`,
|
||||
},
|
||||
}),
|
||||
email: process.env.MAIL_HOST
|
||||
? nodemailerAdapter({
|
||||
defaultFromAddress:
|
||||
process.env.MAIL_FROM?.replace(/.*<|>.*/g, '') || 'postmaster@mg.mintel.me',
|
||||
defaultFromName: process.env.MAIL_FROM?.split('<')[0]?.trim() || 'Cable Creations',
|
||||
transportOptions: {
|
||||
host: process.env.MAIL_HOST || 'smtp.eu.mailgun.org',
|
||||
port: Number(process.env.MAIL_PORT) || 587,
|
||||
...(process.env.MAIL_USERNAME
|
||||
? {
|
||||
auth: {
|
||||
user: process.env.MAIL_USERNAME,
|
||||
pass: process.env.MAIL_PASSWORD,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
sharp,
|
||||
plugins: [],
|
||||
});
|
||||
67
apps/website/src/payload/collections/FormSubmissions.ts
Normal file
67
apps/website/src/payload/collections/FormSubmissions.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const FormSubmissions: CollectionConfig = {
|
||||
slug: 'form-submissions',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'email', 'type', 'createdAt'],
|
||||
description: 'Captured leads from Contact and Product Quote forms.',
|
||||
},
|
||||
access: {
|
||||
// Only Admins can view and delete leads via dashboard.
|
||||
read: ({ req: { user } }) => Boolean(user) || process.env.NODE_ENV === 'development',
|
||||
update: ({ req: { user } }) => Boolean(user) || process.env.NODE_ENV === 'development',
|
||||
delete: ({ req: { user } }) => Boolean(user) || process.env.NODE_ENV === 'development',
|
||||
// Next.js server actions handle secure inserts natively. No public client create access.
|
||||
create: () => false,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'General Contact', value: 'contact' },
|
||||
{ label: 'Product Quote', value: 'product_quote' },
|
||||
],
|
||||
required: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'productName',
|
||||
type: 'text',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
condition: (data) => data.type === 'product_quote',
|
||||
description: 'The specific product the user requested a quote for.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
47
apps/website/src/payload/collections/Media.ts
Normal file
47
apps/website/src/payload/collections/Media.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'filename',
|
||||
defaultColumns: ['filename', 'alt', 'updatedAt'],
|
||||
},
|
||||
upload: {
|
||||
staticDir: 'public/media',
|
||||
adminThumbnail: 'thumbnail',
|
||||
imageSizes: [
|
||||
{
|
||||
name: 'thumbnail',
|
||||
width: 600,
|
||||
height: undefined,
|
||||
position: 'centre',
|
||||
},
|
||||
{
|
||||
name: 'card',
|
||||
width: 768,
|
||||
height: undefined,
|
||||
position: 'centre',
|
||||
},
|
||||
{
|
||||
name: 'tablet',
|
||||
width: 1024,
|
||||
height: undefined,
|
||||
position: 'centre',
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'caption',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
};
|
||||
12
apps/website/src/payload/collections/Users.ts
Normal file
12
apps/website/src/payload/collections/Users.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
auth: true,
|
||||
fields: [
|
||||
// Email added by default
|
||||
],
|
||||
};
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -19,7 +23,12 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@payload-config": [
|
||||
"./payload.config.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -31,5 +40,7 @@
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user