From 0a072a242629913947a93f170342bb77f038c716 Mon Sep 17 00:00:00 2001 From: Heet Senghani <20itsen081@ldce.ac.in> Date: Sun, 24 May 2026 17:01:36 +0530 Subject: [PATCH 1/4] =?UTF-8?q?Added=20Feature:=20Wishlists=20+=20?= =?UTF-8?q?=E2=80=9CNotify=20Me=E2=80=9D=20(Restock=20&=20Price-Drop=20Ale?= =?UTF-8?q?rts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 + README.md | 9 + backend/__tests__/wishlist-alerts.spec.js | 410 ++++++++++++++++++++++ backend/index.js | 30 +- backend/models/alertSubscription.js | 68 ++++ backend/models/product.js | 35 +- backend/models/wishlist.js | 31 ++ backend/package.json | 4 +- backend/routes/alerts.js | 221 ++++++++++++ backend/routes/products.js | 56 +++ backend/routes/wishlist.js | 164 +++++++++ backend/services/emailService.js | 44 +++ backend/services/evaluateSubscriptions.js | 72 ++++ openapi.yaml | 194 ++++++++++ package-lock.json | 79 ++++- src/App.jsx | 6 + src/components/NavigationBar.jsx | 26 ++ src/context/WishlistContext.jsx | 123 +++++++ src/pages/ProductDetails.jsx | 222 +++++++++++- src/pages/Wishlist.jsx | 174 +++++++++ 20 files changed, 1943 insertions(+), 32 deletions(-) create mode 100644 backend/__tests__/wishlist-alerts.spec.js create mode 100644 backend/models/alertSubscription.js create mode 100644 backend/models/wishlist.js create mode 100644 backend/routes/alerts.js create mode 100644 backend/routes/wishlist.js create mode 100644 backend/services/emailService.js create mode 100644 backend/services/evaluateSubscriptions.js create mode 100644 src/context/WishlistContext.jsx create mode 100644 src/pages/Wishlist.jsx 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..456c201 --- /dev/null +++ b/backend/__tests__/wishlist-alerts.spec.js @@ -0,0 +1,410 @@ +/** + * 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(); + }); + + const makeProduct = overrides => ({ + _id: '64a000000000000000000099', + name: 'Test Widget', + price: 80, + stock: 5, + ...overrides, + }); + + const makeSub = overrides => ({ + _id: 'sub1', + type: 'restock', + userEmail: 'buyer@example.com', + 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..6842499 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 @@ -50,7 +52,31 @@ mongoose console.error('❌ Pinecone sync error (continuing with fallbacks):', err); } - // 3. Start Express server + // 3. Start cron sweeper for alert subscriptions (daily at 02:00) + if (process.env.FEATURE_ALERTS === 'true') { + try { + const cron = require('node-cron'); + const { evaluateSubscriptions } = require('./services/evaluateSubscriptions'); + const Product = require('./models/product'); + cron.schedule('0 2 * * *', async () => { + console.log('[cron] Running daily alert subscription sweep…'); + try { + const products = await Product.find({}).lean(); + for (const p of products) { + await evaluateSubscriptions(p); + } + console.log(`[cron] Sweep complete — evaluated ${products.length} products.`); + } catch (cronErr) { + console.error('[cron] Sweep error:', cronErr.message); + } + }); + console.log('⏰ Daily alert sweep scheduled at 02:00'); + } catch (cronSetupErr) { + console.error('❌ Failed to schedule alert cron:', cronSetupErr.message); + } + } + + // 4. Start Express server app.listen(PORT, '0.0.0.0', () => { console.log(`Server ready on port ${PORT}.`); }); @@ -80,5 +106,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..30a000a --- /dev/null +++ b/backend/models/alertSubscription.js @@ -0,0 +1,68 @@ +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, + }, + userEmail: { + type: String, + required: 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..4aad4ce 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,17 +41,19 @@ }, "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", "mongoose": "^8.16.3", + "node-cron": "^4.2.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "weaviate-client": "^3.6.2", diff --git a/backend/routes/alerts.js b/backend/routes/alerts.js new file mode 100644 index 0000000..13b233d --- /dev/null +++ b/backend/routes/alerts.js @@ -0,0 +1,221 @@ +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'); +const User = require('../models/user'); + +/** + * @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' }); + + const user = await User.findById(req.user.id).lean(); + if (!user) return res.status(404).json({ msg: 'User 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, + userEmail: user.email, + 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..355d107 100644 --- a/backend/routes/products.js +++ b/backend/routes/products.js @@ -633,4 +633,60 @@ 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) updates.price = Number(price); + if (stock !== undefined) updates.stock = Number(stock); + + 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..fbac7a8 --- /dev/null +++ b/backend/services/emailService.js @@ -0,0 +1,44 @@ +const isProd = process.env.NODE_ENV === 'production'; + +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; + } + + const sgMail = require('@sendgrid/mail'); + sgMail.setApiKey(process.env.SENDGRID_API_KEY); + + 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..baea6cb --- /dev/null +++ b/backend/services/evaluateSubscriptions.js @@ -0,0 +1,72 @@ +const AlertSubscription = require('../models/alertSubscription'); +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; + + if (sub.type === 'restock') { + await sendRestockEmail({ + to: sub.userEmail, + productName: product.name, + productId: product._id.toString(), + }); + } else if (sub.type === 'price_drop') { + await sendPriceDropEmail({ + to: sub.userEmail, + 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..24d2f95 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", @@ -71,6 +72,7 @@ "http-proxy-middleware": "^3.0.0", "jsonwebtoken": "^9.0.2", "mongoose": "^8.16.3", + "node-cron": "^4.2.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "weaviate-client": "^3.6.2", @@ -5252,6 +5254,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 +7324,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 +10871,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 +11033,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", @@ -16390,6 +16428,14 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==" }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -18678,9 +18724,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() { } /> + } /> + } />