Couponss, Blogs, reviews

This commit is contained in:
2ManyProjects 2025-04-29 18:44:26 -05:00
parent b1f5985224
commit 37da2acb5d
30 changed files with 5263 additions and 130 deletions

65
db/init/15-coupon.sql Normal file
View file

@ -0,0 +1,65 @@
-- Create coupons table
CREATE TABLE coupons (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(50) NOT NULL UNIQUE,
description TEXT,
discount_type VARCHAR(20) NOT NULL, -- 'percentage', 'fixed_amount'
discount_value DECIMAL(10, 2) NOT NULL, -- Percentage or fixed amount value
min_purchase_amount DECIMAL(10, 2), -- Minimum purchase amount to use the coupon (optional)
max_discount_amount DECIMAL(10, 2), -- Maximum discount amount for percentage discounts (optional)
redemption_limit INTEGER, -- NULL means unlimited redemptions
current_redemptions INTEGER NOT NULL DEFAULT 0, -- Track how many times coupon has been used
start_date TIMESTAMP WITH TIME ZONE, -- When the coupon becomes valid (optional)
end_date TIMESTAMP WITH TIME ZONE, -- When the coupon expires (optional)
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- Whether the coupon is currently active
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create coupon_categories junction table
CREATE TABLE coupon_categories (
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES product_categories(id) ON DELETE CASCADE,
PRIMARY KEY (coupon_id, category_id)
);
-- Create coupon_tags junction table
CREATE TABLE coupon_tags (
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (coupon_id, tag_id)
);
-- Create coupon_blacklist table for excluded products
CREATE TABLE coupon_blacklist (
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
PRIMARY KEY (coupon_id, product_id)
);
-- Create coupon_redemptions table to track usage
CREATE TABLE coupon_redemptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
discount_amount DECIMAL(10, 2) NOT NULL,
redeemed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Add applied_coupon_id to orders table
ALTER TABLE orders ADD COLUMN coupon_id UUID REFERENCES coupons(id);
ALTER TABLE orders ADD COLUMN discount_amount DECIMAL(10, 2) DEFAULT 0.00;
-- Add indexes for better performance
CREATE INDEX idx_coupon_code ON coupons(code);
CREATE INDEX idx_coupon_is_active ON coupons(is_active);
CREATE INDEX idx_coupon_end_date ON coupons(end_date);
CREATE INDEX idx_coupon_redemptions_coupon_id ON coupon_redemptions(coupon_id);
CREATE INDEX idx_coupon_redemptions_user_id ON coupon_redemptions(user_id);
-- Create trigger to update the updated_at timestamp
CREATE TRIGGER update_coupons_modtime
BEFORE UPDATE ON coupons
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();

View file

@ -0,0 +1,84 @@
-- Create blog post categories
CREATE TABLE blog_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create blog posts table
CREATE TABLE blog_posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
content TEXT NOT NULL,
excerpt TEXT,
author_id UUID NOT NULL REFERENCES users(id),
category_id UUID REFERENCES blog_categories(id),
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, published, archived
featured_image_path VARCHAR(255),
published_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create blog post tags junction table
CREATE TABLE blog_post_tags (
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
-- Create blog post images table
CREATE TABLE blog_post_images (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
image_path VARCHAR(255) NOT NULL,
caption TEXT,
display_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create blog comments table
CREATE TABLE blog_comments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
parent_id UUID REFERENCES blog_comments(id) ON DELETE CASCADE,
content TEXT NOT NULL,
is_approved BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create indexes for performance
CREATE INDEX idx_blog_posts_author ON blog_posts(author_id);
CREATE INDEX idx_blog_posts_category ON blog_posts(category_id);
CREATE INDEX idx_blog_posts_status ON blog_posts(status);
CREATE INDEX idx_blog_posts_published_at ON blog_posts(published_at);
CREATE INDEX idx_blog_posts_slug ON blog_posts(slug);
CREATE INDEX idx_blog_comments_post ON blog_comments(post_id);
CREATE INDEX idx_blog_comments_user ON blog_comments(user_id);
CREATE INDEX idx_blog_comments_parent ON blog_comments(parent_id);
CREATE INDEX idx_blog_post_images_post ON blog_post_images(post_id);
-- Create triggers to automatically update the updated_at column
CREATE TRIGGER update_blog_categories_modtime
BEFORE UPDATE ON blog_categories
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
CREATE TRIGGER update_blog_posts_modtime
BEFORE UPDATE ON blog_posts
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
CREATE TRIGGER update_blog_comments_modtime
BEFORE UPDATE ON blog_comments
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
-- Insert default blog categories
INSERT INTO blog_categories (name, description) VALUES
('Announcements', 'Official announcements and company news'),
('Collections', 'Information about product collections and releases'),
('Tutorials', 'How-to guides and instructional content'),
('Behind the Scenes', 'Stories about our sourcing and process');

View file

@ -0,0 +1,80 @@
-- Create product reviews table
CREATE TABLE product_reviews (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
parent_id UUID REFERENCES product_reviews(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
content TEXT,
rating decimal CHECK (rating >= 0.0 AND rating <= 5.0),
is_approved BOOLEAN DEFAULT FALSE,
is_verified_purchase BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Add product average rating column to the products table
ALTER TABLE products ADD COLUMN IF NOT EXISTS average_rating DECIMAL(3, 2);
ALTER TABLE products ADD COLUMN IF NOT EXISTS review_count INTEGER DEFAULT 0;
-- Create indexes for performance
CREATE INDEX idx_product_reviews_product ON product_reviews(product_id);
CREATE INDEX idx_product_reviews_user ON product_reviews(user_id);
CREATE INDEX idx_product_reviews_parent ON product_reviews(parent_id);
CREATE INDEX idx_product_reviews_approved ON product_reviews(is_approved);
CREATE INDEX idx_product_reviews_rating ON product_reviews(rating);
-- Create trigger to automatically update the updated_at column
CREATE TRIGGER update_product_reviews_modtime
BEFORE UPDATE ON product_reviews
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
-- Function to update product average rating and review count
CREATE OR REPLACE FUNCTION update_product_average_rating()
RETURNS TRIGGER AS $$
DECLARE
avg_rating DECIMAL(3, 2);
rev_count INTEGER;
BEGIN
-- Calculate average rating and count for approved top-level reviews
SELECT
AVG(rating)::DECIMAL(3, 2),
COUNT(*)
INTO
avg_rating,
rev_count
FROM product_reviews
WHERE product_id = NEW.product_id
AND parent_id IS NULL
AND is_approved = TRUE
AND rating IS NOT NULL;
-- Update the product with new average rating and count
UPDATE products
SET
average_rating = avg_rating,
review_count = rev_count
WHERE id = NEW.product_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create triggers to recalculate average rating when reviews are added/updated/deleted
CREATE TRIGGER update_product_rating_on_insert
AFTER INSERT ON product_reviews
FOR EACH ROW
WHEN (NEW.parent_id IS NULL) -- Only for top-level reviews
EXECUTE FUNCTION update_product_average_rating();
CREATE TRIGGER update_product_rating_on_update
AFTER UPDATE OF is_approved, rating ON product_reviews
FOR EACH ROW
WHEN (NEW.parent_id IS NULL) -- Only for top-level reviews
EXECUTE FUNCTION update_product_average_rating();
CREATE TRIGGER update_product_rating_on_delete
AFTER DELETE ON product_reviews
FOR EACH ROW
WHEN (OLD.parent_id IS NULL) -- Only for top-level reviews
EXECUTE FUNCTION update_product_average_rating();

View file

@ -1,23 +1,132 @@
Rocks/
project/
├── frontend/
│ ├── src/
│ │ ├── utils/
│ │ │ └── imageUtils.js (4/24/2025)
│ │ ├── theme/
│ │ │ ├── ThemeProvider.jsx (4/24/2025)
│ │ │ └── index.js (4/24/2025)
│ │ ├── store/
│ │ │ └── index.js (4/24/2025)
│ │ ├── services/
│ │ │ ├── settingsAdminService.js (4/25/2025)
│ │ │ ├── productService.js (4/24/2025)
│ │ │ ├── imageService.js (4/24/2025)
│ │ │ ├── couponService.js (NEW - 4/29/2025)
│ │ │ ├── categoryAdminService.js (4/25/2025)
│ │ │ ├── cartService.js (4/25/2025)
│ │ │ ├── blogService.js (NEW - 4/29/2025)
│ │ │ ├── blogAdminService.js (NEW - 4/29/2025)
│ │ │ ├── authService.js (4/26/2025)
│ │ │ ├── api.js (4/24/2025)
│ │ │ └── adminService.js (4/26/2025)
│ │ ├── pages/
│ │ │ ├── Admin/
│ │ │ │ ├── SettingsPage.jsx (4/26/2025)
│ │ │ │ ├── ReportsPage.jsx (4/28/2025)
│ │ │ │ ├── ProductsPage.jsx (4/24/2025)
│ │ │ │ ├── ProductEditPage.jsx (4/28/2025)
│ │ │ │ ├── OrdersPage.jsx (4/26/2025)
│ │ │ │ ├── DashboardPage.jsx (4/28/2025)
│ │ │ │ ├── CustomersPage.jsx (4/26/2025)
│ │ │ │ ├── CouponsPage.jsx (NEW - 4/29/2025)
│ │ │ │ ├── CategoriesPage.jsx (4/25/2025)
│ │ │ │ ├── BlogPage.jsx (NEW - 4/29/2025)
│ │ │ │ └── BlogEditPage.jsx (NEW - 4/29/2025)
│ │ │ ├── BlogCommentsPage.jsx (NEW - 4/29/2025)
│ │ │ ├── VerifyPage.jsx (4/24/2025)
│ │ │ ├── UserOrdersPage.jsx (4/26/2025)
│ │ │ ├── RegisterPage.jsx (4/24/2025)
│ │ │ ├── ProductsPage.jsx (4/25/2025)
│ │ │ ├── ProductDetailPage.jsx (4/26/2025)
│ │ │ ├── PaymentSuccessPage.jsx (4/27/2025)
│ │ │ ├── PaymentCancelPage.jsx (4/26/2025)
│ │ │ ├── NotFoundPage.jsx (4/24/2025)
│ │ │ ├── LoginPage.jsx (4/24/2025)
│ │ │ ├── HomePage.jsx (4/25/2025)
│ │ │ ├── CouponRedemptionsPage.jsx (NEW - 4/29/2025)
│ │ │ ├── CouponEditPage.jsx (NEW - 4/29/2025)
│ │ │ ├── CheckoutPage.jsx (4/28/2025)
│ │ │ ├── CartPage.jsx (NEW - 4/29/2025)
│ │ │ ├── BlogPage.jsx (NEW - 4/29/2025)
│ │ │ └── BlogDetailPage.jsx (NEW - 4/29/2025)
│ │ ├── layouts/
│ │ │ ├── MainLayout.jsx (4/29/2025)
│ │ │ ├── AuthLayout.jsx (4/24/2025)
│ │ │ └── AdminLayout.jsx (4/29/2025)
│ │ ├── hooks/
│ │ │ ├── settingsAdminHooks.js (4/25/2025)
│ │ │ ├── reduxHooks.js (4/26/2025)
│ │ │ ├── couponAdminHooks.js (NEW - 4/29/2025)
│ │ │ ├── categoryAdminHooks.js (4/24/2025)
│ │ │ ├── blogHooks.js (NEW - 4/29/2025)
│ │ │ ├── apiHooks.js (4/26/2025)
│ │ │ └── adminHooks.js (4/26/2025)
│ │ ├── features/
│ │ │ ├── ui/
│ │ │ │ └── uiSlice.js (4/24/2025)
│ │ │ ├── cart/
│ │ │ │ └── cartSlice.js (NEW - 4/29/2025)
│ │ │ └── auth/
│ │ │ └── authSlice.js (4/26/2025)
│ │ ├── context/
│ │ │ └── StripeContext.jsx (4/28/2025)
│ │ ├── components/
│ │ │ ├── StripePaymentForm.jsx (4/26/2025)
│ │ │ ├── ProtectedRoute.jsx (4/24/2025)
│ │ │ ├── ProductImage.jsx (4/24/2025)
│ │ │ ├── OrderStatusDialog.jsx (4/26/2025)
│ │ │ ├── Notifications.jsx (4/24/2025)
│ │ │ ├── ImageUploader.jsx (4/24/2025)
│ │ │ ├── Footer.jsx (4/25/2025)
│ │ │ ├── EmailDialog.jsx (4/25/2025)
│ │ │ └── CouponInput.jsx (NEW - 4/29/2025)
│ │ ├── assets/
│ │ │ ├── main.jsx (4/24/2025)
│ │ │ └── config.js (4/24/2025)
│ │ └── App.jsx (4/24/2025)
├── db/
│ ├── test/
│ └── init/
│ ├── 16-blog-schema.sql (NEW)
│ ├── 15-coupon.sql (NEW)
│ ├── 14-product-notifications.sql (NEW)
│ ├── 13-cart-metadata.sql
│ ├── 12-shipping-orders.sql
│ ├── 11-notifications.sql
│ ├── 10-payment.sql
│ ├── 09-system-settings.sql
│ ├── 08-create-email.sql
│ ├── 07-user-keys.sql
│ ├── 06-product-categories.sql
│ ├── 05-admin-role.sql
│ ├── 04-product-images.sql
│ ├── 03-api-key.sql
│ ├── 02-seed.sql
│ └── 01-schema.sql
├── backend/
│ ├── src/
│ │ ├── services/
│ │ │ ├── shippingService.js
│ │ │ └── notificationService.js
│ │ │ └── shippingService.js
│ │ ├── routes/
│ │ │ ├── cart.js
│ │ │ ├── userOrders.js
│ │ │ ├── userAdmin.js
│ │ │ ├── stripePayment.js
│ │ │ ├── shipping.js
│ │ │ ├── settingsAdmin.js
│ │ │ ├── userOrders.js
│ │ │ ├── orderAdmin.js
│ │ │ ├── auth.js
│ │ │ ├── userAdmin.js
│ │ │ ├── products.js
│ │ │ ├── categoryAdmin.js
│ │ │ ├── productAdminImages.js
│ │ │ ├── productAdmin.js
│ │ │ ├── orderAdmin.js
│ │ │ ├── images.js
│ │ │ └── productAdmin.js
│ │ │ ├── couponAdmin.js (NEW - Large file: 18.7 KB)
│ │ │ ├── categoryAdmin.js
│ │ │ ├── cart.js (Updated - now 39.6 KB)
│ │ │ ├── blogCommentsAdmin.js (NEW)
│ │ │ ├── blogAdmin.js (NEW)
│ │ │ ├── blog.js (NEW)
│ │ │ └── auth.js
│ │ ├── models/
│ │ │ └── SystemSettings.js
│ │ ├── middleware/
@ -28,116 +137,10 @@ Rocks/
│ │ ├── index.js
│ │ ├── index.js
│ │ └── config.js
│ ├── node_modules/
│ ├── public/
│ │ └── uploads/
│ │ └── products/
│ ├── .env
│ ├── package.json
│ ├── package-lock.json
│ ├── Dockerfile
│ ├── README.md
│ └── .gitignore
├── frontend/
│ ├── node_modules/
│ ├── src/
│ │ ├── pages/
│ │ │ ├── Admin/
│ │ │ │ ├── OrdersPage.jsx
│ │ │ │ ├── SettingsPage.jsx
│ │ │ │ ├── CustomersPage.jsx
│ │ │ │ ├── ProductEditPage.jsx
│ │ │ │ ├── DashboardPage.jsx
│ │ │ │ ├── CategoriesPage.jsx
│ │ │ │ └── ProductsPage.jsx
│ │ │ ├── CheckoutPage.jsx
│ │ │ ├── PaymentSuccessPage.jsx
│ │ │ ├── UserOrdersPage.jsx
│ │ │ ├── PaymentCancelPage.jsx
│ │ │ ├── ProductDetailPage.jsx
│ │ │ ├── CartPage.jsx
│ │ │ ├── ProductsPage.jsx
│ │ │ ├── HomePage.jsx
│ │ │ ├── VerifyPage.jsx
│ │ │ ├── RegisterPage.jsx
│ │ │ ├── NotFoundPage.jsx
│ │ │ └── LoginPage.jsx
│ │ ├── services/
│ │ │ ├── adminService.js
│ │ │ ├── authService.js
│ │ │ ├── settingsAdminService.js
│ │ │ ├── cartService.js
│ │ │ ├── categoryAdminService.js
│ │ │ ├── imageService.js
│ │ │ ├── productService.js
│ │ │ └── api.js
│ │ ├── components/
│ │ │ ├── OrderStatusDialog.jsx
│ │ │ ├── StripePaymentForm.jsx
│ │ │ ├── EmailDialog.jsx
│ │ │ ├── Footer.jsx
│ │ │ ├── ImageUploader.jsx
│ │ │ ├── ProductImage.jsx
│ │ │ ├── ProtectedRoute.jsx
│ │ │ └── Notifications.jsx
│ │ ├── context/
│ │ │ └── StripeContext.jsx
│ │ ├── hooks/
│ │ │ ├── apiHooks.js
│ │ │ ├── adminHooks.js
│ │ │ ├── reduxHooks.js
│ │ │ ├── settingsAdminHooks.js
│ │ │ └── categoryAdminHooks.js
│ │ ├── utils/
│ │ │ └── imageUtils.js
│ │ ├── layouts/
│ │ │ ├── MainLayout.jsx
│ │ │ ├── AdminLayout.jsx
│ │ │ └── AuthLayout.jsx
│ │ ├── theme/
│ │ │ ├── index.js
│ │ │ └── ThemeProvider.jsx
│ │ ├── features/
│ │ │ ├── ui/
│ │ │ │ └── uiSlice.js
│ │ │ ├── cart/
│ │ │ │ └── cartSlice.js
│ │ │ ├── auth/
│ │ │ │ └── authSlice.js
│ │ │ └── store/
│ │ │ └── index.js
│ │ ├── assets/
│ │ ├── App.jsx
│ │ ├── config.js
│ │ └── main.jsx
│ └── public/
│ ├── favicon.svg
│ ├── package-lock.json
│ ├── package.json
│ ├── vite.config.js
│ ├── Dockerfile
│ ├── nginx.conf
│ ├── index.html
│ ├── README.md
│ ├── .env
│ └── setup-frontend.sh
├── db/
│ ├── init/
│ │ ├── 14-product-notifications.sql
│ │ ├── 13-cart-metadata.sql
│ │ ├── 12-shipping-orders.sql
│ │ ├── 09-system-settings.sql
│ │ ├── 11-notifications.sql
│ │ ├── 10-payment.sql
│ │ ├── 08-create-email.sql
│ │ ├── 07-user-keys.sql
│ │ ├── 06-product-categories.sql
│ │ ├── 05-admin-role.sql
│ │ ├── 02-seed.sql
│ │ ├── 04-product-images.sql
│ │ ├── 03-api-key.sql
│ │ └── 01-schema.sql
│ └── test/
├── fileStructure.txt
├── docker-compose.yml
└── .gitignore
│ └── uploads/
│ ├── products/
│ └── blog/ (NEW)
└── git/
├── fileStructure.txt
└── docker-compose.yml

View file

@ -31,6 +31,16 @@ const AdminSettingsPage = lazy(() => import('@pages/Admin/SettingsPage'));
const AdminReportsPage = lazy(() => import('@pages/Admin/ReportsPage'));
const UserOrdersPage = lazy(() => import('@pages/UserOrdersPage'));
const NotFoundPage = lazy(() => import('@pages/NotFoundPage'));
const CouponsPage = lazy(() => import('@pages/Admin/CouponsPage'));
const CouponEditPage = lazy(() => import('@pages/CouponEditPage'));
const CouponRedemptionsPage = lazy(() => import('@pages/CouponRedemptionsPage'));
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 AdminBlogCommentsPage = lazy(() => import('@pages/Admin/BlogCommentsPage'));
const AdminProductReviewsPage = lazy(() => import('@pages/Admin/ProductReviewsPage'));
// Loading component for suspense fallback
const LoadingComponent = () => (
@ -76,6 +86,9 @@ function App() {
<PaymentCancelPage />
</ProtectedRoute>
} />
{/* Blog routes */}
<Route path="blog" element={<BlogPage />} />
<Route path="blog/:slug" element={<BlogDetailPage />} />
</Route>
{/* Auth routes with AuthLayout */}
@ -102,6 +115,15 @@ function App() {
<Route path="settings" element={<AdminSettingsPage />} />
<Route path="orders" element={<AdminOrdersPage />} />
<Route path="reports" element={<AdminReportsPage />} />
<Route path="coupons" element={<CouponsPage />} />
<Route path="coupons/new" element={<CouponEditPage />} />
<Route path="coupons/:id" element={<CouponEditPage />} />
<Route path="coupons/:id/redemptions" element={<CouponRedemptionsPage />} />
<Route path="blog" element={<AdminBlogPage />} />
<Route path="blog/new" element={<BlogEditPage />} />
<Route path="blog/:id" element={<BlogEditPage />} />
<Route path="blog-comments" element={<AdminBlogCommentsPage />} />
<Route path="product-reviews" element={<AdminProductReviewsPage />} />
</Route>
{/* Catch-all route for 404s */}

View file

@ -0,0 +1,138 @@
import React, { useState } from 'react';
import {
TextField,
Button,
Box,
Typography,
CircularProgress,
Alert,
Paper,
Divider,
Chip
} from '@mui/material';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import couponService from '../services/couponService';
import { useAuth } from '../hooks/reduxHooks';
/**
* Component for inputting and applying coupon codes to the cart
*/
const CouponInput = () => {
const [couponCode, setCouponCode] = useState('');
const [error, setError] = useState(null);
const { user } = useAuth();
const queryClient = useQueryClient();
// Get current cart data from cache
const cartData = queryClient.getQueryData(['cart', user]);
// Apply coupon mutation
const applyCoupon = useMutation({
mutationFn: ({ userId, code }) => couponService.applyCoupon(userId, code),
onSuccess: (data) => {
// Update React Query cache directly
queryClient.setQueryData(['cart', user], data);
setError(null);
},
onError: (error) => {
setError(error.message || 'Failed to apply coupon');
},
});
// Remove coupon mutation
const removeCoupon = useMutation({
mutationFn: (userId) => couponService.removeCoupon(userId),
onSuccess: (data) => {
// Update React Query cache directly
queryClient.setQueryData(['cart', user], data);
setError(null);
},
onError: (error) => {
setError(error.message || 'Failed to remove coupon');
},
});
// Handle coupon code input change
const handleCouponChange = (e) => {
setCouponCode(e.target.value.toUpperCase());
setError(null);
};
// Handle applying coupon
const handleApplyCoupon = () => {
if (!couponCode) {
setError('Please enter a coupon code');
return;
}
applyCoupon.mutate({ userId: user, code: couponCode });
};
// Handle removing coupon
const handleRemoveCoupon = () => {
removeCoupon.mutate(user);
setCouponCode('');
};
// Check if a coupon is already applied
const hasCoupon = cartData?.couponCode;
return (
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Discount Code
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{hasCoupon ? (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Chip
label={cartData.couponCode}
color="success"
sx={{ mr: 2 }}
/>
<Typography variant="body2" color="success.main">
Discount applied: -${cartData.couponDiscount?.toFixed(2)}
</Typography>
</Box>
<Button
variant="outlined"
color="error"
size="small"
onClick={handleRemoveCoupon}
disabled={removeCoupon.isLoading}
>
{removeCoupon.isLoading ? <CircularProgress size={24} /> : 'Remove Coupon'}
</Button>
</Box>
) : (
<Box sx={{ display: 'flex' }}>
<TextField
fullWidth
placeholder="Enter coupon code"
value={couponCode}
onChange={handleCouponChange}
disabled={applyCoupon.isLoading}
inputProps={{ style: { textTransform: 'uppercase' } }}
sx={{ mr: 2 }}
/>
<Button
variant="contained"
onClick={handleApplyCoupon}
disabled={!couponCode || applyCoupon.isLoading}
>
{applyCoupon.isLoading ? <CircularProgress size={24} /> : 'Apply'}
</Button>
</Box>
)}
</Paper>
);
};
export default CouponInput;

View file

@ -0,0 +1,27 @@
import React from 'react';
import { Box, Typography, Rating } from '@mui/material';
/**
* Component to display product rating in a compact format
*/
const ProductRatingDisplay = ({ rating, reviewCount, showEmpty = false }) => {
if (!rating && !reviewCount && !showEmpty) {
return null;
}
return (
<Box sx={{ display: 'flex', alignItems: 'center', my: 1 }}>
<Rating
value={rating || 0}
readOnly
precision={0.5}
size="small"
/>
<Typography variant="body2" color="text.secondary" sx={{ ml: 0.5 }}>
{reviewCount ? `(${reviewCount})` : showEmpty ? '(0)' : ''}
</Typography>
</Box>
);
};
export default ProductRatingDisplay;

View file

@ -0,0 +1,328 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Rating,
Avatar,
Button,
TextField,
Paper,
Divider,
Card,
CardContent,
FormControlLabel,
Checkbox,
Alert,
CircularProgress
} from '@mui/material';
import { format } from 'date-fns';
import CommentIcon from '@mui/icons-material/Comment';
import VerifiedIcon from '@mui/icons-material/Verified';
import { useProductReviews, useCanReviewProduct, useAddProductReview } from '@hooks/productReviewHooks';
import { useAuth } from '@hooks/reduxHooks';
/**
* Component for displaying product reviews and allowing users to submit new reviews
*/
const ProductReviews = ({ productId }) => {
const { isAuthenticated } = useAuth();
const [replyTo, setReplyTo] = useState(null);
const [showReviewForm, setShowReviewForm] = useState(false);
const [formData, setFormData] = useState({
title: '',
content: '',
rating: 0
});
// Fetch reviews for this product
const { data: reviews, isLoading: reviewsLoading } = useProductReviews(productId);
// Check if user can submit a review
const { data: reviewPermission, isLoading: permissionLoading } = useCanReviewProduct(productId);
// Add review mutation
const addReview = useAddProductReview();
// Format date
const formatDate = (dateString) => {
if (!dateString) return '';
return format(new Date(dateString), 'MMMM d, yyyy');
};
// Handle form changes
const handleFormChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
// Handle rating change
const handleRatingChange = (event, newValue) => {
setFormData(prev => ({ ...prev, rating: newValue }));
};
// Handle submit review
const handleSubmitReview = (e) => {
e.preventDefault();
if (!formData.title) {
return; // Title is required
}
if (!replyTo && (!formData.rating || formData.rating < 1)) {
return; // Rating is required for top-level reviews
}
const reviewData = {
title: formData.title,
content: formData.content,
rating: replyTo ? undefined : formData.rating,
parentId: replyTo ? replyTo.id : undefined
};
addReview.mutate({
productId,
reviewData
}, {
onSuccess: () => {
// Reset form
setFormData({
title: '',
content: '',
rating: 0
});
setReplyTo(null);
setShowReviewForm(false);
}
});
};
// Render a single review
const renderReview = (review, isReply = false) => (
<Card
key={review.id}
variant={isReply ? "outlined" : "elevation"}
sx={{
mb: 2,
ml: isReply ? 4 : 0,
bgcolor: isReply ? 'background.paper' : undefined
}}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar sx={{ mr: 1 }}>
{review.first_name ? review.first_name[0] : '?'}
</Avatar>
<Box>
<Typography variant="subtitle1">
{review.first_name} {review.last_name}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(review.created_at)}
</Typography>
</Box>
</Box>
{review.is_verified_purchase && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<VerifiedIcon color="primary" fontSize="small" sx={{ mr: 0.5 }} />
<Typography variant="caption" color="primary">
Verified Purchase
</Typography>
</Box>
)}
</Box>
{!isReply && review.rating && (
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Rating value={review.rating} readOnly precision={0.5} />
<Typography variant="body2" sx={{ ml: 1 }}>
({review.rating}/5)
</Typography>
</Box>
)}
<Typography variant="h6" gutterBottom>{review.title}</Typography>
{review.content && (
<Typography variant="body2" paragraph>
{review.content}
</Typography>
)}
{isAuthenticated && (
<Button
size="small"
startIcon={<CommentIcon />}
onClick={() => {
setReplyTo(review);
setShowReviewForm(true);
// Scroll to form
document.getElementById('review-form')?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}}
>
Reply
</Button>
)}
</CardContent>
{/* Render replies */}
{review.replies && review.replies.length > 0 && (
<Box sx={{ px: 2, pb: 2 }}>
{review.replies.map(reply => renderReview(reply, true))}
</Box>
)}
</Card>
);
return (
<Box sx={{ mt: 4 }}>
<Typography variant="h5" component="h2" gutterBottom>
Customer Reviews
{reviews && reviews.length > 0 && (
<Typography component="span" variant="body2" color="text.secondary" sx={{ ml: 1 }}>
({reviews.length})
</Typography>
)}
</Typography>
<Divider sx={{ mb: 3 }} />
{/* Write a review button */}
{isAuthenticated && !permissionLoading && reviewPermission && (
<Box sx={{ mb: 3 }}>
{!showReviewForm ? (
<Button
variant="contained"
onClick={() => {
setReplyTo(null);
setShowReviewForm(true);
}}
disabled={!reviewPermission?.canReview}
>
Write a Review
</Button>
) : (
<Button
variant="outlined"
onClick={() => {
setReplyTo(null);
setShowReviewForm(false);
}}
>
Cancel
</Button>
)}
{!reviewPermission?.canReview && !reviewPermission?.isAdmin && (
<Alert severity="info" sx={{ mt: 2 }}>
{reviewPermission?.reason || 'You need to purchase this product before you can review it.'}
</Alert>
)}
</Box>
)}
{/* Review form */}
{showReviewForm && isAuthenticated && reviewPermission && (reviewPermission.canReview || replyTo) && (
<Paper id="review-form" sx={{ p: 3, mb: 4 }}>
<Typography variant="h6" gutterBottom>
{replyTo ? `Reply to ${replyTo.first_name}'s Review` : 'Write a Review'}
</Typography>
{replyTo && (
<Alert severity="info" sx={{ mb: 2 }}>
Replying to: "{replyTo.title}"
<Button
size="small"
onClick={() => setReplyTo(null)}
sx={{ ml: 2 }}
>
Cancel Reply
</Button>
</Alert>
)}
<form onSubmit={handleSubmitReview}>
<TextField
fullWidth
required
label="Review Title"
name="title"
value={formData.title}
onChange={handleFormChange}
margin="normal"
/>
{!replyTo && (
<Box sx={{ my: 2 }}>
<Typography component="legend">Rating *</Typography>
<Rating
name="rating"
value={formData.rating}
onChange={handleRatingChange}
precision={0.5}
size="large"
/>
{formData.rating === 0 && (
<Typography variant="caption" color="error">
Please select a rating
</Typography>
)}
</Box>
)}
<TextField
fullWidth
multiline
rows={4}
label="Review"
name="content"
value={formData.content}
onChange={handleFormChange}
margin="normal"
placeholder={replyTo ? "Write your reply..." : "Share your experience with this product..."}
/>
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="submit"
variant="contained"
disabled={
addReview.isLoading ||
!formData.title ||
(!replyTo && formData.rating < 1)
}
>
{addReview.isLoading ? (
<CircularProgress size={24} />
) : (
replyTo ? 'Post Reply' : 'Submit Review'
)}
</Button>
</Box>
</form>
</Paper>
)}
{/* Reviews list */}
{reviewsLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : reviews && reviews.length > 0 ? (
<Box>
{reviews.map(review => renderReview(review))}
</Box>
) : (
<Alert severity="info">
This product doesn't have any reviews yet. Be the first to review it!
</Alert>
)}
</Box>
);
};
export default ProductReviews;

View file

@ -5,6 +5,9 @@ const initialState = {
items: [],
itemCount: 0,
total: 0,
subtotal: 0,
couponCode: null,
couponDiscount: 0,
loading: false,
error: null,
};
@ -27,12 +30,18 @@ export const cartSlice = createSlice({
state.items = action.payload.items;
state.itemCount = action.payload.itemCount;
state.total = action.payload.total;
state.subtotal = action.payload.subtotal || action.payload.total;
state.couponCode = action.payload.couponCode || null;
state.couponDiscount = action.payload.couponDiscount || 0;
},
clearCart: (state) => {
state.id = null;
state.items = [];
state.itemCount = 0;
state.total = 0;
state.subtotal = 0;
state.couponCode = null;
state.couponDiscount = 0;
},
clearCartError: (state) => {
state.error = null;
@ -52,6 +61,9 @@ export const {
export const selectCartItems = (state) => state.cart.items;
export const selectCartItemCount = (state) => state.cart.itemCount;
export const selectCartTotal = (state) => state.cart.total;
export const selectCartSubtotal = (state) => state.cart.subtotal;
export const selectCouponCode = (state) => state.cart.couponCode;
export const selectCouponDiscount = (state) => state.cart.couponDiscount;
export const selectCartLoading = (state) => state.cart.loading;
export const selectCartError = (state) => state.cart.error;

View file

@ -0,0 +1,235 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import blogService from '@services/blogService';
import blogAdminService from '@services/blogAdminService';
import { useNotification } from './reduxHooks';
// Public blog hooks
export const useBlogPosts = (params) => {
return useQuery({
queryKey: ['blog-posts', params],
queryFn: () => blogService.getAllPosts(params),
});
};
export const useBlogPost = (slug) => {
return useQuery({
queryKey: ['blog-post', slug],
queryFn: () => blogService.getPostBySlug(slug),
enabled: !!slug,
});
};
export const useBlogCategories = () => {
return useQuery({
queryKey: ['blog-categories'],
queryFn: () => blogService.getAllCategories(),
});
};
export const useAddComment = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: ({ postId, commentData }) => blogService.addComment(postId, commentData),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['blog-post', variables.postId] });
notification.showNotification(
data.message || 'Comment added successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to add comment',
'error'
);
},
});
};
// Admin blog hooks
export const useAdminBlogPosts = () => {
return useQuery({
queryKey: ['admin-blog-posts'],
queryFn: () => blogAdminService.getAllPosts(),
});
};
export const useAdminBlogPost = (id) => {
return useQuery({
queryKey: ['admin-blog-post', id],
queryFn: () => blogAdminService.getPostById(id),
enabled: !!id,
});
};
export const useCreateBlogPost = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (postData) => blogAdminService.createPost(postData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] });
notification.showNotification(
'Blog post created successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to create blog post',
'error'
);
},
});
};
export const useUpdateBlogPost = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: ({ id, postData }) => blogAdminService.updatePost(id, postData),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] });
queryClient.invalidateQueries({ queryKey: ['admin-blog-post', variables.id] });
notification.showNotification(
'Blog post updated successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to update blog post',
'error'
);
},
});
};
export const useDeleteBlogPost = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (id) => blogAdminService.deletePost(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] });
notification.showNotification(
'Blog post deleted successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to delete blog post',
'error'
);
},
});
};
export const useUploadBlogImage = () => {
const notification = useNotification();
return useMutation({
mutationFn: ({ postId, imageData }) => blogAdminService.uploadImage(postId, imageData),
onSuccess: (_, variables) => {
notification.showNotification(
'Image uploaded successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to upload image',
'error'
);
},
});
};
export const useDeleteBlogImage = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: ({ postId, imageId }) => blogAdminService.deleteImage(postId, imageId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['admin-blog-post', variables.postId] });
notification.showNotification(
'Image deleted successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to delete image',
'error'
);
},
});
};
export const usePendingComments = () => {
return useQuery({
queryKey: ['pending-comments'],
queryFn: () => blogAdminService.getPendingComments(),
});
};
export const usePostComments = (postId) => {
return useQuery({
queryKey: ['post-comments', postId],
queryFn: () => blogAdminService.getPostComments(postId),
enabled: !!postId,
});
};
export const useApproveComment = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (commentId) => blogAdminService.approveComment(commentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pending-comments'] });
queryClient.invalidateQueries({ queryKey: ['post-comments'] });
notification.showNotification(
'Comment approved successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to approve comment',
'error'
);
},
});
};
export const useDeleteComment = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (commentId) => blogAdminService.deleteComment(commentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pending-comments'] });
queryClient.invalidateQueries({ queryKey: ['post-comments'] });
notification.showNotification(
'Comment deleted successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to delete comment',
'error'
);
},
});
};

