diff --git a/db/init/15-coupon.sql b/db/init/15-coupon.sql
new file mode 100644
index 0000000..1019b95
--- /dev/null
+++ b/db/init/15-coupon.sql
@@ -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();
\ No newline at end of file
diff --git a/db/init/16-blog-schema.sql b/db/init/16-blog-schema.sql
new file mode 100644
index 0000000..197eeee
--- /dev/null
+++ b/db/init/16-blog-schema.sql
@@ -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');
\ No newline at end of file
diff --git a/db/init/17-product-reviews.sql b/db/init/17-product-reviews.sql
new file mode 100644
index 0000000..7a9ddd0
--- /dev/null
+++ b/db/init/17-product-reviews.sql
@@ -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();
\ No newline at end of file
diff --git a/fileStructure.txt b/fileStructure.txt
index 2bea297..3562cf7 100644
--- a/fileStructure.txt
+++ b/fileStructure.txt
@@ -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
\ No newline at end of file
+│ └── uploads/
+│ ├── products/
+│ └── blog/ (NEW)
+└── git/
+ ├── fileStructure.txt
+ └── docker-compose.yml
\ No newline at end of file
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 90f877f..f384d6d 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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() {
} />
+ {/* Blog routes */}
+ } />
+ } />
{/* Auth routes with AuthLayout */}
@@ -102,6 +115,15 @@ function App() {
} />
} />
} />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
{/* Catch-all route for 404s */}
diff --git a/frontend/src/components/CouponInput.jsx b/frontend/src/components/CouponInput.jsx
new file mode 100644
index 0000000..d286ea1
--- /dev/null
+++ b/frontend/src/components/CouponInput.jsx
@@ -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 (
+
+
+ Discount Code
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {hasCoupon ? (
+
+
+
+
+ Discount applied: -${cartData.couponDiscount?.toFixed(2)}
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+
+ );
+};
+
+export default CouponInput;
\ No newline at end of file
diff --git a/frontend/src/components/ProductRatingDisplay.jsx b/frontend/src/components/ProductRatingDisplay.jsx
new file mode 100644
index 0000000..18efeba
--- /dev/null
+++ b/frontend/src/components/ProductRatingDisplay.jsx
@@ -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 (
+
+
+
+ {reviewCount ? `(${reviewCount})` : showEmpty ? '(0)' : ''}
+
+
+ );
+};
+
+export default ProductRatingDisplay;
\ No newline at end of file
diff --git a/frontend/src/components/ProductReviews.jsx b/frontend/src/components/ProductReviews.jsx
new file mode 100644
index 0000000..cf59835
--- /dev/null
+++ b/frontend/src/components/ProductReviews.jsx
@@ -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) => (
+
+
+
+
+
+ {review.first_name ? review.first_name[0] : '?'}
+
+
+
+ {review.first_name} {review.last_name}
+
+
+ {formatDate(review.created_at)}
+
+
+
+
+ {review.is_verified_purchase && (
+
+
+
+ Verified Purchase
+
+
+ )}
+
+
+ {!isReply && review.rating && (
+
+
+
+ ({review.rating}/5)
+
+
+ )}
+
+ {review.title}
+
+ {review.content && (
+
+ {review.content}
+
+ )}
+
+ {isAuthenticated && (
+ }
+ onClick={() => {
+ setReplyTo(review);
+ setShowReviewForm(true);
+ // Scroll to form
+ document.getElementById('review-form')?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center'
+ });
+ }}
+ >
+ Reply
+
+ )}
+
+
+ {/* Render replies */}
+ {review.replies && review.replies.length > 0 && (
+
+ {review.replies.map(reply => renderReview(reply, true))}
+
+ )}
+
+ );
+
+ return (
+
+
+ Customer Reviews
+ {reviews && reviews.length > 0 && (
+
+ ({reviews.length})
+
+ )}
+
+
+
+
+ {/* Write a review button */}
+ {isAuthenticated && !permissionLoading && reviewPermission && (
+
+ {!showReviewForm ? (
+
+ ) : (
+
+ )}
+
+ {!reviewPermission?.canReview && !reviewPermission?.isAdmin && (
+
+ {reviewPermission?.reason || 'You need to purchase this product before you can review it.'}
+
+ )}
+
+ )}
+
+ {/* Review form */}
+ {showReviewForm && isAuthenticated && reviewPermission && (reviewPermission.canReview || replyTo) && (
+
+
+ {replyTo ? `Reply to ${replyTo.first_name}'s Review` : 'Write a Review'}
+
+
+ {replyTo && (
+
+ Replying to: "{replyTo.title}"
+
+
+ )}
+
+
+
+ )}
+
+ {/* Reviews list */}
+ {reviewsLoading ? (
+
+
+
+ ) : reviews && reviews.length > 0 ? (
+
+ {reviews.map(review => renderReview(review))}
+
+ ) : (
+
+ This product doesn't have any reviews yet. Be the first to review it!
+
+ )}
+
+ );
+};
+
+export default ProductReviews;
\ No newline at end of file
diff --git a/frontend/src/features/cart/cartSlice.js b/frontend/src/features/cart/cartSlice.js
index b7a1562..4990aa1 100644
--- a/frontend/src/features/cart/cartSlice.js
+++ b/frontend/src/features/cart/cartSlice.js
@@ -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;
diff --git a/frontend/src/hooks/blogHooks.js b/frontend/src/hooks/blogHooks.js
new file mode 100644
index 0000000..495b7b6
--- /dev/null
+++ b/frontend/src/hooks/blogHooks.js
@@ -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'
+ );
+ },
+ });
+};
\ No newline at end of file
diff --git a/frontend/src/hooks/couponAdminHooks.js b/frontend/src/hooks/couponAdminHooks.js
new file mode 100644
index 0000000..a44517f
--- /dev/null
+++ b/frontend/src/hooks/couponAdminHooks.js
@@ -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
+};
\ No newline at end of file
diff --git a/frontend/src/hooks/productReviewHooks.js b/frontend/src/hooks/productReviewHooks.js
new file mode 100644
index 0000000..2285806
--- /dev/null
+++ b/frontend/src/hooks/productReviewHooks.js
@@ -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'
+ );
+ }
+ });
+};
\ No newline at end of file
diff --git a/frontend/src/layouts/AdminLayout.jsx b/frontend/src/layouts/AdminLayout.jsx
index fd15fa2..e255faa 100644
--- a/frontend/src/layouts/AdminLayout.jsx
+++ b/frontend/src/layouts/AdminLayout.jsx
@@ -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,9 +69,13 @@ const AdminLayout = () => {
{ text: 'Categories', icon: , path: '/admin/categories' },
{ text: 'Orders', icon: , path: '/admin/orders' },
{ text: 'Customers', icon: , path: '/admin/customers' },
+ { text: 'Coupons', icon: , path: '/admin/coupons' },
+ { text: 'Blog', icon: , path: '/admin/blog' },
+ { text: 'Blog Comments', icon: , path: '/admin/blog-comments' },
{ text: 'Settings', icon: , path: '/admin/settings' },
{ text: 'Reports', icon: , path: '/admin/reports' },
- ];
+ { text: 'Product Reviews', icon: , path: '/admin/product-reviews' },
+ ];
const secondaryListItems = [
{ text: 'Visit Site', icon: , path: '/' },
diff --git a/frontend/src/layouts/MainLayout.jsx b/frontend/src/layouts/MainLayout.jsx
index b2d19d3..815e7fb 100644
--- a/frontend/src/layouts/MainLayout.jsx
+++ b/frontend/src/layouts/MainLayout.jsx
@@ -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: , path: '/' },
{ text: 'Products', icon: , path: '/products' },
+ { text: 'Blog', icon: , path: '/blog' },
{ text: 'Cart', icon: , path: '/cart', badge: itemCount > 0 ? itemCount : null },
];
if (isAuthenticated) {
diff --git a/frontend/src/pages/Admin/BlogCommentsPage.jsx b/frontend/src/pages/Admin/BlogCommentsPage.jsx
new file mode 100644
index 0000000..cafa290
--- /dev/null
+++ b/frontend/src/pages/Admin/BlogCommentsPage.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+ Error loading comments: {error.message}
+
+ );
+ }
+
+ return (
+
+
+
+ Blog Comments
+
+
+
+ {isRefreshing ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {/* Search Box */}
+
+
+
+
+ ),
+ endAdornment: searchTerm && (
+
+
+
+
+
+ )
+ }}
+ />
+
+
+ {/* Comments List */}
+ {filteredComments.length === 0 ? (
+
+
+ No pending comments found
+
+
+ {searchTerm ? 'Try adjusting your search terms' : 'All comments have been reviewed'}
+
+ }
+ variant="outlined"
+ onClick={handleRefresh}
+ sx={{ mt: 2 }}
+ disabled={isRefreshing}
+ >
+ Refresh
+
+
+ ) : (
+
+ {filteredComments.map(comment => (
+
+
+
+
+ {comment.first_name} {comment.last_name} ({comment.email})
+
+
+
+
+
+
+
+ {comment.content}
+
+
+
+
+ On post:
+
+
+ {comment.post_title}
+
+
+
+
+
+ }
+ onClick={() => handleViewPost(comment.post_slug)}
+ >
+ View Post
+
+ }
+ color="success"
+ onClick={() => handleApproveComment(comment.id)}
+ disabled={approveComment.isLoading}
+ >
+ Approve
+
+ }
+ color="error"
+ onClick={() => handleDeleteClick(comment)}
+ disabled={deleteComment.isLoading}
+ >
+ Delete
+
+
+
+ ))}
+
+ )}
+
+ {/* Delete Confirmation Dialog */}
+
+
+ );
+};
+
+export default AdminBlogCommentsPage;
\ No newline at end of file
diff --git a/frontend/src/pages/Admin/BlogEditPage.jsx b/frontend/src/pages/Admin/BlogEditPage.jsx
new file mode 100644
index 0000000..882098b
--- /dev/null
+++ b/frontend/src/pages/Admin/BlogEditPage.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ // Error state
+ if (postError && !isNewPost) {
+ return (
+
+ Error loading blog post: {postError.message}
+
+ );
+ }
+
+ return (
+
+
+ }
+ onClick={() => navigate('/admin/blog')}
+ sx={{ mb: 2 }}
+ >
+ Back to Blog Posts
+
+
+
+
+ Admin
+
+
+ Blog
+
+
+ {isNewPost ? 'Create Post' : 'Edit Post'}
+
+
+
+
+ {isNewPost ? 'Create New Blog Post' : `Edit Blog Post: ${post?.title || ''}`}
+
+
+
+ {/* Form */}
+
+ {notificationOpen && (
+ setNotificationOpen(false)}
+ >
+ {notification.message}
+
+ )}
+
+
+ {/* Title */}
+
+
+
+
+ {/* Category */}
+
+
+ Category
+
+
+
+
+ {/* Status */}
+
+
+ Status
+
+
+ {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'
+ }
+
+
+
+
+ {/* Publish now option */}
+ {formData.status === 'published' && !post?.published_at && (
+
+
+ }
+ label="Publish immediately (sets published date to now)"
+ />
+
+ )}
+
+ {/* Featured Image */}
+
+
+ Featured Image
+
+
+ {formData.featuredImagePath ? (
+
+
+
+ }
+ color="error"
+ onClick={handleClearFeaturedImage}
+ >
+ Remove
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Content */}
+
+
+ Post Content
+
+
+
+ Pro tip: You can use HTML markup for formatting
+
+
+
+ {/* Excerpt */}
+
+
+
+
+ {/* Tags */}
+
+
+ value.map((option, index) => (
+
+ ))
+ }
+ renderInput={(params) => (
+
+ )}
+ />
+
+
+ {/* Actions */}
+
+ }
+ onClick={handlePreview}
+ disabled={!formData.title || !formData.content}
+ >
+ Preview
+
+
+
+
+
+ : }
+ disabled={createPost.isLoading || updatePost.isLoading}
+ >
+ {createPost.isLoading || updatePost.isLoading ?
+ 'Saving...' : (isNewPost ? 'Create' : 'Update')} Post
+
+
+
+
+
+
+ );
+};
+
+export default BlogEditPage;
\ No newline at end of file
diff --git a/frontend/src/pages/Admin/BlogPage.jsx b/frontend/src/pages/Admin/BlogPage.jsx
new file mode 100644
index 0000000..4517d48
--- /dev/null
+++ b/frontend/src/pages/Admin/BlogPage.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+ Error loading blog posts: {error.message}
+
+ );
+ }
+
+ return (
+
+
+
+ Blog Management
+
+
+ }
+ component={RouterLink}
+ to="/admin/blog/new"
+ >
+ Create Post
+
+
+
+ {/* Search Box */}
+
+
+
+
+ ),
+ endAdornment: searchTerm && (
+
+
+
+
+
+ )
+ }}
+ />
+
+
+ {/* Blog Posts Table */}
+
+
+
+
+
+ Image
+ Title
+ Category
+ Status
+ Published
+ Created
+ Actions
+
+
+
+ {filteredPosts.length > 0 ? (
+ filteredPosts.map((post) => (
+
+
+ {post.featured_image_path ? (
+
+ ) : (
+
+
+ No img
+
+
+ )}
+
+
+
+ {post.title}
+
+ {post.excerpt && (
+
+ {post.excerpt}
+
+ )}
+
+
+ {post.category_name || 'Uncategorized'}
+
+
+
+
+
+ {formatDate(post.published_at)}
+
+
+ {formatDate(post.created_at)}
+
+
+
+ handleViewPost(post.slug)}
+ size="small"
+ >
+
+
+
+
+ handleEditPost(post.id)}
+ size="small"
+ >
+
+
+
+
+ handleDeleteClick(post)}
+ size="small"
+ >
+
+
+
+
+
+ ))
+ ) : (
+
+
+
+ {searchTerm ? 'No posts match your search.' : 'No blog posts found.'}
+
+
+
+ )}
+
+
+
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+ );
+};
+
+export default AdminBlogPage;
\ No newline at end of file
diff --git a/frontend/src/pages/Admin/CouponsPage.jsx b/frontend/src/pages/Admin/CouponsPage.jsx
new file mode 100644
index 0000000..f1240fa
--- /dev/null
+++ b/frontend/src/pages/Admin/CouponsPage.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+ Error loading coupons: {error.message}
+
+ );
+ }
+
+ return (
+
+
+
+ Coupons & Discounts
+
+
+ }
+ onClick={() => navigate('/admin/coupons/new')}
+ >
+ Add Coupon
+
+
+
+ {/* Search Box */}
+
+
+
+
+ ),
+ endAdornment: search && (
+
+
+
+
+
+ )
+ }}
+ />
+
+
+ {/* Coupons Table */}
+
+
+
+
+
+ Code
+ Type
+ Value
+ Status
+ Redemptions
+ Valid Period
+ Actions
+
+
+
+ {paginatedCoupons.length > 0 ? (
+ paginatedCoupons.map((coupon) => {
+ const status = getCouponStatus(coupon);
+
+ return (
+
+
+
+ {coupon.code}
+
+
+ {coupon.description || 'No description'}
+
+
+
+ {coupon.discount_type === 'percentage' ? 'Percentage' : 'Fixed Amount'}
+
+
+ {coupon.discount_type === 'percentage'
+ ? `${coupon.discount_value}%`
+ : `$${parseFloat(coupon.discount_value).toFixed(2)}`}
+ {coupon.max_discount_amount && (
+
+ Max: ${parseFloat(coupon.max_discount_amount).toFixed(2)}
+
+ )}
+
+
+
+
+
+ {coupon.redemption_limit ? (
+
+ {coupon.current_redemptions} / {coupon.redemption_limit}
+
+ ) : (
+
+ {coupon.current_redemptions} / Unlimited
+
+ )}
+
+
+ {coupon.start_date && (
+
+ From: {formatDate(coupon.start_date)}
+
+ )}
+ {coupon.end_date ? (
+
+ To: {formatDate(coupon.end_date)}
+
+ ) : (
+
+ No End Date
+
+ )}
+
+
+
+ handleViewRedemptions(coupon.id)}
+ color="primary"
+ >
+
+
+
+
+ handleEditCoupon(coupon.id)}
+ color="primary"
+ >
+
+
+
+
+ handleDeleteClick(coupon)}
+ color="error"
+ >
+
+
+
+
+
+ );
+ })
+ ) : (
+
+
+
+ {search ? 'No coupons match your search.' : 'No coupons found.'}
+
+
+
+ )}
+
+
+
+
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+ );
+};
+
+export default AdminCouponsPage;
\ No newline at end of file
diff --git a/frontend/src/pages/Admin/ProductReviewsPage.jsx b/frontend/src/pages/Admin/ProductReviewsPage.jsx
new file mode 100644
index 0000000..74ccaea
--- /dev/null
+++ b/frontend/src/pages/Admin/ProductReviewsPage.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+ Error loading reviews: {error.message}
+
+ );
+ }
+
+ return (
+
+
+
+ Product Reviews
+
+
+
+ {isRefreshing ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {/* Search Box */}
+
+
+
+
+ ),
+ endAdornment: searchTerm && (
+
+
+
+
+
+ )
+ }}
+ />
+
+
+ {/* Reviews List */}
+ {filteredReviews.length === 0 ? (
+
+
+ No pending reviews found
+
+
+ {searchTerm ? 'Try adjusting your search terms' : 'All reviews have been approved'}
+
+ }
+ variant="outlined"
+ onClick={handleRefresh}
+ sx={{ mt: 2 }}
+ disabled={isRefreshing}
+ >
+ Refresh
+
+
+ ) : (
+
+ {filteredReviews.map(review => (
+
+
+
+
+
+ {review.first_name} {review.last_name} ({review.email})
+
+
+ {review.is_verified_purchase && (
+
+ )}
+
+
+
+
+
+
+
+
+ {review.title}
+
+ {review.rating && (
+
+
+
+ ({review.rating}/5)
+
+
+ )}
+
+
+ {review.content || No content provided}
+
+
+ {review.parent_id && (
+
+ This is a reply to another review
+
+ )}
+
+
+
+ }
+ onClick={() => handleViewProduct(review.product_id)}
+ >
+ View Product
+
+ }
+ color="success"
+ onClick={() => handleApproveReview(review.id)}
+ disabled={approveReview.isLoading}
+ >
+ Approve
+
+ }
+ color="error"
+ onClick={() => handleDeleteClick(review)}
+ disabled={deleteReview.isLoading}
+ >
+ Delete
+
+
+
+ ))}
+
+ )}
+
+ {/* Delete Confirmation Dialog */}
+
+
+ );
+};
+
+export default AdminProductReviewsPage;
\ No newline at end of file
diff --git a/frontend/src/pages/BlogDetailPage.jsx b/frontend/src/pages/BlogDetailPage.jsx
new file mode 100644
index 0000000..257aceb
--- /dev/null
+++ b/frontend/src/pages/BlogDetailPage.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+ Error loading blog post: {error.message}
+
+ );
+ }
+
+ // If post isn't found
+ if (!post) {
+ return (
+
+ Blog post not found. The post may have been removed or the URL is incorrect.
+
+ );
+ }
+
+ // Comments component
+ const renderComment = (comment, level = 0) => (
+
+
+
+
+
+ {comment.first_name ? comment.first_name[0] : '?'}
+
+
+
+ {comment.first_name} {comment.last_name}
+
+
+ {formatDate(comment.created_at)}
+
+
+
+
+
+ {comment.content}
+
+
+ {isAuthenticated && (
+ }
+ onClick={() => setReplyTo(comment)}
+ >
+ Reply
+
+ )}
+
+
+
+ {/* Render replies */}
+ {comment.replies && comment.replies.map(reply => renderComment(reply, level + 1))}
+
+ );
+
+ return (
+
+
+ {/* Breadcrumbs */}
+ }
+ aria-label="breadcrumb"
+ sx={{ mb: 3 }}
+ >
+
+ Home
+
+
+ Blog
+
+
+ {post.title}
+
+
+
+ {/* Post header */}
+
+ {/* Category */}
+ {post.category_name && (
+
+ )}
+
+ {/* Title */}
+
+ {post.title}
+
+
+ {/* Author and date */}
+
+ By {post.author_first_name} {post.author_last_name} • {formatDate(post.published_at)}
+
+
+ {/* Tags */}
+
+ {post.tags && post.tags.filter(Boolean).map((tag, index) => (
+
+ ))}
+
+
+
+ {/* Featured image */}
+ {post.featured_image_path && (
+
+
+
+ )}
+
+ {/* Post content */}
+
+
+ {/* Post content - rendered as HTML */}
+
+
+
+ {/* Post images */}
+ {post.images && post.images.length > 0 && (
+
+
+ Gallery
+
+
+ {post.images.map((image) => (
+
+
+
+
+ {image.caption && (
+
+ {image.caption}
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+ {/* Comments section */}
+
+
+ Comments ({post.comments ? post.comments.length : 0})
+
+
+
+
+ {/* Comment form */}
+ {isAuthenticated ? (
+
+
+ {replyTo
+ ? `Reply to ${replyTo.first_name}'s comment`
+ : 'Leave a comment'}
+
+
+ {replyTo && (
+
+ setReplyTo(null)}
+ variant="outlined"
+ />
+
+ )}
+
+ setComment(e.target.value)}
+ variant="outlined"
+ sx={{ mb: 2 }}
+ />
+
+ }
+ disabled={!comment.trim() || addComment.isLoading}
+ >
+ {addComment.isLoading ? 'Submitting...' : 'Submit Comment'}
+
+
+ {addComment.isSuccess && (
+
+ Your comment has been submitted and is awaiting approval.
+
+ )}
+
+ ) : (
+
+ Please log in to leave a comment.
+
+ )}
+
+ {/* Comments list */}
+ {post.comments && post.comments.length > 0 ? (
+
+ {post.comments.map(comment => renderComment(comment))}
+
+ ) : (
+
+ No comments yet. Be the first to comment!
+
+ )}
+
+
+
+ );
+};
+
+export default BlogDetailPage;
\ No newline at end of file
diff --git a/frontend/src/pages/BlogPage.jsx b/frontend/src/pages/BlogPage.jsx
new file mode 100644
index 0000000..d11bf5d
--- /dev/null
+++ b/frontend/src/pages/BlogPage.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+ Error loading blog posts: {error.message}
+
+ );
+ }
+
+ // Empty state
+ const posts = data?.posts || [];
+ const pagination = data?.pagination || { page: 1, pages: 1, total: 0 };
+
+ return (
+
+
+
+ Our Blog
+
+
+ Discover insights about our natural collections, sourcing adventures, and unique specimens
+
+
+ {/* Filters and Search */}
+
+ {/* Category filter */}
+
+
+ Filter by Category
+
+
+
+
+ {/* Tag filter */}
+
+ {filters.tag && (
+
+
+ Filtered by tag:
+
+
+
+ )}
+
+
+ {/* Search */}
+
+
+
+
+ ),
+ endAdornment: filters.search && (
+
+
+
+
+
+ )
+ }}
+ />
+
+
+
+ {/* No results message */}
+ {posts.length === 0 && (
+
+
+ No blog posts found
+
+
+ {filters.search || filters.category || filters.tag
+ ? 'Try adjusting your filters or search terms'
+ : 'Check back soon for new content'}
+
+
+ )}
+
+ {/* Blog post grid */}
+
+ {posts.map((post) => (
+
+
+ {/* Featured image */}
+
+
+
+ {/* Category */}
+ {post.category_name && (
+
+ )}
+
+ {/* Title */}
+
+ {post.title}
+
+
+ {/* Published date */}
+
+ {formatPublishedDate(post.published_at)}
+
+
+ {/* Excerpt */}
+
+ {post.excerpt || (post.content && post.content.substring(0, 150) + '...')}
+
+
+ {/* Tags */}
+
+ {post.tags && post.tags.filter(Boolean).map((tag, index) => (
+ handleTagClick(tag)}
+ sx={{ mr: 0.5, mb: 0.5 }}
+ />
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {/* Pagination */}
+ {pagination.pages > 1 && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default BlogPage;
\ No newline at end of file
diff --git a/frontend/src/pages/CartPage.jsx b/frontend/src/pages/CartPage.jsx
index 4d409e6..39ba2bf 100644
--- a/frontend/src/pages/CartPage.jsx
+++ b/frontend/src/pages/CartPage.jsx
@@ -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 */}
+ {/* Coupon Input */}
+
+
Order Summary
@@ -278,10 +281,26 @@ const CartPage = () => {
- ${cart.total.toFixed(2)}
+ ${cart.subtotal?.toFixed(2) || cart.total.toFixed(2)}
+ {/* Display discount if coupon is applied */}
+ {cart.couponDiscount > 0 && (
+ <>
+
+
+ Discount ({cart.couponCode})
+
+
+
+
+ -${cart.couponDiscount.toFixed(2)}
+
+
+ >
+ )}
+
Shipping
diff --git a/frontend/src/pages/CouponEditPage.jsx b/frontend/src/pages/CouponEditPage.jsx
new file mode 100644
index 0000000..f293085
--- /dev/null
+++ b/frontend/src/pages/CouponEditPage.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ // Error state
+ if (!isNewCoupon && couponError) {
+ return (
+
+ Error loading coupon: {couponError.message}
+
+ );
+ }
+
+ return (
+
+
+ }
+ onClick={() => navigate('/admin/coupons')}
+ sx={{ mb: 2 }}
+ >
+ Back to Coupons
+
+
+
+
+ Admin
+
+
+ Coupons
+
+
+ {isNewCoupon ? 'Create Coupon' : 'Edit Coupon'}
+
+
+
+
+ {isNewCoupon ? 'Create New Coupon' : `Edit Coupon: ${coupon?.code || "Enter Code"}`}
+
+
+
+ {/* Form */}
+
+ {notification.open && (
+
+ {notification.message}
+
+ )}
+
+
+ {/* Basic Information */}
+
+
+ Basic Information
+
+
+
+
+
+
+
+
+
+ }
+ label={formData.isActive ? 'Coupon is Active' : 'Coupon is Inactive'}
+ />
+
+
+
+
+
+
+ {/* Discount Settings */}
+
+
+
+ Discount Settings
+
+
+
+
+
+ Discount Type
+
+ {errors.discountType && {errors.discountType}}
+
+
+
+
+
+ {formData.discountType === 'percentage' ? '%' : ''}
+
+ ),
+ }}
+ />
+
+
+
+ $,
+ }}
+ />
+
+
+
+ $,
+ }}
+ disabled={formData.discountType !== 'percentage'}
+ />
+
+
+ {/* Validity and Usage */}
+
+
+
+ Validity and Usage Limits
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Applicable Categories and Tags */}
+
+
+
+ Applicable Products
+
+
+ Specify which categories or tags this coupon applies to. If none selected, coupon applies to all products.
+
+
+
+
+ option.name}
+ isOptionEqualToValue={(option, value) => option.id === value.id}
+ value={formData.categories}
+ onChange={handleCategoriesChange}
+ renderInput={(params) => (
+
+ )}
+ renderTags={(selected, getTagProps) =>
+ selected.map((category, index) => (
+
+ ))
+ }
+ />
+
+
+
+ option.name}
+ isOptionEqualToValue={(option, value) => option.id === value.id}
+ value={formData.tags}
+ onChange={handleTagsChange}
+ renderInput={(params) => (
+
+ )}
+ renderTags={(selected, getTagProps) =>
+ selected.map((tag, index) => (
+
+ ))
+ }
+ />
+
+
+ {/* Submit Button */}
+
+
+ :
+ }
+ disabled={createCoupon.isLoading || updateCoupon.isLoading}
+ >
+ {isNewCoupon ? 'Create Coupon' : 'Update Coupon'}
+
+
+
+
+
+
+
+ );
+};
+
+
+export default CouponEditPage;
\ No newline at end of file
diff --git a/frontend/src/pages/CouponRedemptionsPage.jsx b/frontend/src/pages/CouponRedemptionsPage.jsx
new file mode 100644
index 0000000..7322cbe
--- /dev/null
+++ b/frontend/src/pages/CouponRedemptionsPage.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ // Error state
+ const error = couponError || redemptionsError;
+ if (error) {
+ return (
+
+ Error loading data: {error.message}
+
+ );
+ }
+
+ // Paginate redemptions
+ const paginatedRedemptions = redemptions ? redemptions.slice(
+ page * rowsPerPage,
+ page * rowsPerPage + rowsPerPage
+ ) : [];
+
+ return (
+
+
+ }
+ onClick={() => navigate('/admin/coupons')}
+ sx={{ mb: 2 }}
+ >
+ Back to Coupons
+
+
+
+
+ Admin
+
+
+ Coupons
+
+ Redemptions
+
+
+
+ Redemption History: {coupon?.code}
+
+
+
+
+ Coupon Details
+
+
+
+ Code
+ {coupon?.code}
+
+
+ Discount
+
+ {coupon?.discount_type === 'percentage'
+ ? `${coupon.discount_value}%`
+ : `$${parseFloat(coupon.discount_value).toFixed(2)}`}
+
+
+
+ Usage
+
+ {coupon?.current_redemptions} / {coupon?.redemption_limit || 'Unlimited'}
+
+
+
+ Status
+
+
+
+
+
+
+ {/* Redemptions Table */}
+
+
+
+
+
+ Customer
+ Date
+ Order #
+ Order Total
+ Discount Amount
+
+
+
+ {paginatedRedemptions.length > 0 ? (
+ paginatedRedemptions.map((redemption) => (
+
+
+
+ {redemption.first_name} {redemption.last_name}
+
+
+ {redemption.email}
+
+
+ {formatDate(redemption.redeemed_at)}
+
+
+ {redemption.order_id.substring(0, 8)}...
+
+
+
+ ${parseFloat(redemption.total_amount).toFixed(2)}
+
+
+ ${parseFloat(redemption.discount_amount).toFixed(2)}
+
+
+ ))
+ ) : (
+
+
+
+ No redemptions found for this coupon.
+
+
+
+ )}
+
+
+
+ {redemptions && redemptions.length > 0 && (
+
+ )}
+
+
+ );
+};
+
+export default CouponRedemptionsPage;
\ No newline at end of file
diff --git a/frontend/src/pages/ProductDetailPage.jsx b/frontend/src/pages/ProductDetailPage.jsx
index bddce37..2784e47 100644
--- a/frontend/src/pages/ProductDetailPage.jsx
+++ b/frontend/src/pages/ProductDetailPage.jsx
@@ -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}
{product.name}
+ {product.average_rating && (
+
+
+
+ {product.average_rating} ({product.review_count} {product.review_count === 1 ? 'review' : 'reviews'})
+
+
+ )}
+
+ {!product.average_rating && (
+
+
+ No reviews yet
+
+
+ )}
@@ -349,6 +371,10 @@ const ProductDetailPage = () => {
))}
+
+
+
+
diff --git a/frontend/src/pages/ProductsPage.jsx b/frontend/src/pages/ProductsPage.jsx
index 740a3b8..a6dac88 100644
--- a/frontend/src/pages/ProductsPage.jsx
+++ b/frontend/src/pages/ProductsPage.jsx
@@ -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}
-
+
{
+ 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;
\ No newline at end of file
diff --git a/frontend/src/services/blogService.js b/frontend/src/services/blogService.js
new file mode 100644
index 0000000..f543a15
--- /dev/null
+++ b/frontend/src/services/blogService.js
@@ -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;
\ No newline at end of file
diff --git a/frontend/src/services/couponService.js b/frontend/src/services/couponService.js
new file mode 100644
index 0000000..f986f6c
--- /dev/null
+++ b/frontend/src/services/couponService.js
@@ -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;
\ No newline at end of file
diff --git a/frontend/src/services/productReviewService.js b/frontend/src/services/productReviewService.js
new file mode 100644
index 0000000..4547480
--- /dev/null
+++ b/frontend/src/services/productReviewService.js
@@ -0,0 +1,112 @@
+import apiClient from './api';
+
+const productReviewService = {
+ /**
+ * Get all reviews for a product
+ * @param {string} productId - Product ID
+ * @returns {Promise} - 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