fix: Email template improvements
This commit is contained in:
@@ -9,16 +9,21 @@ RUN apk add --no-cache dumb-init git curl
|
|||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files from backend directory
|
||||||
COPY package*.json ./
|
COPY backend/package*.json ./
|
||||||
|
|
||||||
# Install all dependencies (including dev for building)
|
# Install all dependencies (including dev for building)
|
||||||
RUN npm install && npm cache clean --force
|
RUN npm install && npm cache clean --force
|
||||||
|
|
||||||
# Copy source code
|
# Copy logo from frontend for email templates (needed for build)
|
||||||
COPY . .
|
RUN mkdir -p frontend/public/images/logos
|
||||||
|
COPY frontend/public/images/logos/motovaultpro-logo-title.png frontend/public/images/logos/
|
||||||
|
|
||||||
# Build the application
|
# Copy backend source code
|
||||||
|
COPY backend/ .
|
||||||
|
|
||||||
|
# Build the application (prebuild will encode logo)
|
||||||
|
ENV DOCKER_BUILD=true
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Production runtime
|
# Stage 2: Production runtime
|
||||||
@@ -31,7 +36,7 @@ RUN apk add --no-cache dumb-init curl postgresql-client
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files and any lock file generated in builder stage
|
# Copy package files and any lock file generated in builder stage
|
||||||
COPY package*.json ./
|
COPY backend/package*.json ./
|
||||||
COPY --from=builder /app/package-lock.json ./
|
COPY --from=builder /app/package-lock.json ./
|
||||||
|
|
||||||
# Install only production dependencies
|
# Install only production dependencies
|
||||||
@@ -52,7 +57,7 @@ COPY --from=builder /app/src/features /app/migrations/features
|
|||||||
COPY --from=builder /app/src/core /app/migrations/core
|
COPY --from=builder /app/src/core /app/migrations/core
|
||||||
|
|
||||||
# Copy entrypoint script for permission checks
|
# Copy entrypoint script for permission checks
|
||||||
COPY scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY backend/scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh
|
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Change ownership to non-root user
|
# Change ownership to non-root user
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Base HTML Email Layout
|
||||||
|
* @ai-summary Main email wrapper with MotoVaultPro branding
|
||||||
|
* @ai-context Uses table-based layout for email client compatibility
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EMAIL_STYLES } from './email-styles';
|
||||||
|
|
||||||
|
// External logo URL - hosted on GitHub for reliability
|
||||||
|
const LOGO_URL = 'https://raw.githubusercontent.com/ericgullickson/images/c58b0e4773e8395b532f97f6ab529e38ea4dc8be/motovaultpro-auth0-small.png';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the complete HTML email layout with branding
|
||||||
|
* @param content - The rendered email body content (HTML)
|
||||||
|
* @returns Complete HTML email string with DOCTYPE and layout
|
||||||
|
*/
|
||||||
|
export function renderEmailLayout(content: string): string {
|
||||||
|
return `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="x-apple-disable-message-reformatting" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<title>MotoVaultPro</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style type="text/css">
|
||||||
|
table { border-collapse: collapse; }
|
||||||
|
.outlook-fix { font-family: Arial, sans-serif; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f8f9fa;">
|
||||||
|
<!-- Wrapper table for full width background -->
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="${EMAIL_STYLES.wrapper}">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="${EMAIL_STYLES.cell}">
|
||||||
|
<!-- Main container table (max-width 600px) -->
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="${EMAIL_STYLES.container}" class="outlook-fix">
|
||||||
|
|
||||||
|
<!-- Header with logo -->
|
||||||
|
<tr>
|
||||||
|
<td style="${EMAIL_STYLES.header}">
|
||||||
|
<img src="${LOGO_URL}" alt="MotoVaultPro" style="${EMAIL_STYLES.logo}" width="280" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content area -->
|
||||||
|
<tr>
|
||||||
|
<td style="${EMAIL_STYLES.content}">
|
||||||
|
${content}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="${EMAIL_STYLES.footer}">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<p style="${EMAIL_STYLES.footerText}">
|
||||||
|
<strong>Professional Vehicle Management & Maintenance Tracking</strong>
|
||||||
|
</p>
|
||||||
|
<p style="${EMAIL_STYLES.footerText}">
|
||||||
|
<a href="https://motovaultpro.com" style="${EMAIL_STYLES.footerLink}" target="_blank">Login to MotoVaultPro</a>
|
||||||
|
</p>
|
||||||
|
<p style="${EMAIL_STYLES.footerText}">
|
||||||
|
<a href="{{unsubscribeUrl}}" style="${EMAIL_STYLES.footerLink}" target="_blank">Manage Email Preferences</a>
|
||||||
|
</p>
|
||||||
|
<p style="${EMAIL_STYLES.copyright}">
|
||||||
|
© 2025 MotoVaultPro. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Email Template Inline Styles
|
||||||
|
* @ai-summary Reusable inline CSS constants for email templates
|
||||||
|
* @ai-context Email clients require inline styles for proper rendering
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const EMAIL_STYLES = {
|
||||||
|
// Layout containers
|
||||||
|
wrapper: 'width: 100%; background-color: #f8f9fa; padding: 20px 0;',
|
||||||
|
container: 'max-width: 600px; margin: 0 auto; background-color: #ffffff;',
|
||||||
|
innerContainer: 'width: 100%;',
|
||||||
|
|
||||||
|
// Header
|
||||||
|
header: 'background-color: #7A212A; padding: 30px 20px; text-align: center;',
|
||||||
|
logo: 'max-width: 280px; height: auto; display: block; margin: 0 auto;',
|
||||||
|
|
||||||
|
// Content area
|
||||||
|
content: 'padding: 40px 30px; font-family: Arial, Helvetica, sans-serif; color: #1e293b; line-height: 1.6; font-size: 16px;',
|
||||||
|
|
||||||
|
// Typography
|
||||||
|
heading: 'color: #7A212A; font-size: 24px; font-weight: bold; margin: 0 0 20px 0; font-family: Arial, Helvetica, sans-serif;',
|
||||||
|
subheading: 'color: #1e293b; font-size: 18px; font-weight: bold; margin: 0 0 16px 0; font-family: Arial, Helvetica, sans-serif;',
|
||||||
|
paragraph: 'margin: 0 0 16px 0; font-size: 16px; color: #1e293b; font-family: Arial, Helvetica, sans-serif; line-height: 1.6;',
|
||||||
|
strong: 'font-weight: bold; color: #7A212A;',
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
footer: 'background-color: #f1f5f9; padding: 30px 20px; text-align: center; border-top: 2px solid #7A212A;',
|
||||||
|
footerText: 'font-size: 14px; color: #64748b; margin: 8px 0; font-family: Arial, Helvetica, sans-serif;',
|
||||||
|
footerLink: 'color: #7A212A; text-decoration: none; font-weight: bold;',
|
||||||
|
footerLinkHover: 'color: #9c2a36; text-decoration: underline;',
|
||||||
|
copyright: 'font-size: 12px; color: #94a3b8; margin: 16px 0 0 0; font-family: Arial, Helvetica, sans-serif;',
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
divider: 'border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;',
|
||||||
|
|
||||||
|
// Table cells
|
||||||
|
cell: 'padding: 0;',
|
||||||
|
cellWithPadding: 'padding: 20px;',
|
||||||
|
} as const;
|
||||||
@@ -23,7 +23,7 @@ export class EmailService {
|
|||||||
* Send an email using Resend
|
* Send an email using Resend
|
||||||
* @param to Recipient email address
|
* @param to Recipient email address
|
||||||
* @param subject Email subject line
|
* @param subject Email subject line
|
||||||
* @param html Email body (HTML format)
|
* @param html Email body (HTML format with inline styles for email client compatibility)
|
||||||
* @returns Promise that resolves when email is sent
|
* @returns Promise that resolves when email is sent
|
||||||
*/
|
*/
|
||||||
async send(to: string, subject: string, html: string): Promise<void> {
|
async send(to: string, subject: string, html: string): Promise<void> {
|
||||||
@@ -39,16 +39,4 @@ export class EmailService {
|
|||||||
throw new Error(`Failed to send email: ${errorMessage}`);
|
throw new Error(`Failed to send email: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email with plain text body (converted to HTML)
|
|
||||||
* @param to Recipient email address
|
|
||||||
* @param subject Email subject line
|
|
||||||
* @param text Email body (plain text)
|
|
||||||
*/
|
|
||||||
async sendText(to: string, subject: string, text: string): Promise<void> {
|
|
||||||
// Convert plain text to HTML with proper line breaks
|
|
||||||
const html = text.split('\n').map(line => `<p>${line}</p>`).join('');
|
|
||||||
await this.send(to, subject, html);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,10 +94,15 @@ export class NotificationsService {
|
|||||||
subject: string,
|
subject: string,
|
||||||
body: string,
|
body: string,
|
||||||
variables: Record<string, string | number | boolean | null | undefined>
|
variables: Record<string, string | number | boolean | null | undefined>
|
||||||
): Promise<{ subject: string; body: string }> {
|
): Promise<{ subject: string; body: string; html: string }> {
|
||||||
|
const renderedSubject = this.templateService.render(subject, variables);
|
||||||
|
const renderedBody = this.templateService.render(body, variables);
|
||||||
|
const renderedHtml = this.templateService.renderEmailHtml(body, variables);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subject: this.templateService.render(subject, variables),
|
subject: renderedSubject,
|
||||||
body: this.templateService.render(body, variables),
|
body: renderedBody,
|
||||||
|
html: renderedHtml,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,10 +135,10 @@ export class NotificationsService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const subject = this.templateService.render(template.subject, variables);
|
const subject = this.templateService.render(template.subject, variables);
|
||||||
const body = this.templateService.render(template.body, variables);
|
const htmlBody = this.templateService.renderEmailHtml(template.body, variables);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.emailService.sendText(userEmail, subject, body);
|
await this.emailService.send(userEmail, subject, htmlBody);
|
||||||
|
|
||||||
await this.repository.insertNotificationLog({
|
await this.repository.insertNotificationLog({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
@@ -188,10 +193,10 @@ export class NotificationsService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const subject = this.templateService.render(template.subject, variables);
|
const subject = this.templateService.render(template.subject, variables);
|
||||||
const body = this.templateService.render(template.body, variables);
|
const htmlBody = this.templateService.renderEmailHtml(template.body, variables);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.emailService.sendText(userEmail, subject, body);
|
await this.emailService.send(userEmail, subject, htmlBody);
|
||||||
|
|
||||||
await this.repository.insertNotificationLog({
|
await this.repository.insertNotificationLog({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
@@ -249,9 +254,10 @@ export class NotificationsService {
|
|||||||
|
|
||||||
const subject = this.templateService.render(template.subject, sampleVariables);
|
const subject = this.templateService.render(template.subject, sampleVariables);
|
||||||
const body = this.templateService.render(template.body, sampleVariables);
|
const body = this.templateService.render(template.body, sampleVariables);
|
||||||
|
const htmlBody = this.templateService.renderEmailHtml(template.body, sampleVariables);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.emailService.sendText(recipientEmail, `[TEST] ${subject}`, body);
|
await this.emailService.send(recipientEmail, `[TEST] ${subject}`, htmlBody);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subject,
|
subject,
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
* @ai-context Replaces {{variableName}} with values
|
* @ai-context Replaces {{variableName}} with values
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { renderEmailLayout } from './email-layout/base-layout';
|
||||||
|
import { EMAIL_STYLES } from './email-layout/email-styles';
|
||||||
|
|
||||||
export class TemplateService {
|
export class TemplateService {
|
||||||
/**
|
/**
|
||||||
* Render a template string by replacing {{variableName}} with values
|
* Render a template string by replacing {{variableName}} with values
|
||||||
@@ -22,6 +25,38 @@ export class TemplateService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a template as HTML email with branded layout
|
||||||
|
* @param template Template string with {{variable}} placeholders
|
||||||
|
* @param variables Object mapping variable names to values
|
||||||
|
* @returns Complete HTML email string with layout wrapper
|
||||||
|
*/
|
||||||
|
renderEmailHtml(template: string, variables: Record<string, string | number | boolean | null | undefined>): string {
|
||||||
|
// 1. Replace variables in template body
|
||||||
|
const renderedContent = this.render(template, variables);
|
||||||
|
|
||||||
|
// 2. Escape HTML special characters to prevent XSS
|
||||||
|
const escapeHtml = (text: string): string => {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Convert plain text line breaks to HTML paragraphs
|
||||||
|
const htmlContent = renderedContent
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line.length > 0)
|
||||||
|
.map(line => `<p style="${EMAIL_STYLES.paragraph}">${escapeHtml(line)}</p>`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
// 4. Wrap in branded email layout
|
||||||
|
return renderEmailLayout(htmlContent);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract variable names from a template string
|
* Extract variable names from a template string
|
||||||
* @param template Template string with {{variable}} placeholders
|
* @param template Template string with {{variable}} placeholders
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add HTML body column to email templates
|
||||||
|
* @ai-summary Non-breaking migration for future HTML template support
|
||||||
|
* @ai-context Existing plain text templates auto-convert to HTML
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Add optional html_body column for custom HTML templates (future enhancement)
|
||||||
|
ALTER TABLE email_templates
|
||||||
|
ADD COLUMN html_body TEXT DEFAULT NULL;
|
||||||
|
|
||||||
|
-- Add comment explaining the column purpose
|
||||||
|
COMMENT ON COLUMN email_templates.html_body IS
|
||||||
|
'Optional custom HTML body. If NULL, the plain text body will be auto-converted to HTML with base layout.';
|
||||||
|
|
||||||
|
-- No data updates needed - existing templates continue to work
|
||||||
|
-- The system will auto-convert plain text body to HTML using renderEmailHtml()
|
||||||
@@ -86,8 +86,8 @@ services:
|
|||||||
# Application Services - Backend API
|
# Application Services - Backend API
|
||||||
mvp-backend:
|
mvp-backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
cache_from:
|
cache_from:
|
||||||
- node:lts-alpine
|
- node:lts-alpine
|
||||||
container_name: mvp-backend
|
container_name: mvp-backend
|
||||||
|
|||||||
@@ -48,16 +48,19 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
|
|||||||
- Make no assumptions.
|
- Make no assumptions.
|
||||||
- Ask clarifying questions.
|
- Ask clarifying questions.
|
||||||
- Ultrathink
|
- Ultrathink
|
||||||
- The initial data load for this applicaiton during the CI/CD process in gitlab needs to be updated
|
- This application is ready to go into production.
|
||||||
|
- Analysis needs to be done on the CI/CD pipeline
|
||||||
|
|
||||||
*** CONTEXT ***
|
*** CONTEXT ***
|
||||||
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
|
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
|
||||||
- The current deployment database load needs to be thoroughly critiqued and scrutenized.
|
- The current deployment does not take into account no downtime or miniimal downtime updates.
|
||||||
|
- The same runner's build the software that run the software
|
||||||
|
- There needs to be a balance of uptime and complexity
|
||||||
|
- production will run on a single server to start
|
||||||
|
|
||||||
*** ACTION - CHANGES TO IMPLEMENT ***
|
*** ACTION - CHANGES TO IMPLEMENT ***
|
||||||
- The vehicle catalog currently loaded into the local mvp-postres container needs to be exported into a SQL file and saved as a source of truth
|
- Research this code base and ask iterative questions to compile a complete plan.
|
||||||
- Whatever process is running today only goes up to model year 2022.
|
- We will pair plan this. Ask me for options for various levels of redundancy and automation
|
||||||
- All the existing SQL files setup for import can be replaced with new ones created from the running mvp-postres data.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -100,14 +103,17 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
|
|||||||
- Make no assumptions.
|
- Make no assumptions.
|
||||||
- Ask clarifying questions.
|
- Ask clarifying questions.
|
||||||
- Ultrathink
|
- Ultrathink
|
||||||
- You will be making changes to the color theme of this application.
|
- You will be making changes to email templates of this application.
|
||||||
|
|
||||||
*** CONTEXT ***
|
*** CONTEXT ***
|
||||||
- This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s.
|
- This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s.
|
||||||
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
|
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
|
||||||
- Currently the onboarding drop downs are washed out when using the light theme. See image.
|
- Start your research at this route https://motovaultpro.com/garage/settings/admin/email-templates
|
||||||
- The colors need to change to have more contrast but retain the MUI theme for drop down.
|
- The email templates are currently plain text.
|
||||||
|
- The templates need to be improved with colors and the company logo
|
||||||
|
- The company log should be base64 encoded in the email so end users don't need to download anything.
|
||||||
|
- The theme should match the website light theme
|
||||||
|
- A screenshot showing the colors is attached
|
||||||
|
|
||||||
*** CHANGES TO IMPLEMENT ***
|
*** CHANGES TO IMPLEMENT ***
|
||||||
- Research this code base and ask iterative questions to compile a complete plan.
|
- Research this code base and ask iterative questions to compile a complete plan.
|
||||||
- The URL is here. https://motovaultpro.com/onboarding
|
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
CascadeDeleteResult,
|
CascadeDeleteResult,
|
||||||
EmailTemplate,
|
EmailTemplate,
|
||||||
UpdateEmailTemplateRequest,
|
UpdateEmailTemplateRequest,
|
||||||
|
PreviewTemplateResponse,
|
||||||
// User management types
|
// User management types
|
||||||
ManagedUser,
|
ManagedUser,
|
||||||
ListUsersResponse,
|
ListUsersResponse,
|
||||||
@@ -278,15 +279,15 @@ export const adminApi = {
|
|||||||
const response = await apiClient.put<EmailTemplate>(`/admin/email-templates/${key}`, data);
|
const response = await apiClient.put<EmailTemplate>(`/admin/email-templates/${key}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
preview: async (key: string, variables: Record<string, string>): Promise<{ subject: string; body: string }> => {
|
preview: async (key: string, variables: Record<string, string>): Promise<PreviewTemplateResponse> => {
|
||||||
const response = await apiClient.post<{ subject: string; body: string }>(
|
const response = await apiClient.post<PreviewTemplateResponse>(
|
||||||
`/admin/email-templates/${key}/preview`,
|
`/admin/email-templates/${key}/preview`,
|
||||||
{ variables }
|
{ variables }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
sendTest: async (key: string): Promise<{ message?: string; error?: string; subject: string; body: string }> => {
|
sendTest: async (key: string): Promise<{ message?: string; error?: string }> => {
|
||||||
const response = await apiClient.post<{ message?: string; error?: string; subject: string; body: string }>(
|
const response = await apiClient.post<{ message?: string; error?: string }>(
|
||||||
`/admin/email-templates/${key}/test`
|
`/admin/email-templates/${key}/test`
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
|||||||
const [editIsActive, setEditIsActive] = useState(true);
|
const [editIsActive, setEditIsActive] = useState(true);
|
||||||
const [previewSubject, setPreviewSubject] = useState('');
|
const [previewSubject, setPreviewSubject] = useState('');
|
||||||
const [previewBody, setPreviewBody] = useState('');
|
const [previewBody, setPreviewBody] = useState('');
|
||||||
|
const [previewHtml, setPreviewHtml] = useState('');
|
||||||
|
const [showHtmlPreview, setShowHtmlPreview] = useState(false);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: templates, isLoading } = useQuery({
|
const { data: templates, isLoading } = useQuery({
|
||||||
@@ -66,6 +68,7 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
|||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setPreviewSubject(data.subject);
|
setPreviewSubject(data.subject);
|
||||||
setPreviewBody(data.body);
|
setPreviewBody(data.body);
|
||||||
|
setPreviewHtml(data.html || '');
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error('Failed to generate preview');
|
toast.error('Failed to generate preview');
|
||||||
@@ -117,6 +120,8 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
|||||||
setPreviewTemplate(null);
|
setPreviewTemplate(null);
|
||||||
setPreviewSubject('');
|
setPreviewSubject('');
|
||||||
setPreviewBody('');
|
setPreviewBody('');
|
||||||
|
setPreviewHtml('');
|
||||||
|
setShowHtmlPreview(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
@@ -304,6 +309,23 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Toggle HTML/Text Preview */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-slate-700">Show HTML Preview</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHtmlPreview(!showHtmlPreview)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors min-h-[44px] min-w-[44px] ${
|
||||||
|
showHtmlPreview ? 'bg-blue-600' : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
showHtmlPreview ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
Subject
|
Subject
|
||||||
@@ -313,14 +335,29 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showHtmlPreview ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
Body
|
HTML Preview
|
||||||
|
</label>
|
||||||
|
<div className="border border-gray-300 rounded-lg overflow-hidden bg-gray-50">
|
||||||
|
<iframe
|
||||||
|
srcDoc={previewHtml}
|
||||||
|
style={{ width: '100%', height: '400px', border: 'none' }}
|
||||||
|
title="Email HTML Preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Body (Plain Text)
|
||||||
</label>
|
</label>
|
||||||
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg font-mono text-sm whitespace-pre-wrap">
|
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg font-mono text-sm whitespace-pre-wrap">
|
||||||
{previewBody}
|
{previewBody}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,12 @@ export interface UpdateEmailTemplateRequest {
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PreviewTemplateResponse {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// User Management types (subscription tiers)
|
// User Management types (subscription tiers)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export const AdminEmailTemplatesPage: React.FC = () => {
|
|||||||
const [editIsActive, setEditIsActive] = useState(true);
|
const [editIsActive, setEditIsActive] = useState(true);
|
||||||
const [previewSubject, setPreviewSubject] = useState('');
|
const [previewSubject, setPreviewSubject] = useState('');
|
||||||
const [previewBody, setPreviewBody] = useState('');
|
const [previewBody, setPreviewBody] = useState('');
|
||||||
|
const [previewHtml, setPreviewHtml] = useState('');
|
||||||
|
const [showHtmlPreview, setShowHtmlPreview] = useState(false);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: templates, isLoading } = useQuery({
|
const { data: templates, isLoading } = useQuery({
|
||||||
@@ -87,6 +89,7 @@ export const AdminEmailTemplatesPage: React.FC = () => {
|
|||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setPreviewSubject(data.subject);
|
setPreviewSubject(data.subject);
|
||||||
setPreviewBody(data.body);
|
setPreviewBody(data.body);
|
||||||
|
setPreviewHtml(data.html || '');
|
||||||
setPreviewDialogOpen(true);
|
setPreviewDialogOpen(true);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -141,6 +144,8 @@ export const AdminEmailTemplatesPage: React.FC = () => {
|
|||||||
setPreviewDialogOpen(false);
|
setPreviewDialogOpen(false);
|
||||||
setPreviewSubject('');
|
setPreviewSubject('');
|
||||||
setPreviewBody('');
|
setPreviewBody('');
|
||||||
|
setPreviewHtml('');
|
||||||
|
setShowHtmlPreview(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
@@ -362,6 +367,16 @@ export const AdminEmailTemplatesPage: React.FC = () => {
|
|||||||
This preview uses sample data to show how the template will appear.
|
This preview uses sample data to show how the template will appear.
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={showHtmlPreview}
|
||||||
|
onChange={(e) => setShowHtmlPreview(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Show HTML Preview"
|
||||||
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Subject"
|
label="Subject"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -371,8 +386,29 @@ export const AdminEmailTemplatesPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showHtmlPreview ? (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
HTML Preview
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
srcDoc={previewHtml}
|
||||||
|
style={{ width: '100%', height: '500px', border: 'none' }}
|
||||||
|
title="Email HTML Preview"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
<TextField
|
<TextField
|
||||||
label="Body"
|
label="Body (Plain Text)"
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
rows={12}
|
rows={12}
|
||||||
@@ -384,6 +420,7 @@ export const AdminEmailTemplatesPage: React.FC = () => {
|
|||||||
style: { fontFamily: 'monospace' },
|
style: { fontFamily: 'monospace' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|||||||
Reference in New Issue
Block a user