View file

@ -0,0 +1,117 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import couponService from '../services/couponService';
import { useNotification } from './reduxHooks';
/**
* Hook for fetching all coupons (admin only)
*/
export const useAdminCoupons = () => {
return useQuery({
queryKey: ['admin-coupons'],
queryFn: couponService.getAllCoupons
});
};
/**
* Hook for fetching a single coupon by ID (admin only)
* @param {string} id - Coupon ID
*/
export const useAdminCoupon = (id) => {
return useQuery({
queryKey: ['admin-coupon', id],
queryFn: () => couponService.getCouponById(id),
enabled: !!id
});
};
/**
* Hook for creating a new coupon (admin only)
*/
export const useCreateCoupon = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (couponData) => couponService.createCoupon(couponData),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-coupons'] });
notification.showNotification('Coupon created successfully', 'success');
return data;
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to create coupon',
'error'
);
throw error;
},
});
};
/**
* Hook for updating a coupon (admin only)
*/
export const useUpdateCoupon = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: ({ id, couponData }) => couponService.updateCoupon(id, couponData),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-coupons'] });
queryClient.invalidateQueries({ queryKey: ['admin-coupon', data.coupon?.id] });
notification.showNotification('Coupon updated successfully', 'success');
return data;
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to update coupon',
'error'
);
throw error;
},
});
};
/**
* Hook for deleting a coupon (admin only)
*/
export const useDeleteCoupon = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (id) => couponService.deleteCoupon(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-coupons'] });
notification.showNotification('Coupon deleted successfully', 'success');
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to delete coupon',
'error'
);
throw error;
},
});
};
/**
* Hook for fetching coupon redemption history (admin only)
*/
export const useCouponRedemptions = (id) => {
return useQuery({
queryKey: ['admin-coupon-redemptions', id],
queryFn: () => couponService.getCouponRedemptions(id),
enabled: !!id
});
};
export default {
useAdminCoupons,
useAdminCoupon,
useCreateCoupon,
useUpdateCoupon,
useDeleteCoupon,
useCouponRedemptions
};

