Autosave: 20260130-174021
This commit is contained in:
parent
19d1c5723f
commit
6603ec27a8
@ -12,3 +12,6 @@ EMAIL_USER=AKIAVEW7G4PQUBGM52OF
|
||||
EMAIL_PASS=BLnD4hKGb6YkSz3gaQrf8fnyLi3C3/EdjOOsLEDTDPTz
|
||||
SECRET_KEY=HUEyqESqgQ1yTwzVlO6wprC9Kf1J1xuA
|
||||
PEXELS_KEY=Vc99rnmOhHhJAbgGQoKLZtsaIVfkeownoQNbTj78VemUjKh08ZYRbf18
|
||||
STRIPE_SECRET_KEY=sk_test_placeholder
|
||||
STRIPE_WEBHOOK_SECRET=whsec_placeholder
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_placeholder
|
||||
|
||||
9478
backend/package-lock.json
generated
Normal file
9478
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -36,6 +36,7 @@
|
||||
"sequelize": "6.35.2",
|
||||
"sequelize-json-schema": "^2.1.1",
|
||||
"sqlite": "4.0.15",
|
||||
"stripe": "^20.3.0",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"tedious": "^18.2.4"
|
||||
|
||||
@ -65,6 +65,10 @@ const config = {
|
||||
|
||||
|
||||
gpt_key: process.env.GPT_KEY || '',
|
||||
stripe: {
|
||||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
},
|
||||
};
|
||||
|
||||
config.pexelsKey = process.env.PEXELS_KEY || '';
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
@ -255,6 +254,12 @@ module.exports = class OrdersDBApi {
|
||||
|
||||
static async findBy(where, options) {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const currentUser = options?.currentUser;
|
||||
|
||||
// Data Isolation for Customer
|
||||
if (currentUser && currentUser.app_role && currentUser.app_role.name === 'Customer') {
|
||||
where.userId = currentUser.id;
|
||||
}
|
||||
|
||||
const orders = await db.orders.findOne(
|
||||
{ where },
|
||||
@ -275,7 +280,8 @@ module.exports = class OrdersDBApi {
|
||||
|
||||
|
||||
output.order_items_order = await orders.getOrder_items_order({
|
||||
transaction
|
||||
transaction,
|
||||
include: [{ model: db.products, as: 'product' }]
|
||||
});
|
||||
|
||||
|
||||
@ -314,6 +320,12 @@ module.exports = class OrdersDBApi {
|
||||
const orderBy = null;
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const currentUser = options?.currentUser;
|
||||
|
||||
// Data Isolation: If user is a Customer, only show their own orders
|
||||
if (currentUser && currentUser.app_role && currentUser.app_role.name === 'Customer') {
|
||||
where.userId = currentUser.id;
|
||||
}
|
||||
|
||||
let include = [
|
||||
|
||||
@ -480,6 +492,23 @@ module.exports = class OrdersDBApi {
|
||||
};
|
||||
}
|
||||
|
||||
if (filter.productName) {
|
||||
include.push({
|
||||
model: db.order_items,
|
||||
as: 'order_items_order',
|
||||
required: true,
|
||||
include: [{
|
||||
model: db.products,
|
||||
as: 'product',
|
||||
required: true,
|
||||
where: {
|
||||
title: {
|
||||
[Op.iLike]: `%${filter.productName}%`
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -576,5 +605,4 @@ module.exports = class OrdersDBApi {
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
@ -1,8 +1,10 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
const Utils = require('../utils');
|
||||
const EmailSender = require('../../services/email');
|
||||
const BackInStockEmail = require('../../services/email/list/backInStock');
|
||||
const config = require('../../config');
|
||||
|
||||
|
||||
|
||||
@ -186,6 +188,7 @@ module.exports = class ProductsDBApi {
|
||||
|
||||
const products = await db.products.findByPk(id, {}, {transaction});
|
||||
|
||||
const oldStock = Number(products.stock || 0);
|
||||
|
||||
|
||||
|
||||
@ -248,10 +251,41 @@ module.exports = class ProductsDBApi {
|
||||
options,
|
||||
);
|
||||
|
||||
const newStock = Number(products.stock || 0);
|
||||
if (oldStock === 0 && newStock > 0) {
|
||||
this.sendBackInStockNotifications(products).catch(console.error);
|
||||
}
|
||||
|
||||
return products;
|
||||
}
|
||||
|
||||
static async sendBackInStockNotifications(product) {
|
||||
try {
|
||||
const wishlists = await db.wishlists.findAll({
|
||||
where: { productId: product.id },
|
||||
include: [{ model: db.users, as: 'user' }]
|
||||
});
|
||||
|
||||
for (const wishlist of wishlists) {
|
||||
if (wishlist.user && wishlist.user.email) {
|
||||
const productUrl = `${config.uiUrl}/products/${product.id}`;
|
||||
const email = new BackInStockEmail(
|
||||
wishlist.user.email,
|
||||
product.title,
|
||||
productUrl
|
||||
);
|
||||
if (EmailSender.isConfigured) {
|
||||
await new EmailSender(email).send();
|
||||
} else {
|
||||
console.log('Email not configured, skipping back in stock notification for', wishlist.user.email);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending back in stock notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
@ -342,6 +376,12 @@ module.exports = class ProductsDBApi {
|
||||
output.category = await products.getCategory({
|
||||
transaction
|
||||
});
|
||||
|
||||
output.reviews = await products.getReviews({
|
||||
transaction,
|
||||
include: [{ model: db.users, as: 'user', attributes: ['id', 'firstName', 'lastName'] }],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
|
||||
|
||||
@ -393,6 +433,11 @@ module.exports = class ProductsDBApi {
|
||||
as: 'images',
|
||||
},
|
||||
|
||||
{
|
||||
model: db.reviews,
|
||||
as: 'reviews',
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
if (filter) {
|
||||
@ -662,4 +707,3 @@ module.exports = class ProductsDBApi {
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
84
backend/src/db/api/reviews.js
Normal file
84
backend/src/db/api/reviews.js
Normal file
@ -0,0 +1,84 @@
|
||||
const db = require('../models');
|
||||
const Utils = require('../utils');
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
module.exports = class ReviewsDBApi {
|
||||
static async create(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const reviews = await db.reviews.create(
|
||||
{
|
||||
rating: data.rating || 0,
|
||||
comment: data.comment || null,
|
||||
userId: currentUser.id || data.userId,
|
||||
productId: data.productId,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
return reviews;
|
||||
}
|
||||
|
||||
static async findAll(filter, options) {
|
||||
const limit = filter.limit || 0;
|
||||
let offset = 0;
|
||||
let where = {};
|
||||
const currentPage = +filter.page || 0;
|
||||
|
||||
offset = currentPage * limit;
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
if (filter) {
|
||||
if (filter.productId) {
|
||||
where.productId = filter.productId;
|
||||
}
|
||||
if (filter.userId) {
|
||||
where.userId = filter.userId;
|
||||
}
|
||||
}
|
||||
|
||||
const include = [
|
||||
{
|
||||
model: db.users,
|
||||
as: 'user',
|
||||
attributes: ['id', 'firstName', 'lastName'],
|
||||
}
|
||||
];
|
||||
|
||||
const queryOptions = {
|
||||
where,
|
||||
include,
|
||||
distinct: true,
|
||||
order: [['createdAt', 'DESC']],
|
||||
transaction,
|
||||
};
|
||||
|
||||
if (limit) {
|
||||
queryOptions.limit = Number(limit);
|
||||
queryOptions.offset = Number(offset);
|
||||
}
|
||||
|
||||
const { rows, count } = await db.reviews.findAndCountAll(queryOptions);
|
||||
|
||||
return { rows, count };
|
||||
}
|
||||
|
||||
static async getAverageRating(productId) {
|
||||
const result = await db.reviews.findAll({
|
||||
where: { productId },
|
||||
attributes: [
|
||||
[Sequelize.fn('AVG', Sequelize.col('rating')), 'averageRating'],
|
||||
[Sequelize.fn('COUNT', Sequelize.col('id')), 'reviewCount'],
|
||||
],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
return result[0];
|
||||
}
|
||||
};
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
@ -84,6 +83,11 @@ module.exports = class UsersDBApi {
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
address: data.data.address || null,
|
||||
city: data.data.city || null,
|
||||
zipCode: data.data.zipCode || null,
|
||||
country: data.data.country || null,
|
||||
|
||||
importHash: data.data.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
@ -204,6 +208,11 @@ module.exports = class UsersDBApi {
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
address: item.address || null,
|
||||
city: item.city || null,
|
||||
zipCode: item.zipCode || null,
|
||||
country: item.country || null,
|
||||
|
||||
importHash: item.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
@ -297,6 +306,11 @@ module.exports = class UsersDBApi {
|
||||
|
||||
|
||||
if (data.provider !== undefined) updatePayload.provider = data.provider;
|
||||
|
||||
if (data.address !== undefined) updatePayload.address = data.address;
|
||||
if (data.city !== undefined) updatePayload.city = data.city;
|
||||
if (data.zipCode !== undefined) updatePayload.zipCode = data.zipCode;
|
||||
if (data.country !== undefined) updatePayload.country = data.country;
|
||||
|
||||
|
||||
updatePayload.updatedById = currentUser.id;
|
||||
@ -938,5 +952,4 @@ module.exports = class UsersDBApi {
|
||||
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
112
backend/src/db/api/wishlists.js
Normal file
112
backend/src/db/api/wishlists.js
Normal file
@ -0,0 +1,112 @@
|
||||
|
||||
const db = require('../models');
|
||||
const Utils = require('../utils');
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
module.exports = class WishlistsDBApi {
|
||||
static async create(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const wishlists = await db.wishlists.create(
|
||||
{
|
||||
id: data.id || undefined,
|
||||
userId: currentUser.id,
|
||||
productId: data.product,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
return wishlists;
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const wishlists = await db.wishlists.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: ids,
|
||||
},
|
||||
userId: currentUser.id, // Only allow deleting own wishlist items
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
for (const record of wishlists) {
|
||||
await record.destroy({ transaction });
|
||||
}
|
||||
|
||||
return wishlists;
|
||||
}
|
||||
|
||||
static async remove(id, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const record = await db.wishlists.findOne({
|
||||
where: {
|
||||
id,
|
||||
userId: currentUser.id
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
if (record) {
|
||||
await record.destroy({ transaction });
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
static async findBy(where, options) {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
return await db.wishlists.findOne({ where, transaction });
|
||||
}
|
||||
|
||||
static async findAll(filter, options) {
|
||||
const limit = filter.limit || 0;
|
||||
let offset = 0;
|
||||
const currentPage = +filter.page || 0;
|
||||
offset = currentPage * limit;
|
||||
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
let where = {
|
||||
userId: currentUser.id // Always filter by current user
|
||||
};
|
||||
|
||||
if (filter.product) {
|
||||
where.productId = Utils.uuid(filter.product);
|
||||
}
|
||||
|
||||
const queryOptions = {
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: db.products,
|
||||
as: 'product',
|
||||
}
|
||||
],
|
||||
distinct: true,
|
||||
order: [['createdAt', 'desc']],
|
||||
transaction: options?.transaction,
|
||||
};
|
||||
|
||||
if (!options?.countOnly) {
|
||||
queryOptions.limit = limit ? Number(limit) : undefined;
|
||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||
}
|
||||
|
||||
const { rows, count } = await db.wishlists.findAndCountAll(queryOptions);
|
||||
|
||||
return {
|
||||
rows: options?.countOnly ? [] : rows,
|
||||
count: count
|
||||
};
|
||||
}
|
||||
};
|
||||
71
backend/src/db/migrations/1769789318471.js
Normal file
71
backend/src/db/migrations/1769789318471.js
Normal file
@ -0,0 +1,71 @@
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
try {
|
||||
await queryInterface.createTable('reviews', {
|
||||
id: {
|
||||
type: Sequelize.DataTypes.UUID,
|
||||
defaultValue: Sequelize.DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
rating: {
|
||||
type: Sequelize.DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
comment: {
|
||||
type: Sequelize.DataTypes.TEXT,
|
||||
},
|
||||
userId: {
|
||||
type: Sequelize.DataTypes.UUID,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
productId: {
|
||||
type: Sequelize.DataTypes.UUID,
|
||||
references: {
|
||||
model: 'products',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
createdById: {
|
||||
type: Sequelize.DataTypes.UUID,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
updatedById: {
|
||||
type: Sequelize.DataTypes.UUID,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
createdAt: { type: Sequelize.DataTypes.DATE },
|
||||
updatedAt: { type: Sequelize.DataTypes.DATE },
|
||||
deletedAt: { type: Sequelize.DataTypes.DATE },
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
async down(queryInterface, Sequelize) {
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
try {
|
||||
await queryInterface.dropTable('reviews', { transaction });
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
54
backend/src/db/migrations/1769789318472.js
Normal file
54
backend/src/db/migrations/1769789318472.js
Normal file
@ -0,0 +1,54 @@
|
||||
const { v4: uuid } = require("uuid");
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
const createdAt = new Date();
|
||||
const updatedAt = new Date();
|
||||
|
||||
// Get role IDs
|
||||
const roles = await queryInterface.sequelize.query(
|
||||
`SELECT id, name FROM roles`,
|
||||
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
const getRoleId = (name) => roles.find(r => r.name === name)?.id;
|
||||
|
||||
// Create permissions for REVIEWS
|
||||
const permissions = [
|
||||
{ id: uuid(), name: 'CREATE_REVIEWS', createdAt, updatedAt },
|
||||
{ id: uuid(), name: 'READ_REVIEWS', createdAt, updatedAt },
|
||||
{ id: uuid(), name: 'UPDATE_REVIEWS', createdAt, updatedAt },
|
||||
{ id: uuid(), name: 'DELETE_REVIEWS', createdAt, updatedAt },
|
||||
];
|
||||
|
||||
await queryInterface.bulkInsert('permissions', permissions);
|
||||
|
||||
const getPermissionId = (name) => permissions.find(p => p.name === name)?.id;
|
||||
|
||||
const rolePermissions = [];
|
||||
|
||||
const adminId = getRoleId('Administrator');
|
||||
if (adminId) {
|
||||
permissions.forEach(p => {
|
||||
rolePermissions.push({ createdAt, updatedAt, roles_permissionsId: adminId, permissionId: p.id });
|
||||
});
|
||||
}
|
||||
|
||||
const customerId = getRoleId('Customer');
|
||||
if (customerId) {
|
||||
rolePermissions.push({ createdAt, updatedAt, roles_permissionsId: customerId, permissionId: getPermissionId('CREATE_REVIEWS') });
|
||||
rolePermissions.push({ createdAt, updatedAt, roles_permissionsId: customerId, permissionId: getPermissionId('READ_REVIEWS') });
|
||||
}
|
||||
|
||||
const publicId = getRoleId('Public');
|
||||
if (publicId) {
|
||||
rolePermissions.push({ createdAt, updatedAt, roles_permissionsId: publicId, permissionId: getPermissionId('READ_REVIEWS') });
|
||||
}
|
||||
|
||||
await queryInterface.bulkInsert('rolesPermissionsPermissions', rolePermissions);
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
// Reverse logic if needed
|
||||
}
|
||||
};
|
||||
27
backend/src/db/migrations/1769789318473.js
Normal file
27
backend/src/db/migrations/1769789318473.js
Normal file
@ -0,0 +1,27 @@
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn('users', 'address', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
});
|
||||
await queryInterface.addColumn('users', 'city', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
});
|
||||
await queryInterface.addColumn('users', 'zipCode', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
});
|
||||
await queryInterface.addColumn('users', 'country', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.removeColumn('users', 'address');
|
||||
await queryInterface.removeColumn('users', 'city');
|
||||
await queryInterface.removeColumn('users', 'zipCode');
|
||||
await queryInterface.removeColumn('users', 'country');
|
||||
}
|
||||
};
|
||||
108
backend/src/db/migrations/1769789318475.js
Normal file
108
backend/src/db/migrations/1769789318475.js
Normal file
@ -0,0 +1,108 @@
|
||||
const Sequelize = require('sequelize');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
try {
|
||||
await queryInterface.createTable('wishlists', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
userId: {
|
||||
type: Sequelize.UUID,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
productId: {
|
||||
type: Sequelize.UUID,
|
||||
references: {
|
||||
model: 'products',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
createdById: {
|
||||
type: Sequelize.UUID,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
updatedById: {
|
||||
type: Sequelize.UUID,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
deletedAt: {
|
||||
type: Sequelize.DATE,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('Table wishlists might already exist');
|
||||
}
|
||||
|
||||
// Add permissions
|
||||
const permissionsToCreate = [
|
||||
'wishlists_create',
|
||||
'wishlists_read',
|
||||
'wishlists_edit',
|
||||
'wishlists_delete',
|
||||
'wishlists_import',
|
||||
];
|
||||
|
||||
const permissionIds = {};
|
||||
|
||||
for (const pName of permissionsToCreate) {
|
||||
const id = uuidv4();
|
||||
permissionIds[pName] = id;
|
||||
await queryInterface.bulkInsert('permissions', [{
|
||||
id: id,
|
||||
name: pName,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}]);
|
||||
}
|
||||
|
||||
// Get Role IDs
|
||||
const [roles] = await queryInterface.sequelize.query(
|
||||
`SELECT id, name FROM roles WHERE name IN ('Administrator', 'Customer');`
|
||||
);
|
||||
|
||||
for (const role of roles) {
|
||||
for (const pName of permissionsToCreate) {
|
||||
await queryInterface.bulkInsert('rolesPermissionsPermissions', [{
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
roles_permissionsId: role.id,
|
||||
permissionId: permissionIds[pName],
|
||||
}]);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.dropTable('wishlists');
|
||||
},
|
||||
};
|
||||
@ -13,73 +13,35 @@ module.exports = function(sequelize, DataTypes) {
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
title: {
|
||||
title: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
slug: {
|
||||
slug: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
description: {
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
price: {
|
||||
price: {
|
||||
type: DataTypes.DECIMAL,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
sku: {
|
||||
sku: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
stock: {
|
||||
stock: {
|
||||
type: DataTypes.INTEGER,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
active: {
|
||||
active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
created_on: {
|
||||
created_on: {
|
||||
type: DataTypes.DATE,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
updated_on: {
|
||||
updated_on: {
|
||||
type: DataTypes.DATE,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
importHash: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
@ -94,16 +56,10 @@ updated_on: {
|
||||
);
|
||||
|
||||
products.associate = (db) => {
|
||||
|
||||
|
||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
db.products.hasMany(db.reviews, {
|
||||
as: 'reviews',
|
||||
foreignKey: 'productId',
|
||||
});
|
||||
|
||||
db.products.hasMany(db.order_items, {
|
||||
as: 'order_items_product',
|
||||
@ -113,8 +69,6 @@ updated_on: {
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
|
||||
db.products.hasMany(db.cart_items, {
|
||||
as: 'cart_items_product',
|
||||
foreignKey: {
|
||||
@ -123,13 +77,6 @@ updated_on: {
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
//end loop
|
||||
|
||||
|
||||
|
||||
db.products.belongsTo(db.categories, {
|
||||
as: 'category',
|
||||
foreignKey: {
|
||||
@ -138,8 +85,6 @@ updated_on: {
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
|
||||
db.products.hasMany(db.file, {
|
||||
as: 'images',
|
||||
foreignKey: 'belongsToId',
|
||||
@ -150,7 +95,6 @@ updated_on: {
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
db.products.belongsTo(db.users, {
|
||||
as: 'createdBy',
|
||||
});
|
||||
@ -160,9 +104,5 @@ updated_on: {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
return products;
|
||||
};
|
||||
|
||||
|
||||
};
|
||||
50
backend/src/db/models/reviews.js
Normal file
50
backend/src/db/models/reviews.js
Normal file
@ -0,0 +1,50 @@
|
||||
module.exports = function(sequelize, DataTypes) {
|
||||
const reviews = sequelize.define(
|
||||
'reviews',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
rating: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
validate: {
|
||||
min: 1,
|
||||
max: 5,
|
||||
},
|
||||
},
|
||||
comment: {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
paranoid: true,
|
||||
freezeTableName: true,
|
||||
},
|
||||
);
|
||||
|
||||
reviews.associate = (db) => {
|
||||
db.reviews.belongsTo(db.users, {
|
||||
as: 'user',
|
||||
foreignKey: 'userId',
|
||||
});
|
||||
|
||||
db.reviews.belongsTo(db.products, {
|
||||
as: 'product',
|
||||
foreignKey: 'productId',
|
||||
});
|
||||
|
||||
db.reviews.belongsTo(db.users, {
|
||||
as: 'createdBy',
|
||||
});
|
||||
|
||||
db.reviews.belongsTo(db.users, {
|
||||
as: 'updatedBy',
|
||||
});
|
||||
};
|
||||
|
||||
return reviews;
|
||||
};
|
||||
@ -16,92 +16,54 @@ module.exports = function(sequelize, DataTypes) {
|
||||
|
||||
firstName: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
lastName: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
phoneNumber: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
email: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
disabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
password: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
emailVerified: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
emailVerificationToken: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
emailVerificationTokenExpiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
passwordResetToken: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
passwordResetTokenExpiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
provider: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
importHash: {
|
||||
@ -109,6 +71,22 @@ provider: {
|
||||
allowNull: true,
|
||||
unique: true,
|
||||
},
|
||||
|
||||
address: {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
|
||||
city: {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
|
||||
zipCode: {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
|
||||
country: {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
@ -118,7 +96,6 @@ provider: {
|
||||
);
|
||||
|
||||
users.associate = (db) => {
|
||||
|
||||
db.users.belongsToMany(db.permissions, {
|
||||
as: 'custom_permissions',
|
||||
foreignKey: {
|
||||
@ -137,14 +114,10 @@ provider: {
|
||||
through: 'usersCustom_permissionsPermissions',
|
||||
});
|
||||
|
||||
|
||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
db.users.hasMany(db.reviews, {
|
||||
as: 'reviews',
|
||||
foreignKey: 'userId',
|
||||
});
|
||||
|
||||
db.users.hasMany(db.orders, {
|
||||
as: 'orders_user',
|
||||
@ -154,8 +127,6 @@ provider: {
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
|
||||
db.users.hasMany(db.carts, {
|
||||
as: 'carts_user',
|
||||
foreignKey: {
|
||||
@ -164,14 +135,6 @@ provider: {
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//end loop
|
||||
|
||||
|
||||
|
||||
db.users.belongsTo(db.roles, {
|
||||
as: 'app_role',
|
||||
foreignKey: {
|
||||
@ -180,8 +143,6 @@ provider: {
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
|
||||
db.users.hasMany(db.file, {
|
||||
as: 'avatar',
|
||||
foreignKey: 'belongsToId',
|
||||
@ -192,7 +153,6 @@ provider: {
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
db.users.belongsTo(db.users, {
|
||||
as: 'createdBy',
|
||||
});
|
||||
@ -202,7 +162,6 @@ provider: {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
users.beforeCreate((users, options) => {
|
||||
users = trimStringFields(users);
|
||||
|
||||
@ -228,22 +187,12 @@ provider: {
|
||||
users = trimStringFields(users);
|
||||
});
|
||||
|
||||
|
||||
return users;
|
||||
};
|
||||
|
||||
|
||||
function trimStringFields(users) {
|
||||
users.email = users.email.trim();
|
||||
|
||||
users.firstName = users.firstName
|
||||
? users.firstName.trim()
|
||||
: null;
|
||||
|
||||
users.lastName = users.lastName
|
||||
? users.lastName.trim()
|
||||
: null;
|
||||
|
||||
users.firstName = users.firstName ? users.firstName.trim() : null;
|
||||
users.lastName = users.lastName ? users.lastName.trim() : null;
|
||||
return users;
|
||||
}
|
||||
|
||||
}
|
||||
40
backend/src/db/models/wishlists.js
Normal file
40
backend/src/db/models/wishlists.js
Normal file
@ -0,0 +1,40 @@
|
||||
|
||||
module.exports = function(sequelize, DataTypes) {
|
||||
const wishlists = sequelize.define(
|
||||
'wishlists',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
paranoid: true,
|
||||
freezeTableName: true,
|
||||
},
|
||||
);
|
||||
|
||||
wishlists.associate = (db) => {
|
||||
db.wishlists.belongsTo(db.users, {
|
||||
as: 'user',
|
||||
foreignKey: 'userId',
|
||||
});
|
||||
|
||||
db.wishlists.belongsTo(db.products, {
|
||||
as: 'product',
|
||||
foreignKey: 'productId',
|
||||
});
|
||||
|
||||
db.wishlists.belongsTo(db.users, {
|
||||
as: 'createdBy',
|
||||
});
|
||||
|
||||
db.wishlists.belongsTo(db.users, {
|
||||
as: 'updatedBy',
|
||||
});
|
||||
};
|
||||
|
||||
return wishlists;
|
||||
};
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const app = express();
|
||||
@ -28,6 +27,8 @@ const rolesRoutes = require('./routes/roles');
|
||||
const permissionsRoutes = require('./routes/permissions');
|
||||
|
||||
const productsRoutes = require('./routes/products');
|
||||
const reviewsRoutes = require('./routes/reviews');
|
||||
const wishlistsRoutes = require('./routes/wishlists');
|
||||
|
||||
const categoriesRoutes = require('./routes/categories');
|
||||
|
||||
@ -40,6 +41,7 @@ const cartsRoutes = require('./routes/carts');
|
||||
const cart_itemsRoutes = require('./routes/cart_items');
|
||||
|
||||
const paymentsRoutes = require('./routes/payments');
|
||||
const checkoutRoutes = require('./routes/checkout');
|
||||
|
||||
|
||||
const getBaseUrl = (url) => {
|
||||
@ -91,6 +93,7 @@ app.use('/api-docs', function (req, res, next) {
|
||||
app.use(cors({origin: true}));
|
||||
require('./auth/auth');
|
||||
|
||||
app.use('/api/checkout/webhook', express.raw({ type: 'application/json' }));
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
@ -105,9 +108,11 @@ app.use('/api/roles', passport.authenticate('jwt', {session: false}), rolesRoute
|
||||
|
||||
app.use('/api/permissions', passport.authenticate('jwt', {session: false}), permissionsRoutes);
|
||||
|
||||
app.use('/api/products', passport.authenticate('jwt', {session: false}), productsRoutes);
|
||||
app.use('/api/products', productsRoutes);
|
||||
app.use('/api/reviews', reviewsRoutes);
|
||||
app.use('/api/wishlists', passport.authenticate('jwt', {session: false}), wishlistsRoutes);
|
||||
|
||||
app.use('/api/categories', passport.authenticate('jwt', {session: false}), categoriesRoutes);
|
||||
app.use('/api/categories', categoriesRoutes);
|
||||
|
||||
app.use('/api/orders', passport.authenticate('jwt', {session: false}), ordersRoutes);
|
||||
|
||||
@ -118,6 +123,7 @@ app.use('/api/carts', passport.authenticate('jwt', {session: false}), cartsRoute
|
||||
app.use('/api/cart_items', passport.authenticate('jwt', {session: false}), cart_itemsRoutes);
|
||||
|
||||
app.use('/api/payments', passport.authenticate('jwt', {session: false}), paymentsRoutes);
|
||||
app.use('/api/checkout', (req, res, next) => { if (req.path === '/webhook') return next(); passport.authenticate('jwt', {session: false}, (err, user) => { req.currentUser = user; next(); })(req, res, next); }, checkoutRoutes);
|
||||
|
||||
app.use(
|
||||
'/api/openai',
|
||||
@ -157,10 +163,33 @@ if (fs.existsSync(publicDir)) {
|
||||
|
||||
const PORT = process.env.NODE_ENV === 'dev_stage' ? 3000 : 8080;
|
||||
|
||||
db.sequelize.sync().then(function () {
|
||||
db.sequelize.sync().then(async function () {
|
||||
// Ensure public permissions
|
||||
try {
|
||||
const [roles] = await db.sequelize.query(`SELECT id FROM roles WHERE name = 'Public' LIMIT 1;`);
|
||||
const publicRoleId = roles[0]?.id;
|
||||
const [permissions] = await db.sequelize.query(`SELECT id FROM permissions WHERE name IN ('READ_PRODUCTS', 'READ_CATEGORIES');`);
|
||||
|
||||
if (publicRoleId && permissions.length > 0) {
|
||||
for (const p of permissions) {
|
||||
const [existing] = await db.sequelize.query(
|
||||
`SELECT 1 FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${publicRoleId}' AND "permissionId" = '${p.id}';`
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
await db.sequelize.query(
|
||||
`INSERT INTO "rolesPermissionsPermissions" ("createdAt", "updatedAt", "roles_permissionsId", "permissionId")
|
||||
VALUES (NOW(), NOW(), '${publicRoleId}', '${p.id}');`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error setting public permissions:', e);
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Listening on port ${PORT}`);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
module.exports = app;
|
||||
124
backend/src/routes/checkout.js
Normal file
124
backend/src/routes/checkout.js
Normal file
@ -0,0 +1,124 @@
|
||||
const express = require('express');
|
||||
const stripe = require('stripe');
|
||||
const config = require('../config');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
const db = require('../db/models');
|
||||
|
||||
const router = express.Router();
|
||||
// Initialize stripe only if key is available, or use a placeholder to avoid crash
|
||||
const stripeClient = config.stripe.secretKey ? stripe(config.stripe.secretKey) : null;
|
||||
|
||||
router.post('/create-session', wrapAsync(async (req, res) => {
|
||||
if (!stripeClient) {
|
||||
return res.status(500).send({ error: 'Stripe is not configured on the server' });
|
||||
}
|
||||
|
||||
const { items, successUrl, cancelUrl } = req.body;
|
||||
const currentUser = req.currentUser;
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return res.status(400).send({ error: 'No items in cart' });
|
||||
}
|
||||
|
||||
const lineItems = items.map(item => ({
|
||||
price_data: {
|
||||
currency: 'usd',
|
||||
product_data: {
|
||||
name: item.title,
|
||||
images: item.image ? [item.image] : [],
|
||||
},
|
||||
unit_amount: Math.round(item.price * 100),
|
||||
},
|
||||
quantity: item.quantity,
|
||||
}));
|
||||
|
||||
const session = await stripeClient.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
line_items: lineItems,
|
||||
mode: 'payment',
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
customer_email: currentUser ? currentUser.email : undefined,
|
||||
metadata: {
|
||||
userId: currentUser ? currentUser.id : 'guest',
|
||||
items: JSON.stringify(items.map(i => ({
|
||||
id: i.productId,
|
||||
quantity: i.quantity,
|
||||
price: i.price,
|
||||
title: i.title
|
||||
}))),
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).send({ id: session.id });
|
||||
}));
|
||||
|
||||
// Webhook handler
|
||||
router.post('/webhook', async (req, res) => {
|
||||
if (!stripeClient) {
|
||||
return res.status(500).send({ error: 'Stripe is not configured' });
|
||||
}
|
||||
|
||||
const sig = req.headers['stripe-signature'];
|
||||
let event;
|
||||
|
||||
try {
|
||||
event = stripeClient.webhooks.constructEvent(req.body, sig, config.stripe.webhookSecret);
|
||||
} catch (err) {
|
||||
console.error('Webhook Error:', err.message);
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`);
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object;
|
||||
|
||||
// Fulfill the purchase
|
||||
const userId = session.metadata.userId;
|
||||
const items = JSON.parse(session.metadata.items);
|
||||
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
// Create Order
|
||||
const order = await db.orders.create({
|
||||
order_number: `ORD-${Date.now()}`,
|
||||
total: session.amount_total / 100,
|
||||
status: 'paid',
|
||||
payment_status: 'paid',
|
||||
placed_at: new Date(),
|
||||
userId: userId !== 'guest' ? userId : null,
|
||||
}, { transaction });
|
||||
|
||||
// Create Order Items
|
||||
for (const item of items) {
|
||||
await db.order_items.create({
|
||||
orderId: order.id,
|
||||
productId: item.id,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.price,
|
||||
total_price: item.price * item.quantity,
|
||||
name: item.title
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
// Create Payment record
|
||||
await db.payments.create({
|
||||
orderId: order.id,
|
||||
stripe_payment_id: session.payment_intent,
|
||||
amount: session.amount_total / 100,
|
||||
currency: session.currency,
|
||||
status: 'succeeded',
|
||||
paid_at: new Date(),
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
console.log('Order fulfilled successfully');
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('Order fulfillment failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ received: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const express = require('express');
|
||||
|
||||
const OrdersService = require('../services/orders');
|
||||
@ -428,6 +427,7 @@ router.get('/autocomplete', async (req, res) => {
|
||||
router.get('/:id', wrapAsync(async (req, res) => {
|
||||
const payload = await OrdersDBApi.findBy(
|
||||
{ id: req.params.id },
|
||||
{ currentUser: req.currentUser }
|
||||
);
|
||||
|
||||
|
||||
@ -437,4 +437,4 @@ router.get('/:id', wrapAsync(async (req, res) => {
|
||||
|
||||
router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
33
backend/src/routes/reviews.js
Normal file
33
backend/src/routes/reviews.js
Normal file
@ -0,0 +1,33 @@
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const ReviewsService = require('../services/reviews');
|
||||
const { wrapAsync } = require('../helpers');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await ReviewsService.create(req.body, req.user);
|
||||
res.status(201).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await ReviewsService.findAll(req.query);
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/stats/:productId',
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await ReviewsService.getAverageRating(req.params.productId);
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
37
backend/src/routes/wishlists.js
Normal file
37
backend/src/routes/wishlists.js
Normal file
@ -0,0 +1,37 @@
|
||||
|
||||
const express = require('express');
|
||||
const WishlistsService = require('../services/wishlists');
|
||||
const WishlistsDBApi = require('../db/api/wishlists');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
const router = express.Router();
|
||||
|
||||
const {
|
||||
checkCrudPermissions,
|
||||
} = require('../middlewares/check-permissions');
|
||||
|
||||
router.use(checkCrudPermissions('wishlists'));
|
||||
|
||||
router.post('/', wrapAsync(async (req, res) => {
|
||||
const payload = await WishlistsService.create(req.body.data, req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.delete('/:id', wrapAsync(async (req, res) => {
|
||||
await WishlistsService.remove(req.params.id, req.currentUser);
|
||||
res.status(200).send(true);
|
||||
}));
|
||||
|
||||
router.post('/removeByProduct', wrapAsync(async (req, res) => {
|
||||
await WishlistsService.removeFromWishlist(req.body.productId, req.currentUser);
|
||||
res.status(200).send(true);
|
||||
}));
|
||||
|
||||
router.get('/', wrapAsync(async (req, res) => {
|
||||
const currentUser = req.currentUser;
|
||||
const payload = await WishlistsDBApi.findAll(req.query, { currentUser });
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
module.exports = router;
|
||||
@ -8,6 +8,7 @@ const PasswordResetEmail = require('./email/list/passwordReset');
|
||||
const EmailSender = require('./email');
|
||||
const config = require('../config');
|
||||
const helpers = require('../helpers');
|
||||
const db = require('../db/models');
|
||||
|
||||
class Auth {
|
||||
static async signup(email, password, options = {}, host) {
|
||||
@ -309,4 +310,4 @@ class Auth {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Auth;
|
||||
module.exports = Auth;
|
||||
@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #27ae60;
|
||||
color: #fff;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-body {
|
||||
padding: 16px;
|
||||
}
|
||||
.email-footer {
|
||||
padding: 16px;
|
||||
background-color: #f7fafc;
|
||||
text-align: center;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
.link-primary {
|
||||
color: #27ae60;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="email-header">
|
||||
{productTitle} is back in stock!
|
||||
</div>
|
||||
<div class="email-body">
|
||||
<p>Hello,</p>
|
||||
<p>Good news! <strong>{productTitle}</strong> is back in stock and available for purchase.</p>
|
||||
<p>You can view the product here: <a href="{productUrl}" class="link-primary">{productUrl}</a></p>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
Thanks,<br/>
|
||||
The {appTitle} Team
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
37
backend/src/services/email/list/backInStock.js
Normal file
37
backend/src/services/email/list/backInStock.js
Normal file
@ -0,0 +1,37 @@
|
||||
const { getNotification } = require('../../notifications/helpers');
|
||||
const path = require("path");
|
||||
const {promises: fs} = require("fs");
|
||||
|
||||
module.exports = class BackInStockEmail {
|
||||
constructor(to, productTitle, productUrl) {
|
||||
this.to = to;
|
||||
this.productTitle = productTitle;
|
||||
this.productUrl = productUrl;
|
||||
}
|
||||
|
||||
get subject() {
|
||||
return getNotification(
|
||||
'emails.backInStock.subject',
|
||||
this.productTitle,
|
||||
);
|
||||
}
|
||||
|
||||
async html() {
|
||||
try {
|
||||
const templatePath = path.join(__dirname, '../../email/htmlTemplates/backInStock/backInStockEmail.html');
|
||||
|
||||
const template = await fs.readFile(templatePath, 'utf8');
|
||||
|
||||
const appTitle = getNotification('app.title');
|
||||
|
||||
let html = template.replace(/{appTitle}/g, appTitle)
|
||||
.replace(/{productTitle}/g, this.productTitle)
|
||||
.replace(/{productUrl}/g, this.productUrl);
|
||||
|
||||
return html;
|
||||
} catch (error) {
|
||||
console.error('Error generating back in stock email HTML:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -98,7 +98,17 @@ const errors = {
|
||||
<p>Your {0} team</p>
|
||||
`,
|
||||
},
|
||||
backInStock: {
|
||||
subject: `{0} is back in stock!`,
|
||||
body: `
|
||||
<p>Hello,</p>
|
||||
<p>Good news! <strong>{0}</strong> is back in stock and available for purchase.</p>
|
||||
<p>You can view the product here: <a href='{1}'>{1}</a></p>
|
||||
<p>Thanks,</p>
|
||||
<p>The {2} team</p>
|
||||
`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = errors;
|
||||
module.exports = errors;
|
||||
31
backend/src/services/reviews.js
Normal file
31
backend/src/services/reviews.js
Normal file
@ -0,0 +1,31 @@
|
||||
const db = require('../db/models');
|
||||
const ReviewsDBApi = require('../db/api/reviews');
|
||||
|
||||
module.exports = class ReviewsService {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
const review = await ReviewsDBApi.create(
|
||||
data,
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
return review;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
static async findAll(filter) {
|
||||
return await ReviewsDBApi.findAll(filter);
|
||||
}
|
||||
|
||||
static async getAverageRating(productId) {
|
||||
return await ReviewsDBApi.getAverageRating(productId);
|
||||
}
|
||||
};
|
||||
84
backend/src/services/wishlists.js
Normal file
84
backend/src/services/wishlists.js
Normal file
@ -0,0 +1,84 @@
|
||||
|
||||
const db = require('../db/models');
|
||||
const WishlistsDBApi = require('../db/api/wishlists');
|
||||
|
||||
module.exports = class WishlistsService {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
// Check if already in wishlist
|
||||
const existing = await WishlistsDBApi.findBy({
|
||||
userId: currentUser.id,
|
||||
productId: data.product
|
||||
}, { transaction });
|
||||
|
||||
if (existing) {
|
||||
await transaction.commit();
|
||||
return existing;
|
||||
}
|
||||
|
||||
const record = await WishlistsDBApi.create(
|
||||
data,
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
return record;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
static async deleteByIds(ids, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
await WishlistsDBApi.deleteByIds(ids, {
|
||||
currentUser,
|
||||
transaction,
|
||||
});
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async remove(id, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
await WishlistsDBApi.remove(
|
||||
id,
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async removeFromWishlist(productId, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
const record = await WishlistsDBApi.findBy({
|
||||
userId: currentUser.id,
|
||||
productId: productId
|
||||
}, { transaction });
|
||||
|
||||
if (record) {
|
||||
await record.destroy({ transaction });
|
||||
}
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
3120
backend/yarn.lock
3120
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
8464
frontend/package-lock.json
generated
Normal file
8464
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@
|
||||
"@mui/material": "^6.3.0",
|
||||
"@mui/x-data-grid": "^6.19.2",
|
||||
"@reduxjs/toolkit": "^2.1.0",
|
||||
"@stripe/stripe-js": "^8.7.0",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@tinymce/tinymce-react": "^4.3.2",
|
||||
"apexcharts": "^3.45.2",
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import React, {useEffect, useRef, useState} from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
@ -129,4 +128,4 @@ export default function NavBarItem({ item }: Props) {
|
||||
}
|
||||
|
||||
return <div className={componentClass} ref={excludedRef}>{NavBarItemComponentContents}</div>
|
||||
}
|
||||
}
|
||||
@ -13,3 +13,4 @@ export const appTitle = 'created by Flatlogic generator!'
|
||||
export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle} — ${appTitle}`
|
||||
|
||||
export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || ''
|
||||
export const stripePublishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_51P...';
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
@ -126,4 +125,4 @@ export default function LayoutAuthenticated({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -2,11 +2,21 @@ import * as icon from '@mdi/js';
|
||||
import { MenuAsideItem } from './interfaces'
|
||||
|
||||
const menuAside: MenuAsideItem[] = [
|
||||
{
|
||||
href: '/',
|
||||
icon: icon.mdiStorefrontOutline,
|
||||
label: 'Store',
|
||||
},
|
||||
{
|
||||
href: '/dashboard',
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
href: '/my-orders',
|
||||
icon: icon.mdiPackageVariantClosed,
|
||||
label: 'My Orders',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
@ -50,11 +60,11 @@ const menuAside: MenuAsideItem[] = [
|
||||
},
|
||||
{
|
||||
href: '/orders/orders-list',
|
||||
label: 'Orders',
|
||||
label: 'Admin Orders',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCart' in icon ? icon['mdiCart' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_ORDERS'
|
||||
permissions: 'READ_PERMISSIONS' // Only Admins should see the full list
|
||||
},
|
||||
{
|
||||
href: '/order_items/order_items-list',
|
||||
@ -104,4 +114,4 @@ const menuAside: MenuAsideItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export default menuAside
|
||||
export default menuAside
|
||||
@ -10,6 +10,7 @@ import {
|
||||
mdiThemeLightDark,
|
||||
mdiGithub,
|
||||
mdiVuejs,
|
||||
mdiPackageVariantClosed,
|
||||
} from '@mdi/js'
|
||||
import { MenuNavBarItem } from './interfaces'
|
||||
|
||||
@ -22,6 +23,11 @@ const menuNavBar: MenuNavBarItem[] = [
|
||||
label: 'My Profile',
|
||||
href: '/profile',
|
||||
},
|
||||
{
|
||||
icon: mdiPackageVariantClosed,
|
||||
label: 'My Orders',
|
||||
href: '/my-orders',
|
||||
},
|
||||
{
|
||||
isDivider: true,
|
||||
},
|
||||
@ -50,4 +56,4 @@ export const webPagesNavBar = [
|
||||
|
||||
];
|
||||
|
||||
export default menuNavBar
|
||||
export default menuNavBar
|
||||
@ -16,6 +16,8 @@ import { appWithTranslation } from 'next-i18next';
|
||||
import '../i18n';
|
||||
import IntroGuide from '../components/IntroGuide';
|
||||
import { appSteps, loginSteps, usersSteps, rolesSteps } from '../stores/introSteps';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
// Initialize axios
|
||||
axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API
|
||||
@ -191,6 +193,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
stepsEnabled={stepsEnabled}
|
||||
onExit={handleExit}
|
||||
/>
|
||||
<ToastContainer />
|
||||
{(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') && <DevModeBadge />}
|
||||
</>
|
||||
)}
|
||||
|
||||
189
frontend/src/pages/cart.tsx
Normal file
189
frontend/src/pages/cart.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { getPageTitle, stripePublishableKey } from '../config';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { mdiCart, mdiTrashCan, mdiArrowLeft, mdiChevronRight, mdiLock } from '@mdi/js';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { removeFromCart, updateQuantity } from '../stores/shoppingCartSlice';
|
||||
import axios from 'axios';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
|
||||
const stripePromise = loadStripe(stripePublishableKey);
|
||||
|
||||
export default function CartPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const cartItems = useAppSelector((state) => state.shoppingCart.items);
|
||||
const [isCheckingOut, setIsCheckingOut] = useState(false);
|
||||
|
||||
const subtotal = cartItems.reduce((acc, item) => acc + item.price * item.quantity, 0);
|
||||
const shipping = subtotal > 100 ? 0 : 15;
|
||||
const total = subtotal + shipping;
|
||||
|
||||
const handleCheckout = async () => {
|
||||
setIsCheckingOut(true);
|
||||
try {
|
||||
const stripe = await stripePromise;
|
||||
if (!stripe) throw new Error('Stripe failed to load');
|
||||
|
||||
const response = await axios.post('/checkout/create-session', {
|
||||
items: cartItems,
|
||||
successUrl: `${window.location.origin}/checkout-success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancelUrl: `${window.location.origin}/checkout-cancel`,
|
||||
});
|
||||
|
||||
const session = response.data;
|
||||
|
||||
const result = await stripe.redirectToCheckout({
|
||||
sessionId: session.id,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
alert(result.error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Checkout error:', error);
|
||||
alert('Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setIsCheckingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-screen">
|
||||
<Head>
|
||||
<title>{getPageTitle('Shopping Cart')}</title>
|
||||
</Head>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white border-b border-gray-100 py-4 px-6 flex justify-between items-center sticky top-0 z-50">
|
||||
<Link href="/" className="flex items-center text-gray-600 hover:text-blue-600 font-medium transition-colors">
|
||||
<BaseIcon path={mdiArrowLeft} size={20} className="mr-2" /> Continue Shopping
|
||||
</Link>
|
||||
<Link href="/" className="text-2xl font-black text-blue-600 tracking-tight">
|
||||
STORE<span className="text-gray-900">FRONT</span>
|
||||
</Link>
|
||||
<div className="w-24"></div> {/* Spacer */}
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto py-12 px-6">
|
||||
<h1 className="text-4xl font-extrabold text-gray-900 mb-10">Your Shopping Cart</h1>
|
||||
|
||||
{cartItems.length === 0 ? (
|
||||
<div className="bg-white rounded-3xl p-16 text-center shadow-sm border border-gray-100 max-w-2xl mx-auto">
|
||||
<div className="bg-blue-50 w-24 h-24 rounded-full flex items-center justify-center mx-auto mb-8 text-blue-600">
|
||||
<BaseIcon path={mdiCart} size={48} />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">Your cart is empty</h2>
|
||||
<p className="text-gray-500 mb-10 text-lg">Looks like you haven't added anything to your cart yet. Browse our products and find something you love!</p>
|
||||
<BaseButton href="/" label="Start Shopping" color="info" className="px-10 py-4 rounded-2xl font-bold text-lg shadow-lg hover:shadow-xl transition-all" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
{/* Items List */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{cartItems.map((item) => (
|
||||
<div key={item.productId} className="bg-white rounded-3xl p-6 shadow-sm border border-gray-100 flex flex-col sm:flex-row items-center transition-hover hover:shadow-md">
|
||||
<div className="w-32 h-32 bg-gray-100 rounded-2xl overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={item.image || "https://images.pexels.com/photos/1350789/pexels-photo-1350789.jpeg?auto=compress&cs=tinysrgb&w=200"}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0 sm:ml-8 flex-grow">
|
||||
<div className="flex justify-between items-start">
|
||||
<Link href={`/products/${item.productId}`} className="text-xl font-bold text-gray-900 hover:text-blue-600 transition-colors">
|
||||
{item.title}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => dispatch(removeFromCart(item.productId))}
|
||||
className="text-gray-300 hover:text-red-500 transition-colors p-2"
|
||||
>
|
||||
<BaseIcon path={mdiTrashCan} size={22} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-blue-600 font-bold text-lg mt-1">${item.price}</p>
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<div className="flex items-center bg-gray-50 rounded-xl p-1 border border-gray-100">
|
||||
<button
|
||||
onClick={() => dispatch(updateQuantity({ productId: item.productId, quantity: Math.max(1, item.quantity - 1) }))}
|
||||
className="w-10 h-10 flex items-center justify-center text-gray-500 hover:text-blue-600 hover:bg-white rounded-lg transition-all font-bold"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-12 text-center font-extrabold text-gray-900 text-lg">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => dispatch(updateQuantity({ productId: item.productId, quantity: item.quantity + 1 }))}
|
||||
className="w-10 h-10 flex items-center justify-center text-gray-500 hover:text-blue-600 hover:bg-white rounded-lg transition-all font-bold"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="font-black text-gray-900 text-xl">
|
||||
${(item.price * item.quantity).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-[2.5rem] p-10 shadow-sm border border-gray-100 sticky top-28">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-8">Order Summary</h2>
|
||||
<div className="space-y-5 mb-10">
|
||||
<div className="flex justify-between text-gray-500">
|
||||
<span className="font-medium">Subtotal</span>
|
||||
<span className="font-bold text-gray-900 text-lg">${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-500">
|
||||
<span className="font-medium">Shipping</span>
|
||||
<span className="font-bold text-green-600 text-lg">{shipping === 0 ? 'FREE' : `$${shipping.toFixed(2)}`}</span>
|
||||
</div>
|
||||
{shipping > 0 && (
|
||||
<div className="bg-blue-50 p-4 rounded-2xl">
|
||||
<p className="text-sm text-blue-700 font-medium flex items-center">
|
||||
<BaseIcon path={mdiChevronRight} size={16} className="mr-1" />
|
||||
Add <span className="font-bold mx-1">${(100 - subtotal).toFixed(2)}</span> more for Free Shipping
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-gray-100 pt-6 flex justify-between items-center">
|
||||
<span className="text-xl font-bold text-gray-900">Total</span>
|
||||
<span className="text-3xl font-black text-blue-600">${total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<BaseButton
|
||||
onClick={handleCheckout}
|
||||
label={isCheckingOut ? "Processing..." : "Secure Checkout"}
|
||||
color="info"
|
||||
className="w-full py-5 rounded-2xl text-xl font-bold shadow-xl hover:shadow-2xl transition-all transform hover:-translate-y-1"
|
||||
icon={isCheckingOut ? undefined : mdiLock}
|
||||
disabled={isCheckingOut}
|
||||
/>
|
||||
<div className="flex items-center justify-center mt-8 text-gray-400">
|
||||
<BaseIcon path={mdiLock} size={14} className="mr-2" />
|
||||
<span className="text-xs uppercase tracking-widest font-bold">Encrypted & Secure</span>
|
||||
</div>
|
||||
<div className="flex justify-center mt-4 space-x-4 opacity-30 grayscale">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/b/b5/PayPal.svg" alt="PayPal" className="h-4" />
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/5/5e/Visa_Inc._logo.svg" alt="Visa" className="h-4" />
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/2/2a/Mastercard-logo.svg" alt="Mastercard" className="h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CartPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
51
frontend/src/pages/checkout-cancel.tsx
Normal file
51
frontend/src/pages/checkout-cancel.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { getPageTitle } from '../config';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { mdiAlertCircle, mdiCart, mdiArrowLeft } from '@mdi/js';
|
||||
|
||||
export default function CheckoutCancelPage() {
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-screen flex flex-col items-center justify-center p-6">
|
||||
<Head>
|
||||
<title>{getPageTitle('Payment Cancelled')}</title>
|
||||
</Head>
|
||||
|
||||
<div className="bg-white rounded-[3rem] p-12 shadow-xl border border-gray-100 max-w-2xl w-full text-center">
|
||||
<div className="bg-orange-50 w-24 h-24 rounded-full flex items-center justify-center mx-auto mb-8 text-orange-500">
|
||||
<BaseIcon path={mdiAlertCircle} size={48} />
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-black text-gray-900 mb-4 tracking-tight">Payment Cancelled</h1>
|
||||
<p className="text-xl text-gray-500 mb-10 leading-relaxed">
|
||||
Your payment process was cancelled. No charges were made. If you had trouble during checkout, please try again or contact support.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<BaseButton
|
||||
href="/cart"
|
||||
label="Return to Cart"
|
||||
color="info"
|
||||
className="px-10 py-4 rounded-2xl font-bold text-lg shadow-lg hover:shadow-xl transition-all"
|
||||
icon={mdiCart}
|
||||
/>
|
||||
<BaseButton
|
||||
href="/"
|
||||
label="Back to Store"
|
||||
color="white"
|
||||
className="px-10 py-4 rounded-2xl font-bold text-lg border-gray-200"
|
||||
icon={mdiArrowLeft}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CheckoutCancelPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
74
frontend/src/pages/checkout-success.tsx
Normal file
74
frontend/src/pages/checkout-success.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { getPageTitle } from '../config';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { mdiCheckCircle, mdiArrowLeft, mdiPackageVariantClosed } from '@mdi/js';
|
||||
import { useAppDispatch } from '../stores/hooks';
|
||||
import { clearCart } from '../stores/shoppingCartSlice';
|
||||
|
||||
export default function CheckoutSuccessPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(clearCart());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-screen flex flex-col items-center justify-center p-6">
|
||||
<Head>
|
||||
<title>{getPageTitle('Order Confirmed')}</title>
|
||||
</Head>
|
||||
|
||||
<div className="bg-white rounded-[3rem] p-12 shadow-xl border border-gray-100 max-w-2xl w-full text-center">
|
||||
<div className="bg-green-50 w-24 h-24 rounded-full flex items-center justify-center mx-auto mb-8 text-green-500 animate-bounce">
|
||||
<BaseIcon path={mdiCheckCircle} size={48} />
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-black text-gray-900 mb-4 tracking-tight">Order Confirmed!</h1>
|
||||
<p className="text-xl text-gray-500 mb-10 leading-relaxed">
|
||||
Thank you for your purchase. Your order has been placed successfully and we're getting it ready for shipment.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-3xl p-8 mb-10 flex items-center justify-between text-left">
|
||||
<div className="flex items-center">
|
||||
<div className="bg-white p-3 rounded-2xl shadow-sm mr-4 text-blue-600">
|
||||
<BaseIcon path={mdiPackageVariantClosed} size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-400 uppercase tracking-widest">Order Status</p>
|
||||
<p className="text-lg font-black text-gray-900">Processing</p>
|
||||
</div>
|
||||
</div>
|
||||
<BaseButton
|
||||
href="/dashboard"
|
||||
label="View Orders"
|
||||
color="white"
|
||||
className="rounded-xl font-bold border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<BaseButton
|
||||
href="/"
|
||||
label="Back to Store"
|
||||
color="info"
|
||||
className="px-10 py-4 rounded-2xl font-bold text-lg shadow-lg hover:shadow-xl transition-all"
|
||||
icon={mdiArrowLeft}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-gray-400 text-sm font-medium">
|
||||
A confirmation email has been sent to your inbox.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CheckoutSuccessPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
@ -1,166 +1,274 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import axios from 'axios';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { mdiCart, mdiArrowRight, mdiStar } from '@mdi/js';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { addToCart } from '../stores/shoppingCartSlice';
|
||||
|
||||
export default function Home() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [products, setProducts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const dispatch = useAppDispatch();
|
||||
const cartItems = useAppSelector((state) => state.shoppingCart.items);
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('image');
|
||||
const [contentPosition, setContentPosition] = useState('left');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
|
||||
const title = 'App Draft'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [catRes, prodRes] = await Promise.all([
|
||||
axios.get('/categories'),
|
||||
axios.get('/products?limit=8'),
|
||||
]);
|
||||
setCategories(catRes.data.rows || []);
|
||||
setProducts(prodRes.data.rows || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching storefront data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleQuickAdd = (product: any) => {
|
||||
dispatch(addToCart({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
productId: product.id,
|
||||
title: product.title,
|
||||
price: product.price,
|
||||
quantity: 1,
|
||||
image: product.images?.[0]?.url
|
||||
}));
|
||||
};
|
||||
|
||||
const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
||||
|
||||
const getAvgRating = (reviews: any[]) => {
|
||||
if (!reviews || reviews.length === 0) return 0;
|
||||
return reviews.reduce((acc, r) => acc + r.rating, 0) / reviews.length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<div className="bg-white min-h-screen">
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Home')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your App Draft app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
{/* Navigation */}
|
||||
<nav className="border-b border-gray-100 py-4 px-6 flex justify-between items-center sticky top-0 bg-white z-50">
|
||||
<div className="flex items-center space-x-8">
|
||||
<Link href="/" className="text-2xl font-bold text-blue-600">
|
||||
Storefront
|
||||
</Link>
|
||||
<div className="hidden md:flex space-x-6">
|
||||
<Link href="/products" className="text-gray-600 hover:text-blue-600 font-medium">
|
||||
Products
|
||||
</Link>
|
||||
<Link href="/categories" className="text-gray-600 hover:text-blue-600 font-medium">
|
||||
Categories
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/cart" className="relative p-2 text-gray-600 hover:text-blue-600 mr-2">
|
||||
<BaseIcon path={mdiCart} size={24} />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute top-0 right-0 bg-blue-600 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link href="/login" className="text-gray-600 hover:text-blue-600 font-medium">
|
||||
Login
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="bg-blue-600 text-white px-5 py-2 rounded-full font-medium hover:bg-blue-700 transition"
|
||||
>
|
||||
Sign Up
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative bg-gray-50 py-20 px-6">
|
||||
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center">
|
||||
<div className="md:w-1/2 space-y-6">
|
||||
<h1 className="text-5xl md:text-6xl font-extrabold text-gray-900 leading-tight">
|
||||
Upgrade Your Lifestyle with <span className="text-blue-600">Premium Goods</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-lg">
|
||||
Discover our curated collection of high-quality products designed for the modern world.
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<BaseButton
|
||||
href="/products"
|
||||
label="Shop Now"
|
||||
color="info"
|
||||
className="px-8 py-3 rounded-xl text-lg shadow-lg hover:shadow-xl transition-all"
|
||||
/>
|
||||
<BaseButton
|
||||
href="/register"
|
||||
label="Get Started"
|
||||
outline
|
||||
color="info"
|
||||
className="px-8 py-3 rounded-xl text-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:w-1/2 mt-12 md:mt-0 relative">
|
||||
<div className="w-full h-96 bg-blue-100 rounded-3xl overflow-hidden shadow-2xl relative">
|
||||
<img
|
||||
src="https://images.pexels.com/photos/1350789/pexels-photo-1350789.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1"
|
||||
alt="Hero"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-blue-900/40 to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Categories Grid */}
|
||||
<section className="py-20 px-6 max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-end mb-10">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">Shop by Category</h2>
|
||||
<p className="text-gray-500 mt-2">Explore our wide range of products across different categories.</p>
|
||||
</div>
|
||||
<Link href="/categories" className="text-blue-600 font-semibold flex items-center hover:underline">
|
||||
View All <BaseIcon path={mdiArrowRight} size={20} className="ml-1" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{loading
|
||||
? Array(4)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div key={i} className="h-48 bg-gray-100 rounded-2xl animate-pulse"></div>
|
||||
))
|
||||
: categories.slice(0, 4).map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={`/products?category=${cat.id}`}
|
||||
className="group relative h-48 rounded-2xl overflow-hidden shadow-md hover:shadow-xl transition-all"
|
||||
>
|
||||
<div className="absolute inset-0 bg-blue-600 opacity-10 group-hover:opacity-20 transition-opacity"></div>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center p-4">
|
||||
<span className="text-xl font-bold text-gray-900 group-hover:text-blue-600 transition-colors">
|
||||
{cat.name}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 mt-1 uppercase tracking-wider">Explore</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Products */}
|
||||
<section className="py-20 px-6 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-end mb-10">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">Featured Products</h2>
|
||||
<p className="text-gray-500 mt-2">Our handpicked selection for you this season.</p>
|
||||
</div>
|
||||
<Link href="/products" className="text-blue-600 font-semibold flex items-center hover:underline">
|
||||
View All <BaseIcon path={mdiArrowRight} size={20} className="ml-1" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{loading
|
||||
? Array(4)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl p-4 shadow-sm h-80 animate-pulse"></div>
|
||||
))
|
||||
: products.map((product) => {
|
||||
const avg = getAvgRating(product.reviews);
|
||||
return (
|
||||
<div
|
||||
key={product.id}
|
||||
className="bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-xl transition-all group flex flex-col"
|
||||
>
|
||||
<div className="h-48 bg-gray-200 relative overflow-hidden">
|
||||
<img
|
||||
src={`https://images.pexels.com/photos/1350789/pexels-photo-1350789.jpeg?auto=compress&cs=tinysrgb&w=300`}
|
||||
alt={product.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleQuickAdd(product)}
|
||||
className="absolute top-4 right-4 bg-white/90 backdrop-blur-sm p-2 rounded-full shadow-md hover:bg-white hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<BaseIcon path={mdiCart} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 flex-grow flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-bold text-gray-900 line-clamp-1">{product.title}</h3>
|
||||
{avg > 0 && (
|
||||
<div className="flex items-center text-yellow-400 bg-yellow-50 px-2 py-1 rounded-lg">
|
||||
<BaseIcon path={mdiStar} size={14} className="mr-1" />
|
||||
<span className="text-xs font-bold">{avg.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm mt-1 line-clamp-2 flex-grow">
|
||||
{product.description || 'No description available.'}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-between items-center">
|
||||
<span className="text-xl font-extrabold text-blue-600">${product.price}</span>
|
||||
<Link
|
||||
href={`/products/${product.id}`}
|
||||
className="text-sm font-semibold text-gray-700 hover:text-blue-600"
|
||||
>
|
||||
View Details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-white border-t border-gray-100 py-12 px-6">
|
||||
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center space-y-6 md:space-y-0">
|
||||
<div className="flex flex-col items-center md:items-start">
|
||||
<Link href="/" className="text-2xl font-bold text-blue-600">
|
||||
Storefront
|
||||
</Link>
|
||||
<p className="text-gray-500 mt-2 text-sm text-center md:text-left">
|
||||
© 2026 Storefront platform. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-8">
|
||||
<Link href="/privacy-policy" className="text-gray-500 hover:text-blue-600 text-sm">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link href="/terms-of-use" className="text-gray-500 hover:text-blue-600 text-sm">
|
||||
Terms of Use
|
||||
</Link>
|
||||
<Link href="/dashboard" className="text-blue-600 hover:text-blue-700 text-sm font-bold">
|
||||
Admin Interface
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
Home.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
106
frontend/src/pages/my-orders.tsx
Normal file
106
frontend/src/pages/my-orders.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { mdiPackageVariantClosed, mdiEye } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import CardBox from '../components/CardBox';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import axios from 'axios';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Link from 'next/link';
|
||||
|
||||
const MyOrdersPage = () => {
|
||||
const { t } = useTranslation('common');
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const response = await axios.get('/orders');
|
||||
setOrders(response.data.rows || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching orders:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrders();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('My Orders')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiPackageVariantClosed} title="My Orders" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-10">Loading orders...</div>
|
||||
) : orders.length === 0 ? (
|
||||
<CardBox className="text-center py-10">
|
||||
<p className="text-gray-500 mb-4 text-lg">You haven't placed any orders yet.</p>
|
||||
<Link href="/">
|
||||
<BaseButton color="info" label="Go Shopping" />
|
||||
</Link>
|
||||
</CardBox>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{orders.map((order) => (
|
||||
<CardBox key={order.id} className="flex flex-col h-full hover:shadow-lg transition-shadow duration-300">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">Order #{order.order_number || order.id.slice(0, 8)}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Placed on {new Date(order.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`px-3 py-1 rounded-full text-xs font-semibold uppercase ${
|
||||
order.status === 'delivered' ? 'bg-green-100 text-green-800' :
|
||||
order.status === 'cancelled' ? 'bg-red-100 text-red-800' :
|
||||
'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{order.status || 'Pending'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-gray-600 font-medium">Total:</span>
|
||||
<span className="font-bold text-lg">${parseFloat(order.total).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 line-clamp-2 mb-4">
|
||||
<span className="font-medium">Shipping to:</span> {order.shipping_address}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto border-t pt-4 flex justify-end">
|
||||
<Link href={`/order-details?id=${order.id}`}>
|
||||
<BaseButton
|
||||
color="info"
|
||||
label="View Details"
|
||||
icon={mdiEye}
|
||||
small
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</CardBox>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
MyOrdersPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default MyOrdersPage;
|
||||
193
frontend/src/pages/order-details.tsx
Normal file
193
frontend/src/pages/order-details.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
|
||||
import { mdiPackageVariantClosed, mdiArrowLeft } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import CardBox from '../components/CardBox';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import axios from 'axios';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Link from 'next/link';
|
||||
|
||||
const OrderDetailsPage = () => {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const { t } = useTranslation('common');
|
||||
const [order, setOrder] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const fetchOrder = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/orders/${id}`);
|
||||
setOrder(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching order:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrder();
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<LayoutAuthenticated>
|
||||
<SectionMain>
|
||||
<div className="text-center py-10">Loading order details...</div>
|
||||
</SectionMain>
|
||||
</LayoutAuthenticated>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<LayoutAuthenticated>
|
||||
<SectionMain>
|
||||
<div className="text-center py-10">Order not found.</div>
|
||||
</SectionMain>
|
||||
</LayoutAuthenticated>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle(`Order #${order.order_number || order.id.slice(0, 8)}`)}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={mdiPackageVariantClosed}
|
||||
title={`Order #${order.order_number || order.id.slice(0, 8)}`}
|
||||
main
|
||||
>
|
||||
<Link href="/my-orders">
|
||||
<BaseButton icon={mdiArrowLeft} label="Back to Orders" color="white" small />
|
||||
</Link>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Order Summary */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<CardBox title="Items Ordered">
|
||||
<div className="divide-y">
|
||||
{order.order_items_order?.map((item: any) => (
|
||||
<div key={item.id} className="py-4 flex items-center">
|
||||
<div className="h-16 w-16 bg-gray-100 rounded overflow-hidden flex-shrink-0">
|
||||
{item.product?.image && item.product.image.length > 0 ? (
|
||||
<img
|
||||
src={item.product.image[0].downloadUrl || item.product.image[0].publicUrl}
|
||||
alt={item.product.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center text-gray-400">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4 flex-grow">
|
||||
<h4 className="font-semibold">{item.product?.name || 'Unknown Product'}</h4>
|
||||
<p className="text-sm text-gray-500">Qty: {item.quantity}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold">${parseFloat(item.price).toFixed(2)}</p>
|
||||
<p className="text-xs text-gray-500">ea</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 border-t pt-4 space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Subtotal</span>
|
||||
<span className="font-medium">${parseFloat(order.total).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Shipping</span>
|
||||
<span className="font-medium text-green-600">Free</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t pt-2 text-xl">
|
||||
<span className="font-bold">Total</span>
|
||||
<span className="font-bold">${parseFloat(order.total).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox title="Delivery Information">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-700 mb-2 uppercase text-xs tracking-wider">Shipping Address</h4>
|
||||
<p className="text-gray-600 whitespace-pre-wrap">{order.shipping_address}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-700 mb-2 uppercase text-xs tracking-wider">Order Status</h4>
|
||||
<div className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold uppercase ${
|
||||
order.status === 'delivered' ? 'bg-green-100 text-green-800' :
|
||||
order.status === 'cancelled' ? 'bg-red-100 text-red-800' :
|
||||
'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{order.status || 'Pending'}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h4 className="font-bold text-gray-700 mb-2 uppercase text-xs tracking-wider">Placed On</h4>
|
||||
<p className="text-gray-600">{new Date(order.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
{/* Side Info */}
|
||||
<div className="space-y-6">
|
||||
<CardBox title="Payment Details">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-700 mb-1 uppercase text-xs tracking-wider">Payment Status</h4>
|
||||
<div className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-bold uppercase ${
|
||||
order.payment_status === 'paid' ? 'bg-green-100 text-green-700' :
|
||||
'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{order.payment_status || 'Unpaid'}
|
||||
</div>
|
||||
</div>
|
||||
{order.payments_order?.[0] && (
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-700 mb-1 uppercase text-xs tracking-wider">Payment Method</h4>
|
||||
<p className="text-gray-600">
|
||||
Stripe Payment
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-700 mb-1 uppercase text-xs tracking-wider">Billing Address</h4>
|
||||
<p className="text-gray-600 text-sm whitespace-pre-wrap">{order.billing_address || order.shipping_address}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="bg-blue-50 dark:bg-dark-700">
|
||||
<h4 className="font-bold mb-2">Need Help?</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
If you have any questions about your order, please contact our support team.
|
||||
</p>
|
||||
<BaseButton color="info" label="Contact Support" small outline block />
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
OrderDetailsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default OrderDetailsPage;
|
||||
@ -34,7 +34,7 @@ const OrdersTablesPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'OrderNumber', title: 'order_number'},{label: 'ShippingAddress', title: 'shipping_address'},{label: 'BillingAddress', title: 'billing_address'},
|
||||
const [filters] = useState([{label: 'OrderNumber', title: 'order_number'},{label: 'ProductName', title: 'productName'},{label: 'ShippingAddress', title: 'shipping_address'},{label: 'BillingAddress', title: 'billing_address'},
|
||||
|
||||
{label: 'TotalAmount', title: 'total', number: 'true'},
|
||||
{label: 'PlacedAt', title: 'placed_at', date: 'true'},{label: 'ShippedAt', title: 'shipped_at', date: 'true'},
|
||||
@ -165,4 +165,4 @@ OrdersTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
)
|
||||
}
|
||||
|
||||
export default OrdersTablesPage
|
||||
export default OrdersTablesPage
|
||||
@ -34,7 +34,7 @@ const OrdersTablesPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'OrderNumber', title: 'order_number'},{label: 'ShippingAddress', title: 'shipping_address'},{label: 'BillingAddress', title: 'billing_address'},
|
||||
const [filters] = useState([{label: 'OrderNumber', title: 'order_number'},{label: 'ProductName', title: 'productName'},{label: 'ShippingAddress', title: 'shipping_address'},{label: 'BillingAddress', title: 'billing_address'},
|
||||
|
||||
{label: 'TotalAmount', title: 'total', number: 'true'},
|
||||
{label: 'PlacedAt', title: 'placed_at', date: 'true'},{label: 'ShippedAt', title: 'shipped_at', date: 'true'},
|
||||
@ -165,4 +165,4 @@ OrdersTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
)
|
||||
}
|
||||
|
||||
export default OrdersTablesPage
|
||||
export default OrdersTablesPage
|
||||
234
frontend/src/pages/products.tsx
Normal file
234
frontend/src/pages/products.tsx
Normal file
@ -0,0 +1,234 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import axios from 'axios';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { mdiCart, mdiFilter, mdiStar, mdiHeart, mdiHeartOutline } from '@mdi/js';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { addToCart } from '../stores/shoppingCartSlice';
|
||||
import { useRouter } from 'next/router';
|
||||
import { toggleWishlist, fetchWishlist } from '../stores/wishlistSlice';
|
||||
|
||||
export default function ProductCatalog() {
|
||||
const router = useRouter();
|
||||
const { category } = router.query;
|
||||
const [products, setProducts] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const dispatch = useAppDispatch();
|
||||
const cartItems = useAppSelector((state) => state.shoppingCart.items);
|
||||
const { wishlist } = useAppSelector((state) => state.wishlist);
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
|
||||
useEffect(() => {
|
||||
if (category) {
|
||||
setSelectedCategory(category as string);
|
||||
}
|
||||
}, [category]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
dispatch(fetchWishlist({}));
|
||||
}
|
||||
}, [currentUser, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const catUrl = '/categories';
|
||||
const prodUrl = selectedCategory === 'all'
|
||||
? '/products'
|
||||
: `/products?category=${selectedCategory}`;
|
||||
|
||||
const [catRes, prodRes] = await Promise.all([
|
||||
axios.get(catUrl),
|
||||
axios.get(prodUrl),
|
||||
]);
|
||||
setCategories(catRes.data.rows || []);
|
||||
setProducts(prodRes.data.rows || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching catalog data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [selectedCategory]);
|
||||
|
||||
const handleQuickAdd = (product: any) => {
|
||||
dispatch(addToCart({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
productId: product.id,
|
||||
title: product.title,
|
||||
price: product.price,
|
||||
quantity: 1,
|
||||
image: product.images?.[0]?.url
|
||||
}));
|
||||
};
|
||||
|
||||
const isInWishlist = (productId: string) => {
|
||||
return wishlist.some((item: any) => (item.product?.id === productId || item.productId === productId));
|
||||
};
|
||||
|
||||
const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
||||
|
||||
const getAvgRating = (reviews: any[]) => {
|
||||
if (!reviews || reviews.length === 0) return 0;
|
||||
return reviews.reduce((acc, r) => acc + r.rating, 0) / reviews.length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-screen">
|
||||
<Head>
|
||||
<title>{getPageTitle('Our Products')}</title>
|
||||
</Head>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="border-b border-gray-100 py-4 px-6 flex justify-between items-center sticky top-0 bg-white z-50">
|
||||
<Link href="/" className="text-2xl font-bold text-blue-600">
|
||||
Storefront
|
||||
</Link>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/wishlist" className="p-2 text-gray-600 hover:text-red-500 transition-colors">
|
||||
<BaseIcon path={mdiHeart} size={24} />
|
||||
</Link>
|
||||
<Link href="/cart" className="relative p-2 text-gray-600 hover:text-blue-600">
|
||||
<BaseIcon path={mdiCart} size={24} />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute top-0 right-0 bg-blue-600 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto py-12 px-6">
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
{/* Sidebar Filters */}
|
||||
<aside className="md:w-64 space-y-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center">
|
||||
<BaseIcon path={mdiFilter} size={20} className="mr-2" /> Categories
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setSelectedCategory('all')}
|
||||
className={`w-full text-left px-4 py-2 rounded-xl transition-all ${
|
||||
selectedCategory === 'all'
|
||||
? 'bg-blue-600 text-white font-bold shadow-md'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
All Products
|
||||
</button>
|
||||
{categories.map((cat: any) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setSelectedCategory(cat.id)}
|
||||
className={`w-full text-left px-4 py-2 rounded-xl transition-all ${
|
||||
selectedCategory === cat.id
|
||||
? 'bg-blue-600 text-white font-bold shadow-md'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Product Grid */}
|
||||
<div className="flex-grow">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">
|
||||
{selectedCategory === 'all' ? 'All Products' : categories.find((c: any) => c.id === selectedCategory)?.name}
|
||||
</h1>
|
||||
<p className="text-gray-500 font-medium">{products.length} Products Found</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{loading ? (
|
||||
Array(6).fill(0).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-3xl p-4 shadow-sm h-96 animate-pulse"></div>
|
||||
))
|
||||
) : products.length > 0 ? (
|
||||
products.map((product: any) => {
|
||||
const avg = getAvgRating(product.reviews);
|
||||
const wishlisted = isInWishlist(product.id);
|
||||
return (
|
||||
<div key={product.id} className="bg-white rounded-3xl overflow-hidden shadow-sm hover:shadow-xl transition-all group flex flex-col border border-gray-100">
|
||||
<div className="h-56 bg-gray-100 relative overflow-hidden">
|
||||
<img
|
||||
src={product.images?.[0]?.url || `https://images.pexels.com/photos/1350789/pexels-photo-1350789.jpeg?auto=compress&cs=tinysrgb&w=300`}
|
||||
alt={product.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
<div className="absolute top-4 right-4 flex flex-col space-y-2">
|
||||
<button
|
||||
onClick={() => handleQuickAdd(product)}
|
||||
className="bg-white/90 backdrop-blur-sm p-3 rounded-2xl shadow-md hover:bg-blue-600 hover:text-white transition-all transform hover:scale-110"
|
||||
>
|
||||
<BaseIcon path={mdiCart} size={20} />
|
||||
</button>
|
||||
{currentUser && (
|
||||
<button
|
||||
onClick={() => dispatch(toggleWishlist(product.id))}
|
||||
className={`bg-white/90 backdrop-blur-sm p-3 rounded-2xl shadow-md transition-all transform hover:scale-110 ${wishlisted ? 'text-red-500' : 'text-gray-400 hover:text-red-500'}`}
|
||||
>
|
||||
<BaseIcon path={wishlisted ? mdiHeart : mdiHeartOutline} size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 flex-grow flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xl font-bold text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-1">
|
||||
{product.title}
|
||||
</h3>
|
||||
{avg > 0 && (
|
||||
<div className="flex items-center text-yellow-400 bg-yellow-50 px-2 py-1 rounded-lg">
|
||||
<BaseIcon path={mdiStar} size={14} className="mr-1" />
|
||||
<span className="text-xs font-bold">{avg.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm line-clamp-2 mb-6 flex-grow">
|
||||
{product.description || 'Premium quality product for your daily needs.'}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-auto">
|
||||
<span className="text-2xl font-black text-blue-600">${product.price}</span>
|
||||
<Link
|
||||
href={`/products/${product.id}`}
|
||||
className="px-4 py-2 bg-gray-900 text-white text-xs font-bold rounded-xl hover:bg-blue-600 transition-colors uppercase tracking-widest"
|
||||
>
|
||||
View Details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)})
|
||||
) : (
|
||||
<div className="col-span-full py-20 text-center bg-white rounded-3xl border-2 border-dashed border-gray-200">
|
||||
<p className="text-gray-500 font-bold text-xl">No products found in this category.</p>
|
||||
<BaseButton onClick={() => setSelectedCategory('all')} label="Clear Filters" color="info" outline className="mt-4 rounded-xl" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ProductCatalog.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
403
frontend/src/pages/products/[id].tsx
Normal file
403
frontend/src/pages/products/[id].tsx
Normal file
@ -0,0 +1,403 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import axios from 'axios';
|
||||
import BaseButton from '../../components/BaseButton';
|
||||
import BaseIcon from '../../components/BaseIcon';
|
||||
import { getPageTitle } from '../../config';
|
||||
import LayoutGuest from '../../layouts/Guest';
|
||||
import {
|
||||
mdiCart,
|
||||
mdiArrowLeft,
|
||||
mdiCheckDecagram,
|
||||
mdiTruckDelivery,
|
||||
mdiShieldCheck,
|
||||
mdiStar,
|
||||
mdiStarOutline,
|
||||
mdiStarHalf,
|
||||
mdiAccountCircle,
|
||||
mdiHeart,
|
||||
mdiHeartOutline
|
||||
} from '@mdi/js';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { addToCart } from '../../stores/shoppingCartSlice';
|
||||
import { createReview } from '../../stores/reviewsSlice';
|
||||
import { toggleWishlist, fetchWishlist } from '../../stores/wishlistSlice';
|
||||
|
||||
export default function ProductDetail() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const { id } = router.query;
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const cartItems = useAppSelector((state) => state.shoppingCart.items);
|
||||
const { wishlist } = useAppSelector((state) => state.wishlist);
|
||||
|
||||
const [product, setProduct] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
// Review form state
|
||||
const [rating, setRating] = useState(5);
|
||||
const [comment, setComment] = useState('');
|
||||
const [submittingReview, setSubmittingReview] = useState(false);
|
||||
|
||||
const fetchProduct = async () => {
|
||||
try {
|
||||
const res = await axios.get(`/products/${id}`);
|
||||
setProduct(res.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching product:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchProduct();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
dispatch(fetchWishlist({}));
|
||||
}
|
||||
}, [currentUser, dispatch]);
|
||||
|
||||
const handleAddToCart = () => {
|
||||
setAdding(true);
|
||||
dispatch(addToCart({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
productId: product.id,
|
||||
title: product.title,
|
||||
price: product.price,
|
||||
quantity: quantity,
|
||||
image: product.images?.[0]?.url
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
setAdding(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleReviewSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!currentUser) return;
|
||||
|
||||
setSubmittingReview(true);
|
||||
try {
|
||||
await dispatch(createReview({
|
||||
productId: product.id,
|
||||
rating,
|
||||
comment
|
||||
})).unwrap();
|
||||
setComment('');
|
||||
setRating(5);
|
||||
// Refresh product to show new review
|
||||
fetchProduct();
|
||||
} catch (error) {
|
||||
console.error('Failed to submit review:', error);
|
||||
} finally {
|
||||
setSubmittingReview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isInWishlist = (productId: string) => {
|
||||
return wishlist.some((item: any) => (item.product?.id === productId || item.productId === productId));
|
||||
};
|
||||
|
||||
const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
||||
|
||||
const renderStars = (val: number) => {
|
||||
const stars = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
if (i <= val) {
|
||||
stars.push(<BaseIcon key={i} path={mdiStar} size={18} className="text-yellow-400" />);
|
||||
} else if (i - 0.5 <= val) {
|
||||
stars.push(<BaseIcon key={i} path={mdiStarHalf} size={18} className="text-yellow-400" />);
|
||||
} else {
|
||||
stars.push(<BaseIcon key={i} path={mdiStarOutline} size={18} className="text-gray-300" />);
|
||||
}
|
||||
}
|
||||
return stars;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col items-center justify-center p-6 text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">Product Not Found</h1>
|
||||
<p className="text-gray-600 mb-8">The product you are looking for does not exist or has been removed.</p>
|
||||
<BaseButton href="/" label="Back to Store" color="info" rounded-full />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const averageRating = product.reviews?.length > 0
|
||||
? product.reviews.reduce((acc: number, r: any) => acc + r.rating, 0) / product.reviews.length
|
||||
: 0;
|
||||
|
||||
const wishlisted = isInWishlist(product.id);
|
||||
|
||||
return (
|
||||
<div className="bg-white min-h-screen">
|
||||
<Head>
|
||||
<title>{getPageTitle(product.title)}</title>
|
||||
</Head>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="border-b border-gray-100 py-4 px-6 flex justify-between items-center sticky top-0 bg-white z-50">
|
||||
<Link href="/" className="flex items-center text-gray-600 hover:text-blue-600 font-medium">
|
||||
<BaseIcon path={mdiArrowLeft} size={20} className="mr-2" /> Back to Store
|
||||
</Link>
|
||||
<div className="flex items-center space-x-4">
|
||||
{currentUser && (
|
||||
<Link href="/wishlist" className="p-2 text-gray-600 hover:text-red-500 transition-colors">
|
||||
<BaseIcon path={mdiHeart} size={24} />
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/cart" className="relative p-2 text-gray-600 hover:text-blue-600">
|
||||
<BaseIcon path={mdiCart} size={24} />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute top-0 right-0 bg-blue-600 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto py-12 px-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-20">
|
||||
{/* Image Gallery */}
|
||||
<div className="space-y-4">
|
||||
<div className="aspect-square bg-gray-100 rounded-3xl overflow-hidden shadow-inner relative">
|
||||
<img
|
||||
src={product.images?.[0]?.url || "https://images.pexels.com/photos/1350789/pexels-photo-1350789.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1"}
|
||||
alt={product.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{currentUser && (
|
||||
<button
|
||||
onClick={() => dispatch(toggleWishlist(product.id))}
|
||||
className={`absolute top-6 right-6 p-4 rounded-2xl shadow-xl backdrop-blur-md transition-all transform hover:scale-110 ${wishlisted ? 'bg-red-500 text-white' : 'bg-white/80 text-gray-400 hover:text-red-500'}`}
|
||||
>
|
||||
<BaseIcon path={wishlisted ? mdiHeart : mdiHeartOutline} size={28} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="aspect-square bg-gray-50 rounded-xl overflow-hidden cursor-pointer hover:ring-2 ring-blue-600 transition-all">
|
||||
<img
|
||||
src="https://images.pexels.com/photos/1350789/pexels-photo-1350789.jpeg?auto=compress&cs=tinysrgb&w=200"
|
||||
alt={`Thumb ${i}`}
|
||||
className="w-full h-full object-cover opacity-60 hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center space-x-2 text-blue-600 mb-2">
|
||||
<BaseIcon path={mdiCheckDecagram} size={18} />
|
||||
<span className="text-sm font-bold uppercase tracking-wider">Premium Selection</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-extrabold text-gray-900 mb-2">{product.title}</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center">
|
||||
{renderStars(averageRating)}
|
||||
<span className="ml-2 text-sm font-bold text-gray-900">{averageRating.toFixed(1)}</span>
|
||||
</div>
|
||||
<span className="text-gray-400">|</span>
|
||||
<span className="text-sm text-gray-500 font-medium">{product.reviews?.length || 0} Reviews</span>
|
||||
<span className="text-gray-400">|</span>
|
||||
<p className="text-gray-500 text-sm font-medium">SKU: {product.sku || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 p-6 bg-gray-50 rounded-2xl border border-gray-100">
|
||||
<div className="flex items-baseline space-x-3">
|
||||
<span className="text-4xl font-extrabold text-blue-600">${product.price}</span>
|
||||
</div>
|
||||
<p className="text-green-600 font-medium text-sm mt-2">In Stock ({product.stock} units available)</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-3">Description</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
{product.description || "Experience the perfect blend of style and functionality with our premium selection. This product is crafted with attention to detail and high-quality materials to ensure longevity and satisfaction."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-6 mt-auto">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center border border-gray-200 rounded-xl">
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
className="px-4 py-2 text-gray-600 hover:text-blue-600 font-bold"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="px-4 py-2 font-bold text-gray-900 min-w-[3rem] text-center">{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-blue-600 font-bold"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<BaseButton
|
||||
onClick={handleAddToCart}
|
||||
label={adding ? "Adding..." : "Add to Cart"}
|
||||
icon={mdiCart}
|
||||
color="info"
|
||||
className="flex-grow py-4 rounded-xl text-lg font-bold shadow-lg hover:shadow-xl transition-all"
|
||||
disabled={adding}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Trust Badges */}
|
||||
<div className="grid grid-cols-3 gap-4 pt-8 border-t border-gray-100">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<BaseIcon path={mdiTruckDelivery} size={24} className="text-gray-400 mb-2" />
|
||||
<span className="text-xs font-bold text-gray-900 uppercase">Fast Delivery</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<BaseIcon path={mdiShieldCheck} size={24} className="text-gray-400 mb-2" />
|
||||
<span className="text-xs font-bold text-gray-900 uppercase">2-Year Warranty</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<BaseIcon path={mdiCheckDecagram} size={24} className="text-gray-400 mb-2" />
|
||||
<span className="text-xs font-bold text-gray-900 uppercase">Authentic</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reviews Section */}
|
||||
<div className="border-t border-gray-100 pt-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
{/* Stats & Form */}
|
||||
<div className="md:col-span-1">
|
||||
<h2 className="text-3xl font-extrabold text-gray-900 mb-6">Customer Reviews</h2>
|
||||
<div className="bg-gray-50 rounded-3xl p-8 border border-gray-100 mb-8">
|
||||
<div className="text-center mb-6">
|
||||
<span className="text-6xl font-black text-blue-600">{averageRating.toFixed(1)}</span>
|
||||
<div className="flex justify-center my-2">
|
||||
{renderStars(averageRating)}
|
||||
</div>
|
||||
<p className="text-gray-500 font-medium">Based on {product.reviews?.length || 0} reviews</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentUser ? (
|
||||
<div className="bg-white rounded-3xl p-8 border border-gray-200 shadow-sm">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">Write a Review</h3>
|
||||
<form onSubmit={handleReviewSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">Rating</label>
|
||||
<div className="flex space-x-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => setRating(star)}
|
||||
className="focus:outline-none transform hover:scale-110 transition-transform"
|
||||
>
|
||||
<BaseIcon
|
||||
path={mdiStar}
|
||||
size={32}
|
||||
className={star <= rating ? "text-yellow-400" : "text-gray-200"}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">Comment</label>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
className="w-full rounded-xl border-gray-200 focus:ring-blue-500 focus:border-blue-500 min-h-[100px]"
|
||||
placeholder="Share your experience..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<BaseButton
|
||||
type="submit"
|
||||
label={submittingReview ? "Submitting..." : "Post Review"}
|
||||
color="info"
|
||||
className="w-full py-3 rounded-xl font-bold"
|
||||
disabled={submittingReview}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-blue-50 rounded-3xl p-8 border border-blue-100 text-center">
|
||||
<p className="text-blue-900 font-bold mb-4">Want to share your thoughts?</p>
|
||||
<BaseButton href="/auth/login" label="Login to Write Review" color="info" className="rounded-xl font-bold" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Review List */}
|
||||
<div className="md:col-span-2 space-y-8">
|
||||
{product.reviews?.length > 0 ? (
|
||||
product.reviews.map((review: any) => (
|
||||
<div key={review.id} className="pb-8 border-b border-gray-100 last:border-0">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-blue-100 p-2 rounded-full">
|
||||
<BaseIcon path={mdiAccountCircle} size={24} className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-gray-900">
|
||||
{review.user?.firstName} {review.user?.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(review.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{renderStars(review.rating)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 leading-relaxed italic">
|
||||
"{review.comment}"
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-20 bg-gray-50 rounded-3xl border-2 border-dashed border-gray-200">
|
||||
<BaseIcon path={mdiStarOutline} size={48} className="text-gray-300 mb-4" />
|
||||
<p className="text-gray-500 font-medium">No reviews yet. Be the first to share your experience!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ProductDetail.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
@ -1,12 +1,11 @@
|
||||
import {
|
||||
mdiChartTimelineVariant,
|
||||
mdiAccount,
|
||||
mdiUpload,
|
||||
mdiMapMarker,
|
||||
} from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import { ToastContainer, toast } from 'react-toastify';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CardBox from '../components/CardBox';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
@ -19,31 +18,31 @@ import FormField from '../components/FormField';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import FormCheckRadio from '../components/FormCheckRadio';
|
||||
import FormCheckRadioGroup from '../components/FormCheckRadioGroup';
|
||||
import FormImagePicker from '../components/FormImagePicker';
|
||||
import { SwitchField } from '../components/SwitchField';
|
||||
import { SelectField } from '../components/SelectField';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
|
||||
import { update, fetch } from '../stores/users/usersSlice';
|
||||
import { update } from '../stores/users/usersSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { useRouter } from 'next/router';
|
||||
import {findMe} from "../stores/authSlice";
|
||||
import { findMe } from "../stores/authSlice";
|
||||
|
||||
const EditUsers = () => {
|
||||
const { currentUser, isFetching, token } = useAppSelector(
|
||||
const ProfilePage = () => {
|
||||
const { currentUser } = useAppSelector(
|
||||
(state) => state.auth,
|
||||
);
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const notify = (type, msg) => toast(msg, { type });
|
||||
|
||||
const initVals = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phoneNumber: '',
|
||||
email: '',
|
||||
app_role: '',
|
||||
disabled: false,
|
||||
address: '',
|
||||
city: '',
|
||||
zipCode: '',
|
||||
country: '',
|
||||
avatar: [],
|
||||
password: ''
|
||||
};
|
||||
@ -53,116 +52,131 @@ const EditUsers = () => {
|
||||
if (currentUser?.id && typeof currentUser === 'object') {
|
||||
const newInitialVal = { ...initVals };
|
||||
|
||||
Object.keys(initVals).forEach(
|
||||
(el) => (newInitialVal[el] = currentUser[el]),
|
||||
);
|
||||
Object.keys(initVals).forEach((el) => {
|
||||
if (currentUser[el] !== undefined && currentUser[el] !== null) {
|
||||
newInitialVal[el] = currentUser[el];
|
||||
}
|
||||
});
|
||||
|
||||
setInitialValues(newInitialVal);
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
const handleSubmit = async (data) => {
|
||||
await dispatch(update({ id: currentUser.id, data }));
|
||||
await dispatch(findMe());
|
||||
await router.push('/users/users-list');
|
||||
notify('success', 'Profile was updated!');
|
||||
try {
|
||||
const payload = { ...data };
|
||||
if (!payload.password) {
|
||||
delete payload.password;
|
||||
}
|
||||
|
||||
await dispatch(update({ id: currentUser.id, data: payload })).unwrap();
|
||||
await dispatch(findMe());
|
||||
notify('success', 'Profile updated successfully!');
|
||||
} catch (error) {
|
||||
notify('error', 'Failed to update profile.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Edit profile')}</title>
|
||||
<title>{getPageTitle('My Profile')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={mdiChartTimelineVariant}
|
||||
title='Edit profile'
|
||||
icon={mdiAccount}
|
||||
title='My Profile'
|
||||
main
|
||||
>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<CardBox>
|
||||
{currentUser?.avatar[0]?.publicUrl && <div className={'grid grid-cols-6 gap-4 mb-4'}>
|
||||
<div className="col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8">
|
||||
<img className="w-80 h-80 max-w-full max-h-full object-cover object-center" src={`${currentUser?.avatar[0]?.publicUrl}`} alt="Avatar" />
|
||||
<div className="flex flex-col md:flex-row gap-8 mb-8">
|
||||
<div className="flex-shrink-0">
|
||||
{currentUser?.avatar?.[0]?.publicUrl ? (
|
||||
<div className="w-48 h-48 overflow-hidden border-2 rounded-full flex items-center justify-center">
|
||||
<img className="w-full h-full object-cover" src={currentUser.avatar[0].publicUrl} alt="Avatar" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-48 h-48 bg-gray-200 border-2 rounded-full flex items-center justify-center text-gray-400">
|
||||
<BaseIcon path={mdiAccount} size={48} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
<div className="flex-grow">
|
||||
<h2 className="text-2xl font-bold mb-2">{currentUser?.firstName} {currentUser?.lastName}</h2>
|
||||
<p className="text-gray-500 mb-4">{currentUser?.email}</p>
|
||||
<p className="text-sm text-gray-400">Role: {currentUser?.app_role?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Formik
|
||||
enableReinitialize
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<FormField>
|
||||
<Field
|
||||
label='Avatar'
|
||||
color='info'
|
||||
icon={mdiUpload}
|
||||
path={'users/avatar'}
|
||||
name='avatar'
|
||||
id='avatar'
|
||||
schema={{
|
||||
size: undefined,
|
||||
formats: undefined,
|
||||
}}
|
||||
component={FormImagePicker}
|
||||
></Field>
|
||||
</FormField>
|
||||
<FormField label='First Name'>
|
||||
<Field name='firstName' placeholder='First Name' />
|
||||
</FormField>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Personal Information</h3>
|
||||
<FormField>
|
||||
<Field
|
||||
label='Change Avatar'
|
||||
color='info'
|
||||
icon={mdiUpload}
|
||||
path={'users/avatar'}
|
||||
name='avatar'
|
||||
id='avatar'
|
||||
component={FormImagePicker}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label='First Name'>
|
||||
<Field name='firstName' placeholder='First Name' />
|
||||
</FormField>
|
||||
<FormField label='Last Name'>
|
||||
<Field name='lastName' placeholder='Last Name' />
|
||||
</FormField>
|
||||
<FormField label='Phone Number'>
|
||||
<Field name='phoneNumber' placeholder='Phone Number' />
|
||||
</FormField>
|
||||
<FormField label='E-Mail'>
|
||||
<Field name='email' placeholder='E-Mail' disabled />
|
||||
</FormField>
|
||||
<FormField label='New Password (leave blank to keep current)'>
|
||||
<Field name='password' type="password" placeholder='New Password' />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label='Last Name'>
|
||||
<Field name='lastName' placeholder='Last Name' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Phone Number'>
|
||||
<Field name='phoneNumber' placeholder='Phone Number' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='E-Mail'>
|
||||
<Field name='email' placeholder='E-Mail' disabled />
|
||||
</FormField>
|
||||
|
||||
<FormField label='App Role' labelFor='app_role'>
|
||||
<Field
|
||||
name='app_role'
|
||||
id='app_role'
|
||||
component={SelectField}
|
||||
options={initialValues.app_role}
|
||||
itemRef={'roles'}
|
||||
showField={'name'}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Disabled' labelFor='disabled'>
|
||||
<Field
|
||||
name='disabled'
|
||||
id='disabled'
|
||||
component={SwitchField}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Password"
|
||||
>
|
||||
<Field
|
||||
name="password"
|
||||
placeholder="password"
|
||||
/>
|
||||
</FormField>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<BaseIcon path={mdiMapMarker} className="mr-2" /> Shipping Address
|
||||
</h3>
|
||||
<FormField label='Address'>
|
||||
<Field name='address' placeholder='Street Address' />
|
||||
</FormField>
|
||||
<FormField label='City'>
|
||||
<Field name='city' placeholder='City' />
|
||||
</FormField>
|
||||
<FormField label='Zip Code'>
|
||||
<Field name='zipCode' placeholder='Zip Code' />
|
||||
</FormField>
|
||||
<FormField label='Country'>
|
||||
<Field name='country' placeholder='Country' />
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton type='submit' color='info' label='Submit' />
|
||||
<BaseButton type='submit' color='info' label='Update Profile' />
|
||||
<BaseButton type='reset' color='info' outline label='Reset' />
|
||||
<BaseButton
|
||||
type='reset'
|
||||
color='danger'
|
||||
outline
|
||||
label='Cancel'
|
||||
onClick={() => router.push('/users/users-list')}
|
||||
label='Back to Dashboard'
|
||||
onClick={() => router.push('/dashboard')}
|
||||
/>
|
||||
</BaseButtons>
|
||||
</Form>
|
||||
@ -173,8 +187,8 @@ const EditUsers = () => {
|
||||
);
|
||||
};
|
||||
|
||||
EditUsers.getLayout = function getLayout(page: ReactElement) {
|
||||
ProfilePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default EditUsers;
|
||||
export default ProfilePage;
|
||||
113
frontend/src/pages/wishlist.tsx
Normal file
113
frontend/src/pages/wishlist.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { mdiHeart, mdiCart, mdiTrashCan, mdiArrowLeft } from '@mdi/js';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { fetchWishlist, removeFromWishlist } from '../stores/wishlistSlice';
|
||||
import { addToCart } from '../stores/shoppingCartSlice';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
|
||||
export default function WishlistPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { wishlist, loading } = useAppSelector((state) => state.wishlist);
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
dispatch(fetchWishlist({}));
|
||||
}
|
||||
}, [currentUser, dispatch]);
|
||||
|
||||
const handleAddToCart = (product: any) => {
|
||||
dispatch(addToCart({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
productId: product.id,
|
||||
title: product.title,
|
||||
price: product.price,
|
||||
quantity: 1,
|
||||
image: product.images?.[0]?.url
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('My Wishlist')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiHeart} title="My Wishlist" main>
|
||||
<Link href="/products">
|
||||
<BaseButton label="Back to Shopping" icon={mdiArrowLeft} color="whiteDark" small roundedFull />
|
||||
</Link>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : wishlist.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{wishlist.map((item: any) => {
|
||||
const product = item.product;
|
||||
if (!product) return null;
|
||||
return (
|
||||
<CardBox key={item.id} className="flex flex-col h-full hover:shadow-lg transition-shadow">
|
||||
<div className="relative h-48 -mx-6 -mt-6 mb-4 overflow-hidden rounded-t-2xl">
|
||||
<img
|
||||
src={product.images?.[0]?.url || `https://images.pexels.com/photos/1350789/pexels-photo-1350789.jpeg?auto=compress&cs=tinysrgb&w=300`}
|
||||
alt={product.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<h3 className="text-xl font-bold mb-2">{product.title}</h3>
|
||||
<p className="text-gray-500 text-sm line-clamp-2 mb-4">
|
||||
{product.description || 'Premium quality product for your daily needs.'}
|
||||
</p>
|
||||
<p className="text-2xl font-black text-blue-600 mb-4">${product.price}</p>
|
||||
</div>
|
||||
<div className="flex space-x-2 mt-auto">
|
||||
<BaseButton
|
||||
color="info"
|
||||
icon={mdiCart}
|
||||
label="Add to Cart"
|
||||
onClick={() => handleAddToCart(product)}
|
||||
className="flex-grow rounded-xl"
|
||||
/>
|
||||
<BaseButton
|
||||
color="danger"
|
||||
icon={mdiTrashCan}
|
||||
onClick={() => dispatch(removeFromWishlist(item.id))}
|
||||
className="rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
</CardBox>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<CardBox className="text-center py-20">
|
||||
<BaseIcon path={mdiHeart} size={64} className="text-gray-200 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-500 mb-2">Your wishlist is empty</h2>
|
||||
<p className="text-gray-400 mb-8">Add items you love to your wishlist to find them easily later.</p>
|
||||
<Link href="/products">
|
||||
<BaseButton label="Start Shopping" color="info" className="rounded-xl px-8" />
|
||||
</Link>
|
||||
</CardBox>
|
||||
)}
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
WishlistPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
90
frontend/src/stores/reviewsSlice.ts
Normal file
90
frontend/src/stores/reviewsSlice.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
|
||||
import axios from 'axios'
|
||||
import { fulfilledNotify, rejectNotify, resetNotify } from "../helpers/notifyStateHandler";
|
||||
|
||||
interface ReviewsState {
|
||||
reviews: any[]
|
||||
loading: boolean
|
||||
count: number
|
||||
stats: any
|
||||
notify: {
|
||||
showNotification: boolean
|
||||
textNotification: string
|
||||
typeNotification: string
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: ReviewsState = {
|
||||
reviews: [],
|
||||
loading: false,
|
||||
count: 0,
|
||||
stats: null,
|
||||
notify: {
|
||||
showNotification: false,
|
||||
textNotification: '',
|
||||
typeNotification: 'warn',
|
||||
},
|
||||
}
|
||||
|
||||
export const fetchReviews = createAsyncThunk('reviews/fetch', async (query: any) => {
|
||||
const params = new URLSearchParams(query).toString();
|
||||
const result = await axios.get(`reviews?${params}`)
|
||||
return result.data;
|
||||
})
|
||||
|
||||
export const createReview = createAsyncThunk('reviews/create', async (data: any, { rejectWithValue }) => {
|
||||
try {
|
||||
const result = await axios.post('reviews', data)
|
||||
return result.data
|
||||
} catch (error) {
|
||||
if (!error.response) {
|
||||
throw error;
|
||||
}
|
||||
return rejectWithValue(error.response.data);
|
||||
}
|
||||
})
|
||||
|
||||
export const fetchReviewStats = createAsyncThunk('reviews/fetchStats', async (productId: string) => {
|
||||
const result = await axios.get(`reviews/stats/${productId}`)
|
||||
return result.data;
|
||||
})
|
||||
|
||||
export const reviewsSlice = createSlice({
|
||||
name: 'reviews',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(fetchReviews.pending, (state) => {
|
||||
state.loading = true
|
||||
resetNotify(state);
|
||||
})
|
||||
builder.addCase(fetchReviews.fulfilled, (state, action) => {
|
||||
state.reviews = action.payload.rows;
|
||||
state.count = action.payload.count;
|
||||
state.loading = false
|
||||
})
|
||||
builder.addCase(fetchReviews.rejected, (state, action) => {
|
||||
state.loading = false
|
||||
rejectNotify(state, action);
|
||||
})
|
||||
|
||||
builder.addCase(createReview.pending, (state) => {
|
||||
state.loading = true
|
||||
resetNotify(state);
|
||||
})
|
||||
builder.addCase(createReview.fulfilled, (state) => {
|
||||
state.loading = false
|
||||
fulfilledNotify(state, 'Review submitted successfully');
|
||||
})
|
||||
builder.addCase(createReview.rejected, (state, action) => {
|
||||
state.loading = false
|
||||
rejectNotify(state, action);
|
||||
})
|
||||
|
||||
builder.addCase(fetchReviewStats.fulfilled, (state, action) => {
|
||||
state.stats = action.payload;
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export default reviewsSlice.reducer
|
||||
56
frontend/src/stores/shoppingCartSlice.ts
Normal file
56
frontend/src/stores/shoppingCartSlice.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export interface CartItem {
|
||||
id: string;
|
||||
productId: string;
|
||||
title: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
interface ShoppingCartState {
|
||||
items: CartItem[];
|
||||
}
|
||||
|
||||
const initialState: ShoppingCartState = {
|
||||
items: typeof window !== 'undefined' ? JSON.parse(localStorage.getItem('cart') || '[]') : [],
|
||||
};
|
||||
|
||||
export const shoppingCartSlice = createSlice({
|
||||
name: 'shoppingCart',
|
||||
initialState,
|
||||
reducers: {
|
||||
addToCart: (state, action: PayloadAction<CartItem>) => {
|
||||
const existingItem = state.items.find((item) => item.productId === action.payload.productId);
|
||||
if (existingItem) {
|
||||
existingItem.quantity += action.payload.quantity;
|
||||
} else {
|
||||
state.items.push(action.payload);
|
||||
}
|
||||
localStorage.setItem('cart', JSON.stringify(state.items));
|
||||
},
|
||||
removeFromCart: (state, action: PayloadAction<string>) => {
|
||||
state.items = state.items.filter((item) => item.productId !== action.payload);
|
||||
localStorage.setItem('cart', JSON.stringify(state.items));
|
||||
},
|
||||
updateQuantity: (state, action: PayloadAction<{ productId: string; quantity: number }>) => {
|
||||
const item = state.items.find((item) => item.productId === action.payload.productId);
|
||||
if (item) {
|
||||
item.quantity = action.payload.quantity;
|
||||
if (item.quantity <= 0) {
|
||||
state.items = state.items.filter((i) => i.productId !== action.payload.productId);
|
||||
}
|
||||
}
|
||||
localStorage.setItem('cart', JSON.stringify(state.items));
|
||||
},
|
||||
clearCart: (state) => {
|
||||
state.items = [];
|
||||
localStorage.removeItem('cart');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { addToCart, removeFromCart, updateQuantity, clearCart } = shoppingCartSlice.actions;
|
||||
|
||||
export default shoppingCartSlice.reducer;
|
||||
@ -1,8 +1,11 @@
|
||||
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import styleReducer from './styleSlice';
|
||||
import mainReducer from './mainSlice';
|
||||
import authSlice from './authSlice';
|
||||
import openAiSlice from './openAiSlice';
|
||||
import shoppingCartReducer from './shoppingCartSlice';
|
||||
import wishlistReducer from './wishlistSlice';
|
||||
|
||||
import usersSlice from "./users/usersSlice";
|
||||
import rolesSlice from "./roles/rolesSlice";
|
||||
@ -21,6 +24,8 @@ export const store = configureStore({
|
||||
main: mainReducer,
|
||||
auth: authSlice,
|
||||
openAi: openAiSlice,
|
||||
shoppingCart: shoppingCartReducer,
|
||||
wishlist: wishlistReducer,
|
||||
|
||||
users: usersSlice,
|
||||
roles: rolesSlice,
|
||||
|
||||
110
frontend/src/stores/wishlistSlice.ts
Normal file
110
frontend/src/stores/wishlistSlice.ts
Normal file
@ -0,0 +1,110 @@
|
||||
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
|
||||
import axios from 'axios'
|
||||
import { fulfilledNotify, rejectNotify, resetNotify } from "../helpers/notifyStateHandler";
|
||||
|
||||
interface WishlistState {
|
||||
wishlist: any[]
|
||||
loading: boolean
|
||||
count: number
|
||||
notify: {
|
||||
showNotification: boolean
|
||||
textNotification: string
|
||||
typeNotification: string
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: WishlistState = {
|
||||
wishlist: [],
|
||||
loading: false,
|
||||
count: 0,
|
||||
notify: {
|
||||
showNotification: false,
|
||||
textNotification: '',
|
||||
typeNotification: 'warn',
|
||||
},
|
||||
}
|
||||
|
||||
export const fetchWishlist = createAsyncThunk('wishlist/fetch', async (query: any) => {
|
||||
const params = new URLSearchParams(query).toString();
|
||||
const result = await axios.get(`wishlists?${params}`)
|
||||
return result.data;
|
||||
})
|
||||
|
||||
export const addToWishlist = createAsyncThunk('wishlist/add', async (productId: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const result = await axios.post('wishlists', { data: { product: productId } })
|
||||
return result.data
|
||||
} catch (error) {
|
||||
if (!error.response) {
|
||||
throw error;
|
||||
}
|
||||
return rejectWithValue(error.response.data);
|
||||
}
|
||||
})
|
||||
|
||||
export const removeFromWishlist = createAsyncThunk('wishlist/remove', async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
await axios.delete(`wishlists/${id}`)
|
||||
return id
|
||||
} catch (error) {
|
||||
if (!error.response) {
|
||||
throw error;
|
||||
}
|
||||
return rejectWithValue(error.response.data);
|
||||
}
|
||||
})
|
||||
|
||||
export const toggleWishlist = createAsyncThunk('wishlist/toggle', async (productId: string, { getState, dispatch }) => {
|
||||
const state = getState() as any;
|
||||
const existing = state.wishlist.wishlist.find((item: any) => item.product?.id === productId || item.productId === productId);
|
||||
|
||||
if (existing) {
|
||||
await dispatch(removeFromWishlist(existing.id));
|
||||
} else {
|
||||
await dispatch(addToWishlist(productId));
|
||||
}
|
||||
await dispatch(fetchWishlist({}));
|
||||
})
|
||||
|
||||
export const wishlistSlice = createSlice({
|
||||
name: 'wishlist',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(fetchWishlist.pending, (state) => {
|
||||
state.loading = true
|
||||
resetNotify(state);
|
||||
})
|
||||
builder.addCase(fetchWishlist.fulfilled, (state, action) => {
|
||||
state.wishlist = action.payload.rows;
|
||||
state.count = action.payload.count;
|
||||
state.loading = false
|
||||
})
|
||||
builder.addCase(fetchWishlist.rejected, (state, action) => {
|
||||
state.loading = false
|
||||
rejectNotify(state, action);
|
||||
})
|
||||
|
||||
builder.addCase(addToWishlist.pending, (state) => {
|
||||
state.loading = true
|
||||
resetNotify(state);
|
||||
})
|
||||
builder.addCase(addToWishlist.fulfilled, (state) => {
|
||||
state.loading = false
|
||||
fulfilledNotify(state, 'Product added to wishlist');
|
||||
})
|
||||
builder.addCase(addToWishlist.rejected, (state, action) => {
|
||||
state.loading = false
|
||||
rejectNotify(state, action);
|
||||
})
|
||||
|
||||
builder.addCase(removeFromWishlist.fulfilled, (state, action) => {
|
||||
state.wishlist = state.wishlist.filter(item => item.id !== action.payload);
|
||||
state.count = state.count - 1;
|
||||
fulfilledNotify(state, 'Product removed from wishlist');
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export default wishlistSlice.reducer
|
||||
1780
frontend/yarn.lock
1780
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user