diff --git a/.github/workflows/update-reminders-freetrial.yml b/.github/workflows/update-reminders-freetrial.yml new file mode 100644 index 0000000000..d6ea7d5633 --- /dev/null +++ b/.github/workflows/update-reminders-freetrial.yml @@ -0,0 +1,24 @@ +name: Update Reminder Dates - Free Trial + +on: + schedule: + - cron: "0 0 * * *" # every midnight UTC + workflow_dispatch: # allows you to run it manually from GitHub UI + +jobs: + update: + runs-on: ubuntu-latest + steps: + - name: Call backend to update reminder dates + run: | + curl -v --http1.1 -X PATCH "https://project-final-xhjy.onrender.com/date/update-reminders" \ + -H "Authorization: ${{ secrets.API_SECRET }}" \ + -H "Content-Type: application/json" + + - name: Call backend to decrement freeTrial + run: | + curl -v --http1.1 -X PATCH "https://project-final-xhjy.onrender.com/freetrial/update-freetrial" \ + -H "Authorization: ${{ secrets.API_SECRET }}" \ + -H "Content-Type: application/json" + + diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000..14ab325726 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,20 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Start Backend Server", + "type": "shell", + "command": "npm", + "args": [ + "run", + "dev" + ], + "group": "build", + "isBackground": true, + "options": { + "cwd": "${workspaceFolder}/backend" + }, + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/Procfile b/Profile similarity index 100% rename from Procfile rename to Profile diff --git a/README.md b/README.md index 31466b54c2..28ca4dea12 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,90 @@ -# Final Project +# Welcome to SubscriBee +![Beeatrice the bee](https://subscribee-project.netlify.app/subscribee-logo.webp) -Replace this readme with your own information about your project. +A project by Oskar Nordin, Sofie Johansson & Sofia Lennbom -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +## The problem: -## The problem +People tend to forget which subscriptions they have and what the fee is and keep paying for them despite not using them. -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? +## Describe how you approached the problem, and what tools and techniques you used to solve it: + +To solve the problem, we created an application where the user can add all their subscriptions and display them on a dashboard. + +### The dashboard show: +- Quantity +- Monthly and yearly cost +- Subscription category +- Status: active or inactive +- Free trial and trial days, with count down +- Line graph of costs by month +- Reminder for the next upcoming 3 days +- Filtering by category +- Sort by name, cost, reminder date and status + +### Other features +- The user can choose to get notifications by email on the set reminder date +- When a subscription gets cancelled or change status to inactive, Beeatrice the bee notifies the user +- She lets the user know how much the yearly save is and what they can spend the money on instead + +## How did you plan? + +- Daily standups for motivation and keep everyone on track. +- A board with tasks in Notion to get an overview. +- We decided early to focus on getting all the functionality running before we added any styling. + +## What technologies did you use? + +### Frontend +- Core: HTML5, JavaScript (ES6+), React +- Build Tool: Vite +- Styling: Tailwind CSS, with Material Tailwind for UI components +- Routing: React Router +- State Management: Zustand +- Data Visualization: ApexCharts for graphs and charts +- Icons: Heroicons + +### Backend +- Core: Node.js, Express.js +- Database: MongoDB with Mongoose as the ODM +- Email Service: Nodemailer for sending emails +- Task Scheduling: node-cron for recurring tasks +- Authentication: Custom token-based authentication middleware + +## Tooling & Deployment +- Version Control: Git +- Automation: GitHub Actions (workflow automation via .yml pipelines for tasks such as database updates) +- Hosting: Frontend on Netlify, Backend on Render + +## If you had more time, what would be next? +- An admin page to get an overview of and handle scheduled emails, users etc +- Expand the focus on contribution to choose from different charity organisations ## View it live +https://subscribee-project.netlify.app/ + +## Dependencies +### Frontend +- npm i heroicons +- npm i @material-tailwind/react +- npm i apexcharts +- npm i react-apexcharts +- npm i express-list-routes +- npm i framer-motion +- npm i lodash +- npm i react-router-hash-link +- npm i zustand +- npm install -D tailwindcss@3 +- npx tailwindcss init + +### Backend +- npm i nodemailer +- npm i nodemon +- npm i nodecron +- npm i mongoose +- npm i dotenv +- npm i bcrypt -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. \ No newline at end of file +## How to install & run +- npm i +- npm run dev \ No newline at end of file diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index d1438c9108..0000000000 --- a/backend/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Backend part of Final Project - -This project includes the packages and babel setup for an express server, and is just meant to make things a little simpler to get up and running with. - -## Getting Started - -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file diff --git a/backend/authMiddleware.js b/backend/authMiddleware.js new file mode 100644 index 0000000000..2e343e25e8 --- /dev/null +++ b/backend/authMiddleware.js @@ -0,0 +1,23 @@ +import { User } from "./models/User.js" + +export const authenticateUser = async (req, res, next) => { + try { + const user = await User.findOne({ + accessToken: req.header("Authorization"), + }) + if (user) { + req.user = user + next(); + } else { + res.status(401).json({ + message: "Authentication missing or invalid", + loggedOut: true, + }) + } + } catch (error) { + res.status(500).json({ + message: "Internal server error", error: err.message + }) + } +} + diff --git a/backend/models/ScheduledEmail.js b/backend/models/ScheduledEmail.js new file mode 100644 index 0000000000..0623c320c0 --- /dev/null +++ b/backend/models/ScheduledEmail.js @@ -0,0 +1,55 @@ +import mongoose from 'mongoose'; + +const scheduledEmailSchema = new mongoose.Schema( + { + to: { + type: String, + required: true, + }, + subject: { + type: String, + required: true, + }, + text: { + type: String, + required: true, + }, + scheduledDateTime: { + type: Date, + required: true, + }, + isRecurring: { + type: Boolean, + default: false, + }, + status: { + type: String, + enum: ['scheduled', 'sent', 'failed'], + default: 'scheduled', + }, + lastSent: { + type: Date, + }, + nextRun: { + type: Date, + }, + attempts: { + type: Number, + default: 0, + }, + errorMessage: { + type: String, + }, + }, + { + timestamps: true, + } +); + +// Index for efficient querying of due emails +scheduledEmailSchema.index({ nextRun: 1, status: 1 }); + +export const ScheduledEmail = mongoose.model( + 'ScheduledEmail', + scheduledEmailSchema +); diff --git a/backend/models/Subscription.js b/backend/models/Subscription.js new file mode 100644 index 0000000000..c7488476cc --- /dev/null +++ b/backend/models/Subscription.js @@ -0,0 +1,55 @@ +import mongoose from 'mongoose'; + +const subscriptionSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + minlength: 3, + maxlength: 100, + }, + cost: { + type: Number, + required: true, + min: 0, + }, + freeTrial: { + type: Boolean, + default: false, + }, + //days of free trial + trialDays: { + type: Number, + minlength: 0, + }, + //Next remider email + reminderDate: { + type: Date, + required: true, + }, + status: { + type: String, + enum: ['active', 'inactive'], + default: 'active', + required: true, + }, + category: { + type: String, + enum: ['Entertainment', 'Food', 'Health', 'Learning', 'Other'], + required: true, + }, + createdAt: { + type: Date, + default: Date.now, + }, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + sendEmail: { + type: Boolean, + default: true, + }, +}); + +export const Subscription = mongoose.model('Subscription', subscriptionSchema); diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 0000000000..c21f9dcc35 --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,30 @@ +import crypto from "crypto" +import mongoose from "mongoose" + +const userSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + minlength: 3, + maxlength: 100 + }, + email: { + type: String, + required: true, + unique: true, + minlength: 5, + maxlength: 100, + }, + password: { + type: String, + required: true, + minlength: 3, + maxlength: 100, + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString("hex") + } +}) + +export const User = mongoose.model("User", userSchema) \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 08f29f2448..83fb9dd5a4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,7 @@ { "name": "project-final-backend", "version": "1.0.0", + "type": "module", "description": "Server part of final project", "scripts": { "start": "babel-node server.js", @@ -12,9 +13,15 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt": "^6.0.0", "cors": "^2.8.5", + "dotenv": "^17.2.1", "express": "^4.17.3", - "mongoose": "^8.4.0", + "express-list-endpoints": "^7.1.1", + "lodash": "^4.17.21", + "mongoose": "^8.17.0", + "node-cron": "^3.0.3", + "nodemailer": "^7.0.5", "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/backend/routes/emailRoutes.js b/backend/routes/emailRoutes.js new file mode 100644 index 0000000000..ebceb44777 --- /dev/null +++ b/backend/routes/emailRoutes.js @@ -0,0 +1,146 @@ +import mongoEmailScheduler from './mongoEmailScheduler.js'; +import express from 'express'; + +import { sendEmail } from '../sendEmail.js'; + +export const router = express.Router(); + +// Test route +router.get('/test', (req, res) => { + res.json({ message: 'Email routes working!' }); +}); + +// Get all scheduled email reminders +router.get('/reminders', async (req, res) => { + try { + const reminders = await mongoEmailScheduler.getAllScheduledEmails(); + + const formattedReminders = reminders.map((reminder) => ({ + id: reminder._id, + email: reminder.to, + subject: reminder.subject, + type: reminder.isRecurring ? 'recurring' : 'one-time', + nextRun: reminder.nextRun, + status: reminder.status, + createdAt: reminder.createdAt, + isRecurring: reminder.isRecurring, + lastSent: reminder.lastSent, + attempts: reminder.attempts, + })); + + res.status(200).json({ + message: 'Email reminders retrieved successfully', + reminders: formattedReminders, + count: formattedReminders.length, + }); + } catch (error) { + console.error('Error retrieving reminders:', error); + res.status(500).json({ error: 'Failed to retrieve reminders' }); + } +}); + +// Schedule or send email +router.post('/', async (req, res) => { + try { + const { + to, + subject, + text, + sendImmediately, + scheduledDateTime, + isRecurring, + } = req.body; + + if (sendImmediately) { + // Send the email using sendEmail function + try { + await sendEmail({ + to, + subject, + text, + }); + + res.status(200).json({ + message: 'Email sent successfully!', + recipient: to, + }); + } catch (emailError) { + console.error(`❌ Failed to send email to ${to}:`, emailError); + res.status(500).json({ + error: 'Failed to send email', + details: emailError.message, + }); + } + } else { + // Schedule using MongoDB + const scheduledEmail = await mongoEmailScheduler.scheduleEmail({ + to, + subject, + text, + scheduledDateTime, + isRecurring: isRecurring || false, + }); + + res.status(200).json({ + message: 'Email scheduled successfully!', + scheduledEmail: { + id: scheduledEmail._id, + to: scheduledEmail.to, + scheduledDateTime: scheduledEmail.scheduledDateTime, + isRecurring: scheduledEmail.isRecurring, + status: scheduledEmail.status, + }, + }); + } + } catch (error) { + console.error('Error processing email request:', error); + res.status(500).json({ error: 'Failed to process email request' }); + } +}); + +// Debug endpoint to see all emails in MongoDB +router.get('/debug/all', async (req, res) => { + try { + const { ScheduledEmail } = await import('../models/ScheduledEmail.js'); + const allEmails = await ScheduledEmail.find({}).sort({ createdAt: -1 }); + res.json({ + message: 'All emails in database', + emails: allEmails, + count: allEmails.length, + }); + } catch (error) { + console.error('Debug error:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Add this test endpoint +router.post('/test-config', async (req, res) => { + try { + const { to } = req.body; + + if (!to) { + return res.status(400).json({ error: 'Email address required for test' }); + } + + const result = await sendEmail({ + to, + subject: 'Test Email from Subscribee', + text: 'This is a test email to verify the configuration works.', + }); + + res.status(200).json({ + message: 'Test email sent successfully!', + messageId: result.messageId, + response: result.response, + }); + } catch (error) { + console.error('Test email failed:', error); + res.status(500).json({ + error: 'Test email failed', + details: error.message, + code: error.code, + command: error.command, + }); + } +}); diff --git a/backend/routes/mongoEmailScheduler.js b/backend/routes/mongoEmailScheduler.js new file mode 100644 index 0000000000..3a7199c454 --- /dev/null +++ b/backend/routes/mongoEmailScheduler.js @@ -0,0 +1,190 @@ +import express from 'express'; +import _ from 'lodash'; +import cron from 'node-cron'; + +import { ScheduledEmail } from '../models/ScheduledEmail.js'; +import { Subscription } from '../models/Subscription.js'; +import { sendEmail } from '../sendEmail.js'; + +const router = express.Router(); + +/** + * Starts the MongoEmailScheduler as a function. + * Checks every minute for scheduled emails and processes them. + */ +function startMongoEmailScheduler() { + cron.schedule('* * * * *', async () => { + await processDueEmails(); + }); +} + +// Queries database for emails to send. +async function processDueEmails() { + try { + const now = new Date(); + const dueEmails = await ScheduledEmail.find({ + status: 'scheduled', + nextRun: { $lte: now }, + }); + + if (dueEmails.length > 0) { + console.log(`πŸ“§ Found ${dueEmails.length} due emails to process`); + } + + // Group emails by recipient and scheduled time (to the minute) + const grouped = _.groupBy( + dueEmails, + (email) => + `${email.to}|${new Date(email.nextRun).toISOString().slice(0, 16)}` + ); + + for (const groupKey in grouped) { + const group = grouped[groupKey]; + await processEmailGroup(group); + } + } catch (error) { + console.error('❌ Error processing due emails:', error); + } +} + +// Helper function to process a group of emails +async function processEmailGroup(emailGroup) { + if (emailGroup.length === 0) return; + const recipient = emailGroup[0].to; + + // Combine subjects/texts + const combinedSubject = `Your ${emailGroup.length} scheduled updates`; + const combinedText = emailGroup + .map( + (email, idx) => + `#${idx + 1}\nSubject: ${email.subject}\n${email.text}\n` + ) + .join('\n---\n'); + + try { + await sendEmail({ + to: recipient, + subject: combinedSubject, + text: combinedText, + }); + + for (const email of emailGroup) { + if (email.isRecurring) { + const nextRun = new Date(email.nextRun); + nextRun.setMonth(nextRun.getMonth() + 1); + email.nextRun = nextRun; + email.lastSent = new Date(); + } else { + email.status = 'sent'; + } + await email.save(); + } + } catch (error) { + for (const email of emailGroup) { + email.attempts += 1; + email.errorMessage = error.message; + if (email.attempts < 3) { + email.nextRun = new Date(Date.now() + 5 * 60 * 1000); + } else { + email.status = 'failed'; + } + await email.save(); + } + console.error( + `Failed to send combined email to ${recipient}:`, + error.message + ); + } +} + +// Processes a single email +async function processEmail(email) { + try { + const subscription = await Subscription.findById(email.subscriptionId); + + if (!subscription || subscription.sendEmail === false) { + email.status = 'skipped'; + await email.save(); + return; + } + + await sendEmail({ + to: email.to, + subject: email.subject, + text: email.text, + }); + + if (email.isRecurring) { + const nextRun = new Date(email.nextRun); + nextRun.setMonth(nextRun.getMonth() + 1); + email.nextRun = nextRun; + email.lastSent = new Date(); + } else { + email.status = 'sent'; + } + + await email.save(); + } catch (error) { + email.attempts += 1; + email.errorMessage = error.message; + + if (email.attempts < 3) { + email.nextRun = new Date(Date.now() + 5 * 60 * 1000); + } else { + email.status = 'failed'; + } + + await email.save(); + console.error(`Failed to send email to ${email.to}:`, error.message); + } +} + +// Schedules a new email +async function scheduleEmail(emailData) { + if (!emailData.sendEmail) { + return null; + } + try { + const { subscriptionId, to, subject, text, scheduledDateTime } = emailData; + const scheduledDate = new Date(scheduledDateTime); + + const scheduledEmail = new ScheduledEmail({ + subscriptionId, + to, + subject, + text, + scheduledDateTime: scheduledDate, + isRecurring: false, + nextRun: scheduledDate, + status: 'scheduled', + }); + + const savedEmail = await scheduledEmail.save(); + return savedEmail; + } catch (error) { + console.error('❌ Error scheduling email:', error); + throw error; + } +} + +// Gets all scheduled (and sent) emails +async function getAllScheduledEmails() { + try { + return await ScheduledEmail.find({ + status: { $in: ['scheduled', 'sent'] }, + }).sort({ nextRun: 1 }); + } catch (error) { + console.error('❌ Error fetching scheduled emails:', error); + return []; + } +} + +// There is no need for a stop function in this stateless version. + +// Express route to delete all scheduled emails for a given subscription +router.delete('/:id', async (req, res) => { + await ScheduledEmail.deleteMany({ subscriptionId: req.params.id }); +}); + +// Export the function to start the scheduler +export default startMongoEmailScheduler; diff --git a/backend/routes/subscriptionRoutes.js b/backend/routes/subscriptionRoutes.js new file mode 100644 index 0000000000..7c24be9765 --- /dev/null +++ b/backend/routes/subscriptionRoutes.js @@ -0,0 +1,229 @@ +import express from 'express'; +import cron from 'node-cron'; + +import { authenticateUser } from '../authMiddleware.js'; +import { ScheduledEmail } from '../models/ScheduledEmail.js'; +import { Subscription } from '../models/Subscription.js'; +import mongoEmailScheduler from './mongoEmailScheduler.js'; + +const router = express.Router(); + +// Initialize global cron jobs object +global.cronJobs = global.cronJobs || {}; + +//To get all subscriptions +router.get('/', authenticateUser, async (req, res) => { + try { + const subscriptions = await Subscription.find({ user: req.user._id }); + + if (!subscriptions || subscriptions.length === 0) { + return res.status(200).json({ + success: true, + response: null, + message: 'No subscription was found', + }); + } + + res.status(200).json({ + success: true, + response: subscriptions, + }); + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: 'Failed to fetch subscription', + }); + } +}); + +//To get one subscription based on id (endpoint is /subscriptions/:id) +router.get('/:id', authenticateUser, async (req, res) => { + const { id } = req.params; + + try { + const subscription = await Subscription.findOne({ + _id: id, + user: req.user._id, + }); + + if (!subscription) { + return res.status(404).json({ + success: false, + response: null, + message: 'Subscription not found', + }); + } + + res.status(200).json({ + success: true, + response: subscription, + }); + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Subscription couldn't be found", + }); + } +}); + +//To create/save a subscription to the db (endpoint is /subscriptions) +router.post('/', authenticateUser, async (req, res) => { + const { + name, + category, + cost, + status, + freeTrial, + trialDays, + reminderDate, + sendEmail, + } = req.body; + + if (!req.user) { + return res + .status(403) + .json({ error: 'You must be logged in to add a subscription' }); + } + + try { + const subscription = new Subscription({ + name, + category, + cost, + status, + freeTrial, + trialDays, + reminderDate, + sendEmail, + user: req.user._id, + }); + + const newSubscription = await subscription.save(); + + + // Instead, schedule email only if sendEmail is true + if (sendEmail) { + await mongoEmailScheduler.scheduleEmail({ + to: req.user.email, + subject: `Reminder for ${name}`, + text: `Your subscription for ${name} is due on ${reminderDate}`, + scheduledDateTime: reminderDate, + isRecurring: false, + sendEmail: true, + subscriptionId: newSubscription._id, // Used for tracking and deletion scheduled emails later + }); + } + + res.status(201).json({ + success: true, + response: newSubscription, + message: 'Subscription created successfully', + }); + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Couldn't create subscription", + }); + } +}); + +//To edit a subscription (endpoint is /subscriptions/:id) +router.patch('/:id', authenticateUser, async (req, res) => { + const { id } = req.params; + const { + name, + cost, + freeTrial, + trialDays, + reminderDate, + status, + category, + sendEmail, + } = req.body; + + try { + const editSubscription = await Subscription.findOneAndUpdate( + { _id: id, user: req.user._id }, + { + name, + cost, + freeTrial, + trialDays, + reminderDate, + status, + category, + sendEmail, + }, + { + new: true, + runValidators: true, + } + ); + + if (!editSubscription) { + return res.status(404).json({ + success: false, + response: null, + message: 'Subscription not found', + }); + } + + // If sendEmail is updated to true, schedule the email if not already scheduled + if (sendEmail) { + await mongoEmailScheduler.scheduleEmail({ + to: req.user.email, + subject: `Reminder for ${name}`, + text: `Your subscription for ${name} is due on ${reminderDate}`, + scheduledDateTime: reminderDate, + isRecurring: false, + sendEmail: true, + subscriptionId: editSubscription._id, + }); + } else { + // If sendEmail is updated to false, delete any scheduled emails for this subscription + await ScheduledEmail.deleteMany({ subscriptionId: id }); + } + + res.status(200).json(editSubscription); + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: 'Failed to update subscription', + }); + } +}); + +// To delete a subscription +router.delete('/:id', authenticateUser, async (req, res) => { + try { + const { id } = req.params; + + // Delete the subscription + const deleted = await Subscription.findByIdAndDelete(id); + + if (!deleted) { + return res.status(404).json({ message: 'Subscription not found' }); + } + + // Delete all scheduled emails for this subscription + await ScheduledEmail.deleteMany({ subscriptionId: id }); + + // Cancel any node-cron jobs for this subscription + if (global.cronJobs && global.cronJobs[id]) { + global.cronJobs[id].stop(); + delete global.cronJobs[id]; + } + + res.status(200).json({ + message: 'Subscription and related scheduled emails deleted', + }); + } catch (error) { + res.status(500).json({ message: 'Error deleting subscription', error }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/routes/updateFreeTrialRoutes.js b/backend/routes/updateFreeTrialRoutes.js new file mode 100644 index 0000000000..fbb8c4c29c --- /dev/null +++ b/backend/routes/updateFreeTrialRoutes.js @@ -0,0 +1,58 @@ +import express from 'express'; +import mongoose from 'mongoose'; + +import { Subscription } from '../models/Subscription.js'; + +const router = express.Router(); + +router.patch('/update-freetrial', async (req, res) => { + const authHeader = req.headers.authorization; + + if (authHeader !== process.env.API_SECRET) { + return res.status(403).json({ + error: 'Unauthorized', + success: false, + }); + } + + try { + const subsToUpdate = await Subscription.find({ + trialDays: { $gt: 0 }, + }).select('_id trialDays'); //$gt greater then + + if (subsToUpdate.length === 0) { + return res.json({ message: 'No subscriptions needed updating.' }); + } + + const result = await Subscription.updateMany({ trialDays: { $gte: 0 } }, [ + { + $set: { + // decrement trialDays but never below 0 + trialDays: { $max: [{ $subtract: ['$trialDays', 1] }, 0] }, + // freeTrial is true if trialDays (before decrement) > 1 + freeTrial: { $gt: ['$trialDays', 1] }, + }, + }, + ]); + + const updatedSubs = await Subscription.find({ + _id: { $in: subsToUpdate.map((s) => s._id) }, + }) + .select('_id trialDays') + .lean(); + + res.json({ + message: `Updated ${result.modifiedCount} subscriptions`, + updated: updatedSubs, + }); + } catch (err) { + console.error(err); + res.status(500).json({ + error: 'Failed to update trialDays', + success: false, + message: err.message, + }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/routes/updateRemindersRoutes.js b/backend/routes/updateRemindersRoutes.js new file mode 100644 index 0000000000..b22c0db27c --- /dev/null +++ b/backend/routes/updateRemindersRoutes.js @@ -0,0 +1,70 @@ +import express from 'express'; +import mongoose from 'mongoose'; + +import { Subscription } from '../models/Subscription.js'; + +const router = express.Router(); + +router.patch('/update-reminders', async (req, res) => { + const authHeader = req.headers.authorization; + + // Secure auth check + if (authHeader !== process.env.API_SECRET) { + return res.status(403).json({ + error: 'Unauthorized', + success: false, + }); + } + + try { + const today = new Date(); + + // Find IDs first so we know what we're updating + const subsToUpdate = await Subscription.find({ + reminderDate: { $lt: today }, + }).select('_id reminderDate'); + + if (subsToUpdate.length === 0) { + return res.json({ message: 'No subscriptions needed updating.' }); + } + + // Bulk update: add 1 month + const result = await Subscription.updateMany( + { _id: { $in: subsToUpdate.map((s) => s._id) } }, + [ + { + $set: { + reminderDate: { + $dateAdd: { + startDate: '$reminderDate', + unit: 'month', + amount: 1, + }, + }, + }, + }, + ] + ); + + // Fetch updated docs for verification + const updatedSubs = await Subscription.find({ + _id: { $in: subsToUpdate.map((s) => s._id) }, + }) + .select('_id reminderDate') + .lean(); + + res.json({ + message: `Updated ${updatedSubs.length} subscriptions.`, + updated: updatedSubs, + }); + } catch (err) { + console.error(err); + res.status(500).json({ + error: 'Failed to update reminders', + success: false, + message: err.message, + }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js new file mode 100644 index 0000000000..c4f52084c7 --- /dev/null +++ b/backend/routes/userRoutes.js @@ -0,0 +1,98 @@ +import bcrypt from 'bcrypt'; +import express from 'express'; + +import { User } from '../models/User.js'; + +const router = express.Router(); + +// To get all users +router.get('/', async (req, res) => { + try { + const users = await User.find({}); + + if (!users || users.length === 0) { + return res.status(404).json({ + success: false, + response: null, + message: 'No matching user', + }); + } + + res.status(200).json({ + success: true, + + response: users, + }); + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: 'Failed to fetch user', + }); + } +}); + +// To register a new user +router.post('/', async (req, res) => { + try { + const { name, email, password } = req.body; + const salt = bcrypt.genSaltSync(); + + const user = new User({ + name, + email, + password: bcrypt.hashSync(password, salt), + }); + await user.save(); + + res.status(200).json({ + success: true, + message: 'User created successfully', + response: { + id: user._id, + accessToken: user.accessToken, + }, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: 'Failed to create user', + error: error.message, + response: error, + }); + } +}); + +// To login an existing user +router.post('/login', async (req, res) => { + try { + const { email, password } = req.body; + + // Find user by email in the database + const user = await User.findOne({ email: email.toLowerCase() }); + + if (user && bcrypt.compareSync(password, user.password)) { + res.json({ + success: true, + message: 'Login successful', + id: user.id, + name: user.name, + email: user.email, + accessToken: user.accessToken, + }); + } else { + res.status(401).json({ + success: false, + message: 'Invalid email or password', + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: 'Something went wrong', + error, + }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/sendEmail.js b/backend/sendEmail.js new file mode 100644 index 0000000000..02e78c22f0 --- /dev/null +++ b/backend/sendEmail.js @@ -0,0 +1,49 @@ +import dotenv from 'dotenv'; +import nodemailer from 'nodemailer'; + +dotenv.config(); + +const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, +}); + +async function verifyTransporter() { + try { + await transporter.verify(); + return true; + } catch (error) { + console.error('❌ Email transporter verification failed:', error); + return false; + } +} + +export async function sendEmail({ to, subject, text }) { + try { + // Verify transporter before sending + const isVerified = await verifyTransporter(); + if (!isVerified) { + throw new Error('Email transporter verification failed'); + } + + const result = await transporter.sendMail({ + from: process.env.EMAIL_USER, + to, + subject, + text, + }); + + return result; + } catch (error) { + console.error(`❌ Email sending failed:`, error); + console.error(`❌ Error details:`, error.code, error.command); + + if (error.response) console.error('SMTP Response:', error.response); + if (error.responseCode) console.error('SMTP Code:', error.responseCode); + + throw error; + } +} diff --git a/backend/server.js b/backend/server.js index 070c875189..670e16594d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,22 +1,111 @@ -import express from "express"; -import cors from "cors"; -import mongoose from "mongoose"; +import cors from 'cors'; +import dotenv from 'dotenv'; +import express from 'express'; +import expressListEndpoints from 'express-list-endpoints'; +import mongoose from 'mongoose'; + +import { router as emailRoutes } from './routes/emailRoutes.js'; +import mongoEmailScheduler from './routes/mongoEmailScheduler.js'; +import subscriptionRoutes from './routes/subscriptionRoutes.js'; +import updateFreeTrialRoutes from './routes/updateFreeTrialRoutes.js'; +import updateRemindersRoutes from './routes/updateRemindersRoutes.js'; +import userRoutes from './routes/userRoutes.js'; + +dotenv.config(); + +const mongoUrl = + process.env.MONGO_URL || 'mongodb://localhost:27017/subscribee-app'; + +mongoose + .connect(mongoUrl, { + useNewUrlParser: true, + useUnifiedTopology: true, + }) + .then(() => { + // Start the email scheduler + mongoEmailScheduler.startScheduler(); + }) + .catch((error) => { + console.error('❌ MongoDB connection error:', error); + }); -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); mongoose.Promise = Promise; -const port = process.env.PORT || 8080; +const port = process.env.PORT || 8081; const app = express(); app.use(cors()); app.use(express.json()); -app.get("/", (req, res) => { - res.send("Hello Technigo!"); +// Root endpoint +app.get('/', (req, res) => { + const endpoints = expressListEndpoints(app); + res.json({ + message: 'Welcome to Subscribee API', + endpoints: endpoints, + }); +}); + +// Page endpoints +app.get('/home', (req, res) => { + res.json({ + page: 'home', + message: 'Welcome to Subscribee - Your subscription management platform', + features: ['Manage subscriptions', 'Track expenses', 'Get insights'], + }); +}); + +app.get('/about', (req, res) => { + res.json({ + page: 'about', + message: 'About Subscribee', + description: + 'Subscribee helps you manage and track all your subscriptions in one place', + }); +}); + +app.get('/login', (req, res) => { + res.json({ + page: 'login', + message: 'Login to your Subscribee account', + endpoint: '/users/login', + }); +}); + +app.get('/signup', (req, res) => { + res.json({ + page: 'signup', + message: 'Create a new Subscribee account', + endpoint: '/users/register', + }); +}); + +app.get('/admin', (req, res) => { + res.json({ + page: 'admin', + message: 'Admin dashboard', + description: 'Administrative functions and user management', + }); }); +// Route connections +app.use('/date', updateRemindersRoutes); +app.use('/freetrial', updateFreeTrialRoutes); +app.use('/users', userRoutes); +app.use('/subscriptions', subscriptionRoutes); +app.use('/emails', emailRoutes); + +global.cronJobs = global.cronJobs || {}; + // Start the server app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); + console.log(`πŸš€ Server running on http://localhost:${port}`); + console.log('πŸ“§ MongoDB email scheduler is active'); +}); + +// Graceful shutdown +process.on('SIGINT', () => { + mongoEmailScheduler.stop(); + mongoose.connection.close(); + process.exit(0); }); diff --git a/backend/services/emailService.js b/backend/services/emailService.js new file mode 100644 index 0000000000..c0c32e5b90 --- /dev/null +++ b/backend/services/emailService.js @@ -0,0 +1,18 @@ +import { sendEmail } from '../sendEmail.js'; + +export class EmailService { + async sendImmediateEmail(emailData) { + try { + await sendEmail({ + to: emailData.to, + subject: emailData.subject, + text: emailData.text, + }); + } catch (error) { + console.error(`❌ Failed to send email to ${emailData.to}:`, error); + throw error; + } + } +} + +export const emailService = new EmailService(); diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 5cdb1d9cf3..0000000000 --- a/frontend/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Frontend part of Final Project - -This boilerplate is designed to give you a head start in your React projects, with a focus on understanding the structure and components. As a student of Technigo, you'll find this guide helpful in navigating and utilizing the repository. - -## Getting Started - -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file diff --git a/frontend/dist/_redirects b/frontend/dist/_redirects new file mode 100644 index 0000000000..50a463356b --- /dev/null +++ b/frontend/dist/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/frontend/dist/assets/index-PV6aK9pL.css b/frontend/dist/assets/index-PV6aK9pL.css new file mode 100644 index 0000000000..dd38bb63ff --- /dev/null +++ b/frontend/dist/assets/index-PV6aK9pL.css @@ -0,0 +1 @@ +/*! tailwindcss v4.1.11 | MIT License | https://tailwindcss.com */@layer properties{@supports ((-webkit-hyphens:none) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-duration:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}.visible{visibility:visible}.absolute{position:absolute}.relative{position:relative}.static{position:static}.mx-auto{margin-inline:auto}.block{display:block}.hidden{display:none}.w-full{width:100%}.resize{resize:both}.border{border-style:var(--tw-border-style);border-width:1px}.text-center{text-align:center}.italic{font-style:italic}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,visibility,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,ease);transition-duration:var(--tw-duration,0s)}.transition\!{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,visibility,content-visibility,overlay,pointer-events!important;transition-timing-function:var(--tw-ease,ease)!important;transition-duration:var(--tw-duration,0s)!important}.duration-200{--tw-duration:.2s;transition-duration:.2s}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-duration{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} diff --git a/frontend/dist/assets/index-rF6rWOwD.js b/frontend/dist/assets/index-rF6rWOwD.js new file mode 100644 index 0000000000..1591760a2f --- /dev/null +++ b/frontend/dist/assets/index-rF6rWOwD.js @@ -0,0 +1,51 @@ +(function(){const s=document.createElement("link").relList;if(s&&s.supports&&s.supports("modulepreload"))return;for(const f of document.querySelectorAll('link[rel="modulepreload"]'))p(f);new MutationObserver(f=>{for(const m of f)if(m.type==="childList")for(const x of m.addedNodes)x.tagName==="LINK"&&x.rel==="modulepreload"&&p(x)}).observe(document,{childList:!0,subtree:!0});function a(f){const m={};return f.integrity&&(m.integrity=f.integrity),f.referrerPolicy&&(m.referrerPolicy=f.referrerPolicy),f.crossOrigin==="use-credentials"?m.credentials="include":f.crossOrigin==="anonymous"?m.credentials="omit":m.credentials="same-origin",m}function p(f){if(f.ep)return;f.ep=!0;const m=a(f);fetch(f.href,m)}})();function mc(u){return u&&u.__esModule&&Object.prototype.hasOwnProperty.call(u,"default")?u.default:u}var Ku={exports:{}},Lr={},Yu={exports:{}},b={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var tc;function Ld(){if(tc)return b;tc=1;var u=Symbol.for("react.element"),s=Symbol.for("react.portal"),a=Symbol.for("react.fragment"),p=Symbol.for("react.strict_mode"),f=Symbol.for("react.profiler"),m=Symbol.for("react.provider"),x=Symbol.for("react.context"),C=Symbol.for("react.forward_ref"),S=Symbol.for("react.suspense"),k=Symbol.for("react.memo"),T=Symbol.for("react.lazy"),z=Symbol.iterator;function F(v){return v===null||typeof v!="object"?null:(v=z&&v[z]||v["@@iterator"],typeof v=="function"?v:null)}var J={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Y=Object.assign,M={};function O(v,_,q){this.props=v,this.context=_,this.refs=M,this.updater=q||J}O.prototype.isReactComponent={},O.prototype.setState=function(v,_){if(typeof v!="object"&&typeof v!="function"&&v!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,v,_,"setState")},O.prototype.forceUpdate=function(v){this.updater.enqueueForceUpdate(this,v,"forceUpdate")};function W(){}W.prototype=O.prototype;function V(v,_,q){this.props=v,this.context=_,this.refs=M,this.updater=q||J}var Z=V.prototype=new W;Z.constructor=V,Y(Z,O.prototype),Z.isPureReactComponent=!0;var ne=Array.isArray,he=Object.prototype.hasOwnProperty,xe={current:null},Ee={key:!0,ref:!0,__self:!0,__source:!0};function ze(v,_,q){var ee,re={},le=null,ae=null;if(_!=null)for(ee in _.ref!==void 0&&(ae=_.ref),_.key!==void 0&&(le=""+_.key),_)he.call(_,ee)&&!Ee.hasOwnProperty(ee)&&(re[ee]=_[ee]);var ue=arguments.length-2;if(ue===1)re.children=q;else if(1>>1,_=D[v];if(0>>1;vf(re,U))le<_&&0>f(ae,re)?(D[v]=ae,D[le]=U,v=le):(D[v]=re,D[ee]=U,v=ee);else if(le<_&&0>f(ae,U))D[v]=ae,D[le]=U,v=le;else break e}}return X}function f(D,X){var U=D.sortIndex-X.sortIndex;return U!==0?U:D.id-X.id}if(typeof performance=="object"&&typeof performance.now=="function"){var m=performance;u.unstable_now=function(){return m.now()}}else{var x=Date,C=x.now();u.unstable_now=function(){return x.now()-C}}var S=[],k=[],T=1,z=null,F=3,J=!1,Y=!1,M=!1,O=typeof setTimeout=="function"?setTimeout:null,W=typeof clearTimeout=="function"?clearTimeout:null,V=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function Z(D){for(var X=a(k);X!==null;){if(X.callback===null)p(k);else if(X.startTime<=D)p(k),X.sortIndex=X.expirationTime,s(S,X);else break;X=a(k)}}function ne(D){if(M=!1,Z(D),!Y)if(a(S)!==null)Y=!0,Be(he);else{var X=a(k);X!==null&&ge(ne,X.startTime-D)}}function he(D,X){Y=!1,M&&(M=!1,W(ze),ze=-1),J=!0;var U=F;try{for(Z(X),z=a(S);z!==null&&(!(z.expirationTime>X)||D&&!ht());){var v=z.callback;if(typeof v=="function"){z.callback=null,F=z.priorityLevel;var _=v(z.expirationTime<=X);X=u.unstable_now(),typeof _=="function"?z.callback=_:z===a(S)&&p(S),Z(X)}else p(S);z=a(S)}if(z!==null)var q=!0;else{var ee=a(k);ee!==null&&ge(ne,ee.startTime-X),q=!1}return q}finally{z=null,F=U,J=!1}}var xe=!1,Ee=null,ze=-1,Ce=5,je=-1;function ht(){return!(u.unstable_now()-jeD||125v?(D.sortIndex=U,s(k,D),a(S)===null&&D===a(k)&&(M?(W(ze),ze=-1):M=!0,ge(ne,U-v))):(D.sortIndex=_,s(S,D),Y||J||(Y=!0,Be(he))),D},u.unstable_shouldYield=ht,u.unstable_wrapCallback=function(D){var X=F;return function(){var U=F;F=X;try{return D.apply(this,arguments)}finally{F=U}}}}(Ju)),Ju}var uc;function jd(){return uc||(uc=1,Gu.exports=Dd()),Gu.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var ic;function Od(){if(ic)return Ye;ic=1;var u=ti(),s=jd();function a(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),S=Object.prototype.hasOwnProperty,k=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,T={},z={};function F(e){return S.call(z,e)?!0:S.call(T,e)?!1:k.test(e)?z[e]=!0:(T[e]=!0,!1)}function J(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Y(e,t,n,r){if(t===null||typeof t>"u"||J(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function M(e,t,n,r,l,o,i){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=o,this.removeEmptyString=i}var O={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){O[e]=new M(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];O[t]=new M(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){O[e]=new M(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){O[e]=new M(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){O[e]=new M(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){O[e]=new M(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){O[e]=new M(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){O[e]=new M(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){O[e]=new M(e,5,!1,e.toLowerCase(),null,!1,!1)});var W=/[\-:]([a-z])/g;function V(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(W,V);O[t]=new M(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(W,V);O[t]=new M(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(W,V);O[t]=new M(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){O[e]=new M(e,1,!1,e.toLowerCase(),null,!1,!1)}),O.xlinkHref=new M("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){O[e]=new M(e,1,!1,e.toLowerCase(),null,!0,!0)});function Z(e,t,n,r){var l=O.hasOwnProperty(t)?O[t]:null;(l!==null?l.type!==0:r||!(2c||l[i]!==o[c]){var d=` +`+l[i].replace(" at new "," at ");return e.displayName&&d.includes("")&&(d=d.replace("",e.displayName)),d}while(1<=i&&0<=c);break}}}finally{q=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?_(e):""}function re(e){switch(e.tag){case 5:return _(e.type);case 16:return _("Lazy");case 13:return _("Suspense");case 19:return _("SuspenseList");case 0:case 2:case 15:return e=ee(e.type,!1),e;case 11:return e=ee(e.type.render,!1),e;case 1:return e=ee(e.type,!0),e;default:return""}}function le(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Ee:return"Fragment";case xe:return"Portal";case Ce:return"Profiler";case ze:return"StrictMode";case Xe:return"Suspense";case ut:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case ht:return(e.displayName||"Context")+".Consumer";case je:return(e._context.displayName||"Context")+".Provider";case mt:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case vt:return t=e.displayName||null,t!==null?t:le(e.type)||"Memo";case Be:t=e._payload,e=e._init;try{return le(e(t))}catch{}}return null}function ae(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return le(t);case 8:return t===ze?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function ue(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function de(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Ge(e){var t=de(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,o=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(i){r=""+i,o.call(this,i)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(i){r=""+i},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Or(e){e._valueTracker||(e._valueTracker=Ge(e))}function ii(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=de(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Mr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ql(e,t){var n=t.checked;return U({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ai(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=ue(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function si(e,t){t=t.checked,t!=null&&Z(e,"checked",t,!1)}function bl(e,t){si(e,t);var n=ue(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?eo(e,t.type,n):t.hasOwnProperty("defaultValue")&&eo(e,t.type,ue(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ci(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function eo(e,t,n){(t!=="number"||Mr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Vn=Array.isArray;function mn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=Ir.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Qn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Kn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Fc=["Webkit","ms","Moz","O"];Object.keys(Kn).forEach(function(e){Fc.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Kn[t]=Kn[e]})});function vi(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Kn.hasOwnProperty(e)&&Kn[e]?(""+t).trim():t+"px"}function yi(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=vi(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Dc=U({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function ro(e,t){if(t){if(Dc[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(a(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(a(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(a(61))}if(t.style!=null&&typeof t.style!="object")throw Error(a(62))}}function lo(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var oo=null;function uo(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var io=null,vn=null,yn=null;function gi(e){if(e=hr(e)){if(typeof io!="function")throw Error(a(280));var t=e.stateNode;t&&(t=ul(t),io(e.stateNode,e.type,t))}}function wi(e){vn?yn?yn.push(e):yn=[e]:vn=e}function Si(){if(vn){var e=vn,t=yn;if(yn=vn=null,gi(e),t)for(e=0;e>>=0,e===0?32:31-(Vc(e)/Qc|0)|0}var Hr=64,Wr=4194304;function Jn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Vr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,o=e.pingedLanes,i=n&268435455;if(i!==0){var c=i&~l;c!==0?r=Jn(c):(o&=i,o!==0&&(r=Jn(o)))}else i=n&~l,i!==0?r=Jn(i):o!==0&&(r=Jn(o));if(r===0)return 0;if(t!==0&&t!==r&&(t&l)===0&&(l=r&-r,o=t&-t,l>=o||l===16&&(o&4194240)!==0))return t;if((r&4)!==0&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Zn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-it(t),e[t]=n}function Gc(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=or),Xi=" ",Gi=!1;function Ji(e,t){switch(e){case"keyup":return Cf.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Zi(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Sn=!1;function _f(e,t){switch(e){case"compositionend":return Zi(t);case"keypress":return t.which!==32?null:(Gi=!0,Xi);case"textInput":return e=t.data,e===Xi&&Gi?null:e;default:return null}}function Rf(e,t){if(Sn)return e==="compositionend"||!_o&&Ji(e,t)?(e=Hi(),Gr=So=$t=null,Sn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=la(n)}}function ua(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?ua(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function ia(){for(var e=window,t=Mr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Mr(e.document)}return t}function Lo(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Mf(e){var t=ia(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&ua(n.ownerDocument.documentElement,n)){if(r!==null&&Lo(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,o=Math.min(r.start,l);r=r.end===void 0?o:Math.min(r.end,l),!e.extend&&o>r&&(l=r,r=o,o=l),l=oa(n,o);var i=oa(n,r);l&&i&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==i.node||e.focusOffset!==i.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),o>r?(e.addRange(t),e.extend(i.node,i.offset)):(t.setEnd(i.node,i.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,kn=null,To=null,sr=null,zo=!1;function aa(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;zo||kn==null||kn!==Mr(r)||(r=kn,"selectionStart"in r&&Lo(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),sr&&ar(sr,r)||(sr=r,r=rl(To,"onSelect"),0_n||(e.current=Wo[_n],Wo[_n]=null,_n--)}function se(e,t){_n++,Wo[_n]=e.current,e.current=t}var Wt={},Oe=Ht(Wt),He=Ht(!1),rn=Wt;function Rn(e,t){var n=e.type.contextTypes;if(!n)return Wt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},o;for(o in n)l[o]=t[o];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function We(e){return e=e.childContextTypes,e!=null}function il(){fe(He),fe(Oe)}function Ea(e,t,n){if(Oe.current!==Wt)throw Error(a(168));se(Oe,t),se(He,n)}function Ca(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(a(108,ae(e)||"Unknown",l));return U({},n,r)}function al(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Wt,rn=Oe.current,se(Oe,e),se(He,He.current),!0}function Pa(e,t,n){var r=e.stateNode;if(!r)throw Error(a(169));n?(e=Ca(e,t,rn),r.__reactInternalMemoizedMergedChildContext=e,fe(He),fe(Oe),se(Oe,e)):fe(He),se(He,n)}var Pt=null,sl=!1,Vo=!1;function _a(e){Pt===null?Pt=[e]:Pt.push(e)}function Xf(e){sl=!0,_a(e)}function Vt(){if(!Vo&&Pt!==null){Vo=!0;var e=0,t=ie;try{var n=Pt;for(ie=1;e>=i,l-=i,_t=1<<32-it(t)+l|n<G?(Te=K,K=null):Te=K.sibling;var oe=P(y,K,g[G],L);if(oe===null){K===null&&(K=Te);break}e&&K&&oe.alternate===null&&t(y,K),h=o(oe,h,G),Q===null?H=oe:Q.sibling=oe,Q=oe,K=Te}if(G===g.length)return n(y,K),pe&&on(y,G),H;if(K===null){for(;GG?(Te=K,K=null):Te=K.sibling;var bt=P(y,K,oe.value,L);if(bt===null){K===null&&(K=Te);break}e&&K&&bt.alternate===null&&t(y,K),h=o(bt,h,G),Q===null?H=bt:Q.sibling=bt,Q=bt,K=Te}if(oe.done)return n(y,K),pe&&on(y,G),H;if(K===null){for(;!oe.done;G++,oe=g.next())oe=N(y,oe.value,L),oe!==null&&(h=o(oe,h,G),Q===null?H=oe:Q.sibling=oe,Q=oe);return pe&&on(y,G),H}for(K=r(y,K);!oe.done;G++,oe=g.next())oe=j(K,y,G,oe.value,L),oe!==null&&(e&&oe.alternate!==null&&K.delete(oe.key===null?G:oe.key),h=o(oe,h,G),Q===null?H=oe:Q.sibling=oe,Q=oe);return e&&K.forEach(function(Nd){return t(y,Nd)}),pe&&on(y,G),H}function ke(y,h,g,L){if(typeof g=="object"&&g!==null&&g.type===Ee&&g.key===null&&(g=g.props.children),typeof g=="object"&&g!==null){switch(g.$$typeof){case he:e:{for(var H=g.key,Q=h;Q!==null;){if(Q.key===H){if(H=g.type,H===Ee){if(Q.tag===7){n(y,Q.sibling),h=l(Q,g.props.children),h.return=y,y=h;break e}}else if(Q.elementType===H||typeof H=="object"&&H!==null&&H.$$typeof===Be&&Fa(H)===Q.type){n(y,Q.sibling),h=l(Q,g.props),h.ref=mr(y,Q,g),h.return=y,y=h;break e}n(y,Q);break}else t(y,Q);Q=Q.sibling}g.type===Ee?(h=hn(g.props.children,y.mode,L,g.key),h.return=y,y=h):(L=Il(g.type,g.key,g.props,null,y.mode,L),L.ref=mr(y,h,g),L.return=y,y=L)}return i(y);case xe:e:{for(Q=g.key;h!==null;){if(h.key===Q)if(h.tag===4&&h.stateNode.containerInfo===g.containerInfo&&h.stateNode.implementation===g.implementation){n(y,h.sibling),h=l(h,g.children||[]),h.return=y,y=h;break e}else{n(y,h);break}else t(y,h);h=h.sibling}h=Bu(g,y.mode,L),h.return=y,y=h}return i(y);case Be:return Q=g._init,ke(y,h,Q(g._payload),L)}if(Vn(g))return $(y,h,g,L);if(X(g))return B(y,h,g,L);pl(y,g)}return typeof g=="string"&&g!==""||typeof g=="number"?(g=""+g,h!==null&&h.tag===6?(n(y,h.sibling),h=l(h,g),h.return=y,y=h):(n(y,h),h=Au(g,y.mode,L),h.return=y,y=h),i(y)):n(y,h)}return ke}var zn=Da(!0),ja=Da(!1),hl=Ht(null),ml=null,Fn=null,Jo=null;function Zo(){Jo=Fn=ml=null}function qo(e){var t=hl.current;fe(hl),e._currentValue=t}function bo(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Dn(e,t){ml=e,Jo=Fn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ve=!0),e.firstContext=null)}function nt(e){var t=e._currentValue;if(Jo!==e)if(e={context:e,memoizedValue:t,next:null},Fn===null){if(ml===null)throw Error(a(308));Fn=e,ml.dependencies={lanes:0,firstContext:e}}else Fn=Fn.next=e;return t}var un=null;function eu(e){un===null?un=[e]:un.push(e)}function Oa(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,eu(t)):(n.next=l.next,l.next=n),t.interleaved=n,Nt(e,r)}function Nt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Qt=!1;function tu(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Ma(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Lt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Kt(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,(te&2)!==0){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Nt(e,n)}return l=r.interleaved,l===null?(t.next=t,eu(r)):(t.next=l.next,l.next=t),r.interleaved=t,Nt(e,n)}function vl(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,mo(e,n)}}function Ia(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,o=null;if(n=n.firstBaseUpdate,n!==null){do{var i={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};o===null?l=o=i:o=o.next=i,n=n.next}while(n!==null);o===null?l=o=t:o=o.next=t}else l=o=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:o,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function yl(e,t,n,r){var l=e.updateQueue;Qt=!1;var o=l.firstBaseUpdate,i=l.lastBaseUpdate,c=l.shared.pending;if(c!==null){l.shared.pending=null;var d=c,w=d.next;d.next=null,i===null?o=w:i.next=w,i=d;var R=e.alternate;R!==null&&(R=R.updateQueue,c=R.lastBaseUpdate,c!==i&&(c===null?R.firstBaseUpdate=w:c.next=w,R.lastBaseUpdate=d))}if(o!==null){var N=l.baseState;i=0,R=w=d=null,c=o;do{var P=c.lane,j=c.eventTime;if((r&P)===P){R!==null&&(R=R.next={eventTime:j,lane:0,tag:c.tag,payload:c.payload,callback:c.callback,next:null});e:{var $=e,B=c;switch(P=t,j=n,B.tag){case 1:if($=B.payload,typeof $=="function"){N=$.call(j,N,P);break e}N=$;break e;case 3:$.flags=$.flags&-65537|128;case 0:if($=B.payload,P=typeof $=="function"?$.call(j,N,P):$,P==null)break e;N=U({},N,P);break e;case 2:Qt=!0}}c.callback!==null&&c.lane!==0&&(e.flags|=64,P=l.effects,P===null?l.effects=[c]:P.push(c))}else j={eventTime:j,lane:P,tag:c.tag,payload:c.payload,callback:c.callback,next:null},R===null?(w=R=j,d=N):R=R.next=j,i|=P;if(c=c.next,c===null){if(c=l.shared.pending,c===null)break;P=c,c=P.next,P.next=null,l.lastBaseUpdate=P,l.shared.pending=null}}while(!0);if(R===null&&(d=N),l.baseState=d,l.firstBaseUpdate=w,l.lastBaseUpdate=R,t=l.shared.interleaved,t!==null){l=t;do i|=l.lane,l=l.next;while(l!==t)}else o===null&&(l.shared.lanes=0);cn|=i,e.lanes=i,e.memoizedState=N}}function Ua(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=uu.transition;uu.transition={};try{e(!1),t()}finally{ie=n,uu.transition=r}}function rs(){return rt().memoizedState}function qf(e,t,n){var r=Jt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},ls(e))os(t,n);else if(n=Oa(e,t,n,r),n!==null){var l=Ae();pt(n,e,r,l),us(n,t,r)}}function bf(e,t,n){var r=Jt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(ls(e))os(t,l);else{var o=e.alternate;if(e.lanes===0&&(o===null||o.lanes===0)&&(o=t.lastRenderedReducer,o!==null))try{var i=t.lastRenderedState,c=o(i,n);if(l.hasEagerState=!0,l.eagerState=c,at(c,i)){var d=t.interleaved;d===null?(l.next=l,eu(t)):(l.next=d.next,d.next=l),t.interleaved=l;return}}catch{}finally{}n=Oa(e,t,l,r),n!==null&&(l=Ae(),pt(n,e,r,l),us(n,t,r))}}function ls(e){var t=e.alternate;return e===ve||t!==null&&t===ve}function os(e,t){wr=Sl=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function us(e,t,n){if((n&4194240)!==0){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,mo(e,n)}}var El={readContext:nt,useCallback:Me,useContext:Me,useEffect:Me,useImperativeHandle:Me,useInsertionEffect:Me,useLayoutEffect:Me,useMemo:Me,useReducer:Me,useRef:Me,useState:Me,useDebugValue:Me,useDeferredValue:Me,useTransition:Me,useMutableSource:Me,useSyncExternalStore:Me,useId:Me,unstable_isNewReconciler:!1},ed={readContext:nt,useCallback:function(e,t){return St().memoizedState=[e,t===void 0?null:t],e},useContext:nt,useEffect:Ga,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,kl(4194308,4,qa.bind(null,t,e),n)},useLayoutEffect:function(e,t){return kl(4194308,4,e,t)},useInsertionEffect:function(e,t){return kl(4,2,e,t)},useMemo:function(e,t){var n=St();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=St();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=qf.bind(null,ve,e),[r.memoizedState,e]},useRef:function(e){var t=St();return e={current:e},t.memoizedState=e},useState:Ya,useDebugValue:pu,useDeferredValue:function(e){return St().memoizedState=e},useTransition:function(){var e=Ya(!1),t=e[0];return e=Zf.bind(null,e[1]),St().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=ve,l=St();if(pe){if(n===void 0)throw Error(a(407));n=n()}else{if(n=t(),Le===null)throw Error(a(349));(sn&30)!==0||Ha(r,t,n)}l.memoizedState=n;var o={value:n,getSnapshot:t};return l.queue=o,Ga(Va.bind(null,r,o,e),[e]),r.flags|=2048,xr(9,Wa.bind(null,r,o,n,t),void 0,null),n},useId:function(){var e=St(),t=Le.identifierPrefix;if(pe){var n=Rt,r=_t;n=(r&~(1<<32-it(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Sr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=i.createElement(n,{is:r.is}):(e=i.createElement(n),n==="select"&&(i=e,r.multiple?i.multiple=!0:r.size&&(i.size=r.size))):e=i.createElementNS(e,n),e[gt]=t,e[pr]=r,_s(e,t,!1,!1),t.stateNode=e;e:{switch(i=lo(n,r),n){case"dialog":ce("cancel",e),ce("close",e),l=r;break;case"iframe":case"object":case"embed":ce("load",e),l=r;break;case"video":case"audio":for(l=0;lUn&&(t.flags|=128,r=!0,Er(o,!1),t.lanes=4194304)}else{if(!r)if(e=gl(i),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Er(o,!0),o.tail===null&&o.tailMode==="hidden"&&!i.alternate&&!pe)return Ie(t),null}else 2*Se()-o.renderingStartTime>Un&&n!==1073741824&&(t.flags|=128,r=!0,Er(o,!1),t.lanes=4194304);o.isBackwards?(i.sibling=t.child,t.child=i):(n=o.last,n!==null?n.sibling=i:t.child=i,o.last=i)}return o.tail!==null?(t=o.tail,o.rendering=t,o.tail=t.sibling,o.renderingStartTime=Se(),t.sibling=null,n=me.current,se(me,r?n&1|2:n&1),t):(Ie(t),null);case 22:case 23:return Iu(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&(t.mode&1)!==0?(be&1073741824)!==0&&(Ie(t),t.subtreeFlags&6&&(t.flags|=8192)):Ie(t),null;case 24:return null;case 25:return null}throw Error(a(156,t.tag))}function ad(e,t){switch(Ko(t),t.tag){case 1:return We(t.type)&&il(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return jn(),fe(He),fe(Oe),ou(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return ru(t),null;case 13:if(fe(me),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(a(340));Tn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return fe(me),null;case 4:return jn(),null;case 10:return qo(t.type._context),null;case 22:case 23:return Iu(),null;case 24:return null;default:return null}}var Rl=!1,Ue=!1,sd=typeof WeakSet=="function"?WeakSet:Set,I=null;function Mn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){we(e,t,r)}else n.current=null}function Pu(e,t,n){try{n()}catch(r){we(e,t,r)}}var Ls=!1;function cd(e,t){if(Io=Yr,e=ia(),Lo(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break e}var i=0,c=-1,d=-1,w=0,R=0,N=e,P=null;t:for(;;){for(var j;N!==n||l!==0&&N.nodeType!==3||(c=i+l),N!==o||r!==0&&N.nodeType!==3||(d=i+r),N.nodeType===3&&(i+=N.nodeValue.length),(j=N.firstChild)!==null;)P=N,N=j;for(;;){if(N===e)break t;if(P===n&&++w===l&&(c=i),P===o&&++R===r&&(d=i),(j=N.nextSibling)!==null)break;N=P,P=N.parentNode}N=j}n=c===-1||d===-1?null:{start:c,end:d}}else n=null}n=n||{start:0,end:0}}else n=null;for(Uo={focusedElem:e,selectionRange:n},Yr=!1,I=t;I!==null;)if(t=I,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,I=e;else for(;I!==null;){t=I;try{var $=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if($!==null){var B=$.memoizedProps,ke=$.memoizedState,y=t.stateNode,h=y.getSnapshotBeforeUpdate(t.elementType===t.type?B:ct(t.type,B),ke);y.__reactInternalSnapshotBeforeUpdate=h}break;case 3:var g=t.stateNode.containerInfo;g.nodeType===1?g.textContent="":g.nodeType===9&&g.documentElement&&g.removeChild(g.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(a(163))}}catch(L){we(t,t.return,L)}if(e=t.sibling,e!==null){e.return=t.return,I=e;break}I=t.return}return $=Ls,Ls=!1,$}function Cr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var o=l.destroy;l.destroy=void 0,o!==void 0&&Pu(t,n,o)}l=l.next}while(l!==r)}}function Nl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function _u(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Ts(e){var t=e.alternate;t!==null&&(e.alternate=null,Ts(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[gt],delete t[pr],delete t[Ho],delete t[Kf],delete t[Yf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function zs(e){return e.tag===5||e.tag===3||e.tag===4}function Fs(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||zs(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Ru(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=ol));else if(r!==4&&(e=e.child,e!==null))for(Ru(e,t,n),e=e.sibling;e!==null;)Ru(e,t,n),e=e.sibling}function Nu(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Nu(e,t,n),e=e.sibling;e!==null;)Nu(e,t,n),e=e.sibling}var Fe=null,ft=!1;function Yt(e,t,n){for(n=n.child;n!==null;)Ds(e,t,n),n=n.sibling}function Ds(e,t,n){if(yt&&typeof yt.onCommitFiberUnmount=="function")try{yt.onCommitFiberUnmount(Br,n)}catch{}switch(n.tag){case 5:Ue||Mn(n,t);case 6:var r=Fe,l=ft;Fe=null,Yt(e,t,n),Fe=r,ft=l,Fe!==null&&(ft?(e=Fe,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Fe.removeChild(n.stateNode));break;case 18:Fe!==null&&(ft?(e=Fe,n=n.stateNode,e.nodeType===8?Bo(e.parentNode,n):e.nodeType===1&&Bo(e,n),nr(e)):Bo(Fe,n.stateNode));break;case 4:r=Fe,l=ft,Fe=n.stateNode.containerInfo,ft=!0,Yt(e,t,n),Fe=r,ft=l;break;case 0:case 11:case 14:case 15:if(!Ue&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var o=l,i=o.destroy;o=o.tag,i!==void 0&&((o&2)!==0||(o&4)!==0)&&Pu(n,t,i),l=l.next}while(l!==r)}Yt(e,t,n);break;case 1:if(!Ue&&(Mn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(c){we(n,t,c)}Yt(e,t,n);break;case 21:Yt(e,t,n);break;case 22:n.mode&1?(Ue=(r=Ue)||n.memoizedState!==null,Yt(e,t,n),Ue=r):Yt(e,t,n);break;default:Yt(e,t,n)}}function js(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new sd),t.forEach(function(r){var l=wd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function dt(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=i),r&=~o}if(r=l,r=Se()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*dd(r/1960))-r,10e?16:e,Gt===null)var r=!1;else{if(e=Gt,Gt=null,Dl=0,(te&6)!==0)throw Error(a(331));var l=te;for(te|=4,I=e.current;I!==null;){var o=I,i=o.child;if((I.flags&16)!==0){var c=o.deletions;if(c!==null){for(var d=0;dSe()-zu?dn(e,0):Tu|=n),Ke(e,t)}function Ys(e,t){t===0&&((e.mode&1)===0?t=1:(t=Wr,Wr<<=1,(Wr&130023424)===0&&(Wr=4194304)));var n=Ae();e=Nt(e,t),e!==null&&(Zn(e,t,n),Ke(e,n))}function gd(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Ys(e,n)}function wd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(a(314))}r!==null&&r.delete(t),Ys(e,n)}var Xs;Xs=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||He.current)Ve=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return Ve=!1,ud(e,t,n);Ve=(e.flags&131072)!==0}else Ve=!1,pe&&(t.flags&1048576)!==0&&Ra(t,fl,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;_l(e,t),e=t.pendingProps;var l=Rn(t,Oe.current);Dn(t,n),l=au(null,t,r,e,l,n);var o=su();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,We(r)?(o=!0,al(t)):o=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,tu(t),l.updater=Cl,t.stateNode=l,l._reactInternals=t,mu(t,r,e,n),t=wu(null,t,r,!0,o,n)):(t.tag=0,pe&&o&&Qo(t),$e(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(_l(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=kd(r),e=ct(r,e),l){case 0:t=gu(null,t,r,e,n);break e;case 1:t=Ss(null,t,r,e,n);break e;case 11:t=ms(null,t,r,e,n);break e;case 14:t=vs(null,t,r,ct(r.type,e),n);break e}throw Error(a(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ct(r,l),gu(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ct(r,l),Ss(e,t,r,l,n);case 3:e:{if(ks(t),e===null)throw Error(a(387));r=t.pendingProps,o=t.memoizedState,l=o.element,Ma(e,t),yl(t,r,null,n);var i=t.memoizedState;if(r=i.element,o.isDehydrated)if(o={element:r,isDehydrated:!1,cache:i.cache,pendingSuspenseBoundaries:i.pendingSuspenseBoundaries,transitions:i.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){l=On(Error(a(423)),t),t=xs(e,t,r,n,l);break e}else if(r!==l){l=On(Error(a(424)),t),t=xs(e,t,r,n,l);break e}else for(qe=Bt(t.stateNode.containerInfo.firstChild),Ze=t,pe=!0,st=null,n=ja(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Tn(),r===l){t=Tt(e,t,n);break e}$e(e,t,r,n)}t=t.child}return t;case 5:return $a(t),e===null&&Xo(t),r=t.type,l=t.pendingProps,o=e!==null?e.memoizedProps:null,i=l.children,$o(r,l)?i=null:o!==null&&$o(r,o)&&(t.flags|=32),ws(e,t),$e(e,t,i,n),t.child;case 6:return e===null&&Xo(t),null;case 13:return Es(e,t,n);case 4:return nu(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=zn(t,null,r,n):$e(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ct(r,l),ms(e,t,r,l,n);case 7:return $e(e,t,t.pendingProps,n),t.child;case 8:return $e(e,t,t.pendingProps.children,n),t.child;case 12:return $e(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,o=t.memoizedProps,i=l.value,se(hl,r._currentValue),r._currentValue=i,o!==null)if(at(o.value,i)){if(o.children===l.children&&!He.current){t=Tt(e,t,n);break e}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var c=o.dependencies;if(c!==null){i=o.child;for(var d=c.firstContext;d!==null;){if(d.context===r){if(o.tag===1){d=Lt(-1,n&-n),d.tag=2;var w=o.updateQueue;if(w!==null){w=w.shared;var R=w.pending;R===null?d.next=d:(d.next=R.next,R.next=d),w.pending=d}}o.lanes|=n,d=o.alternate,d!==null&&(d.lanes|=n),bo(o.return,n,t),c.lanes|=n;break}d=d.next}}else if(o.tag===10)i=o.type===t.type?null:o.child;else if(o.tag===18){if(i=o.return,i===null)throw Error(a(341));i.lanes|=n,c=i.alternate,c!==null&&(c.lanes|=n),bo(i,n,t),i=o.sibling}else i=o.child;if(i!==null)i.return=o;else for(i=o;i!==null;){if(i===t){i=null;break}if(o=i.sibling,o!==null){o.return=i.return,i=o;break}i=i.return}o=i}$e(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Dn(t,n),l=nt(l),r=r(l),t.flags|=1,$e(e,t,r,n),t.child;case 14:return r=t.type,l=ct(r,t.pendingProps),l=ct(r.type,l),vs(e,t,r,l,n);case 15:return ys(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ct(r,l),_l(e,t),t.tag=1,We(r)?(e=!0,al(t)):e=!1,Dn(t,n),as(t,r,l),mu(t,r,l,n),wu(null,t,r,!0,e,n);case 19:return Ps(e,t,n);case 22:return gs(e,t,n)}throw Error(a(156,t.tag))};function Gs(e,t){return Ni(e,t)}function Sd(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function ot(e,t,n,r){return new Sd(e,t,n,r)}function $u(e){return e=e.prototype,!(!e||!e.isReactComponent)}function kd(e){if(typeof e=="function")return $u(e)?1:0;if(e!=null){if(e=e.$$typeof,e===mt)return 11;if(e===vt)return 14}return 2}function qt(e,t){var n=e.alternate;return n===null?(n=ot(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Il(e,t,n,r,l,o){var i=2;if(r=e,typeof e=="function")$u(e)&&(i=1);else if(typeof e=="string")i=5;else e:switch(e){case Ee:return hn(n.children,l,o,t);case ze:i=8,l|=8;break;case Ce:return e=ot(12,n,t,l|2),e.elementType=Ce,e.lanes=o,e;case Xe:return e=ot(13,n,t,l),e.elementType=Xe,e.lanes=o,e;case ut:return e=ot(19,n,t,l),e.elementType=ut,e.lanes=o,e;case ge:return Ul(n,l,o,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case je:i=10;break e;case ht:i=9;break e;case mt:i=11;break e;case vt:i=14;break e;case Be:i=16,r=null;break e}throw Error(a(130,e==null?e:typeof e,""))}return t=ot(i,n,t,l),t.elementType=e,t.type=r,t.lanes=o,t}function hn(e,t,n,r){return e=ot(7,e,r,t),e.lanes=n,e}function Ul(e,t,n,r){return e=ot(22,e,r,t),e.elementType=ge,e.lanes=n,e.stateNode={isHidden:!1},e}function Au(e,t,n){return e=ot(6,e,null,t),e.lanes=n,e}function Bu(e,t,n){return t=ot(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function xd(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=ho(0),this.expirationTimes=ho(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=ho(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Hu(e,t,n,r,l,o,i,c,d){return e=new xd(e,t,n,c,d),t===1?(t=1,o===!0&&(t|=8)):t=0,o=ot(3,null,null,t),e.current=o,o.stateNode=e,o.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},tu(o),e}function Ed(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(u)}catch(s){console.error(s)}}return u(),Xu.exports=Od(),Xu.exports}var sc;function Id(){if(sc)return Ql;sc=1;var u=Md();return Ql.createRoot=u.createRoot,Ql.hydrateRoot=u.hydrateRoot,Ql}var Ud=Id();const $d=mc(Ud);/** + * react-router v7.7.1 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */var cc="popstate";function Ad(u={}){function s(p,f){let{pathname:m,search:x,hash:C}=p.location;return bu("",{pathname:m,search:x,hash:C},f.state&&f.state.usr||null,f.state&&f.state.key||"default")}function a(p,f){return typeof f=="string"?f:zr(f)}return Hd(s,a,null,u)}function ye(u,s){if(u===!1||u===null||typeof u>"u")throw new Error(s)}function xt(u,s){if(!u){typeof console<"u"&&console.warn(s);try{throw new Error(s)}catch{}}}function Bd(){return Math.random().toString(36).substring(2,10)}function fc(u,s){return{usr:u.state,key:u.key,idx:s}}function bu(u,s,a=null,p){return{pathname:typeof u=="string"?u:u.pathname,search:"",hash:"",...typeof s=="string"?Hn(s):s,state:a,key:s&&s.key||p||Bd()}}function zr({pathname:u="/",search:s="",hash:a=""}){return s&&s!=="?"&&(u+=s.charAt(0)==="?"?s:"?"+s),a&&a!=="#"&&(u+=a.charAt(0)==="#"?a:"#"+a),u}function Hn(u){let s={};if(u){let a=u.indexOf("#");a>=0&&(s.hash=u.substring(a),u=u.substring(0,a));let p=u.indexOf("?");p>=0&&(s.search=u.substring(p),u=u.substring(0,p)),u&&(s.pathname=u)}return s}function Hd(u,s,a,p={}){let{window:f=document.defaultView,v5Compat:m=!1}=p,x=f.history,C="POP",S=null,k=T();k==null&&(k=0,x.replaceState({...x.state,idx:k},""));function T(){return(x.state||{idx:null}).idx}function z(){C="POP";let O=T(),W=O==null?null:O-k;k=O,S&&S({action:C,location:M.location,delta:W})}function F(O,W){C="PUSH";let V=bu(M.location,O,W);k=T()+1;let Z=fc(V,k),ne=M.createHref(V);try{x.pushState(Z,"",ne)}catch(he){if(he instanceof DOMException&&he.name==="DataCloneError")throw he;f.location.assign(ne)}m&&S&&S({action:C,location:M.location,delta:1})}function J(O,W){C="REPLACE";let V=bu(M.location,O,W);k=T();let Z=fc(V,k),ne=M.createHref(V);x.replaceState(Z,"",ne),m&&S&&S({action:C,location:M.location,delta:0})}function Y(O){return Wd(O)}let M={get action(){return C},get location(){return u(f,x)},listen(O){if(S)throw new Error("A history only accepts one active listener");return f.addEventListener(cc,z),S=O,()=>{f.removeEventListener(cc,z),S=null}},createHref(O){return s(f,O)},createURL:Y,encodeLocation(O){let W=Y(O);return{pathname:W.pathname,search:W.search,hash:W.hash}},push:F,replace:J,go(O){return x.go(O)}};return M}function Wd(u,s=!1){let a="http://localhost";typeof window<"u"&&(a=window.location.origin!=="null"?window.location.origin:window.location.href),ye(a,"No window.location.(origin|href) available to create URL");let p=typeof u=="string"?u:zr(u);return p=p.replace(/ $/,"%20"),!s&&p.startsWith("//")&&(p=a+p),new URL(p,a)}function vc(u,s,a="/"){return Vd(u,s,a,!1)}function Vd(u,s,a,p){let f=typeof s=="string"?Hn(s):s,m=Dt(f.pathname||"/",a);if(m==null)return null;let x=yc(u);Qd(x);let C=null;for(let S=0;C==null&&S{let S={relativePath:C===void 0?m.path||"":C,caseSensitive:m.caseSensitive===!0,childrenIndex:x,route:m};S.relativePath.startsWith("/")&&(ye(S.relativePath.startsWith(p),`Absolute route path "${S.relativePath}" nested under path "${p}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),S.relativePath=S.relativePath.slice(p.length));let k=Ft([p,S.relativePath]),T=a.concat(S);m.children&&m.children.length>0&&(ye(m.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${k}".`),yc(m.children,s,T,k)),!(m.path==null&&!m.index)&&s.push({path:k,score:qd(k,m.index),routesMeta:T})};return u.forEach((m,x)=>{var C;if(m.path===""||!((C=m.path)!=null&&C.includes("?")))f(m,x);else for(let S of gc(m.path))f(m,x,S)}),s}function gc(u){let s=u.split("/");if(s.length===0)return[];let[a,...p]=s,f=a.endsWith("?"),m=a.replace(/\?$/,"");if(p.length===0)return f?[m,""]:[m];let x=gc(p.join("/")),C=[];return C.push(...x.map(S=>S===""?m:[m,S].join("/"))),f&&C.push(...x),C.map(S=>u.startsWith("/")&&S===""?"/":S)}function Qd(u){u.sort((s,a)=>s.score!==a.score?a.score-s.score:bd(s.routesMeta.map(p=>p.childrenIndex),a.routesMeta.map(p=>p.childrenIndex)))}var Kd=/^:[\w-]+$/,Yd=3,Xd=2,Gd=1,Jd=10,Zd=-2,dc=u=>u==="*";function qd(u,s){let a=u.split("/"),p=a.length;return a.some(dc)&&(p+=Zd),s&&(p+=Xd),a.filter(f=>!dc(f)).reduce((f,m)=>f+(Kd.test(m)?Yd:m===""?Gd:Jd),p)}function bd(u,s){return u.length===s.length&&u.slice(0,-1).every((p,f)=>p===s[f])?u[u.length-1]-s[s.length-1]:0}function ep(u,s,a=!1){let{routesMeta:p}=u,f={},m="/",x=[];for(let C=0;C{if(T==="*"){let Y=C[F]||"";x=m.slice(0,m.length-Y.length).replace(/(.)\/+$/,"$1")}const J=C[F];return z&&!J?k[T]=void 0:k[T]=(J||"").replace(/%2F/g,"/"),k},{}),pathname:m,pathnameBase:x,pattern:u}}function tp(u,s=!1,a=!0){xt(u==="*"||!u.endsWith("*")||u.endsWith("/*"),`Route path "${u}" will be treated as if it were "${u.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${u.replace(/\*$/,"/*")}".`);let p=[],f="^"+u.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(x,C,S)=>(p.push({paramName:C,isOptional:S!=null}),S?"/?([^\\/]+)?":"/([^\\/]+)"));return u.endsWith("*")?(p.push({paramName:"*"}),f+=u==="*"||u==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):a?f+="\\/*$":u!==""&&u!=="/"&&(f+="(?:(?=\\/|$))"),[new RegExp(f,s?void 0:"i"),p]}function np(u){try{return u.split("/").map(s=>decodeURIComponent(s).replace(/\//g,"%2F")).join("/")}catch(s){return xt(!1,`The URL path "${u}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${s}).`),u}}function Dt(u,s){if(s==="/")return u;if(!u.toLowerCase().startsWith(s.toLowerCase()))return null;let a=s.endsWith("/")?s.length-1:s.length,p=u.charAt(a);return p&&p!=="/"?null:u.slice(a)||"/"}function rp(u,s="/"){let{pathname:a,search:p="",hash:f=""}=typeof u=="string"?Hn(u):u;return{pathname:a?a.startsWith("/")?a:lp(a,s):s,search:ip(p),hash:ap(f)}}function lp(u,s){let a=s.replace(/\/+$/,"").split("/");return u.split("/").forEach(f=>{f===".."?a.length>1&&a.pop():f!=="."&&a.push(f)}),a.length>1?a.join("/"):"/"}function Zu(u,s,a,p){return`Cannot include a '${u}' character in a manually specified \`to.${s}\` field [${JSON.stringify(p)}]. Please separate it out to the \`to.${a}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function op(u){return u.filter((s,a)=>a===0||s.route.path&&s.route.path.length>0)}function wc(u){let s=op(u);return s.map((a,p)=>p===s.length-1?a.pathname:a.pathnameBase)}function Sc(u,s,a,p=!1){let f;typeof u=="string"?f=Hn(u):(f={...u},ye(!f.pathname||!f.pathname.includes("?"),Zu("?","pathname","search",f)),ye(!f.pathname||!f.pathname.includes("#"),Zu("#","pathname","hash",f)),ye(!f.search||!f.search.includes("#"),Zu("#","search","hash",f)));let m=u===""||f.pathname==="",x=m?"/":f.pathname,C;if(x==null)C=a;else{let z=s.length-1;if(!p&&x.startsWith("..")){let F=x.split("/");for(;F[0]==="..";)F.shift(),z-=1;f.pathname=F.join("/")}C=z>=0?s[z]:"/"}let S=rp(f,C),k=x&&x!=="/"&&x.endsWith("/"),T=(m||x===".")&&a.endsWith("/");return!S.pathname.endsWith("/")&&(k||T)&&(S.pathname+="/"),S}var Ft=u=>u.join("/").replace(/\/\/+/g,"/"),up=u=>u.replace(/\/+$/,"").replace(/^\/*/,"/"),ip=u=>!u||u==="?"?"":u.startsWith("?")?u:"?"+u,ap=u=>!u||u==="#"?"":u.startsWith("#")?u:"#"+u;function sp(u){return u!=null&&typeof u.status=="number"&&typeof u.statusText=="string"&&typeof u.internal=="boolean"&&"data"in u}var kc=["POST","PUT","PATCH","DELETE"];new Set(kc);var cp=["GET",...kc];new Set(cp);var Wn=E.createContext(null);Wn.displayName="DataRouter";var Jl=E.createContext(null);Jl.displayName="DataRouterState";E.createContext(!1);var xc=E.createContext({isTransitioning:!1});xc.displayName="ViewTransition";var fp=E.createContext(new Map);fp.displayName="Fetchers";var dp=E.createContext(null);dp.displayName="Await";var Et=E.createContext(null);Et.displayName="Navigation";var Fr=E.createContext(null);Fr.displayName="Location";var jt=E.createContext({outlet:null,matches:[],isDataRoute:!1});jt.displayName="Route";var ni=E.createContext(null);ni.displayName="RouteError";function pp(u,{relative:s}={}){ye(Dr(),"useHref() may be used only in the context of a component.");let{basename:a,navigator:p}=E.useContext(Et),{hash:f,pathname:m,search:x}=jr(u,{relative:s}),C=m;return a!=="/"&&(C=m==="/"?a:Ft([a,m])),p.createHref({pathname:C,search:x,hash:f})}function Dr(){return E.useContext(Fr)!=null}function en(){return ye(Dr(),"useLocation() may be used only in the context of a component."),E.useContext(Fr).location}var Ec="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function Cc(u){E.useContext(Et).static||E.useLayoutEffect(u)}function hp(){let{isDataRoute:u}=E.useContext(jt);return u?Rp():mp()}function mp(){ye(Dr(),"useNavigate() may be used only in the context of a component.");let u=E.useContext(Wn),{basename:s,navigator:a}=E.useContext(Et),{matches:p}=E.useContext(jt),{pathname:f}=en(),m=JSON.stringify(wc(p)),x=E.useRef(!1);return Cc(()=>{x.current=!0}),E.useCallback((S,k={})=>{if(xt(x.current,Ec),!x.current)return;if(typeof S=="number"){a.go(S);return}let T=Sc(S,JSON.parse(m),f,k.relative==="path");u==null&&s!=="/"&&(T.pathname=T.pathname==="/"?s:Ft([s,T.pathname])),(k.replace?a.replace:a.push)(T,k.state,k)},[s,a,m,f,u])}E.createContext(null);function jr(u,{relative:s}={}){let{matches:a}=E.useContext(jt),{pathname:p}=en(),f=JSON.stringify(wc(a));return E.useMemo(()=>Sc(u,JSON.parse(f),p,s==="path"),[u,f,p,s])}function vp(u,s){return Pc(u,s)}function Pc(u,s,a,p){var W;ye(Dr(),"useRoutes() may be used only in the context of a component.");let{navigator:f}=E.useContext(Et),{matches:m}=E.useContext(jt),x=m[m.length-1],C=x?x.params:{},S=x?x.pathname:"/",k=x?x.pathnameBase:"/",T=x&&x.route;{let V=T&&T.path||"";_c(S,!T||V.endsWith("*")||V.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${S}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let z=en(),F;if(s){let V=typeof s=="string"?Hn(s):s;ye(k==="/"||((W=V.pathname)==null?void 0:W.startsWith(k)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${k}" but pathname "${V.pathname}" was given in the \`location\` prop.`),F=V}else F=z;let J=F.pathname||"/",Y=J;if(k!=="/"){let V=k.replace(/^\//,"").split("/");Y="/"+J.replace(/^\//,"").split("/").slice(V.length).join("/")}let M=vc(u,{pathname:Y});xt(T||M!=null,`No routes matched location "${F.pathname}${F.search}${F.hash}" `),xt(M==null||M[M.length-1].route.element!==void 0||M[M.length-1].route.Component!==void 0||M[M.length-1].route.lazy!==void 0,`Matched leaf route at location "${F.pathname}${F.search}${F.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let O=kp(M&&M.map(V=>Object.assign({},V,{params:Object.assign({},C,V.params),pathname:Ft([k,f.encodeLocation?f.encodeLocation(V.pathname).pathname:V.pathname]),pathnameBase:V.pathnameBase==="/"?k:Ft([k,f.encodeLocation?f.encodeLocation(V.pathnameBase).pathname:V.pathnameBase])})),m,a,p);return s&&O?E.createElement(Fr.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...F},navigationType:"POP"}},O):O}function yp(){let u=_p(),s=sp(u)?`${u.status} ${u.statusText}`:u instanceof Error?u.message:JSON.stringify(u),a=u instanceof Error?u.stack:null,p="rgba(200,200,200, 0.5)",f={padding:"0.5rem",backgroundColor:p},m={padding:"2px 4px",backgroundColor:p},x=null;return console.error("Error handled by React Router default ErrorBoundary:",u),x=E.createElement(E.Fragment,null,E.createElement("p",null,"πŸ’Ώ Hey developer πŸ‘‹"),E.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",E.createElement("code",{style:m},"ErrorBoundary")," or"," ",E.createElement("code",{style:m},"errorElement")," prop on your route.")),E.createElement(E.Fragment,null,E.createElement("h2",null,"Unexpected Application Error!"),E.createElement("h3",{style:{fontStyle:"italic"}},s),a?E.createElement("pre",{style:f},a):null,x)}var gp=E.createElement(yp,null),wp=class extends E.Component{constructor(u){super(u),this.state={location:u.location,revalidation:u.revalidation,error:u.error}}static getDerivedStateFromError(u){return{error:u}}static getDerivedStateFromProps(u,s){return s.location!==u.location||s.revalidation!=="idle"&&u.revalidation==="idle"?{error:u.error,location:u.location,revalidation:u.revalidation}:{error:u.error!==void 0?u.error:s.error,location:s.location,revalidation:u.revalidation||s.revalidation}}componentDidCatch(u,s){console.error("React Router caught the following error during render",u,s)}render(){return this.state.error!==void 0?E.createElement(jt.Provider,{value:this.props.routeContext},E.createElement(ni.Provider,{value:this.state.error,children:this.props.component})):this.props.children}};function Sp({routeContext:u,match:s,children:a}){let p=E.useContext(Wn);return p&&p.static&&p.staticContext&&(s.route.errorElement||s.route.ErrorBoundary)&&(p.staticContext._deepestRenderedBoundaryId=s.route.id),E.createElement(jt.Provider,{value:u},a)}function kp(u,s=[],a=null,p=null){if(u==null){if(!a)return null;if(a.errors)u=a.matches;else if(s.length===0&&!a.initialized&&a.matches.length>0)u=a.matches;else return null}let f=u,m=a==null?void 0:a.errors;if(m!=null){let S=f.findIndex(k=>k.route.id&&(m==null?void 0:m[k.route.id])!==void 0);ye(S>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(m).join(",")}`),f=f.slice(0,Math.min(f.length,S+1))}let x=!1,C=-1;if(a)for(let S=0;S=0?f=f.slice(0,C+1):f=[f[0]];break}}}return f.reduceRight((S,k,T)=>{let z,F=!1,J=null,Y=null;a&&(z=m&&k.route.id?m[k.route.id]:void 0,J=k.route.errorElement||gp,x&&(C<0&&T===0?(_c("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),F=!0,Y=null):C===T&&(F=!0,Y=k.route.hydrateFallbackElement||null)));let M=s.concat(f.slice(0,T+1)),O=()=>{let W;return z?W=J:F?W=Y:k.route.Component?W=E.createElement(k.route.Component,null):k.route.element?W=k.route.element:W=S,E.createElement(Sp,{match:k,routeContext:{outlet:S,matches:M,isDataRoute:a!=null},children:W})};return a&&(k.route.ErrorBoundary||k.route.errorElement||T===0)?E.createElement(wp,{location:a.location,revalidation:a.revalidation,component:J,error:z,children:O(),routeContext:{outlet:null,matches:M,isDataRoute:!0}}):O()},null)}function ri(u){return`${u} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function xp(u){let s=E.useContext(Wn);return ye(s,ri(u)),s}function Ep(u){let s=E.useContext(Jl);return ye(s,ri(u)),s}function Cp(u){let s=E.useContext(jt);return ye(s,ri(u)),s}function li(u){let s=Cp(u),a=s.matches[s.matches.length-1];return ye(a.route.id,`${u} can only be used on routes that contain a unique "id"`),a.route.id}function Pp(){return li("useRouteId")}function _p(){var p;let u=E.useContext(ni),s=Ep("useRouteError"),a=li("useRouteError");return u!==void 0?u:(p=s.errors)==null?void 0:p[a]}function Rp(){let{router:u}=xp("useNavigate"),s=li("useNavigate"),a=E.useRef(!1);return Cc(()=>{a.current=!0}),E.useCallback(async(f,m={})=>{xt(a.current,Ec),a.current&&(typeof f=="number"?u.navigate(f):await u.navigate(f,{fromRouteId:s,...m}))},[u,s])}var pc={};function _c(u,s,a){!s&&!pc[u]&&(pc[u]=!0,xt(!1,a))}E.memo(Np);function Np({routes:u,future:s,state:a}){return Pc(u,void 0,a,s)}function An(u){ye(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function Lp({basename:u="/",children:s=null,location:a,navigationType:p="POP",navigator:f,static:m=!1}){ye(!Dr(),"You cannot render a inside another . You should never have more than one in your app.");let x=u.replace(/^\/*/,"/"),C=E.useMemo(()=>({basename:x,navigator:f,static:m,future:{}}),[x,f,m]);typeof a=="string"&&(a=Hn(a));let{pathname:S="/",search:k="",hash:T="",state:z=null,key:F="default"}=a,J=E.useMemo(()=>{let Y=Dt(S,x);return Y==null?null:{location:{pathname:Y,search:k,hash:T,state:z,key:F},navigationType:p}},[x,S,k,T,z,F,p]);return xt(J!=null,` is not able to match the URL "${S}${k}${T}" because it does not start with the basename, so the won't render anything.`),J==null?null:E.createElement(Et.Provider,{value:C},E.createElement(Fr.Provider,{children:s,value:J}))}function Tp({children:u,location:s}){return vp(ei(u),s)}function ei(u,s=[]){let a=[];return E.Children.forEach(u,(p,f)=>{if(!E.isValidElement(p))return;let m=[...s,f];if(p.type===E.Fragment){a.push.apply(a,ei(p.props.children,m));return}ye(p.type===An,`[${typeof p.type=="string"?p.type:p.type.name}] is not a component. All component children of must be a or `),ye(!p.props.index||!p.props.children,"An index route cannot have child routes.");let x={id:p.props.id||m.join("-"),caseSensitive:p.props.caseSensitive,element:p.props.element,Component:p.props.Component,index:p.props.index,path:p.props.path,loader:p.props.loader,action:p.props.action,hydrateFallbackElement:p.props.hydrateFallbackElement,HydrateFallback:p.props.HydrateFallback,errorElement:p.props.errorElement,ErrorBoundary:p.props.ErrorBoundary,hasErrorBoundary:p.props.hasErrorBoundary===!0||p.props.ErrorBoundary!=null||p.props.errorElement!=null,shouldRevalidate:p.props.shouldRevalidate,handle:p.props.handle,lazy:p.props.lazy};p.props.children&&(x.children=ei(p.props.children,m)),a.push(x)}),a}var Yl="get",Xl="application/x-www-form-urlencoded";function Zl(u){return u!=null&&typeof u.tagName=="string"}function zp(u){return Zl(u)&&u.tagName.toLowerCase()==="button"}function Fp(u){return Zl(u)&&u.tagName.toLowerCase()==="form"}function Dp(u){return Zl(u)&&u.tagName.toLowerCase()==="input"}function jp(u){return!!(u.metaKey||u.altKey||u.ctrlKey||u.shiftKey)}function Op(u,s){return u.button===0&&(!s||s==="_self")&&!jp(u)}var Kl=null;function Mp(){if(Kl===null)try{new FormData(document.createElement("form"),0),Kl=!1}catch{Kl=!0}return Kl}var Ip=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function qu(u){return u!=null&&!Ip.has(u)?(xt(!1,`"${u}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Xl}"`),null):u}function Up(u,s){let a,p,f,m,x;if(Fp(u)){let C=u.getAttribute("action");p=C?Dt(C,s):null,a=u.getAttribute("method")||Yl,f=qu(u.getAttribute("enctype"))||Xl,m=new FormData(u)}else if(zp(u)||Dp(u)&&(u.type==="submit"||u.type==="image")){let C=u.form;if(C==null)throw new Error('Cannot submit a + ); +}; diff --git a/frontend/src/comp/layout/Footer.jsx b/frontend/src/comp/layout/Footer.jsx new file mode 100644 index 0000000000..01e4c81adc --- /dev/null +++ b/frontend/src/comp/layout/Footer.jsx @@ -0,0 +1,90 @@ +import { CodeBracketIcon, EnvelopeIcon } from "@heroicons/react/24/solid"; +import { Typography } from "@material-tailwind/react"; +import Logo from "/subscribee-logo-right.webp"; +import { Link, useNavigate } from "react-router-dom"; + +import { useUserStore } from "../../stores/useUserStore"; +import { Logout } from "../user/LogoutBtn"; +import { Btn } from "./Btn"; + +export const Footer = () => { + const currentYear = new Date().getFullYear(); + const navigate = useNavigate(); + const user = useUserStore((state) => state.user); + + return ( +
+
+
+
    +
  • + + About Subscribee + +
  • +