View file

@ -0,0 +1,114 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import productReviewService, { productReviewAdminService } from '@services/productReviewService';
import { useNotification } from './reduxHooks';
// User-facing review hooks
export const useProductReviews = (productId) => {
return useQuery({
queryKey: ['product-reviews', productId],
queryFn: () => productReviewService.getProductReviews(productId),
enabled: !!productId
});
};
export const useCanReviewProduct = (productId) => {
return useQuery({
queryKey: ['can-review-product', productId],
queryFn: () => productReviewService.canReviewProduct(productId),
enabled: !!productId
});
};
export const useAddProductReview = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: ({ productId, reviewData }) =>
productReviewService.addProductReview(productId, reviewData),
onSuccess: (data, variables) => {
// Invalidate reviews for this product
queryClient.invalidateQueries({ queryKey: ['product-reviews', variables.productId] });
queryClient.invalidateQueries({ queryKey: ['can-review-product', variables.productId] });
notification.showNotification(
data.message || 'Review submitted successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to submit review',
'error'
);
}
});
};
// Admin review hooks
export const usePendingReviews = () => {
return useQuery({
queryKey: ['pending-reviews'],
queryFn: productReviewAdminService.getPendingReviews
});
};
export const useAdminProductReviews = (productId) => {
return useQuery({
queryKey: ['admin-product-reviews', productId],
queryFn: () => productReviewAdminService.getProductReviews(productId),
enabled: !!productId
});
};
export const useApproveReview = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (reviewId) => productReviewAdminService.approveReview(reviewId),
onSuccess: () => {
// Invalidate both pending reviews and product reviews
queryClient.invalidateQueries({ queryKey: ['pending-reviews'] });
queryClient.invalidateQueries({ queryKey: ['admin-product-reviews'] });
queryClient.invalidateQueries({ queryKey: ['product-reviews'] });
notification.showNotification(
'Review approved successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to approve review',
'error'
);
}
});
};
export const useDeleteReview = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (reviewId) => productReviewAdminService.deleteReview(reviewId),
onSuccess: () => {
// Invalidate both pending reviews and product reviews
queryClient.invalidateQueries({ queryKey: ['pending-reviews'] });
queryClient.invalidateQueries({ queryKey: ['admin-product-reviews'] });
queryClient.invalidateQueries({ queryKey: ['product-reviews'] });
notification.showNotification(
'Review deleted successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to delete review',
'error'
);
}
});
};

View file

