Couponss, Blogs, reviews
This commit is contained in:
parent
b1f5985224
commit
37da2acb5d
30 changed files with 5263 additions and 130 deletions
65
db/init/15-coupon.sql
Normal file
65
db/init/15-coupon.sql
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
-- Create coupons table
|
||||
CREATE TABLE coupons (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
code VARCHAR(50) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
discount_type VARCHAR(20) NOT NULL, -- 'percentage', 'fixed_amount'
|
||||
discount_value DECIMAL(10, 2) NOT NULL, -- Percentage or fixed amount value
|
||||
min_purchase_amount DECIMAL(10, 2), -- Minimum purchase amount to use the coupon (optional)
|
||||
max_discount_amount DECIMAL(10, 2), -- Maximum discount amount for percentage discounts (optional)
|
||||
redemption_limit INTEGER, -- NULL means unlimited redemptions
|
||||
current_redemptions INTEGER NOT NULL DEFAULT 0, -- Track how many times coupon has been used
|
||||
start_date TIMESTAMP WITH TIME ZONE, -- When the coupon becomes valid (optional)
|
||||
end_date TIMESTAMP WITH TIME ZONE, -- When the coupon expires (optional)
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- Whether the coupon is currently active
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create coupon_categories junction table
|
||||
CREATE TABLE coupon_categories (
|
||||
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
|
||||
category_id UUID NOT NULL REFERENCES product_categories(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (coupon_id, category_id)
|
||||
);
|
||||
|
||||
-- Create coupon_tags junction table
|
||||
CREATE TABLE coupon_tags (
|
||||
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
|
||||
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (coupon_id, tag_id)
|
||||
);
|
||||
|
||||
-- Create coupon_blacklist table for excluded products
|
||||
CREATE TABLE coupon_blacklist (
|
||||
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
|
||||
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (coupon_id, product_id)
|
||||
);
|
||||
|
||||
-- Create coupon_redemptions table to track usage
|
||||
CREATE TABLE coupon_redemptions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
|
||||
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
discount_amount DECIMAL(10, 2) NOT NULL,
|
||||
redeemed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add applied_coupon_id to orders table
|
||||
ALTER TABLE orders ADD COLUMN coupon_id UUID REFERENCES coupons(id);
|
||||
ALTER TABLE orders ADD COLUMN discount_amount DECIMAL(10, 2) DEFAULT 0.00;
|
||||
|
||||
-- Add indexes for better performance
|
||||
CREATE INDEX idx_coupon_code ON coupons(code);
|
||||
CREATE INDEX idx_coupon_is_active ON coupons(is_active);
|
||||
CREATE INDEX idx_coupon_end_date ON coupons(end_date);
|
||||
CREATE INDEX idx_coupon_redemptions_coupon_id ON coupon_redemptions(coupon_id);
|
||||
CREATE INDEX idx_coupon_redemptions_user_id ON coupon_redemptions(user_id);
|
||||
|
||||
-- Create trigger to update the updated_at timestamp
|
||||
CREATE TRIGGER update_coupons_modtime
|
||||
BEFORE UPDATE ON coupons
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_modified_column();
|
||||
84
db/init/16-blog-schema.sql
Normal file
84
db/init/16-blog-schema.sql
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
-- Create blog post categories
|
||||
CREATE TABLE blog_categories (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(50) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create blog posts table
|
||||
CREATE TABLE blog_posts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
excerpt TEXT,
|
||||
author_id UUID NOT NULL REFERENCES users(id),
|
||||
category_id UUID REFERENCES blog_categories(id),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, published, archived
|
||||
featured_image_path VARCHAR(255),
|
||||
published_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create blog post tags junction table
|
||||
CREATE TABLE blog_post_tags (
|
||||
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
|
||||
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (post_id, tag_id)
|
||||
);
|
||||
|
||||
-- Create blog post images table
|
||||
CREATE TABLE blog_post_images (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
|
||||
image_path VARCHAR(255) NOT NULL,
|
||||
caption TEXT,
|
||||
display_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create blog comments table
|
||||
CREATE TABLE blog_comments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
parent_id UUID REFERENCES blog_comments(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
is_approved BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX idx_blog_posts_author ON blog_posts(author_id);
|
||||
CREATE INDEX idx_blog_posts_category ON blog_posts(category_id);
|
||||
CREATE INDEX idx_blog_posts_status ON blog_posts(status);
|
||||
CREATE INDEX idx_blog_posts_published_at ON blog_posts(published_at);
|
||||
CREATE INDEX idx_blog_posts_slug ON blog_posts(slug);
|
||||
CREATE INDEX idx_blog_comments_post ON blog_comments(post_id);
|
||||
CREATE INDEX idx_blog_comments_user ON blog_comments(user_id);
|
||||
CREATE INDEX idx_blog_comments_parent ON blog_comments(parent_id);
|
||||
CREATE INDEX idx_blog_post_images_post ON blog_post_images(post_id);
|
||||
|
||||
-- Create triggers to automatically update the updated_at column
|
||||
CREATE TRIGGER update_blog_categories_modtime
|
||||
BEFORE UPDATE ON blog_categories
|
||||
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
|
||||
|
||||
CREATE TRIGGER update_blog_posts_modtime
|
||||
BEFORE UPDATE ON blog_posts
|
||||
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
|
||||
|
||||
CREATE TRIGGER update_blog_comments_modtime
|
||||
BEFORE UPDATE ON blog_comments
|
||||
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
|
||||
|
||||
-- Insert default blog categories
|
||||
INSERT INTO blog_categories (name, description) VALUES
|
||||
('Announcements', 'Official announcements and company news'),
|
||||
('Collections', 'Information about product collections and releases'),
|
||||
('Tutorials', 'How-to guides and instructional content'),
|
||||
('Behind the Scenes', 'Stories about our sourcing and process');
|
||||
80
db/init/17-product-reviews.sql
Normal file
80
db/init/17-product-reviews.sql
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
-- Create product reviews table
|
||||
CREATE TABLE product_reviews (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
parent_id UUID REFERENCES product_reviews(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT,
|
||||
rating decimal CHECK (rating >= 0.0 AND rating <= 5.0),
|
||||
is_approved BOOLEAN DEFAULT FALSE,
|
||||
is_verified_purchase BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add product average rating column to the products table
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS average_rating DECIMAL(3, 2);
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS review_count INTEGER DEFAULT 0;
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX idx_product_reviews_product ON product_reviews(product_id);
|
||||
CREATE INDEX idx_product_reviews_user ON product_reviews(user_id);
|
||||
CREATE INDEX idx_product_reviews_parent ON product_reviews(parent_id);
|
||||
CREATE INDEX idx_product_reviews_approved ON product_reviews(is_approved);
|
||||
CREATE INDEX idx_product_reviews_rating ON product_reviews(rating);
|
||||
|
||||
-- Create trigger to automatically update the updated_at column
|
||||
CREATE TRIGGER update_product_reviews_modtime
|
||||
BEFORE UPDATE ON product_reviews
|
||||
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
|
||||
|
||||
-- Function to update product average rating and review count
|
||||
CREATE OR REPLACE FUNCTION update_product_average_rating()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
avg_rating DECIMAL(3, 2);
|
||||
rev_count INTEGER;
|
||||
BEGIN
|
||||
-- Calculate average rating and count for approved top-level reviews
|
||||
SELECT
|
||||
AVG(rating)::DECIMAL(3, 2),
|
||||
COUNT(*)
|
||||
INTO
|
||||
avg_rating,
|
||||
rev_count
|
||||
FROM product_reviews
|
||||
WHERE product_id = NEW.product_id
|
||||
AND parent_id IS NULL
|
||||
AND is_approved = TRUE
|
||||
AND rating IS NOT NULL;
|
||||
|
||||
-- Update the product with new average rating and count
|
||||
UPDATE products
|
||||
SET
|
||||
average_rating = avg_rating,
|
||||
review_count = rev_count
|
||||
WHERE id = NEW.product_id;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create triggers to recalculate average rating when reviews are added/updated/deleted
|
||||
CREATE TRIGGER update_product_rating_on_insert
|
||||
AFTER INSERT ON product_reviews
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.parent_id IS NULL) -- Only for top-level reviews
|
||||
EXECUTE FUNCTION update_product_average_rating();
|
||||
|
||||
CREATE TRIGGER update_product_rating_on_update
|
||||
AFTER UPDATE OF is_approved, rating ON product_reviews
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.parent_id IS NULL) -- Only for top-level reviews
|
||||
EXECUTE FUNCTION update_product_average_rating();
|
||||
|
||||
CREATE TRIGGER update_product_rating_on_delete
|
||||
AFTER DELETE ON product_reviews
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.parent_id IS NULL) -- Only for top-level reviews
|
||||
EXECUTE FUNCTION update_product_average_rating();
|
||||
|
|
@ -1,23 +1,132 @@
|
|||
Rocks/
|
||||
project/
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── utils/
|
||||
│ │ │ └── imageUtils.js (4/24/2025)
|
||||
│ │ ├── theme/
|
||||
│ │ │ ├── ThemeProvider.jsx (4/24/2025)
|
||||
│ │ │ └── index.js (4/24/2025)
|
||||
│ │ ├── store/
|
||||
│ │ │ └── index.js (4/24/2025)
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── settingsAdminService.js (4/25/2025)
|
||||
│ │ │ ├── productService.js (4/24/2025)
|
||||
│ │ │ ├── imageService.js (4/24/2025)
|
||||
│ │ │ ├── couponService.js (NEW - 4/29/2025)
|
||||
│ │ │ ├── categoryAdminService.js (4/25/2025)
|
||||
│ │ │ ├── cartService.js (4/25/2025)
|
||||
│ │ │ ├── blogService.js (NEW - 4/29/2025)
|
||||
│ │ │ ├── blogAdminService.js (NEW - 4/29/2025)
|
||||
│ │ │ ├── authService.js (4/26/2025)
|
||||
│ │ │ ├── api.js (4/24/2025)
|
||||
│ │ │ └── adminService.js (4/26/2025)
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── Admin/
|
||||
│ │ │ │ ├── SettingsPage.jsx (4/26/2025)
|
||||
│ │ │ │ ├── ReportsPage.jsx (4/28/2025)
|
||||
│ │ │ │ ├── ProductsPage.jsx (4/24/2025)
|
||||
│ │ │ │ ├── ProductEditPage.jsx (4/28/2025)
|
||||
│ │ │ │ ├── OrdersPage.jsx (4/26/2025)
|
||||
│ │ │ │ ├── DashboardPage.jsx (4/28/2025)
|
||||
│ │ │ │ ├── CustomersPage.jsx (4/26/2025)
|
||||
│ │ │ │ ├── CouponsPage.jsx (NEW - 4/29/2025)
|
||||
│ │ │ │ ├── CategoriesPage.jsx (4/25/2025)
|
||||
│ │ │ │ ├── BlogPage.jsx (NEW - 4/29/2025)
|
||||
│ │ │ │ └── BlogEditPage.jsx (NEW - 4/29/2025)
|
||||
│ │ │ ├── BlogCommentsPage.jsx (NEW - 4/29/2025)
|
||||
│ │ │ ├── VerifyPage.jsx (4/24/2025)
|
||||
│ │ │ ├── UserOrdersPage.jsx (4/26/2025)
|
||||
│ │ │ ├── RegisterPage.jsx (4/24/2025)
|
||||
│ │ │ ├── ProductsPage.jsx (4/25/2025)
|
||||
│ │ │ ├── ProductDetailPage.jsx (4/26/2025)
|
||||
│ │ │ ├── PaymentSuccessPage.jsx (4/27/2025)
|
||||
│ │ │ ├── PaymentCancelPage.jsx (4/26/2025)
|
||||
│ │ │ ├── NotFoundPage.jsx (4/24/2025)
|
||||
│ │ │ ├── LoginPage.jsx (4/24/2025)
|
||||
│ │ │ ├── HomePage.jsx (4/25/2025)
|
||||
│ │ │ ├── CouponRedemptionsPage.jsx (NEW - 4/29/2025)
|
||||
│ │ │ ├── CouponEditPage.jsx (NEW - 4/29/2025)
|
||||
│ │ │ ├── CheckoutPage.jsx (4/28/2025)
|
||||
│ │ │ ├── CartPage.jsx (NEW - 4/29/2025)
|
||||
│ │ │ ├── BlogPage.jsx (NEW - 4/29/2025)
|
||||
│ │ │ └── BlogDetailPage.jsx (NEW - 4/29/2025)
|
||||
│ │ ├── layouts/
|
||||
│ │ │ ├── MainLayout.jsx (4/29/2025)
|
||||
│ │ │ ├── AuthLayout.jsx (4/24/2025)
|
||||
│ │ │ └── AdminLayout.jsx (4/29/2025)
|
||||
│ │ ├── hooks/
|
||||
│ │ │ ├── settingsAdminHooks.js (4/25/2025)
|
||||
│ │ │ ├── reduxHooks.js (4/26/2025)
|
||||
│ │ │ ├── couponAdminHooks.js (NEW - 4/29/2025)
|
||||
│ │ │ ├── categoryAdminHooks.js (4/24/2025)
|
||||
│ │ │ ├── blogHooks.js (NEW - 4/29/2025)
|
||||
│ │ │ ├── apiHooks.js (4/26/2025)
|
||||
│ │ │ └── adminHooks.js (4/26/2025)
|
||||
│ │ ├── features/
|
||||
│ │ │ ├── ui/
|
||||
│ │ │ │ └── uiSlice.js (4/24/2025)
|
||||
│ │ │ ├── cart/
|
||||
│ │ │ │ └── cartSlice.js (NEW - 4/29/2025)
|
||||
│ │ │ └── auth/
|
||||
│ │ │ └── authSlice.js (4/26/2025)
|
||||
│ │ ├── context/
|
||||
│ │ │ └── StripeContext.jsx (4/28/2025)
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── StripePaymentForm.jsx (4/26/2025)
|
||||
│ │ │ ├── ProtectedRoute.jsx (4/24/2025)
|
||||
│ │ │ ├── ProductImage.jsx (4/24/2025)
|
||||
│ │ │ ├── OrderStatusDialog.jsx (4/26/2025)
|
||||
│ │ │ ├── Notifications.jsx (4/24/2025)
|
||||
│ │ │ ├── ImageUploader.jsx (4/24/2025)
|
||||
│ │ │ ├── Footer.jsx (4/25/2025)
|
||||
│ │ │ ├── EmailDialog.jsx (4/25/2025)
|
||||
│ │ │ └── CouponInput.jsx (NEW - 4/29/2025)
|
||||
│ │ ├── assets/
|
||||
│ │ │ ├── main.jsx (4/24/2025)
|
||||
│ │ │ └── config.js (4/24/2025)
|
||||
│ │ └── App.jsx (4/24/2025)
|
||||
├── db/
|
||||
│ ├── test/
|
||||
│ └── init/
|
||||
│ ├── 16-blog-schema.sql (NEW)
|
||||
│ ├── 15-coupon.sql (NEW)
|
||||
│ ├── 14-product-notifications.sql (NEW)
|
||||
│ ├── 13-cart-metadata.sql
|
||||
│ ├── 12-shipping-orders.sql
|
||||
│ ├── 11-notifications.sql
|
||||
│ ├── 10-payment.sql
|
||||
│ ├── 09-system-settings.sql
|
||||
│ ├── 08-create-email.sql
|
||||
│ ├── 07-user-keys.sql
|
||||
│ ├── 06-product-categories.sql
|
||||
│ ├── 05-admin-role.sql
|
||||
│ ├── 04-product-images.sql
|
||||
│ ├── 03-api-key.sql
|
||||
│ ├── 02-seed.sql
|
||||
│ └── 01-schema.sql
|
||||
├── backend/
|
||||
│ ├── src/
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── shippingService.js
|
||||
│ │ │ └── notificationService.js
|
||||
│ │ │ └── shippingService.js
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── cart.js
|
||||
│ │ │ ├── userOrders.js
|
||||
│ │ │ ├── userAdmin.js
|
||||
│ │ │ ├── stripePayment.js
|
||||
│ │ │ ├── shipping.js
|
||||
│ │ │ ├── settingsAdmin.js
|
||||
│ │ │ ├── userOrders.js
|
||||
│ │ │ ├── orderAdmin.js
|
||||
│ │ │ ├── auth.js
|
||||
│ │ │ ├── userAdmin.js
|
||||
│ │ │ ├── products.js
|
||||
│ │ │ ├── categoryAdmin.js
|
||||
│ │ │ ├── productAdminImages.js
|
||||
│ │ │ ├── productAdmin.js
|
||||
│ │ │ ├── orderAdmin.js
|
||||
│ │ │ ├── images.js
|
||||
│ │ │ └── productAdmin.js
|
||||
│ │ │ ├── couponAdmin.js (NEW - Large file: 18.7 KB)
|
||||
│ │ │ ├── categoryAdmin.js
|
||||
│ │ │ ├── cart.js (Updated - now 39.6 KB)
|
||||
│ │ │ ├── blogCommentsAdmin.js (NEW)
|
||||
│ │ │ ├── blogAdmin.js (NEW)
|
||||
│ │ │ ├── blog.js (NEW)
|
||||
│ │ │ └── auth.js
|
||||
│ │ ├── models/
|
||||
│ │ │ └── SystemSettings.js
|
||||
│ │ ├── middleware/
|
||||
|
|
@ -28,116 +137,10 @@ Rocks/
|
|||
│ │ ├── index.js
|
||||
│ │ ├── index.js
|
||||
│ │ └── config.js
|
||||
│ ├── node_modules/
|
||||
│ ├── public/
|
||||
│ │ └── uploads/
|
||||
│ │ └── products/
|
||||
│ ├── .env
|
||||
│ ├── package.json
|
||||
│ ├── package-lock.json
|
||||
│ ├── Dockerfile
|
||||
│ ├── README.md
|
||||
│ └── .gitignore
|
||||
├── frontend/
|
||||
│ ├── node_modules/
|
||||
│ ├── src/
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── Admin/
|
||||
│ │ │ │ ├── OrdersPage.jsx
|
||||
│ │ │ │ ├── SettingsPage.jsx
|
||||
│ │ │ │ ├── CustomersPage.jsx
|
||||
│ │ │ │ ├── ProductEditPage.jsx
|
||||
│ │ │ │ ├── DashboardPage.jsx
|
||||
│ │ │ │ ├── CategoriesPage.jsx
|
||||
│ │ │ │ └── ProductsPage.jsx
|
||||
│ │ │ ├── CheckoutPage.jsx
|
||||
│ │ │ ├── PaymentSuccessPage.jsx
|
||||
│ │ │ ├── UserOrdersPage.jsx
|
||||
│ │ │ ├── PaymentCancelPage.jsx
|
||||
│ │ │ ├── ProductDetailPage.jsx
|
||||
│ │ │ ├── CartPage.jsx
|
||||
│ │ │ ├── ProductsPage.jsx
|
||||
│ │ │ ├── HomePage.jsx
|
||||
│ │ │ ├── VerifyPage.jsx
|
||||
│ │ │ ├── RegisterPage.jsx
|
||||
│ │ │ ├── NotFoundPage.jsx
|
||||
│ │ │ └── LoginPage.jsx
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── adminService.js
|
||||
│ │ │ ├── authService.js
|
||||
│ │ │ ├── settingsAdminService.js
|
||||
│ │ │ ├── cartService.js
|
||||
│ │ │ ├── categoryAdminService.js
|
||||
│ │ │ ├── imageService.js
|
||||
│ │ │ ├── productService.js
|
||||
│ │ │ └── api.js
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── OrderStatusDialog.jsx
|
||||
│ │ │ ├── StripePaymentForm.jsx
|
||||
│ │ │ ├── EmailDialog.jsx
|
||||
│ │ │ ├── Footer.jsx
|
||||
│ │ │ ├── ImageUploader.jsx
|
||||
│ │ │ ├── ProductImage.jsx
|
||||
│ │ │ ├── ProtectedRoute.jsx
|
||||
│ │ │ └── Notifications.jsx
|
||||
│ │ ├── context/
|
||||
│ │ │ └── StripeContext.jsx
|
||||
│ │ ├── hooks/
|
||||
│ │ │ ├── apiHooks.js
|
||||
│ │ │ ├── adminHooks.js
|
||||
│ │ │ ├── reduxHooks.js
|
||||
│ │ │ ├── settingsAdminHooks.js
|
||||
│ │ │ └── categoryAdminHooks.js
|
||||
│ │ ├── utils/
|
||||
│ │ │ └── imageUtils.js
|
||||
│ │ ├── layouts/
|
||||
│ │ │ ├── MainLayout.jsx
|
||||
│ │ │ ├── AdminLayout.jsx
|
||||
│ │ │ └── AuthLayout.jsx
|
||||
│ │ ├── theme/
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ └── ThemeProvider.jsx
|
||||
│ │ ├── features/
|
||||
│ │ │ ├── ui/
|
||||
│ │ │ │ └── uiSlice.js
|
||||
│ │ │ ├── cart/
|
||||
│ │ │ │ └── cartSlice.js
|
||||
│ │ │ ├── auth/
|
||||
│ │ │ │ └── authSlice.js
|
||||
│ │ │ └── store/
|
||||
│ │ │ └── index.js
|
||||
│ │ ├── assets/
|
||||
│ │ ├── App.jsx
|
||||
│ │ ├── config.js
|
||||
│ │ └── main.jsx
|
||||
│ └── public/
|
||||
│ ├── favicon.svg
|
||||
│ ├── package-lock.json
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.js
|
||||
│ ├── Dockerfile
|
||||
│ ├── nginx.conf
|
||||
│ ├── index.html
|
||||
│ ├── README.md
|
||||
│ ├── .env
|
||||
│ └── setup-frontend.sh
|
||||
├── db/
|
||||
│ ├── init/
|
||||
│ │ ├── 14-product-notifications.sql
|
||||
│ │ ├── 13-cart-metadata.sql
|
||||
│ │ ├── 12-shipping-orders.sql
|
||||
│ │ ├── 09-system-settings.sql
|
||||
│ │ ├── 11-notifications.sql
|
||||
│ │ ├── 10-payment.sql
|
||||
│ │ ├── 08-create-email.sql
|
||||
│ │ ├── 07-user-keys.sql
|
||||
│ │ ├── 06-product-categories.sql
|
||||
│ │ ├── 05-admin-role.sql
|
||||
│ │ ├── 02-seed.sql
|
||||
│ │ ├── 04-product-images.sql
|
||||
│ │ ├── 03-api-key.sql
|
||||
│ │ └── 01-schema.sql
|
||||
│ └── test/
|
||||
├── fileStructure.txt
|
||||
├── docker-compose.yml
|
||||
└── .gitignore
|
||||
│ └── uploads/
|
||||
│ ├── products/
|
||||
│ └── blog/ (NEW)
|
||||
└── git/
|
||||
├── fileStructure.txt
|
||||
└── docker-compose.yml
|
||||
|
|
@ -31,6 +31,16 @@ const AdminSettingsPage = lazy(() => import('@pages/Admin/SettingsPage'));
|
|||
const AdminReportsPage = lazy(() => import('@pages/Admin/ReportsPage'));
|
||||
const UserOrdersPage = lazy(() => import('@pages/UserOrdersPage'));
|
||||
const NotFoundPage = lazy(() => import('@pages/NotFoundPage'));
|
||||
const CouponsPage = lazy(() => import('@pages/Admin/CouponsPage'));
|
||||
const CouponEditPage = lazy(() => import('@pages/CouponEditPage'));
|
||||
const CouponRedemptionsPage = lazy(() => import('@pages/CouponRedemptionsPage'));
|
||||
const BlogPage = lazy(() => import('@pages/BlogPage'));
|
||||
const BlogDetailPage = lazy(() => import('@pages/BlogDetailPage'));
|
||||
const AdminBlogPage = lazy(() => import('@pages/Admin/BlogPage'));
|
||||
const BlogEditPage = lazy(() => import('@pages/Admin/BlogEditPage'));
|
||||
const AdminBlogCommentsPage = lazy(() => import('@pages/Admin/BlogCommentsPage'));
|
||||
const AdminProductReviewsPage = lazy(() => import('@pages/Admin/ProductReviewsPage'));
|
||||
|
||||
|
||||
// Loading component for suspense fallback
|
||||
const LoadingComponent = () => (
|
||||
|
|
@ -76,6 +86,9 @@ function App() {
|
|||
<PaymentCancelPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
{/* Blog routes */}
|
||||
<Route path="blog" element={<BlogPage />} />
|
||||
<Route path="blog/:slug" element={<BlogDetailPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Auth routes with AuthLayout */}
|
||||
|
|
@ -102,6 +115,15 @@ function App() {
|
|||
<Route path="settings" element={<AdminSettingsPage />} />
|
||||
<Route path="orders" element={<AdminOrdersPage />} />
|
||||
<Route path="reports" element={<AdminReportsPage />} />
|
||||
<Route path="coupons" element={<CouponsPage />} />
|
||||
<Route path="coupons/new" element={<CouponEditPage />} />
|
||||
<Route path="coupons/:id" element={<CouponEditPage />} />
|
||||
<Route path="coupons/:id/redemptions" element={<CouponRedemptionsPage />} />
|
||||
<Route path="blog" element={<AdminBlogPage />} />
|
||||
<Route path="blog/new" element={<BlogEditPage />} />
|
||||
<Route path="blog/:id" element={<BlogEditPage />} />
|
||||
<Route path="blog-comments" element={<AdminBlogCommentsPage />} />
|
||||
<Route path="product-reviews" element={<AdminProductReviewsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch-all route for 404s */}
|
||||
|
|
|
|||
138
frontend/src/components/CouponInput.jsx
Normal file
138
frontend/src/components/CouponInput.jsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
TextField,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Paper,
|
||||
Divider,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import couponService from '../services/couponService';
|
||||
import { useAuth } from '../hooks/reduxHooks';
|
||||
|
||||
/**
|
||||
* Component for inputting and applying coupon codes to the cart
|
||||
*/
|
||||
const CouponInput = () => {
|
||||
const [couponCode, setCouponCode] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get current cart data from cache
|
||||
const cartData = queryClient.getQueryData(['cart', user]);
|
||||
|
||||
// Apply coupon mutation
|
||||
const applyCoupon = useMutation({
|
||||
mutationFn: ({ userId, code }) => couponService.applyCoupon(userId, code),
|
||||
onSuccess: (data) => {
|
||||
// Update React Query cache directly
|
||||
queryClient.setQueryData(['cart', user], data);
|
||||
setError(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error.message || 'Failed to apply coupon');
|
||||
},
|
||||
});
|
||||
|
||||
// Remove coupon mutation
|
||||
const removeCoupon = useMutation({
|
||||
mutationFn: (userId) => couponService.removeCoupon(userId),
|
||||
onSuccess: (data) => {
|
||||
// Update React Query cache directly
|
||||
queryClient.setQueryData(['cart', user], data);
|
||||
setError(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error.message || 'Failed to remove coupon');
|
||||
},
|
||||
});
|
||||
|
||||
// Handle coupon code input change
|
||||
const handleCouponChange = (e) => {
|
||||
setCouponCode(e.target.value.toUpperCase());
|
||||
setError(null);
|
||||
};
|
||||
|
||||
// Handle applying coupon
|
||||
const handleApplyCoupon = () => {
|
||||
if (!couponCode) {
|
||||
setError('Please enter a coupon code');
|
||||
return;
|
||||
}
|
||||
|
||||
applyCoupon.mutate({ userId: user, code: couponCode });
|
||||
};
|
||||
|
||||
// Handle removing coupon
|
||||
const handleRemoveCoupon = () => {
|
||||
removeCoupon.mutate(user);
|
||||
setCouponCode('');
|
||||
};
|
||||
|
||||
// Check if a coupon is already applied
|
||||
const hasCoupon = cartData?.couponCode;
|
||||
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Discount Code
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasCoupon ? (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Chip
|
||||
label={cartData.couponCode}
|
||||
color="success"
|
||||
sx={{ mr: 2 }}
|
||||
/>
|
||||
<Typography variant="body2" color="success.main">
|
||||
Discount applied: -${cartData.couponDiscount?.toFixed(2)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemoveCoupon}
|
||||
disabled={removeCoupon.isLoading}
|
||||
>
|
||||
{removeCoupon.isLoading ? <CircularProgress size={24} /> : 'Remove Coupon'}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Enter coupon code"
|
||||
value={couponCode}
|
||||
onChange={handleCouponChange}
|
||||
disabled={applyCoupon.isLoading}
|
||||
inputProps={{ style: { textTransform: 'uppercase' } }}
|
||||
sx={{ mr: 2 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleApplyCoupon}
|
||||
disabled={!couponCode || applyCoupon.isLoading}
|
||||
>
|
||||
{applyCoupon.isLoading ? <CircularProgress size={24} /> : 'Apply'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CouponInput;
|
||||
27
frontend/src/components/ProductRatingDisplay.jsx
Normal file
27
frontend/src/components/ProductRatingDisplay.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography, Rating } from '@mui/material';
|
||||
|
||||
/**
|
||||
* Component to display product rating in a compact format
|
||||
*/
|
||||
const ProductRatingDisplay = ({ rating, reviewCount, showEmpty = false }) => {
|
||||
if (!rating && !reviewCount && !showEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', my: 1 }}>
|
||||
<Rating
|
||||
value={rating || 0}
|
||||
readOnly
|
||||
precision={0.5}
|
||||
size="small"
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 0.5 }}>
|
||||
{reviewCount ? `(${reviewCount})` : showEmpty ? '(0)' : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductRatingDisplay;
|
||||
328
frontend/src/components/ProductReviews.jsx
Normal file
328
frontend/src/components/ProductReviews.jsx
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Rating,
|
||||
Avatar,
|
||||
Button,
|
||||
TextField,
|
||||
Paper,
|
||||
Divider,
|
||||
Card,
|
||||
CardContent,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Alert,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import { format } from 'date-fns';
|
||||
import CommentIcon from '@mui/icons-material/Comment';
|
||||
import VerifiedIcon from '@mui/icons-material/Verified';
|
||||
import { useProductReviews, useCanReviewProduct, useAddProductReview } from '@hooks/productReviewHooks';
|
||||
import { useAuth } from '@hooks/reduxHooks';
|
||||
|
||||
/**
|
||||
* Component for displaying product reviews and allowing users to submit new reviews
|
||||
*/
|
||||
const ProductReviews = ({ productId }) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [replyTo, setReplyTo] = useState(null);
|
||||
const [showReviewForm, setShowReviewForm] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
content: '',
|
||||
rating: 0
|
||||
});
|
||||
|
||||
// Fetch reviews for this product
|
||||
const { data: reviews, isLoading: reviewsLoading } = useProductReviews(productId);
|
||||
|
||||
// Check if user can submit a review
|
||||
const { data: reviewPermission, isLoading: permissionLoading } = useCanReviewProduct(productId);
|
||||
|
||||
// Add review mutation
|
||||
const addReview = useAddProductReview();
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
return format(new Date(dateString), 'MMMM d, yyyy');
|
||||
};
|
||||
|
||||
// Handle form changes
|
||||
const handleFormChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Handle rating change
|
||||
const handleRatingChange = (event, newValue) => {
|
||||
setFormData(prev => ({ ...prev, rating: newValue }));
|
||||
};
|
||||
|
||||
// Handle submit review
|
||||
const handleSubmitReview = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.title) {
|
||||
return; // Title is required
|
||||
}
|
||||
|
||||
if (!replyTo && (!formData.rating || formData.rating < 1)) {
|
||||
return; // Rating is required for top-level reviews
|
||||
}
|
||||
|
||||
const reviewData = {
|
||||
title: formData.title,
|
||||
content: formData.content,
|
||||
rating: replyTo ? undefined : formData.rating,
|
||||
parentId: replyTo ? replyTo.id : undefined
|
||||
};
|
||||
|
||||
addReview.mutate({
|
||||
productId,
|
||||
reviewData
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
// Reset form
|
||||
setFormData({
|
||||
title: '',
|
||||
content: '',
|
||||
rating: 0
|
||||
});
|
||||
setReplyTo(null);
|
||||
setShowReviewForm(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Render a single review
|
||||
const renderReview = (review, isReply = false) => (
|
||||
<Card
|
||||
key={review.id}
|
||||
variant={isReply ? "outlined" : "elevation"}
|
||||
sx={{
|
||||
mb: 2,
|
||||
ml: isReply ? 4 : 0,
|
||||
bgcolor: isReply ? 'background.paper' : undefined
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Avatar sx={{ mr: 1 }}>
|
||||
{review.first_name ? review.first_name[0] : '?'}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="subtitle1">
|
||||
{review.first_name} {review.last_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(review.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{review.is_verified_purchase && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<VerifiedIcon color="primary" fontSize="small" sx={{ mr: 0.5 }} />
|
||||
<Typography variant="caption" color="primary">
|
||||
Verified Purchase
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!isReply && review.rating && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Rating value={review.rating} readOnly precision={0.5} />
|
||||
<Typography variant="body2" sx={{ ml: 1 }}>
|
||||
({review.rating}/5)
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="h6" gutterBottom>{review.title}</Typography>
|
||||
|
||||
{review.content && (
|
||||
<Typography variant="body2" paragraph>
|
||||
{review.content}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{isAuthenticated && (
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<CommentIcon />}
|
||||
onClick={() => {
|
||||
setReplyTo(review);
|
||||
setShowReviewForm(true);
|
||||
// Scroll to form
|
||||
document.getElementById('review-form')?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reply
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Render replies */}
|
||||
{review.replies && review.replies.length > 0 && (
|
||||
<Box sx={{ px: 2, pb: 2 }}>
|
||||
{review.replies.map(reply => renderReview(reply, true))}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Customer Reviews
|
||||
{reviews && reviews.length > 0 && (
|
||||
<Typography component="span" variant="body2" color="text.secondary" sx={{ ml: 1 }}>
|
||||
({reviews.length})
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
{/* Write a review button */}
|
||||
{isAuthenticated && !permissionLoading && reviewPermission && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{!showReviewForm ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
setReplyTo(null);
|
||||
setShowReviewForm(true);
|
||||
}}
|
||||
disabled={!reviewPermission?.canReview}
|
||||
>
|
||||
Write a Review
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setReplyTo(null);
|
||||
setShowReviewForm(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!reviewPermission?.canReview && !reviewPermission?.isAdmin && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
{reviewPermission?.reason || 'You need to purchase this product before you can review it.'}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Review form */}
|
||||
{showReviewForm && isAuthenticated && reviewPermission && (reviewPermission.canReview || replyTo) && (
|
||||
<Paper id="review-form" sx={{ p: 3, mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{replyTo ? `Reply to ${replyTo.first_name}'s Review` : 'Write a Review'}
|
||||
</Typography>
|
||||
|
||||
{replyTo && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
Replying to: "{replyTo.title}"
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setReplyTo(null)}
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
Cancel Reply
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmitReview}>
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
label="Review Title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleFormChange}
|
||||
margin="normal"
|
||||
/>
|
||||
|
||||
{!replyTo && (
|
||||
<Box sx={{ my: 2 }}>
|
||||
<Typography component="legend">Rating *</Typography>
|
||||
<Rating
|
||||
name="rating"
|
||||
value={formData.rating}
|
||||
onChange={handleRatingChange}
|
||||
precision={0.5}
|
||||
size="large"
|
||||
/>
|
||||
{formData.rating === 0 && (
|
||||
<Typography variant="caption" color="error">
|
||||
Please select a rating
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
label="Review"
|
||||
name="content"
|
||||
value={formData.content}
|
||||
onChange={handleFormChange}
|
||||
margin="normal"
|
||||
placeholder={replyTo ? "Write your reply..." : "Share your experience with this product..."}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={
|
||||
addReview.isLoading ||
|
||||
!formData.title ||
|
||||
(!replyTo && formData.rating < 1)
|
||||
}
|
||||
>
|
||||
{addReview.isLoading ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
replyTo ? 'Post Reply' : 'Submit Review'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Reviews list */}
|
||||
{reviewsLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : reviews && reviews.length > 0 ? (
|
||||
<Box>
|
||||
{reviews.map(review => renderReview(review))}
|
||||
</Box>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
This product doesn't have any reviews yet. Be the first to review it!
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductReviews;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
235
frontend/src/hooks/blogHooks.js
Normal file
235
frontend/src/hooks/blogHooks.js
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import blogService from '@services/blogService';
|
||||
import blogAdminService from '@services/blogAdminService';
|
||||
import { useNotification } from './reduxHooks';
|
||||
|
||||
// Public blog hooks
|
||||
export const useBlogPosts = (params) => {
|
||||
return useQuery({
|
||||
queryKey: ['blog-posts', params],
|
||||
queryFn: () => blogService.getAllPosts(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useBlogPost = (slug) => {
|
||||
return useQuery({
|
||||
queryKey: ['blog-post', slug],
|
||||
queryFn: () => blogService.getPostBySlug(slug),
|
||||
enabled: !!slug,
|
||||
});
|
||||
};
|
||||
|
||||
export const useBlogCategories = () => {
|
||||
return useQuery({
|
||||
queryKey: ['blog-categories'],
|
||||
queryFn: () => blogService.getAllCategories(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddComment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ postId, commentData }) => blogService.addComment(postId, commentData),
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['blog-post', variables.postId] });
|
||||
notification.showNotification(
|
||||
data.message || 'Comment added successfully',
|
||||
'success'
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to add comment',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Admin blog hooks
|
||||
export const useAdminBlogPosts = () => {
|
||||
return useQuery({
|
||||
queryKey: ['admin-blog-posts'],
|
||||
queryFn: () => blogAdminService.getAllPosts(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useAdminBlogPost = (id) => {
|
||||
return useQuery({
|
||||
queryKey: ['admin-blog-post', id],
|
||||
queryFn: () => blogAdminService.getPostById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateBlogPost = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (postData) => blogAdminService.createPost(postData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] });
|
||||
notification.showNotification(
|
||||
'Blog post created successfully',
|
||||
'success'
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to create blog post',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateBlogPost = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, postData }) => blogAdminService.updatePost(id, postData),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-blog-post', variables.id] });
|
||||
notification.showNotification(
|
||||
'Blog post updated successfully',
|
||||
'success'
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to update blog post',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteBlogPost = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id) => blogAdminService.deletePost(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] });
|
||||
notification.showNotification(
|
||||
'Blog post deleted successfully',
|
||||
'success'
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to delete blog post',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUploadBlogImage = () => {
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ postId, imageData }) => blogAdminService.uploadImage(postId, imageData),
|
||||
onSuccess: (_, variables) => {
|
||||
notification.showNotification(
|
||||
'Image uploaded successfully',
|
||||
'success'
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to upload image',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteBlogImage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ postId, imageId }) => blogAdminService.deleteImage(postId, imageId),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-blog-post', variables.postId] });
|
||||
notification.showNotification(
|
||||
'Image deleted successfully',
|
||||
'success'
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to delete image',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePendingComments = () => {
|
||||
return useQuery({
|
||||
queryKey: ['pending-comments'],
|
||||
queryFn: () => blogAdminService.getPendingComments(),
|
||||
});
|
||||
};
|
||||
|
||||
export const usePostComments = (postId) => {
|
||||
return useQuery({
|
||||
queryKey: ['post-comments', postId],
|
||||
queryFn: () => blogAdminService.getPostComments(postId),
|
||||
enabled: !!postId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useApproveComment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (commentId) => blogAdminService.approveComment(commentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pending-comments'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['post-comments'] });
|
||||
notification.showNotification(
|
||||
'Comment approved successfully',
|
||||
'success'
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to approve comment',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteComment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (commentId) => blogAdminService.deleteComment(commentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pending-comments'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['post-comments'] });
|
||||
notification.showNotification(
|
||||
'Comment deleted successfully',
|
||||
'success'
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to delete comment',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
117
frontend/src/hooks/couponAdminHooks.js
Normal file
117
frontend/src/hooks/couponAdminHooks.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import couponService from '../services/couponService';
|
||||
import { useNotification } from './reduxHooks';
|
||||
|
||||
/**
|
||||
* Hook for fetching all coupons (admin only)
|
||||
*/
|
||||
export const useAdminCoupons = () => {
|
||||
return useQuery({
|
||||
queryKey: ['admin-coupons'],
|
||||
queryFn: couponService.getAllCoupons
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching a single coupon by ID (admin only)
|
||||
* @param {string} id - Coupon ID
|
||||
*/
|
||||
export const useAdminCoupon = (id) => {
|
||||
return useQuery({
|
||||
queryKey: ['admin-coupon', id],
|
||||
queryFn: () => couponService.getCouponById(id),
|
||||
enabled: !!id
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for creating a new coupon (admin only)
|
||||
*/
|
||||
export const useCreateCoupon = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (couponData) => couponService.createCoupon(couponData),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-coupons'] });
|
||||
notification.showNotification('Coupon created successfully', 'success');
|
||||
return data;
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to create coupon',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for updating a coupon (admin only)
|
||||
*/
|
||||
export const useUpdateCoupon = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, couponData }) => couponService.updateCoupon(id, couponData),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-coupons'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-coupon', data.coupon?.id] });
|
||||
notification.showNotification('Coupon updated successfully', 'success');
|
||||
return data;
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to update coupon',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for deleting a coupon (admin only)
|
||||
*/
|
||||
export const useDeleteCoupon = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id) => couponService.deleteCoupon(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-coupons'] });
|
||||
notification.showNotification('Coupon deleted successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to delete coupon',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching coupon redemption history (admin only)
|
||||
*/
|
||||
export const useCouponRedemptions = (id) => {
|
||||
return useQuery({
|
||||
queryKey: ['admin-coupon-redemptions', id],
|
||||
queryFn: () => couponService.getCouponRedemptions(id),
|
||||
enabled: !!id
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
useAdminCoupons,
|
||||
useAdminCoupon,
|
||||
useCreateCoupon,
|
||||
useUpdateCoupon,
|
||||
useDeleteCoupon,
|
||||
useCouponRedemptions
|
||||
};
|
||||
114
frontend/src/hooks/productReviewHooks.js
Normal file
114
frontend/src/hooks/productReviewHooks.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import productReviewService, { productReviewAdminService } from '@services/productReviewService';
|
||||
import { useNotification } from './reduxHooks';
|
||||
|
||||
// User-facing review hooks
|
||||
export const useProductReviews = (productId) => {
|
||||
return useQuery({
|
||||
queryKey: ['product-reviews', productId],
|
||||
queryFn: () => productReviewService.getProductReviews(productId),
|
||||
enabled: !!productId
|
||||
});
|
||||
};
|
||||
|
||||
export const useCanReviewProduct = (productId) => {
|
||||
return useQuery({
|
||||
queryKey: ['can-review-product', productId],
|
||||
queryFn: () => productReviewService.canReviewProduct(productId),
|
||||
enabled: !!productId
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddProductReview = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ productId, reviewData }) =>
|
||||
productReviewService.addProductReview(productId, reviewData),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate reviews for this product
|
||||
queryClient.invalidateQueries({ queryKey: ['product-reviews', variables.productId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['can-review-product', variables.productId] });
|
||||
|
||||
notification.showNotification(
|
||||
data.message || 'Review submitted successfully',
|
||||
'success'
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to submit review',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Admin review hooks
|
||||
export const usePendingReviews = () => {
|
||||
return useQuery({
|
||||
queryKey: ['pending-reviews'],
|
||||
queryFn: productReviewAdminService.getPendingReviews
|
||||
});
|
||||
};
|
||||
|
||||
export const useAdminProductReviews = (productId) => {
|
||||
return useQuery({
|
||||
queryKey: ['admin-product-reviews', productId],
|
||||
queryFn: () => productReviewAdminService.getProductReviews(productId),
|
||||
enabled: !!productId
|
||||
});
|
||||
};
|
||||
|
||||
export const useApproveReview = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (reviewId) => productReviewAdminService.approveReview(reviewId),
|
||||
onSuccess: () => {
|
||||
// Invalidate both pending reviews and product reviews
|
||||
queryClient.invalidateQueries({ queryKey: ['pending-reviews'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-product-reviews'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['product-reviews'] });
|
||||
|
||||
notification.showNotification(
|
||||
'Review approved successfully',
|
||||
'success'
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to approve review',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteReview = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (reviewId) => productReviewAdminService.deleteReview(reviewId),
|
||||
onSuccess: () => {
|
||||
// Invalidate both pending reviews and product reviews
|
||||
queryClient.invalidateQueries({ queryKey: ['pending-reviews'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-product-reviews'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['product-reviews'] });
|
||||
|
||||
notification.showNotification(
|
||||
'Review deleted successfully',
|
||||
'success'
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to delete review',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -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: <ClassIcon />, path: '/admin/categories' },
|
||||
{ text: 'Orders', icon: <ShoppingCartIcon />, path: '/admin/orders' },
|
||||
{ text: 'Customers', icon: <PeopleIcon />, path: '/admin/customers' },
|
||||
{ text: 'Coupons', icon: <LocalOfferIcon />, path: '/admin/coupons' },
|
||||
{ text: 'Blog', icon: <BookIcon />, path: '/admin/blog' },
|
||||
{ text: 'Blog Comments', icon: <CommentIcon />, path: '/admin/blog-comments' },
|
||||
{ text: 'Settings', icon: <SettingsIcon />, path: '/admin/settings' },
|
||||
{ text: 'Reports', icon: <BarChartIcon />, path: '/admin/reports' },
|
||||
];
|
||||
{ text: 'Product Reviews', icon: <RateReviewIcon />, path: '/admin/product-reviews' },
|
||||
];
|
||||
|
||||
const secondaryListItems = [
|
||||
{ text: 'Visit Site', icon: <HomeIcon />, path: '/' },
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import DashboardIcon from '@mui/icons-material/Dashboard';
|
|||
import Brightness4Icon from '@mui/icons-material/Brightness4';
|
||||
import Brightness7Icon from '@mui/icons-material/Brightness7';
|
||||
import ReceiptIcon from '@mui/icons-material/Receipt';
|
||||
import BookIcon from '@mui/icons-material/Book';
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth, useCart, useDarkMode } from '../hooks/reduxHooks';
|
||||
import Footer from '../components/Footer';
|
||||
|
|
@ -41,6 +42,7 @@ const MainLayout = () => {
|
|||
let mainMenu = [
|
||||
{ text: 'Home', icon: <HomeIcon />, path: '/' },
|
||||
{ text: 'Products', icon: <CategoryIcon />, path: '/products' },
|
||||
{ text: 'Blog', icon: <BookIcon />, path: '/blog' },
|
||||
{ text: 'Cart', icon: <ShoppingCartIcon />, path: '/cart', badge: itemCount > 0 ? itemCount : null },
|
||||
];
|
||||
if (isAuthenticated) {
|
||||
|
|
|
|||
333
frontend/src/pages/Admin/BlogCommentsPage.jsx
Normal file
333
frontend/src/pages/Admin/BlogCommentsPage.jsx
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Tabs,
|
||||
Tab,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Button,
|
||||
Divider,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Check as ApproveIcon,
|
||||
Delete as DeleteIcon,
|
||||
Search as SearchIcon,
|
||||
Clear as ClearIcon,
|
||||
Visibility as ViewIcon,
|
||||
Refresh as RefreshIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { usePendingComments, useApproveComment, useDeleteComment } from '@hooks/blogHooks';
|
||||
import { format } from 'date-fns';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const AdminBlogCommentsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [commentToDelete, setCommentToDelete] = useState(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Fetch pending comments
|
||||
const { data: pendingComments, isLoading, error } = usePendingComments();
|
||||
const approveComment = useApproveComment();
|
||||
const deleteComment = useDeleteComment();
|
||||
|
||||
// Filter comments by search term
|
||||
const filteredComments = pendingComments ? pendingComments.filter(comment =>
|
||||
comment.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
comment.first_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
comment.last_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
comment.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
comment.post_title?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [];
|
||||
|
||||
// Handle tab change
|
||||
const handleTabChange = (event, newValue) => {
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
|
||||
// Handle search input change
|
||||
const handleSearchChange = (e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
};
|
||||
|
||||
// Clear search
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
// Handle refresh
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await queryClient.invalidateQueries(['pending-comments']);
|
||||
// Optional timeout to ensure the refresh button animation is visible
|
||||
setTimeout(() => {
|
||||
setIsRefreshing(false);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error refreshing comments:', error);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle view post
|
||||
const handleViewPost = (slug) => {
|
||||
window.open(`/blog/${slug}`, '_blank');
|
||||
};
|
||||
|
||||
// Handle approve comment
|
||||
const handleApproveComment = async (id) => {
|
||||
try {
|
||||
await approveComment.mutateAsync(id);
|
||||
} catch (error) {
|
||||
console.error('Error approving comment:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete dialog open
|
||||
const handleDeleteClick = (comment) => {
|
||||
setCommentToDelete(comment);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// Handle delete confirmation
|
||||
const handleConfirmDelete = async () => {
|
||||
if (commentToDelete) {
|
||||
await deleteComment.mutateAsync(commentToDelete.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setCommentToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete cancellation
|
||||
const handleCancelDelete = () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setCommentToDelete(null);
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString) => {
|
||||
return format(new Date(dateString), 'MMM d, yyyy h:mm a');
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading && !isRefreshing) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 2 }}>
|
||||
Error loading comments: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Blog Comments
|
||||
</Typography>
|
||||
<Tooltip title="Refresh comments">
|
||||
<IconButton
|
||||
onClick={handleRefresh}
|
||||
color="primary"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
<RefreshIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant="fullWidth"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
<Tab label={`Pending Approval (${pendingComments?.length || 0})`} />
|
||||
<Tab label="All Comments" />
|
||||
</Tabs>
|
||||
|
||||
{/* Search Box */}
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search comments by content, author, or post..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchTerm && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" onClick={handleClearSearch}>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Comments List */}
|
||||
{filteredComments.length === 0 ? (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
No pending comments found
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{searchTerm ? 'Try adjusting your search terms' : 'All comments have been reviewed'}
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<RefreshIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleRefresh}
|
||||
sx={{ mt: 2 }}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Paper>
|
||||
) : (
|
||||
<Box>
|
||||
{filteredComments.map(comment => (
|
||||
<Card key={comment.id} sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1">
|
||||
{comment.first_name} {comment.last_name} ({comment.email})
|
||||
</Typography>
|
||||
<Chip
|
||||
label={formatDate(comment.created_at)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Typography variant="body2" paragraph>
|
||||
{comment.content}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ bgcolor: 'background.paper', p: 1, borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
On post:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{comment.post_title}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
<CardActions sx={{ justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
startIcon={<ViewIcon />}
|
||||
onClick={() => handleViewPost(comment.post_slug)}
|
||||
>
|
||||
View Post
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<ApproveIcon />}
|
||||
color="success"
|
||||
onClick={() => handleApproveComment(comment.id)}
|
||||
disabled={approveComment.isLoading}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<DeleteIcon />}
|
||||
color="error"
|
||||
onClick={() => handleDeleteClick(comment)}
|
||||
disabled={deleteComment.isLoading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={handleCancelDelete}
|
||||
aria-labelledby="delete-dialog-title"
|
||||
aria-describedby="delete-dialog-description"
|
||||
>
|
||||
<DialogTitle id="delete-dialog-title">
|
||||
Confirm Delete
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="delete-dialog-description">
|
||||
Are you sure you want to delete this comment? This action cannot be undone.
|
||||
</DialogContentText>
|
||||
|
||||
{commentToDelete && (
|
||||
<Paper variant="outlined" sx={{ p: 2, mt: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Comment by {commentToDelete.first_name} {commentToDelete.last_name}:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
{commentToDelete.content}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancelDelete}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleConfirmDelete}
|
||||
color="error"
|
||||
variant="contained"
|
||||
autoFocus
|
||||
disabled={deleteComment.isLoading}
|
||||
>
|
||||
{deleteComment.isLoading ? (
|
||||
<CircularProgress size={24} sx={{ mr: 1 }} />
|
||||
) : null}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminBlogCommentsPage;
|
||||
476
frontend/src/pages/Admin/BlogEditPage.jsx
Normal file
476
frontend/src/pages/Admin/BlogEditPage.jsx
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
TextField,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
Grid,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
FormHelperText,
|
||||
Autocomplete,
|
||||
IconButton,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
CardActions,
|
||||
Tooltip,
|
||||
Breadcrumbs,
|
||||
Link
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack as ArrowBackIcon,
|
||||
Save as SaveIcon,
|
||||
Preview as PreviewIcon,
|
||||
Delete as DeleteIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useParams, Link as RouterLink } from 'react-router-dom';
|
||||
import { useAdminBlogPost, useCreateBlogPost, useUpdateBlogPost, useBlogCategories } from '@hooks/blogHooks';
|
||||
import { useAuth } from '@hooks/reduxHooks';
|
||||
import ImageUploader from '@components/ImageUploader';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
const BlogEditPage = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const isNewPost = !id;
|
||||
const { userData } = useAuth();
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
content: '',
|
||||
excerpt: '',
|
||||
categoryId: '',
|
||||
tags: [],
|
||||
featuredImagePath: '',
|
||||
status: 'draft',
|
||||
publishNow: false
|
||||
});
|
||||
|
||||
// Validation state
|
||||
const [errors, setErrors] = useState({});
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [notification, setNotification] = useState({ type: 'success', message: '' });
|
||||
|
||||
// Fetch blog post if editing
|
||||
const {
|
||||
data: post,
|
||||
isLoading: postLoading,
|
||||
error: postError
|
||||
} = useAdminBlogPost(isNewPost ? null : id);
|
||||
|
||||
// Fetch categories
|
||||
const { data: categories } = useBlogCategories();
|
||||
|
||||
// Mutations
|
||||
const createPost = useCreateBlogPost();
|
||||
const updatePost = useUpdateBlogPost();
|
||||
|
||||
// Handle form changes
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// Clear validation error
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle switch changes
|
||||
const handleSwitchChange = (e) => {
|
||||
const { name, checked } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: checked }));
|
||||
};
|
||||
|
||||
// Handle tags change
|
||||
const handleTagsChange = (event, newValue) => {
|
||||
setFormData(prev => ({ ...prev, tags: newValue }));
|
||||
};
|
||||
|
||||
// Handle featured image change
|
||||
const handleFeaturedImageChange = (images) => {
|
||||
if (images && images.length > 0) {
|
||||
setFormData(prev => ({ ...prev, featuredImagePath: images[0].path }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, featuredImagePath: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
// Clear featured image
|
||||
const handleClearFeaturedImage = () => {
|
||||
setFormData(prev => ({ ...prev, featuredImagePath: '' }));
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = 'Title is required';
|
||||
}
|
||||
|
||||
if (!formData.content.trim()) {
|
||||
newErrors.content = 'Content is required';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
setNotification({
|
||||
type: 'error',
|
||||
message: 'Please fix the form errors before submitting'
|
||||
});
|
||||
setNotificationOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formattedData = {
|
||||
...formData,
|
||||
// Convert tag objects to string names if needed
|
||||
tags: formData.tags.map(tag => typeof tag === 'string' ? tag : tag.name)
|
||||
};
|
||||
|
||||
if (isNewPost) {
|
||||
await createPost.mutateAsync(formattedData);
|
||||
navigate('/admin/blog');
|
||||
} else {
|
||||
await updatePost.mutateAsync({ id, postData: formattedData });
|
||||
setNotification({
|
||||
type: 'success',
|
||||
message: 'Blog post updated successfully'
|
||||
});
|
||||
setNotificationOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
setNotification({
|
||||
type: 'error',
|
||||
message: error.message || 'An error occurred while saving the post'
|
||||
});
|
||||
setNotificationOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Preview post
|
||||
const handlePreview = () => {
|
||||
// Store current form data in session storage
|
||||
sessionStorage.setItem('blog-post-preview', JSON.stringify(formData));
|
||||
window.open('/admin/blog/preview', '_blank');
|
||||
};
|
||||
|
||||
// Load post data when available
|
||||
useEffect(() => {
|
||||
if (post && !isNewPost) {
|
||||
setFormData({
|
||||
title: post.title || '',
|
||||
content: post.content || '',
|
||||
excerpt: post.excerpt || '',
|
||||
categoryId: post.category_id || '',
|
||||
tags: post.tags || [],
|
||||
featuredImagePath: post.featured_image_path || '',
|
||||
status: post.status || 'draft',
|
||||
publishNow: false
|
||||
});
|
||||
}
|
||||
}, [post, isNewPost]);
|
||||
|
||||
// Loading state
|
||||
if (postLoading && !isNewPost) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (postError && !isNewPost) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 2 }}>
|
||||
Error loading blog post: {postError.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Button
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate('/admin/blog')}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Back to Blog Posts
|
||||
</Button>
|
||||
|
||||
<Breadcrumbs sx={{ mb: 2 }}>
|
||||
<Link component={RouterLink} to="/admin" color="inherit">
|
||||
Admin
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/admin/blog" color="inherit">
|
||||
Blog
|
||||
</Link>
|
||||
<Typography color="text.primary">
|
||||
{isNewPost ? 'Create Post' : 'Edit Post'}
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
{isNewPost ? 'Create New Blog Post' : `Edit Blog Post: ${post?.title || ''}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Form */}
|
||||
<Paper component="form" onSubmit={handleSubmit} sx={{ p: 3 }}>
|
||||
{notificationOpen && (
|
||||
<Alert
|
||||
severity={notification.type}
|
||||
sx={{ mb: 3 }}
|
||||
onClose={() => setNotificationOpen(false)}
|
||||
>
|
||||
{notification.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Title */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
label="Post Title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
error={!!errors.title}
|
||||
helperText={errors.title}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Category */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="category-label">Category</InputLabel>
|
||||
<Select
|
||||
labelId="category-label"
|
||||
name="categoryId"
|
||||
value={formData.categoryId}
|
||||
onChange={handleChange}
|
||||
label="Category"
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None (Uncategorized)</em>
|
||||
</MenuItem>
|
||||
{categories?.map((category) => (
|
||||
<MenuItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* Status */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="status-label">Status</InputLabel>
|
||||
<Select
|
||||
labelId="status-label"
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
label="Status"
|
||||
>
|
||||
<MenuItem value="draft">Draft</MenuItem>
|
||||
<MenuItem value="published">Published</MenuItem>
|
||||
<MenuItem value="archived">Archived</MenuItem>
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
{formData.status === 'published' ?
|
||||
'Published posts are visible to all users' :
|
||||
formData.status === 'draft' ?
|
||||
'Drafts are only visible to admins' :
|
||||
'Archived posts are hidden but not deleted'
|
||||
}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* Publish now option */}
|
||||
{formData.status === 'published' && !post?.published_at && (
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.publishNow}
|
||||
onChange={handleSwitchChange}
|
||||
name="publishNow"
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Publish immediately (sets published date to now)"
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Featured Image */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Featured Image
|
||||
</Typography>
|
||||
|
||||
{formData.featuredImagePath ? (
|
||||
<Card sx={{ maxWidth: 400, mb: 2 }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={imageUtils.getImageUrl(formData.featuredImagePath)}
|
||||
alt="Featured image"
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
<CardActions sx={{ justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
startIcon={<DeleteIcon />}
|
||||
color="error"
|
||||
onClick={handleClearFeaturedImage}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
) : (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<ImageUploader
|
||||
multiple={false}
|
||||
onChange={handleFeaturedImageChange}
|
||||
admin={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Content */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Post Content
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
multiline
|
||||
minRows={10}
|
||||
maxRows={20}
|
||||
name="content"
|
||||
value={formData.content}
|
||||
onChange={handleChange}
|
||||
placeholder="Write your blog post content here..."
|
||||
error={!!errors.content}
|
||||
helperText={errors.content}
|
||||
variant="outlined"
|
||||
/>
|
||||
<FormHelperText>
|
||||
Pro tip: You can use HTML markup for formatting
|
||||
</FormHelperText>
|
||||
</Grid>
|
||||
|
||||
{/* Excerpt */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
label="Excerpt"
|
||||
name="excerpt"
|
||||
value={formData.excerpt}
|
||||
onChange={handleChange}
|
||||
placeholder="Write a short summary of your post (optional)"
|
||||
variant="outlined"
|
||||
helperText="If left empty, an excerpt will be automatically generated from your content"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Tags */}
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
options={[]}
|
||||
value={formData.tags}
|
||||
onChange={handleTagsChange}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip
|
||||
label={typeof option === 'string' ? option : option.name}
|
||||
{...getTagProps({ index })}
|
||||
key={index}
|
||||
/>
|
||||
))
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Tags"
|
||||
placeholder="Add tags and press Enter"
|
||||
helperText="Tags help users find related content"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Actions */}
|
||||
<Grid item xs={12} sx={{ mt: 2, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<PreviewIcon />}
|
||||
onClick={handlePreview}
|
||||
disabled={!formData.title || !formData.content}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
sx={{ mr: 2 }}
|
||||
onClick={() => navigate('/admin/blog')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={createPost.isLoading || updatePost.isLoading ?
|
||||
<CircularProgress size={20} /> : <SaveIcon />}
|
||||
disabled={createPost.isLoading || updatePost.isLoading}
|
||||
>
|
||||
{createPost.isLoading || updatePost.isLoading ?
|
||||
'Saving...' : (isNewPost ? 'Create' : 'Update')} Post
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogEditPage;
|
||||
342
frontend/src/pages/Admin/BlogPage.jsx
Normal file
342
frontend/src/pages/Admin/BlogPage.jsx
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Add as AddIcon,
|
||||
Search as SearchIcon,
|
||||
Clear as ClearIcon,
|
||||
Visibility as ViewIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { useAdminBlogPosts, useDeleteBlogPost } from '@hooks/blogHooks';
|
||||
import { format } from 'date-fns';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
const AdminBlogPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [postToDelete, setPostToDelete] = useState(null);
|
||||
|
||||
// Fetch blog posts
|
||||
const { data: posts, isLoading, error } = useAdminBlogPosts();
|
||||
const deletePost = useDeleteBlogPost();
|
||||
|
||||
// Filter posts based on search term
|
||||
const filteredPosts = posts ? posts.filter(post =>
|
||||
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
post.excerpt?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
post.category_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [];
|
||||
|
||||
// Handle search input change
|
||||
const handleSearchChange = (e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
};
|
||||
|
||||
// Clear search
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
// Handle edit post
|
||||
const handleEditPost = (id) => {
|
||||
navigate(`/admin/blog/${id}`);
|
||||
};
|
||||
|
||||
// Handle view post
|
||||
const handleViewPost = (slug) => {
|
||||
window.open(`/blog/${slug}`, '_blank');
|
||||
};
|
||||
|
||||
// Handle delete dialog open
|
||||
const handleDeleteClick = (post) => {
|
||||
setPostToDelete(post);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// Handle delete confirmation
|
||||
const handleConfirmDelete = async () => {
|
||||
if (postToDelete) {
|
||||
await deletePost.mutateAsync(postToDelete.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setPostToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete cancellation
|
||||
const handleCancelDelete = () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setPostToDelete(null);
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'Not published';
|
||||
return format(new Date(dateString), 'MMM d, yyyy');
|
||||
};
|
||||
|
||||
// Get status chip color
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return 'success';
|
||||
case 'draft':
|
||||
return 'warning';
|
||||
case 'archived':
|
||||
return 'default';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 2 }}>
|
||||
Error loading blog posts: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Blog Management
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
component={RouterLink}
|
||||
to="/admin/blog/new"
|
||||
>
|
||||
Create Post
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Search Box */}
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search posts by title, excerpt, or category..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchTerm && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" onClick={handleClearSearch}>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Blog Posts Table */}
|
||||
<Paper>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="50px">Image</TableCell>
|
||||
<TableCell>Title</TableCell>
|
||||
<TableCell>Category</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Published</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredPosts.length > 0 ? (
|
||||
filteredPosts.map((post) => (
|
||||
<TableRow key={post.id}>
|
||||
<TableCell>
|
||||
{post.featured_image_path ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={imageUtils.getImageUrl(post.featured_image_path)}
|
||||
alt={post.title}
|
||||
sx={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 1
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
bgcolor: 'grey.200',
|
||||
borderRadius: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
No img
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="subtitle2" noWrap sx={{ maxWidth: 250 }}>
|
||||
{post.title}
|
||||
</Typography>
|
||||
{post.excerpt && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{
|
||||
display: 'block',
|
||||
maxWidth: 250
|
||||
}}
|
||||
>
|
||||
{post.excerpt}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{post.category_name || 'Uncategorized'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={post.status}
|
||||
color={getStatusColor(post.status)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDate(post.published_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDate(post.created_at)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="View">
|
||||
<IconButton
|
||||
color="info"
|
||||
onClick={() => handleViewPost(post.slug)}
|
||||
size="small"
|
||||
>
|
||||
<ViewIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => handleEditPost(post.id)}
|
||||
size="small"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => handleDeleteClick(post)}
|
||||
size="small"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center">
|
||||
<Typography variant="body1" py={3}>
|
||||
{searchTerm ? 'No posts match your search.' : 'No blog posts found.'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={handleCancelDelete}
|
||||
aria-labelledby="delete-dialog-title"
|
||||
aria-describedby="delete-dialog-description"
|
||||
>
|
||||
<DialogTitle id="delete-dialog-title">
|
||||
Confirm Delete
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="delete-dialog-description">
|
||||
Are you sure you want to delete the post <strong>{postToDelete?.title}</strong>?
|
||||
This action cannot be undone and all comments will be permanently removed.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancelDelete}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleConfirmDelete}
|
||||
color="error"
|
||||
variant="contained"
|
||||
autoFocus
|
||||
disabled={deletePost.isLoading}
|
||||
>
|
||||
{deletePost.isLoading ? (
|
||||
<CircularProgress size={24} sx={{ mr: 1 }} />
|
||||
) : null}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminBlogPage;
|
||||
380
frontend/src/pages/Admin/CouponsPage.jsx
Normal file
380
frontend/src/pages/Admin/CouponsPage.jsx
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
IconButton,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Add as AddIcon,
|
||||
Search as SearchIcon,
|
||||
Clear as ClearIcon,
|
||||
History as HistoryIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAdminCoupons, useDeleteCoupon } from '@hooks/couponAdminHooks';
|
||||
import { format, isPast, isFuture } from 'date-fns';
|
||||
|
||||
const AdminCouponsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [couponToDelete, setCouponToDelete] = useState(null);
|
||||
|
||||
// Fetch coupons
|
||||
const { data: coupons, isLoading, error } = useAdminCoupons();
|
||||
|
||||
// Delete mutation
|
||||
const deleteCoupon = useDeleteCoupon();
|
||||
|
||||
// Filter coupons by search
|
||||
const filteredCoupons = coupons ? coupons.filter(coupon => {
|
||||
const searchTerm = search.toLowerCase();
|
||||
return (
|
||||
coupon.code.toLowerCase().includes(searchTerm) ||
|
||||
(coupon.description && coupon.description.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
}) : [];
|
||||
|
||||
// Paginate coupons
|
||||
const paginatedCoupons = filteredCoupons.slice(
|
||||
page * rowsPerPage,
|
||||
page * rowsPerPage + rowsPerPage
|
||||
);
|
||||
|
||||
// Handle search change
|
||||
const handleSearchChange = (event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
// Clear search
|
||||
const handleClearSearch = () => {
|
||||
setSearch('');
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handleChangePage = (event, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
// Handle rows per page change
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
// Handle edit action
|
||||
const handleEditCoupon = (id) => {
|
||||
navigate(`/admin/coupons/${id}`);
|
||||
};
|
||||
|
||||
// Handle delete click
|
||||
const handleDeleteClick = (coupon) => {
|
||||
setCouponToDelete(coupon);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// Confirm delete
|
||||
const handleConfirmDelete = () => {
|
||||
if (couponToDelete) {
|
||||
deleteCoupon.mutate(couponToDelete.id, {
|
||||
onSuccess: () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setCouponToDelete(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel delete
|
||||
const handleCancelDelete = () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setCouponToDelete(null);
|
||||
};
|
||||
|
||||
// Navigate to view redemptions
|
||||
const handleViewRedemptions = (id) => {
|
||||
navigate(`/admin/coupons/${id}/redemptions`);
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'No Date Set';
|
||||
try {
|
||||
return format(new Date(dateString), 'MMM d, yyyy');
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// Get coupon status
|
||||
const getCouponStatus = (coupon) => {
|
||||
if (!coupon.is_active) {
|
||||
return { label: 'Inactive', color: 'default' };
|
||||
}
|
||||
|
||||
if (coupon.start_date && isFuture(new Date(coupon.start_date))) {
|
||||
return { label: 'Scheduled', color: 'info' };
|
||||
}
|
||||
|
||||
if (coupon.end_date && isPast(new Date(coupon.end_date))) {
|
||||
return { label: 'Expired', color: 'error' };
|
||||
}
|
||||
|
||||
if (coupon.redemption_limit !== null && coupon.current_redemptions >= coupon.redemption_limit) {
|
||||
return { label: 'Fully Redeemed', color: 'warning' };
|
||||
}
|
||||
|
||||
return { label: 'Active', color: 'success' };
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 2 }}>
|
||||
Error loading coupons: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Coupons & Discounts
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => navigate('/admin/coupons/new')}
|
||||
>
|
||||
Add Coupon
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Search Box */}
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search coupons by code or description..."
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: search && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" onClick={handleClearSearch}>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Coupons Table */}
|
||||
<Paper>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Code</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Value</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Redemptions</TableCell>
|
||||
<TableCell>Valid Period</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{paginatedCoupons.length > 0 ? (
|
||||
paginatedCoupons.map((coupon) => {
|
||||
const status = getCouponStatus(coupon);
|
||||
|
||||
return (
|
||||
<TableRow key={coupon.id}>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{coupon.code}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{coupon.description || 'No description'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{coupon.discount_type === 'percentage' ? 'Percentage' : 'Fixed Amount'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{coupon.discount_type === 'percentage'
|
||||
? `${coupon.discount_value}%`
|
||||
: `$${parseFloat(coupon.discount_value).toFixed(2)}`}
|
||||
{coupon.max_discount_amount && (
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
Max: ${parseFloat(coupon.max_discount_amount).toFixed(2)}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={status.label}
|
||||
color={status.color}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{coupon.redemption_limit ? (
|
||||
<Typography variant="body2">
|
||||
{coupon.current_redemptions} / {coupon.redemption_limit}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2">
|
||||
{coupon.current_redemptions} / Unlimited
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{coupon.start_date && (
|
||||
<Typography variant="caption" display="block">
|
||||
From: {formatDate(coupon.start_date)}
|
||||
</Typography>
|
||||
)}
|
||||
{coupon.end_date ? (
|
||||
<Typography variant="caption" display="block">
|
||||
To: {formatDate(coupon.end_date)}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="caption" display="block">
|
||||
No End Date
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="View Redemption History">
|
||||
<IconButton
|
||||
onClick={() => handleViewRedemptions(coupon.id)}
|
||||
color="primary"
|
||||
>
|
||||
<HistoryIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit Coupon">
|
||||
<IconButton
|
||||
onClick={() => handleEditCoupon(coupon.id)}
|
||||
color="primary"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete Coupon">
|
||||
<IconButton
|
||||
onClick={() => handleDeleteClick(coupon)}
|
||||
color="error"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center">
|
||||
<Typography variant="body1" py={2}>
|
||||
{search ? 'No coupons match your search.' : 'No coupons found.'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
rowsPerPageOptions={[5, 10, 25, 50]}
|
||||
component="div"
|
||||
count={filteredCoupons.length}
|
||||
rowsPerPage={rowsPerPage}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={handleCancelDelete}
|
||||
aria-labelledby="delete-dialog-title"
|
||||
aria-describedby="delete-dialog-description"
|
||||
>
|
||||
<DialogTitle id="delete-dialog-title">
|
||||
Confirm Delete
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="delete-dialog-description">
|
||||
Are you sure you want to delete the coupon <strong>{couponToDelete?.code}</strong>?
|
||||
This action cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancelDelete}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleConfirmDelete}
|
||||
color="error"
|
||||
variant="contained"
|
||||
autoFocus
|
||||
disabled={deleteCoupon.isLoading}
|
||||
>
|
||||
{deleteCoupon.isLoading ? (
|
||||
<CircularProgress size={24} sx={{ mr: 1 }} />
|
||||
) : null}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminCouponsPage;
|
||||
366
frontend/src/pages/Admin/ProductReviewsPage.jsx
Normal file
366
frontend/src/pages/Admin/ProductReviewsPage.jsx
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Tabs,
|
||||
Tab,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Button,
|
||||
Divider,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Rating
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Check as ApproveIcon,
|
||||
Delete as DeleteIcon,
|
||||
Search as SearchIcon,
|
||||
Clear as ClearIcon,
|
||||
Visibility as ViewIcon,
|
||||
Refresh as RefreshIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { usePendingReviews, useApproveReview, useDeleteReview } from '@hooks/productReviewHooks';
|
||||
import { format } from 'date-fns';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const AdminProductReviewsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [reviewToDelete, setReviewToDelete] = useState(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Fetch pending reviews
|
||||
const { data: pendingReviews, isLoading, error } = usePendingReviews();
|
||||
const approveReview = useApproveReview();
|
||||
const deleteReview = useDeleteReview();
|
||||
|
||||
// Filter reviews by search term
|
||||
const filteredReviews = pendingReviews ? pendingReviews.filter(review =>
|
||||
review.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
review.content?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
review.first_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
review.last_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
review.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
review.product_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [];
|
||||
|
||||
// Handle tab change
|
||||
const handleTabChange = (event, newValue) => {
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
|
||||
// Handle search input change
|
||||
const handleSearchChange = (e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
};
|
||||
|
||||
// Clear search
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
// Handle refresh
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await queryClient.invalidateQueries(['pending-reviews']);
|
||||
// Optional timeout to ensure the refresh button animation is visible
|
||||
setTimeout(() => {
|
||||
setIsRefreshing(false);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error refreshing reviews:', error);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle view product
|
||||
const handleViewProduct = (productId) => {
|
||||
window.open(`/products/${productId}`, '_blank');
|
||||
};
|
||||
|
||||
// Handle approve review
|
||||
const handleApproveReview = async (id) => {
|
||||
try {
|
||||
await approveReview.mutateAsync(id);
|
||||
} catch (error) {
|
||||
console.error('Error approving review:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete dialog open
|
||||
const handleDeleteClick = (review) => {
|
||||
setReviewToDelete(review);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// Handle delete confirmation
|
||||
const handleConfirmDelete = async () => {
|
||||
if (reviewToDelete) {
|
||||
await deleteReview.mutateAsync(reviewToDelete.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setReviewToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete cancellation
|
||||
const handleCancelDelete = () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setReviewToDelete(null);
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString) => {
|
||||
return format(new Date(dateString), 'MMM d, yyyy h:mm a');
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading && !isRefreshing) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 2 }}>
|
||||
Error loading reviews: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Product Reviews
|
||||
</Typography>
|
||||
<Tooltip title="Refresh reviews">
|
||||
<IconButton
|
||||
onClick={handleRefresh}
|
||||
color="primary"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
<RefreshIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant="fullWidth"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
<Tab label={`Pending Approval (${pendingReviews?.length || 0})`} />
|
||||
<Tab label="All Reviews" />
|
||||
</Tabs>
|
||||
|
||||
{/* Search Box */}
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search reviews by content, author, or product..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchTerm && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" onClick={handleClearSearch}>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Reviews List */}
|
||||
{filteredReviews.length === 0 ? (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
No pending reviews found
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{searchTerm ? 'Try adjusting your search terms' : 'All reviews have been approved'}
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<RefreshIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleRefresh}
|
||||
sx={{ mt: 2 }}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Paper>
|
||||
) : (
|
||||
<Box>
|
||||
{filteredReviews.map(review => (
|
||||
<Card key={review.id} sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1">
|
||||
{review.first_name} {review.last_name} ({review.email})
|
||||
</Typography>
|
||||
<Chip
|
||||
label={formatDate(review.created_at)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
{review.is_verified_purchase && (
|
||||
<Chip
|
||||
label="Verified Purchase"
|
||||
color="success"
|
||||
size="small"
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Chip
|
||||
label={`Product: ${review.product_name}`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Typography variant="h6">{review.title}</Typography>
|
||||
|
||||
{review.rating && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', my: 1 }}>
|
||||
<Rating value={review.rating} readOnly precision={0.5} />
|
||||
<Typography variant="body2" sx={{ ml: 1 }}>
|
||||
({review.rating}/5)
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" paragraph>
|
||||
{review.content || <em>No content provided</em>}
|
||||
</Typography>
|
||||
|
||||
{review.parent_id && (
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
This is a reply to another review
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardActions sx={{ justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
startIcon={<ViewIcon />}
|
||||
onClick={() => handleViewProduct(review.product_id)}
|
||||
>
|
||||
View Product
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<ApproveIcon />}
|
||||
color="success"
|
||||
onClick={() => handleApproveReview(review.id)}
|
||||
disabled={approveReview.isLoading}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<DeleteIcon />}
|
||||
color="error"
|
||||
onClick={() => handleDeleteClick(review)}
|
||||
disabled={deleteReview.isLoading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={handleCancelDelete}
|
||||
aria-labelledby="delete-dialog-title"
|
||||
aria-describedby="delete-dialog-description"
|
||||
>
|
||||
<DialogTitle id="delete-dialog-title">
|
||||
Confirm Delete
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="delete-dialog-description">
|
||||
Are you sure you want to delete this review? This action cannot be undone.
|
||||
</DialogContentText>
|
||||
|
||||
{reviewToDelete && (
|
||||
<Paper variant="outlined" sx={{ p: 2, mt: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="subtitle1">
|
||||
Review by {reviewToDelete.first_name} {reviewToDelete.last_name}:
|
||||
</Typography>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{reviewToDelete.title}
|
||||
</Typography>
|
||||
{reviewToDelete.rating && (
|
||||
<Rating value={reviewToDelete.rating} readOnly size="small" />
|
||||
)}
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
{reviewToDelete.content || <em>No content provided</em>}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancelDelete}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleConfirmDelete}
|
||||
color="error"
|
||||
variant="contained"
|
||||
autoFocus
|
||||
disabled={deleteReview.isLoading}
|
||||
>
|
||||
{deleteReview.isLoading ? (
|
||||
<CircularProgress size={24} sx={{ mr: 1 }} />
|
||||
) : null}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminProductReviewsPage;
|
||||
361
frontend/src/pages/BlogDetailPage.jsx
Normal file
361
frontend/src/pages/BlogDetailPage.jsx
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Divider,
|
||||
Chip,
|
||||
Button,
|
||||
Avatar,
|
||||
TextField,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Breadcrumbs,
|
||||
Link,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Container
|
||||
} from '@mui/material';
|
||||
import { useParams, Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { useBlogPost, useAddComment } from '@hooks/blogHooks';
|
||||
import { useAuth } from '@hooks/reduxHooks';
|
||||
import { format } from 'date-fns';
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
import CommentIcon from '@mui/icons-material/Comment';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
const BlogDetailPage = () => {
|
||||
const { slug } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, user, userData } = useAuth();
|
||||
const [comment, setComment] = useState('');
|
||||
const [replyTo, setReplyTo] = useState(null);
|
||||
|
||||
// Fetch blog post
|
||||
const { data: post, isLoading, error } = useBlogPost(slug);
|
||||
const addComment = useAddComment();
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
return format(new Date(dateString), 'MMMM d, yyyy');
|
||||
};
|
||||
|
||||
// Handle comment submission
|
||||
const handleSubmitComment = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!comment.trim()) return;
|
||||
|
||||
try {
|
||||
await addComment.mutateAsync({
|
||||
postId: post.id,
|
||||
commentData: {
|
||||
userId: userData.id,
|
||||
content: comment,
|
||||
parentId: replyTo ? replyTo.id : null
|
||||
}
|
||||
});
|
||||
|
||||
// Reset comment form
|
||||
setComment('');
|
||||
setReplyTo(null);
|
||||
} catch (error) {
|
||||
console.error('Error submitting comment:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', my: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 4 }}>
|
||||
Error loading blog post: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// If post isn't found
|
||||
if (!post) {
|
||||
return (
|
||||
<Alert severity="warning" sx={{ my: 4 }}>
|
||||
Blog post not found. The post may have been removed or the URL is incorrect.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Comments component
|
||||
const renderComment = (comment, level = 0) => (
|
||||
<Box key={comment.id} sx={{ ml: level * 3, mb: 2 }}>
|
||||
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Avatar sx={{ mr: 1 }}>
|
||||
{comment.first_name ? comment.first_name[0] : '?'}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">
|
||||
{comment.first_name} {comment.last_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(comment.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" paragraph>
|
||||
{comment.content}
|
||||
</Typography>
|
||||
|
||||
{isAuthenticated && (
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<CommentIcon />}
|
||||
onClick={() => setReplyTo(comment)}
|
||||
>
|
||||
Reply
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Render replies */}
|
||||
{comment.replies && comment.replies.map(reply => renderComment(reply, level + 1))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ py: 4 }}>
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs
|
||||
separator={<NavigateNextIcon fontSize="small" />}
|
||||
aria-label="breadcrumb"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
<Link component={RouterLink} to="/" color="inherit">
|
||||
Home
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/blog" color="inherit">
|
||||
Blog
|
||||
</Link>
|
||||
<Typography color="text.primary">
|
||||
{post.title}
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
{/* Post header */}
|
||||
<Paper sx={{ p: 3, mb: 4 }}>
|
||||
{/* Category */}
|
||||
{post.category_name && (
|
||||
<Chip
|
||||
label={post.category_name}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
{post.title}
|
||||
</Typography>
|
||||
|
||||
{/* Author and date */}
|
||||
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
|
||||
By {post.author_first_name} {post.author_last_name} • {formatDate(post.published_at)}
|
||||
</Typography>
|
||||
|
||||
{/* Tags */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{post.tags && post.tags.filter(Boolean).map((tag, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={tag}
|
||||
size="small"
|
||||
component={RouterLink}
|
||||
to={`/blog?tag=${tag}`}
|
||||
clickable
|
||||
sx={{ mr: 1, mb: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Featured image */}
|
||||
{post.featured_image_path && (
|
||||
<Box sx={{ mb: 4, textAlign: 'center' }}>
|
||||
<img
|
||||
src={imageUtils.getImageUrl(post.featured_image_path)}
|
||||
alt={post.title}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '500px',
|
||||
objectFit: 'contain',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Post content */}
|
||||
<Paper sx={{ p: { xs: 2, md: 4 }, mb: 4 }}>
|
||||
<Box
|
||||
className="blog-content"
|
||||
sx={{
|
||||
'& p': { mb: 2 },
|
||||
'& h2': { mt: 3, mb: 2 },
|
||||
'& h3': { mt: 2.5, mb: 1.5 },
|
||||
'& ul, & ol': { mb: 2, pl: 4 },
|
||||
'& li': { mb: 0.5 },
|
||||
'& img': {
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
borderRadius: '4px',
|
||||
my: 2
|
||||
},
|
||||
'& blockquote': {
|
||||
borderLeft: '4px solid',
|
||||
borderColor: 'primary.main',
|
||||
pl: 2,
|
||||
py: 1,
|
||||
my: 2,
|
||||
fontStyle: 'italic',
|
||||
bgcolor: 'background.paper'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Post content - rendered as HTML */}
|
||||
<div dangerouslySetInnerHTML={{ __html: post.content }} />
|
||||
</Box>
|
||||
|
||||
{/* Post images */}
|
||||
{post.images && post.images.length > 0 && (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Gallery
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{post.images.map((image) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={image.id}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
pb: '75%', // 4:3 aspect ratio
|
||||
height: 0,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 1
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imageUtils.getImageUrl(image.path)}
|
||||
alt={image.caption || 'Blog image'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{image.caption && (
|
||||
<Typography variant="caption" color="text.secondary" display="block" align="center" sx={{ mt: 1 }}>
|
||||
{image.caption}
|
||||
</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Comments section */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Comments ({post.comments ? post.comments.length : 0})
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
{/* Comment form */}
|
||||
{isAuthenticated ? (
|
||||
<Box component="form" onSubmit={handleSubmitComment} sx={{ mb: 4 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{replyTo
|
||||
? `Reply to ${replyTo.first_name}'s comment`
|
||||
: 'Leave a comment'}
|
||||
</Typography>
|
||||
|
||||
{replyTo && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Chip
|
||||
label={`Cancel reply to ${replyTo.first_name}`}
|
||||
onDelete={() => setReplyTo(null)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
placeholder="Write your comment here..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
variant="outlined"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
endIcon={<SendIcon />}
|
||||
disabled={!comment.trim() || addComment.isLoading}
|
||||
>
|
||||
{addComment.isLoading ? 'Submitting...' : 'Submit Comment'}
|
||||
</Button>
|
||||
|
||||
{addComment.isSuccess && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
Your comment has been submitted and is awaiting approval.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
Please <Link component={RouterLink} to="/auth/login" state={{ from: `/blog/${slug}` }}>log in</Link> to leave a comment.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Comments list */}
|
||||
{post.comments && post.comments.length > 0 ? (
|
||||
<Box>
|
||||
{post.comments.map(comment => renderComment(comment))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No comments yet. Be the first to comment!
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogDetailPage;
|
||||
312
frontend/src/pages/BlogPage.jsx
Normal file
312
frontend/src/pages/BlogPage.jsx
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardMedia,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Button,
|
||||
Chip,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Divider,
|
||||
Pagination,
|
||||
MenuItem,
|
||||
Select,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Container
|
||||
} from '@mui/material';
|
||||
import { useNavigate, useLocation, Link as RouterLink } from 'react-router-dom';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import { useBlogPosts, useBlogCategories } from '@hooks/blogHooks';
|
||||
import { format } from 'date-fns';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
const BlogPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
// State for filters and search
|
||||
const [filters, setFilters] = useState({
|
||||
category: searchParams.get('category') || '',
|
||||
tag: searchParams.get('tag') || '',
|
||||
search: searchParams.get('search') || '',
|
||||
page: parseInt(searchParams.get('page') || '1'),
|
||||
});
|
||||
|
||||
// Fetch blog posts
|
||||
const { data, isLoading, error } = useBlogPosts(filters);
|
||||
const { data: categories } = useBlogCategories();
|
||||
|
||||
// Update URL when filters change
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.category) params.set('category', filters.category);
|
||||
if (filters.tag) params.set('tag', filters.tag);
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.page > 1) params.set('page', filters.page.toString());
|
||||
|
||||
navigate(`/blog${params.toString() ? `?${params.toString()}` : ''}`, { replace: true });
|
||||
}, [filters, navigate]);
|
||||
|
||||
// Handle search input
|
||||
const handleSearchChange = (e) => {
|
||||
setFilters({ ...filters, search: e.target.value, page: 1 });
|
||||
};
|
||||
|
||||
// Clear search
|
||||
const handleClearSearch = () => {
|
||||
setFilters({ ...filters, search: '', page: 1 });
|
||||
};
|
||||
|
||||
// Handle category change
|
||||
const handleCategoryChange = (e) => {
|
||||
setFilters({ ...filters, category: e.target.value, page: 1 });
|
||||
};
|
||||
|
||||
// Handle tag click
|
||||
const handleTagClick = (tag) => {
|
||||
setFilters({ ...filters, tag, page: 1 });
|
||||
};
|
||||
|
||||
// Clear tag filter
|
||||
const handleClearTag = () => {
|
||||
setFilters({ ...filters, tag: '', page: 1 });
|
||||
};
|
||||
|
||||
// Handle pagination
|
||||
const handlePageChange = (event, value) => {
|
||||
setFilters({ ...filters, page: value });
|
||||
// Scroll to top when page changes
|
||||
window.scrollTo(0, 0);
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatPublishedDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
return format(new Date(dateString), 'MMMM d, yyyy');
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', my: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 4 }}>
|
||||
Error loading blog posts: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
const posts = data?.posts || [];
|
||||
const pagination = data?.pagination || { page: 1, pages: 1, total: 0 };
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ py: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Our Blog
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary" paragraph>
|
||||
Discover insights about our natural collections, sourcing adventures, and unique specimens
|
||||
</Typography>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Grid container spacing={2} sx={{ mb: 4, mt: 2 }}>
|
||||
{/* Category filter */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<FormControl fullWidth variant="outlined">
|
||||
<InputLabel id="category-filter-label">Filter by Category</InputLabel>
|
||||
<Select
|
||||
labelId="category-filter-label"
|
||||
value={filters.category}
|
||||
onChange={handleCategoryChange}
|
||||
label="Filter by Category"
|
||||
>
|
||||
<MenuItem value="">All Categories</MenuItem>
|
||||
{categories?.map((category) => (
|
||||
<MenuItem key={category.id} value={category.name}>
|
||||
{category.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* Tag filter */}
|
||||
<Grid item xs={12} md={8}>
|
||||
{filters.tag && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ mr: 1 }}>
|
||||
Filtered by tag:
|
||||
</Typography>
|
||||
<Chip
|
||||
label={filters.tag}
|
||||
onDelete={handleClearTag}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Search */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search blog posts..."
|
||||
value={filters.search}
|
||||
onChange={handleSearchChange}
|
||||
variant="outlined"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: filters.search && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" onClick={handleClearSearch}>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* No results message */}
|
||||
{posts.length === 0 && (
|
||||
<Box sx={{ textAlign: 'center', my: 8 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
No blog posts found
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{filters.search || filters.category || filters.tag
|
||||
? 'Try adjusting your filters or search terms'
|
||||
: 'Check back soon for new content'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Blog post grid */}
|
||||
<Grid container spacing={4}>
|
||||
{posts.map((post) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={post.id}>
|
||||
<Card sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: 6
|
||||
}
|
||||
}}>
|
||||
{/* Featured image */}
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={post.featured_image_path
|
||||
? imageUtils.getImageUrl(post.featured_image_path)
|
||||
: '/images/placeholder.jpg'}
|
||||
alt={post.title}
|
||||
/>
|
||||
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
{/* Category */}
|
||||
{post.category_name && (
|
||||
<Chip
|
||||
label={post.category_name}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
{post.title}
|
||||
</Typography>
|
||||
|
||||
{/* Published date */}
|
||||
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>
|
||||
{formatPublishedDate(post.published_at)}
|
||||
</Typography>
|
||||
|
||||
{/* Excerpt */}
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
{post.excerpt || (post.content && post.content.substring(0, 150) + '...')}
|
||||
</Typography>
|
||||
|
||||
{/* Tags */}
|
||||
<Box sx={{ mt: 2, mb: 1 }}>
|
||||
{post.tags && post.tags.filter(Boolean).map((tag, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={tag}
|
||||
size="small"
|
||||
onClick={() => handleTagClick(tag)}
|
||||
sx={{ mr: 0.5, mb: 0.5 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
<CardActions>
|
||||
<Button
|
||||
size="small"
|
||||
component={RouterLink}
|
||||
to={`/blog/${post.slug}`}
|
||||
>
|
||||
Read More
|
||||
</Button>
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<Chip
|
||||
label={`${post.comment_count || 0} comments`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.pages > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
|
||||
<Pagination
|
||||
count={pagination.pages}
|
||||
page={pagination.page}
|
||||
onChange={handlePageChange}
|
||||
color="primary"
|
||||
siblingCount={1}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogPage;
|
||||
|
|
@ -24,7 +24,7 @@ import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
|||
import { useAuth } from '@hooks/reduxHooks';
|
||||
import { useGetCart, useUpdateCartItem, useClearCart, useProduct } from '@hooks/apiHooks';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
import CouponInput from '@components/CouponInput';
|
||||
const CartPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
|
@ -264,6 +264,9 @@ const CartPage = () => {
|
|||
|
||||
{/* Order summary */}
|
||||
<Grid item xs={12} lg={4}>
|
||||
{/* Coupon Input */}
|
||||
<CouponInput />
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Order Summary
|
||||
|
|
@ -278,10 +281,26 @@ const CartPage = () => {
|
|||
</Grid>
|
||||
<Grid item xs={4} sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="body1">
|
||||
${cart.total.toFixed(2)}
|
||||
${cart.subtotal?.toFixed(2) || cart.total.toFixed(2)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
{/* Display discount if coupon is applied */}
|
||||
{cart.couponDiscount > 0 && (
|
||||
<>
|
||||
<Grid item xs={8}>
|
||||
<Typography variant="body1" color="success.main">
|
||||
Discount ({cart.couponCode})
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4} sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="body1" color="success.main">
|
||||
-${cart.couponDiscount.toFixed(2)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Grid item xs={8}>
|
||||
<Typography variant="body1">
|
||||
Shipping
|
||||
|
|
|
|||
596
frontend/src/pages/CouponEditPage.jsx
Normal file
596
frontend/src/pages/CouponEditPage.jsx
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Select,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
FormHelperText,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
Button,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Divider,
|
||||
InputAdornment,
|
||||
Autocomplete,
|
||||
Chip,
|
||||
Breadcrumbs,
|
||||
Link
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack as ArrowBackIcon,
|
||||
Save as SaveIcon,
|
||||
Clear as ClearIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useParams, Link as RouterLink } from 'react-router-dom';
|
||||
import { useAdminCoupon, useCreateCoupon, useUpdateCoupon } from '@hooks/couponAdminHooks';
|
||||
import { useCategories, useTags } from '@hooks/apiHooks';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
const CouponEditPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isNewCoupon = !id;
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
code: '',
|
||||
description: '',
|
||||
discountType: 'percentage', // 'percentage' or 'fixed_amount'
|
||||
discountValue: '',
|
||||
minPurchaseAmount: '',
|
||||
maxDiscountAmount: '',
|
||||
redemptionLimit: '',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
isActive: true,
|
||||
categories: [],
|
||||
tags: [],
|
||||
blacklistedProducts: []
|
||||
});
|
||||
|
||||
// Validation state
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
// Notification state
|
||||
const [notification, setNotification] = useState({
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'success'
|
||||
});
|
||||
|
||||
// Fetch coupon data for editing
|
||||
const { data: coupon, isLoading: couponLoading, error: couponError } = useAdminCoupon(
|
||||
isNewCoupon ? null : id
|
||||
);
|
||||
|
||||
// Mutations
|
||||
const createCoupon = useCreateCoupon();
|
||||
const updateCoupon = useUpdateCoupon();
|
||||
|
||||
// Fetch categories and tags
|
||||
const { data: categories } = useCategories();
|
||||
const { data: tags } = useTags();
|
||||
|
||||
// Set form data when editing existing coupon
|
||||
useEffect(() => {
|
||||
if (!isNewCoupon && coupon) {
|
||||
setFormData({
|
||||
code: coupon.code || '',
|
||||
description: coupon.description || '',
|
||||
discountType: coupon.discount_type || 'percentage',
|
||||
discountValue: coupon.discount_value?.toString() || '',
|
||||
minPurchaseAmount: coupon.min_purchase_amount?.toString() || '',
|
||||
maxDiscountAmount: coupon.max_discount_amount?.toString() || '',
|
||||
redemptionLimit: coupon.redemption_limit?.toString() || '',
|
||||
startDate: coupon.start_date ? parseISO(coupon.start_date) : null,
|
||||
endDate: coupon.end_date ? parseISO(coupon.end_date) : null,
|
||||
isActive: coupon.is_active ?? true,
|
||||
categories: coupon.categories || [],
|
||||
tags: coupon.tags || [],
|
||||
blacklistedProducts: coupon.blacklisted_products || []
|
||||
});
|
||||
}
|
||||
}, [isNewCoupon, coupon]);
|
||||
|
||||
// Handle form changes
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
|
||||
// Clear validation error when field is edited
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Handle date changes
|
||||
const handleDateChange = (name, date) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: date
|
||||
}));
|
||||
|
||||
// Clear validation error when field is edited
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle categories change
|
||||
const handleCategoriesChange = (event, newValue) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
categories: newValue
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle tags change
|
||||
const handleTagsChange = (event, newValue) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: newValue
|
||||
}));
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
// Required fields
|
||||
if (!formData.code) {
|
||||
newErrors.code = 'Coupon code is required';
|
||||
} else if (!/^[A-Za-z0-9_-]+$/.test(formData.code)) {
|
||||
newErrors.code = 'Code can only contain letters, numbers, underscores, and hyphens';
|
||||
}
|
||||
|
||||
if (!formData.discountType) {
|
||||
newErrors.discountType = 'Discount type is required';
|
||||
}
|
||||
|
||||
if (!formData.discountValue) {
|
||||
newErrors.discountValue = 'Discount value is required';
|
||||
} else if (isNaN(formData.discountValue) || parseFloat(formData.discountValue) <= 0) {
|
||||
newErrors.discountValue = 'Discount value must be a positive number';
|
||||
} else if (formData.discountType === 'percentage' && parseFloat(formData.discountValue) > 100) {
|
||||
newErrors.discountValue = 'Percentage discount cannot exceed 100%';
|
||||
}
|
||||
|
||||
// Optional numeric fields
|
||||
if (formData.minPurchaseAmount && (isNaN(formData.minPurchaseAmount) || parseFloat(formData.minPurchaseAmount) < 0)) {
|
||||
newErrors.minPurchaseAmount = 'Minimum purchase amount must be a non-negative number';
|
||||
}
|
||||
|
||||
if (formData.maxDiscountAmount && (isNaN(formData.maxDiscountAmount) || parseFloat(formData.maxDiscountAmount) <= 0)) {
|
||||
newErrors.maxDiscountAmount = 'Maximum discount amount must be a positive number';
|
||||
}
|
||||
|
||||
if (formData.redemptionLimit && (isNaN(formData.redemptionLimit) || parseInt(formData.redemptionLimit) <= 0 || !Number.isInteger(parseFloat(formData.redemptionLimit)))) {
|
||||
newErrors.redemptionLimit = 'Redemption limit must be a positive integer';
|
||||
}
|
||||
|
||||
// Date validation
|
||||
if (formData.startDate && formData.endDate && formData.startDate > formData.endDate) {
|
||||
newErrors.endDate = 'End date must be after start date';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Please fix the form errors before submitting',
|
||||
severity: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Format date fields
|
||||
const formattedData = {
|
||||
...formData,
|
||||
code: formData.code.toUpperCase(),
|
||||
discountValue: parseFloat(formData.discountValue),
|
||||
minPurchaseAmount: formData.minPurchaseAmount ? parseFloat(formData.minPurchaseAmount) : null,
|
||||
maxDiscountAmount: formData.maxDiscountAmount ? parseFloat(formData.maxDiscountAmount) : null,
|
||||
redemptionLimit: formData.redemptionLimit ? parseInt(formData.redemptionLimit) : null,
|
||||
startDate: formData.startDate ? format(formData.startDate, "yyyy-MM-dd'T'HH:mm:ss") : null,
|
||||
endDate: formData.endDate ? format(formData.endDate, "yyyy-MM-dd'T'HH:mm:ss") : null,
|
||||
// Format arrays for API
|
||||
categories: formData.categories.map(cat => cat.id),
|
||||
tags: formData.tags.map(tag => tag.id),
|
||||
blacklistedProducts: formData.blacklistedProducts.map(prod => prod.id)
|
||||
};
|
||||
|
||||
try {
|
||||
if (isNewCoupon) {
|
||||
await createCoupon.mutateAsync(formattedData);
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Coupon created successfully',
|
||||
severity: 'success'
|
||||
});
|
||||
|
||||
// Navigate after successful creation
|
||||
setTimeout(() => {
|
||||
navigate('/admin/coupons');
|
||||
}, 1500);
|
||||
} else {
|
||||
await updateCoupon.mutateAsync({
|
||||
id,
|
||||
couponData: formattedData
|
||||
});
|
||||
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Coupon updated successfully',
|
||||
severity: 'success'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setNotification({
|
||||
open: true,
|
||||
message: error.message || 'Error saving coupon',
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle notification close
|
||||
const handleNotificationClose = () => {
|
||||
setNotification(prev => ({ ...prev, open: false }));
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if ((!isNewCoupon && couponLoading) || (!categories || !tags)) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (!isNewCoupon && couponError) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 2 }}>
|
||||
Error loading coupon: {couponError.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Button
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate('/admin/coupons')}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Back to Coupons
|
||||
</Button>
|
||||
|
||||
<Breadcrumbs sx={{ mb: 2 }}>
|
||||
<Link component={RouterLink} to="/admin" color="inherit">
|
||||
Admin
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/admin/coupons" color="inherit">
|
||||
Coupons
|
||||
</Link>
|
||||
<Typography color="text.primary">
|
||||
{isNewCoupon ? 'Create Coupon' : 'Edit Coupon'}
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
{isNewCoupon ? 'Create New Coupon' : `Edit Coupon: ${coupon?.code || "Enter Code"}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Form */}
|
||||
<Paper component="form" onSubmit={handleSubmit} sx={{ p: 3 }}>
|
||||
{notification.open && (
|
||||
<Alert
|
||||
severity={notification.severity}
|
||||
sx={{ mb: 3 }}
|
||||
onClose={handleNotificationClose}
|
||||
>
|
||||
{notification.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Basic Information */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Basic Information
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
label="Coupon Code"
|
||||
name="code"
|
||||
value={formData.code}
|
||||
onChange={handleChange}
|
||||
error={!!errors.code}
|
||||
helperText={errors.code || 'Use uppercase letters, numbers, and hyphens'}
|
||||
inputProps={{ style: { textTransform: 'uppercase' } }}
|
||||
disabled={!isNewCoupon} // Cannot edit code for existing coupons
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.isActive}
|
||||
onChange={handleChange}
|
||||
name="isActive"
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label={formData.isActive ? 'Coupon is Active' : 'Coupon is Inactive'}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="Optional description of the coupon and its usage"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Discount Settings */}
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Discount Settings
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth required error={!!errors.discountType}>
|
||||
<InputLabel id="discount-type-label">Discount Type</InputLabel>
|
||||
<Select
|
||||
labelId="discount-type-label"
|
||||
name="discountType"
|
||||
value={formData.discountType}
|
||||
label="Discount Type"
|
||||
onChange={handleChange}
|
||||
>
|
||||
<MenuItem value="percentage">Percentage</MenuItem>
|
||||
<MenuItem value="fixed_amount">Fixed Amount</MenuItem>
|
||||
</Select>
|
||||
{errors.discountType && <FormHelperText>{errors.discountType}</FormHelperText>}
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
label="Discount Value"
|
||||
name="discountValue"
|
||||
type="number"
|
||||
value={formData.discountValue}
|
||||
onChange={handleChange}
|
||||
error={!!errors.discountValue}
|
||||
helperText={errors.discountValue}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
{formData.discountType === 'percentage' ? '%' : ''}
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Minimum Purchase Amount"
|
||||
name="minPurchaseAmount"
|
||||
type="number"
|
||||
value={formData.minPurchaseAmount}
|
||||
onChange={handleChange}
|
||||
error={!!errors.minPurchaseAmount}
|
||||
helperText={errors.minPurchaseAmount || 'Minimum cart total to use this coupon (optional)'}
|
||||
InputProps={{
|
||||
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Maximum Discount Amount"
|
||||
name="maxDiscountAmount"
|
||||
type="number"
|
||||
value={formData.maxDiscountAmount}
|
||||
onChange={handleChange}
|
||||
error={!!errors.maxDiscountAmount}
|
||||
helperText={errors.maxDiscountAmount || 'Maximum discount for percentage coupons (optional)'}
|
||||
InputProps={{
|
||||
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||||
}}
|
||||
disabled={formData.discountType !== 'percentage'}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Validity and Usage */}
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Validity and Usage Limits
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Start Date (Optional)"
|
||||
name="startDate"
|
||||
type="date"
|
||||
value={formData.startDate}
|
||||
onChange={handleChange}
|
||||
error={!!errors.startDate}
|
||||
helperText={errors.startDate || 'When this coupon becomes valid'}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="End Date (Optional)"
|
||||
name="endDate"
|
||||
type="date"
|
||||
value={formData.endDate}
|
||||
onChange={handleChange}
|
||||
error={!!errors.endDate}
|
||||
helperText={errors.endDate || 'When this coupon expires'}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Redemption Limit"
|
||||
name="redemptionLimit"
|
||||
type="number"
|
||||
value={formData.redemptionLimit}
|
||||
onChange={handleChange}
|
||||
error={!!errors.redemptionLimit}
|
||||
helperText={errors.redemptionLimit || 'How many times this coupon can be used (optional, leave empty for unlimited)'}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Applicable Categories and Tags */}
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Applicable Products
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Specify which categories or tags this coupon applies to. If none selected, coupon applies to all products.
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={categories || []}
|
||||
getOptionLabel={(option) => option.name}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
value={formData.categories}
|
||||
onChange={handleCategoriesChange}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Applicable Categories"
|
||||
placeholder="Select categories"
|
||||
/>
|
||||
)}
|
||||
renderTags={(selected, getTagProps) =>
|
||||
selected.map((category, index) => (
|
||||
<Chip
|
||||
label={category.name}
|
||||
{...getTagProps({ index })}
|
||||
key={category.id}
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={tags || []}
|
||||
getOptionLabel={(option) => option.name}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
value={formData.tags}
|
||||
onChange={handleTagsChange}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Applicable Tags"
|
||||
placeholder="Select tags"
|
||||
/>
|
||||
)}
|
||||
renderTags={(selected, getTagProps) =>
|
||||
selected.map((tag, index) => (
|
||||
<Chip
|
||||
label={tag.name}
|
||||
{...getTagProps({ index })}
|
||||
key={tag.id}
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Grid item xs={12} sx={{ mt: 3 }}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={
|
||||
createCoupon.isLoading || updateCoupon.isLoading
|
||||
? <CircularProgress size={24} color="inherit" />
|
||||
: <SaveIcon />
|
||||
}
|
||||
disabled={createCoupon.isLoading || updateCoupon.isLoading}
|
||||
>
|
||||
{isNewCoupon ? 'Create Coupon' : 'Update Coupon'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
sx={{ ml: 2 }}
|
||||
onClick={() => navigate('/admin/coupons')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default CouponEditPage;
|
||||
215
frontend/src/pages/CouponRedemptionsPage.jsx
Normal file
215
frontend/src/pages/CouponRedemptionsPage.jsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
IconButton,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Button,
|
||||
Breadcrumbs,
|
||||
Link
|
||||
} from '@mui/material';
|
||||
import { useParams, useNavigate, Link as RouterLink } from 'react-router-dom';
|
||||
import { ArrowBack as ArrowBackIcon } from '@mui/icons-material';
|
||||
import { useCouponRedemptions, useAdminCoupon } from '@hooks/couponAdminHooks';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const CouponRedemptionsPage = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
|
||||
// Fetch coupon details
|
||||
const { data: coupon, isLoading: couponLoading, error: couponError } = useAdminCoupon(id);
|
||||
|
||||
// Fetch redemption history
|
||||
const { data: redemptions, isLoading: redemptionsLoading, error: redemptionsError } = useCouponRedemptions(id);
|
||||
|
||||
// Handle page change
|
||||
const handleChangePage = (event, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
// Handle rows per page change
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
return format(new Date(dateString), 'MMM d, yyyy h:mm a');
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
const isLoading = couponLoading || redemptionsLoading;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
const error = couponError || redemptionsError;
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 2 }}>
|
||||
Error loading data: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Paginate redemptions
|
||||
const paginatedRedemptions = redemptions ? redemptions.slice(
|
||||
page * rowsPerPage,
|
||||
page * rowsPerPage + rowsPerPage
|
||||
) : [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Button
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate('/admin/coupons')}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Back to Coupons
|
||||
</Button>
|
||||
|
||||
<Breadcrumbs sx={{ mb: 2 }}>
|
||||
<Link component={RouterLink} to="/admin" color="inherit">
|
||||
Admin
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/admin/coupons" color="inherit">
|
||||
Coupons
|
||||
</Link>
|
||||
<Typography color="text.primary">Redemptions</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Redemption History: {coupon?.code}
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Coupon Details
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Code</Typography>
|
||||
<Typography variant="body1">{coupon?.code}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Discount</Typography>
|
||||
<Typography variant="body1">
|
||||
{coupon?.discount_type === 'percentage'
|
||||
? `${coupon.discount_value}%`
|
||||
: `$${parseFloat(coupon.discount_value).toFixed(2)}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Usage</Typography>
|
||||
<Typography variant="body1">
|
||||
{coupon?.current_redemptions} / {coupon?.redemption_limit || 'Unlimited'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Status</Typography>
|
||||
<Chip
|
||||
label={coupon?.is_active ? 'Active' : 'Inactive'}
|
||||
color={coupon?.is_active ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Redemptions Table */}
|
||||
<Paper>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Customer</TableCell>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Order #</TableCell>
|
||||
<TableCell align="right">Order Total</TableCell>
|
||||
<TableCell align="right">Discount Amount</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{paginatedRedemptions.length > 0 ? (
|
||||
paginatedRedemptions.map((redemption) => (
|
||||
<TableRow key={redemption.id}>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{redemption.first_name} {redemption.last_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{redemption.email}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(redemption.redeemed_at)}</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/admin/orders/${redemption.order_id}`}
|
||||
>
|
||||
{redemption.order_id.substring(0, 8)}...
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
${parseFloat(redemption.total_amount).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
${parseFloat(redemption.discount_amount).toFixed(2)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} align="center">
|
||||
<Typography variant="body1" py={2}>
|
||||
No redemptions found for this coupon.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{redemptions && redemptions.length > 0 && (
|
||||
<TablePagination
|
||||
rowsPerPageOptions={[5, 10, 25, 50]}
|
||||
component="div"
|
||||
count={redemptions.length}
|
||||
rowsPerPage={rowsPerPage}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CouponRedemptionsPage;
|
||||
|
|
@ -26,9 +26,11 @@ import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
|||
import AddIcon from '@mui/icons-material/Add';
|
||||
import RemoveIcon from '@mui/icons-material/Remove';
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
import { Rating } from '@mui/material';
|
||||
import ProductReviews from '@components/ProductReviews';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useProduct, useAddToCart } from '../hooks/apiHooks';
|
||||
import { useAuth } from '../hooks/reduxHooks';
|
||||
import { useProduct, useAddToCart } from '@hooks/apiHooks';
|
||||
import { useAuth } from '@hooks/reduxHooks';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
const ProductDetailPage = () => {
|
||||
|
|
@ -173,6 +175,26 @@ const ProductDetailPage = () => {
|
|||
{product.category_name}
|
||||
</Link>
|
||||
<Typography color="text.primary">{product.name}</Typography>
|
||||
{product.average_rating && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1, mb: 2 }}>
|
||||
<Rating
|
||||
value={product.average_rating}
|
||||
readOnly
|
||||
precision={0.5}
|
||||
/>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ ml: 1 }}>
|
||||
{product.average_rating} ({product.review_count} {product.review_count === 1 ? 'review' : 'reviews'})
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!product.average_rating && (
|
||||
<Box sx={{ mt: 1, mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No reviews yet
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Breadcrumbs>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
|
|
@ -349,6 +371,10 @@ const ProductDetailPage = () => {
|
|||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 4 }} />
|
||||
<ProductReviews productId={id} />
|
||||
</Grid>
|
||||
</TableContainer>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ import SortIcon from '@mui/icons-material/Sort';
|
|||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import { Link as RouterLink, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useProducts, useCategories, useTags, useAddToCart } from '../hooks/apiHooks';
|
||||
import { useAuth } from '../hooks/reduxHooks';
|
||||
import { useProducts, useCategories, useTags, useAddToCart } from '@hooks/apiHooks';
|
||||
import ProductRatingDisplay from '@components/ProductRatingDisplay';
|
||||
import { useAuth } from '@hooks/reduxHooks';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
const ProductsPage = () => {
|
||||
|
|
@ -362,7 +363,10 @@ const ProductsPage = () => {
|
|||
>
|
||||
{product.name}
|
||||
</Typography>
|
||||
|
||||
<ProductRatingDisplay
|
||||
rating={product.average_rating}
|
||||
reviewCount={product.review_count}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
|
|
|
|||
160
frontend/src/services/blogAdminService.js
Normal file
160
frontend/src/services/blogAdminService.js
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import apiClient from './api';
|
||||
|
||||
const blogAdminService = {
|
||||
/**
|
||||
* Get all blog posts (admin)
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getAllPosts: async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/blog');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single blog post for editing (admin)
|
||||
* @param {string} id - Blog post ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getPostById: async (id) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/blog/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new blog post (admin)
|
||||
* @param {Object} postData - Blog post data
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
createPost: async (postData) => {
|
||||
try {
|
||||
const response = await apiClient.post('/admin/blog', postData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a blog post (admin)
|
||||
* @param {string} id - Blog post ID
|
||||
* @param {Object} postData - Updated blog post data
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
updatePost: async (id, postData) => {
|
||||
try {
|
||||
const response = await apiClient.put(`/admin/blog/${id}`, postData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a blog post (admin)
|
||||
* @param {string} id - Blog post ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
deletePost: async (id) => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/admin/blog/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload an image for a blog post (admin)
|
||||
* @param {string} postId - Blog post ID
|
||||
* @param {Object} imageData - Image data to upload
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
uploadImage: async (postId, imageData) => {
|
||||
try {
|
||||
const response = await apiClient.post(`/admin/blog/${postId}/images`, imageData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an image from a blog post (admin)
|
||||
* @param {string} postId - Blog post ID
|
||||
* @param {string} imageId - Image ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
deleteImage: async (postId, imageId) => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/admin/blog/${postId}/images/${imageId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all pending comments (admin)
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getPendingComments: async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/blog-comments/pending');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all comments for a post (admin)
|
||||
* @param {string} postId - Blog post ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getPostComments: async (postId) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/blog-comments/posts/${postId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Approve a comment (admin)
|
||||
* @param {string} commentId - Comment ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
approveComment: async (commentId) => {
|
||||
try {
|
||||
const response = await apiClient.post(`/admin/blog-comments/${commentId}/approve`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a comment (admin)
|
||||
* @param {string} commentId - Comment ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
deleteComment: async (commentId) => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/admin/blog-comments/${commentId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default blogAdminService;
|
||||
62
frontend/src/services/blogService.js
Normal file
62
frontend/src/services/blogService.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import apiClient from './api';
|
||||
|
||||
const blogService = {
|
||||
/**
|
||||
* Get all published blog posts with optional filtering
|
||||
* @param {Object} params - Query parameters for filtering posts
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getAllPosts: async (params = {}) => {
|
||||
try {
|
||||
const response = await apiClient.get('/blog', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single blog post by slug
|
||||
* @param {string} slug - Blog post slug
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getPostBySlug: async (slug) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/blog/${slug}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all blog categories
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getAllCategories: async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/blog/categories/all');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a comment to a blog post
|
||||
* @param {string} postId - Blog post ID
|
||||
* @param {Object} commentData - Comment data to submit
|
||||
* @param {string} commentData.userId - User ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
addComment: async (postId, commentData) => {
|
||||
try {
|
||||
const response = await apiClient.post(`/blog/${postId}/comments`, commentData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default blogService;
|
||||
133
frontend/src/services/couponService.js
Normal file
133
frontend/src/services/couponService.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import apiClient from './api';
|
||||
|
||||
const couponService = {
|
||||
/**
|
||||
* Get all coupons (admin only)
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getAllCoupons: async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/coupons');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single coupon by ID (admin only)
|
||||
* @param {string} id - Coupon ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getCouponById: async (id) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/coupons/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new coupon (admin only)
|
||||
* @param {Object} couponData - Coupon data
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
createCoupon: async (couponData) => {
|
||||
try {
|
||||
const response = await apiClient.post('/admin/coupons', couponData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a coupon (admin only)
|
||||
* @param {string} id - Coupon ID
|
||||
* @param {Object} couponData - Updated coupon data
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
updateCoupon: async (id, couponData) => {
|
||||
try {
|
||||
const response = await apiClient.put(`/admin/coupons/${id}`, couponData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a coupon (admin only)
|
||||
* @param {string} id - Coupon ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
deleteCoupon: async (id) => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/admin/coupons/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a coupon code to the cart
|
||||
* @param {string} userId - User ID
|
||||
* @param {string} code - Coupon code
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
applyCoupon: async (userId, code) => {
|
||||
try {
|
||||
const response = await apiClient.post(`/cart/apply-coupon`, { userId, code });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a coupon from the cart
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
removeCoupon: async (userId) => {
|
||||
try {
|
||||
const response = await apiClient.post(`/cart/remove-coupon`, { userId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate a coupon code
|
||||
* @param {string} code - Coupon code
|
||||
* @param {number} cartTotal - Cart total amount
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
validateCoupon: async (code, cartTotal) => {
|
||||
try {
|
||||
const response = await apiClient.post('/coupons/validate', { code, cartTotal });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get coupon redemption history (admin only)
|
||||
* @param {string} id - Coupon ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getCouponRedemptions: async (id) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/coupons/${id}/redemptions`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default couponService;
|
||||
112
frontend/src/services/productReviewService.js
Normal file
112
frontend/src/services/productReviewService.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import apiClient from './api';
|
||||
|
||||
const productReviewService = {
|
||||
/**
|
||||
* Get all reviews for a product
|
||||
* @param {string} productId - Product ID
|
||||
* @returns {Promise<Array>} - Array of reviews
|
||||
*/
|
||||
getProductReviews: async (productId) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/product-reviews/${productId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a review to a product
|
||||
* @param {string} productId - Product ID
|
||||
* @param {Object} reviewData - Review data
|
||||
* @param {string} reviewData.title - Review title
|
||||
* @param {string} reviewData.content - Review content
|
||||
* @param {number} reviewData.rating - Star rating (1-5)
|
||||
* @param {string} [reviewData.parentId] - Parent review ID (for replies)
|
||||
* @returns {Promise<Object>} - Response with the new review
|
||||
*/
|
||||
addProductReview: async (productId, reviewData) => {
|
||||
try {
|
||||
const response = await apiClient.post(`/product-reviews/${productId}`, reviewData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user can review a product
|
||||
* @param {string} productId - Product ID
|
||||
* @returns {Promise<Object>} - Object with canReview, isPurchaser, and isAdmin flags
|
||||
*/
|
||||
canReviewProduct: async (productId) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/product-reviews/${productId}/can-review`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Admin-specific review service functions
|
||||
*/
|
||||
export const productReviewAdminService = {
|
||||
/**
|
||||
* Get all pending reviews (admin only)
|
||||
* @returns {Promise<Array>} - Array of pending reviews
|
||||
*/
|
||||
getPendingReviews: async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/product-reviews/pending');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all reviews for a product (admin only)
|
||||
* @param {string} productId - Product ID
|
||||
* @returns {Promise<Object>} - Object with product and reviews
|
||||
*/
|
||||
getProductReviews: async (productId) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/product-reviews/products/${productId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Approve a review (admin only)
|
||||
* @param {string} reviewId - Review ID
|
||||
* @returns {Promise<Object>} - Response with the approved review
|
||||
*/
|
||||
approveReview: async (reviewId) => {
|
||||
try {
|
||||
const response = await apiClient.post(`/admin/product-reviews/${reviewId}/approve`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a review (admin only)
|
||||
* @param {string} reviewId - Review ID
|
||||
* @returns {Promise<Object>} - Response with success message
|
||||
*/
|
||||
deleteReview: async (reviewId) => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/admin/product-reviews/${reviewId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default productReviewService;
|
||||
Loading…
Reference in a new issue