From 37da2acb5de8c69e87dbbf4ff3025a05b40cf0b5 Mon Sep 17 00:00:00 2001 From: 2ManyProjects Date: Tue, 29 Apr 2025 18:44:26 -0500 Subject: [PATCH] Couponss, Blogs, reviews --- db/init/15-coupon.sql | 65 ++ db/init/16-blog-schema.sql | 84 +++ db/init/17-product-reviews.sql | 80 +++ fileStructure.txt | 245 +++---- frontend/src/App.jsx | 22 + frontend/src/components/CouponInput.jsx | 138 ++++ .../src/components/ProductRatingDisplay.jsx | 27 + frontend/src/components/ProductReviews.jsx | 328 ++++++++++ frontend/src/features/cart/cartSlice.js | 12 + frontend/src/hooks/blogHooks.js | 235 +++++++ frontend/src/hooks/couponAdminHooks.js | 117 ++++ frontend/src/hooks/productReviewHooks.js | 114 ++++ frontend/src/layouts/AdminLayout.jsx | 13 +- frontend/src/layouts/MainLayout.jsx | 2 + frontend/src/pages/Admin/BlogCommentsPage.jsx | 333 ++++++++++ frontend/src/pages/Admin/BlogEditPage.jsx | 476 ++++++++++++++ frontend/src/pages/Admin/BlogPage.jsx | 342 ++++++++++ frontend/src/pages/Admin/CouponsPage.jsx | 380 +++++++++++ .../src/pages/Admin/ProductReviewsPage.jsx | 366 +++++++++++ frontend/src/pages/BlogDetailPage.jsx | 361 +++++++++++ frontend/src/pages/BlogPage.jsx | 312 +++++++++ frontend/src/pages/CartPage.jsx | 23 +- frontend/src/pages/CouponEditPage.jsx | 596 ++++++++++++++++++ frontend/src/pages/CouponRedemptionsPage.jsx | 215 +++++++ frontend/src/pages/ProductDetailPage.jsx | 30 +- frontend/src/pages/ProductsPage.jsx | 10 +- frontend/src/services/blogAdminService.js | 160 +++++ frontend/src/services/blogService.js | 62 ++ frontend/src/services/couponService.js | 133 ++++ frontend/src/services/productReviewService.js | 112 ++++ 30 files changed, 5263 insertions(+), 130 deletions(-) create mode 100644 db/init/15-coupon.sql create mode 100644 db/init/16-blog-schema.sql create mode 100644 db/init/17-product-reviews.sql create mode 100644 frontend/src/components/CouponInput.jsx create mode 100644 frontend/src/components/ProductRatingDisplay.jsx create mode 100644 frontend/src/components/ProductReviews.jsx create mode 100644 frontend/src/hooks/blogHooks.js create mode 100644 frontend/src/hooks/couponAdminHooks.js create mode 100644 frontend/src/hooks/productReviewHooks.js create mode 100644 frontend/src/pages/Admin/BlogCommentsPage.jsx create mode 100644 frontend/src/pages/Admin/BlogEditPage.jsx create mode 100644 frontend/src/pages/Admin/BlogPage.jsx create mode 100644 frontend/src/pages/Admin/CouponsPage.jsx create mode 100644 frontend/src/pages/Admin/ProductReviewsPage.jsx create mode 100644 frontend/src/pages/BlogDetailPage.jsx create mode 100644 frontend/src/pages/BlogPage.jsx create mode 100644 frontend/src/pages/CouponEditPage.jsx create mode 100644 frontend/src/pages/CouponRedemptionsPage.jsx create mode 100644 frontend/src/services/blogAdminService.js create mode 100644 frontend/src/services/blogService.js create mode 100644 frontend/src/services/couponService.js create mode 100644 frontend/src/services/productReviewService.js 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 && ( + + )} + + + {/* 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}" + + + )} + +
+ + + {!replyTo && ( + + Rating * + + {formData.rating === 0 && ( + + Please select a rating + + )} + + )} + + + + + + + +
+ )} + + {/* 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'} + + + + ) : ( + + {filteredComments.map(comment => ( + + + + + {comment.first_name} {comment.last_name} ({comment.email}) + + + + + + + + {comment.content} + + + + + On post: + + + {comment.post_title} + + + + + + + + + + + ))} + + )} + + {/* Delete Confirmation Dialog */} + + + Confirm Delete + + + + Are you sure you want to delete this comment? This action cannot be undone. + + + {commentToDelete && ( + + + Comment by {commentToDelete.first_name} {commentToDelete.last_name}: + + + {commentToDelete.content} + + + )} + + + + + + + + ); +}; + +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 ( + + + + + + + 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 ? ( + + + + + + + ) : ( + + + + )} + + + {/* Content */} + + + Post Content + + + + Pro tip: You can use HTML markup for formatting + + + + {/* Excerpt */} + + + + + {/* Tags */} + + + value.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + + + {/* Actions */} + + + + + + + + + + + + + ); +}; + +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 + + + + + + {/* 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 */} + + + Confirm Delete + + + + Are you sure you want to delete the post {postToDelete?.title}? + This action cannot be undone and all comments will be permanently removed. + + + + + + + +
+ ); +}; + +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 + + + + + + {/* 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 */} + + + Confirm Delete + + + + Are you sure you want to delete the coupon {couponToDelete?.code}? + This action cannot be undone. + + + + + + + +
+ ); +}; + +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'} + + + + ) : ( + + {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 + + )} + + + + + + + + + ))} + + )} + + {/* Delete Confirmation Dialog */} + + + Confirm Delete + + + + Are you sure you want to delete this review? This action cannot be undone. + + + {reviewToDelete && ( + + + Review by {reviewToDelete.first_name} {reviewToDelete.last_name}: + + + {reviewToDelete.title} + + {reviewToDelete.rating && ( + + )} + + {reviewToDelete.content || No content provided} + + + )} + + + + + + + + ); +}; + +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 && ( + + )} + + + + {/* 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.title} + + )} + + {/* Post content */} + + + {/* Post content - rendered as HTML */} +
+ + + {/* Post images */} + {post.images && post.images.length > 0 && ( + + + Gallery + + + {post.images.map((image) => ( + + + {image.caption + + {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 }} + /> + + + + {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 ( + + + + + + + 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 */} + + + + + + + + + ); +}; + + +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 ( + + + + + + + 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} - Response with the new review + */ + addProductReview: async (productId, reviewData) => { + try { + const response = await apiClient.post(`/product-reviews/${productId}`, reviewData); + return response.data; + } catch (error) { + throw error.response?.data || { message: 'An unknown error occurred' }; + } + }, + + /** + * Check if user can review a product + * @param {string} productId - Product ID + * @returns {Promise} - Object with canReview, isPurchaser, and isAdmin flags + */ + canReviewProduct: async (productId) => { + try { + const response = await apiClient.get(`/product-reviews/${productId}/can-review`); + return response.data; + } catch (error) { + throw error.response?.data || { message: 'An unknown error occurred' }; + } + } +}; + +/** + * Admin-specific review service functions + */ +export const productReviewAdminService = { + /** + * Get all pending reviews (admin only) + * @returns {Promise} - Array of pending reviews + */ + getPendingReviews: async () => { + try { + const response = await apiClient.get('/admin/product-reviews/pending'); + return response.data; + } catch (error) { + throw error.response?.data || { message: 'An unknown error occurred' }; + } + }, + + /** + * Get all reviews for a product (admin only) + * @param {string} productId - Product ID + * @returns {Promise} - Object with product and reviews + */ + getProductReviews: async (productId) => { + try { + const response = await apiClient.get(`/admin/product-reviews/products/${productId}`); + return response.data; + } catch (error) { + throw error.response?.data || { message: 'An unknown error occurred' }; + } + }, + + /** + * Approve a review (admin only) + * @param {string} reviewId - Review ID + * @returns {Promise} - Response with the approved review + */ + approveReview: async (reviewId) => { + try { + const response = await apiClient.post(`/admin/product-reviews/${reviewId}/approve`); + return response.data; + } catch (error) { + throw error.response?.data || { message: 'An unknown error occurred' }; + } + }, + + /** + * Delete a review (admin only) + * @param {string} reviewId - Review ID + * @returns {Promise} - Response with success message + */ + deleteReview: async (reviewId) => { + try { + const response = await apiClient.delete(`/admin/product-reviews/${reviewId}`); + return response.data; + } catch (error) { + throw error.response?.data || { message: 'An unknown error occurred' }; + } + } +}; + +export default productReviewService; \ No newline at end of file