@ -10,6 +10,7 @@ import DashboardIcon from '@mui/icons-material/Dashboard';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import PeopleIcon from '@mui/icons-material/People';
import SettingsIcon from '@mui/icons-material/Settings';
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
import BarChartIcon from '@mui/icons-material/BarChart';
import CategoryIcon from '@mui/icons-material/Category';
import HomeIcon from '@mui/icons-material/Home';
@ -18,8 +19,12 @@ import LogoutIcon from '@mui/icons-material/Logout';
import Brightness4Icon from '@mui/icons-material/Brightness4';
import Brightness7Icon from '@mui/icons-material/Brightness7';
import ClassIcon from '@mui/icons-material/Class';
import BookIcon from '@mui/icons-material/Book';
import CommentIcon from '@mui/icons-material/Comment';
import RateReviewIcon from '@mui/icons-material/RateReview';
import { Link as RouterLink } from 'react-router-dom';
import { useAuth, useDarkMode } from '../hooks/reduxHooks';
import { useAuth, useDarkMode } from '@hooks/reduxHooks';
const drawerWidth = 240;
@ -64,8 +69,12 @@ const AdminLayout = () => {
{ text: 'Categories', icon: <ClassIcon />, path: '/admin/categories' },
{ text: 'Orders', icon: <ShoppingCartIcon />, path: '/admin/orders' },
{ text: 'Customers', icon: <PeopleIcon />, path: '/admin/customers' },
{ text: 'Coupons', icon: <LocalOfferIcon />, path: '/admin/coupons' },
{ text: 'Blog', icon: <BookIcon />, path: '/admin/blog' },
{ text: 'Blog Comments', icon: <CommentIcon />, path: '/admin/blog-comments' },
{ text: 'Settings', icon: <SettingsIcon />, path: '/admin/settings' },
{ text: 'Reports', icon: <BarChartIcon />, path: '/admin/reports' },
{ text: 'Product Reviews', icon: <RateReviewIcon />, path: '/admin/product-reviews' },
];
const secondaryListItems = [

View file

@ -15,6 +15,7 @@ import DashboardIcon from '@mui/icons-material/Dashboard';
import Brightness4Icon from '@mui/icons-material/Brightness4';
import Brightness7Icon from '@mui/icons-material/Brightness7';
import ReceiptIcon from '@mui/icons-material/Receipt';
import BookIcon from '@mui/icons-material/Book';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useAuth, useCart, useDarkMode } from '../hooks/reduxHooks';
import Footer from '../components/Footer';
@ -41,6 +42,7 @@ const MainLayout = () => {
let mainMenu = [
{ text: 'Home', icon: <HomeIcon />, path: '/' },
{ text: 'Products', icon: <CategoryIcon />, path: '/products' },
{ text: 'Blog', icon: <BookIcon />, path: '/blog' },
{ text: 'Cart', icon: <ShoppingCartIcon />, path: '/cart', badge: itemCount > 0 ? itemCount : null },
];
if (isAuthenticated) {

View file

@ -0,0 +1,333 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Paper,
Tabs,
Tab,
Card,
CardContent,
CardActions,
Button,
Divider,
Chip,
CircularProgress,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
TextField,
InputAdornment,
IconButton,
Tooltip
} from '@mui/material';
import {
Check as ApproveIcon,
Delete as DeleteIcon,
Search as SearchIcon,
Clear as ClearIcon,
Visibility as ViewIcon,
Refresh as RefreshIcon
} from '@mui/icons-material';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { usePendingComments, useApproveComment, useDeleteComment } from '@hooks/blogHooks';
import { format } from 'date-fns';
import { useQueryClient } from '@tanstack/react-query';
const AdminBlogCommentsPage = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [commentToDelete, setCommentToDelete] = useState(null);
const [isRefreshing, setIsRefreshing] = useState(false);
// Fetch pending comments
const { data: pendingComments, isLoading, error } = usePendingComments();
const approveComment = useApproveComment();
const deleteComment = useDeleteComment();
// Filter comments by search term
const filteredComments = pendingComments ? pendingComments.filter(comment =>
comment.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
comment.first_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
comment.last_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
comment.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
comment.post_title?.toLowerCase().includes(searchTerm.toLowerCase())
) : [];
// Handle tab change
const handleTabChange = (event, newValue) => {
setActiveTab(newValue);
};
// Handle search input change
const handleSearchChange = (e) => {
setSearchTerm(e.target.value);
};
// Clear search
const handleClearSearch = () => {
setSearchTerm('');
};
// Handle refresh
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await queryClient.invalidateQueries(['pending-comments']);
// Optional timeout to ensure the refresh button animation is visible
setTimeout(() => {
setIsRefreshing(false);
}, 500);
} catch (error) {
console.error('Error refreshing comments:', error);
setIsRefreshing(false);
}
};
// Handle view post
const handleViewPost = (slug) => {
window.open(`/blog/${slug}`, '_blank');
};
// Handle approve comment
const handleApproveComment = async (id) => {
try {
await approveComment.mutateAsync(id);
} catch (error) {
console.error('Error approving comment:', error);
}
};
// Handle delete dialog open
const handleDeleteClick = (comment) => {
setCommentToDelete(comment);
setDeleteDialogOpen(true);
};
// Handle delete confirmation
const handleConfirmDelete = async () => {
if (commentToDelete) {
await deleteComment.mutateAsync(commentToDelete.id);
setDeleteDialogOpen(false);
setCommentToDelete(null);
}
};
// Handle delete cancellation
const handleCancelDelete = () => {
setDeleteDialogOpen(false);
setCommentToDelete(null);
};
// Format date
const formatDate = (dateString) => {
return format(new Date(dateString), 'MMM d, yyyy h:mm a');
};
// Loading state
if (isLoading && !isRefreshing) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (error) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading comments: {error.message}
</Alert>
);
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" component="h1">
Blog Comments
</Typography>
<Tooltip title="Refresh comments">
<IconButton
onClick={handleRefresh}
color="primary"
disabled={isRefreshing}
>
{isRefreshing ? (
<CircularProgress size={24} />
) : (
<RefreshIcon />
)}
</IconButton>
</Tooltip>
</Box>
<Tabs
value={activeTab}
onChange={handleTabChange}
indicatorColor="primary"
textColor="primary"
variant="fullWidth"
sx={{ mb: 3 }}
>
<Tab label={`Pending Approval (${pendingComments?.length || 0})`} />
<Tab label="All Comments" />
</Tabs>
{/* Search Box */}
<Paper sx={{ p: 2, mb: 3 }}>
<TextField
fullWidth
placeholder="Search comments by content, author, or post..."
value={searchTerm}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: searchTerm && (
<InputAdornment position="end">
<IconButton size="small" onClick={handleClearSearch}>
<ClearIcon />
</IconButton>
</InputAdornment>
)
}}
/>
</Paper>
{/* Comments List */}
{filteredComments.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h6" gutterBottom>
No pending comments found
</Typography>
<Typography variant="body2" color="text.secondary">
{searchTerm ? 'Try adjusting your search terms' : 'All comments have been reviewed'}
</Typography>
<Button
startIcon={<RefreshIcon />}
variant="outlined"
onClick={handleRefresh}
sx={{ mt: 2 }}
disabled={isRefreshing}
>
Refresh
</Button>
</Paper>
) : (
<Box>
{filteredComments.map(comment => (
<Card key={comment.id} sx={{ mb: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1">
{comment.first_name} {comment.last_name} ({comment.email})
</Typography>
<Chip
label={formatDate(comment.created_at)}
variant="outlined"
size="small"
/>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" paragraph>
{comment.content}
</Typography>
<Box sx={{ bgcolor: 'background.paper', p: 1, borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">
On post:
</Typography>
<Typography variant="body2">
{comment.post_title}
</Typography>
</Box>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
startIcon={<ViewIcon />}
onClick={() => handleViewPost(comment.post_slug)}
>
View Post
</Button>
<Button
startIcon={<ApproveIcon />}
color="success"
onClick={() => handleApproveComment(comment.id)}
disabled={approveComment.isLoading}
>
Approve
</Button>
<Button
startIcon={<DeleteIcon />}
color="error"
onClick={() => handleDeleteClick(comment)}
disabled={deleteComment.isLoading}
>
Delete
</Button>
</CardActions>
</Card>
))}
</Box>
)}
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={handleCancelDelete}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">
Confirm Delete
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
Are you sure you want to delete this comment? This action cannot be undone.
</DialogContentText>
{commentToDelete && (
<Paper variant="outlined" sx={{ p: 2, mt: 2, bgcolor: 'background.paper' }}>
<Typography variant="caption" color="text.secondary">
Comment by {commentToDelete.first_name} {commentToDelete.last_name}:
</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
{commentToDelete.content}
</Typography>
</Paper>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCancelDelete}>Cancel</Button>
<Button
onClick={handleConfirmDelete}
color="error"
variant="contained"
autoFocus
disabled={deleteComment.isLoading}
>
{deleteComment.isLoading ? (
<CircularProgress size={24} sx={{ mr: 1 }} />
) : null}
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default AdminBlogCommentsPage;

View file

@ -0,0 +1,476 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
TextField,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
Chip,
Grid,
Divider,
CircularProgress,
Alert,
Switch,
FormControlLabel,
FormHelperText,
Autocomplete,
IconButton,
Card,
CardContent,
CardMedia,
CardActions,
Tooltip,
Breadcrumbs,
Link
} from '@mui/material';
import {
ArrowBack as ArrowBackIcon,
Save as SaveIcon,
Preview as PreviewIcon,
Delete as DeleteIcon
} 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';
const BlogEditPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const isNewPost = !id;
const { userData } = useAuth();
// Form state
const [formData, setFormData] = useState({
title: '',
content: '',
excerpt: '',
categoryId: '',
tags: [],
featuredImagePath: '',
status: 'draft',
publishNow: false
});
// Validation state
const [errors, setErrors] = useState({});
const [notificationOpen, setNotificationOpen] = useState(false);
const [notification, setNotification] = useState({ type: 'success', message: '' });
// Fetch blog post if editing
const {
data: post,
isLoading: postLoading,
error: postError
} = useAdminBlogPost(isNewPost ? null : id);
// Fetch categories
const { data: categories } = useBlogCategories();
// Mutations
const createPost = useCreateBlogPost();
const updatePost = useUpdateBlogPost();
// Handle form changes
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear validation error
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: null }));
}
};
// Handle switch changes
const handleSwitchChange = (e) => {
const { name, checked } = e.target;
setFormData(prev => ({ ...prev, [name]: checked }));
};
// Handle tags change
const handleTagsChange = (event, newValue) => {
setFormData(prev => ({ ...prev, tags: newValue }));
};
// Handle featured image change
const handleFeaturedImageChange = (images) => {
if (images && images.length > 0) {
setFormData(prev => ({ ...prev, featuredImagePath: images[0].path }));
} else {
setFormData(prev => ({ ...prev, featuredImagePath: '' }));
}
};
// Clear featured image
const handleClearFeaturedImage = () => {
setFormData(prev => ({ ...prev, featuredImagePath: '' }));
};
// Validate form
const validateForm = () => {
const newErrors = {};
if (!formData.title.trim()) {
newErrors.title = 'Title is required';
}
if (!formData.content.trim()) {
newErrors.content = 'Content is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle form submission
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
setNotification({
type: 'error',
message: 'Please fix the form errors before submitting'
});
setNotificationOpen(true);
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 (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);
}
};
// 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');
};
// Load post data when available
useEffect(() => {
if (post && !isNewPost) {
setFormData({
title: post.title || '',
content: post.content || '',
excerpt: post.excerpt || '',
categoryId: post.category_id || '',
tags: post.tags || [],
featuredImagePath: post.featured_image_path || '',
status: post.status || 'draft',
publishNow: false
});
}
}, [post, isNewPost]);
// Loading state
if (postLoading && !isNewPost) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (postError && !isNewPost) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading blog post: {postError.message}
</Alert>
);
}
return (
<Box>
<Box sx={{ mb: 3 }}>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/admin/blog')}
sx={{ mb: 2 }}
>
Back to Blog Posts
</Button>
<Breadcrumbs sx={{ mb: 2 }}>
<Link component={RouterLink} to="/admin" color="inherit">
Admin
</Link>
<Link component={RouterLink} to="/admin/blog" color="inherit">
Blog
</Link>
<Typography color="text.primary">
{isNewPost ? 'Create Post' : 'Edit Post'}
</Typography>
</Breadcrumbs>
<Typography variant="h4" component="h1" gutterBottom>
{isNewPost ? 'Create New Blog Post' : `Edit Blog Post: ${post?.title || ''}`}
</Typography>
</Box>
{/* Form */}
<Paper component="form" onSubmit={handleSubmit} sx={{ p: 3 }}>
{notificationOpen && (
<Alert
severity={notification.type}
sx={{ mb: 3 }}
onClose={() => setNotificationOpen(false)}
>
{notification.message}
</Alert>
)}
<Grid container spacing={3}>
{/* Title */}
<Grid item xs={12}>
<TextField
fullWidth
required
label="Post Title"
name="title"
value={formData.title}
onChange={handleChange}
error={!!errors.title}
helperText={errors.title}
/>
</Grid>
{/* Category */}
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel id="category-label">Category</InputLabel>
<Select
labelId="category-label"
name="categoryId"
value={formData.categoryId}
onChange={handleChange}
label="Category"
>
<MenuItem value="">
<em>None (Uncategorized)</em>
</MenuItem>
{categories?.map((category) => (
<MenuItem key={category.id} value={category.id}>
{category.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* Status */}
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel id="status-label">Status</InputLabel>
<Select
labelId="status-label"
name="status"
value={formData.status}
onChange={handleChange}
label="Status"
>
<MenuItem value="draft">Draft</MenuItem>
<MenuItem value="published">Published</MenuItem>
<MenuItem value="archived">Archived</MenuItem>
</Select>
<FormHelperText>
{formData.status === 'published' ?
'Published posts are visible to all users' :
formData.status === 'draft' ?
'Drafts are only visible to admins' :
'Archived posts are hidden but not deleted'
}
</FormHelperText>
</FormControl>
</Grid>
{/* Publish now option */}
{formData.status === 'published' && !post?.published_at && (
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={formData.publishNow}
onChange={handleSwitchChange}
name="publishNow"
color="primary"
/>
}
label="Publish immediately (sets published date to now)"
/>
</Grid>
)}
{/* Featured Image */}
<Grid item xs={12}>
<Typography variant="h6" gutterBottom>
Featured Image
</Typography>
{formData.featuredImagePath ? (
<Card sx={{ maxWidth: 400, mb: 2 }}>
<CardMedia
component="img"
height="200"
image={imageUtils.getImageUrl(formData.featuredImagePath)}
alt="Featured image"
sx={{ objectFit: 'cover' }}
/>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
startIcon={<DeleteIcon />}
color="error"
onClick={handleClearFeaturedImage}
>
Remove
</Button>
</CardActions>
</Card>
) : (
<Box sx={{ mb: 2 }}>
<ImageUploader
multiple={false}
onChange={handleFeaturedImageChange}
admin={true}
/>
</Box>
)}
</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
fullWidth
multiline
rows={3}
label="Excerpt"
name="excerpt"
value={formData.excerpt}
onChange={handleChange}
placeholder="Write a short summary of your post (optional)"
variant="outlined"
helperText="If left empty, an excerpt will be automatically generated from your content"
/>
</Grid>
{/* Tags */}
<Grid item xs={12}>
<Autocomplete
multiple
freeSolo
options={[]}
value={formData.tags}
onChange={handleTagsChange}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
label={typeof option === 'string' ? option : option.name}
{...getTagProps({ index })}
key={index}
/>
))
}
renderInput={(params) => (
<TextField
{...params}
label="Tags"
placeholder="Add tags and press Enter"
helperText="Tags help users find related content"
/>
)}
/>
</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}
>
Preview
</Button>
<Box>
<Button
variant="outlined"
sx={{ mr: 2 }}
onClick={() => navigate('/admin/blog')}
>
Cancel
</Button>
<Button
type="submit"
variant="contained"
color="primary"
startIcon={createPost.isLoading || updatePost.isLoading ?
<CircularProgress size={20} /> : <SaveIcon />}
disabled={createPost.isLoading || updatePost.isLoading}
>
{createPost.isLoading || updatePost.isLoading ?
'Saving...' : (isNewPost ? 'Create' : 'Update')} Post
</Button>
</Box>
</Grid>
</Grid>
</Paper>
</Box>
);
};
export default BlogEditPage;

View file

