Revert to version 19d1c57
This commit is contained in:
parent
3c06dbfb70
commit
119305dc22
@ -12,6 +12,3 @@ EMAIL_USER=AKIAVEW7G4PQUBGM52OF
|
|||||||
EMAIL_PASS=BLnD4hKGb6YkSz3gaQrf8fnyLi3C3/EdjOOsLEDTDPTz
|
EMAIL_PASS=BLnD4hKGb6YkSz3gaQrf8fnyLi3C3/EdjOOsLEDTDPTz
|
||||||
SECRET_KEY=HUEyqESqgQ1yTwzVlO6wprC9Kf1J1xuA
|
SECRET_KEY=HUEyqESqgQ1yTwzVlO6wprC9Kf1J1xuA
|
||||||
PEXELS_KEY=Vc99rnmOhHhJAbgGQoKLZtsaIVfkeownoQNbTj78VemUjKh08ZYRbf18
|
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
9478
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -36,7 +36,6 @@
|
|||||||
"sequelize": "6.35.2",
|
"sequelize": "6.35.2",
|
||||||
"sequelize-json-schema": "^2.1.1",
|
"sequelize-json-schema": "^2.1.1",
|
||||||
"sqlite": "4.0.15",
|
"sqlite": "4.0.15",
|
||||||
"stripe": "^20.3.0",
|
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"tedious": "^18.2.4"
|
"tedious": "^18.2.4"
|
||||||
|
|||||||
@ -65,10 +65,6 @@ const config = {
|
|||||||
|
|
||||||
|
|
||||||
gpt_key: process.env.GPT_KEY || '',
|
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 || '';
|
config.pexelsKey = process.env.PEXELS_KEY || '';
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@ -254,12 +255,6 @@ module.exports = class OrdersDBApi {
|
|||||||
|
|
||||||
static async findBy(where, options) {
|
static async findBy(where, options) {
|
||||||
const transaction = (options && options.transaction) || undefined;
|
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(
|
const orders = await db.orders.findOne(
|
||||||
{ where },
|
{ where },
|
||||||
@ -280,8 +275,7 @@ module.exports = class OrdersDBApi {
|
|||||||
|
|
||||||
|
|
||||||
output.order_items_order = await orders.getOrder_items_order({
|
output.order_items_order = await orders.getOrder_items_order({
|
||||||
transaction,
|
transaction
|
||||||
include: [{ model: db.products, as: 'product' }]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@ -320,12 +314,6 @@ module.exports = class OrdersDBApi {
|
|||||||
const orderBy = null;
|
const orderBy = null;
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
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 = [
|
let include = [
|
||||||
|
|
||||||
@ -492,23 +480,6 @@ 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}%`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -606,3 +577,4 @@ module.exports = class OrdersDBApi {
|
|||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
const EmailSender = require('../../services/email');
|
|
||||||
const BackInStockEmail = require('../../services/email/list/backInStock');
|
|
||||||
const config = require('../../config');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -68,7 +66,7 @@ module.exports = class ProductsDBApi {
|
|||||||
||
|
||
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
sellerId: data.sellerId || null,
|
|
||||||
importHash: data.importHash || null,
|
importHash: data.importHash || null,
|
||||||
createdById: currentUser.id,
|
createdById: currentUser.id,
|
||||||
updatedById: currentUser.id,
|
updatedById: currentUser.id,
|
||||||
@ -153,7 +151,7 @@ module.exports = class ProductsDBApi {
|
|||||||
||
|
||
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
sellerId: item.sellerId || null,
|
|
||||||
importHash: item.importHash || null,
|
importHash: item.importHash || null,
|
||||||
createdById: currentUser.id,
|
createdById: currentUser.id,
|
||||||
updatedById: currentUser.id,
|
updatedById: currentUser.id,
|
||||||
@ -188,7 +186,6 @@ module.exports = class ProductsDBApi {
|
|||||||
|
|
||||||
const products = await db.products.findByPk(id, {}, {transaction});
|
const products = await db.products.findByPk(id, {}, {transaction});
|
||||||
|
|
||||||
const oldStock = Number(products.stock || 0);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -220,7 +217,6 @@ module.exports = class ProductsDBApi {
|
|||||||
|
|
||||||
if (data.updated_on !== undefined) updatePayload.updated_on = data.updated_on;
|
if (data.updated_on !== undefined) updatePayload.updated_on = data.updated_on;
|
||||||
|
|
||||||
if (data.sellerId !== undefined) updatePayload.sellerId = data.sellerId;
|
|
||||||
|
|
||||||
updatePayload.updatedById = currentUser.id;
|
updatePayload.updatedById = currentUser.id;
|
||||||
|
|
||||||
@ -252,41 +248,10 @@ module.exports = class ProductsDBApi {
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
const newStock = Number(products.stock || 0);
|
|
||||||
if (oldStock === 0 && newStock > 0) {
|
|
||||||
this.sendBackInStockNotifications(products).catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return products;
|
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) {
|
static async deleteByIds(ids, options) {
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
@ -378,12 +343,6 @@ module.exports = class ProductsDBApi {
|
|||||||
transaction
|
transaction
|
||||||
});
|
});
|
||||||
|
|
||||||
output.reviews = await products.getReviews({
|
|
||||||
transaction,
|
|
||||||
include: [{ model: db.users, as: 'user', attributes: ['id', 'firstName', 'lastName'] }],
|
|
||||||
order: [['createdAt', 'DESC']]
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
@ -434,11 +393,6 @@ module.exports = class ProductsDBApi {
|
|||||||
as: 'images',
|
as: 'images',
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
|
||||||
model: db.reviews,
|
|
||||||
as: 'reviews',
|
|
||||||
},
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
@ -449,13 +403,6 @@ module.exports = class ProductsDBApi {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.sellerId) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
sellerId: Utils.uuid(filter.sellerId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.title) {
|
if (filter.title) {
|
||||||
where = {
|
where = {
|
||||||
@ -713,24 +660,6 @@ module.exports = class ProductsDBApi {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getRecommendations(productId, limit = 4) {
|
|
||||||
const product = await db.products.findByPk(productId);
|
|
||||||
if (!product) return [];
|
|
||||||
|
|
||||||
return db.products.findAll({
|
|
||||||
where: {
|
|
||||||
id: { [Op.ne]: productId },
|
|
||||||
categoryId: product.categoryId,
|
|
||||||
active: true
|
|
||||||
},
|
|
||||||
include: [
|
|
||||||
{ model: db.file, as: 'images' },
|
|
||||||
{ model: db.categories, as: 'category' }
|
|
||||||
],
|
|
||||||
limit: Number(limit),
|
|
||||||
order: db.sequelize.random() // Random recommendations from same category
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,84 +0,0 @@
|
|||||||
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,3 +1,4 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@ -84,11 +85,6 @@ module.exports = class UsersDBApi {
|
|||||||
null
|
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,
|
importHash: data.data.importHash || null,
|
||||||
createdById: currentUser.id,
|
createdById: currentUser.id,
|
||||||
updatedById: currentUser.id,
|
updatedById: currentUser.id,
|
||||||
@ -209,11 +205,6 @@ module.exports = class UsersDBApi {
|
|||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
address: item.address || null,
|
|
||||||
city: item.city || null,
|
|
||||||
zipCode: item.zipCode || null,
|
|
||||||
country: item.country || null,
|
|
||||||
|
|
||||||
importHash: item.importHash || null,
|
importHash: item.importHash || null,
|
||||||
createdById: currentUser.id,
|
createdById: currentUser.id,
|
||||||
updatedById: currentUser.id,
|
updatedById: currentUser.id,
|
||||||
@ -307,11 +298,6 @@ module.exports = class UsersDBApi {
|
|||||||
|
|
||||||
if (data.provider !== undefined) updatePayload.provider = data.provider;
|
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;
|
updatePayload.updatedById = currentUser.id;
|
||||||
|
|
||||||
@ -953,3 +939,4 @@ module.exports = class UsersDBApi {
|
|||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
production: {
|
production: {
|
||||||
@ -12,12 +12,11 @@ module.exports = {
|
|||||||
seederStorage: 'sequelize',
|
seederStorage: 'sequelize',
|
||||||
},
|
},
|
||||||
development: {
|
development: {
|
||||||
|
username: 'postgres',
|
||||||
dialect: 'postgres',
|
dialect: 'postgres',
|
||||||
username: process.env.DB_USER,
|
password: '',
|
||||||
password: process.env.DB_PASS,
|
database: 'db_app_draft',
|
||||||
database: process.env.DB_NAME,
|
host: process.env.DB_HOST || 'localhost',
|
||||||
host: process.env.DB_HOST,
|
|
||||||
port: process.env.DB_PORT,
|
|
||||||
logging: console.log,
|
logging: console.log,
|
||||||
seederStorage: 'sequelize',
|
seederStorage: 'sequelize',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
up: async (queryInterface, Sequelize) => {
|
|
||||||
await queryInterface.addColumn('products', 'sale_price', {
|
|
||||||
type: Sequelize.DECIMAL,
|
|
||||||
allowNull: true,
|
|
||||||
});
|
|
||||||
await queryInterface.addColumn('products', 'sale_ends_at', {
|
|
||||||
type: Sequelize.DATE,
|
|
||||||
allowNull: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
down: async (queryInterface, Sequelize) => {
|
|
||||||
await queryInterface.removeColumn('products', 'sale_price');
|
|
||||||
await queryInterface.removeColumn('products', 'sale_ends_at');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
up: async (queryInterface, Sequelize) => {
|
|
||||||
await queryInterface.createTable('discount_codes', {
|
|
||||||
id: {
|
|
||||||
type: Sequelize.UUID,
|
|
||||||
defaultValue: Sequelize.UUIDV4,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
type: Sequelize.STRING,
|
|
||||||
allowNull: false,
|
|
||||||
unique: true,
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
type: Sequelize.ENUM('percent', 'fixed'),
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 'percent',
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
type: Sequelize.DECIMAL(10, 2),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
min_purchase: {
|
|
||||||
type: Sequelize.DECIMAL(10, 2),
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
starts_at: {
|
|
||||||
type: Sequelize.DATE,
|
|
||||||
},
|
|
||||||
expires_at: {
|
|
||||||
type: Sequelize.DATE,
|
|
||||||
},
|
|
||||||
active: {
|
|
||||||
type: Sequelize.BOOLEAN,
|
|
||||||
defaultValue: true,
|
|
||||||
},
|
|
||||||
createdAt: {
|
|
||||||
allowNull: false,
|
|
||||||
type: Sequelize.DATE,
|
|
||||||
},
|
|
||||||
updatedAt: {
|
|
||||||
allowNull: false,
|
|
||||||
type: Sequelize.DATE,
|
|
||||||
},
|
|
||||||
deletedAt: {
|
|
||||||
type: Sequelize.DATE,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
down: async (queryInterface, Sequelize) => {
|
|
||||||
await queryInterface.dropTable('discount_codes');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
up: async (queryInterface, Sequelize) => {
|
|
||||||
await queryInterface.createTable('page_views', {
|
|
||||||
id: {
|
|
||||||
type: Sequelize.UUID,
|
|
||||||
defaultValue: Sequelize.UUIDV4,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
productId: {
|
|
||||||
type: Sequelize.UUID,
|
|
||||||
references: {
|
|
||||||
model: 'products',
|
|
||||||
key: 'id',
|
|
||||||
},
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
onDelete: 'SET NULL',
|
|
||||||
},
|
|
||||||
categoryId: {
|
|
||||||
type: Sequelize.UUID,
|
|
||||||
references: {
|
|
||||||
model: 'categories',
|
|
||||||
key: 'id',
|
|
||||||
},
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
onDelete: 'SET NULL',
|
|
||||||
},
|
|
||||||
userId: {
|
|
||||||
type: Sequelize.UUID,
|
|
||||||
references: {
|
|
||||||
model: 'users',
|
|
||||||
key: 'id',
|
|
||||||
},
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
onDelete: 'SET NULL',
|
|
||||||
},
|
|
||||||
ipAddress: {
|
|
||||||
type: Sequelize.STRING,
|
|
||||||
},
|
|
||||||
userAgent: {
|
|
||||||
type: Sequelize.TEXT,
|
|
||||||
},
|
|
||||||
createdAt: {
|
|
||||||
allowNull: false,
|
|
||||||
type: Sequelize.DATE,
|
|
||||||
},
|
|
||||||
updatedAt: {
|
|
||||||
allowNull: false,
|
|
||||||
type: Sequelize.DATE,
|
|
||||||
},
|
|
||||||
deletedAt: {
|
|
||||||
type: Sequelize.DATE,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
down: async (queryInterface, Sequelize) => {
|
|
||||||
await queryInterface.dropTable('page_views');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
up: async (queryInterface, Sequelize) => {
|
|
||||||
const tableUsers = await queryInterface.describeTable('users');
|
|
||||||
if (!tableUsers.shopName) {
|
|
||||||
await queryInterface.addColumn('users', 'shopName', {
|
|
||||||
type: Sequelize.TEXT,
|
|
||||||
allowNull: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!tableUsers.shopDescription) {
|
|
||||||
await queryInterface.addColumn('users', 'shopDescription', {
|
|
||||||
type: Sequelize.TEXT,
|
|
||||||
allowNull: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!tableUsers.sellerStatus) {
|
|
||||||
await queryInterface.addColumn('users', 'sellerStatus', {
|
|
||||||
type: Sequelize.TEXT,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: 'none',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableProducts = await queryInterface.describeTable('products');
|
|
||||||
if (!tableProducts.sellerId) {
|
|
||||||
await queryInterface.addColumn('products', 'sellerId', {
|
|
||||||
type: Sequelize.UUID,
|
|
||||||
references: {
|
|
||||||
model: 'users',
|
|
||||||
key: 'id',
|
|
||||||
},
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
onDelete: 'SET NULL',
|
|
||||||
allowNull: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const sellerRoleId = 'd3b3b3b3-b3b3-4b3b-b3b3-b3b3b3b3b3b3';
|
|
||||||
|
|
||||||
// Check if Seller role exists
|
|
||||||
const [existingRoles] = await queryInterface.sequelize.query(
|
|
||||||
`SELECT id FROM roles WHERE name = 'Seller' LIMIT 1`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingRoles.length === 0) {
|
|
||||||
await queryInterface.bulkInsert('roles', [{
|
|
||||||
id: sellerRoleId,
|
|
||||||
name: 'Seller',
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grant permissions to Seller
|
|
||||||
const permissions = await queryInterface.sequelize.query(
|
|
||||||
`SELECT id FROM permissions WHERE name IN ('CREATE_PRODUCTS', 'READ_PRODUCTS', 'UPDATE_PRODUCTS', 'DELETE_PRODUCTS', 'READ_CATEGORIES')`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (permissions[0].length > 0) {
|
|
||||||
const rolePermissions = permissions[0].map((p, i) => ({
|
|
||||||
id: `e3b3b3b3-b3b3-4b3b-b3b3-b3b3b3b3b3b${i}`,
|
|
||||||
roles_permissionsId: sellerRoleId,
|
|
||||||
permissionId: p.id,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Use a try-catch to ignore duplicates in junction table
|
|
||||||
try {
|
|
||||||
await queryInterface.bulkInsert('rolesPermissionsPermissions', rolePermissions);
|
|
||||||
} catch (e) {
|
|
||||||
console.log('Role permissions might already exist, skipping...');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
down: async (queryInterface, Sequelize) => {
|
|
||||||
// Standard down migration
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
module.exports = function(sequelize, DataTypes) {
|
|
||||||
const discount_codes = sequelize.define(
|
|
||||||
'discount_codes',
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
defaultValue: DataTypes.UUIDV4,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: false,
|
|
||||||
unique: true,
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
type: DataTypes.ENUM('percent', 'fixed'),
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 'percent',
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
type: DataTypes.DECIMAL(10, 2),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
min_purchase: {
|
|
||||||
type: DataTypes.DECIMAL(10, 2),
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
starts_at: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
},
|
|
||||||
expires_at: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
},
|
|
||||||
active: {
|
|
||||||
type: DataTypes.BOOLEAN,
|
|
||||||
defaultValue: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timestamps: true,
|
|
||||||
paranoid: true,
|
|
||||||
freezeTableName: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
discount_codes.associate = () => {
|
|
||||||
// Add associations if needed
|
|
||||||
};
|
|
||||||
|
|
||||||
return discount_codes;
|
|
||||||
};
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
module.exports = function(sequelize, DataTypes) {
|
|
||||||
const page_views = sequelize.define(
|
|
||||||
'page_views',
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
defaultValue: DataTypes.UUIDV4,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
ipAddress: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
},
|
|
||||||
userAgent: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timestamps: true,
|
|
||||||
paranoid: true,
|
|
||||||
freezeTableName: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
page_views.associate = (db) => {
|
|
||||||
db.page_views.belongsTo(db.users, {
|
|
||||||
as: 'user',
|
|
||||||
foreignKey: 'userId',
|
|
||||||
});
|
|
||||||
|
|
||||||
db.page_views.belongsTo(db.products, {
|
|
||||||
as: 'product',
|
|
||||||
foreignKey: 'productId',
|
|
||||||
});
|
|
||||||
|
|
||||||
db.page_views.belongsTo(db.categories, {
|
|
||||||
as: 'category',
|
|
||||||
foreignKey: 'categoryId',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return page_views;
|
|
||||||
};
|
|
||||||
@ -13,41 +13,73 @@ module.exports = function(sequelize, DataTypes) {
|
|||||||
defaultValue: DataTypes.UUIDV4,
|
defaultValue: DataTypes.UUIDV4,
|
||||||
primaryKey: true,
|
primaryKey: true,
|
||||||
},
|
},
|
||||||
title: {
|
|
||||||
|
title: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
slug: {
|
|
||||||
|
slug: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
description: {
|
|
||||||
|
description: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
price: {
|
|
||||||
|
price: {
|
||||||
type: DataTypes.DECIMAL,
|
type: DataTypes.DECIMAL,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
sale_price: {
|
|
||||||
type: DataTypes.DECIMAL,
|
sku: {
|
||||||
},
|
|
||||||
sale_ends_at: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
},
|
|
||||||
sku: {
|
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
stock: {
|
|
||||||
|
stock: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
active: {
|
|
||||||
|
active: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
|
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
created_on: {
|
|
||||||
|
created_on: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
updated_on: {
|
|
||||||
|
updated_on: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
importHash: {
|
importHash: {
|
||||||
type: DataTypes.STRING(255),
|
type: DataTypes.STRING(255),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
@ -62,10 +94,16 @@ module.exports = function(sequelize, DataTypes) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
products.associate = (db) => {
|
products.associate = (db) => {
|
||||||
db.products.hasMany(db.reviews, {
|
|
||||||
as: 'reviews',
|
|
||||||
foreignKey: 'productId',
|
/// 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.order_items, {
|
db.products.hasMany(db.order_items, {
|
||||||
as: 'order_items_product',
|
as: 'order_items_product',
|
||||||
@ -75,6 +113,8 @@ module.exports = function(sequelize, DataTypes) {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.products.hasMany(db.cart_items, {
|
db.products.hasMany(db.cart_items, {
|
||||||
as: 'cart_items_product',
|
as: 'cart_items_product',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -83,6 +123,13 @@ module.exports = function(sequelize, DataTypes) {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//end loop
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.products.belongsTo(db.categories, {
|
db.products.belongsTo(db.categories, {
|
||||||
as: 'category',
|
as: 'category',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -91,10 +138,7 @@ module.exports = function(sequelize, DataTypes) {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
db.products.belongsTo(db.users, {
|
|
||||||
as: 'seller',
|
|
||||||
foreignKey: 'sellerId',
|
|
||||||
});
|
|
||||||
|
|
||||||
db.products.hasMany(db.file, {
|
db.products.hasMany(db.file, {
|
||||||
as: 'images',
|
as: 'images',
|
||||||
@ -106,6 +150,7 @@ module.exports = function(sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
db.products.belongsTo(db.users, {
|
db.products.belongsTo(db.users, {
|
||||||
as: 'createdBy',
|
as: 'createdBy',
|
||||||
});
|
});
|
||||||
@ -115,5 +160,9 @@ module.exports = function(sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return products;
|
return products;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
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,54 +16,92 @@ module.exports = function(sequelize, DataTypes) {
|
|||||||
|
|
||||||
firstName: {
|
firstName: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
lastName: {
|
lastName: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
phoneNumber: {
|
phoneNumber: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
email: {
|
email: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
disabled: {
|
disabled: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
|
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
password: {
|
password: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
emailVerified: {
|
emailVerified: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
|
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
emailVerificationToken: {
|
emailVerificationToken: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
emailVerificationTokenExpiresAt: {
|
emailVerificationTokenExpiresAt: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
passwordResetToken: {
|
passwordResetToken: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
passwordResetTokenExpiresAt: {
|
passwordResetTokenExpiresAt: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
provider: {
|
provider: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
importHash: {
|
importHash: {
|
||||||
@ -71,32 +109,6 @@ provider: {
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
unique: true,
|
unique: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
address: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
|
|
||||||
city: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
|
|
||||||
zipCode: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
|
|
||||||
country: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
shopName: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
shopDescription: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
sellerStatus: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
defaultValue: 'none',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
@ -106,6 +118,7 @@ provider: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
users.associate = (db) => {
|
users.associate = (db) => {
|
||||||
|
|
||||||
db.users.belongsToMany(db.permissions, {
|
db.users.belongsToMany(db.permissions, {
|
||||||
as: 'custom_permissions',
|
as: 'custom_permissions',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -124,10 +137,14 @@ provider: {
|
|||||||
through: 'usersCustom_permissionsPermissions',
|
through: 'usersCustom_permissionsPermissions',
|
||||||
});
|
});
|
||||||
|
|
||||||
db.users.hasMany(db.reviews, {
|
|
||||||
as: 'reviews',
|
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
||||||
foreignKey: 'userId',
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.users.hasMany(db.orders, {
|
db.users.hasMany(db.orders, {
|
||||||
as: 'orders_user',
|
as: 'orders_user',
|
||||||
@ -137,6 +154,8 @@ provider: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.users.hasMany(db.carts, {
|
db.users.hasMany(db.carts, {
|
||||||
as: 'carts_user',
|
as: 'carts_user',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -145,6 +164,14 @@ provider: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//end loop
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.users.belongsTo(db.roles, {
|
db.users.belongsTo(db.roles, {
|
||||||
as: 'app_role',
|
as: 'app_role',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -153,6 +180,8 @@ provider: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.users.hasMany(db.file, {
|
db.users.hasMany(db.file, {
|
||||||
as: 'avatar',
|
as: 'avatar',
|
||||||
foreignKey: 'belongsToId',
|
foreignKey: 'belongsToId',
|
||||||
@ -163,10 +192,6 @@ provider: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
db.users.hasMany(db.products, {
|
|
||||||
as: 'seller_products',
|
|
||||||
foreignKey: 'sellerId',
|
|
||||||
});
|
|
||||||
|
|
||||||
db.users.belongsTo(db.users, {
|
db.users.belongsTo(db.users, {
|
||||||
as: 'createdBy',
|
as: 'createdBy',
|
||||||
@ -177,6 +202,7 @@ provider: {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
users.beforeCreate((users, options) => {
|
users.beforeCreate((users, options) => {
|
||||||
users = trimStringFields(users);
|
users = trimStringFields(users);
|
||||||
|
|
||||||
@ -202,12 +228,22 @@ provider: {
|
|||||||
users = trimStringFields(users);
|
users = trimStringFields(users);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
return users;
|
return users;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
function trimStringFields(users) {
|
function trimStringFields(users) {
|
||||||
users.email = users.email.trim();
|
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;
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
|
|
||||||
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,24 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
up: async (queryInterface, Sequelize) => {
|
|
||||||
const products = await queryInterface.sequelize.query(
|
|
||||||
`SELECT id FROM products LIMIT 5;`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (products[0].length > 0) {
|
|
||||||
const now = new Date();
|
|
||||||
const saleEnd = new Date(now.getTime() + (24 * 60 * 60 * 1000)); // 24 hours from now
|
|
||||||
|
|
||||||
for (const product of products[0]) {
|
|
||||||
await queryInterface.sequelize.query(
|
|
||||||
`UPDATE products SET sale_price = price * 0.8, sale_ends_at = '${saleEnd.toISOString()}' WHERE id = '${product.id}';`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
down: async (queryInterface, Sequelize) => {
|
|
||||||
await queryInterface.sequelize.query(
|
|
||||||
`UPDATE products SET sale_price = NULL, sale_ends_at = NULL;`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
up: async (queryInterface, Sequelize) => {
|
|
||||||
return queryInterface.bulkInsert('discount_codes', [
|
|
||||||
{
|
|
||||||
id: '99999999-9999-9999-9999-999999999991',
|
|
||||||
code: 'SAVE10',
|
|
||||||
type: 'percent',
|
|
||||||
value: 10.00,
|
|
||||||
min_purchase: 50.00,
|
|
||||||
starts_at: new Date(),
|
|
||||||
expires_at: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
|
||||||
active: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '99999999-9999-9999-9999-999999999992',
|
|
||||||
code: 'WELCOME20',
|
|
||||||
type: 'fixed',
|
|
||||||
value: 20.00,
|
|
||||||
min_purchase: 100.00,
|
|
||||||
starts_at: new Date(),
|
|
||||||
expires_at: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
|
||||||
active: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
down: async (queryInterface, Sequelize) => {
|
|
||||||
return queryInterface.bulkDelete('discount_codes', null, {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -27,12 +28,9 @@ const rolesRoutes = require('./routes/roles');
|
|||||||
const permissionsRoutes = require('./routes/permissions');
|
const permissionsRoutes = require('./routes/permissions');
|
||||||
|
|
||||||
const productsRoutes = require('./routes/products');
|
const productsRoutes = require('./routes/products');
|
||||||
const reviewsRoutes = require('./routes/reviews');
|
|
||||||
const wishlistsRoutes = require('./routes/wishlists');
|
|
||||||
|
|
||||||
const categoriesRoutes = require('./routes/categories');
|
const categoriesRoutes = require('./routes/categories');
|
||||||
|
|
||||||
const aiRecommendationsRoutes = require('./routes/aiRecommendations');
|
|
||||||
const ordersRoutes = require('./routes/orders');
|
const ordersRoutes = require('./routes/orders');
|
||||||
|
|
||||||
const order_itemsRoutes = require('./routes/order_items');
|
const order_itemsRoutes = require('./routes/order_items');
|
||||||
@ -42,9 +40,6 @@ const cartsRoutes = require('./routes/carts');
|
|||||||
const cart_itemsRoutes = require('./routes/cart_items');
|
const cart_itemsRoutes = require('./routes/cart_items');
|
||||||
|
|
||||||
const paymentsRoutes = require('./routes/payments');
|
const paymentsRoutes = require('./routes/payments');
|
||||||
const checkoutRoutes = require('./routes/checkout');
|
|
||||||
const analyticsRoutes = require('./routes/analytics');
|
|
||||||
const sellerRoutes = require('./routes/seller');
|
|
||||||
|
|
||||||
|
|
||||||
const getBaseUrl = (url) => {
|
const getBaseUrl = (url) => {
|
||||||
@ -96,7 +91,6 @@ app.use('/api-docs', function (req, res, next) {
|
|||||||
app.use(cors({origin: true}));
|
app.use(cors({origin: true}));
|
||||||
require('./auth/auth');
|
require('./auth/auth');
|
||||||
|
|
||||||
app.use('/api/checkout/webhook', express.raw({ type: 'application/json' }));
|
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
@ -111,11 +105,9 @@ app.use('/api/roles', passport.authenticate('jwt', {session: false}), rolesRoute
|
|||||||
|
|
||||||
app.use('/api/permissions', passport.authenticate('jwt', {session: false}), permissionsRoutes);
|
app.use('/api/permissions', passport.authenticate('jwt', {session: false}), permissionsRoutes);
|
||||||
|
|
||||||
app.use('/api/products', productsRoutes);
|
app.use('/api/products', passport.authenticate('jwt', {session: false}), productsRoutes);
|
||||||
app.use('/api/reviews', reviewsRoutes);
|
|
||||||
app.use('/api/wishlists', passport.authenticate('jwt', {session: false}), wishlistsRoutes);
|
|
||||||
|
|
||||||
app.use('/api/categories', categoriesRoutes);
|
app.use('/api/categories', passport.authenticate('jwt', {session: false}), categoriesRoutes);
|
||||||
|
|
||||||
app.use('/api/orders', passport.authenticate('jwt', {session: false}), ordersRoutes);
|
app.use('/api/orders', passport.authenticate('jwt', {session: false}), ordersRoutes);
|
||||||
|
|
||||||
@ -126,10 +118,6 @@ 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/cart_items', passport.authenticate('jwt', {session: false}), cart_itemsRoutes);
|
||||||
|
|
||||||
app.use('/api/payments', passport.authenticate('jwt', {session: false}), paymentsRoutes);
|
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/analytics', analyticsRoutes);
|
|
||||||
app.use('/api/seller', sellerRoutes);
|
|
||||||
app.use('/api/recommendations', aiRecommendationsRoutes);
|
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/api/openai',
|
'/api/openai',
|
||||||
@ -169,30 +157,7 @@ if (fs.existsSync(publicDir)) {
|
|||||||
|
|
||||||
const PORT = process.env.NODE_ENV === 'dev_stage' ? 3000 : 8080;
|
const PORT = process.env.NODE_ENV === 'dev_stage' ? 3000 : 8080;
|
||||||
|
|
||||||
db.sequelize.sync().then(async function () {
|
db.sequelize.sync().then(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, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Listening on port ${PORT}`);
|
console.log(`Listening on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const AIRecommendationsService = require('../services/aiRecommendations');
|
|
||||||
const { wrapAsync } = require('../helpers');
|
|
||||||
const passport = require('passport');
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/',
|
|
||||||
(req, res, next) => {
|
|
||||||
passport.authenticate('jwt', { session: false }, (err, user) => {
|
|
||||||
req.user = user || null;
|
|
||||||
next();
|
|
||||||
})(req, res, next);
|
|
||||||
},
|
|
||||||
wrapAsync(async (req, res) => {
|
|
||||||
const userId = req.user ? req.user.id : null;
|
|
||||||
const limit = parseInt(req.query.limit) || 4;
|
|
||||||
const recommendations = await AIRecommendationsService.getRecommendations(userId, limit);
|
|
||||||
res.status(200).send(recommendations);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const passport = require('passport');
|
|
||||||
const AnalyticsService = require('../services/analytics');
|
|
||||||
const { wrapAsync } = require('../helpers');
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/record',
|
|
||||||
wrapAsync(async (req, res) => {
|
|
||||||
const payload = await AnalyticsService.recordView(req.body, req);
|
|
||||||
res.status(200).send(payload);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/top-products',
|
|
||||||
passport.authenticate('jwt', { session: false }),
|
|
||||||
wrapAsync(async (req, res) => {
|
|
||||||
const payload = await AnalyticsService.getTopProducts(req.query.limit);
|
|
||||||
res.status(200).send(payload);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/stats',
|
|
||||||
passport.authenticate('jwt', { session: false }),
|
|
||||||
wrapAsync(async (req, res) => {
|
|
||||||
const payload = await AnalyticsService.getViewStats();
|
|
||||||
res.status(200).send(payload);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -1,9 +1,10 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
|
||||||
const CartsService = require('../services/carts');
|
const CartsService = require('../services/carts');
|
||||||
const CartsDBApi = require('../db/api/carts');
|
const CartsDBApi = require('../db/api/carts');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
const AbandonedCartService = require('../services/notifications/abandonedCart');
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -14,11 +15,6 @@ const {
|
|||||||
checkCrudPermissions,
|
checkCrudPermissions,
|
||||||
} = require('../middlewares/check-permissions');
|
} = require('../middlewares/check-permissions');
|
||||||
|
|
||||||
router.post('/process-abandoned', wrapAsync(async (req, res) => {
|
|
||||||
const result = await AbandonedCartService.processAbandonedCarts();
|
|
||||||
res.status(200).send(result);
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.use(checkCrudPermissions('carts'));
|
router.use(checkCrudPermissions('carts'));
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,203 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const stripe = require('stripe');
|
|
||||||
const config = require('../config');
|
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
|
||||||
const db = require('../db/models');
|
|
||||||
const { Op } = require('sequelize');
|
|
||||||
const LowStockNotificationService = require('../services/notifications/lowStock');
|
|
||||||
|
|
||||||
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('/validate-discount', wrapAsync(async (req, res) => {
|
|
||||||
const { code, total } = req.body;
|
|
||||||
|
|
||||||
if (!code) {
|
|
||||||
return res.status(400).send({ message: 'Discount code is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const discount = await db.discount_codes.findOne({
|
|
||||||
where: {
|
|
||||||
code: code,
|
|
||||||
active: true,
|
|
||||||
starts_at: { [Op.lte]: new Date() },
|
|
||||||
expires_at: { [Op.gte]: new Date() },
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!discount) {
|
|
||||||
return res.status(404).send({ message: 'Invalid or expired discount code' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (total < parseFloat(discount.min_purchase)) {
|
|
||||||
return res.status(400).send({
|
|
||||||
message: `Minimum purchase of $${discount.min_purchase} required for this code`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).send(discount);
|
|
||||||
}));
|
|
||||||
|
|
||||||
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, discountCode } = req.body;
|
|
||||||
const currentUser = req.currentUser;
|
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
|
||||||
return res.status(400).send({ error: 'No items in cart' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let discount = null;
|
|
||||||
if (discountCode) {
|
|
||||||
discount = await db.discount_codes.findOne({
|
|
||||||
where: {
|
|
||||||
code: discountCode,
|
|
||||||
active: true,
|
|
||||||
starts_at: { [Op.lte]: new Date() },
|
|
||||||
expires_at: { [Op.gte]: new Date() },
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalBeforeDiscount = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
|
||||||
let discountAmount = 0;
|
|
||||||
|
|
||||||
if (discount && totalBeforeDiscount >= parseFloat(discount.min_purchase)) {
|
|
||||||
if (discount.type === 'percent') {
|
|
||||||
discountAmount = totalBeforeDiscount * (parseFloat(discount.value) / 100);
|
|
||||||
} else {
|
|
||||||
discountAmount = parseFloat(discount.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap discount at total
|
|
||||||
discountAmount = Math.min(discountAmount, totalBeforeDiscount);
|
|
||||||
|
|
||||||
const lineItems = items.map(item => {
|
|
||||||
const itemTotal = item.price * item.quantity;
|
|
||||||
const ratio = itemTotal / totalBeforeDiscount;
|
|
||||||
const itemDiscount = discountAmount * ratio;
|
|
||||||
const discountedItemTotal = itemTotal - itemDiscount;
|
|
||||||
const discountedUnitPrice = discountedItemTotal / item.quantity;
|
|
||||||
|
|
||||||
return {
|
|
||||||
price_data: {
|
|
||||||
currency: 'usd',
|
|
||||||
product_data: {
|
|
||||||
name: item.title,
|
|
||||||
images: item.image ? [item.image] : [],
|
|
||||||
},
|
|
||||||
unit_amount: Math.max(0, Math.round(discountedUnitPrice * 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',
|
|
||||||
discountCode: discountCode || '',
|
|
||||||
discountAmount: discountAmount.toString(),
|
|
||||||
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 and update stock
|
|
||||||
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 });
|
|
||||||
|
|
||||||
// Update product stock
|
|
||||||
const product = await db.products.findByPk(item.id, { transaction });
|
|
||||||
if (product) {
|
|
||||||
const newStock = Math.max(0, (product.stock || 0) - item.quantity);
|
|
||||||
await product.update({ stock: newStock }, { transaction });
|
|
||||||
|
|
||||||
// Check for low stock after transaction commits (async)
|
|
||||||
transaction.afterCommit(() => {
|
|
||||||
LowStockNotificationService.notify(product.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 and stock updated');
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
console.error('Order fulfillment failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ received: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
|
||||||
const OrdersService = require('../services/orders');
|
const OrdersService = require('../services/orders');
|
||||||
@ -427,7 +428,6 @@ router.get('/autocomplete', async (req, res) => {
|
|||||||
router.get('/:id', wrapAsync(async (req, res) => {
|
router.get('/:id', wrapAsync(async (req, res) => {
|
||||||
const payload = await OrdersDBApi.findBy(
|
const payload = await OrdersDBApi.findBy(
|
||||||
{ id: req.params.id },
|
{ id: req.params.id },
|
||||||
{ currentUser: req.currentUser }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
|
||||||
const ProductsService = require('../services/products');
|
const ProductsService = require('../services/products');
|
||||||
@ -14,11 +15,6 @@ const {
|
|||||||
checkCrudPermissions,
|
checkCrudPermissions,
|
||||||
} = require('../middlewares/check-permissions');
|
} = require('../middlewares/check-permissions');
|
||||||
|
|
||||||
router.get('/:id/recommendations', wrapAsync(async (req, res) => {
|
|
||||||
const payload = await ProductsDBApi.getRecommendations(req.params.id, req.query.limit);
|
|
||||||
res.status(200).send(payload);
|
|
||||||
}));
|
|
||||||
|
|
||||||
router.use(checkCrudPermissions('products'));
|
router.use(checkCrudPermissions('products'));
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,23 +1,37 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const SearchService = require('../services/search');
|
const SearchService = require('../services/search');
|
||||||
const passport = require('passport');
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/autocomplete', async (req, res) => {
|
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||||
const { query } = req.query;
|
router.use(checkCrudPermissions('search'));
|
||||||
|
|
||||||
// Try to get user if token present, but don't require it
|
/**
|
||||||
passport.authenticate('jwt', { session: false }, async (err, user) => {
|
* @swagger
|
||||||
try {
|
* path:
|
||||||
const results = await SearchService.autocomplete(query, user || null);
|
* /api/search:
|
||||||
res.json(results);
|
* post:
|
||||||
} catch (error) {
|
* summary: Search
|
||||||
console.error('Autocomplete API Error:', error);
|
* description: Search results across multiple tables
|
||||||
res.status(500).json({ error: 'Internal Server Error' });
|
* requestBody:
|
||||||
}
|
* content:
|
||||||
})(req, res);
|
* application/json:
|
||||||
});
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* searchQuery:
|
||||||
|
* type: string
|
||||||
|
* required:
|
||||||
|
* - searchQuery
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Successful request
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request
|
||||||
|
* 500:
|
||||||
|
* description: Internal server error
|
||||||
|
*/
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { searchQuery } = req.body;
|
const { searchQuery } = req.body;
|
||||||
@ -26,15 +40,13 @@ router.post('/', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Please enter a search query' });
|
return res.status(400).json({ error: 'Please enter a search query' });
|
||||||
}
|
}
|
||||||
|
|
||||||
passport.authenticate('jwt', { session: false }, async (err, user) => {
|
try {
|
||||||
try {
|
const foundMatches = await SearchService.search(searchQuery, req.currentUser );
|
||||||
const foundMatches = await SearchService.search(searchQuery, user || null);
|
res.json(foundMatches);
|
||||||
res.json(foundMatches);
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error('Internal Server Error', error);
|
||||||
console.error('Search API Error', error);
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
res.status(500).json({ error: 'Internal Server Error' });
|
}
|
||||||
}
|
});
|
||||||
})(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@ -1,55 +0,0 @@
|
|||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const SellerService = require('../services/seller');
|
|
||||||
const { wrapAsync } = require('../helpers');
|
|
||||||
const passport = require('passport');
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/apply',
|
|
||||||
passport.authenticate('jwt', { session: false }),
|
|
||||||
wrapAsync(async (req, res) => {
|
|
||||||
const { shopName, shopDescription } = req.body;
|
|
||||||
const result = await SellerService.apply(req.currentUser.id, { shopName, shopDescription });
|
|
||||||
res.status(200).send(result);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/status',
|
|
||||||
passport.authenticate('jwt', { session: false }),
|
|
||||||
wrapAsync(async (req, res) => {
|
|
||||||
const result = await SellerService.getStatus(req.currentUser.id);
|
|
||||||
res.status(200).send(result);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Admin only: list pending applications
|
|
||||||
router.get(
|
|
||||||
'/admin/pending',
|
|
||||||
passport.authenticate('jwt', { session: false }),
|
|
||||||
wrapAsync(async (req, res) => {
|
|
||||||
// Check if admin (simplified for now)
|
|
||||||
if (req.currentUser.app_role.name !== 'Administrator') {
|
|
||||||
return res.status(403).send({ message: 'Forbidden' });
|
|
||||||
}
|
|
||||||
const result = await SellerService.getPendingApplications();
|
|
||||||
res.status(200).send(result);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Admin only: approve/reject
|
|
||||||
router.post(
|
|
||||||
'/admin/review/:userId',
|
|
||||||
passport.authenticate('jwt', { session: false }),
|
|
||||||
wrapAsync(async (req, res) => {
|
|
||||||
if (req.currentUser.app_role.name !== 'Administrator') {
|
|
||||||
return res.status(403).send({ message: 'Forbidden' });
|
|
||||||
}
|
|
||||||
const { status } = req.body; // approved or rejected
|
|
||||||
const result = await SellerService.reviewApplication(req.params.userId, status);
|
|
||||||
res.status(200).send(result);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
|
|
||||||
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;
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
const { LocalAIApi, decodeJsonFromResponse } = require('../ai/LocalAIApi');
|
|
||||||
const db = require('../db/models');
|
|
||||||
|
|
||||||
class AIRecommendationsService {
|
|
||||||
static async getRecommendations(userId, limit = 4) {
|
|
||||||
try {
|
|
||||||
// 1. Get recent product views
|
|
||||||
const recentViews = await db.page_views.findAll({
|
|
||||||
where: { userId },
|
|
||||||
include: [{ model: db.products, as: 'product' }],
|
|
||||||
order: [['createdAt', 'DESC']],
|
|
||||||
limit: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Get current cart items
|
|
||||||
const cart = await db.carts.findOne({
|
|
||||||
where: { userId },
|
|
||||||
include: [{
|
|
||||||
model: db.cart_items,
|
|
||||||
as: 'cart_items_cart',
|
|
||||||
include: [{ model: db.products, as: 'product' }]
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
const viewedProducts = [...new Set(recentViews.map(v => v.product?.title).filter(Boolean))];
|
|
||||||
const cartProducts = cart?.cart_items_cart?.map(i => i.product?.title).filter(Boolean) || [];
|
|
||||||
|
|
||||||
if (viewedProducts.length === 0 && cartProducts.length === 0) {
|
|
||||||
// Return top products as fallback
|
|
||||||
return await db.products.findAll({ limit });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Get all available product titles for catalog
|
|
||||||
const allProducts = await db.products.findAll({ attributes: ['id', 'title'], limit: 50 });
|
|
||||||
const productListString = allProducts.map(p => p.title).join(', ');
|
|
||||||
|
|
||||||
const prompt = `
|
|
||||||
A user has viewed these products: ${viewedProducts.join(', ')}.
|
|
||||||
They have these products in their cart: ${cartProducts.join(', ')}.
|
|
||||||
Based on this, recommend up to ${limit} products from the following catalog: ${productListString}.
|
|
||||||
Focus on similar categories or complementary items.
|
|
||||||
Return ONLY a JSON array of strings containing the recommended product titles exactly as they appear in the catalog.
|
|
||||||
`;
|
|
||||||
|
|
||||||
const aiResponse = await LocalAIApi.createResponse({
|
|
||||||
input: [
|
|
||||||
{ role: 'system', content: 'You are an e-commerce recommendation engine. Return only JSON array of strings.' },
|
|
||||||
{ role: 'user', content: prompt }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (aiResponse.success) {
|
|
||||||
try {
|
|
||||||
const recommendedTitles = decodeJsonFromResponse(aiResponse);
|
|
||||||
if (Array.isArray(recommendedTitles)) {
|
|
||||||
const recommendedProducts = await db.products.findAll({
|
|
||||||
where: {
|
|
||||||
title: { [db.Sequelize.Op.in]: recommendedTitles }
|
|
||||||
},
|
|
||||||
limit
|
|
||||||
});
|
|
||||||
if (recommendedProducts.length > 0) return recommendedProducts;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('AI JSON parse error:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Return some products if AI fails or no recommendations
|
|
||||||
return await db.products.findAll({
|
|
||||||
order: db.sequelize.random(),
|
|
||||||
limit
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in AIRecommendationsService:', error);
|
|
||||||
return await db.products.findAll({ limit });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = AIRecommendationsService;
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
const db = require('../db/models');
|
|
||||||
const Sequelize = db.Sequelize;
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
|
|
||||||
module.exports = class AnalyticsService {
|
|
||||||
static async recordView(data, req) {
|
|
||||||
return await db.page_views.create({
|
|
||||||
productId: data.productId || null,
|
|
||||||
categoryId: data.categoryId || null,
|
|
||||||
userId: req.user ? req.user.id : null,
|
|
||||||
ipAddress: req.ip,
|
|
||||||
userAgent: req.headers['user-agent']
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getTopProducts(limit = 5) {
|
|
||||||
// Use a simpler query to avoid complex group by issues with nested includes
|
|
||||||
const topViewedIds = await db.page_views.findAll({
|
|
||||||
attributes: [
|
|
||||||
'productId',
|
|
||||||
[Sequelize.fn('COUNT', Sequelize.col('productId')), 'viewCount']
|
|
||||||
],
|
|
||||||
where: {
|
|
||||||
productId: { [Op.ne]: null }
|
|
||||||
},
|
|
||||||
group: ['productId'],
|
|
||||||
order: [[Sequelize.literal('"viewCount"'), 'DESC']],
|
|
||||||
limit: Number(limit),
|
|
||||||
raw: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (topViewedIds.length === 0) return [];
|
|
||||||
|
|
||||||
const productIds = topViewedIds.map(v => v.productId);
|
|
||||||
const products = await db.products.findAll({
|
|
||||||
where: { id: { [Op.in]: productIds } },
|
|
||||||
include: [{ model: db.file, as: 'images' }]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Map counts back to products
|
|
||||||
return products.map(p => {
|
|
||||||
const viewData = topViewedIds.find(v => v.productId === p.id);
|
|
||||||
return {
|
|
||||||
...p.get({ plain: true }),
|
|
||||||
viewCount: viewData ? parseInt(viewData.viewCount) : 0
|
|
||||||
};
|
|
||||||
}).sort((a, b) => b.viewCount - a.viewCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getViewStats() {
|
|
||||||
const totalViews = await db.page_views.count();
|
|
||||||
const productViews = await db.page_views.count({ where: { productId: { [Op.ne]: null } } });
|
|
||||||
const categoryViews = await db.page_views.count({ where: { categoryId: { [Op.ne]: null } } });
|
|
||||||
|
|
||||||
// Last 7 days views
|
|
||||||
const sevenDaysAgo = new Date();
|
|
||||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
||||||
|
|
||||||
const dailyViews = await db.page_views.findAll({
|
|
||||||
attributes: [
|
|
||||||
[Sequelize.fn('DATE', Sequelize.col('createdAt')), 'date'],
|
|
||||||
[Sequelize.fn('COUNT', Sequelize.col('id')), 'count']
|
|
||||||
],
|
|
||||||
where: {
|
|
||||||
createdAt: { [Op.gte]: sevenDaysAgo }
|
|
||||||
},
|
|
||||||
group: [Sequelize.fn('DATE', Sequelize.col('createdAt'))],
|
|
||||||
order: [[Sequelize.fn('DATE', Sequelize.col('createdAt')), 'ASC']]
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalViews,
|
|
||||||
productViews,
|
|
||||||
categoryViews,
|
|
||||||
dailyViews
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -8,7 +8,6 @@ const PasswordResetEmail = require('./email/list/passwordReset');
|
|||||||
const EmailSender = require('./email');
|
const EmailSender = require('./email');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const helpers = require('../helpers');
|
const helpers = require('../helpers');
|
||||||
const db = require('../db/models');
|
|
||||||
|
|
||||||
class Auth {
|
class Auth {
|
||||||
static async signup(email, password, options = {}, host) {
|
static async signup(email, password, options = {}, host) {
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Abandoned Cart</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: sans-serif; line-height: 1.6; color: #333; }
|
|
||||||
.container { width: 80%; margin: 20px auto; padding: 20px; border: 1px solid #ddd; border-radius: 8px; }
|
|
||||||
.header { background-color: #f8f8f8; padding: 10px; text-align: center; border-bottom: 1px solid #ddd; }
|
|
||||||
.content { padding: 20px; }
|
|
||||||
.footer { font-size: 0.8em; color: #777; text-align: center; margin-top: 20px; }
|
|
||||||
.button {
|
|
||||||
background-color: #4F46E5;
|
|
||||||
color: white !important;
|
|
||||||
padding: 12px 24px;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
display: inline-block;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h2>{appTitle}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Hello {userName},</p>
|
|
||||||
<p>We noticed you left some items in your shopping cart. Don't miss out on these great products!</p>
|
|
||||||
<p>Click below to return to your cart and complete your purchase:</p>
|
|
||||||
<p style="text-align: center; margin-top: 30px;">
|
|
||||||
<a href="{cartUrl}" class="button">Return to Cart</a>
|
|
||||||
</p>
|
|
||||||
<p>If you have any questions, feel free to reply to this email.</p>
|
|
||||||
<p>Thanks,<br>The {appTitle} team</p>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
© 2026 {appTitle}. All rights reserved.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Low Stock Alert</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: sans-serif; line-height: 1.6; color: #333; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
||||||
.header { background: #f44336; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
|
|
||||||
.content { padding: 20px; border: 1px solid #eee; border-top: none; border-radius: 0 0 8px 8px; }
|
|
||||||
.product-card { background: #f9f9f9; padding: 15px; margin: 15px 0; border-radius: 8px; border-left: 5px solid #f44336; }
|
|
||||||
.btn { display: inline-block; padding: 12px 24px; background: #2196f3; color: white; text-decoration: none; border-radius: 4px; font-weight: bold; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>Low Stock Alert!</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Hello Admin,</p>
|
|
||||||
<p>The following product is running low on stock and needs your attention:</p>
|
|
||||||
|
|
||||||
<div class="product-card">
|
|
||||||
<h2 style="margin-top: 0;">{{productTitle}}</h2>
|
|
||||||
<p><strong>Current Stock:</strong> <span style="color: #f44336; font-size: 1.2em;">{{currentStock}}</span></p>
|
|
||||||
<p><strong>SKU:</strong> {{productSku}}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>Please restock this item as soon as possible to avoid missed sales.</p>
|
|
||||||
|
|
||||||
<div style="text-align: center; margin-top: 30px;">
|
|
||||||
<a href="{{productUrl}}" class="btn">Manage Product</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Great News! An item in your wishlist is on sale!</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; color: #333; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e1e1e1; border-radius: 8px; }
|
|
||||||
.header { text-align: center; margin-bottom: 30px; }
|
|
||||||
.content { margin-bottom: 30px; }
|
|
||||||
.product-box { background: #f9f9f9; padding: 15px; border-radius: 5px; text-align: center; }
|
|
||||||
.price { color: #e53e3e; font-size: 24px; font-weight: bold; }
|
|
||||||
.btn { display: inline-block; padding: 12px 24px; background-color: #3182ce; color: #fff !important; text-decoration: none; border-radius: 5px; font-weight: bold; margin-top: 20px; }
|
|
||||||
.footer { text-align: center; font-size: 12px; color: #777; margin-top: 30px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>Good News!</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Hi there,</p>
|
|
||||||
<p>An item you've been watching in your wishlist is now on sale at <strong>{appTitle}</strong>!</p>
|
|
||||||
<div class="product-box">
|
|
||||||
<h2>{productTitle}</h2>
|
|
||||||
<p>Grab it now for just:</p>
|
|
||||||
<p class="price">{salePrice}</p>
|
|
||||||
<a href="{productUrl}" class="btn">View Deal</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<p>© {appTitle}. All rights reserved.</p>
|
|
||||||
<p>You received this because you added this item to your wishlist.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
const { getNotification } = require('../../notifications/helpers');
|
|
||||||
const path = require("path");
|
|
||||||
const {promises: fs} = require("fs");
|
|
||||||
|
|
||||||
module.exports = class AbandonedCartEmail {
|
|
||||||
constructor(to, userName, cartUrl) {
|
|
||||||
this.to = to;
|
|
||||||
this.userName = userName;
|
|
||||||
this.cartUrl = cartUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
get subject() {
|
|
||||||
return getNotification(
|
|
||||||
'emails.abandonedCart.subject'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async html() {
|
|
||||||
try {
|
|
||||||
const templatePath = path.join(__dirname, '../../email/htmlTemplates/abandonedCart/abandonedCartEmail.html');
|
|
||||||
const template = await fs.readFile(templatePath, 'utf8');
|
|
||||||
|
|
||||||
const appTitle = getNotification('app.title');
|
|
||||||
|
|
||||||
let html = template.replace(/{appTitle}/g, appTitle)
|
|
||||||
.replace(/{userName}/g, this.userName)
|
|
||||||
.replace(/{cartUrl}/g, this.cartUrl);
|
|
||||||
|
|
||||||
return html;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating abandoned cart email HTML:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
const { getNotification } = require('../../notifications/helpers');
|
|
||||||
const path = require("path");
|
|
||||||
const {promises: fs} = require("fs");
|
|
||||||
|
|
||||||
module.exports = class WishlistSaleEmail {
|
|
||||||
constructor(to, productTitle, productUrl, salePrice) {
|
|
||||||
this.to = to;
|
|
||||||
this.productTitle = productTitle;
|
|
||||||
this.productUrl = productUrl;
|
|
||||||
this.salePrice = salePrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
get subject() {
|
|
||||||
return `Sale Alert: ${this.productTitle} is now on sale!`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async html() {
|
|
||||||
try {
|
|
||||||
const templatePath = path.join(__dirname, '../../email/htmlTemplates/wishlistSale/wishlistSaleEmail.html');
|
|
||||||
const template = await fs.readFile(templatePath, 'utf8');
|
|
||||||
|
|
||||||
const appTitle = getNotification('app.title') || 'Our Store';
|
|
||||||
|
|
||||||
let html = template.replace(/{appTitle}/g, appTitle)
|
|
||||||
.replace(/{productTitle}/g, this.productTitle)
|
|
||||||
.replace(/{productUrl}/g, this.productUrl)
|
|
||||||
.replace(/{salePrice}/g, this.salePrice);
|
|
||||||
|
|
||||||
return html;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating wishlist sale email HTML:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
const db = require('../../db/models');
|
|
||||||
const { Op } = require('sequelize');
|
|
||||||
const moment = require('moment');
|
|
||||||
const EmailSender = require('../email');
|
|
||||||
const AbandonedCartEmail = require('../email/list/abandonedCart');
|
|
||||||
|
|
||||||
class AbandonedCartService {
|
|
||||||
static async processAbandonedCarts() {
|
|
||||||
console.log('Starting Abandoned Cart Recovery process...');
|
|
||||||
|
|
||||||
// Find carts updated more than 24 hours ago but less than 7 days ago
|
|
||||||
const abandonmentThreshold = moment().subtract(24, 'hours').toDate();
|
|
||||||
const cutoffThreshold = moment().subtract(7, 'days').toDate();
|
|
||||||
|
|
||||||
const abandonedCarts = await db.carts.findAll({
|
|
||||||
where: {
|
|
||||||
updated_on: {
|
|
||||||
[Op.lt]: abandonmentThreshold,
|
|
||||||
[Op.gt]: cutoffThreshold
|
|
||||||
},
|
|
||||||
userId: {
|
|
||||||
[Op.ne]: null // Only for registered users
|
|
||||||
}
|
|
||||||
},
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: db.users,
|
|
||||||
as: 'user'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: db.cart_items,
|
|
||||||
as: 'cart_items_cart'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Found ${abandonedCarts.length} potentially abandoned carts.`);
|
|
||||||
|
|
||||||
let sentCount = 0;
|
|
||||||
|
|
||||||
for (const cart of abandonedCarts) {
|
|
||||||
if (!cart.user || !cart.user.email) continue;
|
|
||||||
if (!cart.cart_items_cart || cart.cart_items_cart.length === 0) continue;
|
|
||||||
|
|
||||||
// Check if user has placed an order AFTER the cart was last updated
|
|
||||||
const recentOrder = await db.orders.findOne({
|
|
||||||
where: {
|
|
||||||
userId: cart.userId,
|
|
||||||
placed_at: {
|
|
||||||
[Op.gt]: cart.updated_on
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (recentOrder) {
|
|
||||||
console.log(`User ${cart.user.email} already placed an order after cart update. Skipping.`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send email
|
|
||||||
try {
|
|
||||||
const cartUrl = `${process.env.FRONTEND_URL || 'http://localhost:3001'}/cart`;
|
|
||||||
const userName = cart.user.firstName || 'there';
|
|
||||||
|
|
||||||
const email = new AbandonedCartEmail(
|
|
||||||
cart.user.email,
|
|
||||||
userName,
|
|
||||||
cartUrl
|
|
||||||
);
|
|
||||||
|
|
||||||
if (EmailSender.isConfigured) {
|
|
||||||
await new EmailSender(email).send();
|
|
||||||
sentCount++;
|
|
||||||
console.log(`Abandoned cart email sent to ${cart.user.email}`);
|
|
||||||
} else {
|
|
||||||
console.log(`Email not configured. Skipping send to ${cart.user.email}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cart so we don't send it again (touch updated_on)
|
|
||||||
await cart.update({ updated_on: new Date() });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to send abandoned cart email to ${cart.user.email}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sent: sentCount };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = AbandonedCartService;
|
|
||||||
@ -98,27 +98,6 @@ const errors = {
|
|||||||
<p>Your {0} team</p>
|
<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>
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
abandonedCart: {
|
|
||||||
subject: `You left something in your cart!`,
|
|
||||||
body: `
|
|
||||||
<p>Hello {0},</p>
|
|
||||||
<p>We noticed you left some items in your shopping cart. Don't miss out on these great products!</p>
|
|
||||||
<p>Click below to return to your cart and complete your purchase:</p>
|
|
||||||
<p><a href='{1}' style='background-color: #4F46E5; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;'>Return to Cart</a></p>
|
|
||||||
<p>Thanks,</p>
|
|
||||||
<p>The {2} team</p>
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
|
|
||||||
const EmailSender = require('../email');
|
|
||||||
const db = require('../../db/models');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const config = require('../../config');
|
|
||||||
|
|
||||||
class LowStockNotificationService {
|
|
||||||
static async notify(productId) {
|
|
||||||
try {
|
|
||||||
const product = await db.products.findByPk(productId);
|
|
||||||
if (!product) return;
|
|
||||||
|
|
||||||
// Threshold is 5
|
|
||||||
if (product.stock > 5) return;
|
|
||||||
|
|
||||||
const templatePath = path.join(__dirname, '../email/htmlTemplates/lowStock/lowStockEmail.html');
|
|
||||||
let html = fs.readFileSync(templatePath, 'utf8');
|
|
||||||
|
|
||||||
html = html.replace('{{productTitle}}', product.title);
|
|
||||||
html = html.replace('{{currentStock}}', product.stock);
|
|
||||||
html = html.replace('{{productSku}}', product.sku || 'N/A');
|
|
||||||
html = html.replace('{{productUrl}}', `${config.uiUrl}/products/products-edit?id=${product.id}`);
|
|
||||||
|
|
||||||
// Find all admins
|
|
||||||
const adminRole = await db.roles.findOne({ where: { name: 'Administrator' } });
|
|
||||||
if (!adminRole) return;
|
|
||||||
|
|
||||||
const admins = await db.users.findAll({ where: { app_roleId: adminRole.id } });
|
|
||||||
|
|
||||||
for (const admin of admins) {
|
|
||||||
if (admin.email) {
|
|
||||||
const emailOptions = {
|
|
||||||
to: admin.email,
|
|
||||||
subject: `⚠️ Low Stock Alert: ${product.title}`,
|
|
||||||
html: html
|
|
||||||
};
|
|
||||||
|
|
||||||
if (EmailSender.isConfigured) {
|
|
||||||
await new EmailSender(emailOptions).send();
|
|
||||||
} else {
|
|
||||||
console.log('Email not configured, skipping low stock notification to', admin.email);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in LowStockNotificationService:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = LowStockNotificationService;
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
const db = require('../../db/models');
|
|
||||||
const EmailSender = require('../email');
|
|
||||||
const WishlistSaleEmail = require('../email/list/wishlistSale');
|
|
||||||
|
|
||||||
class WishlistNotificationService {
|
|
||||||
static async notifySale(productId) {
|
|
||||||
try {
|
|
||||||
const product = await db.products.findByPk(productId);
|
|
||||||
if (!product || !product.sale_price) return;
|
|
||||||
|
|
||||||
const wishlists = await db.wishlists.findAll({
|
|
||||||
where: { productId },
|
|
||||||
include: [{ model: db.users, as: 'user' }]
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Found ${wishlists.length} wishlist entries for product ${productId}`);
|
|
||||||
|
|
||||||
for (const wishlist of wishlists) {
|
|
||||||
if (!wishlist.user || !wishlist.user.email) continue;
|
|
||||||
|
|
||||||
const productUrl = `${process.env.FRONTEND_URL || 'http://localhost:3001'}/products/${product.id}`;
|
|
||||||
|
|
||||||
// Ensure sale_price is treated as a number
|
|
||||||
const salePriceNum = Number(product.sale_price);
|
|
||||||
const salePriceFormatted = isNaN(salePriceNum) ? `$${product.sale_price}` : `$${salePriceNum.toFixed(2)}`;
|
|
||||||
|
|
||||||
const email = new WishlistSaleEmail(
|
|
||||||
wishlist.user.email,
|
|
||||||
product.title,
|
|
||||||
productUrl,
|
|
||||||
salePriceFormatted
|
|
||||||
);
|
|
||||||
|
|
||||||
if (EmailSender.isConfigured) {
|
|
||||||
try {
|
|
||||||
await new EmailSender(email).send();
|
|
||||||
console.log(`Wishlist sale notification sent to ${wishlist.user.email}`);
|
|
||||||
} catch (sendErr) {
|
|
||||||
console.error(`Failed to send wishlist email to ${wishlist.user.email}:`, sendErr);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(`Email not configured. Skipping send to ${wishlist.user.email}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in WishlistNotificationService.notifySale:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = WishlistNotificationService;
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
const WishlistNotificationService = require('./notifications/wishlistSale');
|
|
||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const ProductsDBApi = require('../db/api/products');
|
const ProductsDBApi = require('../db/api/products');
|
||||||
const processFile = require("../middlewares/upload");
|
const processFile = require("../middlewares/upload");
|
||||||
@ -16,13 +15,6 @@ module.exports = class ProductsService {
|
|||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
// If user is a seller, set sellerId to currentUser.id
|
|
||||||
// We check if app_role exists and has name 'Seller' or if shopName is set
|
|
||||||
const user = await db.users.findByPk(currentUser.id, { include: [{ model: db.roles, as: 'app_role' }] });
|
|
||||||
if (user && (user.app_role?.name === 'Seller' || user.sellerStatus === 'approved')) {
|
|
||||||
data.sellerId = user.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ProductsDBApi.create(
|
await ProductsDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
@ -87,11 +79,6 @@ module.exports = class ProductsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security check: only seller of this product or admin can update
|
|
||||||
if (currentUser.app_role?.name !== 'Administrator' && products.sellerId && products.sellerId !== currentUser.id) {
|
|
||||||
throw new Error('Forbidden: You can only update your own products');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedProducts = await ProductsDBApi.update(
|
const updatedProducts = await ProductsDBApi.update(
|
||||||
id,
|
id,
|
||||||
data,
|
data,
|
||||||
@ -101,10 +88,6 @@ module.exports = class ProductsService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data.sale_price) {
|
|
||||||
WishlistNotificationService.notifySale(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
return updatedProducts;
|
return updatedProducts;
|
||||||
|
|
||||||
@ -112,23 +95,44 @@ module.exports = class ProductsService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
static async deleteByIds(ids, currentUser) {
|
static async deleteByIds(ids, currentUser) {
|
||||||
// Security check: only seller or admin
|
const transaction = await db.sequelize.transaction();
|
||||||
const products = await db.products.findAll({ where: { id: ids } });
|
|
||||||
if (currentUser.app_role?.name !== 'Administrator') {
|
try {
|
||||||
const unauthorized = products.some(p => p.sellerId && p.sellerId !== currentUser.id);
|
await ProductsDBApi.deleteByIds(ids, {
|
||||||
if (unauthorized) throw new Error('Forbidden');
|
currentUser,
|
||||||
}
|
transaction,
|
||||||
return await ProductsDBApi.deleteByIds(ids, { currentUser });
|
});
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async remove(id, currentUser) {
|
static async remove(id, currentUser) {
|
||||||
const product = await db.products.findByPk(id);
|
const transaction = await db.sequelize.transaction();
|
||||||
if (currentUser.app_role?.name !== 'Administrator' && product.sellerId && product.sellerId !== currentUser.id) {
|
|
||||||
throw new Error('Forbidden');
|
try {
|
||||||
}
|
await ProductsDBApi.remove(
|
||||||
return await ProductsDBApi.remove(id, { currentUser });
|
id,
|
||||||
|
{
|
||||||
|
currentUser,
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -9,16 +9,12 @@ const Op = Sequelize.Op;
|
|||||||
* @param {object} currentUser
|
* @param {object} currentUser
|
||||||
*/
|
*/
|
||||||
async function checkPermissions(permission, currentUser) {
|
async function checkPermissions(permission, currentUser) {
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
// For public storefront search, we might allow certain tables
|
throw new ValidationError('auth.unauthorized');
|
||||||
const publicAllowed = ['READ_PRODUCTS', 'READ_CATEGORIES'];
|
|
||||||
if (publicAllowed.includes(permission)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userPermission = currentUser.custom_permissions?.find(
|
const userPermission = currentUser.custom_permissions.find(
|
||||||
(cp) => cp.name === permission,
|
(cp) => cp.name === permission,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -28,83 +24,212 @@ async function checkPermissions(permission, currentUser) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!currentUser.app_role) {
|
if (!currentUser.app_role) {
|
||||||
return false;
|
throw new ValidationError('auth.forbidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissions = await currentUser.app_role.getPermissions();
|
const permissions = await currentUser.app_role.getPermissions();
|
||||||
|
|
||||||
return !!permissions.find((p) => p.name === permission);
|
return !!permissions.find((p) => p.name === permission);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = class SearchService {
|
module.exports = class SearchService {
|
||||||
static async autocomplete(searchQuery, currentUser) {
|
|
||||||
if (!searchQuery || searchQuery.length < 2) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
// 1. Search Products
|
|
||||||
const hasProductPermission = await checkPermissions('READ_PRODUCTS', currentUser);
|
|
||||||
if (hasProductPermission) {
|
|
||||||
const products = await db.products.findAll({
|
|
||||||
where: {
|
|
||||||
[Op.or]: [
|
|
||||||
{ title: { [Op.iLike]: `%${searchQuery}%` } },
|
|
||||||
{ sku: { [Op.iLike]: `%${searchQuery}%` } }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
limit: 5,
|
|
||||||
attributes: ['id', 'title', 'price', 'slug']
|
|
||||||
});
|
|
||||||
results.push(...products.map(p => ({ ...p.toJSON(), type: 'product' })));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Search Categories
|
|
||||||
const hasCategoryPermission = await checkPermissions('READ_CATEGORIES', currentUser);
|
|
||||||
if (hasCategoryPermission) {
|
|
||||||
const categories = await db.categories.findAll({
|
|
||||||
where: {
|
|
||||||
name: { [Op.iLike]: `%${searchQuery}%` }
|
|
||||||
},
|
|
||||||
limit: 3,
|
|
||||||
attributes: ['id', 'name', 'slug']
|
|
||||||
});
|
|
||||||
results.push(...categories.map(c => ({ ...c.toJSON(), type: 'category' })));
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Autocomplete Error:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async search(searchQuery, currentUser ) {
|
static async search(searchQuery, currentUser ) {
|
||||||
try {
|
try {
|
||||||
if (!searchQuery) {
|
if (!searchQuery) {
|
||||||
throw new ValidationError('iam.errors.searchQueryRequired');
|
throw new ValidationError('iam.errors.searchQueryRequired');
|
||||||
}
|
}
|
||||||
const tableColumns = {
|
const tableColumns = {
|
||||||
"users": ["firstName", "lastName", "phoneNumber", "email"],
|
|
||||||
"products": ["title", "slug", "description", "sku"],
|
|
||||||
"categories": ["name", "slug", "description"],
|
|
||||||
"orders": ["order_number", "shipping_address", "billing_address"],
|
|
||||||
"order_items": ["name"],
|
|
||||||
"carts": ["session_id"],
|
"users": [
|
||||||
"cart_items": ["name"],
|
|
||||||
"payments": ["stripe_payment_id", "currency"],
|
"firstName",
|
||||||
|
|
||||||
|
"lastName",
|
||||||
|
|
||||||
|
"phoneNumber",
|
||||||
|
|
||||||
|
"email",
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"products": [
|
||||||
|
|
||||||
|
"title",
|
||||||
|
|
||||||
|
"slug",
|
||||||
|
|
||||||
|
"description",
|
||||||
|
|
||||||
|
"sku",
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"categories": [
|
||||||
|
|
||||||
|
"name",
|
||||||
|
|
||||||
|
"slug",
|
||||||
|
|
||||||
|
"description",
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"orders": [
|
||||||
|
|
||||||
|
"order_number",
|
||||||
|
|
||||||
|
"shipping_address",
|
||||||
|
|
||||||
|
"billing_address",
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"order_items": [
|
||||||
|
|
||||||
|
"name",
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"carts": [
|
||||||
|
|
||||||
|
"session_id",
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"cart_items": [
|
||||||
|
|
||||||
|
"name",
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"payments": [
|
||||||
|
|
||||||
|
"stripe_payment_id",
|
||||||
|
|
||||||
|
"currency",
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
const columnsInt = {
|
const columnsInt = {
|
||||||
"products": ["price", "stock"],
|
|
||||||
"orders": ["total"],
|
|
||||||
"order_items": ["quantity", "unit_price", "total_price"],
|
|
||||||
"cart_items": ["quantity", "unit_price"],
|
|
||||||
"payments": ["amount"],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"products": [
|
||||||
|
|
||||||
|
"price",
|
||||||
|
|
||||||
|
"stock",
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"orders": [
|
||||||
|
|
||||||
|
"total",
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"order_items": [
|
||||||
|
|
||||||
|
"quantity",
|
||||||
|
|
||||||
|
"unit_price",
|
||||||
|
|
||||||
|
"total_price",
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"cart_items": [
|
||||||
|
|
||||||
|
"quantity",
|
||||||
|
|
||||||
|
"unit_price",
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"payments": [
|
||||||
|
|
||||||
|
"amount",
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let allFoundRecords = [];
|
let allFoundRecords = [];
|
||||||
@ -129,6 +254,8 @@ module.exports = class SearchService {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const hasPermission = await checkPermissions(`READ_${tableName.toUpperCase()}`, currentUser);
|
const hasPermission = await checkPermissions(`READ_${tableName.toUpperCase()}`, currentUser);
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -1,48 +0,0 @@
|
|||||||
|
|
||||||
const db = require('../db/models');
|
|
||||||
|
|
||||||
class SellerService {
|
|
||||||
static async apply(userId, { shopName, shopDescription }) {
|
|
||||||
const user = await db.users.findByPk(userId);
|
|
||||||
if (!user) throw new Error('User not found');
|
|
||||||
|
|
||||||
await user.update({
|
|
||||||
shopName,
|
|
||||||
shopDescription,
|
|
||||||
sellerStatus: 'pending'
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, message: 'Application submitted successfully' };
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getStatus(userId) {
|
|
||||||
const user = await db.users.findByPk(userId);
|
|
||||||
return { status: user.sellerStatus, shopName: user.shopName };
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getPendingApplications() {
|
|
||||||
return await db.users.findAll({
|
|
||||||
where: { sellerStatus: 'pending' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static async reviewApplication(userId, status) {
|
|
||||||
const user = await db.users.findByPk(userId);
|
|
||||||
if (!user) throw new Error('User not found');
|
|
||||||
|
|
||||||
const updateData = { sellerStatus: status };
|
|
||||||
|
|
||||||
if (status === 'approved') {
|
|
||||||
// Find Seller role
|
|
||||||
const sellerRole = await db.roles.findOne({ where: { name: 'Seller' } });
|
|
||||||
if (sellerRole) {
|
|
||||||
updateData.app_roleId = sellerRole.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await user.update(updateData);
|
|
||||||
return { success: true, status };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = SellerService;
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
3126
backend/yarn.lock
3126
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -26,6 +26,7 @@ trailingSlash: true,
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
8464
frontend/package-lock.json
generated
8464
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,7 +14,6 @@
|
|||||||
"@mui/material": "^6.3.0",
|
"@mui/material": "^6.3.0",
|
||||||
"@mui/x-data-grid": "^6.19.2",
|
"@mui/x-data-grid": "^6.19.2",
|
||||||
"@reduxjs/toolkit": "^2.1.0",
|
"@reduxjs/toolkit": "^2.1.0",
|
||||||
"@stripe/stripe-js": "^8.7.0",
|
|
||||||
"@tailwindcss/typography": "^0.5.13",
|
"@tailwindcss/typography": "^0.5.13",
|
||||||
"@tinymce/tinymce-react": "^4.3.2",
|
"@tinymce/tinymce-react": "^4.3.2",
|
||||||
"apexcharts": "^3.45.2",
|
"apexcharts": "^3.45.2",
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
interface CountdownTimerProps {
|
|
||||||
targetDate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CountdownTimer: React.FC<CountdownTimerProps> = ({ targetDate }) => {
|
|
||||||
const calculateTimeLeft = () => {
|
|
||||||
const difference = +new Date(targetDate) - +new Date();
|
|
||||||
let timeLeft = {};
|
|
||||||
|
|
||||||
if (difference > 0) {
|
|
||||||
timeLeft = {
|
|
||||||
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
|
|
||||||
hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
|
|
||||||
minutes: Math.floor((difference / 1000 / 60) % 60),
|
|
||||||
seconds: Math.floor((difference / 1000) % 60),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return timeLeft;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [timeLeft, setTimeLeft] = useState<any>(calculateTimeLeft());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setTimeLeft(calculateTimeLeft());
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
});
|
|
||||||
|
|
||||||
const timerComponents: any[] = [];
|
|
||||||
|
|
||||||
Object.keys(timeLeft).forEach((interval) => {
|
|
||||||
if (!timeLeft[interval] && interval !== 'seconds' && interval !== 'minutes') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
timerComponents.push(
|
|
||||||
<span key={interval} className="mx-1">
|
|
||||||
{timeLeft[interval]}
|
|
||||||
{interval.charAt(0)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center text-xs font-bold bg-red-600 text-white px-2 py-1 rounded-full shadow-md animate-pulse">
|
|
||||||
<span className="mr-1">Ends in:</span>
|
|
||||||
{timerComponents.length ? timerComponents : <span>Expired!</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CountdownTimer;
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import React, {useEffect, useRef, useState} from 'react'
|
import React, {useEffect, useRef} from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useState } from 'react'
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,72 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import axios from 'axios';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import ImageField from '../ImageField';
|
|
||||||
import LoadingSpinner from '../LoadingSpinner';
|
|
||||||
import { useAppSelector } from '../../stores/hooks';
|
|
||||||
|
|
||||||
const ProductRecommendations = () => {
|
|
||||||
const [recommendations, setRecommendations] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
axios.get('/recommendations?limit=4')
|
|
||||||
.then(res => {
|
|
||||||
setRecommendations(Array.isArray(res.data) ? res.data : []);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Failed to fetch recommendations:', err);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [currentUser]);
|
|
||||||
|
|
||||||
const formatPrice = (price: any) => {
|
|
||||||
const num = Number(price);
|
|
||||||
return isNaN(num) ? price : num.toFixed(2);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <LoadingSpinner />;
|
|
||||||
if (!recommendations || recommendations.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-10">
|
|
||||||
<h2 className="text-2xl font-bold mb-6">Recommended for You</h2>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{recommendations.map((item: any) => (
|
|
||||||
<div key={item.id} className={`overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm hover:shadow-md transition-shadow`}>
|
|
||||||
<Link href={`/products/${item.id}`}>
|
|
||||||
<div className="relative h-48 w-full">
|
|
||||||
<ImageField
|
|
||||||
name="Avatar"
|
|
||||||
image={item.images}
|
|
||||||
className="w-full h-full"
|
|
||||||
imageClassName="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<h3 className="font-semibold text-lg truncate mb-1">{item.title}</h3>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-blue-600 font-bold">
|
|
||||||
${formatPrice(item.sale_price || item.price)}
|
|
||||||
</span>
|
|
||||||
{item.sale_price && (
|
|
||||||
<span className="text-xs line-through text-gray-400">
|
|
||||||
${formatPrice(item.price)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProductRecommendations;
|
|
||||||
@ -1,20 +1,13 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React from 'react';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import { useAppSelector } from '../stores/hooks';
|
||||||
import axios from 'axios';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const Search = () => {
|
const Search = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
||||||
const corners = useAppSelector((state) => state.style.corners);
|
const corners = useAppSelector((state) => state.style.corners);
|
||||||
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
||||||
|
|
||||||
const [autocompleteResults, setAutocompleteResults] = useState([]);
|
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
|
||||||
const dropdownRef = useRef(null);
|
|
||||||
|
|
||||||
const validateSearch = (value) => {
|
const validateSearch = (value) => {
|
||||||
let error;
|
let error;
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@ -24,108 +17,34 @@ const Search = () => {
|
|||||||
}
|
}
|
||||||
return error;
|
return error;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAutocomplete = async (query) => {
|
|
||||||
if (query.length < 2) {
|
|
||||||
setAutocompleteResults([]);
|
|
||||||
setShowDropdown(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`/search/autocomplete?query=${query}`);
|
|
||||||
setAutocompleteResults(response.data);
|
|
||||||
setShowDropdown(response.data.length > 0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Autocomplete error:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
|
||||||
setShowDropdown(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<Formik
|
||||||
<Formik
|
initialValues={{
|
||||||
initialValues={{
|
search: '',
|
||||||
search: '',
|
}}
|
||||||
}}
|
onSubmit={(values, { setSubmitting, resetForm }) => {
|
||||||
onSubmit={(values, { setSubmitting, resetForm }) => {
|
router.push(`/search?query=${values.search}`);
|
||||||
router.push(`/search?query=${values.search}`);
|
resetForm();
|
||||||
resetForm();
|
setSubmitting(false);
|
||||||
setSubmitting(false);
|
}}
|
||||||
setShowDropdown(false);
|
validateOnBlur={false}
|
||||||
}}
|
validateOnChange={false}
|
||||||
validateOnBlur={false}
|
>
|
||||||
validateOnChange={false}
|
{({ errors, touched, values }) => (
|
||||||
>
|
<Form style={{width: '300px'}} >
|
||||||
{({ errors, touched, values, setFieldValue, submitForm }) => (
|
<Field
|
||||||
<Form style={{width: '300px'}} autoComplete="off">
|
id='search'
|
||||||
<Field
|
name='search'
|
||||||
id='search'
|
validate={validateSearch}
|
||||||
name='search'
|
placeholder='Search'
|
||||||
validate={validateSearch}
|
className={` ${corners} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-2 relative ml-2 w-full dark:placeholder-dark-600 ${focusRing} shadow-none`}
|
||||||
placeholder='Search'
|
/>
|
||||||
className={` ${corners} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-2 relative ml-2 w-full dark:placeholder-dark-600 ${focusRing} shadow-none`}
|
{errors.search && touched.search && values.search.length < 2 ? (
|
||||||
onChange={(e) => {
|
<div className='text-red-500 text-sm ml-2 absolute'>{errors.search}</div>
|
||||||
const value = e.target.value;
|
) : null}
|
||||||
setFieldValue('search', value);
|
</Form>
|
||||||
handleAutocomplete(value);
|
)}
|
||||||
}}
|
</Formik>
|
||||||
onFocus={() => {
|
|
||||||
if (autocompleteResults.length > 0) setShowDropdown(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{errors.search && touched.search && values.search.length < 2 ? (
|
|
||||||
<div className='text-red-500 text-sm ml-2 absolute'>{errors.search}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{showDropdown && (
|
|
||||||
<div className={`absolute z-50 w-full ml-2 mt-1 bg-white dark:bg-dark-800 border dark:border-dark-700 shadow-lg ${corners} overflow-hidden`}>
|
|
||||||
{autocompleteResults.map((result) => (
|
|
||||||
<div
|
|
||||||
key={`${result.type}-${result.id}`}
|
|
||||||
className="p-3 hover:bg-gray-100 dark:hover:bg-dark-700 cursor-pointer border-b dark:border-dark-700 last:border-0"
|
|
||||||
onClick={() => {
|
|
||||||
if (result.type === 'product') {
|
|
||||||
router.push(`/products/${result.id}`);
|
|
||||||
} else {
|
|
||||||
router.push(`/categories/${result.id}`);
|
|
||||||
}
|
|
||||||
setShowDropdown(false);
|
|
||||||
setFieldValue('search', '');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<span className="font-semibold text-sm block">{result.title || result.name}</span>
|
|
||||||
<span className="text-xs text-gray-500 capitalize">{result.type}</span>
|
|
||||||
</div>
|
|
||||||
{result.price && (
|
|
||||||
<span className="text-green-600 font-bold text-sm">${result.price}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div
|
|
||||||
className="p-2 text-center text-blue-600 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-dark-700 cursor-pointer"
|
|
||||||
onClick={() => submitForm()}
|
|
||||||
>
|
|
||||||
View all results for "{values.search}"
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default Search;
|
export default Search;
|
||||||
@ -1,119 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { mdiMagnify, mdiClose, mdiTagOutline, mdiPackageVariantClosed } from '@mdi/js';
|
|
||||||
import BaseIcon from '../BaseIcon';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
|
|
||||||
const SmartSearch = () => {
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [results, setResults] = useState([]);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const searchRef = useRef<HTMLDivElement>(null);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (query.length < 2) {
|
|
||||||
setResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delayDebounceFn = setTimeout(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await axios.get(`/search/autocomplete?query=${query}`);
|
|
||||||
setResults(res.data);
|
|
||||||
setIsOpen(true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Search error:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => clearTimeout(delayDebounceFn);
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (query.trim()) {
|
|
||||||
router.push(`/search?q=${encodeURIComponent(query)}`);
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full max-w-md" ref={searchRef}>
|
|
||||||
<form onSubmit={handleSearch} className="relative">
|
|
||||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
|
||||||
<BaseIcon path={mdiMagnify} size={20} className="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onFocus={() => query.length >= 2 && setIsOpen(true)}
|
|
||||||
placeholder="Search products, categories..."
|
|
||||||
className="w-full bg-gray-50 border-none rounded-2xl pl-12 pr-10 py-3 text-sm focus:ring-2 focus:ring-blue-500/20 transition-all font-medium text-gray-700"
|
|
||||||
/>
|
|
||||||
{query && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setQuery('')}
|
|
||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiClose} size={18} className="text-gray-400 hover:text-gray-600" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{isOpen && (results.length > 0 || loading) && (
|
|
||||||
<div className="absolute mt-2 w-full bg-white rounded-2xl shadow-2xl border border-gray-100 z-[100] overflow-hidden">
|
|
||||||
{loading ? (
|
|
||||||
<div className="p-4 text-center text-gray-400 text-sm animate-pulse">Searching...</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-h-[400px] overflow-y-auto aside-scrollbars">
|
|
||||||
{results.map((item: any) => (
|
|
||||||
<Link
|
|
||||||
key={`${item.type}-${item.id}`}
|
|
||||||
href={item.type === 'product' ? `/products/${item.id}` : `/products?category=${item.id}`}
|
|
||||||
className="flex items-center px-4 py-3 hover:bg-gray-50 transition-colors border-b border-gray-50 last:border-0"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
>
|
|
||||||
<div className={`p-2 rounded-xl mr-3 ${item.type === 'product' ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'}`}>
|
|
||||||
<BaseIcon path={item.type === 'product' ? mdiPackageVariantClosed : mdiTagOutline} size={20} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-grow">
|
|
||||||
<p className="text-sm font-bold text-gray-900">{item.title || item.name}</p>
|
|
||||||
<p className="text-[10px] uppercase tracking-widest font-black text-gray-400">
|
|
||||||
{item.type} {item.price ? `• $${Number(item.price).toFixed(2)}` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={handleSearch}
|
|
||||||
className="w-full py-3 text-center text-xs font-bold text-blue-600 hover:bg-blue-50 transition-colors uppercase tracking-widest"
|
|
||||||
>
|
|
||||||
View all results for "{query}"
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SmartSearch;
|
|
||||||
@ -13,4 +13,3 @@ export const appTitle = 'created by Flatlogic generator!'
|
|||||||
export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle} — ${appTitle}`
|
export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle} — ${appTitle}`
|
||||||
|
|
||||||
export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || ''
|
export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || ''
|
||||||
export const stripePublishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_51P...';
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React, { ReactNode, useEffect, useState } from 'react'
|
import React, { ReactNode, useEffect } from 'react'
|
||||||
|
import { useState } from 'react'
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -2,44 +2,11 @@ import * as icon from '@mdi/js';
|
|||||||
import { MenuAsideItem } from './interfaces'
|
import { MenuAsideItem } from './interfaces'
|
||||||
|
|
||||||
const menuAside: MenuAsideItem[] = [
|
const menuAside: MenuAsideItem[] = [
|
||||||
{
|
|
||||||
href: '/',
|
|
||||||
icon: icon.mdiStorefrontOutline,
|
|
||||||
label: 'Store',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: '/seller/portal',
|
|
||||||
icon: icon.mdiStore,
|
|
||||||
label: 'Seller Portal',
|
|
||||||
permissions: 'CREATE_PRODUCTS' // Sellers have this
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/seller/apply',
|
|
||||||
icon: icon.mdiStorePlus,
|
|
||||||
label: 'Become a Seller',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/admin/seller-applications',
|
|
||||||
icon: icon.mdiAccountCheck,
|
|
||||||
label: 'Seller Apps',
|
|
||||||
permissions: 'READ_USERS' // Admin only
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/admin/analytics',
|
|
||||||
icon: icon.mdiChartBar,
|
|
||||||
label: 'Analytics',
|
|
||||||
permissions: 'READ_USERS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/my-orders',
|
|
||||||
icon: icon.mdiPackageVariantClosed,
|
|
||||||
label: 'My Orders',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
@ -83,11 +50,11 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/orders/orders-list',
|
href: '/orders/orders-list',
|
||||||
label: 'Admin Orders',
|
label: 'Orders',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: 'mdiCart' in icon ? icon['mdiCart' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
icon: 'mdiCart' in icon ? icon['mdiCart' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
permissions: 'READ_PERMISSIONS' // Only Admins should see the full list
|
permissions: 'READ_ORDERS'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/order_items/order_items-list',
|
href: '/order_items/order_items-list',
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import {
|
|||||||
mdiThemeLightDark,
|
mdiThemeLightDark,
|
||||||
mdiGithub,
|
mdiGithub,
|
||||||
mdiVuejs,
|
mdiVuejs,
|
||||||
mdiPackageVariantClosed,
|
|
||||||
} from '@mdi/js'
|
} from '@mdi/js'
|
||||||
import { MenuNavBarItem } from './interfaces'
|
import { MenuNavBarItem } from './interfaces'
|
||||||
|
|
||||||
@ -23,11 +22,6 @@ const menuNavBar: MenuNavBarItem[] = [
|
|||||||
label: 'My Profile',
|
label: 'My Profile',
|
||||||
href: '/profile',
|
href: '/profile',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: mdiPackageVariantClosed,
|
|
||||||
label: 'My Orders',
|
|
||||||
href: '/my-orders',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
isDivider: true,
|
isDivider: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -16,8 +16,6 @@ import { appWithTranslation } from 'next-i18next';
|
|||||||
import '../i18n';
|
import '../i18n';
|
||||||
import IntroGuide from '../components/IntroGuide';
|
import IntroGuide from '../components/IntroGuide';
|
||||||
import { appSteps, loginSteps, usersSteps, rolesSteps } from '../stores/introSteps';
|
import { appSteps, loginSteps, usersSteps, rolesSteps } from '../stores/introSteps';
|
||||||
import { ToastContainer } from 'react-toastify';
|
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
|
||||||
|
|
||||||
// Initialize axios
|
// Initialize axios
|
||||||
axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API
|
axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API
|
||||||
@ -193,7 +191,6 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
|||||||
stepsEnabled={stepsEnabled}
|
stepsEnabled={stepsEnabled}
|
||||||
onExit={handleExit}
|
onExit={handleExit}
|
||||||
/>
|
/>
|
||||||
<ToastContainer />
|
|
||||||
{(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') && <DevModeBadge />}
|
{(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') && <DevModeBadge />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,150 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import axios from 'axios';
|
|
||||||
import {
|
|
||||||
mdiChartBar,
|
|
||||||
mdiEye,
|
|
||||||
mdiPackageVariant,
|
|
||||||
mdiTag,
|
|
||||||
mdiTrendingUp,
|
|
||||||
mdiStar
|
|
||||||
} from '@mdi/js';
|
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
|
||||||
import SectionMain from '../../components/SectionMain';
|
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
|
||||||
import CardBox from '../../components/CardBox';
|
|
||||||
import BaseIcon from '../../components/BaseIcon';
|
|
||||||
import { getPageTitle } from '../../config';
|
|
||||||
import LoadingSpinner from '../../components/LoadingSpinner';
|
|
||||||
|
|
||||||
const AnalyticsPage = () => {
|
|
||||||
const [stats, setStats] = useState<any>(null);
|
|
||||||
const [topProducts, setTopProducts] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const [statsRes, topRes] = await Promise.all([
|
|
||||||
axios.get('/analytics/stats'),
|
|
||||||
axios.get('/analytics/top-products?limit=5')
|
|
||||||
]);
|
|
||||||
setStats(statsRes.data);
|
|
||||||
setTopProducts(topRes.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching analytics:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) return <LoadingSpinner />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{getPageTitle('Advanced Analytics')}</title>
|
|
||||||
</Head>
|
|
||||||
<SectionMain>
|
|
||||||
<SectionTitleLineWithButton icon={mdiChartBar} title="Advanced Analytics" main>
|
|
||||||
{''}
|
|
||||||
</SectionTitleLineWithButton>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6">
|
|
||||||
<CardBox className="bg-blue-600 text-white">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="p-3 bg-blue-500 rounded-full mr-4">
|
|
||||||
<BaseIcon path={mdiEye} size={24} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-bold uppercase tracking-wider opacity-80">Total Page Views</p>
|
|
||||||
<h3 className="text-3xl font-black">{stats?.totalViews || 0}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
<CardBox className="bg-green-600 text-white">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="p-3 bg-green-500 rounded-full mr-4">
|
|
||||||
<BaseIcon path={mdiPackageVariant} size={24} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-bold uppercase tracking-wider opacity-80">Product Views</p>
|
|
||||||
<h3 className="text-3xl font-black">{stats?.productViews || 0}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
<CardBox className="bg-purple-600 text-white">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="p-3 bg-purple-500 rounded-full mr-4">
|
|
||||||
<BaseIcon path={mdiTag} size={24} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-bold uppercase tracking-wider opacity-80">Category Views</p>
|
|
||||||
<h3 className="text-3xl font-black">{stats?.categoryViews || 0}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<CardBox title="Top Viewed Products" icon={mdiTrendingUp}>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{topProducts.map((item, index) => (
|
|
||||||
<div key={item.productId} className="flex items-center justify-between p-4 bg-gray-50 rounded-2xl border border-gray-100 hover:border-blue-200 transition-colors">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-2xl font-black text-gray-300 mr-4">#{index + 1}</span>
|
|
||||||
<img
|
|
||||||
src={item.product?.images?.[0]?.url || "https://images.pexels.com/photos/1350789/pexels-photo-1350789.jpeg?auto=compress&cs=tinysrgb&w=50"}
|
|
||||||
alt={item.product?.title}
|
|
||||||
className="w-12 h-12 object-cover rounded-lg mr-4"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-bold text-gray-900 line-clamp-1">{item.product?.title}</h4>
|
|
||||||
<p className="text-xs text-gray-500">${item.product?.price}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<span className="block text-xl font-bold text-blue-600">{item.viewCount}</span>
|
|
||||||
<span className="text-[10px] uppercase font-bold text-gray-400 tracking-tighter">Views</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{topProducts.length === 0 && <p className="text-center text-gray-500 py-8">No view data available yet.</p>}
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
<CardBox title="Views Trend (Last 7 Days)" icon={mdiChartBar}>
|
|
||||||
<div className="flex flex-col h-full justify-center">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{stats?.dailyViews?.map((day: any) => (
|
|
||||||
<div key={day.date} className="flex items-center">
|
|
||||||
<span className="w-24 text-sm font-bold text-gray-500">{new Date(day.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}</span>
|
|
||||||
<div className="flex-grow bg-gray-100 rounded-full h-4 overflow-hidden mx-4">
|
|
||||||
<div
|
|
||||||
className="bg-blue-600 h-full rounded-full transition-all duration-1000"
|
|
||||||
style={{ width: `${Math.min(100, (day.count / (Math.max(...stats.dailyViews.map((d:any) => d.count)) || 1)) * 100)}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span className="w-12 text-sm font-black text-gray-900 text-right">{day.count}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(!stats?.dailyViews || stats.dailyViews.length === 0) && <p className="text-center text-gray-500 py-8">No trend data available yet.</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
AnalyticsPage.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AnalyticsPage;
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { mdiAccountCheck, mdiCheck, mdiClose, mdiStore } from '@mdi/js';
|
|
||||||
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 BaseButton from '../../components/BaseButton';
|
|
||||||
import BaseIcon from '../../components/BaseIcon';
|
|
||||||
|
|
||||||
const AdminSellerApplications = () => {
|
|
||||||
const [applications, setApplications] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchApplications();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchApplications = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get('/seller/admin/pending');
|
|
||||||
setApplications(response.data || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching applications:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReview = async (userId: string, status: 'approved' | 'rejected') => {
|
|
||||||
try {
|
|
||||||
await axios.post(`/seller/admin/review/${userId}`, { status });
|
|
||||||
setApplications(applications.filter((a: any) => a.id !== userId));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reviewing application:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{getPageTitle('Seller Applications')}</title>
|
|
||||||
</Head>
|
|
||||||
<SectionMain>
|
|
||||||
<SectionTitleLineWithButton icon={mdiAccountCheck} title="Seller Applications" main>
|
|
||||||
{''}
|
|
||||||
</SectionTitleLineWithButton>
|
|
||||||
|
|
||||||
<CardBox className="border-gray-100">
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-center py-12">Loading applications...</p>
|
|
||||||
) : applications.length > 0 ? (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-gray-400 uppercase text-xs font-black tracking-widest border-b border-gray-50">
|
|
||||||
<th className="px-4 py-4">User</th>
|
|
||||||
<th className="px-4 py-4">Shop Name</th>
|
|
||||||
<th className="px-4 py-4">Description</th>
|
|
||||||
<th className="px-4 py-4 text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{applications.map((app: any) => (
|
|
||||||
<tr key={app.id} className="border-b border-gray-50">
|
|
||||||
<td className="px-4 py-6">
|
|
||||||
<div className="font-bold text-gray-900">{app.firstName} {app.lastName}</div>
|
|
||||||
<div className="text-xs text-gray-400">{app.email}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-6 font-bold text-blue-600">{app.shopName}</td>
|
|
||||||
<td className="px-4 py-6 text-sm text-gray-600 max-w-md">{app.shopDescription}</td>
|
|
||||||
<td className="px-4 py-6 text-right">
|
|
||||||
<div className="flex justify-end space-x-2">
|
|
||||||
<BaseButton
|
|
||||||
color="success"
|
|
||||||
icon={mdiCheck}
|
|
||||||
small
|
|
||||||
onClick={() => handleReview(app.id, 'approved')}
|
|
||||||
label="Approve"
|
|
||||||
/>
|
|
||||||
<BaseButton
|
|
||||||
color="danger"
|
|
||||||
icon={mdiClose}
|
|
||||||
small
|
|
||||||
onClick={() => handleReview(app.id, 'rejected')}
|
|
||||||
label="Reject"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-20 bg-gray-50 rounded-3xl">
|
|
||||||
<BaseIcon path={mdiStore} size={48} className="mx-auto mb-4 text-gray-300" />
|
|
||||||
<p className="text-gray-500 font-bold">No pending seller applications.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
AdminSellerApplications.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminSellerApplications;
|
|
||||||
@ -1,264 +0,0 @@
|
|||||||
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, mdiTagOutline, mdiCheckCircle } 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 [promoCode, setPromoCode] = useState('');
|
|
||||||
const [appliedDiscount, setAppliedDiscount] = useState<any>(null);
|
|
||||||
const [discountError, setDiscountError] = useState('');
|
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
|
||||||
|
|
||||||
const subtotal = cartItems.reduce((acc, item) => acc + item.price * item.quantity, 0);
|
|
||||||
const shipping = subtotal > 100 ? 0 : 15;
|
|
||||||
|
|
||||||
let discountValue = 0;
|
|
||||||
if (appliedDiscount) {
|
|
||||||
if (appliedDiscount.type === 'percent') {
|
|
||||||
discountValue = subtotal * (parseFloat(appliedDiscount.value) / 100);
|
|
||||||
} else {
|
|
||||||
discountValue = parseFloat(appliedDiscount.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = subtotal - discountValue + shipping;
|
|
||||||
|
|
||||||
const handleApplyPromoCode = async () => {
|
|
||||||
if (!promoCode) return;
|
|
||||||
setIsValidating(true);
|
|
||||||
setDiscountError('');
|
|
||||||
try {
|
|
||||||
const response = await axios.post('/checkout/validate-discount', {
|
|
||||||
code: promoCode,
|
|
||||||
total: subtotal
|
|
||||||
});
|
|
||||||
setAppliedDiscount(response.data);
|
|
||||||
} catch (error: any) {
|
|
||||||
setDiscountError(error.response?.data?.message || 'Invalid promo code');
|
|
||||||
setAppliedDiscount(null);
|
|
||||||
} finally {
|
|
||||||
setIsValidating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
discountCode: appliedDiscount?.code,
|
|
||||||
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>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Promo Code Section */}
|
|
||||||
<div className="bg-white rounded-3xl p-8 shadow-sm border border-gray-100">
|
|
||||||
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center">
|
|
||||||
<BaseIcon path={mdiTagOutline} size={20} className="mr-2 text-blue-600" /> Have a promo code?
|
|
||||||
</h3>
|
|
||||||
<div className="flex space-x-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={promoCode}
|
|
||||||
onChange={(e) => setPromoCode(e.target.value)}
|
|
||||||
placeholder="Enter code (e.g. SAVE10)"
|
|
||||||
className="flex-grow bg-gray-50 border border-gray-100 rounded-2xl px-6 py-4 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all font-medium"
|
|
||||||
/>
|
|
||||||
<BaseButton
|
|
||||||
label={isValidating ? "..." : "Apply"}
|
|
||||||
color="info"
|
|
||||||
onClick={handleApplyPromoCode}
|
|
||||||
disabled={isValidating || !promoCode}
|
|
||||||
className="px-8 rounded-2xl font-bold"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{discountError && <p className="text-red-500 text-sm mt-3 font-medium ml-2">{discountError}</p>}
|
|
||||||
{appliedDiscount && (
|
|
||||||
<div className="mt-4 bg-green-50 border border-green-100 rounded-2xl p-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center text-green-700 font-bold">
|
|
||||||
<BaseIcon path={mdiCheckCircle} size={20} className="mr-2" />
|
|
||||||
Code {appliedDiscount.code} applied!
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => { setAppliedDiscount(null); setPromoCode(''); }}
|
|
||||||
className="text-green-700 hover:text-green-800 text-sm font-bold underline"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{appliedDiscount && (
|
|
||||||
<div className="flex justify-between text-green-600">
|
|
||||||
<span className="font-medium">Discount ({appliedDiscount.code})</span>
|
|
||||||
<span className="font-bold text-lg">-${discountValue.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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
CartPage.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
|
||||||
};
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
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>;
|
|
||||||
};
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
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,6 +1,6 @@
|
|||||||
import * as icon from '@mdi/js';
|
import * as icon from '@mdi/js';
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React, { useEffect } from 'react'
|
import React from 'react'
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated'
|
import LayoutAuthenticated from '../layouts/Authenticated'
|
||||||
@ -9,11 +9,13 @@ import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton
|
|||||||
import BaseIcon from "../components/BaseIcon";
|
import BaseIcon from "../components/BaseIcon";
|
||||||
import { getPageTitle } from '../config'
|
import { getPageTitle } from '../config'
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import CardBox from '../components/CardBox';
|
|
||||||
|
|
||||||
import { hasPermission } from "../helpers/userPermissions";
|
import { hasPermission } from "../helpers/userPermissions";
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { fetchWidgets } from '../stores/roles/rolesSlice';
|
||||||
|
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
|
||||||
|
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
|
||||||
|
|
||||||
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||||
@ -24,56 +26,124 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
|
|
||||||
const [users, setUsers] = React.useState(loadingMessage);
|
const [users, setUsers] = React.useState(loadingMessage);
|
||||||
|
const [roles, setRoles] = React.useState(loadingMessage);
|
||||||
|
const [permissions, setPermissions] = React.useState(loadingMessage);
|
||||||
const [products, setProducts] = React.useState(loadingMessage);
|
const [products, setProducts] = React.useState(loadingMessage);
|
||||||
const [categories, setCategories] = React.useState(loadingMessage);
|
const [categories, setCategories] = React.useState(loadingMessage);
|
||||||
const [orders, setOrders] = React.useState(loadingMessage);
|
const [orders, setOrders] = React.useState(loadingMessage);
|
||||||
|
const [order_items, setOrder_items] = React.useState(loadingMessage);
|
||||||
|
const [carts, setCarts] = React.useState(loadingMessage);
|
||||||
|
const [cart_items, setCart_items] = React.useState(loadingMessage);
|
||||||
const [payments, setPayments] = React.useState(loadingMessage);
|
const [payments, setPayments] = React.useState(loadingMessage);
|
||||||
const [lowStockProducts, setLowStockProducts] = React.useState([]);
|
|
||||||
|
|
||||||
|
|
||||||
|
const [widgetsRole, setWidgetsRole] = React.useState({
|
||||||
|
role: { value: '', label: '' },
|
||||||
|
});
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
|
||||||
|
|
||||||
|
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
const entities = ['users','roles','permissions','products','categories','orders','order_items','carts','cart_items','payments',];
|
||||||
const [u, pr, c, o, p] = await Promise.all([
|
const fns = [setUsers,setRoles,setPermissions,setProducts,setCategories,setOrders,setOrder_items,setCarts,setCart_items,setPayments,];
|
||||||
axios.get('/users/count'),
|
|
||||||
axios.get('/products/count'),
|
|
||||||
axios.get('/categories/count'),
|
|
||||||
axios.get('/orders/count'),
|
|
||||||
axios.get('/payments/count'),
|
|
||||||
]);
|
|
||||||
setUsers(u.data.count);
|
|
||||||
setProducts(pr.data.count);
|
|
||||||
setCategories(c.data.count);
|
|
||||||
setOrders(o.data.count);
|
|
||||||
setPayments(p.data.count);
|
|
||||||
|
|
||||||
if (hasPermission(currentUser, 'READ_PRODUCTS')) {
|
const requests = entities.map((entity, index) => {
|
||||||
const lowStock = await axios.get('/products', {
|
|
||||||
params: { stockRange: [0, 5], active: true }
|
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
|
||||||
});
|
return axios.get(`/${entity.toLowerCase()}/count`);
|
||||||
setLowStockProducts(lowStock.data.rows || []);
|
} else {
|
||||||
}
|
fns[index](null);
|
||||||
} catch (e) {
|
return Promise.resolve({data: {count: null}});
|
||||||
console.error(e);
|
}
|
||||||
}
|
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.allSettled(requests).then((results) => {
|
||||||
|
results.forEach((result, i) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
fns[i](result.value.data.count);
|
||||||
|
} else {
|
||||||
|
fns[i](result.reason.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getWidgets(roleId) {
|
||||||
|
await dispatch(fetchWidgets(roleId));
|
||||||
|
}
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
loadData().then();
|
||||||
|
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentUser || !widgetsRole?.role?.value) return;
|
||||||
|
getWidgets(widgetsRole?.role?.value || '').then();
|
||||||
|
}, [widgetsRole?.role?.value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Dashboard')}</title>
|
<title>
|
||||||
|
{getPageTitle('Overview')}
|
||||||
|
</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={icon.mdiChartTimelineVariant} title="Dashboard" main>
|
<SectionTitleLineWithButton
|
||||||
|
icon={icon.mdiChartTimelineVariant}
|
||||||
|
title='Overview'
|
||||||
|
main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6">
|
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
|
||||||
|
currentUser={currentUser}
|
||||||
|
isFetchingQuery={isFetchingQuery}
|
||||||
|
setWidgetsRole={setWidgetsRole}
|
||||||
|
widgetsRole={widgetsRole}
|
||||||
|
/>}
|
||||||
|
{!!rolesWidgets.length &&
|
||||||
|
hasPermission(currentUser, 'CREATE_ROLES') && (
|
||||||
|
<p className=' text-gray-500 dark:text-gray-400 mb-4'>
|
||||||
|
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
|
||||||
|
{(isFetchingQuery || loading) && (
|
||||||
|
<div className={` ${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor} animate-spin mr-5`}
|
||||||
|
w='w-16'
|
||||||
|
h='h-16'
|
||||||
|
size={48}
|
||||||
|
path={icon.mdiLoading}
|
||||||
|
/>{' '}
|
||||||
|
Loading widgets...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ rolesWidgets &&
|
||||||
|
rolesWidgets.map((widget) => (
|
||||||
|
<SmartWidget
|
||||||
|
key={widget.id}
|
||||||
|
userId={currentUser?.id}
|
||||||
|
widget={widget}
|
||||||
|
roleId={widgetsRole?.role?.value || ''}
|
||||||
|
admin={hasPermission(currentUser, 'CREATE_ROLES')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!!rolesWidgets.length && <hr className='my-6 ' />}
|
||||||
|
|
||||||
|
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
|
||||||
|
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-list'}>
|
{hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-list'}>
|
||||||
<div
|
<div
|
||||||
@ -94,7 +164,65 @@ const Dashboard = () => {
|
|||||||
w="w-16"
|
w="w-16"
|
||||||
h="h-16"
|
h="h-16"
|
||||||
size={48}
|
size={48}
|
||||||
path={icon.mdiAccountGroup}
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={icon.mdiAccountGroup || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_ROLES') && <Link href={'/roles/roles-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Roles
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{roles}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={icon.mdiShieldAccountVariantOutline || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_PERMISSIONS') && <Link href={'/permissions/permissions-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Permissions
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{permissions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={icon.mdiShieldAccountOutline || icon.mdiTable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -120,7 +248,37 @@ const Dashboard = () => {
|
|||||||
w="w-16"
|
w="w-16"
|
||||||
h="h-16"
|
h="h-16"
|
||||||
size={48}
|
size={48}
|
||||||
path={icon.mdiPackageVariant}
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={'mdiCube' in icon ? icon['mdiCube' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_CATEGORIES') && <Link href={'/categories/categories-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Categories
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{categories}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={'mdiTag' in icon ? icon['mdiTag' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -146,60 +304,128 @@ const Dashboard = () => {
|
|||||||
w="w-16"
|
w="w-16"
|
||||||
h="h-16"
|
h="h-16"
|
||||||
size={48}
|
size={48}
|
||||||
path={icon.mdiCart}
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={'mdiCart' in icon ? icon['mdiCart' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>}
|
</Link>}
|
||||||
</div>
|
|
||||||
|
|
||||||
{lowStockProducts.length > 0 && (
|
{hasPermission(currentUser, 'READ_ORDER_ITEMS') && <Link href={'/order_items/order_items-list'}>
|
||||||
<div className="mt-12">
|
<div
|
||||||
<SectionTitleLineWithButton icon={icon.mdiAlertCircle} title="Low Stock Alerts" />
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
>
|
||||||
{lowStockProducts.map((p: any) => (
|
|
||||||
<CardBox key={p.id} className="border-l-4 border-red-500">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-xs font-black text-red-600 bg-red-100 px-2 py-1 rounded uppercase tracking-widest">Low Stock</span>
|
|
||||||
<span className="text-sm font-bold text-gray-900">{p.stock} left</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="font-bold text-gray-900 line-clamp-1 mb-4">{p.title}</h3>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-xs text-gray-400 font-bold">{p.sku}</span>
|
|
||||||
<Link href={`/products/products-edit?id=${p.id}`} className="text-blue-600 font-bold text-xs hover:underline">
|
|
||||||
Restock
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 mt-12">
|
|
||||||
{hasPermission(currentUser, 'READ_CATEGORIES') && <Link href={'/categories/categories-list'}>
|
|
||||||
<div className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}>
|
|
||||||
<div className="flex justify-between align-center">
|
<div className="flex justify-between align-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg text-gray-500">Categories</div>
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
<div className="text-3xl font-semibold">{categories}</div>
|
Order items
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{order_items}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={'mdiClipboardList' in icon ? icon['mdiClipboardList' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<BaseIcon className={iconsColor} size={48} path={icon.mdiTag} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>}
|
</Link>}
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_CARTS') && <Link href={'/carts/carts-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Carts
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{carts}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={'mdiCartOutline' in icon ? icon['mdiCartOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
|
{hasPermission(currentUser, 'READ_CART_ITEMS') && <Link href={'/cart_items/cart_items-list'}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
|
Cart items
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{cart_items}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={'mdiPlusBox' in icon ? icon['mdiPlusBox' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>}
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_PAYMENTS') && <Link href={'/payments/payments-list'}>
|
{hasPermission(currentUser, 'READ_PAYMENTS') && <Link href={'/payments/payments-list'}>
|
||||||
<div className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}>
|
<div
|
||||||
|
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
<div className="flex justify-between align-center">
|
<div className="flex justify-between align-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg text-gray-500">Payments</div>
|
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||||
<div className="text-3xl font-semibold">{payments}</div>
|
Payments
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl leading-tight font-semibold">
|
||||||
|
{payments}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w="w-16"
|
||||||
|
h="h-16"
|
||||||
|
size={48}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
path={'mdiCreditCard' in icon ? icon['mdiCreditCard' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<BaseIcon className={iconsColor} size={48} path={icon.mdiCreditCard} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>}
|
</Link>}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,357 +1,166 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import axios from 'axios';
|
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import { getPageTitle } from '../config';
|
import CardBox from '../components/CardBox';
|
||||||
|
import SectionFullScreen from '../components/SectionFullScreen';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { mdiCart, mdiArrowRight, mdiStar } from '@mdi/js';
|
import BaseDivider from '../components/BaseDivider';
|
||||||
import BaseIcon from '../components/BaseIcon';
|
import BaseButtons from '../components/BaseButtons';
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { getPageTitle } from '../config';
|
||||||
import { addToCart } from '../stores/shoppingCartSlice';
|
import { useAppSelector } from '../stores/hooks';
|
||||||
import CountdownTimer from '../components/CountdownTimer';
|
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||||
import ProductRecommendations from '../components/Products/ProductRecommendations';
|
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||||
import SmartSearch from '../components/Search/SmartSearch';
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
export default function Starter() {
|
||||||
const fetchData = async () => {
|
const [illustrationImage, setIllustrationImage] = useState({
|
||||||
try {
|
src: undefined,
|
||||||
const [catRes, prodRes] = await Promise.all([
|
photographer: undefined,
|
||||||
axios.get('/categories'),
|
photographer_url: undefined,
|
||||||
axios.get('/products?limit=8'),
|
})
|
||||||
]);
|
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||||
setCategories(catRes.data.rows || []);
|
const [contentType, setContentType] = useState('image');
|
||||||
setProducts(prodRes.data.rows || []);
|
const [contentPosition, setContentPosition] = useState('left');
|
||||||
} catch (error) {
|
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||||
console.error('Error fetching storefront data:', error);
|
|
||||||
} finally {
|
const title = 'App Draft'
|
||||||
setLoading(false);
|
|
||||||
}
|
// 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>)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleQuickAdd = (product: any) => {
|
|
||||||
dispatch(addToCart({
|
|
||||||
id: Math.random().toString(36).substr(2, 9),
|
|
||||||
productId: product.id,
|
|
||||||
title: product.title,
|
|
||||||
price: product.sale_price && new Date(product.sale_ends_at) > new Date() ? product.sale_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 (
|
return (
|
||||||
<div className="bg-white min-h-screen">
|
<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',
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Home')}</title>
|
<title>{getPageTitle('Starter Page')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
{/* Navigation */}
|
<SectionFullScreen bg='violet'>
|
||||||
<nav className="border-b border-gray-100 py-4 px-6 flex justify-between items-center sticky top-0 bg-white z-50">
|
<div
|
||||||
<div className="flex items-center space-x-8 flex-grow">
|
className={`flex ${
|
||||||
<Link href="/" className="text-2xl font-black text-blue-600 tracking-tighter shrink-0">
|
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||||
STORE<span className="text-gray-900">FRONT</span>
|
} min-h-screen w-full`}
|
||||||
</Link>
|
>
|
||||||
<div className="hidden lg:flex flex-grow max-w-xl">
|
{contentType === 'image' && contentPosition !== 'background'
|
||||||
<SmartSearch />
|
? imageBlock(illustrationImage)
|
||||||
</div>
|
: null}
|
||||||
<div className="hidden md:flex space-x-6 items-center shrink-0">
|
{contentType === 'video' && contentPosition !== 'background'
|
||||||
<Link href="/products" className="text-gray-600 hover:text-blue-600 font-bold text-sm uppercase tracking-wider">
|
? videoBlock(illustrationVideo)
|
||||||
Products
|
: null}
|
||||||
</Link>
|
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||||
<Link href="/categories" className="text-gray-600 hover:text-blue-600 font-bold text-sm uppercase tracking-wider">
|
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||||
Categories
|
<CardBoxComponentTitle title="Welcome to your App Draft app!"/>
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4 shrink-0 ml-4">
|
|
||||||
<Link href="/cart" className="relative p-2 text-gray-400 hover:text-blue-600 transition-colors">
|
|
||||||
<BaseIcon path={mdiCart} size={24} />
|
|
||||||
{cartCount > 0 && (
|
|
||||||
<span className="absolute -top-1 -right-1 bg-blue-600 text-white text-[10px] font-black rounded-full h-5 w-5 flex items-center justify-center ring-4 ring-white">
|
|
||||||
{cartCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<Link href="/login" className="text-gray-600 hover:text-blue-600 font-bold text-sm uppercase tracking-wider">
|
|
||||||
Login
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/register"
|
|
||||||
className="bg-gray-900 text-white px-6 py-2.5 rounded-2xl font-black text-[10px] uppercase tracking-widest hover:bg-blue-600 transition-all shadow-md"
|
|
||||||
>
|
|
||||||
Sign Up
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
<div className="space-y-3">
|
||||||
<section className="relative bg-gray-50 py-24 px-6">
|
<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>
|
||||||
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center">
|
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||||
<div className="md:w-1/2 space-y-8">
|
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||||
<div className="inline-block bg-blue-100 text-blue-600 px-4 py-1 rounded-full text-xs font-black uppercase tracking-widest">
|
|
||||||
New Collection 2026
|
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-6xl md:text-7xl font-black text-gray-900 leading-[1.1] tracking-tighter">
|
|
||||||
Upgrade Your <span className="text-blue-600">Lifestyle</span>
|
<BaseButtons>
|
||||||
</h1>
|
<BaseButton
|
||||||
<p className="text-xl text-gray-500 max-w-lg font-medium leading-relaxed">
|
href='/login'
|
||||||
Discover our curated collection of high-quality products designed for the modern world. Premium goods for premium people.
|
label='Login'
|
||||||
</p>
|
color='info'
|
||||||
<div className="flex space-x-4 pt-4">
|
className='w-full'
|
||||||
<BaseButton
|
/>
|
||||||
href="/products"
|
|
||||||
label="Explore Shop"
|
</BaseButtons>
|
||||||
color="info"
|
</CardBox>
|
||||||
className="px-10 py-4 rounded-2xl text-xs font-black uppercase tracking-widest shadow-xl shadow-blue-200 hover:shadow-blue-300 transition-all transform hover:-translate-y-1"
|
|
||||||
/>
|
|
||||||
<BaseButton
|
|
||||||
href="/register"
|
|
||||||
label="Join Community"
|
|
||||||
outline
|
|
||||||
color="info"
|
|
||||||
className="px-10 py-4 rounded-2xl text-xs font-black uppercase tracking-widest transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="md:w-1/2 mt-16 md:mt-0 relative">
|
|
||||||
<div className="w-full aspect-square bg-blue-100 rounded-[4rem] overflow-hidden shadow-2xl relative transform rotate-3">
|
|
||||||
<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 transform -rotate-3 scale-110"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-blue-900/40 to-transparent"></div>
|
|
||||||
</div>
|
|
||||||
{/* Abstract elements */}
|
|
||||||
<div className="absolute -bottom-10 -left-10 w-40 h-40 bg-yellow-400 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-pulse"></div>
|
|
||||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-blue-400 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-pulse delay-700"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
|
|
||||||
{/* Flash Sales Section */}
|
|
||||||
{products.some(p => p.sale_ends_at && new Date(p.sale_ends_at) > new Date()) && (
|
|
||||||
<section className="py-24 px-6 bg-red-50">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<div className="mb-12 flex justify-between items-end">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-4xl font-black text-red-600 uppercase tracking-tighter">Flash Deals</h2>
|
|
||||||
<p className="text-gray-500 mt-2 font-bold uppercase tracking-widest text-xs">High-speed deals ending soon.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
|
||||||
{products
|
|
||||||
.filter(p => p.sale_ends_at && new Date(p.sale_ends_at) > new Date())
|
|
||||||
.map((product) => (
|
|
||||||
<div key={`sale-${product.id}`} className="bg-white rounded-[2.5rem] overflow-hidden shadow-xl border border-red-100 relative group transition-all hover:shadow-2xl hover:shadow-red-200">
|
|
||||||
<div className="absolute top-4 left-4 z-10 scale-90 origin-top-left">
|
|
||||||
<CountdownTimer targetDate={product.sale_ends_at} />
|
|
||||||
</div>
|
|
||||||
<div className="h-56 relative overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={product.images?.[0]?.url || `https://images.pexels.com/photos/1350789/pexels-photo-1350789.jpeg?auto=compress&cs=tinysrgb&w=400`}
|
|
||||||
alt={product.title}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="p-8">
|
|
||||||
<h3 className="font-bold text-gray-900 line-clamp-1 text-lg mb-2">{product.title}</h3>
|
|
||||||
<div className="flex items-baseline space-x-3 mb-6">
|
|
||||||
<span className="text-3xl font-black text-red-600">${product.sale_price}</span>
|
|
||||||
<span className="text-sm text-gray-400 line-through font-bold">${product.price}</span>
|
|
||||||
</div>
|
|
||||||
<BaseButton
|
|
||||||
label="Add to Cart"
|
|
||||||
color="danger"
|
|
||||||
className="w-full py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-lg shadow-red-100"
|
|
||||||
onClick={() => handleQuickAdd(product)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Categories Grid */}
|
|
||||||
<section className="py-24 px-6 max-w-7xl mx-auto">
|
|
||||||
<div className="flex justify-between items-end mb-12">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-4xl font-black text-gray-900 tracking-tighter">Shop Categories</h2>
|
|
||||||
<p className="text-gray-400 font-bold mt-2 uppercase tracking-widest text-xs">Explore our wide range of collections.</p>
|
|
||||||
</div>
|
|
||||||
<Link href="/categories" className="text-blue-600 font-black text-xs uppercase tracking-widest flex items-center hover:underline">
|
|
||||||
View All <BaseIcon path={mdiArrowRight} size={16} className="ml-1" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
|
||||||
{loading
|
|
||||||
? Array(4)
|
|
||||||
.fill(0)
|
|
||||||
.map((_, i) => (
|
|
||||||
<div key={i} className="h-64 bg-gray-50 rounded-[2.5rem] animate-pulse"></div>
|
|
||||||
))
|
|
||||||
: categories.slice(0, 4).map((cat) => (
|
|
||||||
<Link
|
|
||||||
key={cat.id}
|
|
||||||
href={`/products?category=${cat.id}`}
|
|
||||||
className="group relative h-64 rounded-[2.5rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all border border-gray-50"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-blue-600 opacity-5 group-hover:opacity-10 transition-opacity"></div>
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center p-8 text-center">
|
|
||||||
<span className="text-2xl font-black text-gray-900 group-hover:text-blue-600 transition-colors tracking-tighter leading-tight mb-2">
|
|
||||||
{cat.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] group-hover:text-blue-400 transition-colors">Explore Collection</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Featured Products */}
|
|
||||||
<section className="py-24 px-6 bg-gray-50">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<div className="flex justify-between items-end mb-12">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-4xl font-black text-gray-900 tracking-tighter">New Arrivals</h2>
|
|
||||||
<p className="text-gray-400 font-bold mt-2 uppercase tracking-widest text-xs">Our latest drops curated just for you.</p>
|
|
||||||
</div>
|
|
||||||
<Link href="/products" className="text-blue-600 font-black text-xs uppercase tracking-widest flex items-center hover:underline">
|
|
||||||
View Shop <BaseIcon path={mdiArrowRight} size={16} className="ml-1" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-10">
|
|
||||||
{loading
|
|
||||||
? Array(4)
|
|
||||||
.fill(0)
|
|
||||||
.map((_, i) => (
|
|
||||||
<div key={i} className="bg-white rounded-[2.5rem] p-4 shadow-sm h-96 animate-pulse"></div>
|
|
||||||
))
|
|
||||||
: products.map((product) => {
|
|
||||||
const avg = getAvgRating(product.reviews);
|
|
||||||
const isOnSale = product.sale_ends_at && new Date(product.sale_ends_at) > new Date();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={product.id}
|
|
||||||
className="bg-white rounded-[2.5rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 group flex flex-col relative border border-gray-100"
|
|
||||||
>
|
|
||||||
{isOnSale && (
|
|
||||||
<div className="absolute top-4 left-4 z-10 scale-75 origin-top-left">
|
|
||||||
<CountdownTimer targetDate={product.sale_ends_at} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="h-64 bg-gray-200 relative overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={product.images?.[0]?.url || `https://images.pexels.com/photos/1350789/pexels-photo-1350789.jpeg?auto=compress&cs=tinysrgb&w=400`}
|
|
||||||
alt={product.title}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleQuickAdd(product)}
|
|
||||||
className="absolute bottom-4 right-4 bg-white/90 backdrop-blur-md p-4 rounded-[1.25rem] shadow-xl hover:bg-blue-600 hover:text-white transition-all transform hover:scale-110 translate-y-20 group-hover:translate-y-0 duration-500"
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiCart} size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-8 flex-grow flex flex-col">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-xl font-bold text-gray-900 line-clamp-1 tracking-tight group-hover:text-blue-600 transition-colors">{product.title}</h3>
|
|
||||||
{avg > 0 && (
|
|
||||||
<div className="flex items-center text-yellow-500 font-bold text-sm">
|
|
||||||
<BaseIcon path={mdiStar} size={16} className="mr-1" />
|
|
||||||
<span>{avg.toFixed(1)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-auto pt-6 border-t border-gray-50 flex justify-between items-center">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{isOnSale ? (
|
|
||||||
<>
|
|
||||||
<span className="text-2xl font-black text-red-600">${product.sale_price}</span>
|
|
||||||
<span className="text-xs text-gray-400 line-through font-bold">${product.price}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-2xl font-black text-blue-600">${product.price}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/products/${product.id}`}
|
|
||||||
className="bg-gray-900 text-white text-[10px] font-black px-6 py-3 rounded-2xl uppercase tracking-widest hover:bg-blue-600 transition-all shadow-md"
|
|
||||||
>
|
|
||||||
Details
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Recommendations */}
|
|
||||||
<section className="py-24 px-6 max-w-7xl mx-auto">
|
|
||||||
<ProductRecommendations />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="bg-white border-t border-gray-100 py-20 px-6">
|
|
||||||
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-12">
|
|
||||||
<div className="col-span-1 md:col-span-2">
|
|
||||||
<Link href="/" className="text-3xl font-black text-blue-600 tracking-tighter">
|
|
||||||
STORE<span className="text-gray-900">FRONT</span>
|
|
||||||
</Link>
|
|
||||||
<p className="text-gray-400 mt-6 text-sm max-w-xs font-medium leading-relaxed">
|
|
||||||
Premium storefront platform for modern lifestyle goods. Upgrade your daily routine with our curated selections.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-gray-900">Explore</h4>
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<Link href="/products" className="text-gray-500 hover:text-blue-600 text-sm font-bold transition-colors">Products</Link>
|
|
||||||
<Link href="/categories" className="text-gray-500 hover:text-blue-600 text-sm font-bold transition-colors">Categories</Link>
|
|
||||||
<Link href="/wishlist" className="text-gray-500 hover:text-blue-600 text-sm font-bold transition-colors">Wishlist</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-gray-900">Legal</h4>
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<Link href="/privacy-policy" className="text-gray-500 hover:text-blue-600 text-sm font-bold transition-colors">Privacy Policy</Link>
|
|
||||||
<Link href="/terms-of-use" className="text-gray-500 hover:text-blue-600 text-sm font-bold transition-colors">Terms of Use</Link>
|
|
||||||
<Link href="/dashboard" className="text-blue-600 hover:text-blue-700 text-sm font-black uppercase tracking-widest pt-2">Admin Panel</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="max-w-7xl mx-auto border-t border-gray-50 mt-16 pt-8 text-center text-gray-400 text-xs font-bold uppercase tracking-widest">
|
|
||||||
© 2026 Storefront platform. All rights reserved.
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Home.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,106 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
|
|
||||||
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 dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
const [filters] = useState([{label: 'OrderNumber', title: 'order_number'},{label: 'ProductName', title: 'productName'},{label: 'ShippingAddress', title: 'shipping_address'},{label: 'BillingAddress', title: 'billing_address'},
|
const [filters] = useState([{label: 'OrderNumber', title: 'order_number'},{label: 'ShippingAddress', title: 'shipping_address'},{label: 'BillingAddress', title: 'billing_address'},
|
||||||
|
|
||||||
{label: 'TotalAmount', title: 'total', number: 'true'},
|
{label: 'TotalAmount', title: 'total', number: 'true'},
|
||||||
{label: 'PlacedAt', title: 'placed_at', date: 'true'},{label: 'ShippedAt', title: 'shipped_at', date: 'true'},
|
{label: 'PlacedAt', title: 'placed_at', date: 'true'},{label: 'ShippedAt', title: 'shipped_at', date: 'true'},
|
||||||
|
|||||||
@ -34,7 +34,7 @@ const OrdersTablesPage = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
const [filters] = useState([{label: 'OrderNumber', title: 'order_number'},{label: 'ProductName', title: 'productName'},{label: 'ShippingAddress', title: 'shipping_address'},{label: 'BillingAddress', title: 'billing_address'},
|
const [filters] = useState([{label: 'OrderNumber', title: 'order_number'},{label: 'ShippingAddress', title: 'shipping_address'},{label: 'BillingAddress', title: 'billing_address'},
|
||||||
|
|
||||||
{label: 'TotalAmount', title: 'total', number: 'true'},
|
{label: 'TotalAmount', title: 'total', number: 'true'},
|
||||||
{label: 'PlacedAt', title: 'placed_at', date: 'true'},{label: 'ShippedAt', title: 'shipped_at', date: 'true'},
|
{label: 'PlacedAt', title: 'placed_at', date: 'true'},{label: 'ShippedAt', title: 'shipped_at', date: 'true'},
|
||||||
|
|||||||
@ -1,524 +0,0 @@
|
|||||||
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,
|
|
||||||
mdiArrowRight
|
|
||||||
} 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';
|
|
||||||
import CountdownTimer from '../../components/CountdownTimer';
|
|
||||||
import ProductRecommendations from '../../components/Products/ProductRecommendations';
|
|
||||||
|
|
||||||
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 [recommendations, setRecommendations] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [loadingRecs, setLoadingRecs] = useState(false);
|
|
||||||
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);
|
|
||||||
fetchRecommendations();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching product:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchRecommendations = async () => {
|
|
||||||
if (!id) return;
|
|
||||||
setLoadingRecs(true);
|
|
||||||
try {
|
|
||||||
const res = await axios.get(`/products/${id}/recommendations?limit=4`);
|
|
||||||
setRecommendations(res.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching recommendations:', error);
|
|
||||||
} finally {
|
|
||||||
setLoadingRecs(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const recordView = async () => {
|
|
||||||
if (!id) return;
|
|
||||||
try {
|
|
||||||
await axios.post('/analytics/record', { productId: id });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error recording view:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (id) {
|
|
||||||
fetchProduct();
|
|
||||||
recordView();
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
}
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentUser) {
|
|
||||||
dispatch(fetchWishlist({}));
|
|
||||||
}
|
|
||||||
}, [currentUser, dispatch]);
|
|
||||||
|
|
||||||
const handleAddToCart = (item: any = product, qty: number = quantity) => {
|
|
||||||
const isOnSale = item.sale_ends_at && new Date(item.sale_ends_at) > new Date();
|
|
||||||
setAdding(true);
|
|
||||||
dispatch(addToCart({
|
|
||||||
id: Math.random().toString(36).substr(2, 9),
|
|
||||||
productId: item.id,
|
|
||||||
title: item.title,
|
|
||||||
price: isOnSale && item.sale_price ? item.sale_price : item.price,
|
|
||||||
quantity: qty,
|
|
||||||
image: item.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 getAvgRating = (reviews: any[]) => {
|
|
||||||
if (!reviews || reviews.length === 0) return 0;
|
|
||||||
return reviews.reduce((acc, r) => acc + r.rating, 0) / reviews.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 = getAvgRating(product.reviews);
|
|
||||||
const wishlisted = isInWishlist(product.id);
|
|
||||||
const isOnSale = product.sale_ends_at && new Date(product.sale_ends_at) > new Date();
|
|
||||||
|
|
||||||
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-24">
|
|
||||||
{/* 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"
|
|
||||||
/>
|
|
||||||
{isOnSale && (
|
|
||||||
<div className="absolute top-6 left-6 z-10">
|
|
||||||
<CountdownTimer targetDate={product.sale_ends_at} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{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 ${isOnSale ? 'bg-red-50 border-red-100' : 'bg-gray-50 border-gray-100'} rounded-2xl border`}>
|
|
||||||
<div className="flex items-baseline space-x-3">
|
|
||||||
{isOnSale ? (
|
|
||||||
<>
|
|
||||||
<span className="text-4xl font-extrabold text-red-600">${product.sale_price}</span>
|
|
||||||
<span className="text-xl text-gray-400 line-through">${product.price}</span>
|
|
||||||
<span className="bg-red-600 text-white text-xs font-bold px-2 py-1 rounded-lg ml-2">SALE</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<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={isOnSale ? "danger" : "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>
|
|
||||||
|
|
||||||
{/* Recommendations Section */}
|
|
||||||
{recommendations.length > 0 && (
|
|
||||||
<div className="mb-24">
|
|
||||||
<div className="flex justify-between items-end mb-8">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl font-bold text-gray-900">You May Also Like</h2>
|
|
||||||
<p className="text-gray-500 mt-2">Based on this product's category.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
|
||||||
{recommendations.map((rec) => {
|
|
||||||
const avg = getAvgRating(rec.reviews);
|
|
||||||
const recOnSale = rec.sale_ends_at && new Date(rec.sale_ends_at) > new Date();
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={rec.id}
|
|
||||||
className="bg-white rounded-3xl overflow-hidden shadow-sm hover:shadow-xl transition-all group flex flex-col border border-gray-100 relative"
|
|
||||||
>
|
|
||||||
{recOnSale && (
|
|
||||||
<div className="absolute top-2 left-2 z-10 scale-75 origin-top-left">
|
|
||||||
<CountdownTimer targetDate={rec.sale_ends_at} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="h-48 bg-gray-200 relative overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={rec.images?.[0]?.url || "https://images.pexels.com/photos/1350789/pexels-photo-1350789.jpeg?auto=compress&cs=tinysrgb&w=300"}
|
|
||||||
alt={rec.title}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleAddToCart(rec, 1)}
|
|
||||||
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">{rec.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>
|
|
||||||
<div className="mt-4 flex justify-between items-center">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{recOnSale ? (
|
|
||||||
<>
|
|
||||||
<span className="text-xl font-extrabold text-red-600">${rec.sale_price}</span>
|
|
||||||
<span className="text-xs text-gray-400 line-through">${rec.price}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-xl font-extrabold text-blue-600">${rec.price}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/products/${rec.id}`}
|
|
||||||
className="text-sm font-semibold text-gray-700 hover:text-blue-600 flex items-center"
|
|
||||||
>
|
|
||||||
View <BaseIcon path={mdiArrowRight} size={16} className="ml-1" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ProductRecommendations />
|
|
||||||
|
|
||||||
{/* 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>;
|
|
||||||
};
|
|
||||||
883
frontend/src/pages/products/[productsId].tsx
Normal file
883
frontend/src/pages/products/[productsId].tsx
Normal file
@ -0,0 +1,883 @@
|
|||||||
|
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
|
||||||
|
import Head from 'next/head'
|
||||||
|
import React, { ReactElement, useEffect, useState } from 'react'
|
||||||
|
import DatePicker from "react-datepicker";
|
||||||
|
import "react-datepicker/dist/react-datepicker.css";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
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 { Field, Form, Formik } from 'formik'
|
||||||
|
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 FormFilePicker from '../../components/FormFilePicker'
|
||||||
|
import FormImagePicker from '../../components/FormImagePicker'
|
||||||
|
import { SelectField } from "../../components/SelectField";
|
||||||
|
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
||||||
|
import { SwitchField } from '../../components/SwitchField'
|
||||||
|
import {RichTextField} from "../../components/RichTextField";
|
||||||
|
|
||||||
|
import { update, fetch } from '../../stores/products/productsSlice'
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import {saveFile} from "../../helpers/fileSaver";
|
||||||
|
import dataFormatter from '../../helpers/dataFormatter';
|
||||||
|
import ImageField from "../../components/ImageField";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const EditProducts = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const initVals = {
|
||||||
|
|
||||||
|
|
||||||
|
'title': '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
'slug': '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
description: '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
'price': '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
'sku': '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
stock: '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
active: false,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
images: [],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
category: null,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
created_on: new Date(),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
updated_on: new Date(),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
const [initialValues, setInitialValues] = useState(initVals)
|
||||||
|
|
||||||
|
const { products } = useAppSelector((state) => state.products)
|
||||||
|
|
||||||
|
|
||||||
|
const { productsId } = router.query
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetch({ id: productsId }))
|
||||||
|
}, [productsId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof products === 'object') {
|
||||||
|
setInitialValues(products)
|
||||||
|
}
|
||||||
|
}, [products])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof products === 'object') {
|
||||||
|
|
||||||
|
const newInitialVal = {...initVals};
|
||||||
|
|
||||||
|
Object.keys(initVals).forEach(el => newInitialVal[el] = (products)[el])
|
||||||
|
|
||||||
|
setInitialValues(newInitialVal);
|
||||||
|
}
|
||||||
|
}, [products])
|
||||||
|
|
||||||
|
const handleSubmit = async (data) => {
|
||||||
|
await dispatch(update({ id: productsId, data }))
|
||||||
|
await router.push('/products/products-list')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Edit products')}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit products'} main>
|
||||||
|
{''}
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
<CardBox>
|
||||||
|
<Formik
|
||||||
|
enableReinitialize
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
|
>
|
||||||
|
<Form>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Title"
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
name="title"
|
||||||
|
placeholder="Title"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Slug"
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
name="slug"
|
||||||
|
placeholder="Slug"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<FormField label='Description' hasTextareaHeight>
|
||||||
|
<Field
|
||||||
|
name='description'
|
||||||
|
id='description'
|
||||||
|
component={RichTextField}
|
||||||
|
></Field>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Price"
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
type="number"
|
||||||
|
name="price"
|
||||||
|
placeholder="Price"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="SKU"
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
name="sku"
|
||||||
|
placeholder="SKU"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Stock"
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
type="number"
|
||||||
|
name="stock"
|
||||||
|
placeholder="Stock"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<FormField label='Active' labelFor='active'>
|
||||||
|
<Field
|
||||||
|
name='active'
|
||||||
|
id='active'
|
||||||
|
component={SwitchField}
|
||||||
|
></Field>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<FormField>
|
||||||
|
<Field
|
||||||
|
label='Images'
|
||||||
|
color='info'
|
||||||
|
icon={mdiUpload}
|
||||||
|
path={'products/images'}
|
||||||
|
name='images'
|
||||||
|
id='images'
|
||||||
|
schema={{
|
||||||
|
size: undefined,
|
||||||
|
formats: undefined,
|
||||||
|
}}
|
||||||
|
component={FormImagePicker}
|
||||||
|
></Field>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<FormField label='Category' labelFor='category'>
|
||||||
|
<Field
|
||||||
|
name='category'
|
||||||
|
id='category'
|
||||||
|
component={SelectField}
|
||||||
|
options={initialValues.category}
|
||||||
|
itemRef={'categories'}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
showField={'name'}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
></Field>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="CreatedOn"
|
||||||
|
>
|
||||||
|
<DatePicker
|
||||||
|
dateFormat="yyyy-MM-dd hh:mm"
|
||||||
|
showTimeSelect
|
||||||
|
selected={initialValues.created_on ?
|
||||||
|
new Date(
|
||||||
|
dayjs(initialValues.created_on).format('YYYY-MM-DD hh:mm'),
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
onChange={(date) => setInitialValues({...initialValues, 'created_on': date})}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="UpdatedOn"
|
||||||
|
>
|
||||||
|
<DatePicker
|
||||||
|
dateFormat="yyyy-MM-dd hh:mm"
|
||||||
|
showTimeSelect
|
||||||
|
selected={initialValues.updated_on ?
|
||||||
|
new Date(
|
||||||
|
dayjs(initialValues.updated_on).format('YYYY-MM-DD hh:mm'),
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
onChange={(date) => setInitialValues({...initialValues, 'updated_on': date})}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<BaseDivider />
|
||||||
|
<BaseButtons>
|
||||||
|
<BaseButton type="submit" color="info" label="Submit" />
|
||||||
|
<BaseButton type="reset" color="info" outline label="Reset" />
|
||||||
|
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/products/products-list')}/>
|
||||||
|
</BaseButtons>
|
||||||
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
</CardBox>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
EditProducts.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return (
|
||||||
|
<LayoutAuthenticated
|
||||||
|
|
||||||
|
permission={'UPDATE_PRODUCTS'}
|
||||||
|
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</LayoutAuthenticated>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditProducts
|
||||||
@ -1,325 +0,0 @@
|
|||||||
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, mdiSortVariant } 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';
|
|
||||||
import SmartSearch from '../../components/Search/SmartSearch';
|
|
||||||
|
|
||||||
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 [sortBy, setSortBy] = useState('createdAt');
|
|
||||||
const [sortDir, setSortDir] = useState('desc');
|
|
||||||
const [priceRange, setPriceRange] = useState({ min: '', max: '' });
|
|
||||||
|
|
||||||
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 params: any = {
|
|
||||||
field: sortBy,
|
|
||||||
sort: sortDir,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (selectedCategory !== 'all') {
|
|
||||||
params.category = selectedCategory;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (priceRange.min) params['priceRange[0]'] = priceRange.min;
|
|
||||||
if (priceRange.max) params['priceRange[1]'] = priceRange.max;
|
|
||||||
|
|
||||||
const [catRes, prodRes] = await Promise.all([
|
|
||||||
axios.get(catUrl),
|
|
||||||
axios.get('/products', { params }),
|
|
||||||
]);
|
|
||||||
setCategories(catRes.data.rows || []);
|
|
||||||
setProducts(prodRes.data.rows || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching catalog data:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchData();
|
|
||||||
}, [selectedCategory, sortBy, sortDir, priceRange]);
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
const [field, dir] = e.target.value.split(':');
|
|
||||||
setSortBy(field);
|
|
||||||
setSortDir(dir);
|
|
||||||
};
|
|
||||||
|
|
||||||
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">
|
|
||||||
<div className="flex items-center space-x-8 flex-grow">
|
|
||||||
<Link href="/" className="text-2xl font-black text-blue-600 tracking-tighter shrink-0">
|
|
||||||
STORE<span className="text-gray-900">FRONT</span>
|
|
||||||
</Link>
|
|
||||||
<div className="hidden lg:flex flex-grow max-w-xl">
|
|
||||||
<SmartSearch />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4 ml-4">
|
|
||||||
<Link href="/wishlist" className="p-2 text-gray-400 hover:text-red-500 transition-colors">
|
|
||||||
<BaseIcon path={mdiHeart} size={24} />
|
|
||||||
</Link>
|
|
||||||
<Link href="/cart" className="relative p-2 text-gray-400 hover:text-blue-600 transition-colors">
|
|
||||||
<BaseIcon path={mdiCart} size={24} />
|
|
||||||
{cartCount > 0 && (
|
|
||||||
<span className="absolute -top-1 -right-1 bg-blue-600 text-white text-[10px] font-black rounded-full h-5 w-5 flex items-center justify-center ring-4 ring-white">
|
|
||||||
{cartCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto py-12 px-6">
|
|
||||||
<div className="flex flex-col lg:flex-row gap-12">
|
|
||||||
{/* Sidebar Filters */}
|
|
||||||
<aside className="lg:w-72 space-y-10">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm uppercase tracking-widest font-black text-gray-400 mb-6 flex items-center">
|
|
||||||
<BaseIcon path={mdiFilter} size={16} className="mr-2" /> Categories
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedCategory('all')}
|
|
||||||
className={`w-full text-left px-5 py-3 rounded-2xl transition-all font-bold ${
|
|
||||||
selectedCategory === 'all'
|
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-200'
|
|
||||||
: 'text-gray-600 hover:bg-white hover:shadow-sm'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
All Products
|
|
||||||
</button>
|
|
||||||
{categories.map((cat: any) => (
|
|
||||||
<button
|
|
||||||
key={cat.id}
|
|
||||||
onClick={() => setSelectedCategory(cat.id)}
|
|
||||||
className={`w-full text-left px-5 py-3 rounded-2xl transition-all font-bold ${
|
|
||||||
selectedCategory === cat.id
|
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-200'
|
|
||||||
: 'text-gray-600 hover:bg-white hover:shadow-sm'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{cat.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm uppercase tracking-widest font-black text-gray-400 mb-6 flex items-center">
|
|
||||||
Price Range
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-bold text-gray-400 uppercase ml-1">Min</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="0"
|
|
||||||
value={priceRange.min}
|
|
||||||
onChange={(e) => setPriceRange({...priceRange, min: e.target.value})}
|
|
||||||
className="w-full bg-white border border-gray-100 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all font-bold text-gray-900"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-bold text-gray-400 uppercase ml-1">Max</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="1000"
|
|
||||||
value={priceRange.max}
|
|
||||||
onChange={(e) => setPriceRange({...priceRange, max: e.target.value})}
|
|
||||||
className="w-full bg-white border border-gray-100 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all font-bold text-gray-900"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setPriceRange({ min: '', max: '' })}
|
|
||||||
className="text-xs font-bold text-blue-600 hover:text-blue-700 transition-colors uppercase tracking-widest ml-1"
|
|
||||||
>
|
|
||||||
Reset Price
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Product Grid */}
|
|
||||||
<div className="flex-grow">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-10 gap-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-black text-gray-900 tracking-tight">
|
|
||||||
{selectedCategory === 'all' ? 'All Products' : categories.find((c: any) => c.id === selectedCategory)?.name}
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-400 font-bold mt-1 uppercase tracking-widest text-xs">{products.length} Items found</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center bg-white rounded-2xl px-4 py-2 border border-gray-100 shadow-sm">
|
|
||||||
<BaseIcon path={mdiSortVariant} size={20} className="text-gray-400 mr-2" />
|
|
||||||
<select
|
|
||||||
onChange={handleSortChange}
|
|
||||||
value={`${sortBy}:${sortDir}`}
|
|
||||||
className="bg-transparent border-none focus:ring-0 font-bold text-gray-700 pr-8 py-1 appearance-none cursor-pointer"
|
|
||||||
>
|
|
||||||
<option value="createdAt:desc">Newest Arrivals</option>
|
|
||||||
<option value="price:asc">Price: Low to High</option>
|
|
||||||
<option value="price:desc">Price: High to Low</option>
|
|
||||||
<option value="title:asc">Alphabetical</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</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-[2rem] p-4 shadow-sm h-[420px] animate-pulse border border-gray-100"></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-[2.5rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-300 group flex flex-col border border-gray-100">
|
|
||||||
<div className="h-64 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=400`}
|
|
||||||
alt={product.title}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
|
||||||
/>
|
|
||||||
<div className="absolute top-4 right-4 flex flex-col space-y-2 opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-4 group-hover:translate-x-0">
|
|
||||||
<button
|
|
||||||
onClick={() => handleQuickAdd(product)}
|
|
||||||
className="bg-white p-3 rounded-2xl shadow-xl 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 p-3 rounded-2xl shadow-xl 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-8 flex-grow flex flex-col">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<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-500 font-bold text-sm">
|
|
||||||
<BaseIcon path={mdiStar} size={16} className="mr-1" />
|
|
||||||
<span>{avg.toFixed(1)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between mt-auto pt-6 border-t border-gray-50">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{product.sale_price ? (
|
|
||||||
<>
|
|
||||||
<span className="text-2xl font-black text-red-600">${product.sale_price}</span>
|
|
||||||
<span className="text-xs text-gray-400 line-through font-bold">${product.price}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-2xl font-black text-blue-600">${product.price}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/products/${product.id}`}
|
|
||||||
className="px-6 py-3 bg-gray-900 text-white text-[10px] font-black rounded-2xl hover:bg-blue-600 transition-all uppercase tracking-widest shadow-md hover:shadow-xl transform hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
Details
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)})
|
|
||||||
) : (
|
|
||||||
<div className="col-span-full py-32 text-center bg-white rounded-[3rem] border border-gray-100 shadow-sm">
|
|
||||||
<div className="bg-gray-50 w-24 h-24 rounded-full flex items-center justify-center mx-auto mb-8 text-gray-300">
|
|
||||||
<BaseIcon path={mdiFilter} size={48} />
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-500 font-bold text-2xl mb-8">No products matching your criteria.</p>
|
|
||||||
<BaseButton
|
|
||||||
onClick={() => { setSelectedCategory('all'); setPriceRange({min:'', max:''}); }}
|
|
||||||
label="Reset all filters"
|
|
||||||
color="info"
|
|
||||||
className="rounded-2xl px-10 py-4 font-bold"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ProductCatalog.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
|
||||||
};
|
|
||||||
@ -1,11 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
mdiAccount,
|
mdiChartTimelineVariant,
|
||||||
mdiUpload,
|
mdiUpload,
|
||||||
mdiMapMarker,
|
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { ToastContainer, toast } from 'react-toastify';
|
||||||
|
import DatePicker from 'react-datepicker';
|
||||||
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
|
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
@ -18,31 +19,31 @@ import FormField from '../components/FormField';
|
|||||||
import BaseDivider from '../components/BaseDivider';
|
import BaseDivider from '../components/BaseDivider';
|
||||||
import BaseButtons from '../components/BaseButtons';
|
import BaseButtons from '../components/BaseButtons';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import FormCheckRadio from '../components/FormCheckRadio';
|
||||||
|
import FormCheckRadioGroup from '../components/FormCheckRadioGroup';
|
||||||
import FormImagePicker from '../components/FormImagePicker';
|
import FormImagePicker from '../components/FormImagePicker';
|
||||||
import BaseIcon from '../components/BaseIcon';
|
import { SwitchField } from '../components/SwitchField';
|
||||||
|
import { SelectField } from '../components/SelectField';
|
||||||
|
|
||||||
import { update } from '../stores/users/usersSlice';
|
import { update, fetch } from '../stores/users/usersSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { findMe } from "../stores/authSlice";
|
import {findMe} from "../stores/authSlice";
|
||||||
|
|
||||||
const ProfilePage = () => {
|
const EditUsers = () => {
|
||||||
const { currentUser } = useAppSelector(
|
const { currentUser, isFetching, token } = useAppSelector(
|
||||||
(state) => state.auth,
|
(state) => state.auth,
|
||||||
);
|
);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const notify = (type, msg) => toast(msg, { type });
|
const notify = (type, msg) => toast(msg, { type });
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
phoneNumber: '',
|
phoneNumber: '',
|
||||||
email: '',
|
email: '',
|
||||||
address: '',
|
app_role: '',
|
||||||
city: '',
|
disabled: false,
|
||||||
zipCode: '',
|
|
||||||
country: '',
|
|
||||||
avatar: [],
|
avatar: [],
|
||||||
password: ''
|
password: ''
|
||||||
};
|
};
|
||||||
@ -52,131 +53,116 @@ const ProfilePage = () => {
|
|||||||
if (currentUser?.id && typeof currentUser === 'object') {
|
if (currentUser?.id && typeof currentUser === 'object') {
|
||||||
const newInitialVal = { ...initVals };
|
const newInitialVal = { ...initVals };
|
||||||
|
|
||||||
Object.keys(initVals).forEach((el) => {
|
Object.keys(initVals).forEach(
|
||||||
if (currentUser[el] !== undefined && currentUser[el] !== null) {
|
(el) => (newInitialVal[el] = currentUser[el]),
|
||||||
newInitialVal[el] = currentUser[el];
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setInitialValues(newInitialVal);
|
setInitialValues(newInitialVal);
|
||||||
}
|
}
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
const handleSubmit = async (data) => {
|
const handleSubmit = async (data) => {
|
||||||
try {
|
await dispatch(update({ id: currentUser.id, data }));
|
||||||
const payload = { ...data };
|
await dispatch(findMe());
|
||||||
if (!payload.password) {
|
await router.push('/users/users-list');
|
||||||
delete payload.password;
|
notify('success', 'Profile was updated!');
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('My Profile')}</title>
|
<title>{getPageTitle('Edit profile')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton
|
<SectionTitleLineWithButton
|
||||||
icon={mdiAccount}
|
icon={mdiChartTimelineVariant}
|
||||||
title='My Profile'
|
title='Edit profile'
|
||||||
main
|
main
|
||||||
>
|
>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
<CardBox>
|
<CardBox>
|
||||||
<div className="flex flex-col md:flex-row gap-8 mb-8">
|
{currentUser?.avatar[0]?.publicUrl && <div className={'grid grid-cols-6 gap-4 mb-4'}>
|
||||||
<div className="flex-shrink-0">
|
<div className="col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8">
|
||||||
{currentUser?.avatar?.[0]?.publicUrl ? (
|
<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="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">
|
</div>}
|
||||||
<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
|
<Formik
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
>
|
>
|
||||||
<Form>
|
<Form>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<FormField>
|
||||||
<div>
|
<Field
|
||||||
<h3 className="text-lg font-semibold mb-4">Personal Information</h3>
|
label='Avatar'
|
||||||
<FormField>
|
color='info'
|
||||||
<Field
|
icon={mdiUpload}
|
||||||
label='Change Avatar'
|
path={'users/avatar'}
|
||||||
color='info'
|
name='avatar'
|
||||||
icon={mdiUpload}
|
id='avatar'
|
||||||
path={'users/avatar'}
|
schema={{
|
||||||
name='avatar'
|
size: undefined,
|
||||||
id='avatar'
|
formats: undefined,
|
||||||
component={FormImagePicker}
|
}}
|
||||||
/>
|
component={FormImagePicker}
|
||||||
</FormField>
|
></Field>
|
||||||
<FormField label='First Name'>
|
</FormField>
|
||||||
<Field name='firstName' placeholder='First Name' />
|
<FormField label='First Name'>
|
||||||
</FormField>
|
<Field name='firstName' placeholder='First Name' />
|
||||||
<FormField label='Last Name'>
|
</FormField>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
<FormField label='Last Name'>
|
||||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
<Field name='lastName' placeholder='Last Name' />
|
||||||
<BaseIcon path={mdiMapMarker} className="mr-2" /> Shipping Address
|
</FormField>
|
||||||
</h3>
|
|
||||||
<FormField label='Address'>
|
<FormField label='Phone Number'>
|
||||||
<Field name='address' placeholder='Street Address' />
|
<Field name='phoneNumber' placeholder='Phone Number' />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label='City'>
|
|
||||||
<Field name='city' placeholder='City' />
|
<FormField label='E-Mail'>
|
||||||
</FormField>
|
<Field name='email' placeholder='E-Mail' disabled />
|
||||||
<FormField label='Zip Code'>
|
</FormField>
|
||||||
<Field name='zipCode' placeholder='Zip Code' />
|
|
||||||
</FormField>
|
<FormField label='App Role' labelFor='app_role'>
|
||||||
<FormField label='Country'>
|
<Field
|
||||||
<Field name='country' placeholder='Country' />
|
name='app_role'
|
||||||
</FormField>
|
id='app_role'
|
||||||
</div>
|
component={SelectField}
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<BaseDivider />
|
<BaseDivider />
|
||||||
|
|
||||||
<BaseButtons>
|
<BaseButtons>
|
||||||
<BaseButton type='submit' color='info' label='Update Profile' />
|
<BaseButton type='submit' color='info' label='Submit' />
|
||||||
<BaseButton type='reset' color='info' outline label='Reset' />
|
<BaseButton type='reset' color='info' outline label='Reset' />
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
type='reset'
|
||||||
color='danger'
|
color='danger'
|
||||||
outline
|
outline
|
||||||
label='Back to Dashboard'
|
label='Cancel'
|
||||||
onClick={() => router.push('/dashboard')}
|
onClick={() => router.push('/users/users-list')}
|
||||||
/>
|
/>
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
</Form>
|
</Form>
|
||||||
@ -187,8 +173,8 @@ const ProfilePage = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ProfilePage.getLayout = function getLayout(page: ReactElement) {
|
EditUsers.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProfilePage;
|
export default EditUsers;
|
||||||
|
|||||||
@ -1,158 +0,0 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { mdiStorePlus, mdiCheckCircle, mdiClockOutline, mdiAlertCircle } from '@mdi/js';
|
|
||||||
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 FormField from '../../components/FormField';
|
|
||||||
import BaseButton from '../../components/BaseButton';
|
|
||||||
import BaseIcon from '../../components/BaseIcon';
|
|
||||||
import { useAppSelector } from '../../stores/hooks';
|
|
||||||
|
|
||||||
const SellerApply = () => {
|
|
||||||
const [shopName, setShopName] = useState('');
|
|
||||||
const [shopDescription, setShopDescription] = useState('');
|
|
||||||
const [status, setStatus] = useState<'none' | 'pending' | 'approved' | 'rejected'>('none');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStatus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get('/seller/status');
|
|
||||||
setStatus(response.data.status || 'none');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching seller status:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await axios.post('/seller/apply', { shopName, shopDescription });
|
|
||||||
setStatus('pending');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error applying for seller:', error);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <div className="p-8 text-center font-bold">Loading...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{getPageTitle('Become a Seller')}</title>
|
|
||||||
</Head>
|
|
||||||
<SectionMain>
|
|
||||||
<SectionTitleLineWithButton icon={mdiStorePlus} title="Marketplace Seller Application" main>
|
|
||||||
{''}
|
|
||||||
</SectionTitleLineWithButton>
|
|
||||||
|
|
||||||
<div className="max-w-2xl mx-auto">
|
|
||||||
{status === 'pending' && (
|
|
||||||
<CardBox className="border-2 border-yellow-400 bg-yellow-50 mb-6">
|
|
||||||
<div className="flex items-center text-yellow-700 p-4">
|
|
||||||
<BaseIcon path={mdiClockOutline} size={48} className="mr-6 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold mb-1">Application Pending</h3>
|
|
||||||
<p>Our team is reviewing your application. You'll be notified once approved.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'approved' && (
|
|
||||||
<CardBox className="border-2 border-green-400 bg-green-50 mb-6">
|
|
||||||
<div className="flex items-center text-green-700 p-4">
|
|
||||||
<BaseIcon path={mdiCheckCircle} size={48} className="mr-6 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold mb-1">Application Approved!</h3>
|
|
||||||
<p>Welcome to the Marketplace. You can now start listing your products.</p>
|
|
||||||
<BaseButton
|
|
||||||
className="mt-4"
|
|
||||||
color="success"
|
|
||||||
label="Go to Seller Portal"
|
|
||||||
href="/seller/portal"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'rejected' && (
|
|
||||||
<CardBox className="border-2 border-red-400 bg-red-50 mb-6">
|
|
||||||
<div className="flex items-center text-red-700 p-4">
|
|
||||||
<BaseIcon path={mdiAlertCircle} size={48} className="mr-6 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold mb-1">Application Rejected</h3>
|
|
||||||
<p>Unfortunately, your application was not approved at this time. You can try applying again later.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'none' && (
|
|
||||||
<CardBox isForm onSubmit={handleSubmit}>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Open your shop today! Fill out the details below to start selling your products on our platform.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<FormField label="Shop Name" help="Enter a unique name for your storefront">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={shopName}
|
|
||||||
onChange={(e) => setShopName(e.target.value)}
|
|
||||||
placeholder="e.g. Awesome Gadgets Hub"
|
|
||||||
required
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Shop Description" help="Tell us what you plan to sell (min 20 characters)">
|
|
||||||
<textarea
|
|
||||||
value={shopDescription}
|
|
||||||
onChange={(e) => setShopDescription(e.target.value)}
|
|
||||||
placeholder="Provide a brief overview of your business..."
|
|
||||||
required
|
|
||||||
rows={4}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<div className="pt-4 border-t">
|
|
||||||
<BaseButton
|
|
||||||
type="submit"
|
|
||||||
color="info"
|
|
||||||
label={submitting ? "Submitting..." : "Submit Application"}
|
|
||||||
disabled={submitting || shopDescription.length < 20}
|
|
||||||
className="w-full py-4 font-bold text-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
SellerApply.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SellerApply;
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { mdiStore, mdiPlus, mdiFormatListBulleted, mdiTrendingUp, mdiPackageVariant } from '@mdi/js';
|
|
||||||
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 BaseButton from '../../components/BaseButton';
|
|
||||||
import BaseIcon from '../../components/BaseIcon';
|
|
||||||
import { useAppSelector } from '../../stores/hooks';
|
|
||||||
|
|
||||||
const SellerPortal = () => {
|
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
|
||||||
const [products, setProducts] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchMyProducts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchMyProducts = async () => {
|
|
||||||
try {
|
|
||||||
// Fetch products where sellerId matches current user
|
|
||||||
// Note: Backend might need to support filtering by sellerId in /products
|
|
||||||
const response = await axios.get('/products', {
|
|
||||||
params: { sellerId: currentUser.id }
|
|
||||||
});
|
|
||||||
setProducts(response.data.rows || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching seller products:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{getPageTitle('Seller Portal')}</title>
|
|
||||||
</Head>
|
|
||||||
<SectionMain>
|
|
||||||
<SectionTitleLineWithButton icon={mdiStore} title={`Seller Portal: ${currentUser?.shopName || 'Your Shop'}`} main>
|
|
||||||
<BaseButton
|
|
||||||
href="/products/products-new"
|
|
||||||
icon={mdiPlus}
|
|
||||||
label="Add Product"
|
|
||||||
color="info"
|
|
||||||
className="rounded-2xl"
|
|
||||||
/>
|
|
||||||
</SectionTitleLineWithButton>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
|
||||||
<CardBox className="bg-blue-600 text-white border-none shadow-blue-200 shadow-xl">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="bg-white/20 p-4 rounded-2xl mr-6">
|
|
||||||
<BaseIcon path={mdiPackageVariant} size={32} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-blue-100 font-bold uppercase tracking-widest text-xs mb-1">Total Products</p>
|
|
||||||
<h3 className="text-3xl font-black">{products.length}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
<CardBox className="bg-white border-gray-100">
|
|
||||||
<div className="flex items-center text-gray-900">
|
|
||||||
<div className="bg-gray-50 p-4 rounded-2xl mr-6 text-green-600">
|
|
||||||
<BaseIcon path={mdiTrendingUp} size={32} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-400 font-bold uppercase tracking-widest text-xs mb-1">Active Sales</p>
|
|
||||||
<h3 className="text-3xl font-black">0</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
<CardBox className="bg-white border-gray-100">
|
|
||||||
<div className="flex items-center text-gray-900">
|
|
||||||
<div className="bg-gray-50 p-4 rounded-2xl mr-6 text-blue-600">
|
|
||||||
<BaseIcon path={mdiFormatListBulleted} size={32} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-400 font-bold uppercase tracking-widest text-xs mb-1">Manage</p>
|
|
||||||
<h3 className="text-xl font-bold">Listings</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardBox className="border-gray-100">
|
|
||||||
<h2 className="text-xl font-black mb-8 flex items-center">
|
|
||||||
<BaseIcon path={mdiFormatListBulleted} size={24} className="mr-3 text-blue-600" />
|
|
||||||
Your Active Listings
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-center py-12 text-gray-400 font-bold">Loading your products...</p>
|
|
||||||
) : products.length > 0 ? (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-gray-400 uppercase text-xs font-black tracking-widest border-b border-gray-50">
|
|
||||||
<th className="px-4 py-4">Product</th>
|
|
||||||
<th className="px-4 py-4">Price</th>
|
|
||||||
<th className="px-4 py-4">Stock</th>
|
|
||||||
<th className="px-4 py-4">Status</th>
|
|
||||||
<th className="px-4 py-4 text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{products.map((p: any) => (
|
|
||||||
<tr key={p.id} className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors group">
|
|
||||||
<td className="px-4 py-6">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-gray-100 mr-4 overflow-hidden">
|
|
||||||
<img src={p.images?.[0]?.url || 'https://via.placeholder.com/100'} className="w-full h-full object-cover" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-bold text-gray-900 group-hover:text-blue-600 transition-colors">{p.title}</p>
|
|
||||||
<p className="text-xs text-gray-400 font-bold uppercase">{p.sku}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-6 font-bold text-gray-900">${p.price}</td>
|
|
||||||
<td className="px-4 py-6">
|
|
||||||
<span className={`px-3 py-1 rounded-lg text-xs font-black ${p.stock > 10 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
|
||||||
{p.stock} units
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-6">
|
|
||||||
<span className={`px-3 py-1 rounded-lg text-xs font-black ${p.active ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'}`}>
|
|
||||||
{p.active ? 'ACTIVE' : 'INACTIVE'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-6 text-right">
|
|
||||||
<BaseButton
|
|
||||||
color="info"
|
|
||||||
label="Edit"
|
|
||||||
small
|
|
||||||
className="rounded-xl px-4"
|
|
||||||
href={`/products/products-edit?id=${p.id}`}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-20 bg-gray-50 rounded-[2rem] border-2 border-dashed border-gray-100">
|
|
||||||
<BaseIcon path={mdiPackageVariant} size={48} className="mx-auto mb-4 text-gray-300" />
|
|
||||||
<p className="text-gray-500 font-bold text-lg mb-6">You haven't added any products yet.</p>
|
|
||||||
<BaseButton
|
|
||||||
href="/products/products-new"
|
|
||||||
icon={mdiPlus}
|
|
||||||
label="Create Your First Listing"
|
|
||||||
color="info"
|
|
||||||
className="rounded-2xl px-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
SellerPortal.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SellerPortal;
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
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>;
|
|
||||||
};
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
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,11 +1,8 @@
|
|||||||
|
|
||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import styleReducer from './styleSlice';
|
import styleReducer from './styleSlice';
|
||||||
import mainReducer from './mainSlice';
|
import mainReducer from './mainSlice';
|
||||||
import authSlice from './authSlice';
|
import authSlice from './authSlice';
|
||||||
import openAiSlice from './openAiSlice';
|
import openAiSlice from './openAiSlice';
|
||||||
import shoppingCartReducer from './shoppingCartSlice';
|
|
||||||
import wishlistReducer from './wishlistSlice';
|
|
||||||
|
|
||||||
import usersSlice from "./users/usersSlice";
|
import usersSlice from "./users/usersSlice";
|
||||||
import rolesSlice from "./roles/rolesSlice";
|
import rolesSlice from "./roles/rolesSlice";
|
||||||
@ -24,8 +21,6 @@ export const store = configureStore({
|
|||||||
main: mainReducer,
|
main: mainReducer,
|
||||||
auth: authSlice,
|
auth: authSlice,
|
||||||
openAi: openAiSlice,
|
openAi: openAiSlice,
|
||||||
shoppingCart: shoppingCartReducer,
|
|
||||||
wishlist: wishlistReducer,
|
|
||||||
|
|
||||||
users: usersSlice,
|
users: usersSlice,
|
||||||
roles: rolesSlice,
|
roles: rolesSlice,
|
||||||
|
|||||||
@ -1,110 +0,0 @@
|
|||||||
|
|
||||||
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
|
|
||||||
1774
frontend/yarn.lock
1774
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user