better blog editing adn email confirmations

This commit is contained in:
2ManyProjects 2025-05-07 00:28:25 -05:00
parent 2bdcbb17d9
commit 5b5649a17d
7 changed files with 537 additions and 104 deletions

View file

@ -43,6 +43,11 @@ module.exports = (pool, query) => {
[email]
);
}
await emailService.sendWelcomeEmail({
to: email,
first_name: firstName
});
if(isSubscribed){
let subResult = await query(
`INSERT INTO subscribers (id, email, first_name, last_name, status)

View file

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const stripe = require('stripe');
const config = require('../config');
const emailService = require('../services/emailService');
module.exports = (pool, query, authMiddleware) => {
@ -42,6 +43,69 @@ module.exports = (pool, query, authMiddleware) => {
);
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;

1
db/init/22-blog-json.sql Normal file
View file

@ -0,0 +1 @@
ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS design JSONB;

View file

@ -42,6 +42,7 @@ const BlogPage = lazy(() => import('@pages/BlogPage'));
const BlogDetailPage = lazy(() => import('@pages/BlogDetailPage'));
const AdminBlogPage = lazy(() => import('@pages/Admin/BlogPage'));
const BlogEditPage = lazy(() => import('@pages/Admin/BlogEditPage'));
const BlogPreviewPage = lazy(() => import('@pages/Admin/BlogPreviewPage'));
const AdminBlogCommentsPage = lazy(() => import('@pages/Admin/BlogCommentsPage'));
const AdminProductReviewsPage = lazy(() => import('@pages/Admin/ProductReviewsPage'));
const EmailTemplatesPage = lazy(() => import('@pages/Admin/EmailTemplatesPage'));
@ -199,6 +200,7 @@ function App() {
<Route path="coupons/:id" element={<CouponEditPage />} />
<Route path="coupons/:id/redemptions" element={<CouponRedemptionsPage />} />
<Route path="blog" element={<AdminBlogPage />} />
<Route path="blog/preview" element={<BlogPreviewPage />} />
<Route path="blog/new" element={<BlogEditPage />} />
<Route path="blog/:id" element={<BlogEditPage />} />
<Route path="blog-comments" element={<AdminBlogCommentsPage />} />

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
Box,
Typography,
@ -25,25 +25,32 @@ import {
CardActions,
Tooltip,
Breadcrumbs,
Link
Link,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import {
ArrowBack as ArrowBackIcon,
Save as SaveIcon,
Preview as PreviewIcon,
Delete as DeleteIcon
Delete as DeleteIcon,
ExpandMore as ExpandMoreIcon,
Info as InfoIcon
} from '@mui/icons-material';
import { useNavigate, useParams, Link as RouterLink } from 'react-router-dom';
import { useAdminBlogPost, useCreateBlogPost, useUpdateBlogPost, useBlogCategories } from '@hooks/blogHooks';
import { useAuth } from '@hooks/reduxHooks';
import ImageUploader from '@components/ImageUploader';
import imageUtils from '@utils/imageUtils';
import EmailEditor from 'react-email-editor';
const BlogEditPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const isNewPost = !id;
const { userData } = useAuth();
const emailEditorRef = useRef(null);
// Form state
const [formData, setFormData] = useState({
@ -54,13 +61,15 @@ const BlogEditPage = () => {
tags: [],
featuredImagePath: '',
status: 'draft',
publishNow: false
publishNow: false,
design: null // New field to store email editor design JSON
});
// Validation state
const [errors, setErrors] = useState({});
const [notificationOpen, setNotificationOpen] = useState(false);
const [notification, setNotification] = useState({ type: 'success', message: '' });
const [isEditorReady, setIsEditorReady] = useState(false);
// Fetch blog post if editing
const {
@ -112,6 +121,120 @@ const BlogEditPage = () => {
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
const validateForm = () => {
const newErrors = {};
@ -120,10 +243,6 @@ const BlogEditPage = () => {
newErrors.title = 'Title is required';
}
if (!formData.content.trim()) {
newErrors.content = 'Content is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@ -141,38 +260,55 @@ const BlogEditPage = () => {
return;
}
try {
const formattedData = {
...formData,
// Convert tag objects to string names if needed
tags: formData.tags.map(tag => typeof tag === 'string' ? tag : tag.name)
};
if (emailEditorRef.current) {
emailEditorRef.current.editor.exportHtml(async (data) => {
const { design, html } = data;
if (isNewPost) {
await createPost.mutateAsync(formattedData);
navigate('/admin/blog');
} else {
await updatePost.mutateAsync({ id, postData: formattedData });
setNotification({
type: 'success',
message: 'Blog post updated successfully'
});
setNotificationOpen(true);
}
} catch (error) {
setNotification({
type: 'error',
message: error.message || 'An error occurred while saving the post'
try {
const formattedData = {
...formData,
content: html,
design: design,
tags: formData.tags.map(tag => typeof tag === 'string' ? tag : tag.name)
};
if (isNewPost) {
await createPost.mutateAsync(formattedData);
navigate('/admin/blog');
} else {
await updatePost.mutateAsync({ id, postData: formattedData });
setNotification({
type: 'success',
message: 'Blog post updated successfully'
});
setNotificationOpen(true);
}
} catch (error) {
setNotification({
type: 'error',
message: error.message || 'An error occurred while saving the post'
});
setNotificationOpen(true);
}
});
setNotificationOpen(true);
}
};
// Preview post
const handlePreview = () => {
// Store current form data in session storage
sessionStorage.setItem('blog-post-preview', JSON.stringify(formData));
window.open('/admin/blog/preview', '_blank');
if (emailEditorRef.current) {
emailEditorRef.current.editor.exportHtml((data) => {
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
@ -186,7 +322,8 @@ const BlogEditPage = () => {
tags: post.tags || [],
featuredImagePath: post.featured_image_path || '',
status: post.status || 'draft',
publishNow: false
publishNow: false,
design: post.design || null
});
}
}, [post, isNewPost]);
@ -366,30 +503,6 @@ const BlogEditPage = () => {
)}
</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 */}
<Grid item xs={12}>
<TextField
@ -434,13 +547,53 @@ const BlogEditPage = () => {
/>
</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 */}
<Grid item xs={12} sx={{ mt: 2, display: 'flex', justifyContent: 'space-between' }}>
<Button
variant="outlined"
startIcon={<PreviewIcon />}
onClick={handlePreview}
disabled={!formData.title || !formData.content}
disabled={!formData.title || !isEditorReady}
>
Preview
</Button>
@ -460,7 +613,7 @@ const BlogEditPage = () => {
color="primary"
startIcon={createPost.isLoading || updatePost.isLoading ?
<CircularProgress size={20} /> : <SaveIcon />}
disabled={createPost.isLoading || updatePost.isLoading}
disabled={createPost.isLoading || updatePost.isLoading || !isEditorReady}
>
{createPost.isLoading || updatePost.isLoading ?
'Saving...' : (isNewPost ? 'Create' : 'Update')} Post

View 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;

View file

@ -232,54 +232,26 @@ const BlogDetailPage = () => {
my: 2,
fontStyle: 'italic',
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 */}
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</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>
{/* Comments section */}