@ -0,0 +1,342 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Button,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
TextField,
InputAdornment,
Chip,
CircularProgress,
Alert,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Tooltip
} from '@mui/material';
import {
Edit as EditIcon,
Delete as DeleteIcon,
Add as AddIcon,
Search as SearchIcon,
Clear as ClearIcon,
Visibility as ViewIcon
} from '@mui/icons-material';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useAdminBlogPosts, useDeleteBlogPost } from '@hooks/blogHooks';
import { format } from 'date-fns';
import imageUtils from '@utils/imageUtils';
const AdminBlogPage = () => {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [postToDelete, setPostToDelete] = useState(null);
// Fetch blog posts
const { data: posts, isLoading, error } = useAdminBlogPosts();
const deletePost = useDeleteBlogPost();
// Filter posts based on search term
const filteredPosts = posts ? posts.filter(post =>
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
post.excerpt?.toLowerCase().includes(searchTerm.toLowerCase()) ||
post.category_name?.toLowerCase().includes(searchTerm.toLowerCase())
) : [];
// Handle search input change
const handleSearchChange = (e) => {
setSearchTerm(e.target.value);
};
// Clear search
const handleClearSearch = () => {
setSearchTerm('');
};
// Handle edit post
const handleEditPost = (id) => {
navigate(`/admin/blog/${id}`);
};
// Handle view post
const handleViewPost = (slug) => {
window.open(`/blog/${slug}`, '_blank');
};
// Handle delete dialog open
const handleDeleteClick = (post) => {
setPostToDelete(post);
setDeleteDialogOpen(true);
};
// Handle delete confirmation
const handleConfirmDelete = async () => {
if (postToDelete) {
await deletePost.mutateAsync(postToDelete.id);
setDeleteDialogOpen(false);
setPostToDelete(null);
}
};
// Handle delete cancellation
const handleCancelDelete = () => {
setDeleteDialogOpen(false);
setPostToDelete(null);
};
// Format date
const formatDate = (dateString) => {
if (!dateString) return 'Not published';
return format(new Date(dateString), 'MMM d, yyyy');
};
// Get status chip color
const getStatusColor = (status) => {
switch (status) {
case 'published':
return 'success';
case 'draft':
return 'warning';
case 'archived':
return 'default';
default:
return 'default';
}
};
// Loading state
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (error) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading blog posts: {error.message}
</Alert>
);
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4" component="h1">
Blog Management
</Typography>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
component={RouterLink}
to="/admin/blog/new"
>
Create Post
</Button>
</Box>
{/* Search Box */}
<Paper sx={{ p: 2, mb: 3 }}>
<TextField
fullWidth
placeholder="Search posts by title, excerpt, or category..."
value={searchTerm}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: searchTerm && (
<InputAdornment position="end">
<IconButton size="small" onClick={handleClearSearch}>
<ClearIcon />
</IconButton>
</InputAdornment>
)
}}
/>
</Paper>
{/* Blog Posts Table */}
<Paper>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="50px">Image</TableCell>
<TableCell>Title</TableCell>
<TableCell>Category</TableCell>
<TableCell>Status</TableCell>
<TableCell>Published</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredPosts.length > 0 ? (
filteredPosts.map((post) => (
<TableRow key={post.id}>
<TableCell>
{post.featured_image_path ? (
<Box
component="img"
src={imageUtils.getImageUrl(post.featured_image_path)}
alt={post.title}
sx={{
width: 50,
height: 50,
objectFit: 'cover',
borderRadius: 1
}}
/>
) : (
<Box
sx={{
width: 50,
height: 50,
bgcolor: 'grey.200',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography variant="caption" color="text.secondary">
No img
</Typography>
</Box>
)}
</TableCell>
<TableCell>
<Typography variant="subtitle2" noWrap sx={{ maxWidth: 250 }}>
{post.title}
</Typography>
{post.excerpt && (
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{
display: 'block',
maxWidth: 250
}}
>
{post.excerpt}
</Typography>
)}
</TableCell>
<TableCell>
{post.category_name || 'Uncategorized'}
</TableCell>
<TableCell>
<Chip
label={post.status}
color={getStatusColor(post.status)}
size="small"
/>
</TableCell>
<TableCell>
{formatDate(post.published_at)}
</TableCell>
<TableCell>
{formatDate(post.created_at)}
</TableCell>
<TableCell align="right">
<Tooltip title="View">
<IconButton
color="info"
onClick={() => handleViewPost(post.slug)}
size="small"
>
<ViewIcon />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton
color="primary"
onClick={() => handleEditPost(post.id)}
size="small"
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
color="error"
onClick={() => handleDeleteClick(post)}
size="small"
>
<DeleteIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body1" py={3}>
{searchTerm ? 'No posts match your search.' : 'No blog posts found.'}
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={handleCancelDelete}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">
Confirm Delete
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
Are you sure you want to delete the post <strong>{postToDelete?.title}</strong>?
This action cannot be undone and all comments will be permanently removed.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCancelDelete}>Cancel</Button>
<Button
onClick={handleConfirmDelete}
color="error"
variant="contained"
autoFocus
disabled={deletePost.isLoading}
>
{deletePost.isLoading ? (
<CircularProgress size={24} sx={{ mr: 1 }} />
) : null}
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default AdminBlogPage;

View file

@ -0,0 +1,380 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Button,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
IconButton,
TextField,
InputAdornment,
Chip,
CircularProgress,
Alert,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Tooltip
} from '@mui/material';
import {
Edit as EditIcon,
Delete as DeleteIcon,
Add as AddIcon,
Search as SearchIcon,
Clear as ClearIcon,
History as HistoryIcon
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useAdminCoupons, useDeleteCoupon } from '@hooks/couponAdminHooks';
import { format, isPast, isFuture } from 'date-fns';
const AdminCouponsPage = () => {
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [search, setSearch] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [couponToDelete, setCouponToDelete] = useState(null);
// Fetch coupons
const { data: coupons, isLoading, error } = useAdminCoupons();
// Delete mutation
const deleteCoupon = useDeleteCoupon();
// Filter coupons by search
const filteredCoupons = coupons ? coupons.filter(coupon => {
const searchTerm = search.toLowerCase();
return (
coupon.code.toLowerCase().includes(searchTerm) ||
(coupon.description && coupon.description.toLowerCase().includes(searchTerm))
);
}) : [];
// Paginate coupons
const paginatedCoupons = filteredCoupons.slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage
);
// Handle search change
const handleSearchChange = (event) => {
setSearch(event.target.value);
setPage(0);
};
// Clear search
const handleClearSearch = () => {
setSearch('');
setPage(0);
};
// Handle page change
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
// Handle rows per page change
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
// Handle edit action
const handleEditCoupon = (id) => {
navigate(`/admin/coupons/${id}`);
};
// Handle delete click
const handleDeleteClick = (coupon) => {
setCouponToDelete(coupon);
setDeleteDialogOpen(true);
};
// Confirm delete
const handleConfirmDelete = () => {
if (couponToDelete) {
deleteCoupon.mutate(couponToDelete.id, {
onSuccess: () => {
setDeleteDialogOpen(false);
setCouponToDelete(null);
}
});
}
};
// Cancel delete
const handleCancelDelete = () => {
setDeleteDialogOpen(false);
setCouponToDelete(null);
};
// Navigate to view redemptions
const handleViewRedemptions = (id) => {
navigate(`/admin/coupons/${id}/redemptions`);
};
// Format date
const formatDate = (dateString) => {
if (!dateString) return 'No Date Set';
try {
return format(new Date(dateString), 'MMM d, yyyy');
} catch (error) {
return dateString;
}
};
// Get coupon status
const getCouponStatus = (coupon) => {
if (!coupon.is_active) {
return { label: 'Inactive', color: 'default' };
}
if (coupon.start_date && isFuture(new Date(coupon.start_date))) {
return { label: 'Scheduled', color: 'info' };
}
if (coupon.end_date && isPast(new Date(coupon.end_date))) {
return { label: 'Expired', color: 'error' };
}
if (coupon.redemption_limit !== null && coupon.current_redemptions >= coupon.redemption_limit) {
return { label: 'Fully Redeemed', color: 'warning' };
}
return { label: 'Active', color: 'success' };
};
// Loading state
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (error) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading coupons: {error.message}
</Alert>
);
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4" component="h1">
Coupons & Discounts
</Typography>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
onClick={() => navigate('/admin/coupons/new')}
>
Add Coupon
</Button>
</Box>
{/* Search Box */}
<Paper sx={{ p: 2, mb: 3 }}>
<TextField
fullWidth
placeholder="Search coupons by code or description..."
value={search}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: search && (
<InputAdornment position="end">
<IconButton size="small" onClick={handleClearSearch}>
<ClearIcon />
</IconButton>
</InputAdornment>
)
}}
/>
</Paper>
{/* Coupons Table */}
<Paper>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Code</TableCell>
<TableCell>Type</TableCell>
<TableCell>Value</TableCell>
<TableCell>Status</TableCell>
<TableCell>Redemptions</TableCell>
<TableCell>Valid Period</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedCoupons.length > 0 ? (
paginatedCoupons.map((coupon) => {
const status = getCouponStatus(coupon);
return (
<TableRow key={coupon.id}>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{coupon.code}
</Typography>
<Typography variant="caption" color="text.secondary">
{coupon.description || 'No description'}
</Typography>
</TableCell>
<TableCell>
{coupon.discount_type === 'percentage' ? 'Percentage' : 'Fixed Amount'}
</TableCell>
<TableCell>
{coupon.discount_type === 'percentage'
? `${coupon.discount_value}%`
: `$${parseFloat(coupon.discount_value).toFixed(2)}`}
{coupon.max_discount_amount && (
<Typography variant="caption" display="block" color="text.secondary">
Max: ${parseFloat(coupon.max_discount_amount).toFixed(2)}
</Typography>
)}
</TableCell>
<TableCell>
<Chip
label={status.label}
color={status.color}
size="small"
/>
</TableCell>
<TableCell>
{coupon.redemption_limit ? (
<Typography variant="body2">
{coupon.current_redemptions} / {coupon.redemption_limit}
</Typography>
) : (
<Typography variant="body2">
{coupon.current_redemptions} / Unlimited
</Typography>
)}
</TableCell>
<TableCell>
{coupon.start_date && (
<Typography variant="caption" display="block">
From: {formatDate(coupon.start_date)}
</Typography>
)}
{coupon.end_date ? (
<Typography variant="caption" display="block">
To: {formatDate(coupon.end_date)}
</Typography>
) : (
<Typography variant="caption" display="block">
No End Date
</Typography>
)}
</TableCell>
<TableCell align="right">
<Tooltip title="View Redemption History">
<IconButton
onClick={() => handleViewRedemptions(coupon.id)}
color="primary"
>
<HistoryIcon />
</IconButton>
</Tooltip>
<Tooltip title="Edit Coupon">
<IconButton
onClick={() => handleEditCoupon(coupon.id)}
color="primary"
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete Coupon">
<IconButton
onClick={() => handleDeleteClick(coupon)}
color="error"
>
<DeleteIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body1" py={2}>
{search ? 'No coupons match your search.' : 'No coupons found.'}
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[5, 10, 25, 50]}
component="div"
count={filteredCoupons.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</Paper>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={handleCancelDelete}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">
Confirm Delete
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
Are you sure you want to delete the coupon <strong>{couponToDelete?.code}</strong>?
This action cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCancelDelete}>Cancel</Button>
<Button
onClick={handleConfirmDelete}
color="error"
variant="contained"
autoFocus
disabled={deleteCoupon.isLoading}
>
{deleteCoupon.isLoading ? (
<CircularProgress size={24} sx={{ mr: 1 }} />
) : null}
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default AdminCouponsPage;

View file

@ -0,0 +1,366 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Paper,
Tabs,
Tab,
Card,
CardContent,
CardActions,
Button,
Divider,
Chip,
CircularProgress,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
TextField,
InputAdornment,
IconButton,
Tooltip,
Rating
} from '@mui/material';
import {
Check as ApproveIcon,
Delete as DeleteIcon,
Search as SearchIcon,
Clear as ClearIcon,
Visibility as ViewIcon,
Refresh as RefreshIcon
} from '@mui/icons-material';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { usePendingReviews, useApproveReview, useDeleteReview } from '@hooks/productReviewHooks';
import { format } from 'date-fns';
import { useQueryClient } from '@tanstack/react-query';
const AdminProductReviewsPage = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [reviewToDelete, setReviewToDelete] = useState(null);
const [isRefreshing, setIsRefreshing] = useState(false);
// Fetch pending reviews
const { data: pendingReviews, isLoading, error } = usePendingReviews();
const approveReview = useApproveReview();
const deleteReview = useDeleteReview();
// Filter reviews by search term
const filteredReviews = pendingReviews ? pendingReviews.filter(review =>
review.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
review.content?.toLowerCase().includes(searchTerm.toLowerCase()) ||
review.first_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
review.last_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
review.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
review.product_name?.toLowerCase().includes(searchTerm.toLowerCase())
) : [];
// Handle tab change
const handleTabChange = (event, newValue) => {
setActiveTab(newValue);
};
// Handle search input change
const handleSearchChange = (e) => {
setSearchTerm(e.target.value);
};
// Clear search
const handleClearSearch = () => {
setSearchTerm('');
};
// Handle refresh
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await queryClient.invalidateQueries(['pending-reviews']);
// Optional timeout to ensure the refresh button animation is visible
setTimeout(() => {
setIsRefreshing(false);
}, 500);
} catch (error) {
console.error('Error refreshing reviews:', error);
setIsRefreshing(false);
}
};
// Handle view product
const handleViewProduct = (productId) => {
window.open(`/products/${productId}`, '_blank');
};
// Handle approve review
const handleApproveReview = async (id) => {
try {
await approveReview.mutateAsync(id);
} catch (error) {
console.error('Error approving review:', error);
}
};
// Handle delete dialog open
const handleDeleteClick = (review) => {
setReviewToDelete(review);
setDeleteDialogOpen(true);
};
// Handle delete confirmation
const handleConfirmDelete = async () => {
if (reviewToDelete) {
await deleteReview.mutateAsync(reviewToDelete.id);
setDeleteDialogOpen(false);
setReviewToDelete(null);
}
};
// Handle delete cancellation
const handleCancelDelete = () => {
setDeleteDialogOpen(false);
setReviewToDelete(null);
};
// Format date
const formatDate = (dateString) => {
return format(new Date(dateString), 'MMM d, yyyy h:mm a');
};
// Loading state
if (isLoading && !isRefreshing) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (error) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading reviews: {error.message}
</Alert>
);
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" component="h1">
Product Reviews
</Typography>
<Tooltip title="Refresh reviews">
<IconButton
onClick={handleRefresh}
color="primary"
disabled={isRefreshing}
>
{isRefreshing ? (
<CircularProgress size={24} />
) : (
<RefreshIcon />
)}
</IconButton>
</Tooltip>
</Box>
<Tabs
value={activeTab}
onChange={handleTabChange}
indicatorColor="primary"
textColor="primary"
variant="fullWidth"
sx={{ mb: 3 }}
>
<Tab label={`Pending Approval (${pendingReviews?.length || 0})`} />
<Tab label="All Reviews" />
</Tabs>
{/* Search Box */}
<Paper sx={{ p: 2, mb: 3 }}>
<TextField
fullWidth
placeholder="Search reviews by content, author, or product..."
value={searchTerm}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: searchTerm && (
<InputAdornment position="end">
<IconButton size="small" onClick={handleClearSearch}>
<ClearIcon />
</IconButton>
</InputAdornment>
)
}}
/>
</Paper>
{/* Reviews List */}
{filteredReviews.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h6" gutterBottom>
No pending reviews found
</Typography>
<Typography variant="body2" color="text.secondary">
{searchTerm ? 'Try adjusting your search terms' : 'All reviews have been approved'}
</Typography>
<Button
startIcon={<RefreshIcon />}
variant="outlined"
onClick={handleRefresh}
sx={{ mt: 2 }}
disabled={isRefreshing}
>
Refresh
</Button>
</Paper>
) : (
<Box>
{filteredReviews.map(review => (
<Card key={review.id} sx={{ mb: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography variant="subtitle1">
{review.first_name} {review.last_name} ({review.email})
</Typography>
<Chip
label={formatDate(review.created_at)}
variant="outlined"
size="small"
/>
{review.is_verified_purchase && (
<Chip
label="Verified Purchase"
color="success"
size="small"
sx={{ ml: 1 }}
/>
)}
</Box>
<Box>
<Chip
label={`Product: ${review.product_name}`}
color="primary"
variant="outlined"
/>
</Box>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="h6">{review.title}</Typography>
{review.rating && (
<Box sx={{ display: 'flex', alignItems: 'center', my: 1 }}>
<Rating value={review.rating} readOnly precision={0.5} />
<Typography variant="body2" sx={{ ml: 1 }}>
({review.rating}/5)
</Typography>
</Box>
)}
<Typography variant="body2" paragraph>
{review.content || <em>No content provided</em>}
</Typography>
{review.parent_id && (
<Alert severity="info" sx={{ mt: 1 }}>
This is a reply to another review
</Alert>
)}
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
startIcon={<ViewIcon />}
onClick={() => handleViewProduct(review.product_id)}
>
View Product
</Button>
<Button
startIcon={<ApproveIcon />}
color="success"
onClick={() => handleApproveReview(review.id)}
disabled={approveReview.isLoading}
>
Approve
</Button>
<Button
startIcon={<DeleteIcon />}
color="error"
onClick={() => handleDeleteClick(review)}
disabled={deleteReview.isLoading}
>
Delete
</Button>
</CardActions>
</Card>
))}
</Box>
)}
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={handleCancelDelete}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">
Confirm Delete
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
Are you sure you want to delete this review? This action cannot be undone.
</DialogContentText>
{reviewToDelete && (
<Paper variant="outlined" sx={{ p: 2, mt: 2, bgcolor: 'background.paper' }}>
<Typography variant="subtitle1">
Review by {reviewToDelete.first_name} {reviewToDelete.last_name}:
</Typography>
<Typography variant="h6" gutterBottom>
{reviewToDelete.title}
</Typography>
{reviewToDelete.rating && (
<Rating value={reviewToDelete.rating} readOnly size="small" />
)}
<Typography variant="body2" sx={{ mt: 1 }}>
{reviewToDelete.content || <em>No content provided</em>}
</Typography>
</Paper>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCancelDelete}>Cancel</Button>
<Button
onClick={handleConfirmDelete}
color="error"
variant="contained"
autoFocus
disabled={deleteReview.isLoading}
>
{deleteReview.isLoading ? (
<CircularProgress size={24} sx={{ mr: 1 }} />
) : null}
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default AdminProductReviewsPage;

View file

@ -0,0 +1,361 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Paper,
Divider,
Chip,
Button,
Avatar,
TextField,
Grid,
Card,
CardContent,
Breadcrumbs,
Link,
CircularProgress,
Alert,
Container
} from '@mui/material';
import { useParams, Link as RouterLink, useNavigate } from 'react-router-dom';
import { useBlogPost, useAddComment } from '@hooks/blogHooks';
import { useAuth } from '@hooks/reduxHooks';
import { format } from 'date-fns';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import CommentIcon from '@mui/icons-material/Comment';
import SendIcon from '@mui/icons-material/Send';
import imageUtils from '@utils/imageUtils';
const BlogDetailPage = () => {
const { slug } = useParams();
const navigate = useNavigate();
const { isAuthenticated, user, userData } = useAuth();
const [comment, setComment] = useState('');
const [replyTo, setReplyTo] = useState(null);
// Fetch blog post
const { data: post, isLoading, error } = useBlogPost(slug);
const addComment = useAddComment();
// Format date
const formatDate = (dateString) => {
if (!dateString) return '';
return format(new Date(dateString), 'MMMM d, yyyy');
};
// Handle comment submission
const handleSubmitComment = async (e) => {
e.preventDefault();
if (!comment.trim()) return;
try {
await addComment.mutateAsync({
postId: post.id,
commentData: {
userId: userData.id,
content: comment,
parentId: replyTo ? replyTo.id : null
}
});
// Reset comment form
setComment('');
setReplyTo(null);
} catch (error) {
console.error('Error submitting comment:', error);
}
};
// Loading state
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 8 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (error) {
return (
<Alert severity="error" sx={{ my: 4 }}>
Error loading blog post: {error.message}
</Alert>
);
}
// If post isn't found
if (!post) {
return (
<Alert severity="warning" sx={{ my: 4 }}>
Blog post not found. The post may have been removed or the URL is incorrect.
</Alert>
);
}
// Comments component
const renderComment = (comment, level = 0) => (
<Box key={comment.id} sx={{ ml: level * 3, mb: 2 }}>
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Avatar sx={{ mr: 1 }}>
{comment.first_name ? comment.first_name[0] : '?'}
</Avatar>
<Box>
<Typography variant="subtitle2">
{comment.first_name} {comment.last_name}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(comment.created_at)}
</Typography>
</Box>
</Box>
<Typography variant="body2" paragraph>
{comment.content}
</Typography>
{isAuthenticated && (
<Button
size="small"
startIcon={<CommentIcon />}
onClick={() => setReplyTo(comment)}
>
Reply
</Button>
)}
</CardContent>
</Card>
{/* Render replies */}
{comment.replies && comment.replies.map(reply => renderComment(reply, level + 1))}
</Box>
);
return (
<Container maxWidth="lg">
<Box sx={{ py: 4 }}>
{/* Breadcrumbs */}
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link component={RouterLink} to="/" color="inherit">
Home
</Link>
<Link component={RouterLink} to="/blog" color="inherit">
Blog
</Link>
<Typography color="text.primary">
{post.title}
</Typography>
</Breadcrumbs>
{/* Post header */}
<Paper sx={{ p: 3, mb: 4 }}>
{/* Category */}
{post.category_name && (
<Chip
label={post.category_name}
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>
By {post.author_first_name} {post.author_last_name} {formatDate(post.published_at)}
</Typography>
{/* Tags */}
<Box sx={{ mt: 2 }}>
{post.tags && post.tags.filter(Boolean).map((tag, index) => (
<Chip
key={index}
label={tag}
size="small"
component={RouterLink}
to={`/blog?tag=${tag}`}
clickable
sx={{ mr: 1, mb: 1 }}
/>
))}
</Box>
</Paper>
{/* Featured image */}
{post.featured_image_path && (
<Box sx={{ mb: 4, textAlign: 'center' }}>
<img
src={imageUtils.getImageUrl(post.featured_image_path)}
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>
{/* 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 */}
<Paper sx={{ p: 3 }}>
<Typography variant="h5" component="h2" gutterBottom>
Comments ({post.comments ? post.comments.length : 0})
</Typography>
<Divider sx={{ mb: 3 }} />
{/* Comment form */}
{isAuthenticated ? (
<Box component="form" onSubmit={handleSubmitComment} sx={{ mb: 4 }}>
<Typography variant="subtitle1" gutterBottom>
{replyTo
? `Reply to ${replyTo.first_name}'s comment`
: 'Leave a comment'}
</Typography>
{replyTo && (
<Box sx={{ mb: 2 }}>
<Chip
label={`Cancel reply to ${replyTo.first_name}`}
onDelete={() => setReplyTo(null)}
variant="outlined"
/>
</Box>
)}
<TextField
fullWidth
multiline
rows={4}
placeholder="Write your comment here..."
value={comment}
onChange={(e) => setComment(e.target.value)}
variant="outlined"
sx={{ mb: 2 }}
/>
<Button
type="submit"
variant="contained"
color="primary"
endIcon={<SendIcon />}
disabled={!comment.trim() || addComment.isLoading}
>
{addComment.isLoading ? 'Submitting...' : 'Submit Comment'}
</Button>
{addComment.isSuccess && (
<Alert severity="success" sx={{ mt: 2 }}>
Your comment has been submitted and is awaiting approval.
</Alert>
)}
</Box>
) : (
<Alert severity="info" sx={{ mb: 3 }}>
Please <Link component={RouterLink} to="/auth/login" state={{ from: `/blog/${slug}` }}>log in</Link> to leave a comment.
</Alert>
)}
{/* Comments list */}
{post.comments && post.comments.length > 0 ? (
<Box>
{post.comments.map(comment => renderComment(comment))}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
No comments yet. Be the first to comment!
</Typography>
)}
</Paper>
</Box>
</Container>
);
};
export default BlogDetailPage;

View file

@ -0,0 +1,312 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Grid,
Card,
CardMedia,
CardContent,
CardActions,
Button,
Chip,
TextField,
InputAdornment,
IconButton,
Divider,
Pagination,
MenuItem,
Select,
FormControl,
InputLabel,
CircularProgress,
Alert,
Container
} from '@mui/material';
import { useNavigate, useLocation, Link as RouterLink } from 'react-router-dom';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import { useBlogPosts, useBlogCategories } from '@hooks/blogHooks';
import { format } from 'date-fns';
import imageUtils from '@utils/imageUtils';
const BlogPage = () => {
const navigate = useNavigate();
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
// State for filters and search
const [filters, setFilters] = useState({
category: searchParams.get('category') || '',
tag: searchParams.get('tag') || '',
search: searchParams.get('search') || '',
page: parseInt(searchParams.get('page') || '1'),
});
// Fetch blog posts
const { data, isLoading, error } = useBlogPosts(filters);
const { data: categories } = useBlogCategories();
// Update URL when filters change
useEffect(() => {
const params = new URLSearchParams();
if (filters.category) params.set('category', filters.category);
if (filters.tag) params.set('tag', filters.tag);
if (filters.search) params.set('search', filters.search);
if (filters.page > 1) params.set('page', filters.page.toString());
navigate(`/blog${params.toString() ? `?${params.toString()}` : ''}`, { replace: true });
}, [filters, navigate]);
// Handle search input
const handleSearchChange = (e) => {
setFilters({ ...filters, search: e.target.value, page: 1 });
};
// Clear search
const handleClearSearch = () => {
setFilters({ ...filters, search: '', page: 1 });
};
// Handle category change
const handleCategoryChange = (e) => {
setFilters({ ...filters, category: e.target.value, page: 1 });
};
// Handle tag click
const handleTagClick = (tag) => {
setFilters({ ...filters, tag, page: 1 });
};
// Clear tag filter
const handleClearTag = () => {
setFilters({ ...filters, tag: '', page: 1 });
};
// Handle pagination
const handlePageChange = (event, value) => {
setFilters({ ...filters, page: value });
// Scroll to top when page changes
window.scrollTo(0, 0);
};
// Format date for display
const formatPublishedDate = (dateString) => {
if (!dateString) return '';
return format(new Date(dateString), 'MMMM d, yyyy');
};
// Loading state
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 8 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (error) {
return (
<Alert severity="error" sx={{ my: 4 }}>
Error loading blog posts: {error.message}
</Alert>
);
}
// Empty state
const posts = data?.posts || [];
const pagination = data?.pagination || { page: 1, pages: 1, total: 0 };
return (
<Container maxWidth="lg">
<Box sx={{ py: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Our Blog
</Typography>
<Typography variant="subtitle1" color="text.secondary" paragraph>
Discover insights about our natural collections, sourcing adventures, and unique specimens
</Typography>
{/* Filters and Search */}
<Grid container spacing={2} sx={{ mb: 4, mt: 2 }}>
{/* Category filter */}
<Grid item xs={12} md={4}>
<FormControl fullWidth variant="outlined">
<InputLabel id="category-filter-label">Filter by Category</InputLabel>
<Select
labelId="category-filter-label"
value={filters.category}
onChange={handleCategoryChange}
label="Filter by Category"
>
<MenuItem value="">All Categories</MenuItem>
{categories?.map((category) => (
<MenuItem key={category.id} value={category.name}>
{category.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* Tag filter */}
<Grid item xs={12} md={8}>
{filters.tag && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2" sx={{ mr: 1 }}>
Filtered by tag:
</Typography>
<Chip
label={filters.tag}
onDelete={handleClearTag}
color="primary"
size="small"
/>
</Box>
)}
</Grid>
{/* Search */}
<Grid item xs={12}>
<TextField
fullWidth
placeholder="Search blog posts..."
value={filters.search}
onChange={handleSearchChange}
variant="outlined"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: filters.search && (
<InputAdornment position="end">
<IconButton size="small" onClick={handleClearSearch}>
<ClearIcon />
</IconButton>
</InputAdornment>
)
}}
/>
</Grid>
</Grid>
{/* No results message */}
{posts.length === 0 && (
<Box sx={{ textAlign: 'center', my: 8 }}>
<Typography variant="h6" gutterBottom>
No blog posts found
</Typography>
<Typography variant="body1" color="text.secondary">
{filters.search || filters.category || filters.tag
? 'Try adjusting your filters or search terms'
: 'Check back soon for new content'}
</Typography>
</Box>
)}
{/* Blog post grid */}
<Grid container spacing={4}>
{posts.map((post) => (
<Grid item xs={12} sm={6} md={4} key={post.id}>
<Card sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: 6
}
}}>
{/* Featured image */}
<CardMedia
component="img"
height="200"
image={post.featured_image_path
? imageUtils.getImageUrl(post.featured_image_path)
: '/images/placeholder.jpg'}
alt={post.title}
/>
<CardContent sx={{ flexGrow: 1 }}>
{/* Category */}
{post.category_name && (
<Chip
label={post.category_name}
size="small"
color="primary"
sx={{ mb: 1 }}
/>
)}
{/* Title */}
<Typography variant="h5" component="h2" gutterBottom>
{post.title}
</Typography>
{/* Published date */}
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>
{formatPublishedDate(post.published_at)}
</Typography>
{/* Excerpt */}
<Typography variant="body2" color="text.secondary" paragraph>
{post.excerpt || (post.content && post.content.substring(0, 150) + '...')}
</Typography>
{/* Tags */}
<Box sx={{ mt: 2, mb: 1 }}>
{post.tags && post.tags.filter(Boolean).map((tag, index) => (
<Chip
key={index}
label={tag}
size="small"
onClick={() => handleTagClick(tag)}
sx={{ mr: 0.5, mb: 0.5 }}
/>
))}
</Box>
</CardContent>
<CardActions>
<Button
size="small"
component={RouterLink}
to={`/blog/${post.slug}`}
>
Read More
</Button>
<Box sx={{ ml: 'auto' }}>
<Chip
label={`${post.comment_count || 0} comments`}
size="small"
variant="outlined"
/>
</Box>
</CardActions>
</Card>
</Grid>
))}
</Grid>
{/* Pagination */}
{pagination.pages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
<Pagination
count={pagination.pages}
page={pagination.page}
onChange={handlePageChange}
color="primary"
siblingCount={1}
/>
</Box>
)}
</Box>
</Container>
);
};
export default BlogPage;

View file

@ -24,7 +24,7 @@ import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useAuth } from '@hooks/reduxHooks';
import { useGetCart, useUpdateCartItem, useClearCart, useProduct } from '@hooks/apiHooks';
import imageUtils from '@utils/imageUtils';
import CouponInput from '@components/CouponInput';
const CartPage = () => {
const navigate = useNavigate();
const { user } = useAuth();
@ -264,6 +264,9 @@ const CartPage = () => {
{/* Order summary */}
<Grid item xs={12} lg={4}>
{/* Coupon Input */}
<CouponInput />
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Order Summary
@ -278,10 +281,26 @@ const CartPage = () => {
</Grid>
<Grid item xs={4} sx={{ textAlign: 'right' }}>
<Typography variant="body1">
${cart.total.toFixed(2)}
${cart.subtotal?.toFixed(2) || cart.total.toFixed(2)}
</Typography>
</Grid>
{/* Display discount if coupon is applied */}
{cart.couponDiscount > 0 && (
<>
<Grid item xs={8}>
<Typography variant="body1" color="success.main">
Discount ({cart.couponCode})
</Typography>
</Grid>
<Grid item xs={4} sx={{ textAlign: 'right' }}>
<Typography variant="body1" color="success.main">
-${cart.couponDiscount.toFixed(2)}
</Typography>
</Grid>
</>
)}
<Grid item xs={8}>
<Typography variant="body1">
Shipping

View file

@ -0,0 +1,596 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
TextField,
MenuItem,
Select,
FormControl,
InputLabel,
FormHelperText,
Switch,
FormControlLabel,
Grid,
Button,
IconButton,
CircularProgress,
Alert,
Divider,
InputAdornment,
Autocomplete,
Chip,
Breadcrumbs,
Link
} from '@mui/material';
import {
ArrowBack as ArrowBackIcon,
Save as SaveIcon,
Clear as ClearIcon
} from '@mui/icons-material';
import { useNavigate, useParams, Link as RouterLink } from 'react-router-dom';
import { useAdminCoupon, useCreateCoupon, useUpdateCoupon } from '@hooks/couponAdminHooks';
import { useCategories, useTags } from '@hooks/apiHooks';
import { format, parseISO } from 'date-fns';
const CouponEditPage = () => {
const navigate = useNavigate();
const { id } = useParams();
const isNewCoupon = !id;
// Form state
const [formData, setFormData] = useState({
code: '',
description: '',
discountType: 'percentage', // 'percentage' or 'fixed_amount'
discountValue: '',
minPurchaseAmount: '',
maxDiscountAmount: '',
redemptionLimit: '',
startDate: null,
endDate: null,
isActive: true,
categories: [],
tags: [],
blacklistedProducts: []
});
// Validation state
const [errors, setErrors] = useState({});
// Notification state
const [notification, setNotification] = useState({
open: false,
message: '',
severity: 'success'
});
// Fetch coupon data for editing
const { data: coupon, isLoading: couponLoading, error: couponError } = useAdminCoupon(
isNewCoupon ? null : id
);
// Mutations
const createCoupon = useCreateCoupon();
const updateCoupon = useUpdateCoupon();
// Fetch categories and tags
const { data: categories } = useCategories();
const { data: tags } = useTags();
// Set form data when editing existing coupon
useEffect(() => {
if (!isNewCoupon && coupon) {
setFormData({
code: coupon.code || '',
description: coupon.description || '',
discountType: coupon.discount_type || 'percentage',
discountValue: coupon.discount_value?.toString() || '',
minPurchaseAmount: coupon.min_purchase_amount?.toString() || '',
maxDiscountAmount: coupon.max_discount_amount?.toString() || '',
redemptionLimit: coupon.redemption_limit?.toString() || '',
startDate: coupon.start_date ? parseISO(coupon.start_date) : null,
endDate: coupon.end_date ? parseISO(coupon.end_date) : null,
isActive: coupon.is_active ?? true,
categories: coupon.categories || [],
tags: coupon.tags || [],
blacklistedProducts: coupon.blacklisted_products || []
});
}
}, [isNewCoupon, coupon]);
// Handle form changes
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
// Clear validation error when field is edited
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: null }));
}
};
// Handle date changes
const handleDateChange = (name, date) => {
setFormData(prev => ({
...prev,
[name]: date
}));
// Clear validation error when field is edited
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: null }));
}
};
// Handle categories change
const handleCategoriesChange = (event, newValue) => {
setFormData(prev => ({
...prev,
categories: newValue
}));
};
// Handle tags change
const handleTagsChange = (event, newValue) => {
setFormData(prev => ({
...prev,
tags: newValue
}));
};
// Validate form
const validateForm = () => {
const newErrors = {};
// Required fields
if (!formData.code) {
newErrors.code = 'Coupon code is required';
} else if (!/^[A-Za-z0-9_-]+$/.test(formData.code)) {
newErrors.code = 'Code can only contain letters, numbers, underscores, and hyphens';
}
if (!formData.discountType) {
newErrors.discountType = 'Discount type is required';
}
if (!formData.discountValue) {
newErrors.discountValue = 'Discount value is required';
} else if (isNaN(formData.discountValue) || parseFloat(formData.discountValue) <= 0) {
newErrors.discountValue = 'Discount value must be a positive number';
} else if (formData.discountType === 'percentage' && parseFloat(formData.discountValue) > 100) {
newErrors.discountValue = 'Percentage discount cannot exceed 100%';
}
// Optional numeric fields
if (formData.minPurchaseAmount && (isNaN(formData.minPurchaseAmount) || parseFloat(formData.minPurchaseAmount) < 0)) {
newErrors.minPurchaseAmount = 'Minimum purchase amount must be a non-negative number';
}
if (formData.maxDiscountAmount && (isNaN(formData.maxDiscountAmount) || parseFloat(formData.maxDiscountAmount) <= 0)) {
newErrors.maxDiscountAmount = 'Maximum discount amount must be a positive number';
}
if (formData.redemptionLimit && (isNaN(formData.redemptionLimit) || parseInt(formData.redemptionLimit) <= 0 || !Number.isInteger(parseFloat(formData.redemptionLimit)))) {
newErrors.redemptionLimit = 'Redemption limit must be a positive integer';
}
// Date validation
if (formData.startDate && formData.endDate && formData.startDate > formData.endDate) {
newErrors.endDate = 'End date must be after start date';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle form submission
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
setNotification({
open: true,
message: 'Please fix the form errors before submitting',
severity: 'error'
});
return;
}
// Format date fields
const formattedData = {
...formData,
code: formData.code.toUpperCase(),
discountValue: parseFloat(formData.discountValue),
minPurchaseAmount: formData.minPurchaseAmount ? parseFloat(formData.minPurchaseAmount) : null,
maxDiscountAmount: formData.maxDiscountAmount ? parseFloat(formData.maxDiscountAmount) : null,
redemptionLimit: formData.redemptionLimit ? parseInt(formData.redemptionLimit) : null,
startDate: formData.startDate ? format(formData.startDate, "yyyy-MM-dd'T'HH:mm:ss") : null,
endDate: formData.endDate ? format(formData.endDate, "yyyy-MM-dd'T'HH:mm:ss") : null,
// Format arrays for API
categories: formData.categories.map(cat => cat.id),
tags: formData.tags.map(tag => tag.id),
blacklistedProducts: formData.blacklistedProducts.map(prod => prod.id)
};
try {
if (isNewCoupon) {
await createCoupon.mutateAsync(formattedData);
setNotification({
open: true,
message: 'Coupon created successfully',
severity: 'success'
});
// Navigate after successful creation
setTimeout(() => {
navigate('/admin/coupons');
}, 1500);
} else {
await updateCoupon.mutateAsync({
id,
couponData: formattedData
});
setNotification({
open: true,
message: 'Coupon updated successfully',
severity: 'success'
});
}
} catch (error) {
setNotification({
open: true,
message: error.message || 'Error saving coupon',
severity: 'error'
});
}
};
// Handle notification close
const handleNotificationClose = () => {
setNotification(prev => ({ ...prev, open: false }));
};
// Loading state
if ((!isNewCoupon && couponLoading) || (!categories || !tags)) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (!isNewCoupon && couponError) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading coupon: {couponError.message}
</Alert>
);
}
return (
<Box>
<Box sx={{ mb: 3 }}>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/admin/coupons')}
sx={{ mb: 2 }}
>
Back to Coupons
</Button>
<Breadcrumbs sx={{ mb: 2 }}>
<Link component={RouterLink} to="/admin" color="inherit">
Admin
</Link>
<Link component={RouterLink} to="/admin/coupons" color="inherit">
Coupons
</Link>
<Typography color="text.primary">
{isNewCoupon ? 'Create Coupon' : 'Edit Coupon'}
</Typography>
</Breadcrumbs>
<Typography variant="h4" component="h1" gutterBottom>
{isNewCoupon ? 'Create New Coupon' : `Edit Coupon: ${coupon?.code || "Enter Code"}`}
</Typography>
</Box>
{/* Form */}
<Paper component="form" onSubmit={handleSubmit} sx={{ p: 3 }}>
{notification.open && (
<Alert
severity={notification.severity}
sx={{ mb: 3 }}
onClose={handleNotificationClose}
>
{notification.message}
</Alert>
)}
<Grid container spacing={3}>
{/* Basic Information */}
<Grid item xs={12}>
<Typography variant="h6" gutterBottom>
Basic Information
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
required
label="Coupon Code"
name="code"
value={formData.code}
onChange={handleChange}
error={!!errors.code}
helperText={errors.code || 'Use uppercase letters, numbers, and hyphens'}
inputProps={{ style: { textTransform: 'uppercase' } }}
disabled={!isNewCoupon} // Cannot edit code for existing coupons
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControlLabel
control={
<Switch
checked={formData.isActive}
onChange={handleChange}
name="isActive"
color="primary"
/>
}
label={formData.isActive ? 'Coupon is Active' : 'Coupon is Inactive'}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Description"
name="description"
value={formData.description}
onChange={handleChange}
multiline
rows={2}
placeholder="Optional description of the coupon and its usage"
/>
</Grid>
{/* Discount Settings */}
<Grid item xs={12}>
<Divider sx={{ my: 1 }} />
<Typography variant="h6" gutterBottom>
Discount Settings
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth required error={!!errors.discountType}>
<InputLabel id="discount-type-label">Discount Type</InputLabel>
<Select
labelId="discount-type-label"
name="discountType"
value={formData.discountType}
label="Discount Type"
onChange={handleChange}
>
<MenuItem value="percentage">Percentage</MenuItem>
<MenuItem value="fixed_amount">Fixed Amount</MenuItem>
</Select>
{errors.discountType && <FormHelperText>{errors.discountType}</FormHelperText>}
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
required
label="Discount Value"
name="discountValue"
type="number"
value={formData.discountValue}
onChange={handleChange}
error={!!errors.discountValue}
helperText={errors.discountValue}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{formData.discountType === 'percentage' ? '%' : ''}
</InputAdornment>
),
}}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Minimum Purchase Amount"
name="minPurchaseAmount"
type="number"
value={formData.minPurchaseAmount}
onChange={handleChange}
error={!!errors.minPurchaseAmount}
helperText={errors.minPurchaseAmount || 'Minimum cart total to use this coupon (optional)'}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Maximum Discount Amount"
name="maxDiscountAmount"
type="number"
value={formData.maxDiscountAmount}
onChange={handleChange}
error={!!errors.maxDiscountAmount}
helperText={errors.maxDiscountAmount || 'Maximum discount for percentage coupons (optional)'}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
disabled={formData.discountType !== 'percentage'}
/>
</Grid>
{/* Validity and Usage */}
<Grid item xs={12}>
<Divider sx={{ my: 1 }} />
<Typography variant="h6" gutterBottom>
Validity and Usage Limits
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Start Date (Optional)"
name="startDate"
type="date"
value={formData.startDate}
onChange={handleChange}
error={!!errors.startDate}
helperText={errors.startDate || 'When this coupon becomes valid'}
InputLabelProps={{
shrink: true,
}}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="End Date (Optional)"
name="endDate"
type="date"
value={formData.endDate}
onChange={handleChange}
error={!!errors.endDate}
helperText={errors.endDate || 'When this coupon expires'}
InputLabelProps={{
shrink: true,
}}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Redemption Limit"
name="redemptionLimit"
type="number"
value={formData.redemptionLimit}
onChange={handleChange}
error={!!errors.redemptionLimit}
helperText={errors.redemptionLimit || 'How many times this coupon can be used (optional, leave empty for unlimited)'}
/>
</Grid>
{/* Applicable Categories and Tags */}
<Grid item xs={12}>
<Divider sx={{ my: 1 }} />
<Typography variant="h6" gutterBottom>
Applicable Products
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Specify which categories or tags this coupon applies to. If none selected, coupon applies to all products.
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<Autocomplete
multiple
options={categories || []}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={formData.categories}
onChange={handleCategoriesChange}
renderInput={(params) => (
<TextField
{...params}
label="Applicable Categories"
placeholder="Select categories"
/>
)}
renderTags={(selected, getTagProps) =>
selected.map((category, index) => (
<Chip
label={category.name}
{...getTagProps({ index })}
key={category.id}
/>
))
}
/>
</Grid>
<Grid item xs={12} md={6}>
<Autocomplete
multiple
options={tags || []}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={formData.tags}
onChange={handleTagsChange}
renderInput={(params) => (
<TextField
{...params}
label="Applicable Tags"
placeholder="Select tags"
/>
)}
renderTags={(selected, getTagProps) =>
selected.map((tag, index) => (
<Chip
label={tag.name}
{...getTagProps({ index })}
key={tag.id}
/>
))
}
/>
</Grid>
{/* Submit Button */}
<Grid item xs={12} sx={{ mt: 3 }}>
<Button
type="submit"
variant="contained"
color="primary"
startIcon={
createCoupon.isLoading || updateCoupon.isLoading
? <CircularProgress size={24} color="inherit" />
: <SaveIcon />
}
disabled={createCoupon.isLoading || updateCoupon.isLoading}
>
{isNewCoupon ? 'Create Coupon' : 'Update Coupon'}
</Button>
<Button
variant="outlined"
sx={{ ml: 2 }}
onClick={() => navigate('/admin/coupons')}
>
Cancel
</Button>
</Grid>
</Grid>
</Paper>
</Box>
);
};
export default CouponEditPage;