+ + + + +
+ +
+ {user ? ( + <> + navigate("/admin")} + size="sm" + variant="outlined" + > + Dashboard + + + + ) : ( + navigate("/login")} + size="sm" + variant="outlined" + > + Log in + + )} +
+ + + © {currentYear} SubscriBee. All rights reserved. + +
+ SubscriBee Logo +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/comp/layout/Header.jsx b/frontend/src/comp/layout/Header.jsx new file mode 100644 index 0000000000..aeb63ea006 --- /dev/null +++ b/frontend/src/comp/layout/Header.jsx @@ -0,0 +1,62 @@ +import { Typography } from "@material-tailwind/react"; +import { useNavigate } from "react-router-dom"; + +import HeroImage from "../../assets/images/hero-img.webp"; +import { useUserStore } from "../../stores/useUserStore"; +import { Btn } from "./Btn"; + +export const Header = () => { + const navigate = useNavigate(); + const user = useUserStore((state) => state.user); + + return ( +
+
+
+ + Bee in control of your subscriptions with{" "} + + SubscriBee + + + + We make it simple to track monthly and yearly costs, so you always + know where your money’s going. + +
+
+ {user ? ( + navigate("/admin")} + size="md" + variant="filled" + > + Dashboard + + ) : ( + navigate("/signup")} + size="md" + variant="filled" + > + Join the hive + + )} +
+
+
+ +
+
+
+ ); +}; diff --git a/frontend/src/comp/layout/Loader.jsx b/frontend/src/comp/layout/Loader.jsx new file mode 100644 index 0000000000..ecbd5113ff --- /dev/null +++ b/frontend/src/comp/layout/Loader.jsx @@ -0,0 +1,52 @@ +import Logo from "/subscribee-logo-buzzing.webp"; +import { motion } from "framer-motion"; + +export const Loader = () => { + const trailCount = 5; + + return ( +
+ {/* Trail */} + {[...Array(trailCount)].map((_, i) => ( + + ))} + + {/* Main Bee */} + +

+ Hold on! Beeatrice is buzzing as fast as she can... +

+
+ ); +}; + + diff --git a/frontend/src/comp/layout/Navbar.jsx b/frontend/src/comp/layout/Navbar.jsx new file mode 100644 index 0000000000..2ffca4f19b --- /dev/null +++ b/frontend/src/comp/layout/Navbar.jsx @@ -0,0 +1,200 @@ +import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline"; +import { Collapse, IconButton, Typography } from "@material-tailwind/react"; +import Logo from "/subscribee-logo.webp"; +import { useState } from "react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; + +import { Logout } from "../../comp/user/LogoutBtn"; +import { useUserStore } from "../../stores/useUserStore"; +import { Btn } from "./Btn"; + +export const Navbar = () => { + const navigate = useNavigate(); + const location = useLocation(); + const isHome = location.pathname === "/"; + const user = useUserStore((state) => state.user); + + const [open, setOpen] = useState(false); + const handleOpen = () => setOpen((cur) => !cur); + const closeMenu = () => setOpen(false); + + const NavItem = ({ to, label, onClick }) => ( + +
    + + {label} + +
+ + ); + + const NavList = ({ onClick }) => ( +
+ {!isHome && } + + + +
+ ); + + return ( +
+
+
+ {/* Left */} + + SubscriBee Logo + + SubscriBee + + + + {/* Center */} +
+ +
+ + {/* Right */} +
+ {user ? ( + <> + Hi {user.name}! + navigate("/admin")} + size="sm" + variant="outlined" + > + Dashboard + + + + ) : ( + <> + navigate("/login")} + size="sm" + variant="filled" + > + Log in + + navigate("/signup")} + size="sm" + variant="outlined" + > + Sign up + + + )} +
+ + {/* Mobile Toggle */} + + {open ? ( + + ) : ( + + )} + +
+ + {/* Mobile menu */} + +
+ {/* TOP section */} + {user ? ( + <> + + Hi {user.name}! + +
+ + Dashboard + +
+
+ +
+ + ) : ( +
+ +
+ )} + + {/* BOTTOM section */} +
+ {user ? ( +
{ + closeMenu(); + }} + > + +
+ ) : ( + <> + { + navigate("/login"); + closeMenu(); + }} + size="md" + variant="filled" + className="w-auto mb-2" + > + Log in + + { + navigate("/signup"); + closeMenu(); + }} + size="md" + variant="outlined" + className=" w-auto" + > + Sign up + + + )} +
+
+
+
+
+ ); +}; diff --git a/frontend/src/comp/layout/Popup.jsx b/frontend/src/comp/layout/Popup.jsx new file mode 100644 index 0000000000..8b54db9829 --- /dev/null +++ b/frontend/src/comp/layout/Popup.jsx @@ -0,0 +1,63 @@ +import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline"; +import { IconButton, Typography } from "@material-tailwind/react"; +import Logo from "/subscribee-logo-left.webp"; +import { useEffect, useState } from "react"; + +export const Popup = ({ children, delay }) => { + const [visible, setVisible] = useState(!delay); + const [collapsed, setCollapsed] = useState(false); + + useEffect(() => { + if (delay) { + const timer = setTimeout(() => setVisible(true), delay); + return () => clearTimeout(timer); + } + }, [delay]); + + if (!visible) return null; + + return ( +
+ {!collapsed && ( +
+ setCollapsed(true)} + > + + + + + {children} + +
+ )} + + {/* Logo toggle */} +
setCollapsed(false)} + className="cursor-pointer transition-transform mt-1" + > + SubscriBee Logo +
+ + {collapsed && ( + setCollapsed(false)} + > + + + )} +
+ ); +}; diff --git a/frontend/src/comp/layout/TopArrow.jsx b/frontend/src/comp/layout/TopArrow.jsx new file mode 100644 index 0000000000..19d633ce43 --- /dev/null +++ b/frontend/src/comp/layout/TopArrow.jsx @@ -0,0 +1,40 @@ +import { ArrowUpCircleIcon } from "@heroicons/react/24/solid"; +import { IconButton } from "@material-tailwind/react"; +import { useEffect, useState } from "react"; + +export const TopArrow = () => { + const [visible, setVisible] = useState(false); + + // Show button when scrolling down on the page + useEffect(() => { + const toggleVisibility = () => { + if (window.scrollY > 300) { + setVisible(true); + } else { + setVisible(false); + } + }; + + window.addEventListener("scroll", toggleVisibility); + return () => window.removeEventListener("scroll", toggleVisibility); + }, []); + + const scrollToTop = () => { + window.scrollTo({ top: 0 }); + }; + + return ( + visible && ( +
+ + + +
+ ) + ); +}; diff --git a/frontend/src/comp/user/Input.jsx b/frontend/src/comp/user/Input.jsx new file mode 100644 index 0000000000..684ff87361 --- /dev/null +++ b/frontend/src/comp/user/Input.jsx @@ -0,0 +1,13 @@ +export const Input = ({ label, type, name, value, onChange }) => ( +
+ + +
+); diff --git a/frontend/src/comp/user/LogoutBtn.jsx b/frontend/src/comp/user/LogoutBtn.jsx new file mode 100644 index 0000000000..2d9cec1a61 --- /dev/null +++ b/frontend/src/comp/user/LogoutBtn.jsx @@ -0,0 +1,32 @@ +import { useNavigate } from "react-router-dom"; +import { Btn } from "../layout/Btn"; + +import { useUserStore } from "../../stores/useUserStore"; + +export const Logout = ({ + size = "md", + variant = "small", + className = "", + ...props +}) => { + const clearUser = useUserStore((state) => state.clearUser); + const navigate = useNavigate(); + + const handleLogout = () => { + clearUser(); // Clear user data in store + localStorage.removeItem("user"); // Remove stored user object + navigate("/login"); // Redirect to login page + }; + + return ( + + Log out + + ); +}; diff --git a/frontend/src/comp/user/Userlogin.jsx b/frontend/src/comp/user/Userlogin.jsx new file mode 100644 index 0000000000..95ca30e260 --- /dev/null +++ b/frontend/src/comp/user/Userlogin.jsx @@ -0,0 +1,135 @@ +import { Typography } from "@material-tailwind/react"; +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; + +import { useLoadingStore } from "../../stores/useLoadingStore"; +import { useUserStore } from "../../stores/useUserStore"; +import { Btn } from "../layout/Btn"; +import { BaseURL } from "../utils/BaseURL"; +import { Input } from "./Input"; + +export const Userlogin = () => { + const setLoading = useLoadingStore((state) => state.setLoading); + + const navigate = useNavigate(); + + const urlAPI = `${BaseURL}/users/login`; + + const [formData, setFormData] = useState({ + email: "", + password: "", + }); + + let [error, setError] = useState([]); + + // Get setUser from Zustand store + const setUser = useUserStore((state) => state.setUser); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!formData.email.trim() || !formData.password.trim()) { + setError("Please fill in both email and password!"); + return; + } + + setLoading(true); + + try { + const response = await fetch(`${urlAPI}`, { + method: "POST", + body: JSON.stringify(formData), + headers: { + "Content-Type": "application/json", + }, + }); + + const data = await response.json(); + + if (data.success && data.id) { + localStorage.setItem("user", JSON.stringify(data)); + + // Update with logged-in user info + setUser({ + name: data.name, + email: data.email, + token: data.accessToken, + }); + + e.target.reset(); + navigate("/Admin"); + } else { + setError("Login failed. Please check your credentials."); + } + } catch (error) { + console.error("Signin error:", error); + setError("CanΒ΄t login, please try again!"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + User login + + + Welcome back! Please log in below. + + +
+ {error && ( + + {error} + + )} +
+ +
+
+ +
+ + Login + +
+ + + Don’t have an account?{" "} + + Sign up + + +
+
+ ); +}; diff --git a/frontend/src/comp/user/Usersignup.jsx b/frontend/src/comp/user/Usersignup.jsx new file mode 100644 index 0000000000..433c32b064 --- /dev/null +++ b/frontend/src/comp/user/Usersignup.jsx @@ -0,0 +1,159 @@ +import { Typography } from "@material-tailwind/react"; +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; + +import { useLoadingStore } from "../../stores/useLoadingStore"; +import { useUserStore } from "../../stores/useUserStore"; +import { Btn } from "../layout/Btn"; +import { BaseURL } from "../utils/BaseURL"; +import { Input } from "./Input"; + +export const Usersignup = () => { + const [formData, setFormData] = useState({ + name: "", + email: "", + password: "", + }); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const setUser = useUserStore((state) => state.setUser); + const navigate = useNavigate(); + + const setLoading = useLoadingStore((state) => state.setLoading); + + const handleChange = (e) => { + setFormData({ ...formData, [e.target.name]: e.target.value }); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + const urlAPI = `${BaseURL}/users`; + + setLoading(true); + + try { + // Sign up + const response = await fetch(`${urlAPI}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + + const data = await response.json(); + + if (response.ok) { + setSuccess(true); + + // Auto-login after successful signup + const loginRes = await fetch(`${urlAPI}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: formData.email, + password: formData.password, + }), + }); + + const loginData = await loginRes.json(); + + if (loginRes.ok) { + setUser({ + name: loginData.name, + email: loginData.email, + token: loginData.accessToken, + }); + + navigate("/admin"); // When successful signup, redirect to admin page + } else { + setError("Signup succeeded, but login failed"); + } + } else { + setError(data.message || "Signup failed"); + } + } catch (err) { + setError("Server error"); + console.error(err); + } finally { + setLoading(false); + } + }; + return ( +
+
+ + Sign Up + + + Join SubscriBee and get started today + + +
+
+ +
+ +
+ +
+ +
+ +
+ + {error && ( + + {error} + + )} + {success && ( + + User created successfully! + + )} + + Sign up + +
+ + + Already have an account?{" "} + + Login + + +
+
+ ); +}; diff --git a/frontend/src/comp/utils/BaseURL.jsx b/frontend/src/comp/utils/BaseURL.jsx new file mode 100644 index 0000000000..a4e60319f4 --- /dev/null +++ b/frontend/src/comp/utils/BaseURL.jsx @@ -0,0 +1,3 @@ +export const BaseURL = "https://project-final-xhjy.onrender.com" +//"http://localhost:8081" +//"https://project-final-xhjy.onrender.com" \ No newline at end of file diff --git a/frontend/src/comp/utils/CalculateCost.jsx b/frontend/src/comp/utils/CalculateCost.jsx new file mode 100644 index 0000000000..f8963e195f --- /dev/null +++ b/frontend/src/comp/utils/CalculateCost.jsx @@ -0,0 +1,25 @@ +import contributeMessages from "../../data/savemoneycontribute.json"; +import { useSubscriptionStore } from "../../stores/useSubscriptionStore"; + +export const CalculateCost = () => { + const selectedSubSave = useSubscriptionStore((s) => s.selectedSubSave); + + if (!selectedSubSave) { + return
No subscription selected
; + } + + const cost = Number(selectedSubSave.cost ?? 0); + const messageSelect = contributeMessages.find( + (message) => cost * 12 >= message.spanStart && cost * 12 <= message.spanEnd + ); + + return ( +
+

+ {cost * 12} kr left to + spend this year πŸŽ‰ +

+

{messageSelect?.message || "Donate and save the bees! 🐝"}

+
+ ); +}; diff --git a/frontend/src/comp/utils/EmailForm.jsx b/frontend/src/comp/utils/EmailForm.jsx new file mode 100644 index 0000000000..0236d8f4eb --- /dev/null +++ b/frontend/src/comp/utils/EmailForm.jsx @@ -0,0 +1,293 @@ +import React, { useEffect, useState } from "react"; + +import { EmailRemindersList } from "./EmailRemindersList"; + +export function EmailForm({ setRefreshReminders }) { + const [to, setTo] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [scheduledDate, setScheduledDate] = useState(""); + const [scheduledTime, setScheduledTime] = useState(""); + const [sendImmediately, setSendImmediately] = useState(true); + const [isRecurring, setIsRecurring] = useState(false); + const [refreshRemindersLocal, setRefreshRemindersLocal] = useState(false); + + // Pre-written message + const subject = "Subscription Reminder"; + const text = `Hello! +This is a reminder that you have subscriptions that is due to be renewed soon. + +**Insert the subscription details here.** + +// Subscribee`; + + // Get current date and time for minimum values + const getCurrentDate = () => { + const now = new Date(); + return now.toISOString().split("T")[0]; + }; + + const getCurrentTime = () => { + const now = new Date(); + return now.toTimeString().slice(0, 5); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!to) { + alert("Please enter an email address"); + return; + } + + // Validate scheduled sending + if (!sendImmediately) { + if (!scheduledDate || !scheduledTime) { + alert("Please select both date and time for scheduled sending"); + return; + } + + const scheduledDateTime = new Date(`${scheduledDate}T${scheduledTime}`); + const now = new Date(); + + if (scheduledDateTime <= now) { + alert("Scheduled time must be in the future"); + return; + } + + // Validate recurring option + if (isRecurring) { + const selectedDay = scheduledDateTime.getDate(); + if (selectedDay > 28) { + if ( + !confirm( + `You've selected day ${selectedDay} for monthly recurrence. This date doesn't exist in all months (e.g., February). The email will be sent on the last available day of shorter months. Continue?` + ) + ) { + return; + } + } + } + } + + setIsLoading(true); + + try { + const emailData = { + to, + subject, + text, + sendImmediately, + ...(!sendImmediately && { + scheduledDate, + scheduledTime, + scheduledDateTime: `${scheduledDate}T${scheduledTime}`, + isRecurring, + }), + }; + + const res = await fetch("/api/email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(emailData), + }); + const data = await res.json(); + + if (res.ok) { + if (sendImmediately) { + alert("Subscription reminder sent successfully!"); + } else { + const scheduledDateTime = new Date( + `${scheduledDate}T${scheduledTime}` + ); + const recurringText = isRecurring ? " (recurring monthly)" : ""; + alert( + `Email scheduled successfully for ${scheduledDateTime.toLocaleString()}${recurringText}!` + ); + } + setTo(""); // Clear the email field after success + setScheduledDate(""); + setScheduledTime(""); + setIsRecurring(false); + + if (setRefreshReminders) setRefreshReminders((prev) => !prev); + } else { + alert(`Error: det hΓ€r Γ€r ett test ${data.error}`); + } + } catch (error) { + alert(`Error: ${error.message}`); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

+ Send reminder email +

+

+ Send our pre-written reminder message to a subscriber +

+ +
+
+ + setTo(e.target.value)} + placeholder="Enter email address" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + required + disabled={isLoading} + /> +
+ + {/* Send Timing Options */} +
+ +
+
+ setSendImmediately(true)} + className="mr-2" + disabled={isLoading} + /> + +
+
+ setSendImmediately(false)} + className="mr-2" + disabled={isLoading} + /> + +
+
+
+ + {/* Date and Time Pickers - only show when scheduling */} + {!sendImmediately && ( +
+
+ + setScheduledDate(e.target.value)} + min={getCurrentDate()} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + required={!sendImmediately} + disabled={isLoading} + /> +
+
+ + setScheduledTime(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + required={!sendImmediately} + disabled={isLoading} + /> +
+ + {/* Recurring Option */} +
+
+ setIsRecurring(e.target.checked)} + className="mr-2" + disabled={isLoading} + /> + +
+ {isRecurring && ( +

+ This will send a reminder email every month on the same date. + If the date doesn't exist in certain months (e.g., January + 31st in February), the email will be sent on the last day of + that month. +

+ )} +
+ + {scheduledDate && scheduledTime && ( +
+ Scheduled for:{" "} + {new Date(`${scheduledDate}T${scheduledTime}`).toLocaleString()} + {isRecurring && ( +
+ πŸ“… Will repeat monthly on day{" "} + {new Date(`${scheduledDate}T${scheduledTime}`).getDate()} +
+ )} +
+ )} +
+ )} + + +
+ + {/* Email Reminders List */} +
+ +
+
+ ); +} diff --git a/frontend/src/comp/utils/EmailRemindersList.jsx b/frontend/src/comp/utils/EmailRemindersList.jsx new file mode 100644 index 0000000000..4bc824a004 --- /dev/null +++ b/frontend/src/comp/utils/EmailRemindersList.jsx @@ -0,0 +1,210 @@ +import React, { useState, useEffect } from "react"; + +export const EmailRemindersList = () => { + const [reminders, setReminders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + // Fetch immediately when component loads + fetchReminders(); + + // Set up polling to refresh every 30 seconds + const interval = setInterval(() => { + fetchReminders(); + }, 30000); // 30 seconds + + // Cleanup interval when component unmounts + return () => clearInterval(interval); + }, []); + + const fetchReminders = async () => { + try { + setLoading(true); + const response = await fetch("/api/email/reminders"); + + if (response.ok) { + const data = await response.json(); + setReminders(data.reminders || []); + } else { + setError("Failed to fetch email reminders"); + } + } catch (err) { + setError("Error connecting to server"); + console.error("Error fetching reminders:", err); + } finally { + setLoading(false); + } + }; + + const handleCancelReminder = async (jobId) => { + try { + const response = await fetch(`/api/email/job/${jobId}`, { + method: "DELETE", + }); + + if (response.ok) { + // Refresh the list after canceling + fetchReminders(); + } else { + const errorData = await response.json(); + alert(`Failed to cancel reminder: ${errorData.error}`); + } + } catch (err) { + alert("Error canceling reminder"); + console.error("Error canceling reminder:", err); + } + }; + + const formatDate = (dateString) => { + if (!dateString) return "N/A"; + return new Date(dateString).toLocaleString(); + }; + + const getStatusColor = (status) => { + switch (status) { + case "completed": + return "text-main bg-green-100"; + case "failed": + return "text-red-600 bg-red-100"; + case "active": + return "text-blue-600 bg-blue-100"; + default: + return "text-yellow-600 bg-yellow-100"; + } + }; + + const getTypeIcon = (type, isRecurring) => { + if (isRecurring) return "πŸ”„"; + switch (type) { + case "immediate": + return "⚑"; + case "delayed": + return "⏰"; + case "recurring": + return "πŸ”„"; + default: + return "πŸ“§"; + } + }; + + if (loading) { + return ( +
+

Email Reminders

+
+
+ Loading reminders... +
+
+ ); + } + + if (error) { + return ( +
+

Email Reminders

+
+

{error}

+ +
+
+ ); + } + + return ( +
+
+

Email Reminders

+
+ + {reminders.length} reminder{reminders.length !== 1 ? "s" : ""} + + +
+
+ + {reminders.length === 0 ? ( +
+

No email reminders scheduled

+

+ Use the form above to schedule your first reminder +

+
+ ) : ( +
+ {reminders.map((reminder) => ( +
+
+
+
+ + {getTypeIcon(reminder.type, reminder.isRecurring)} + + + {reminder.email} + + + {reminder.status} + +
+ +

{reminder.subject}

+ +
+ + Type:{" "} + {reminder.isRecurring + ? "Recurring (Monthly)" + : reminder.type} + + {reminder.nextRun && ( + + Next run:{" "} + {formatDate(reminder.nextRun)} + + )} + + Created: {formatDate(reminder.createdAt)} + +
+
+ +
+ {reminder.status === "scheduled" || + reminder.status === "waiting" ? ( + + ) : null} +
+
+
+ ))} +
+ )} +
+ ); +}; diff --git a/frontend/src/comp/utils/getLogoPath.js b/frontend/src/comp/utils/getLogoPath.js new file mode 100644 index 0000000000..c9e8965991 --- /dev/null +++ b/frontend/src/comp/utils/getLogoPath.js @@ -0,0 +1,7 @@ +export const getLogoPath = (subscriptionName) => { + // Handles spaces and special characters for URLs + const fileName = encodeURIComponent(subscriptionName) + ".webp"; + return `/logos/${fileName}`; +} + + diff --git a/frontend/src/data/savemoneycontribute.json b/frontend/src/data/savemoneycontribute.json new file mode 100644 index 0000000000..fe62359a93 --- /dev/null +++ b/frontend/src/data/savemoneycontribute.json @@ -0,0 +1,72 @@ +[ + { + "message": "Buy your friend a coffee β˜•οΈ", + "spanStart": 0, + "spanEnd": 100 + }, + { + "message": "Contribute to someone in need β€οΈβ€πŸ©Ή", + "spanStart": 101, + "spanEnd": 150 + }, + { + "message": "Take a friend out for ice cream 🍦", + "spanStart": 151, + "spanEnd": 180 + }, + { + "message": "Donate and save the bee's 🐝", + "spanStart": 181, + "spanEnd": 210 + }, + { + "message": "Buy flowers for yourself 🌸", + "spanStart": 211, + "spanEnd": 280 + }, + { + "message": "Buy flowers for someone special 🌼", + "spanStart": 281, + "spanEnd": 350 + }, + { + "message": "Treat yourself to something nice! πŸŽ‰", + "spanStart": 351, + "spanEnd": 450 + }, + { + "message": "Donate and save the bee's 🐝", + "spanStart": 451, + "spanEnd": 550 + }, + { + "message": "Go to the cinemas, bring someone who enyoy the same movies as you 🍿", + "spanStart": 551, + "spanEnd": 650 + }, + { + "message": "Get a massage and relax πŸ’†β€β™€οΈ", + "spanStart": 651, + "spanEnd": 800 + }, + { + "message": "Spend money on someone you love πŸ₯°", + "spanStart": 801, + "spanEnd": 1200 + }, + { + "message": "Take your family out for dinner 🍽️", + "spanStart": 1201, + "spanEnd": 2000 + }, + { + "message": "Take a friend and spend the night at a Spa πŸ’¦", + "spanStart": 2001, + "spanEnd": 5000 + }, + { + "message": "You deserve a trip ✈️", + "spanStart": 5001, + "spanEnd": 9999999 + } +] \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index e69de29bb2..a916ed5eee 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html { + scroll-behavior: smooth; +} \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 51294f3998..70aa5297a5 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,16 @@ +import "./index.css"; + +import { ThemeProvider } from "@material-tailwind/react"; import React from "react"; import ReactDOM from "react-dom/client"; + import { App } from "./App.jsx"; -import "./index.css"; + ReactDOM.createRoot(document.getElementById("root")).render( - + + + ); diff --git a/frontend/src/pages/About.jsx b/frontend/src/pages/About.jsx new file mode 100644 index 0000000000..08507e3be3 --- /dev/null +++ b/frontend/src/pages/About.jsx @@ -0,0 +1,21 @@ +import { AboutProject } from "../comp/blocks/AboutProject"; +import { AboutSub } from "../comp/blocks/AboutSub"; +import { FAQ } from "../comp/blocks/Faq"; +import { Popup } from "../comp/layout/Popup"; + +export const About = () => { + return ( + <> + + + + + +

Did you know?

+

+ 74% of people forget about their subscription fees and 42% still pay for ones they no longer use! πŸ’Έ +

+
+ + ); +} \ No newline at end of file diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx new file mode 100644 index 0000000000..50d0e4caf5 --- /dev/null +++ b/frontend/src/pages/Admin.jsx @@ -0,0 +1,10 @@ +import { Dashboard } from "../comp/dashboard/Dashboard"; + +import "../index.css"; + +export const Admin = () => { + + return ( + + ) +}; \ No newline at end of file diff --git a/frontend/src/pages/Email.jsx b/frontend/src/pages/Email.jsx new file mode 100644 index 0000000000..542fd30a3e --- /dev/null +++ b/frontend/src/pages/Email.jsx @@ -0,0 +1,20 @@ +import React, { useState, useEffect } from "react"; +import { EmailForm } from "./utils/comp/EmailForm"; +import EmailRemindersList from "../comp/utils/EmailRemindersList"; + +export const Email = () => { + const [refreshReminders, setRefreshReminders] = useState(false); + + useEffect(() => { + const handler = () => setRefreshReminders((prev) => !prev); + window.addEventListener("refresh-reminders", handler); + return () => window.removeEventListener("refresh-reminders", handler); + }, []); + + return ( +
+ + +
+ ); +}; diff --git a/frontend/src/pages/Error.jsx b/frontend/src/pages/Error.jsx new file mode 100644 index 0000000000..f8b07b84a9 --- /dev/null +++ b/frontend/src/pages/Error.jsx @@ -0,0 +1,30 @@ +import { FlagIcon } from "@heroicons/react/24/solid"; +import { Typography } from "@material-tailwind/react"; +import { useNavigate } from "react-router-dom"; +import { Btn } from "../comp/layout/Btn"; + +export const Error = () => { + const navigate = useNavigate(); + + const handleClick = () => { + navigate("/"); // navigate to /home route + }; + + return ( +
+ + + Error 404
It looks like something went wrong. +
+ + Go back to home, and try again. + + + Back to home + +
+ ); +}; diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx new file mode 100644 index 0000000000..85a7e6065b --- /dev/null +++ b/frontend/src/pages/Home.jsx @@ -0,0 +1,21 @@ +import { ContentBlock } from "../comp/blocks/ContentBlock"; +import { Guide } from "../comp/blocks/Guide"; +import { Header } from "../comp/layout/Header"; +import { Popup } from "../comp/layout/Popup"; + +export const Home = () => { + return ( + <> + +
+ + + +

Welcome busy bee! I'm Beeatrice.

+

+ Are you ready to save some honey today? 🍯 +

+
+ + ); +} \ No newline at end of file diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000000..18d7ae2016 --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,10 @@ +import { Userlogin } from '../comp/user/Userlogin'; + +export const Login = () => { + return ( + + + + ); +}; + diff --git a/frontend/src/pages/Signup.jsx b/frontend/src/pages/Signup.jsx new file mode 100644 index 0000000000..7381a88cb9 --- /dev/null +++ b/frontend/src/pages/Signup.jsx @@ -0,0 +1,10 @@ +import { Usersignup } from '../comp/user/Usersignup'; + +export const Signup = () => { + + + return ( + + + ); +}; \ No newline at end of file diff --git a/frontend/src/stores/useLoadingStore.jsx b/frontend/src/stores/useLoadingStore.jsx new file mode 100644 index 0000000000..a735019582 --- /dev/null +++ b/frontend/src/stores/useLoadingStore.jsx @@ -0,0 +1,6 @@ +import { create } from "zustand"; + +export const useLoadingStore = create((set) => ({ + loading: false, + setLoading: (value) => set({ loading: value }), +})); diff --git a/frontend/src/stores/useSubscriptionStore.jsx b/frontend/src/stores/useSubscriptionStore.jsx new file mode 100644 index 0000000000..f4bdfae4f5 --- /dev/null +++ b/frontend/src/stores/useSubscriptionStore.jsx @@ -0,0 +1,95 @@ +import { create } from "zustand"; +import { BaseURL } from "../comp/utils/BaseURL"; +import { useLoadingStore } from "./useLoadingStore"; + +export const useSubscriptionStore = create((set) => ({ + subscriptions: [], + message: null, + status: null, + + setSubscriptions: (subscriptions) => set({ subscriptions }), + + //SubscriptionSave + //state + isSaveOpen: false, + selectedSubSave: null, + //actions + openSaveDialog: (subscription) => + set({ isSaveOpen: true, selectedSubSave: subscription }), + closeSaveDialog: () => set({ isSaveOpen: false, selectedSubSave: null }), + + //SubscriptionModal + //state + isModalOpen: false, + selectedSub: null, + //actions + openModalDialog: (subscription) => + set({ isModalOpen: true, selectedSub: subscription || null }), + closeModalDialog: () => set({ isModalOpen: false, selectedSub: null }), + + addSubscription: (subscription) => + set((state) => ({ + subscriptions: [subscription, ...state.subscriptions], + })), + + // Update sub + updateSubscription: (updatedSub) => + set((state) => ({ + subscriptions: state.subscriptions.map((sub) => + sub._id === updatedSub._id ? updatedSub : sub + ), + })), + + clearSubscriptions: () => set({ subscriptions: [] }), + + fetchSubscriptions: async () => { + const urlAPI = `${BaseURL}/subscriptions`; + + const token = localStorage.getItem("user") + ? JSON.parse(localStorage.getItem("user")).token + : null; + + if (!token) { + set({ subscriptions: [] }); // Clear if no token + return; + } + + useLoadingStore.getState().setLoading(true); + + try { + const response = await fetch(`${urlAPI}`, { + headers: { + Authorization: token, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + // Try to parse backend message + let errorMessage = "Failed to fetch subscriptions"; + try { + const errorData = await response.json(); + if (errorData?.message) errorMessage = errorData.message; + } catch (_) { + // ignore JSON parse errors + } + + set({ + subscriptions: [], + message: errorMessage, + status: response.status, + }); + return; + } + + const data = await response.json(); + + set({ subscriptions: data.response || [] }); + } catch (error) { + console.error("Error fetching subscriptions:", error); + set({ subscriptions: [] }); + } finally { + useLoadingStore.getState().setLoading(false); + } + }, +})); diff --git a/frontend/src/stores/useUserStore.jsx b/frontend/src/stores/useUserStore.jsx new file mode 100644 index 0000000000..d5061628cd --- /dev/null +++ b/frontend/src/stores/useUserStore.jsx @@ -0,0 +1,20 @@ +import { create } from "zustand"; + +const storedUser = JSON.parse(localStorage.getItem("user") || "null"); + +export const useUserStore = create((set) => ({ + message: [], + status: [], + user: storedUser && storedUser.token ? storedUser : null, + + setUser: (userData) => { + localStorage.setItem("user", JSON.stringify(userData)); + set({ user: userData }); + }, + + clearUser: () => { + localStorage.removeItem("user"); + localStorage.removeItem("accessToken"); + set({ user: null }); + }, +})); diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000000..78634f725e --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,42 @@ +const withMT = require("@material-tailwind/react/utils/withMT"); + +module.exports = withMT({ + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + colors: { + main: "#047857", // teal greeen + accent: "#065F46", //Amerald + text: "#333333", // charcoal + light: "#555555", // light-grey + }, + keyframes: { + slideUpFade: { + "0%": { opacity: 0, transform: "translateY(20px)" }, + "100%": { opacity: 1, transform: "translateY(0)" }, + }, + buzzCircle: { + "0%": { transform: "translate(0, 0) rotate(0deg)" }, + "25%": { transform: "translate(2px, -2px) rotate(5deg)" }, + "50%": { transform: "translate(0, -4px) rotate(0deg)" }, + "75%": { transform: "translate(-2px, -2px) rotate(-5deg)" }, + "100%": { transform: "translate(0, 0) rotate(0deg)" }, + }, + }, + animation: { + slideUpFade: "slideUpFade 0.6s ease-out", + buzzCircle: "buzzCircle 0.4s linear infinite", + }, + fontFamily: { + heading: ["Didact Gothic", "Helvetica", "sans-serif"], + }, + fontWeight: { + light: 100, + normal: 400, + medium: 500, + bold: 700, + }, + }, + }, + plugins: [], +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 5a33944a9b..74ace2d6be 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,7 +1,16 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; -// https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], -}) + base: "/", + server: { + proxy: { + "/api": { + target: "http://localhost:8081", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ""), + }, + }, + }, +}); diff --git a/package.json b/package.json index 680d190772..84d17b7747 100644 --- a/package.json +++ b/package.json @@ -3,5 +3,19 @@ "version": "1.0.0", "scripts": { "postinstall": "npm install --prefix backend" + }, + "devDependencies": { + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17" + }, + "dependencies": { + "@material-tailwind/react": "^2.1.10", + "apexcharts": "^5.3.3", + "express-list-routes": "^1.3.1", + "framer-motion": "^12.23.12", + "lodash": "^4.17.21", + "react-apexcharts": "^1.7.0", + "react-router-hash-link": "^2.4.3" } -} \ No newline at end of file +}