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 + + + +
+
+

{appTitle}

+
+
+

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 + + + +
+
+

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.

+ +
+ Manage Product +
+
+
+ + 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! + + + +
+
+

Good News!

+
+
+

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 }) => ( -
- - {errors.search && touched.search && values.search.length < 2 ? ( -
{errors.search}
- ) : null} - - )} -
+
+ { + router.push(`/search?query=${values.search}`); + resetForm(); + setSubmitting(false); + setShowDropdown(false); + }} + validateOnBlur={false} + validateOnChange={false} + > + {({ errors, touched, values, setFieldValue, submitForm }) => ( +
+ { + const value = e.target.value; + setFieldValue('search', value); + handleAutocomplete(value); + }} + onFocus={() => { + if (autocompleteResults.length > 0) setShowDropdown(true); + }} + /> + {errors.search && touched.search && values.search.length < 2 ? ( +
{errors.search}
+ ) : null} + + {showDropdown && ( +
+ {autocompleteResults.map((result) => ( +
{ + if (result.type === 'product') { + router.push(`/products/${result.id}`); + } else { + router.push(`/categories/${result.id}`); + } + setShowDropdown(false); + setFieldValue('search', ''); + }} + > +
+
+ {result.title || result.name} + {result.type} +
+ {result.price && ( + ${result.price} + )} +
+
+ ))} +
submitForm()} + > + View all results for "{values.search}" +
+
+ )} + + )} +
+
); }; -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 ( +
+
+
+ +
+ 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 && ( + + )} +
+ + {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)}` : ''} +

+
+ + ))} + +
+ )} +
+ )} +
+ ); +}; + +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?.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 ? ( +
+ + + + + + + + + + + {applications.map((app: any) => ( + + + + + + + ))} + +
UserShop NameDescriptionActions
+
{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! +
+ +
+ )} +
{/* 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

-
- PayPal - Visa - Mastercard -
@@ -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 */} {/* 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.

-
+
-
-
+
+
Hero
+ {/* 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.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 && ( +
+ +
+ )} +
{product.title}
-
-
-

{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 */} -