better blog editing adn email confirmations
This commit is contained in:
parent
2bdcbb17d9
commit
5b5649a17d
7 changed files with 537 additions and 104 deletions
|
|
@ -43,6 +43,11 @@ module.exports = (pool, query) => {
|
||||||
[email]
|
[email]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await emailService.sendWelcomeEmail({
|
||||||
|
to: email,
|
||||||
|
first_name: firstName
|
||||||
|
});
|
||||||
if(isSubscribed){
|
if(isSubscribed){
|
||||||
let subResult = await query(
|
let subResult = await query(
|
||||||
`INSERT INTO subscribers (id, email, first_name, last_name, status)
|
`INSERT INTO subscribers (id, email, first_name, last_name, status)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const stripe = require('stripe');
|
const stripe = require('stripe');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
const emailService = require('../services/emailService');
|
||||||
|
|
||||||
module.exports = (pool, query, authMiddleware) => {
|
module.exports = (pool, query, authMiddleware) => {
|
||||||
|
|
||||||
|
|
@ -42,6 +43,69 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Payment completed for order ${order_id}`);
|
console.log(`Payment completed for order ${order_id}`);
|
||||||
|
|
||||||
|
// Get order details for email confirmation
|
||||||
|
const orderResult = await query(`
|
||||||
|
SELECT o.*,
|
||||||
|
u.email, u.first_name, u.last_name,
|
||||||
|
o.shipping_address
|
||||||
|
FROM orders o
|
||||||
|
JOIN users u ON o.user_id = u.id
|
||||||
|
WHERE o.id = $1
|
||||||
|
`, [order_id]);
|
||||||
|
|
||||||
|
if (orderResult.rows.length > 0) {
|
||||||
|
const order = orderResult.rows[0];
|
||||||
|
|
||||||
|
// Get order items with product details
|
||||||
|
const itemsResult = await query(`
|
||||||
|
SELECT
|
||||||
|
oi.id,
|
||||||
|
oi.quantity,
|
||||||
|
oi.price_at_purchase,
|
||||||
|
p.name as product_name
|
||||||
|
FROM order_items oi
|
||||||
|
JOIN products p ON oi.product_id = p.id
|
||||||
|
WHERE oi.order_id = $1
|
||||||
|
`, [order_id]);
|
||||||
|
|
||||||
|
// Generate items HTML table for email
|
||||||
|
const itemsHtml = itemsResult.rows.map(item => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.product_name}</td>
|
||||||
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.quantity}</td>
|
||||||
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${parseFloat(item.price_at_purchase).toFixed(2)}</td>
|
||||||
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${(parseFloat(item.price_at_purchase) * item.quantity).toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Format shipping address for email
|
||||||
|
let shippingAddress = 'No shipping address provided';
|
||||||
|
if (order.shipping_address) {
|
||||||
|
const address = typeof order.shipping_address === 'string' ?
|
||||||
|
JSON.parse(order.shipping_address) : order.shipping_address;
|
||||||
|
|
||||||
|
shippingAddress = `
|
||||||
|
${address.name || ''}<br>
|
||||||
|
${address.street || ''}<br>
|
||||||
|
${address.city || ''}, ${address.state || ''} ${address.zip || ''}<br>
|
||||||
|
${address.country || ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send order confirmation email
|
||||||
|
await emailService.sendOrderConfirmation({
|
||||||
|
to: order.email,
|
||||||
|
first_name: order.first_name || 'Customer',
|
||||||
|
order_id: order.id.substring(0, 8), // first 8 characters of UUID for cleaner display
|
||||||
|
order_date: new Date(order.created_at).toLocaleDateString(),
|
||||||
|
order_total: `$${parseFloat(order.total_amount).toFixed(2)}`,
|
||||||
|
shipping_address: shippingAddress,
|
||||||
|
items_html: itemsHtml
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Order confirmation email sent to ${order.email}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
1
db/init/22-blog-json.sql
Normal file
1
db/init/22-blog-json.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS design JSONB;
|
||||||
|
|
@ -42,6 +42,7 @@ const BlogPage = lazy(() => import('@pages/BlogPage'));
|
||||||
const BlogDetailPage = lazy(() => import('@pages/BlogDetailPage'));
|
const BlogDetailPage = lazy(() => import('@pages/BlogDetailPage'));
|
||||||
const AdminBlogPage = lazy(() => import('@pages/Admin/BlogPage'));
|
const AdminBlogPage = lazy(() => import('@pages/Admin/BlogPage'));
|
||||||
const BlogEditPage = lazy(() => import('@pages/Admin/BlogEditPage'));
|
const BlogEditPage = lazy(() => import('@pages/Admin/BlogEditPage'));
|
||||||
|
const BlogPreviewPage = lazy(() => import('@pages/Admin/BlogPreviewPage'));
|
||||||
const AdminBlogCommentsPage = lazy(() => import('@pages/Admin/BlogCommentsPage'));
|
const AdminBlogCommentsPage = lazy(() => import('@pages/Admin/BlogCommentsPage'));
|
||||||
const AdminProductReviewsPage = lazy(() => import('@pages/Admin/ProductReviewsPage'));
|
const AdminProductReviewsPage = lazy(() => import('@pages/Admin/ProductReviewsPage'));
|
||||||
const EmailTemplatesPage = lazy(() => import('@pages/Admin/EmailTemplatesPage'));
|
const EmailTemplatesPage = lazy(() => import('@pages/Admin/EmailTemplatesPage'));
|
||||||
|
|
@ -199,6 +200,7 @@ function App() {
|
||||||
<Route path="coupons/:id" element={<CouponEditPage />} />
|
<Route path="coupons/:id" element={<CouponEditPage />} />
|
||||||
<Route path="coupons/:id/redemptions" element={<CouponRedemptionsPage />} />
|
<Route path="coupons/:id/redemptions" element={<CouponRedemptionsPage />} />
|
||||||
<Route path="blog" element={<AdminBlogPage />} />
|
<Route path="blog" element={<AdminBlogPage />} />
|
||||||
|
<Route path="blog/preview" element={<BlogPreviewPage />} />
|
||||||
<Route path="blog/new" element={<BlogEditPage />} />
|
<Route path="blog/new" element={<BlogEditPage />} />
|
||||||
<Route path="blog/:id" element={<BlogEditPage />} />
|
<Route path="blog/:id" element={<BlogEditPage />} />
|
||||||
<Route path="blog-comments" element={<AdminBlogCommentsPage />} />
|
<Route path="blog-comments" element={<AdminBlogCommentsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
|
|
@ -25,25 +25,32 @@ import {
|
||||||
CardActions,
|
CardActions,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Link
|
Link,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
ArrowBack as ArrowBackIcon,
|
ArrowBack as ArrowBackIcon,
|
||||||
Save as SaveIcon,
|
Save as SaveIcon,
|
||||||
Preview as PreviewIcon,
|
Preview as PreviewIcon,
|
||||||
Delete as DeleteIcon
|
Delete as DeleteIcon,
|
||||||
|
ExpandMore as ExpandMoreIcon,
|
||||||
|
Info as InfoIcon
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigate, useParams, Link as RouterLink } from 'react-router-dom';
|
import { useNavigate, useParams, Link as RouterLink } from 'react-router-dom';
|
||||||
import { useAdminBlogPost, useCreateBlogPost, useUpdateBlogPost, useBlogCategories } from '@hooks/blogHooks';
|
import { useAdminBlogPost, useCreateBlogPost, useUpdateBlogPost, useBlogCategories } from '@hooks/blogHooks';
|
||||||
import { useAuth } from '@hooks/reduxHooks';
|
import { useAuth } from '@hooks/reduxHooks';
|
||||||
import ImageUploader from '@components/ImageUploader';
|
import ImageUploader from '@components/ImageUploader';
|
||||||
import imageUtils from '@utils/imageUtils';
|
import imageUtils from '@utils/imageUtils';
|
||||||
|
import EmailEditor from 'react-email-editor';
|
||||||
|
|
||||||
const BlogEditPage = () => {
|
const BlogEditPage = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isNewPost = !id;
|
const isNewPost = !id;
|
||||||
const { userData } = useAuth();
|
const { userData } = useAuth();
|
||||||
|
const emailEditorRef = useRef(null);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
|
|
@ -54,13 +61,15 @@ const BlogEditPage = () => {
|
||||||
tags: [],
|
tags: [],
|
||||||
featuredImagePath: '',
|
featuredImagePath: '',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
publishNow: false
|
publishNow: false,
|
||||||
|
design: null // New field to store email editor design JSON
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validation state
|
// Validation state
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||||
const [notification, setNotification] = useState({ type: 'success', message: '' });
|
const [notification, setNotification] = useState({ type: 'success', message: '' });
|
||||||
|
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||||
|
|
||||||
// Fetch blog post if editing
|
// Fetch blog post if editing
|
||||||
const {
|
const {
|
||||||
|
|
@ -112,6 +121,120 @@ const BlogEditPage = () => {
|
||||||
setFormData(prev => ({ ...prev, featuredImagePath: '' }));
|
setFormData(prev => ({ ...prev, featuredImagePath: '' }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Email editor ready handler
|
||||||
|
const onEditorReady = () => {
|
||||||
|
setIsEditorReady(true);
|
||||||
|
console.log('Email editor is ready');
|
||||||
|
|
||||||
|
// If editing existing post with design data, load it
|
||||||
|
if (formData.design && emailEditorRef.current) {
|
||||||
|
emailEditorRef.current.editor.loadDesign(formData.design);
|
||||||
|
}
|
||||||
|
// If post has content but no design, create default design with the content
|
||||||
|
else if (formData.content && emailEditorRef.current) {
|
||||||
|
const defaultDesign = {
|
||||||
|
body: {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
cells: [1],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
type: "html",
|
||||||
|
values: {
|
||||||
|
html: formData.content,
|
||||||
|
containerPadding: "10px"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
emailEditorRef.current.editor.loadDesign(defaultDesign);
|
||||||
|
}
|
||||||
|
// For new posts, load a simple default template
|
||||||
|
else if (emailEditorRef.current) {
|
||||||
|
const defaultTemplate = {
|
||||||
|
body: {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
cells: [1],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
type: "html",
|
||||||
|
values: {
|
||||||
|
containerPadding: "10px",
|
||||||
|
textAlign: "left",
|
||||||
|
html: `<h1 style="font-size: 30px; color: #3f51b5; margin-bottom: 10px;">Exploring the Wonders of Nature</h1>
|
||||||
|
<p style="font-size: 18px; color: #757575; font-style: italic; margin-bottom: 30px;">A journey through fascinating landscapes and natural phenomena</p>
|
||||||
|
|
||||||
|
<img src="https://images.unsplash.com/photo-1506744038136-46273834b3fb" alt="Beautiful mountain landscape" width="100%" style="max-width: 100%;">
|
||||||
|
|
||||||
|
<h2 style="font-size: 28px; color: #4caf50; margin-top: 30px; margin-bottom: 15px;">The Majesty of Mountains</h2>
|
||||||
|
<p style="font-size: 16px; line-height: 1.6; color: #333333; margin-bottom: 20px;">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lacinia odio vitae vestibulum vestibulum. Cras porttitor metus justo, vitae facilisis massa tempus sit amet. Nullam sit amet ipsum at magna tempus dignissim. Donec et nisi sed dolor scelerisque bibendum ut sit amet dolor.</p>
|
||||||
|
<p style="font-size: 16px; line-height: 1.6; color: #333333; margin-bottom: 20px;">Proin gravida nibh vel velit auctor aliquet. Aenean sollicitudin, lorem quis bibendum auctor, nisi elit consequat ipsum, nec sagittis sem nibh id elit. Duis sed odio sit amet nibh vulputate cursus a sit amet mauris. Morbi accumsan ipsum velit. Nam nec tellus a odio tincidunt auctor a ornare odio.</p>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-wrap: wrap;">
|
||||||
|
<div style="flex: 2; padding: 10px;">
|
||||||
|
<img src="https://images.unsplash.com/photo-1565118531796-763e5082d113" alt="Forest waterfall" width="100%" style="max-width: 100%;">
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; padding: 20px;">
|
||||||
|
<h3 style="font-size: 22px; color: #2196f3; margin-bottom: 15px;">The Serenity of Waterfalls</h3>
|
||||||
|
<p style="font-size: 16px; line-height: 1.6; color: #333333;">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="border: 0; border-top: 1px solid #e0e0e0; margin: 20px 0;">
|
||||||
|
|
||||||
|
<h2 style="font-size: 28px; color: #ff9800; margin-top: 30px; margin-bottom: 15px;">Embracing Biodiversity</h2>
|
||||||
|
<p style="font-size: 16px; line-height: 1.6; color: #333333; margin-bottom: 20px;">Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.</p>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-wrap: wrap; justify-content: space-between;">
|
||||||
|
<div style="flex: 1; padding: 10px; max-width: 33%;">
|
||||||
|
<img src="https://images.unsplash.com/photo-1535083783855-76ae62b2914e" alt="Wildlife" width="100%" style="max-width: 100%;">
|
||||||
|
<p style="font-size: 14px; font-style: italic; color: #757575; text-align: center;">Wildlife in natural habitat</p>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; padding: 10px; max-width: 33%;">
|
||||||
|
<img src="https://images.unsplash.com/photo-1533038590840-1f12a7a36ddb" alt="Insect" width="100%" style="max-width: 100%;">
|
||||||
|
<p style="font-size: 14px; font-style: italic; color: #757575; text-align: center;">Macro photography reveals tiny worlds</p>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; padding: 10px; max-width: 33%;">
|
||||||
|
<img src="https://images.unsplash.com/photo-1596328546171-77e37b5e8b3d" alt="Flowers" width="100%" style="max-width: 100%;">
|
||||||
|
<p style="font-size: 14px; font-style: italic; color: #757575; text-align: center;">Vibrant flora adds color to our world</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<blockquote style="border-left: 4px solid #9c27b0; padding-left: 20px; margin-left: 0; font-style: italic; color: #555555;">
|
||||||
|
<p>"In all things of nature there is something of the marvelous." — Aristotle</p>
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<h2 style="font-size: 28px; color: #e91e63; margin-top: 30px; margin-bottom: 15px;">Conservation Efforts</h2>
|
||||||
|
<p style="font-size: 16px; line-height: 1.6; color: #333333; margin-bottom: 20px;">Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?</p>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 20px 0;">
|
||||||
|
<a href="#" style="display: inline-block; background-color: #4caf50; color: #ffffff; font-weight: bold; font-size: 16px; text-align: center; line-height: 1.5; border-radius: 4px; padding: 12px 24px; text-decoration: none; width: 200px;">Learn More</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 14px; color: #9e9e9e; margin-top: 40px; text-align: center;">Thank you for reading our blog post. Feel free to share it with others who might be interested in nature conservation.</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
emailEditorRef.current.editor.loadDesign(defaultTemplate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Validate form
|
// Validate form
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
const newErrors = {};
|
const newErrors = {};
|
||||||
|
|
@ -120,10 +243,6 @@ const BlogEditPage = () => {
|
||||||
newErrors.title = 'Title is required';
|
newErrors.title = 'Title is required';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.content.trim()) {
|
|
||||||
newErrors.content = 'Content is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
};
|
};
|
||||||
|
|
@ -140,39 +259,56 @@ const BlogEditPage = () => {
|
||||||
setNotificationOpen(true);
|
setNotificationOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (emailEditorRef.current) {
|
||||||
const formattedData = {
|
emailEditorRef.current.editor.exportHtml(async (data) => {
|
||||||
...formData,
|
const { design, html } = data;
|
||||||
// Convert tag objects to string names if needed
|
|
||||||
tags: formData.tags.map(tag => typeof tag === 'string' ? tag : tag.name)
|
try {
|
||||||
};
|
const formattedData = {
|
||||||
|
...formData,
|
||||||
if (isNewPost) {
|
content: html,
|
||||||
await createPost.mutateAsync(formattedData);
|
design: design,
|
||||||
navigate('/admin/blog');
|
tags: formData.tags.map(tag => typeof tag === 'string' ? tag : tag.name)
|
||||||
} else {
|
};
|
||||||
await updatePost.mutateAsync({ id, postData: formattedData });
|
|
||||||
setNotification({
|
if (isNewPost) {
|
||||||
type: 'success',
|
await createPost.mutateAsync(formattedData);
|
||||||
message: 'Blog post updated successfully'
|
navigate('/admin/blog');
|
||||||
});
|
} else {
|
||||||
setNotificationOpen(true);
|
await updatePost.mutateAsync({ id, postData: formattedData });
|
||||||
}
|
setNotification({
|
||||||
} catch (error) {
|
type: 'success',
|
||||||
setNotification({
|
message: 'Blog post updated successfully'
|
||||||
type: 'error',
|
});
|
||||||
message: error.message || 'An error occurred while saving the post'
|
setNotificationOpen(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setNotification({
|
||||||
|
type: 'error',
|
||||||
|
message: error.message || 'An error occurred while saving the post'
|
||||||
|
});
|
||||||
|
setNotificationOpen(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
setNotificationOpen(true);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Preview post
|
// Preview post
|
||||||
const handlePreview = () => {
|
const handlePreview = () => {
|
||||||
// Store current form data in session storage
|
if (emailEditorRef.current) {
|
||||||
sessionStorage.setItem('blog-post-preview', JSON.stringify(formData));
|
emailEditorRef.current.editor.exportHtml((data) => {
|
||||||
window.open('/admin/blog/preview', '_blank');
|
const { html } = data;
|
||||||
|
|
||||||
|
// Store current form data and HTML content in session storage
|
||||||
|
const previewData = {
|
||||||
|
...formData,
|
||||||
|
content: html
|
||||||
|
};
|
||||||
|
sessionStorage.setItem('blog-post-preview', JSON.stringify(previewData));
|
||||||
|
window.open('/admin/blog/preview', '_blank');
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load post data when available
|
// Load post data when available
|
||||||
|
|
@ -186,7 +322,8 @@ const BlogEditPage = () => {
|
||||||
tags: post.tags || [],
|
tags: post.tags || [],
|
||||||
featuredImagePath: post.featured_image_path || '',
|
featuredImagePath: post.featured_image_path || '',
|
||||||
status: post.status || 'draft',
|
status: post.status || 'draft',
|
||||||
publishNow: false
|
publishNow: false,
|
||||||
|
design: post.design || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [post, isNewPost]);
|
}, [post, isNewPost]);
|
||||||
|
|
@ -366,30 +503,6 @@ const BlogEditPage = () => {
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Post Content
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
multiline
|
|
||||||
minRows={10}
|
|
||||||
maxRows={20}
|
|
||||||
name="content"
|
|
||||||
value={formData.content}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Write your blog post content here..."
|
|
||||||
error={!!errors.content}
|
|
||||||
helperText={errors.content}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
<FormHelperText>
|
|
||||||
Pro tip: You can use HTML markup for formatting
|
|
||||||
</FormHelperText>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Excerpt */}
|
{/* Excerpt */}
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<TextField
|
||||||
|
|
@ -434,13 +547,53 @@ const BlogEditPage = () => {
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Email Editor Tips */}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Accordion sx={{ mb: 2 }}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<InfoIcon sx={{ mr: 1 }} color="primary" />
|
||||||
|
Editor Tips
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>Tips for creating beautiful blog posts:</Typography>
|
||||||
|
<ul>
|
||||||
|
<li>Use the visual editor to create rich, visually appealing content</li>
|
||||||
|
<li>You can drag and drop content blocks from the right panel</li>
|
||||||
|
<li>The editor supports images, buttons, columns, and more</li>
|
||||||
|
<li>For advanced users: You can switch to HTML mode to edit raw HTML</li>
|
||||||
|
<li>Add your own images by using the image upload button in the editor</li>
|
||||||
|
<li>Save often to avoid losing your work</li>
|
||||||
|
</ul>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
For best results, design your blog post in Figma first, then implement it here using the visual tools.
|
||||||
|
</Typography>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Content (Email Editor) */}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Post Content
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1, height: '600px' }}>
|
||||||
|
<EmailEditor
|
||||||
|
ref={emailEditorRef}
|
||||||
|
onReady={onEditorReady}
|
||||||
|
minHeight="600px"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Grid item xs={12} sx={{ mt: 2, display: 'flex', justifyContent: 'space-between' }}>
|
<Grid item xs={12} sx={{ mt: 2, display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<PreviewIcon />}
|
startIcon={<PreviewIcon />}
|
||||||
onClick={handlePreview}
|
onClick={handlePreview}
|
||||||
disabled={!formData.title || !formData.content}
|
disabled={!formData.title || !isEditorReady}
|
||||||
>
|
>
|
||||||
Preview
|
Preview
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -460,7 +613,7 @@ const BlogEditPage = () => {
|
||||||
color="primary"
|
color="primary"
|
||||||
startIcon={createPost.isLoading || updatePost.isLoading ?
|
startIcon={createPost.isLoading || updatePost.isLoading ?
|
||||||
<CircularProgress size={20} /> : <SaveIcon />}
|
<CircularProgress size={20} /> : <SaveIcon />}
|
||||||
disabled={createPost.isLoading || updatePost.isLoading}
|
disabled={createPost.isLoading || updatePost.isLoading || !isEditorReady}
|
||||||
>
|
>
|
||||||
{createPost.isLoading || updatePost.isLoading ?
|
{createPost.isLoading || updatePost.isLoading ?
|
||||||
'Saving...' : (isNewPost ? 'Create' : 'Update')} Post
|
'Saving...' : (isNewPost ? 'Create' : 'Update')} Post
|
||||||
|
|
|
||||||
236
frontend/src/pages/Admin/BlogPreviewPage.jsx
Normal file
236
frontend/src/pages/Admin/BlogPreviewPage.jsx
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Divider,
|
||||||
|
Chip,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Container,
|
||||||
|
Breadcrumbs,
|
||||||
|
Link
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import imageUtils from '@utils/imageUtils';
|
||||||
|
|
||||||
|
const BlogPreviewPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [post, setPost] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Load the preview data from session storage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const previewData = sessionStorage.getItem('blog-post-preview');
|
||||||
|
if (previewData) {
|
||||||
|
const parsedData = JSON.parse(previewData);
|
||||||
|
setPost(parsedData);
|
||||||
|
} else {
|
||||||
|
setError('No preview data found. Please go back and try again.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load preview data: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
const date = dateString ? new Date(dateString) : new Date();
|
||||||
|
return format(date, 'MMMM d, yyyy');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle edit button click
|
||||||
|
const handleEditClick = () => {
|
||||||
|
if (post?.id) {
|
||||||
|
navigate(`/admin/blog/edit/${post.id}`);
|
||||||
|
} else {
|
||||||
|
navigate('/admin/blog/create');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', my: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert severity="error" sx={{ my: 4 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If post isn't found
|
||||||
|
if (!post) {
|
||||||
|
return (
|
||||||
|
<Alert severity="warning" sx={{ my: 4 }}>
|
||||||
|
Preview data not found. Please go back and try again.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Box sx={{ py: 4 }}>
|
||||||
|
{/* Preview header */}
|
||||||
|
<Paper sx={{ p: 2, mb: 3, bgcolor: 'info.light', color: 'info.contrastText' }}>
|
||||||
|
<Typography variant="h6" component="div">
|
||||||
|
Preview Mode
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
This is a preview of how your blog post will appear to readers. It is not published yet.
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 2, display: 'flex', gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<ArrowBackIcon />}
|
||||||
|
variant="contained"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => window.close()}
|
||||||
|
>
|
||||||
|
Close Preview
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<EditIcon />}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={handleEditClick}
|
||||||
|
>
|
||||||
|
Continue Editing
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<Breadcrumbs
|
||||||
|
separator={<NavigateNextIcon fontSize="small" />}
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
<Link component="span" color="inherit">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link component="span" color="inherit">
|
||||||
|
Blog
|
||||||
|
</Link>
|
||||||
|
<Typography color="text.primary">
|
||||||
|
{post.title}
|
||||||
|
</Typography>
|
||||||
|
</Breadcrumbs>
|
||||||
|
|
||||||
|
{/* Post header */}
|
||||||
|
<Paper sx={{ p: 3, mb: 4 }}>
|
||||||
|
{/* Category */}
|
||||||
|
{post.categoryId && (
|
||||||
|
<Chip
|
||||||
|
label="Category"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
{post.title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Author and date */}
|
||||||
|
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
|
||||||
|
Preview • {formatDate()}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
{post.tags && post.tags.filter(Boolean).map((tag, index) => (
|
||||||
|
<Chip
|
||||||
|
key={index}
|
||||||
|
label={typeof tag === 'string' ? tag : tag.name}
|
||||||
|
size="small"
|
||||||
|
component="span"
|
||||||
|
sx={{ mr: 1, mb: 1 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Featured image */}
|
||||||
|
{post.featuredImagePath && (
|
||||||
|
<Box sx={{ mb: 4, textAlign: 'center' }}>
|
||||||
|
<img
|
||||||
|
src={imageUtils.getImageUrl(post.featuredImagePath)}
|
||||||
|
alt={post.title}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '500px',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Post content */}
|
||||||
|
<Paper sx={{ p: { xs: 2, md: 4 }, mb: 4 }}>
|
||||||
|
<Box
|
||||||
|
className="blog-content"
|
||||||
|
sx={{
|
||||||
|
'& p': { mb: 2 },
|
||||||
|
'& h2': { mt: 3, mb: 2 },
|
||||||
|
'& h3': { mt: 2.5, mb: 1.5 },
|
||||||
|
'& ul, & ol': { mb: 2, pl: 4 },
|
||||||
|
'& li': { mb: 0.5 },
|
||||||
|
'& img': {
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
borderRadius: '4px',
|
||||||
|
my: 2
|
||||||
|
},
|
||||||
|
'& blockquote': {
|
||||||
|
borderLeft: '4px solid',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
pl: 2,
|
||||||
|
py: 1,
|
||||||
|
my: 2,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
bgcolor: 'background.paper'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Post content - rendered as HTML */}
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: post.content }} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Comments section placeholder */}
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h5" component="h2" gutterBottom>
|
||||||
|
Comments
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Comments will appear here when your post is published.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlogPreviewPage;
|
||||||
|
|
@ -232,54 +232,26 @@ const BlogDetailPage = () => {
|
||||||
my: 2,
|
my: 2,
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
bgcolor: 'background.paper'
|
bgcolor: 'background.paper'
|
||||||
|
},
|
||||||
|
'& table': {
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
my: 2
|
||||||
|
},
|
||||||
|
'& th, & td': {
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
p: 1
|
||||||
|
},
|
||||||
|
'& th': {
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
fontWeight: 'bold'
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Post content - rendered as HTML */}
|
{/* Post content - rendered as HTML */}
|
||||||
<div dangerouslySetInnerHTML={{ __html: post.content }} />
|
<div dangerouslySetInnerHTML={{ __html: post.content }} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Post images */}
|
|
||||||
{post.images && post.images.length > 0 && (
|
|
||||||
<Box sx={{ mt: 4 }}>
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Gallery
|
|
||||||
</Typography>
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
{post.images.map((image) => (
|
|
||||||
<Grid item xs={12} sm={6} md={4} key={image.id}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
pb: '75%', // 4:3 aspect ratio
|
|
||||||
height: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
borderRadius: 1
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={imageUtils.getImageUrl(image.path)}
|
|
||||||
alt={image.caption || 'Blog image'}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
objectFit: 'cover'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
{image.caption && (
|
|
||||||
<Typography variant="caption" color="text.secondary" display="block" align="center" sx={{ mt: 1 }}>
|
|
||||||
{image.caption}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Comments section */}
|
{/* Comments section */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue