diff --git a/backend/src/db/api/products.js b/backend/src/db/api/products.js
index 5d29701..f1d2227 100644
--- a/backend/src/db/api/products.js
+++ b/backend/src/db/api/products.js
@@ -68,7 +68,7 @@ module.exports = class ProductsDBApi {
||
null
,
-
+ sellerId: data.sellerId || null,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
@@ -153,7 +153,7 @@ module.exports = class ProductsDBApi {
||
null
,
-
+ sellerId: item.sellerId || null,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
@@ -220,6 +220,7 @@ module.exports = class ProductsDBApi {
if (data.updated_on !== undefined) updatePayload.updated_on = data.updated_on;
+ if (data.sellerId !== undefined) updatePayload.sellerId = data.sellerId;
updatePayload.updatedById = currentUser.id;
@@ -448,6 +449,13 @@ module.exports = class ProductsDBApi {
};
}
+ if (filter.sellerId) {
+ where = {
+ ...where,
+ sellerId: Utils.uuid(filter.sellerId),
+ };
+ }
+
if (filter.title) {
where = {
@@ -705,5 +713,24 @@ 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
+ });
+ }
+
-};
+};
\ No newline at end of file
diff --git a/backend/src/db/db.config.js b/backend/src/db/db.config.js
index 5a2f718..8f37ad8 100644
--- a/backend/src/db/db.config.js
+++ b/backend/src/db/db.config.js
@@ -1,4 +1,4 @@
-
+require('dotenv').config();
module.exports = {
production: {
@@ -12,11 +12,12 @@ module.exports = {
seederStorage: 'sequelize',
},
development: {
- username: 'postgres',
dialect: 'postgres',
- password: '',
- database: 'db_app_draft',
- host: process.env.DB_HOST || 'localhost',
+ username: process.env.DB_USER,
+ password: process.env.DB_PASS,
+ database: process.env.DB_NAME,
+ host: process.env.DB_HOST,
+ port: process.env.DB_PORT,
logging: console.log,
seederStorage: 'sequelize',
},
@@ -30,4 +31,4 @@ module.exports = {
logging: console.log,
seederStorage: 'sequelize',
}
-};
+};
\ No newline at end of file
diff --git a/backend/src/db/migrations/1769795556.js b/backend/src/db/migrations/1769795556.js
new file mode 100644
index 0000000..2843627
--- /dev/null
+++ b/backend/src/db/migrations/1769795556.js
@@ -0,0 +1,16 @@
+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');
+ }
+};
\ No newline at end of file
diff --git a/backend/src/db/migrations/1769796000.js b/backend/src/db/migrations/1769796000.js
new file mode 100644
index 0000000..a6838cb
--- /dev/null
+++ b/backend/src/db/migrations/1769796000.js
@@ -0,0 +1,56 @@
+'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');
+ },
+};
diff --git a/backend/src/db/migrations/1769796284.js b/backend/src/db/migrations/1769796284.js
new file mode 100644
index 0000000..af9767e
--- /dev/null
+++ b/backend/src/db/migrations/1769796284.js
@@ -0,0 +1,61 @@
+'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');
+ },
+};
diff --git a/backend/src/db/migrations/1769797000.js b/backend/src/db/migrations/1769797000.js
new file mode 100644
index 0000000..bca4f0c
--- /dev/null
+++ b/backend/src/db/migrations/1769797000.js
@@ -0,0 +1,81 @@
+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
+ },
+};
\ No newline at end of file
diff --git a/backend/src/db/models/discount_codes.js b/backend/src/db/models/discount_codes.js
new file mode 100644
index 0000000..466c291
--- /dev/null
+++ b/backend/src/db/models/discount_codes.js
@@ -0,0 +1,51 @@
+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;
+};
\ No newline at end of file
diff --git a/backend/src/db/models/page_views.js b/backend/src/db/models/page_views.js
new file mode 100644
index 0000000..7d93cde
--- /dev/null
+++ b/backend/src/db/models/page_views.js
@@ -0,0 +1,42 @@
+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;
+};
diff --git a/backend/src/db/models/products.js b/backend/src/db/models/products.js
index 0fbccd4..4d47663 100644
--- a/backend/src/db/models/products.js
+++ b/backend/src/db/models/products.js
@@ -1,108 +1,119 @@
-const config = require('../../config');
-const providers = config.providers;
-const crypto = require('crypto');
-const bcrypt = require('bcrypt');
-const moment = require('moment');
-
-module.exports = function(sequelize, DataTypes) {
- const products = sequelize.define(
- 'products',
- {
- id: {
- type: DataTypes.UUID,
- defaultValue: DataTypes.UUIDV4,
- primaryKey: true,
- },
- title: {
- type: DataTypes.TEXT,
- },
- slug: {
- type: DataTypes.TEXT,
- },
- description: {
- type: DataTypes.TEXT,
- },
- price: {
- type: DataTypes.DECIMAL,
- },
- sku: {
- type: DataTypes.TEXT,
- },
- stock: {
- type: DataTypes.INTEGER,
- },
- active: {
- type: DataTypes.BOOLEAN,
- allowNull: false,
- defaultValue: false,
- },
- created_on: {
- type: DataTypes.DATE,
- },
- updated_on: {
- type: DataTypes.DATE,
- },
- importHash: {
- type: DataTypes.STRING(255),
- allowNull: true,
- unique: true,
- },
- },
- {
- timestamps: true,
- paranoid: true,
- freezeTableName: true,
- },
- );
-
- products.associate = (db) => {
- db.products.hasMany(db.reviews, {
- as: 'reviews',
- foreignKey: 'productId',
- });
-
- db.products.hasMany(db.order_items, {
- as: 'order_items_product',
- foreignKey: {
- name: 'productId',
- },
- constraints: false,
- });
-
- db.products.hasMany(db.cart_items, {
- as: 'cart_items_product',
- foreignKey: {
- name: 'productId',
- },
- constraints: false,
- });
-
- db.products.belongsTo(db.categories, {
- as: 'category',
- foreignKey: {
- name: 'categoryId',
- },
- constraints: false,
- });
-
- db.products.hasMany(db.file, {
- as: 'images',
- foreignKey: 'belongsToId',
- constraints: false,
- scope: {
- belongsTo: db.products.getTableName(),
- belongsToColumn: 'images',
- },
+const config = require('../../config');
+const providers = config.providers;
+const crypto = require('crypto');
+const bcrypt = require('bcrypt');
+const moment = require('moment');
+
+module.exports = function(sequelize, DataTypes) {
+ const products = sequelize.define(
+ 'products',
+ {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ title: {
+ type: DataTypes.TEXT,
+ },
+ slug: {
+ type: DataTypes.TEXT,
+ },
+ description: {
+ type: DataTypes.TEXT,
+ },
+ price: {
+ type: DataTypes.DECIMAL,
+ },
+ sale_price: {
+ type: DataTypes.DECIMAL,
+ },
+ sale_ends_at: {
+ type: DataTypes.DATE,
+ },
+ sku: {
+ type: DataTypes.TEXT,
+ },
+ stock: {
+ type: DataTypes.INTEGER,
+ },
+ active: {
+ type: DataTypes.BOOLEAN,
+ allowNull: false,
+ defaultValue: false,
+ },
+ created_on: {
+ type: DataTypes.DATE,
+ },
+ updated_on: {
+ type: DataTypes.DATE,
+ },
+ importHash: {
+ type: DataTypes.STRING(255),
+ allowNull: true,
+ unique: true,
+ },
+ },
+ {
+ timestamps: true,
+ paranoid: true,
+ freezeTableName: true,
+ },
+ );
+
+ products.associate = (db) => {
+ db.products.hasMany(db.reviews, {
+ as: 'reviews',
+ foreignKey: 'productId',
+ });
+
+ db.products.hasMany(db.order_items, {
+ as: 'order_items_product',
+ foreignKey: {
+ name: 'productId',
+ },
+ constraints: false,
+ });
+
+ db.products.hasMany(db.cart_items, {
+ as: 'cart_items_product',
+ foreignKey: {
+ name: 'productId',
+ },
+ constraints: false,
+ });
+
+ db.products.belongsTo(db.categories, {
+ as: 'category',
+ foreignKey: {
+ name: 'categoryId',
+ },
+ constraints: false,
});
db.products.belongsTo(db.users, {
- as: 'createdBy',
- });
-
- db.products.belongsTo(db.users, {
- as: 'updatedBy',
- });
- };
-
- return products;
+ as: 'seller',
+ foreignKey: 'sellerId',
+ });
+
+ db.products.hasMany(db.file, {
+ as: 'images',
+ foreignKey: 'belongsToId',
+ constraints: false,
+ scope: {
+ belongsTo: db.products.getTableName(),
+ belongsToColumn: 'images',
+ },
+ });
+
+ db.products.belongsTo(db.users, {
+ as: 'createdBy',
+ });
+
+ db.products.belongsTo(db.users, {
+ as: 'updatedBy',
+ });
+ };
+
+ return products;
};
\ No newline at end of file
diff --git a/backend/src/db/models/users.js b/backend/src/db/models/users.js
index 047f27d..4b4acc8 100644
--- a/backend/src/db/models/users.js
+++ b/backend/src/db/models/users.js
@@ -1,198 +1,213 @@
-const config = require('../../config');
-const providers = config.providers;
-const crypto = require('crypto');
-const bcrypt = require('bcrypt');
-const moment = require('moment');
-
-module.exports = function(sequelize, DataTypes) {
- const users = sequelize.define(
- 'users',
- {
- id: {
- type: DataTypes.UUID,
- defaultValue: DataTypes.UUIDV4,
- primaryKey: true,
+const config = require('../../config');
+const providers = config.providers;
+const crypto = require('crypto');
+const bcrypt = require('bcrypt');
+const moment = require('moment');
+
+module.exports = function(sequelize, DataTypes) {
+ const users = sequelize.define(
+ 'users',
+ {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+
+firstName: {
+ type: DataTypes.TEXT,
+ },
+
+lastName: {
+ type: DataTypes.TEXT,
+ },
+
+phoneNumber: {
+ type: DataTypes.TEXT,
+ },
+
+email: {
+ type: DataTypes.TEXT,
+ },
+
+disabled: {
+ type: DataTypes.BOOLEAN,
+ allowNull: false,
+ defaultValue: false,
+ },
+
+password: {
+ type: DataTypes.TEXT,
+ },
+
+emailVerified: {
+ type: DataTypes.BOOLEAN,
+ allowNull: false,
+ defaultValue: false,
+ },
+
+emailVerificationToken: {
+ type: DataTypes.TEXT,
+ },
+
+emailVerificationTokenExpiresAt: {
+ type: DataTypes.DATE,
+ },
+
+passwordResetToken: {
+ type: DataTypes.TEXT,
+ },
+
+passwordResetTokenExpiresAt: {
+ type: DataTypes.DATE,
+ },
+
+provider: {
+ type: DataTypes.TEXT,
+ },
+
+ importHash: {
+ type: DataTypes.STRING(255),
+ allowNull: true,
+ unique: true,
+ },
+
+ address: {
+ type: DataTypes.TEXT,
+ },
+
+ city: {
+ type: DataTypes.TEXT,
+ },
+
+ zipCode: {
+ type: DataTypes.TEXT,
+ },
+
+ country: {
+ type: DataTypes.TEXT,
},
-
-firstName: {
+ shopName: {
type: DataTypes.TEXT,
},
-
-lastName: {
+ shopDescription: {
type: DataTypes.TEXT,
},
-
-phoneNumber: {
+ sellerStatus: {
type: DataTypes.TEXT,
- },
-
-email: {
- type: DataTypes.TEXT,
- },
-
-disabled: {
- type: DataTypes.BOOLEAN,
- allowNull: false,
- defaultValue: false,
- },
-
-password: {
- type: DataTypes.TEXT,
- },
-
-emailVerified: {
- type: DataTypes.BOOLEAN,
- allowNull: false,
- defaultValue: false,
- },
-
-emailVerificationToken: {
- type: DataTypes.TEXT,
- },
-
-emailVerificationTokenExpiresAt: {
- type: DataTypes.DATE,
- },
-
-passwordResetToken: {
- type: DataTypes.TEXT,
- },
-
-passwordResetTokenExpiresAt: {
- type: DataTypes.DATE,
- },
-
-provider: {
- type: DataTypes.TEXT,
- },
-
- importHash: {
- type: DataTypes.STRING(255),
- allowNull: true,
- unique: true,
- },
-
- address: {
- type: DataTypes.TEXT,
- },
-
- city: {
- type: DataTypes.TEXT,
- },
-
- zipCode: {
- type: DataTypes.TEXT,
- },
-
- country: {
- type: DataTypes.TEXT,
- },
- },
- {
- timestamps: true,
- paranoid: true,
- freezeTableName: true,
- },
- );
-
- users.associate = (db) => {
- db.users.belongsToMany(db.permissions, {
- as: 'custom_permissions',
- foreignKey: {
- name: 'users_custom_permissionsId',
- },
- constraints: false,
- through: 'usersCustom_permissionsPermissions',
+ defaultValue: 'none',
+ },
+ },
+ {
+ timestamps: true,
+ paranoid: true,
+ freezeTableName: true,
+ },
+ );
+
+ users.associate = (db) => {
+ db.users.belongsToMany(db.permissions, {
+ as: 'custom_permissions',
+ foreignKey: {
+ name: 'users_custom_permissionsId',
+ },
+ constraints: false,
+ through: 'usersCustom_permissionsPermissions',
+ });
+
+ db.users.belongsToMany(db.permissions, {
+ as: 'custom_permissions_filter',
+ foreignKey: {
+ name: 'users_custom_permissionsId',
+ },
+ constraints: false,
+ through: 'usersCustom_permissionsPermissions',
+ });
+
+ db.users.hasMany(db.reviews, {
+ as: 'reviews',
+ foreignKey: 'userId',
+ });
+
+ db.users.hasMany(db.orders, {
+ as: 'orders_user',
+ foreignKey: {
+ name: 'userId',
+ },
+ constraints: false,
+ });
+
+ db.users.hasMany(db.carts, {
+ as: 'carts_user',
+ foreignKey: {
+ name: 'userId',
+ },
+ constraints: false,
+ });
+
+ db.users.belongsTo(db.roles, {
+ as: 'app_role',
+ foreignKey: {
+ name: 'app_roleId',
+ },
+ constraints: false,
+ });
+
+ db.users.hasMany(db.file, {
+ as: 'avatar',
+ foreignKey: 'belongsToId',
+ constraints: false,
+ scope: {
+ belongsTo: db.users.getTableName(),
+ belongsToColumn: 'avatar',
+ },
});
- db.users.belongsToMany(db.permissions, {
- as: 'custom_permissions_filter',
- foreignKey: {
- name: 'users_custom_permissionsId',
- },
- constraints: false,
- through: 'usersCustom_permissionsPermissions',
- });
-
- db.users.hasMany(db.reviews, {
- as: 'reviews',
- foreignKey: 'userId',
- });
-
- db.users.hasMany(db.orders, {
- as: 'orders_user',
- foreignKey: {
- name: 'userId',
- },
- constraints: false,
- });
-
- db.users.hasMany(db.carts, {
- as: 'carts_user',
- foreignKey: {
- name: 'userId',
- },
- constraints: false,
- });
-
- db.users.belongsTo(db.roles, {
- as: 'app_role',
- foreignKey: {
- name: 'app_roleId',
- },
- constraints: false,
- });
-
- db.users.hasMany(db.file, {
- as: 'avatar',
- foreignKey: 'belongsToId',
- constraints: false,
- scope: {
- belongsTo: db.users.getTableName(),
- belongsToColumn: 'avatar',
- },
- });
-
- db.users.belongsTo(db.users, {
- as: 'createdBy',
- });
-
- db.users.belongsTo(db.users, {
- as: 'updatedBy',
- });
- };
-
- users.beforeCreate((users, options) => {
- users = trimStringFields(users);
-
- if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
- users.emailVerified = true;
-
- if (!users.password) {
- const password = crypto
- .randomBytes(20)
- .toString('hex');
-
- const hashedPassword = bcrypt.hashSync(
- password,
- config.bcrypt.saltRounds,
- );
-
- users.password = hashedPassword
- }
- }
- });
-
- users.beforeUpdate((users, options) => {
- users = trimStringFields(users);
- });
-
- return users;
-};
-
-function trimStringFields(users) {
- users.email = users.email.trim();
- users.firstName = users.firstName ? users.firstName.trim() : null;
- users.lastName = users.lastName ? users.lastName.trim() : null;
- return users;
+ db.users.hasMany(db.products, {
+ as: 'seller_products',
+ foreignKey: 'sellerId',
+ });
+
+ db.users.belongsTo(db.users, {
+ as: 'createdBy',
+ });
+
+ db.users.belongsTo(db.users, {
+ as: 'updatedBy',
+ });
+ };
+
+ users.beforeCreate((users, options) => {
+ users = trimStringFields(users);
+
+ if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
+ users.emailVerified = true;
+
+ if (!users.password) {
+ const password = crypto
+ .randomBytes(20)
+ .toString('hex');
+
+ const hashedPassword = bcrypt.hashSync(
+ password,
+ config.bcrypt.saltRounds,
+ );
+
+ users.password = hashedPassword
+ }
+ }
+ });
+
+ users.beforeUpdate((users, options) => {
+ users = trimStringFields(users);
+ });
+
+ return users;
+};
+
+function trimStringFields(users) {
+ users.email = users.email.trim();
+ users.firstName = users.firstName ? users.firstName.trim() : null;
+ users.lastName = users.lastName ? users.lastName.trim() : null;
+ return users;
}
\ No newline at end of file
diff --git a/backend/src/db/seeders/1769795566.js b/backend/src/db/seeders/1769795566.js
new file mode 100644
index 0000000..ff681b8
--- /dev/null
+++ b/backend/src/db/seeders/1769795566.js
@@ -0,0 +1,24 @@
+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;`
+ );
+ }
+};
\ No newline at end of file
diff --git a/backend/src/db/seeders/1769796010.js b/backend/src/db/seeders/1769796010.js
new file mode 100644
index 0000000..7b4fa71
--- /dev/null
+++ b/backend/src/db/seeders/1769796010.js
@@ -0,0 +1,36 @@
+'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, {});
+ }
+};
diff --git a/backend/src/index.js b/backend/src/index.js
index 7ee6dd8..19e8751 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -32,6 +32,7 @@ const wishlistsRoutes = require('./routes/wishlists');
const categoriesRoutes = require('./routes/categories');
+const aiRecommendationsRoutes = require('./routes/aiRecommendations');
const ordersRoutes = require('./routes/orders');
const order_itemsRoutes = require('./routes/order_items');
@@ -42,6 +43,8 @@ const cart_itemsRoutes = require('./routes/cart_items');
const paymentsRoutes = require('./routes/payments');
const checkoutRoutes = require('./routes/checkout');
+const analyticsRoutes = require('./routes/analytics');
+const sellerRoutes = require('./routes/seller');
const getBaseUrl = (url) => {
@@ -124,6 +127,9 @@ app.use('/api/cart_items', passport.authenticate('jwt', {session: false}), cart_
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(
'/api/openai',
diff --git a/backend/src/routes/aiRecommendations.js b/backend/src/routes/aiRecommendations.js
new file mode 100644
index 0000000..68e395f
--- /dev/null
+++ b/backend/src/routes/aiRecommendations.js
@@ -0,0 +1,23 @@
+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;
\ No newline at end of file
diff --git a/backend/src/routes/analytics.js b/backend/src/routes/analytics.js
new file mode 100644
index 0000000..80772fb
--- /dev/null
+++ b/backend/src/routes/analytics.js
@@ -0,0 +1,34 @@
+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;
diff --git a/backend/src/routes/carts.js b/backend/src/routes/carts.js
index 39ee99b..f85dbd2 100644
--- a/backend/src/routes/carts.js
+++ b/backend/src/routes/carts.js
@@ -1,10 +1,9 @@
-
const express = require('express');
const CartsService = require('../services/carts');
const CartsDBApi = require('../db/api/carts');
const wrapAsync = require('../helpers').wrapAsync;
-
+const AbandonedCartService = require('../services/notifications/abandonedCart');
const router = express.Router();
@@ -15,6 +14,11 @@ const {
checkCrudPermissions,
} = 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'));
@@ -426,4 +430,4 @@ router.get('/:id', wrapAsync(async (req, res) => {
router.use('/', require('../helpers').commonErrorHandler);
-module.exports = router;
+module.exports = router;
\ No newline at end of file
diff --git a/backend/src/routes/checkout.js b/backend/src/routes/checkout.js
index f4b97e9..54053d1 100644
--- a/backend/src/routes/checkout.js
+++ b/backend/src/routes/checkout.js
@@ -3,34 +3,99 @@ 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 } = req.body;
+ 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' });
}
- const lineItems = items.map(item => ({
- price_data: {
- currency: 'usd',
- product_data: {
- name: item.title,
- images: item.image ? [item.image] : [],
+ 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)),
},
- unit_amount: Math.round(item.price * 100),
- },
- quantity: item.quantity,
- }));
+ quantity: item.quantity,
+ };
+ });
const session = await stripeClient.checkout.sessions.create({
payment_method_types: ['card'],
@@ -41,6 +106,8 @@ router.post('/create-session', wrapAsync(async (req, res) => {
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,
@@ -88,7 +155,7 @@ router.post('/webhook', async (req, res) => {
userId: userId !== 'guest' ? userId : null,
}, { transaction });
- // Create Order Items
+ // Create Order Items and update stock
for (const item of items) {
await db.order_items.create({
orderId: order.id,
@@ -98,6 +165,18 @@ router.post('/webhook', async (req, res) => {
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
@@ -111,7 +190,7 @@ router.post('/webhook', async (req, res) => {
}, { transaction });
await transaction.commit();
- console.log('Order fulfilled successfully');
+ console.log('Order fulfilled successfully and stock updated');
} catch (error) {
await transaction.rollback();
console.error('Order fulfillment failed:', error);
@@ -121,4 +200,4 @@ router.post('/webhook', async (req, res) => {
res.json({ received: true });
});
-module.exports = router;
+module.exports = router;
\ No newline at end of file
diff --git a/backend/src/routes/products.js b/backend/src/routes/products.js
index 13b4acb..b37cb03 100644
--- a/backend/src/routes/products.js
+++ b/backend/src/routes/products.js
@@ -1,4 +1,3 @@
-
const express = require('express');
const ProductsService = require('../services/products');
@@ -15,6 +14,11 @@ const {
checkCrudPermissions,
} = 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'));
@@ -441,4 +445,4 @@ router.get('/:id', wrapAsync(async (req, res) => {
router.use('/', require('../helpers').commonErrorHandler);
-module.exports = router;
+module.exports = router;
\ No newline at end of file
diff --git a/backend/src/routes/search.js b/backend/src/routes/search.js
index 164b376..6a4fb26 100644
--- a/backend/src/routes/search.js
+++ b/backend/src/routes/search.js
@@ -1,37 +1,23 @@
const express = require('express');
const SearchService = require('../services/search');
-
+const passport = require('passport');
const router = express.Router();
-const { checkCrudPermissions } = require('../middlewares/check-permissions');
-router.use(checkCrudPermissions('search'));
-
-/**
- * @swagger
- * path:
- * /api/search:
- * post:
- * summary: Search
- * description: Search results across multiple tables
- * requestBody:
- * content:
- * 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.get('/autocomplete', async (req, res) => {
+ const { query } = req.query;
+
+ // Try to get user if token present, but don't require it
+ passport.authenticate('jwt', { session: false }, async (err, user) => {
+ try {
+ const results = await SearchService.autocomplete(query, user || null);
+ res.json(results);
+ } catch (error) {
+ console.error('Autocomplete API Error:', error);
+ res.status(500).json({ error: 'Internal Server Error' });
+ }
+ })(req, res);
+});
router.post('/', async (req, res) => {
const { searchQuery } = req.body;
@@ -40,13 +26,15 @@ router.post('/', async (req, res) => {
return res.status(400).json({ error: 'Please enter a search query' });
}
- try {
- const foundMatches = await SearchService.search(searchQuery, req.currentUser );
- res.json(foundMatches);
- } catch (error) {
- console.error('Internal Server Error', error);
- res.status(500).json({ error: 'Internal Server Error' });
- }
- });
+ passport.authenticate('jwt', { session: false }, async (err, user) => {
+ try {
+ const foundMatches = await SearchService.search(searchQuery, user || null);
+ res.json(foundMatches);
+ } catch (error) {
+ console.error('Search API Error', error);
+ res.status(500).json({ error: 'Internal Server Error' });
+ }
+ })(req, res);
+});
-module.exports = router;
\ No newline at end of file
+module.exports = router;
diff --git a/backend/src/routes/seller.js b/backend/src/routes/seller.js
new file mode 100644
index 0000000..85ce109
--- /dev/null
+++ b/backend/src/routes/seller.js
@@ -0,0 +1,55 @@
+
+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;
diff --git a/backend/src/services/aiRecommendations.js b/backend/src/services/aiRecommendations.js
new file mode 100644
index 0000000..575a309
--- /dev/null
+++ b/backend/src/services/aiRecommendations.js
@@ -0,0 +1,81 @@
+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;
diff --git a/backend/src/services/analytics.js b/backend/src/services/analytics.js
new file mode 100644
index 0000000..4ef2f71
--- /dev/null
+++ b/backend/src/services/analytics.js
@@ -0,0 +1,78 @@
+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
+ };
+ }
+};
\ No newline at end of file
diff --git a/backend/src/services/email/htmlTemplates/abandonedCart/abandonedCartEmail.html b/backend/src/services/email/htmlTemplates/abandonedCart/abandonedCartEmail.html
new file mode 100644
index 0000000..4238ddf
--- /dev/null
+++ b/backend/src/services/email/htmlTemplates/abandonedCart/abandonedCartEmail.html
@@ -0,0 +1,43 @@
+
+
+
+
+ Abandoned Cart
+
+
+
+
+
+
+
Hello {userName},
+
We noticed you left some items in your shopping cart. Don't miss out on these great products!
+
Click below to return to your cart and complete your purchase:
+
+ Return to Cart
+
+
If you have any questions, feel free to reply to this email.
+
Thanks, The {appTitle} team
+
+
+
+
+
diff --git a/backend/src/services/email/htmlTemplates/lowStock/lowStockEmail.html b/backend/src/services/email/htmlTemplates/lowStock/lowStockEmail.html
new file mode 100644
index 0000000..3c0b9ad
--- /dev/null
+++ b/backend/src/services/email/htmlTemplates/lowStock/lowStockEmail.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+ Low Stock Alert
+
+
+
+
+
+
+
Hello Admin,
+
The following product is running low on stock and needs your attention:
+
+
+
{{productTitle}}
+
Current Stock: {{currentStock}}
+
SKU: {{productSku}}
+
+
+
Please restock this item as soon as possible to avoid missed sales.
+
+
+
+
+
+
diff --git a/backend/src/services/email/htmlTemplates/wishlistSale/wishlistSaleEmail.html b/backend/src/services/email/htmlTemplates/wishlistSale/wishlistSaleEmail.html
new file mode 100644
index 0000000..b5037cd
--- /dev/null
+++ b/backend/src/services/email/htmlTemplates/wishlistSale/wishlistSaleEmail.html
@@ -0,0 +1,38 @@
+
+
+
+
+ Great News! An item in your wishlist is on sale!
+
+
+
+
+
+
+
Hi there,
+
An item you've been watching in your wishlist is now on sale at {appTitle} !
+
+
{productTitle}
+
Grab it now for just:
+
{salePrice}
+
View Deal
+
+
+
+
+
+
diff --git a/backend/src/services/email/list/abandonedCart.js b/backend/src/services/email/list/abandonedCart.js
new file mode 100644
index 0000000..b28a39f
--- /dev/null
+++ b/backend/src/services/email/list/abandonedCart.js
@@ -0,0 +1,35 @@
+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;
+ }
+ }
+};
diff --git a/backend/src/services/email/list/wishlistSale.js b/backend/src/services/email/list/wishlistSale.js
new file mode 100644
index 0000000..ce5cdff
--- /dev/null
+++ b/backend/src/services/email/list/wishlistSale.js
@@ -0,0 +1,35 @@
+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;
+ }
+ }
+};
diff --git a/backend/src/services/notifications/abandonedCart.js b/backend/src/services/notifications/abandonedCart.js
new file mode 100644
index 0000000..1de273f
--- /dev/null
+++ b/backend/src/services/notifications/abandonedCart.js
@@ -0,0 +1,91 @@
+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;
diff --git a/backend/src/services/notifications/list.js b/backend/src/services/notifications/list.js
index b40a071..ddfd6b2 100644
--- a/backend/src/services/notifications/list.js
+++ b/backend/src/services/notifications/list.js
@@ -108,6 +108,17 @@ const errors = {
The {2} team
`,
},
+ abandonedCart: {
+ subject: `You left something in your cart!`,
+ body: `
+ Hello {0},
+ We noticed you left some items in your shopping cart. Don't miss out on these great products!
+ Click below to return to your cart and complete your purchase:
+ Return to Cart
+ Thanks,
+ The {2} team
+ `,
+ },
},
};
diff --git a/backend/src/services/notifications/lowStock.js b/backend/src/services/notifications/lowStock.js
new file mode 100644
index 0000000..7f584d5
--- /dev/null
+++ b/backend/src/services/notifications/lowStock.js
@@ -0,0 +1,52 @@
+
+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;
diff --git a/backend/src/services/notifications/wishlistSale.js b/backend/src/services/notifications/wishlistSale.js
new file mode 100644
index 0000000..fe8a1fe
--- /dev/null
+++ b/backend/src/services/notifications/wishlistSale.js
@@ -0,0 +1,51 @@
+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;
\ No newline at end of file
diff --git a/backend/src/services/products.js b/backend/src/services/products.js
index 458d58f..c30ebbd 100644
--- a/backend/src/services/products.js
+++ b/backend/src/services/products.js
@@ -1,3 +1,4 @@
+const WishlistNotificationService = require('./notifications/wishlistSale');
const db = require('../db/models');
const ProductsDBApi = require('../db/api/products');
const processFile = require("../middlewares/upload");
@@ -15,6 +16,13 @@ module.exports = class ProductsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
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(
data,
{
@@ -79,6 +87,11 @@ 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(
id,
data,
@@ -88,6 +101,10 @@ module.exports = class ProductsService {
},
);
+ if (data.sale_price) {
+ WishlistNotificationService.notifySale(id);
+ }
+
await transaction.commit();
return updatedProducts;
@@ -95,44 +112,23 @@ module.exports = class ProductsService {
await transaction.rollback();
throw error;
}
- };
+ }
static async deleteByIds(ids, currentUser) {
- const transaction = await db.sequelize.transaction();
-
- try {
- await ProductsDBApi.deleteByIds(ids, {
- currentUser,
- transaction,
- });
-
- await transaction.commit();
- } catch (error) {
- await transaction.rollback();
- throw error;
- }
+ // Security check: only seller or admin
+ const products = await db.products.findAll({ where: { id: ids } });
+ if (currentUser.app_role?.name !== 'Administrator') {
+ const unauthorized = products.some(p => p.sellerId && p.sellerId !== currentUser.id);
+ if (unauthorized) throw new Error('Forbidden');
+ }
+ return await ProductsDBApi.deleteByIds(ids, { currentUser });
}
static async remove(id, currentUser) {
- const transaction = await db.sequelize.transaction();
-
- try {
- await ProductsDBApi.remove(
- id,
- {
- currentUser,
- transaction,
- },
- );
-
- await transaction.commit();
- } catch (error) {
- await transaction.rollback();
- throw error;
- }
+ const product = await db.products.findByPk(id);
+ if (currentUser.app_role?.name !== 'Administrator' && product.sellerId && product.sellerId !== currentUser.id) {
+ throw new Error('Forbidden');
+ }
+ return await ProductsDBApi.remove(id, { currentUser });
}
-
-
-};
-
-
+};
\ No newline at end of file
diff --git a/backend/src/services/search.js b/backend/src/services/search.js
index e78015c..d53997f 100644
--- a/backend/src/services/search.js
+++ b/backend/src/services/search.js
@@ -9,12 +9,16 @@ const Op = Sequelize.Op;
* @param {object} currentUser
*/
async function checkPermissions(permission, currentUser) {
-
if (!currentUser) {
- throw new ValidationError('auth.unauthorized');
+ // For public storefront search, we might allow certain tables
+ 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,
);
@@ -24,212 +28,83 @@ async function checkPermissions(permission, currentUser) {
try {
if (!currentUser.app_role) {
- throw new ValidationError('auth.forbidden');
+ return false;
}
const permissions = await currentUser.app_role.getPermissions();
return !!permissions.find((p) => p.name === permission);
} catch (e) {
- throw e;
+ return false;
}
}
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 ) {
try {
if (!searchQuery) {
throw new ValidationError('iam.errors.searchQueryRequired');
}
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",
-
- ],
-
-
-
-
-
-
- "cart_items": [
-
- "name",
-
- ],
-
-
-
-
-
-
- "payments": [
-
- "stripe_payment_id",
-
- "currency",
-
- ],
-
-
+ "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"],
+ "cart_items": ["name"],
+ "payments": ["stripe_payment_id", "currency"],
};
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 = [];
@@ -254,8 +129,6 @@ module.exports = class SearchService {
],
};
-
-
const hasPermission = await checkPermissions(`READ_${tableName.toUpperCase()}`, currentUser);
if (!hasPermission) {
continue;
diff --git a/backend/src/services/seller.js b/backend/src/services/seller.js
new file mode 100644
index 0000000..5978c98
--- /dev/null
+++ b/backend/src/services/seller.js
@@ -0,0 +1,48 @@
+
+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;
diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs
index 89767ec..0c817b7 100644
--- a/frontend/next.config.mjs
+++ b/frontend/next.config.mjs
@@ -1,32 +1,31 @@
-/**
- * @type {import('next').NextConfig}
- */
-
-const output = process.env.NODE_ENV === 'production' ? 'export' : 'standalone';
- const nextConfig = {
-trailingSlash: true,
- distDir: 'build',
- output,
- basePath: "",
- devIndicators: {
- position: 'bottom-left',
- },
- typescript: {
- ignoreBuildErrors: true,
- },
- eslint: {
- ignoreDuringBuilds: true,
- },
- images: {
- unoptimized: true,
- remotePatterns: [
- {
- protocol: 'https',
- hostname: '**',
- },
- ],
- },
-
-}
-
+/**
+ * @type {import('next').NextConfig}
+ */
+
+const output = process.env.NODE_ENV === 'production' ? 'export' : 'standalone';
+ const nextConfig = {
+trailingSlash: true,
+ distDir: 'build',
+ output,
+ basePath: "",
+ devIndicators: {
+ position: 'bottom-left',
+ },
+ typescript: {
+ ignoreBuildErrors: true,
+ },
+ eslint: {
+ ignoreDuringBuilds: true,
+ },
+ images: {
+ unoptimized: true,
+ remotePatterns: [
+ {
+ protocol: 'https',
+ hostname: '**',
+ },
+ ],
+ },
+}
+
export default nextConfig
\ No newline at end of file
diff --git a/frontend/src/components/CountdownTimer.tsx b/frontend/src/components/CountdownTimer.tsx
new file mode 100644
index 0000000..2b0003b
--- /dev/null
+++ b/frontend/src/components/CountdownTimer.tsx
@@ -0,0 +1,57 @@
+import React, { useState, useEffect } from 'react';
+
+interface CountdownTimerProps {
+ targetDate: string;
+}
+
+const CountdownTimer: React.FC = ({ 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(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(
+
+ {timeLeft[interval]}
+ {interval.charAt(0)}
+
+ );
+ });
+
+ return (
+
+ Ends in:
+ {timerComponents.length ? timerComponents : Expired! }
+
+ );
+};
+
+export default CountdownTimer;
\ No newline at end of file
diff --git a/frontend/src/components/Products/ProductRecommendations.tsx b/frontend/src/components/Products/ProductRecommendations.tsx
new file mode 100644
index 0000000..33f0feb
--- /dev/null
+++ b/frontend/src/components/Products/ProductRecommendations.tsx
@@ -0,0 +1,72 @@
+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 ;
+ if (!recommendations || recommendations.length === 0) return null;
+
+ return (
+
+
Recommended for You
+
+ {recommendations.map((item: any) => (
+
+
+
+
+
+
+
{item.title}
+
+
+
+ ${formatPrice(item.sale_price || item.price)}
+
+ {item.sale_price && (
+
+ ${formatPrice(item.price)}
+
+ )}
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
+export default ProductRecommendations;
\ No newline at end of file
diff --git a/frontend/src/components/Search.tsx b/frontend/src/components/Search.tsx
index b7beb98..e403714 100644
--- a/frontend/src/components/Search.tsx
+++ b/frontend/src/components/Search.tsx
@@ -1,13 +1,20 @@
-import React from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import { Field, Form, Formik } from 'formik';
import { useRouter } from 'next/router';
import { useAppSelector } from '../stores/hooks';
+import axios from 'axios';
+import Link from 'next/link';
const Search = () => {
const router = useRouter();
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const corners = useAppSelector((state) => state.style.corners);
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
+
+ const [autocompleteResults, setAutocompleteResults] = useState([]);
+ const [showDropdown, setShowDropdown] = useState(false);
+ const dropdownRef = useRef(null);
+
const validateSearch = (value) => {
let error;
if (!value) {
@@ -17,34 +24,108 @@ const Search = () => {
}
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 (
- {
- router.push(`/search?query=${values.search}`);
- resetForm();
- setSubmitting(false);
- }}
- validateOnBlur={false}
- validateOnChange={false}
- >
- {({ errors, touched, values }) => (
-
- )}
-
+
+
{
+ router.push(`/search?query=${values.search}`);
+ resetForm();
+ setSubmitting(false);
+ setShowDropdown(false);
+ }}
+ validateOnBlur={false}
+ validateOnChange={false}
+ >
+ {({ errors, touched, values, setFieldValue, submitForm }) => (
+
+ )}
+
+
);
};
-export default Search;
+export default Search;
\ No newline at end of file
diff --git a/frontend/src/components/Search/SmartSearch.tsx b/frontend/src/components/Search/SmartSearch.tsx
new file mode 100644
index 0000000..c638b21
--- /dev/null
+++ b/frontend/src/components/Search/SmartSearch.tsx
@@ -0,0 +1,119 @@
+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(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 (
+
+
+
+ {isOpen && (results.length > 0 || loading) && (
+
+ {loading ? (
+
Searching...
+ ) : (
+
+ {results.map((item: any) => (
+
setIsOpen(false)}
+ >
+
+
+
+
+
{item.title || item.name}
+
+ {item.type} {item.price ? `• $${Number(item.price).toFixed(2)}` : ''}
+
+
+
+ ))}
+
+ View all results for "{query}"
+
+
+ )}
+
+ )}
+
+ );
+};
+
+export default SmartSearch;
diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts
index 27925f9..5fb72bd 100644
--- a/frontend/src/menuAside.ts
+++ b/frontend/src/menuAside.ts
@@ -12,6 +12,29 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
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,
diff --git a/frontend/src/pages/admin/analytics.tsx b/frontend/src/pages/admin/analytics.tsx
new file mode 100644
index 0000000..e3a8690
--- /dev/null
+++ b/frontend/src/pages/admin/analytics.tsx
@@ -0,0 +1,150 @@
+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(null);
+ const [topProducts, setTopProducts] = useState([]);
+ 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 ;
+
+ return (
+ <>
+
+ {getPageTitle('Advanced Analytics')}
+
+
+
+ {''}
+
+
+
+
+
+
+
+
+
+
Total Page Views
+
{stats?.totalViews || 0}
+
+
+
+
+
+
+
+
+
+
Product Views
+
{stats?.productViews || 0}
+
+
+
+
+
+
+
+
+
+
Category Views
+
{stats?.categoryViews || 0}
+
+
+
+
+
+
+
+
+ {topProducts.map((item, index) => (
+
+
+
#{index + 1}
+
+
+
{item.product?.title}
+
${item.product?.price}
+
+
+
+ {item.viewCount}
+ Views
+
+
+ ))}
+ {topProducts.length === 0 &&
No view data available yet.
}
+
+
+
+
+
+
+ {stats?.dailyViews?.map((day: any) => (
+
+
{new Date(day.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
+
+
d.count)) || 1)) * 100)}%` }}
+ >
+
+
{day.count}
+
+ ))}
+ {(!stats?.dailyViews || stats.dailyViews.length === 0) &&
No trend data available yet.
}
+
+
+
+
+
+ >
+ );
+};
+
+AnalyticsPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
+
+export default AnalyticsPage;
diff --git a/frontend/src/pages/admin/seller-applications.tsx b/frontend/src/pages/admin/seller-applications.tsx
new file mode 100644
index 0000000..c8579f3
--- /dev/null
+++ b/frontend/src/pages/admin/seller-applications.tsx
@@ -0,0 +1,115 @@
+
+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 (
+ <>
+
+ {getPageTitle('Seller Applications')}
+
+
+
+ {''}
+
+
+
+ {loading ? (
+ Loading applications...
+ ) : applications.length > 0 ? (
+
+
+
+
+ User
+ Shop Name
+ Description
+ Actions
+
+
+
+ {applications.map((app: any) => (
+
+
+ {app.firstName} {app.lastName}
+ {app.email}
+
+ {app.shopName}
+ {app.shopDescription}
+
+
+ handleReview(app.id, 'approved')}
+ label="Approve"
+ />
+ handleReview(app.id, 'rejected')}
+ label="Reject"
+ />
+
+
+
+ ))}
+
+
+
+ ) : (
+
+
+
No pending seller applications.
+
+ )}
+
+
+ >
+ );
+};
+
+AdminSellerApplications.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
+
+export default AdminSellerApplications;
diff --git a/frontend/src/pages/cart.tsx b/frontend/src/pages/cart.tsx
index d9f54a8..1fb85f4 100644
--- a/frontend/src/pages/cart.tsx
+++ b/frontend/src/pages/cart.tsx
@@ -6,7 +6,7 @@ import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import { getPageTitle, stripePublishableKey } from '../config';
import LayoutGuest from '../layouts/Guest';
-import { mdiCart, mdiTrashCan, mdiArrowLeft, mdiChevronRight, mdiLock } from '@mdi/js';
+import { 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';
@@ -18,10 +18,42 @@ 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(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;
- const total = subtotal + shipping;
+
+ 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);
@@ -31,6 +63,7 @@ export default function CartPage() {
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`,
});
@@ -130,6 +163,44 @@ export default function CartPage() {
))}
+
+ {/* Promo Code Section */}
+
+
+ Have a promo code?
+
+
+ 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"
+ />
+
+
+ {discountError &&
{discountError}
}
+ {appliedDiscount && (
+
+
+
+ Code {appliedDiscount.code} applied!
+
+
{ setAppliedDiscount(null); setPromoCode(''); }}
+ className="text-green-700 hover:text-green-800 text-sm font-bold underline"
+ >
+ Remove
+
+
+ )}
+
{/* Summary */}
@@ -141,10 +212,19 @@ export default function CartPage() {
Subtotal
${subtotal.toFixed(2)}
+
+ {appliedDiscount && (
+
+ Discount ({appliedDiscount.code})
+ -${discountValue.toFixed(2)}
+
+ )}
+
Shipping
{shipping === 0 ? 'FREE' : `$${shipping.toFixed(2)}`}
+
{shipping > 0 && (
@@ -170,11 +250,6 @@ export default function CartPage() {
Encrypted & Secure
-
@@ -186,4 +261,4 @@ export default function CartPage() {
CartPage.getLayout = function getLayout(page: ReactElement) {
return {page} ;
-};
+};
\ No newline at end of file
diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx
index 9012439..c9bd9b6 100644
--- a/frontend/src/pages/dashboard.tsx
+++ b/frontend/src/pages/dashboard.tsx
@@ -1,6 +1,6 @@
import * as icon from '@mdi/js';
import Head from 'next/head'
-import React from 'react'
+import React, { useEffect } from 'react'
import axios from 'axios';
import type { ReactElement } from 'react'
import LayoutAuthenticated from '../layouts/Authenticated'
@@ -9,13 +9,11 @@ import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton
import BaseIcon from "../components/BaseIcon";
import { getPageTitle } from '../config'
import Link from "next/link";
+import CardBox from '../components/CardBox';
import { hasPermission } from "../helpers/userPermissions";
-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 dispatch = useAppDispatch();
const iconsColor = useAppSelector((state) => state.style.iconsColor);
@@ -26,124 +24,56 @@ const Dashboard = () => {
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 [categories, setCategories] = 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 [lowStockProducts, setLowStockProducts] = React.useState([]);
- const [widgetsRole, setWidgetsRole] = React.useState({
- role: { value: '', label: '' },
- });
const { currentUser } = useAppSelector((state) => state.auth);
- const { isFetchingQuery } = useAppSelector((state) => state.openAi);
-
- const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
+ useEffect(() => {
+ loadData();
+ }, []);
async function loadData() {
- const entities = ['users','roles','permissions','products','categories','orders','order_items','carts','cart_items','payments',];
- const fns = [setUsers,setRoles,setPermissions,setProducts,setCategories,setOrders,setOrder_items,setCarts,setCart_items,setPayments,];
+ try {
+ const [u, pr, c, o, p] = await Promise.all([
+ 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);
- const requests = entities.map((entity, index) => {
-
- if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
- return axios.get(`/${entity.toLowerCase()}/count`);
- } else {
- fns[index](null);
- return Promise.resolve({data: {count: null}});
- }
-
- });
-
- 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);
- }
- });
- });
+ if (hasPermission(currentUser, 'READ_PRODUCTS')) {
+ const lowStock = await axios.get('/products', {
+ params: { stockRange: [0, 5], active: true }
+ });
+ setLowStockProducts(lowStock.data.rows || []);
+ }
+ } catch (e) {
+ console.error(e);
+ }
}
-
- 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 (
<>
-
- {getPageTitle('Overview')}
-
+ {getPageTitle('Dashboard')}
-
+
{''}
-
- {hasPermission(currentUser, 'CREATE_ROLES') && }
- {!!rolesWidgets.length &&
- hasPermission(currentUser, 'CREATE_ROLES') && (
-
- {`${widgetsRole?.role?.label || 'Users'}'s widgets`}
-
- )}
-
- {(isFetchingQuery || loading) && (
-
- {' '}
- Loading widgets...
-
- )}
-
- { rolesWidgets &&
- rolesWidgets.map((widget) => (
-
- ))}
-
-
- {!!rolesWidgets.length && }
-
-
-
+
{hasPermission(currentUser, 'READ_USERS') &&
{
w="w-16"
h="h-16"
size={48}
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- path={icon.mdiAccountGroup || icon.mdiTable}
- />
-
-
-
- }
-
- {hasPermission(currentUser, 'READ_ROLES') &&
-
-
-
-
- Roles
-
-
- {roles}
-
-
-
-
-
-
-
- }
-
- {hasPermission(currentUser, 'READ_PERMISSIONS') &&
-
-
-
-
- Permissions
-
-
- {permissions}
-
-
-
-
@@ -248,43 +120,13 @@ const Dashboard = () => {
w="w-16"
h="h-16"
size={48}
- // 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}
+ path={icon.mdiPackageVariant}
/>
}
-
- {hasPermission(currentUser, 'READ_CATEGORIES') &&
-
-
-
-
- Categories
-
-
- {categories}
-
-
-
-
-
-
-
- }
-
+
{hasPermission(currentUser, 'READ_ORDERS') &&
{
w="w-16"
h="h-16"
size={48}
- // 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}
+ path={icon.mdiCart}
/>
}
-
- {hasPermission(currentUser, 'READ_ORDER_ITEMS') &&
-
+
+
+ {lowStockProducts.length > 0 && (
+
+
+
+ {lowStockProducts.map((p: any) => (
+
+
+ Low Stock
+ {p.stock} left
+
+ {p.title}
+
+ {p.sku}
+
+ Restock
+
+
+
+ ))}
+
+
+ )}
+
+
+ {hasPermission(currentUser, 'READ_CATEGORIES') &&
+
-
- Order items
-
-
- {order_items}
-
-
-
-
+
Categories
+
{categories}
+
}
-
- {hasPermission(currentUser, 'READ_CARTS') &&
-
-
-
-
- Carts
-
-
- {carts}
-
-
-
-
-
-
-
- }
-
- {hasPermission(currentUser, 'READ_CART_ITEMS') &&
-
-
-
-
- Cart items
-
-
- {cart_items}
-
-
-
-
-
-
-
- }
-
{hasPermission(currentUser, 'READ_PAYMENTS') &&
-
+
-
- Payments
-
-
- {payments}
-
-
-
-
+
Payments
+
{payments}
+
}
-
-
>
@@ -436,4 +210,4 @@ Dashboard.getLayout = function getLayout(page: ReactElement) {
return
{page}
}
-export default Dashboard
+export default Dashboard
\ No newline at end of file
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index ddaa9c9..b6dce42 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -10,6 +10,9 @@ import { mdiCart, mdiArrowRight, mdiStar } from '@mdi/js';
import BaseIcon from '../components/BaseIcon';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { addToCart } from '../stores/shoppingCartSlice';
+import CountdownTimer from '../components/CountdownTimer';
+import ProductRecommendations from '../components/Products/ProductRecommendations';
+import SmartSearch from '../components/Search/SmartSearch';
export default function Home() {
const [categories, setCategories] = useState([]);
@@ -41,7 +44,7 @@ export default function Home() {
id: Math.random().toString(36).substr(2, 9),
productId: product.id,
title: product.title,
- price: product.price,
+ price: product.sale_price && new Date(product.sale_ends_at) > new Date() ? product.sale_price : product.price,
quantity: 1,
image: product.images?.[0]?.url
}));
@@ -62,34 +65,37 @@ export default function Home() {
{/* Navigation */}
-
-
- Storefront
+
+
+ STORE
FRONT
-
-
+
+
+
+
+
Products
-
+
Categories
-
-
+
+
{cartCount > 0 && (
-
+
{cartCount}
)}
-
+
Login
Sign Up
@@ -97,74 +103,125 @@ export default function Home() {
{/* Hero Section */}
-
+
-
-
- Upgrade Your Lifestyle with Premium Goods
+
+
+ New Collection 2026
+
+
+ Upgrade Your Lifestyle
-
- Discover our curated collection of high-quality products designed for the modern world.
+
+ Discover our curated collection of high-quality products designed for the modern world. Premium goods for premium people.
-
-
-
+
+
+ {/* Abstract elements */}
+
+
- {/* Categories Grid */}
-
-
-
-
Shop by Category
-
Explore our wide range of products across different categories.
+ {/* Flash Sales Section */}
+ {products.some(p => p.sale_ends_at && new Date(p.sale_ends_at) > new Date()) && (
+
+
+
+
+
Flash Deals
+
High-speed deals ending soon.
+
+
+
+ {products
+ .filter(p => p.sale_ends_at && new Date(p.sale_ends_at) > new Date())
+ .map((product) => (
+
+
+
+
+
+
+
+
+
{product.title}
+
+ ${product.sale_price}
+ ${product.price}
+
+
handleQuickAdd(product)}
+ />
+
+
+ ))}
+
-
- View All
+
+ )}
+
+ {/* Categories Grid */}
+
+
+
+
Shop Categories
+
Explore our wide range of collections.
+
+
+ View All
-
+
{loading
? Array(4)
.fill(0)
.map((_, i) => (
-
+
))
: categories.slice(0, 4).map((cat) => (
-
-
-
+
+
+
{cat.name}
- Explore
+ Explore Collection
))}
@@ -172,65 +229,78 @@ export default function Home() {
{/* Featured Products */}
-
+
-
+
-
Featured Products
-
Our handpicked selection for you this season.
+
New Arrivals
+
Our latest drops curated just for you.
-
- View All
+
+ View Shop
-
+
{loading
? Array(4)
.fill(0)
.map((_, i) => (
-
+
))
: products.map((product) => {
const avg = getAvgRating(product.reviews);
+ const isOnSale = product.sale_ends_at && new Date(product.sale_ends_at) > new Date();
+
return (
-
+ {isOnSale && (
+
+
+
+ )}
+
handleQuickAdd(product)}
- className="absolute top-4 right-4 bg-white/90 backdrop-blur-sm p-2 rounded-full shadow-md hover:bg-white hover:text-blue-600 transition-colors"
+ 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"
>
-
-
-
{product.title}
+
+
+
{product.title}
{avg > 0 && (
-
-
-
{avg.toFixed(1)}
+
+
+ {avg.toFixed(1)}
)}
-
- {product.description || 'No description available.'}
-
-
-
${product.price}
+
+
+ {isOnSale ? (
+ <>
+ ${product.sale_price}
+ ${product.price}
+ >
+ ) : (
+ ${product.price}
+ )}
+
- View Details
+ Details
@@ -241,28 +311,41 @@ export default function Home() {
+ {/* Recommendations */}
+
+
{/* Footer */}
-