View file

@ -0,0 +1,215 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
IconButton,
Chip,
CircularProgress,
Alert,
Button,
Breadcrumbs,
Link
} from '@mui/material';
import { useParams, useNavigate, Link as RouterLink } from 'react-router-dom';
import { ArrowBack as ArrowBackIcon } from '@mui/icons-material';
import { useCouponRedemptions, useAdminCoupon } from '@hooks/couponAdminHooks';
import { format } from 'date-fns';
const CouponRedemptionsPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
// Fetch coupon details
const { data: coupon, isLoading: couponLoading, error: couponError } = useAdminCoupon(id);
// Fetch redemption history
const { data: redemptions, isLoading: redemptionsLoading, error: redemptionsError } = useCouponRedemptions(id);
// Handle page change
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
// Handle rows per page change
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
// Format date
const formatDate = (dateString) => {
if (!dateString) return '';
try {
return format(new Date(dateString), 'MMM d, yyyy h:mm a');
} catch (error) {
return dateString;
}
};
// Loading state
const isLoading = couponLoading || redemptionsLoading;
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
const error = couponError || redemptionsError;
if (error) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading data: {error.message}
</Alert>
);
}
// Paginate redemptions
const paginatedRedemptions = redemptions ? redemptions.slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage
) : [];
return (
<Box>
<Box sx={{ mb: 3 }}>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/admin/coupons')}
sx={{ mb: 2 }}
>
Back to Coupons
</Button>
<Breadcrumbs sx={{ mb: 2 }}>
<Link component={RouterLink} to="/admin" color="inherit">
Admin
</Link>
<Link component={RouterLink} to="/admin/coupons" color="inherit">
Coupons
</Link>
<Typography color="text.primary">Redemptions</Typography>
</Breadcrumbs>
<Typography variant="h4" component="h1" gutterBottom>
Redemption History: {coupon?.code}
</Typography>
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Coupon Details
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
<Box>
<Typography variant="subtitle2" color="text.secondary">Code</Typography>
<Typography variant="body1">{coupon?.code}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Discount</Typography>
<Typography variant="body1">
{coupon?.discount_type === 'percentage'
? `${coupon.discount_value}%`
: `$${parseFloat(coupon.discount_value).toFixed(2)}`}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Usage</Typography>
<Typography variant="body1">
{coupon?.current_redemptions} / {coupon?.redemption_limit || 'Unlimited'}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Status</Typography>
<Chip
label={coupon?.is_active ? 'Active' : 'Inactive'}
color={coupon?.is_active ? 'success' : 'default'}
size="small"
/>
</Box>
</Box>
</Paper>
</Box>
{/* Redemptions Table */}
<Paper>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Customer</TableCell>
<TableCell>Date</TableCell>
<TableCell>Order #</TableCell>
<TableCell align="right">Order Total</TableCell>
<TableCell align="right">Discount Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedRedemptions.length > 0 ? (
paginatedRedemptions.map((redemption) => (
<TableRow key={redemption.id}>
<TableCell>
<Typography variant="body2">
{redemption.first_name} {redemption.last_name}
</Typography>
<Typography variant="caption" color="text.secondary">
{redemption.email}
</Typography>
</TableCell>
<TableCell>{formatDate(redemption.redeemed_at)}</TableCell>
<TableCell>
<Link
component={RouterLink}
to={`/admin/orders/${redemption.order_id}`}
>
{redemption.order_id.substring(0, 8)}...
</Link>
</TableCell>
<TableCell align="right">
${parseFloat(redemption.total_amount).toFixed(2)}
</TableCell>
<TableCell align="right">
${parseFloat(redemption.discount_amount).toFixed(2)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography variant="body1" py={2}>
No redemptions found for this coupon.
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{redemptions && redemptions.length > 0 && (
<TablePagination
rowsPerPageOptions={[5, 10, 25, 50]}
component="div"
count={redemptions.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
)}
</Paper>
</Box>
);
};
export default CouponRedemptionsPage;

View file

@ -26,9 +26,11 @@ import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import AddIcon from '@mui/icons-material/Add';
import RemoveIcon from '@mui/icons-material/Remove';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { Rating } from '@mui/material';
import ProductReviews from '@components/ProductReviews';
import { Link as RouterLink } from 'react-router-dom';
import { useProduct, useAddToCart } from '../hooks/apiHooks';
import { useAuth } from '../hooks/reduxHooks';
import { useProduct, useAddToCart } from '@hooks/apiHooks';
import { useAuth } from '@hooks/reduxHooks';
import imageUtils from '@utils/imageUtils';
const ProductDetailPage = () => {
@ -173,6 +175,26 @@ const ProductDetailPage = () => {
{product.category_name}
</Link>
<Typography color="text.primary">{product.name}</Typography>
{product.average_rating && (
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1, mb: 2 }}>
<Rating
value={product.average_rating}
readOnly
precision={0.5}
/>
<Typography variant="body1" color="text.secondary" sx={{ ml: 1 }}>
{product.average_rating} ({product.review_count} {product.review_count === 1 ? 'review' : 'reviews'})
</Typography>
</Box>
)}
{!product.average_rating && (
<Box sx={{ mt: 1, mb: 2 }}>
<Typography variant="body2" color="text.secondary">
No reviews yet
</Typography>
</Box>
)}
</Breadcrumbs>
<Grid container spacing={4}>
@ -349,6 +371,10 @@ const ProductDetailPage = () => {
))}
</TableBody>
</Table>
<Grid item xs={12}>
<Divider sx={{ my: 4 }} />
<ProductReviews productId={id} />
</Grid>
</TableContainer>
</Grid>
</Grid>

View file

@ -31,8 +31,9 @@ import SortIcon from '@mui/icons-material/Sort';
import CloseIcon from '@mui/icons-material/Close';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import { Link as RouterLink, useNavigate, useLocation } from 'react-router-dom';
import { useProducts, useCategories, useTags, useAddToCart } from '../hooks/apiHooks';
import { useAuth } from '../hooks/reduxHooks';
import { useProducts, useCategories, useTags, useAddToCart } from '@hooks/apiHooks';
import ProductRatingDisplay from '@components/ProductRatingDisplay';
import { useAuth } from '@hooks/reduxHooks';
import imageUtils from '@utils/imageUtils';
const ProductsPage = () => {
@ -362,7 +363,10 @@ const ProductsPage = () => {
>
{product.name}
</Typography>
<ProductRatingDisplay
rating={product.average_rating}
reviewCount={product.review_count}
/>
<Typography
variant="body2"
color="text.secondary"

View file

@ -0,0 +1,160 @@
import apiClient from './api';
const blogAdminService = {
/**
* Get all blog posts (admin)
* @returns {Promise} Promise with the API response
*/
getAllPosts: async () => {
try {
const response = await apiClient.get('/admin/blog');
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get a single blog post for editing (admin)
* @param {string} id - Blog post ID
* @returns {Promise} Promise with the API response
*/
getPostById: async (id) => {
try {
const response = await apiClient.get(`/admin/blog/${id}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Create a new blog post (admin)
* @param {Object} postData - Blog post data
* @returns {Promise} Promise with the API response
*/
createPost: async (postData) => {
try {
const response = await apiClient.post('/admin/blog', postData);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Update a blog post (admin)
* @param {string} id - Blog post ID
* @param {Object} postData - Updated blog post data
* @returns {Promise} Promise with the API response
*/
updatePost: async (id, postData) => {
try {
const response = await apiClient.put(`/admin/blog/${id}`, postData);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Delete a blog post (admin)
* @param {string} id - Blog post ID
* @returns {Promise} Promise with the API response
*/
deletePost: async (id) => {
try {
const response = await apiClient.delete(`/admin/blog/${id}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Upload an image for a blog post (admin)
* @param {string} postId - Blog post ID
* @param {Object} imageData - Image data to upload
* @returns {Promise} Promise with the API response
*/
uploadImage: async (postId, imageData) => {
try {
const response = await apiClient.post(`/admin/blog/${postId}/images`, imageData);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Delete an image from a blog post (admin)
* @param {string} postId - Blog post ID
* @param {string} imageId - Image ID
* @returns {Promise} Promise with the API response
*/
deleteImage: async (postId, imageId) => {
try {
const response = await apiClient.delete(`/admin/blog/${postId}/images/${imageId}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get all pending comments (admin)
* @returns {Promise} Promise with the API response
*/
getPendingComments: async () => {
try {
const response = await apiClient.get('/admin/blog-comments/pending');
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get all comments for a post (admin)
* @param {string} postId - Blog post ID
* @returns {Promise} Promise with the API response
*/
getPostComments: async (postId) => {
try {
const response = await apiClient.get(`/admin/blog-comments/posts/${postId}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Approve a comment (admin)
* @param {string} commentId - Comment ID
* @returns {Promise} Promise with the API response
*/
approveComment: async (commentId) => {
try {
const response = await apiClient.post(`/admin/blog-comments/${commentId}/approve`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Delete a comment (admin)
* @param {string} commentId - Comment ID
* @returns {Promise} Promise with the API response
*/
deleteComment: async (commentId) => {
try {
const response = await apiClient.delete(`/admin/blog-comments/${commentId}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
}
};
export default blogAdminService;

View file

@ -0,0 +1,62 @@
import apiClient from './api';
const blogService = {
/**
* Get all published blog posts with optional filtering
* @param {Object} params - Query parameters for filtering posts
* @returns {Promise} Promise with the API response
*/
getAllPosts: async (params = {}) => {
try {
const response = await apiClient.get('/blog', { params });
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get a single blog post by slug
* @param {string} slug - Blog post slug
* @returns {Promise} Promise with the API response
*/
getPostBySlug: async (slug) => {
try {
const response = await apiClient.get(`/blog/${slug}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get all blog categories
* @returns {Promise} Promise with the API response
*/
getAllCategories: async () => {
try {
const response = await apiClient.get('/blog/categories/all');
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Add a comment to a blog post
* @param {string} postId - Blog post ID
* @param {Object} commentData - Comment data to submit
* @param {string} commentData.userId - User ID
* @returns {Promise} Promise with the API response
*/
addComment: async (postId, commentData) => {
try {
const response = await apiClient.post(`/blog/${postId}/comments`, commentData);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
}
};
export default blogService;

View file

@ -0,0 +1,133 @@
import apiClient from './api';
const couponService = {
/**
* Get all coupons (admin only)
* @returns {Promise} Promise with the API response
*/
getAllCoupons: async () => {
try {
const response = await apiClient.get('/admin/coupons');
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get a single coupon by ID (admin only)
* @param {string} id - Coupon ID
* @returns {Promise} Promise with the API response
*/
getCouponById: async (id) => {
try {
const response = await apiClient.get(`/admin/coupons/${id}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Create a new coupon (admin only)
* @param {Object} couponData - Coupon data
* @returns {Promise} Promise with the API response
*/
createCoupon: async (couponData) => {
try {
const response = await apiClient.post('/admin/coupons', couponData);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Update a coupon (admin only)
* @param {string} id - Coupon ID
* @param {Object} couponData - Updated coupon data
* @returns {Promise} Promise with the API response
*/
updateCoupon: async (id, couponData) => {
try {
const response = await apiClient.put(`/admin/coupons/${id}`, couponData);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Delete a coupon (admin only)
* @param {string} id - Coupon ID
* @returns {Promise} Promise with the API response
*/
deleteCoupon: async (id) => {
try {
const response = await apiClient.delete(`/admin/coupons/${id}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Apply a coupon code to the cart
* @param {string} userId - User ID
* @param {string} code - Coupon code
* @returns {Promise} Promise with the API response
*/
applyCoupon: async (userId, code) => {
try {
const response = await apiClient.post(`/cart/apply-coupon`, { userId, code });
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Remove a coupon from the cart
* @param {string} userId - User ID
* @returns {Promise} Promise with the API response
*/
removeCoupon: async (userId) => {
try {
const response = await apiClient.post(`/cart/remove-coupon`, { userId });
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Validate a coupon code
* @param {string} code - Coupon code
* @param {number} cartTotal - Cart total amount
* @returns {Promise} Promise with the API response
*/
validateCoupon: async (code, cartTotal) => {
try {
const response = await apiClient.post('/coupons/validate', { code, cartTotal });
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get coupon redemption history (admin only)
* @param {string} id - Coupon ID
* @returns {Promise} Promise with the API response
*/
getCouponRedemptions: async (id) => {
try {
const response = await apiClient.get(`/admin/coupons/${id}/redemptions`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
}
};
export default couponService;

View file

@ -0,0 +1,112 @@
import apiClient from './api';
const productReviewService = {
/**
* Get all reviews for a product
* @param {string} productId - Product ID
* @returns {Promise<Array>} - Array of reviews
*/
getProductReviews: async (productId) => {
try {
const response = await apiClient.get(`/product-reviews/${productId}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Add a review to a product
* @param {string} productId - Product ID
* @param {Object} reviewData - Review data
* @param {string} reviewData.title - Review title
* @param {string} reviewData.content - Review content
* @param {number} reviewData.rating - Star rating (1-5)
* @param {string} [reviewData.parentId] - Parent review ID (for replies)
* @returns {Promise<Object>} - Response with the new review
*/
addProductReview: async (productId, reviewData) => {
try {
const response = await apiClient.post(`/product-reviews/${productId}`, reviewData);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Check if user can review a product
* @param {string} productId - Product ID
* @returns {Promise<Object>} - Object with canReview, isPurchaser, and isAdmin flags
*/
canReviewProduct: async (productId) => {
try {
const response = await apiClient.get(`/product-reviews/${productId}/can-review`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
}
};
/**
* Admin-specific review service functions
*/
export const productReviewAdminService = {
/**
* Get all pending reviews (admin only)
* @returns {Promise<Array>} - Array of pending reviews
*/
getPendingReviews: async () => {
try {
const response = await apiClient.get('/admin/product-reviews/pending');
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get all reviews for a product (admin only)
* @param {string} productId - Product ID
* @returns {Promise<Object>} - Object with product and reviews
*/
getProductReviews: async (productId) => {
try {
const response = await apiClient.get(`/admin/product-reviews/products/${productId}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Approve a review (admin only)
* @param {string} reviewId - Review ID
* @returns {Promise<Object>} - Response with the approved review
*/
approveReview: async (reviewId) => {
try {
const response = await apiClient.post(`/admin/product-reviews/${reviewId}/approve`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Delete a review (admin only)
* @param {string} reviewId - Review ID
* @returns {Promise<Object>} - Response with success message
*/
deleteReview: async (reviewId) => {
try {
const response = await apiClient.delete(`/admin/product-reviews/${reviewId}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
}
};
export default productReviewService;