diff --git a/.env.example b/.env.example
index e0b6d11..b0ac412 100644
--- a/.env.example
+++ b/.env.example
@@ -3,3 +3,10 @@ JWT_SECRET=
WEAVIATE_HOST=
WEAVIATE_API_KEY=
GOOGLE_AI_API_KEY=
+
+# Wishlist + Notify Me alerts (set to true to enable alert endpoints and evaluation)
+FEATURE_ALERTS=true
+
+# SendGrid — used in production for restock/price-drop emails (leave blank to use console in dev)
+SENDGRID_API_KEY=
+SENDGRID_FROM_EMAIL=no-reply@your-domain.com
diff --git a/README.md b/README.md
index 27a42c2..042ee7e 100644
--- a/README.md
+++ b/README.md
@@ -303,6 +303,15 @@ The **backup** backend server is deployed on Render and can be accessed at the f
- User profile management (view and update profile information).
- Order history (view past orders).
+- **Wishlist & "Notify Me" Alerts** *(new in v1.2)*:
+ - Save any product to a personal wishlist with one click (heart icon on product detail pages and in the navbar badge).
+ - `/wishlist` page: grid view of saved items with name, price, stock status, quick-remove, and go-to-product link.
+ - **Restock alerts**: on out-of-stock products, click "Notify me when back in stock" to receive an email the moment inventory is replenished.
+ - **Price-drop alerts**: set a target price or a percentage drop threshold; get an email when the product price falls to your target.
+ - Alerts are evaluated automatically whenever a product is updated *and* via a daily sweep (02:00 cron job).
+ - Email delivery uses console logging in development; [SendGrid](https://sendgrid.com/) in production (`SENDGRID_API_KEY` env var).
+ - Entire feature is gated behind the `FEATURE_ALERTS=true` environment variable.
+
- **Product Recommendations:**
- Vector-based product recommendations using Pinecone (with optional Weaviate support).
- Similar products displayed on product detail pages.
diff --git a/backend/__tests__/wishlist-alerts.spec.js b/backend/__tests__/wishlist-alerts.spec.js
new file mode 100644
index 0000000..7b8b451
--- /dev/null
+++ b/backend/__tests__/wishlist-alerts.spec.js
@@ -0,0 +1,411 @@
+/**
+ * Tests for wishlist + alert subscription feature.
+ * Covers: subscription validation, threshold evaluation, idempotent triggering.
+ */
+
+const express = require('express');
+const bodyParser = require('body-parser');
+const request = require('supertest');
+
+// ── Model mocks ──────────────────────────────────────────────────────────────
+jest.mock('../models/alertSubscription');
+jest.mock('../models/wishlist');
+jest.mock('../models/product');
+jest.mock('../models/user');
+jest.mock('../middleware/auth', () => (req, _res, next) => {
+ req.user = { id: 'user123' };
+ next();
+});
+jest.mock('../services/emailService', () => ({
+ sendRestockEmail: jest.fn().mockResolvedValue(),
+ sendPriceDropEmail: jest.fn().mockResolvedValue(),
+}));
+jest.mock('../services/pineconeSync', () => ({
+ ensureProductSyncedWithPinecone: jest.fn().mockResolvedValue(),
+ removeProductFromPinecone: jest.fn().mockResolvedValue(),
+}));
+
+const AlertSubscription = require('../models/alertSubscription');
+const Wishlist = require('../models/wishlist');
+const Product = require('../models/product');
+const User = require('../models/user');
+const { sendRestockEmail, sendPriceDropEmail } = require('../services/emailService');
+const { evaluateSubscriptions } = require('../services/evaluateSubscriptions');
+
+// Helper: returns a mock query object that resolves via .lean()
+const leanQuery = value => ({ lean: jest.fn().mockResolvedValue(value) });
+// Helper: returns a mock sort→lean chain
+const sortLeanQuery = value => ({
+ sort: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue(value) }),
+});
+
+function buildApp() {
+ const app = express();
+ app.use(bodyParser.json());
+ app.use('/api/wishlist', require('../routes/wishlist'));
+ app.use('/api/alerts', require('../routes/alerts'));
+ return app;
+}
+
+// ── Wishlist route tests ──────────────────────────────────────────────────────
+describe('Wishlist API', () => {
+ let app;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ app = buildApp();
+ });
+
+ describe('GET /api/wishlist', () => {
+ it('returns empty items when no wishlist exists', async () => {
+ Wishlist.findOne = jest.fn().mockReturnValue(leanQuery(null));
+ const res = await request(app).get('/api/wishlist');
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ items: [] });
+ });
+
+ it('returns populated product items', async () => {
+ const productId = '64a000000000000000000001';
+ Wishlist.findOne = jest.fn().mockReturnValue(
+ leanQuery({ items: [{ productId }] })
+ );
+ Product.find = jest.fn().mockReturnValue(
+ leanQuery([{ _id: productId, name: 'Test', price: 99, toString: () => productId }])
+ );
+ const res = await request(app).get('/api/wishlist');
+ expect(res.status).toBe(200);
+ expect(res.body.items.length).toBeGreaterThanOrEqual(0);
+ });
+ });
+
+ describe('POST /api/wishlist', () => {
+ it('400 → missing productId', async () => {
+ const res = await request(app).post('/api/wishlist').send({});
+ expect(res.status).toBe(400);
+ });
+
+ it('400 → invalid ObjectId', async () => {
+ const res = await request(app).post('/api/wishlist').send({ productId: 'not-valid' });
+ expect(res.status).toBe(400);
+ });
+
+ it('404 → product not found', async () => {
+ Product.findById = jest.fn().mockReturnValue(leanQuery(null));
+ const res = await request(app).post('/api/wishlist').send({ productId: '64a000000000000000000001' });
+ expect(res.status).toBe(404);
+ });
+
+ it('400 → already in wishlist', async () => {
+ const productId = '64a000000000000000000001';
+ Product.findById = jest.fn().mockReturnValue(leanQuery({ _id: productId }));
+ const fakeWishlist = {
+ items: [{ productId: { toString: () => productId } }],
+ save: jest.fn(),
+ };
+ // items.some needs to work — give it a real array method
+ fakeWishlist.items.some = arr => Array.prototype.some.call(fakeWishlist.items, arr);
+ Wishlist.findOne = jest.fn().mockResolvedValue(fakeWishlist);
+ const res = await request(app).post('/api/wishlist').send({ productId });
+ expect(res.status).toBe(400);
+ expect(res.body.msg).toMatch(/already/i);
+ });
+
+ it('200 → item added to existing wishlist', async () => {
+ const productId = '64a000000000000000000001';
+ Product.findById = jest.fn().mockReturnValue(leanQuery({ _id: productId }));
+ const items = [];
+ const fakeWishlist = {
+ items,
+ save: jest.fn().mockResolvedValue(),
+ };
+ Wishlist.findOne = jest.fn().mockResolvedValue(fakeWishlist);
+ const res = await request(app).post('/api/wishlist').send({ productId });
+ expect(res.status).toBe(200);
+ expect(res.body.msg).toMatch(/added/i);
+ });
+ });
+
+ describe('DELETE /api/wishlist/:productId', () => {
+ it('400 → invalid ObjectId', async () => {
+ const res = await request(app).delete('/api/wishlist/bad-id');
+ expect(res.status).toBe(400);
+ });
+
+ it('404 → wishlist not found', async () => {
+ Wishlist.findOne = jest.fn().mockResolvedValue(null);
+ const res = await request(app).delete('/api/wishlist/64a000000000000000000001');
+ expect(res.status).toBe(404);
+ });
+
+ it('200 → item removed', async () => {
+ const productId = '64a000000000000000000001';
+ const items = [{ productId: { toString: () => productId } }];
+ const fakeWishlist = {
+ items,
+ save: jest.fn().mockResolvedValue(),
+ };
+ Wishlist.findOne = jest.fn().mockResolvedValue(fakeWishlist);
+ const res = await request(app).delete(`/api/wishlist/${productId}`);
+ expect(res.status).toBe(200);
+ });
+ });
+});
+
+// ── Alert subscription route tests ───────────────────────────────────────────
+describe('Alert subscription API', () => {
+ let app;
+
+ beforeAll(() => {
+ process.env.FEATURE_ALERTS = 'true';
+ });
+
+ afterAll(() => {
+ delete process.env.FEATURE_ALERTS;
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ app = buildApp();
+ });
+
+ describe('POST /api/alerts/subscribe — validation', () => {
+ it('400 → missing type', async () => {
+ const res = await request(app)
+ .post('/api/alerts/subscribe')
+ .send({ productId: '64a000000000000000000001' });
+ expect(res.status).toBe(400);
+ });
+
+ it('400 → invalid type', async () => {
+ const res = await request(app)
+ .post('/api/alerts/subscribe')
+ .send({ productId: '64a000000000000000000001', type: 'unknown' });
+ expect(res.status).toBe(400);
+ });
+
+ it('400 → price_drop without targetPrice or dropPercent', async () => {
+ Product.findById = jest.fn().mockReturnValue(leanQuery({ _id: '64a000000000000000000001', price: 100 }));
+ User.findById = jest.fn().mockReturnValue(leanQuery({ _id: 'user123', email: 'u@e.com' }));
+ AlertSubscription.findOne = jest.fn().mockResolvedValue(null);
+ const res = await request(app)
+ .post('/api/alerts/subscribe')
+ .send({ productId: '64a000000000000000000001', type: 'price_drop' });
+ expect(res.status).toBe(400);
+ expect(res.body.msg).toMatch(/targetPrice or dropPercent/i);
+ });
+
+ it('400 → duplicate active subscription', async () => {
+ Product.findById = jest.fn().mockReturnValue(leanQuery({ _id: '64a000000000000000000001', price: 100 }));
+ User.findById = jest.fn().mockReturnValue(leanQuery({ _id: 'user123', email: 'u@e.com' }));
+ AlertSubscription.findOne = jest.fn().mockResolvedValue({ status: 'ACTIVE' });
+ const res = await request(app)
+ .post('/api/alerts/subscribe')
+ .send({ productId: '64a000000000000000000001', type: 'restock' });
+ expect(res.status).toBe(400);
+ expect(res.body.msg).toMatch(/already have an active/i);
+ });
+
+ it('201 → restock subscription created', async () => {
+ const productId = '64a000000000000000000001';
+ Product.findById = jest.fn().mockReturnValue(leanQuery({ _id: productId, price: 100 }));
+ User.findById = jest.fn().mockReturnValue(leanQuery({ _id: 'user123', email: 'u@e.com' }));
+ AlertSubscription.findOne = jest.fn().mockResolvedValue(null);
+ AlertSubscription.prototype.save = jest.fn().mockResolvedValue();
+ const res = await request(app)
+ .post('/api/alerts/subscribe')
+ .send({ productId, type: 'restock' });
+ expect(res.status).toBe(201);
+ });
+
+ it('201 → price_drop subscription with targetPrice created', async () => {
+ const productId = '64a000000000000000000001';
+ Product.findById = jest.fn().mockReturnValue(leanQuery({ _id: productId, price: 100 }));
+ User.findById = jest.fn().mockReturnValue(leanQuery({ _id: 'user123', email: 'u@e.com' }));
+ AlertSubscription.findOne = jest.fn().mockResolvedValue(null);
+ AlertSubscription.prototype.save = jest.fn().mockResolvedValue();
+ const res = await request(app)
+ .post('/api/alerts/subscribe')
+ .send({ productId, type: 'price_drop', targetPrice: 80 });
+ expect(res.status).toBe(201);
+ });
+ });
+
+ describe('GET /api/alerts/mine', () => {
+ it('returns enriched subscriptions', async () => {
+ const productId = '64a000000000000000000001';
+ AlertSubscription.find = jest.fn().mockReturnValue(
+ sortLeanQuery([{ _id: 'sub1', productId, type: 'restock', status: 'ACTIVE' }])
+ );
+ Product.find = jest.fn().mockReturnValue(
+ leanQuery([{ _id: { toString: () => productId }, name: 'Widget', price: 50 }])
+ );
+ const res = await request(app).get('/api/alerts/mine');
+ expect(res.status).toBe(200);
+ expect(Array.isArray(res.body)).toBe(true);
+ });
+ });
+
+ describe('POST /api/alerts/cancel', () => {
+ it('400 → missing productId', async () => {
+ const res = await request(app).post('/api/alerts/cancel').send({ type: 'restock' });
+ expect(res.status).toBe(400);
+ });
+
+ it('404 → no active subscription', async () => {
+ AlertSubscription.findOneAndUpdate = jest.fn().mockResolvedValue(null);
+ const res = await request(app)
+ .post('/api/alerts/cancel')
+ .send({ productId: '64a000000000000000000001', type: 'restock' });
+ expect(res.status).toBe(404);
+ });
+
+ it('200 → subscription cancelled', async () => {
+ AlertSubscription.findOneAndUpdate = jest.fn().mockResolvedValue({
+ _id: 'sub1', status: 'CANCELLED',
+ });
+ const res = await request(app)
+ .post('/api/alerts/cancel')
+ .send({ productId: '64a000000000000000000001', type: 'restock' });
+ expect(res.status).toBe(200);
+ });
+ });
+});
+
+// ── evaluateSubscriptions unit tests ─────────────────────────────────────────
+describe('evaluateSubscriptions service', () => {
+ beforeAll(() => {
+ process.env.FEATURE_ALERTS = 'true';
+ });
+
+ afterAll(() => {
+ delete process.env.FEATURE_ALERTS;
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ User.findById = jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ email: 'buyer@example.com' }) });
+ });
+
+ const makeProduct = overrides => ({
+ _id: '64a000000000000000000099',
+ name: 'Test Widget',
+ price: 80,
+ stock: 5,
+ ...overrides,
+ });
+
+ const makeSub = overrides => ({
+ _id: 'sub1',
+ userId: 'user123',
+ type: 'restock',
+ status: 'ACTIVE',
+ targetPrice: null,
+ dropPercent: null,
+ priceAtSubscription: null,
+ productId: '64a000000000000000000099',
+ ...overrides,
+ });
+
+ it('does nothing when FEATURE_ALERTS is not set', async () => {
+ delete process.env.FEATURE_ALERTS;
+ AlertSubscription.find = jest.fn();
+ await evaluateSubscriptions(makeProduct());
+ expect(AlertSubscription.find).not.toHaveBeenCalled();
+ process.env.FEATURE_ALERTS = 'true';
+ });
+
+ it('does nothing when no active subscriptions', async () => {
+ AlertSubscription.find = jest.fn().mockResolvedValue([]);
+ await evaluateSubscriptions(makeProduct());
+ expect(sendRestockEmail).not.toHaveBeenCalled();
+ });
+
+ it('triggers restock alert when stock > 0', async () => {
+ const sub = makeSub({ type: 'restock' });
+ AlertSubscription.find = jest.fn().mockResolvedValue([sub]);
+ AlertSubscription.findOneAndUpdate = jest.fn().mockResolvedValue(sub);
+
+ await evaluateSubscriptions(makeProduct({ stock: 3 }));
+
+ expect(AlertSubscription.findOneAndUpdate).toHaveBeenCalledWith(
+ { _id: sub._id, status: 'ACTIVE' },
+ { $set: { status: 'TRIGGERED', lastTriggeredAt: expect.any(Date) } },
+ { new: false }
+ );
+ expect(sendRestockEmail).toHaveBeenCalledWith(
+ expect.objectContaining({ to: 'buyer@example.com', productName: 'Test Widget' })
+ );
+ });
+
+ it('does NOT trigger restock alert when stock is 0', async () => {
+ const sub = makeSub({ type: 'restock' });
+ AlertSubscription.find = jest.fn().mockResolvedValue([sub]);
+
+ await evaluateSubscriptions(makeProduct({ stock: 0 }));
+
+ expect(sendRestockEmail).not.toHaveBeenCalled();
+ });
+
+ it('triggers price_drop alert when price <= targetPrice', async () => {
+ const sub = makeSub({ type: 'price_drop', targetPrice: 90 });
+ AlertSubscription.find = jest.fn().mockResolvedValue([sub]);
+ AlertSubscription.findOneAndUpdate = jest.fn().mockResolvedValue(sub);
+
+ await evaluateSubscriptions(makeProduct({ price: 85 }));
+
+ expect(sendPriceDropEmail).toHaveBeenCalledWith(
+ expect.objectContaining({ to: 'buyer@example.com', price: 85 })
+ );
+ });
+
+ it('does NOT trigger price_drop when price > targetPrice', async () => {
+ const sub = makeSub({ type: 'price_drop', targetPrice: 70 });
+ AlertSubscription.find = jest.fn().mockResolvedValue([sub]);
+
+ await evaluateSubscriptions(makeProduct({ price: 85 }));
+
+ expect(sendPriceDropEmail).not.toHaveBeenCalled();
+ });
+
+ it('triggers price_drop via dropPercent threshold', async () => {
+ const sub = makeSub({
+ type: 'price_drop',
+ dropPercent: 20,
+ priceAtSubscription: 100,
+ targetPrice: null,
+ });
+ AlertSubscription.find = jest.fn().mockResolvedValue([sub]);
+ AlertSubscription.findOneAndUpdate = jest.fn().mockResolvedValue(sub);
+
+ // threshold = 100 * (1 - 0.20) = 80; price 79 ≤ 80 → fires
+ await evaluateSubscriptions(makeProduct({ price: 79 }));
+
+ expect(sendPriceDropEmail).toHaveBeenCalled();
+ });
+
+ it('does NOT trigger price_drop via dropPercent when threshold not met', async () => {
+ const sub = makeSub({
+ type: 'price_drop',
+ dropPercent: 20,
+ priceAtSubscription: 100,
+ targetPrice: null,
+ });
+ AlertSubscription.find = jest.fn().mockResolvedValue([sub]);
+
+ // threshold = 80; price 81 > 80 → does not fire
+ await evaluateSubscriptions(makeProduct({ price: 81 }));
+
+ expect(sendPriceDropEmail).not.toHaveBeenCalled();
+ });
+
+ it('is idempotent — second concurrent trigger is a no-op', async () => {
+ const sub = makeSub({ type: 'restock' });
+ AlertSubscription.find = jest.fn().mockResolvedValue([sub]);
+ // Another process already claimed the trigger (returns null)
+ AlertSubscription.findOneAndUpdate = jest.fn().mockResolvedValue(null);
+
+ await evaluateSubscriptions(makeProduct({ stock: 5 }));
+
+ expect(sendRestockEmail).not.toHaveBeenCalled();
+ });
+});
diff --git a/backend/index.js b/backend/index.js
index b2899b3..ccddffc 100644
--- a/backend/index.js
+++ b/backend/index.js
@@ -9,6 +9,8 @@ const productRoutes = require('./routes/products');
const checkoutRoutes = require('./routes/checkout');
const orderRoutes = require('./routes/orders');
const authRoutes = require('./routes/auth');
+const wishlistRoutes = require('./routes/wishlist');
+const alertsRoutes = require('./routes/alerts');
const { swaggerUi, swaggerSpec, setupSwaggerUi, setupSwaggerJson } = require('./docs/swagger');
// Create Express App
@@ -80,5 +82,7 @@ app.use('/api/checkout', checkoutRoutes);
app.use('/api/orders', orderRoutes);
app.use('/api/search', require('./routes/search'));
app.use('/api/auth', authRoutes);
+app.use('/api/wishlist', wishlistRoutes);
+app.use('/api/alerts', alertsRoutes);
module.exports = app;
diff --git a/backend/models/alertSubscription.js b/backend/models/alertSubscription.js
new file mode 100644
index 0000000..476eba6
--- /dev/null
+++ b/backend/models/alertSubscription.js
@@ -0,0 +1,64 @@
+const mongoose = require('mongoose');
+
+const ALERT_TYPES = ['restock', 'price_drop'];
+const ALERT_STATUSES = ['ACTIVE', 'TRIGGERED', 'CANCELLED'];
+
+const AlertSubscriptionSchema = new mongoose.Schema(
+ {
+ userId: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ required: true,
+ index: true,
+ },
+ productId: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Product',
+ required: true,
+ index: true,
+ },
+ type: {
+ type: String,
+ enum: ALERT_TYPES,
+ required: true,
+ },
+ // For price_drop: absolute target price (takes precedence over dropPercent)
+ targetPrice: {
+ type: Number,
+ min: 0,
+ default: null,
+ },
+ // For price_drop: percentage drop from current price at subscription time
+ dropPercent: {
+ type: Number,
+ min: 1,
+ max: 99,
+ default: null,
+ },
+ // Snapshot of price at subscription time; used to compute % drop threshold
+ priceAtSubscription: {
+ type: Number,
+ min: 0,
+ default: null,
+ },
+ status: {
+ type: String,
+ enum: ALERT_STATUSES,
+ default: 'ACTIVE',
+ index: true,
+ },
+ lastTriggeredAt: {
+ type: Date,
+ default: null,
+ },
+ },
+ { timestamps: true }
+);
+
+// Enforce one active subscription per user+product+type combo
+AlertSubscriptionSchema.index({ userId: 1, productId: 1, type: 1 }, { unique: true });
+
+AlertSubscriptionSchema.statics.ALERT_TYPES = ALERT_TYPES;
+AlertSubscriptionSchema.statics.ALERT_STATUSES = ALERT_STATUSES;
+
+module.exports = mongoose.model('AlertSubscription', AlertSubscriptionSchema);
diff --git a/backend/models/product.js b/backend/models/product.js
index 8b43633..e4a956a 100644
--- a/backend/models/product.js
+++ b/backend/models/product.js
@@ -3,6 +3,7 @@ const {
ensureProductSyncedWithPinecone,
removeProductFromPinecone,
} = require('../services/pineconeSync');
+const { evaluateSubscriptions } = require('../services/evaluateSubscriptions');
const productSchema = new mongoose.Schema({
name: {
@@ -68,33 +69,43 @@ productSchema.pre('save', function(next) {
productSchema.post('save', function(doc, next) {
const shouldSync = doc._shouldSyncPinecone;
delete doc._shouldSyncPinecone;
- if (!shouldSync) return next();
- ensureProductSyncedWithPinecone(doc)
- .catch(err => {
- console.error('Pinecone sync (save) failed:', err);
- })
- .finally(() => next());
+ const tasks = [];
+ if (shouldSync) {
+ tasks.push(ensureProductSyncedWithPinecone(doc).catch(err => console.error('Pinecone sync (save) failed:', err)));
+ }
+ tasks.push(evaluateSubscriptions(doc).catch(err => console.error('Alert evaluation (save) failed:', err)));
+
+ Promise.allSettled(tasks).finally(() => next());
});
+const ALERT_TRIGGER_FIELDS = ['price', 'stock'];
+
productSchema.pre('findOneAndUpdate', function(next) {
const update = this.getUpdate() || {};
const directUpdates = { ...update, ...(update.$set || {}) };
const keys = Object.keys(directUpdates);
this._shouldSyncPinecone = PINECONE_SYNC_FIELDS.some(field => keys.includes(field));
+ this._shouldEvaluateAlerts = ALERT_TRIGGER_FIELDS.some(field => keys.includes(field));
next();
});
productSchema.post('findOneAndUpdate', function(doc, next) {
const shouldSync = this._shouldSyncPinecone;
+ const shouldEvaluate = this._shouldEvaluateAlerts;
delete this._shouldSyncPinecone;
- if (!shouldSync || !doc) return next();
+ delete this._shouldEvaluateAlerts;
+ if (!doc) return next();
- ensureProductSyncedWithPinecone(doc)
- .catch(err => {
- console.error('Pinecone sync (update) failed:', err);
- })
- .finally(() => next());
+ const tasks = [];
+ if (shouldSync) {
+ tasks.push(ensureProductSyncedWithPinecone(doc).catch(err => console.error('Pinecone sync (update) failed:', err)));
+ }
+ if (shouldEvaluate) {
+ tasks.push(evaluateSubscriptions(doc).catch(err => console.error('Alert evaluation (update) failed:', err)));
+ }
+
+ Promise.allSettled(tasks).finally(() => next());
});
productSchema.post('deleteOne', { document: true, query: false }, function(next) {
diff --git a/backend/models/wishlist.js b/backend/models/wishlist.js
new file mode 100644
index 0000000..131ef6a
--- /dev/null
+++ b/backend/models/wishlist.js
@@ -0,0 +1,31 @@
+const mongoose = require('mongoose');
+
+const WishlistItemSchema = new mongoose.Schema(
+ {
+ productId: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Product',
+ required: true,
+ },
+ },
+ { _id: false }
+);
+
+const WishlistSchema = new mongoose.Schema(
+ {
+ userId: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ required: true,
+ unique: true,
+ index: true,
+ },
+ items: {
+ type: [WishlistItemSchema],
+ default: [],
+ },
+ },
+ { timestamps: true }
+);
+
+module.exports = mongoose.model('Wishlist', WishlistSchema);
diff --git a/backend/package.json b/backend/package.json
index c864a25..93ab4cb 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -41,13 +41,14 @@
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
+ "@sendgrid/mail": "^8.1.6",
+ "axios": "^1.7.2",
"bcryptjs": "^2.4.3",
"concurrently": "^9.0.1",
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.19.2",
"express-validator": "^7.2.0",
- "axios": "^1.7.2",
"faiss-node": "^0.5.1",
"http-proxy-middleware": "^3.0.0",
"jsonwebtoken": "^9.0.2",
diff --git a/backend/routes/alerts.js b/backend/routes/alerts.js
new file mode 100644
index 0000000..d046048
--- /dev/null
+++ b/backend/routes/alerts.js
@@ -0,0 +1,216 @@
+const express = require('express');
+const router = express.Router();
+const mongoose = require('mongoose');
+const { check, validationResult } = require('express-validator');
+const auth = require('../middleware/auth');
+const AlertSubscription = require('../models/alertSubscription');
+const Product = require('../models/product');
+
+/**
+ * @swagger
+ * tags:
+ * name: Alerts
+ * description: Restock and price-drop notification subscriptions
+ */
+
+/**
+ * @swagger
+ * /api/alerts/subscribe:
+ * post:
+ * summary: Subscribe to a restock or price-drop alert
+ * tags: [Alerts]
+ * security:
+ * - bearerAuth: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/AlertSubscribeRequest'
+ * responses:
+ * 201:
+ * description: Subscription created
+ * 400:
+ * description: Validation error or duplicate
+ * 404:
+ * description: Product not found
+ * 401:
+ * description: Unauthorized
+ * 500:
+ * description: Server error
+ */
+router.post(
+ '/subscribe',
+ auth,
+ [
+ check('productId', 'productId is required').not().isEmpty(),
+ check('type', 'type must be restock or price_drop').isIn(['restock', 'price_drop']),
+ check('targetPrice').optional().isFloat({ min: 0 }).withMessage('targetPrice must be a non-negative number'),
+ check('dropPercent').optional().isFloat({ min: 1, max: 99 }).withMessage('dropPercent must be between 1 and 99'),
+ ],
+ async (req, res) => {
+ if (process.env.FEATURE_ALERTS !== 'true') {
+ return res.status(404).json({ msg: 'Alerts feature is not enabled' });
+ }
+
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
+
+ const { productId, type, targetPrice, dropPercent } = req.body;
+
+ if (!mongoose.Types.ObjectId.isValid(productId)) {
+ return res.status(400).json({ msg: 'Invalid productId' });
+ }
+
+ if (type === 'price_drop' && targetPrice == null && dropPercent == null) {
+ return res.status(400).json({ msg: 'price_drop alert requires targetPrice or dropPercent' });
+ }
+
+ try {
+ const product = await Product.findById(productId).lean();
+ if (!product) return res.status(404).json({ msg: 'Product not found' });
+
+ // Upsert: reactivate a cancelled/triggered sub or create fresh
+ const existing = await AlertSubscription.findOne({
+ userId: req.user.id,
+ productId,
+ type,
+ });
+
+ if (existing) {
+ if (existing.status === 'ACTIVE') {
+ return res.status(400).json({ msg: 'You already have an active alert for this product and type' });
+ }
+ existing.status = 'ACTIVE';
+ existing.lastTriggeredAt = null;
+ existing.targetPrice = targetPrice ?? null;
+ existing.dropPercent = dropPercent ?? null;
+ existing.priceAtSubscription = type === 'price_drop' ? product.price : null;
+ await existing.save();
+ return res.status(201).json({ msg: 'Alert reactivated', subscription: existing });
+ }
+
+ const sub = new AlertSubscription({
+ userId: req.user.id,
+ productId,
+ type,
+ targetPrice: targetPrice ?? null,
+ dropPercent: dropPercent ?? null,
+ priceAtSubscription: type === 'price_drop' ? product.price : null,
+ });
+
+ await sub.save();
+ res.status(201).json({ msg: 'Alert subscription created', subscription: sub });
+ } catch (err) {
+ console.error('POST /api/alerts/subscribe error:', err.message);
+ res.status(500).send('Server error');
+ }
+ }
+);
+
+/**
+ * @swagger
+ * /api/alerts/cancel:
+ * post:
+ * summary: Cancel an active alert subscription
+ * tags: [Alerts]
+ * security:
+ * - bearerAuth: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [productId, type]
+ * properties:
+ * productId:
+ * type: string
+ * type:
+ * type: string
+ * enum: [restock, price_drop]
+ * responses:
+ * 200:
+ * description: Subscription cancelled
+ * 400:
+ * description: Invalid input
+ * 404:
+ * description: Subscription not found
+ * 401:
+ * description: Unauthorized
+ * 500:
+ * description: Server error
+ */
+router.post('/cancel', auth, async (req, res) => {
+ if (process.env.FEATURE_ALERTS !== 'true') {
+ return res.status(404).json({ msg: 'Alerts feature is not enabled' });
+ }
+
+ const { productId, type } = req.body;
+
+ if (!productId || !mongoose.Types.ObjectId.isValid(productId)) {
+ return res.status(400).json({ msg: 'Invalid productId' });
+ }
+ if (!['restock', 'price_drop'].includes(type)) {
+ return res.status(400).json({ msg: 'type must be restock or price_drop' });
+ }
+
+ try {
+ const sub = await AlertSubscription.findOneAndUpdate(
+ { userId: req.user.id, productId, type, status: 'ACTIVE' },
+ { $set: { status: 'CANCELLED' } },
+ { new: true }
+ );
+
+ if (!sub) return res.status(404).json({ msg: 'Active subscription not found' });
+
+ res.json({ msg: 'Alert cancelled', subscription: sub });
+ } catch (err) {
+ console.error('POST /api/alerts/cancel error:', err.message);
+ res.status(500).send('Server error');
+ }
+});
+
+/**
+ * @swagger
+ * /api/alerts/mine:
+ * get:
+ * summary: List all alert subscriptions for the current user
+ * tags: [Alerts]
+ * security:
+ * - bearerAuth: []
+ * responses:
+ * 200:
+ * description: Array of subscriptions with product details
+ * 401:
+ * description: Unauthorized
+ * 500:
+ * description: Server error
+ */
+router.get('/mine', auth, async (req, res) => {
+ if (process.env.FEATURE_ALERTS !== 'true') {
+ return res.status(404).json({ msg: 'Alerts feature is not enabled' });
+ }
+
+ try {
+ const subs = await AlertSubscription.find({ userId: req.user.id })
+ .sort({ createdAt: -1 })
+ .lean();
+
+ const productIds = [...new Set(subs.map(s => s.productId.toString()))];
+ const products = await Product.find({ _id: { $in: productIds } }).lean();
+ const productMap = new Map(products.map(p => [p._id.toString(), p]));
+
+ const enriched = subs.map(s => ({
+ ...s,
+ product: productMap.get(s.productId.toString()) || null,
+ }));
+
+ res.json(enriched);
+ } catch (err) {
+ console.error('GET /api/alerts/mine error:', err.message);
+ res.status(500).send('Server error');
+ }
+});
+
+module.exports = router;
diff --git a/backend/routes/products.js b/backend/routes/products.js
index 3be4f95..9cce8e7 100644
--- a/backend/routes/products.js
+++ b/backend/routes/products.js
@@ -633,4 +633,68 @@ router.put('/:id/rating', async (req, res) => {
}
});
+/**
+ * @swagger
+ * /api/products/{id}:
+ * patch:
+ * summary: Update product price or stock (triggers alert evaluation)
+ * tags: [Products]
+ * parameters:
+ * - name: id
+ * in: path
+ * required: true
+ * schema:
+ * type: string
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * price:
+ * type: number
+ * stock:
+ * type: integer
+ * responses:
+ * 200:
+ * description: Product updated
+ * 400:
+ * description: Nothing to update
+ * 404:
+ * description: Product not found
+ */
+router.patch('/:id', async (req, res) => {
+ try {
+ const { price, stock } = req.body;
+ const updates = {};
+ if (price !== undefined) {
+ const p = Number(price);
+ if (isNaN(p) || p < 0) return res.status(400).json({ msg: 'price must be a non-negative number' });
+ updates.price = p;
+ }
+ if (stock !== undefined) {
+ const s = Number(stock);
+ if (isNaN(s) || s < 0) return res.status(400).json({ msg: 'stock must be a non-negative integer' });
+ updates.stock = s;
+ }
+
+ if (Object.keys(updates).length === 0) {
+ return res.status(400).json({ msg: 'Provide price or stock to update' });
+ }
+
+ const product = await Product.findByIdAndUpdate(
+ req.params.id,
+ { $set: updates },
+ { new: true, runValidators: true }
+ );
+
+ if (!product) return res.status(404).json({ msg: 'Product not found' });
+
+ res.json(product);
+ } catch (err) {
+ res.status(500).send('Server error');
+ }
+});
+
module.exports = router;
diff --git a/backend/routes/wishlist.js b/backend/routes/wishlist.js
new file mode 100644
index 0000000..a526ea8
--- /dev/null
+++ b/backend/routes/wishlist.js
@@ -0,0 +1,164 @@
+const express = require('express');
+const router = express.Router();
+const mongoose = require('mongoose');
+const auth = require('../middleware/auth');
+const Wishlist = require('../models/wishlist');
+const Product = require('../models/product');
+
+/**
+ * @swagger
+ * tags:
+ * name: Wishlist
+ * description: Save and manage wished products
+ */
+
+/**
+ * @swagger
+ * /api/wishlist:
+ * get:
+ * summary: Get current user's wishlist
+ * tags: [Wishlist]
+ * security:
+ * - bearerAuth: []
+ * responses:
+ * 200:
+ * description: Wishlist with populated product details
+ * 401:
+ * description: Unauthorized
+ * 500:
+ * description: Server error
+ */
+router.get('/', auth, async (req, res) => {
+ try {
+ const wishlist = await Wishlist.findOne({ userId: req.user.id }).lean();
+ if (!wishlist) return res.json({ items: [] });
+
+ const productIds = wishlist.items.map(i => i.productId);
+ const products = await Product.find({ _id: { $in: productIds } }).lean();
+ const productMap = new Map(products.map(p => [p._id.toString(), p]));
+
+ const items = wishlist.items
+ .map(i => productMap.get(i.productId.toString()))
+ .filter(Boolean);
+
+ res.json({ items });
+ } catch (err) {
+ console.error('GET /api/wishlist error:', err.message);
+ res.status(500).send('Server error');
+ }
+});
+
+/**
+ * @swagger
+ * /api/wishlist:
+ * post:
+ * summary: Add a product to the wishlist
+ * tags: [Wishlist]
+ * security:
+ * - bearerAuth: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [productId]
+ * properties:
+ * productId:
+ * type: string
+ * responses:
+ * 200:
+ * description: Item added
+ * 400:
+ * description: Invalid or already in wishlist
+ * 404:
+ * description: Product not found
+ * 401:
+ * description: Unauthorized
+ * 500:
+ * description: Server error
+ */
+router.post('/', auth, async (req, res) => {
+ try {
+ const { productId } = req.body;
+
+ if (!productId || !mongoose.Types.ObjectId.isValid(productId)) {
+ return res.status(400).json({ msg: 'Invalid productId' });
+ }
+
+ const product = await Product.findById(productId).lean();
+ if (!product) return res.status(404).json({ msg: 'Product not found' });
+
+ let wishlist = await Wishlist.findOne({ userId: req.user.id });
+ if (!wishlist) {
+ wishlist = new Wishlist({ userId: req.user.id, items: [] });
+ }
+
+ const alreadyIn = wishlist.items.some(i => i.productId.toString() === productId);
+ if (alreadyIn) {
+ return res.status(400).json({ msg: 'Product already in wishlist' });
+ }
+
+ wishlist.items.push({ productId });
+ await wishlist.save();
+
+ res.json({ msg: 'Added to wishlist', count: wishlist.items.length });
+ } catch (err) {
+ console.error('POST /api/wishlist error:', err.message);
+ res.status(500).send('Server error');
+ }
+});
+
+/**
+ * @swagger
+ * /api/wishlist/{productId}:
+ * delete:
+ * summary: Remove a product from the wishlist
+ * tags: [Wishlist]
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: productId
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Item removed
+ * 400:
+ * description: Invalid productId
+ * 404:
+ * description: Wishlist or item not found
+ * 401:
+ * description: Unauthorized
+ * 500:
+ * description: Server error
+ */
+router.delete('/:productId', auth, async (req, res) => {
+ try {
+ const { productId } = req.params;
+
+ if (!mongoose.Types.ObjectId.isValid(productId)) {
+ return res.status(400).json({ msg: 'Invalid productId' });
+ }
+
+ const wishlist = await Wishlist.findOne({ userId: req.user.id });
+ if (!wishlist) return res.status(404).json({ msg: 'Wishlist not found' });
+
+ const before = wishlist.items.length;
+ wishlist.items = wishlist.items.filter(i => i.productId.toString() !== productId);
+
+ if (wishlist.items.length === before) {
+ return res.status(404).json({ msg: 'Item not in wishlist' });
+ }
+
+ await wishlist.save();
+ res.json({ msg: 'Removed from wishlist', count: wishlist.items.length });
+ } catch (err) {
+ console.error('DELETE /api/wishlist/:productId error:', err.message);
+ res.status(500).send('Server error');
+ }
+});
+
+module.exports = router;
diff --git a/backend/services/emailService.js b/backend/services/emailService.js
new file mode 100644
index 0000000..b295a3d
--- /dev/null
+++ b/backend/services/emailService.js
@@ -0,0 +1,46 @@
+const isProd = process.env.NODE_ENV === 'production';
+
+const sgMail = require('@sendgrid/mail');
+if (process.env.SENDGRID_API_KEY) {
+ sgMail.setApiKey(process.env.SENDGRID_API_KEY);
+}
+
+const SUBJECTS = {
+ restock: productName => `Back in stock: ${productName}`,
+ price_drop: (productName, price) => `Price dropped to $${price}: ${productName}`,
+};
+
+const BODIES = {
+ restock: (productName, productId) =>
+ `Great news! ${productName} is back in stock. Visit https://fusion-electronics.vercel.app/product/${productId} to grab yours before it sells out.`,
+ price_drop: (productName, productId, price) =>
+ `The price of ${productName} has dropped to $${price}! Visit https://fusion-electronics.vercel.app/product/${productId} to take advantage of this deal.`,
+};
+
+async function sendRestockEmail({ to, productName, productId }) {
+ const subject = SUBJECTS.restock(productName);
+ const body = BODIES.restock(productName, productId);
+ return _send({ to, subject, body });
+}
+
+async function sendPriceDropEmail({ to, productName, productId, price }) {
+ const subject = SUBJECTS.price_drop(productName, price);
+ const body = BODIES.price_drop(productName, productId, price);
+ return _send({ to, subject, body });
+}
+
+async function _send({ to, subject, body }) {
+ if (!isProd || !process.env.SENDGRID_API_KEY) {
+ console.log(`[emailService] DEV — To: ${to} | Subject: ${subject}\n${body}`);
+ return;
+ }
+
+ await sgMail.send({
+ to,
+ from: process.env.SENDGRID_FROM_EMAIL || 'no-reply@fusion-electronics.com',
+ subject,
+ text: body,
+ });
+}
+
+module.exports = { sendRestockEmail, sendPriceDropEmail };
diff --git a/backend/services/evaluateSubscriptions.js b/backend/services/evaluateSubscriptions.js
new file mode 100644
index 0000000..c6fb8f4
--- /dev/null
+++ b/backend/services/evaluateSubscriptions.js
@@ -0,0 +1,76 @@
+const AlertSubscription = require('../models/alertSubscription');
+const User = require('../models/user');
+const { sendRestockEmail, sendPriceDropEmail } = require('./emailService');
+
+/**
+ * Evaluate all ACTIVE subscriptions for a given product and fire alerts when
+ * thresholds are met. Uses findOneAndUpdate with $set for idempotent triggering —
+ * only one process can win the race to set status=TRIGGERED per subscription.
+ *
+ * Called from:
+ * - product update hooks (price / stock changes)
+ * - daily cron sweep
+ */
+async function evaluateSubscriptions(product) {
+ if (process.env.FEATURE_ALERTS !== 'true') return;
+ if (!product || !product._id) return;
+
+ const productId = product._id;
+ const subs = await AlertSubscription.find({ productId, status: 'ACTIVE' });
+ if (!subs.length) return;
+
+ const promises = subs.map(sub => _evaluate(sub, product));
+ await Promise.allSettled(promises);
+}
+
+async function _evaluate(sub, product) {
+ try {
+ let shouldFire = false;
+
+ if (sub.type === 'restock') {
+ shouldFire = Number(product.stock) > 0;
+ } else if (sub.type === 'price_drop') {
+ const currentPrice = Number(product.price);
+ if (sub.targetPrice !== null && sub.targetPrice !== undefined) {
+ shouldFire = currentPrice <= sub.targetPrice;
+ } else if (sub.dropPercent !== null && sub.dropPercent !== undefined && sub.priceAtSubscription) {
+ const threshold = sub.priceAtSubscription * (1 - sub.dropPercent / 100);
+ shouldFire = currentPrice <= threshold;
+ }
+ }
+
+ if (!shouldFire) return;
+
+ // Idempotent: atomically claim the trigger slot
+ const claimed = await AlertSubscription.findOneAndUpdate(
+ { _id: sub._id, status: 'ACTIVE' },
+ { $set: { status: 'TRIGGERED', lastTriggeredAt: new Date() } },
+ { new: false }
+ );
+
+ // Another process already triggered it
+ if (!claimed) return;
+
+ const user = await User.findById(sub.userId).lean();
+ if (!user) return;
+
+ if (sub.type === 'restock') {
+ await sendRestockEmail({
+ to: user.email,
+ productName: product.name,
+ productId: product._id.toString(),
+ });
+ } else if (sub.type === 'price_drop') {
+ await sendPriceDropEmail({
+ to: user.email,
+ productName: product.name,
+ productId: product._id.toString(),
+ price: product.price,
+ });
+ }
+ } catch (err) {
+ console.error(`[evaluateSubscriptions] Error processing sub ${sub._id}:`, err.message);
+ }
+}
+
+module.exports = { evaluateSubscriptions };
diff --git a/openapi.yaml b/openapi.yaml
index 098d20d..f62f564 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -216,7 +216,143 @@ paths:
type: array
items:
$ref: '#/components/schemas/Product'
+ /api/wishlist:
+ get:
+ summary: Get the current user's wishlist
+ tags: [Wishlist]
+ security:
+ - bearerAuth: []
+ responses:
+ '200':
+ description: Wishlist with populated product objects
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ items:
+ type: array
+ items:
+ $ref: '#/components/schemas/Product'
+ '401':
+ description: Unauthorized
+ post:
+ summary: Add a product to the wishlist
+ tags: [Wishlist]
+ security:
+ - bearerAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WishlistAddRequest'
+ responses:
+ '200':
+ description: Item added
+ '400':
+ description: Invalid productId or already in wishlist
+ '404':
+ description: Product not found
+ '401':
+ description: Unauthorized
+ /api/wishlist/{productId}:
+ delete:
+ summary: Remove a product from the wishlist
+ tags: [Wishlist]
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: productId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Item removed
+ '400':
+ description: Invalid productId
+ '404':
+ description: Item or wishlist not found
+ '401':
+ description: Unauthorized
+ /api/alerts/subscribe:
+ post:
+ summary: Subscribe to a restock or price-drop alert
+ tags: [Alerts]
+ security:
+ - bearerAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AlertSubscribeRequest'
+ responses:
+ '201':
+ description: Subscription created or reactivated
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AlertSubscription'
+ '400':
+ description: Validation error or duplicate active subscription
+ '404':
+ description: Product not found
+ '401':
+ description: Unauthorized
+ /api/alerts/cancel:
+ post:
+ summary: Cancel an active alert subscription
+ tags: [Alerts]
+ security:
+ - bearerAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [productId, type]
+ properties:
+ productId:
+ type: string
+ type:
+ type: string
+ enum: [restock, price_drop]
+ responses:
+ '200':
+ description: Subscription cancelled
+ '400':
+ description: Invalid input
+ '404':
+ description: Active subscription not found
+ '401':
+ description: Unauthorized
+ /api/alerts/mine:
+ get:
+ summary: List all alert subscriptions for the current user
+ tags: [Alerts]
+ security:
+ - bearerAuth: []
+ responses:
+ '200':
+ description: Array of subscriptions enriched with product details
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/AlertSubscription'
+ '401':
+ description: Unauthorized
components:
+ securitySchemes:
+ bearerAuth:
+ type: apiKey
+ in: header
+ name: x-auth-token
schemas:
Product:
type: object
@@ -244,3 +380,61 @@ components:
type: string
password:
type: string
+ WishlistAddRequest:
+ type: object
+ required: [productId]
+ properties:
+ productId:
+ type: string
+ description: MongoDB ObjectId of the product to add
+ AlertSubscribeRequest:
+ type: object
+ required: [productId, type]
+ properties:
+ productId:
+ type: string
+ description: MongoDB ObjectId of the product
+ type:
+ type: string
+ enum: [restock, price_drop]
+ description: Alert type
+ targetPrice:
+ type: number
+ description: (price_drop only) Notify when price falls to or below this value
+ dropPercent:
+ type: number
+ minimum: 1
+ maximum: 99
+ description: (price_drop only) Notify when price drops by this percentage from subscription-time price
+ AlertSubscription:
+ type: object
+ properties:
+ _id:
+ type: string
+ userId:
+ type: string
+ productId:
+ type: string
+ userEmail:
+ type: string
+ type:
+ type: string
+ enum: [restock, price_drop]
+ targetPrice:
+ type: number
+ nullable: true
+ dropPercent:
+ type: number
+ nullable: true
+ priceAtSubscription:
+ type: number
+ nullable: true
+ status:
+ type: string
+ enum: [ACTIVE, TRIGGERED, CANCELLED]
+ lastTriggeredAt:
+ type: string
+ format: date-time
+ nullable: true
+ product:
+ $ref: '#/components/schemas/Product'
diff --git a/package-lock.json b/package-lock.json
index 1ff6d52..3c63157 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -60,6 +60,7 @@
"license": "MIT",
"dependencies": {
"@google/generative-ai": "^0.24.1",
+ "@sendgrid/mail": "^8.1.6",
"axios": "^1.7.2",
"bcryptjs": "^2.4.3",
"concurrently": "^9.0.1",
@@ -5252,6 +5253,41 @@
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true
},
+ "node_modules/@sendgrid/client": {
+ "version": "8.1.6",
+ "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.6.tgz",
+ "integrity": "sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==",
+ "dependencies": {
+ "@sendgrid/helpers": "^8.0.0",
+ "axios": "^1.12.0"
+ },
+ "engines": {
+ "node": ">=12.*"
+ }
+ },
+ "node_modules/@sendgrid/helpers": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz",
+ "integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==",
+ "dependencies": {
+ "deepmerge": "^4.2.2"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/@sendgrid/mail": {
+ "version": "8.1.6",
+ "resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.6.tgz",
+ "integrity": "sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==",
+ "dependencies": {
+ "@sendgrid/client": "^8.1.5",
+ "@sendgrid/helpers": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=12.*"
+ }
+ },
"node_modules/@sinclair/typebox": {
"version": "0.34.37",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz",
@@ -7287,13 +7323,14 @@
}
},
"node_modules/axios": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
- "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
+ "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
"dependencies": {
- "follow-redirects": "^1.15.6",
- "form-data": "^4.0.0",
- "proxy-from-env": "^1.1.0"
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.5",
+ "https-proxy-agent": "^5.0.1",
+ "proxy-from-env": "^2.1.0"
}
},
"node_modules/axobject-query": {
@@ -10833,9 +10870,9 @@
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="
},
"node_modules/follow-redirects": {
- "version": "1.15.9",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
- "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -10995,9 +11032,9 @@
}
},
"node_modules/form-data": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
- "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@@ -18678,9 +18715,12 @@
}
},
"node_modules/proxy-from-env": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
- "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "engines": {
+ "node": ">=10"
+ }
},
"node_modules/psl": {
"version": "1.15.0",
diff --git a/src/App.jsx b/src/App.jsx
index 7284623..8a40ce1 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -21,9 +21,11 @@ import Terms from './pages/Terms';
import Privacy from './pages/Privacy';
import ShippingReturns from './pages/ShippingReturns';
import OrderTracking from './pages/OrderTracking';
+import Wishlist from './pages/Wishlist';
import ScrollToTop from './components/ScrollToTop';
import { apiClient, withRetry } from './services/apiClient';
import { useNotifier } from './context/NotificationProvider';
+import { WishlistProvider } from './context/WishlistContext';
const theme = createTheme({
palette: {
@@ -204,6 +206,7 @@ function App() {
+
@@ -241,11 +244,14 @@ function App() {
} />
+ } />
+
} />
+
);
diff --git a/src/components/NavigationBar.jsx b/src/components/NavigationBar.jsx
index b1e9aad..29597dd 100644
--- a/src/components/NavigationBar.jsx
+++ b/src/components/NavigationBar.jsx
@@ -23,6 +23,7 @@ import {
import MenuIcon from '@mui/icons-material/Menu';
import SearchIcon from '@mui/icons-material/Search';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
+import FavoriteIcon from '@mui/icons-material/Favorite';
import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
import StorefrontIcon from '@mui/icons-material/Storefront';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
@@ -36,6 +37,7 @@ import { debounce } from 'lodash';
import SearchResults from './SearchResults';
import { apiClient } from '../services/apiClient';
import { useNotifier } from '../context/NotificationProvider';
+import { useWishlist } from '../context/WishlistContext';
const navLinks = [
{ label: 'Home', to: '/', icon: },
@@ -58,6 +60,7 @@ function NavigationBar({ cartItemCount }) {
const navigate = useNavigate();
const isMobile = useMediaQuery('(max-width:1200px)');
const { notify } = useNotifier();
+ const { wishlistCount } = useWishlist();
const [searchModalOpen, setSearchModalOpen] = React.useState(false);
React.useEffect(() => {
@@ -231,6 +234,21 @@ function NavigationBar({ cartItemCount }) {
{link.label}
))}
+