├── public
├── images
│ ├── beach.jpg
│ ├── compass.png
│ ├── travel.jpeg
│ ├── download.webp
│ ├── gssoc_logo.png
│ ├── travel_cover-1500x1000.jpeg
│ ├── adult-adventure-backlit-915972.jpg
│ ├── Hot-Air-Balloon-captions-for-Instagram-1.webp
│ └── cappadocia-hot-air-balloons-colorful-specatcle.jpg
├── manifest.json
├── offline.html
└── CSS
│ ├── footer-theme.css
│ ├── tailwind.input.css
│ ├── loading.css
│ └── holiday.css
├── postcss.config.js
├── utils
├── wrapAsync.js
├── ExpressError.js
└── updateCoordinates.js
├── .gitignore
├── routes
├── phraseAssistant.js
├── newsletter.js
├── review.js
├── compare.js
├── listing.js
├── notifications.js
├── weather.js
├── packingList.js
├── user.js
├── safety.js
└── currency.js
├── models
├── review.js
├── phrase.js
├── newsletter.js
├── searchLog.js
├── wishlist.js
├── listing.js
└── badgeDefinition.js
├── tests
├── setup.js
├── utils
│ ├── ExpressError.test.js
│ └── wrapAsync.test.js
├── models
│ ├── user.test.js
│ ├── review.test.js
│ └── listing.test.js
├── middleware.test.js
├── reviews.test.js
├── map.test.js
├── auth.test.js
├── listings.test.js
├── README.md
└── schema.test.js
├── .github
├── ISSUE_TEMPLATE
│ ├── config.yml
│ └── issue_template.yml
├── dependabot.yml
└── workflows
│ ├── deploy.yml
│ ├── stale.yml
│ ├── issue-create-automate-message.yml
│ ├── codeql.yml
│ └── ci.yml
├── schema.js
├── views
├── includes
│ ├── flash.ejs
│ ├── category-bar.ejs
│ ├── footer.ejs
│ └── theme-toggle.ejs
├── users
│ ├── login.ejs
│ └── liked.ejs
├── phraseAssistant
│ ├── _widget.ejs
│ └── index.ejs
├── termCondition.ejs
├── privacy.ejs
├── error.ejs
└── listings
│ └── edit.ejs
├── .env.example
├── cloudConfig.js
├── LICENSE
├── tailwind.config.js
├── init
└── index.js
├── config
└── passport.js
├── SECURITY.md
├── locales
├── es.json
├── gu.json
├── mr.json
├── fr.json
├── pa.json
├── bn.json
├── ur.json
├── as.json
├── te.json
├── or.json
├── kn.json
├── ml.json
└── ta.json
├── middleware.js
├── package.json
├── controllers
├── reviews.js
└── newsletter.js
├── CODE_OF_CONDUCT.md
└── services
├── badgeService.js
├── weatherService.js
└── aiSummarizationService.js
/public/images/beach.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koushik369mondal/WanderLust/HEAD/public/images/beach.jpg
--------------------------------------------------------------------------------
/public/images/compass.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koushik369mondal/WanderLust/HEAD/public/images/compass.png
--------------------------------------------------------------------------------
/public/images/travel.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koushik369mondal/WanderLust/HEAD/public/images/travel.jpeg
--------------------------------------------------------------------------------
/public/images/download.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koushik369mondal/WanderLust/HEAD/public/images/download.webp
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/images/gssoc_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koushik369mondal/WanderLust/HEAD/public/images/gssoc_logo.png
--------------------------------------------------------------------------------
/utils/wrapAsync.js:
--------------------------------------------------------------------------------
1 | module.exports = (fn) => {
2 | return (req, res, next) => {
3 | fn(req, res, next).catch(next);
4 | }
5 | }
--------------------------------------------------------------------------------
/public/images/travel_cover-1500x1000.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koushik369mondal/WanderLust/HEAD/public/images/travel_cover-1500x1000.jpeg
--------------------------------------------------------------------------------
/public/images/adult-adventure-backlit-915972.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koushik369mondal/WanderLust/HEAD/public/images/adult-adventure-backlit-915972.jpg
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | node_modules/
3 | .vscode/
4 | *.log
5 | .DS_Store
6 | Thumbs.db
7 |
8 | # Tailwind CSS compiled output
9 | public/CSS/tailwind.output.css
10 |
--------------------------------------------------------------------------------
/public/images/Hot-Air-Balloon-captions-for-Instagram-1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koushik369mondal/WanderLust/HEAD/public/images/Hot-Air-Balloon-captions-for-Instagram-1.webp
--------------------------------------------------------------------------------
/public/images/cappadocia-hot-air-balloons-colorful-specatcle.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koushik369mondal/WanderLust/HEAD/public/images/cappadocia-hot-air-balloons-colorful-specatcle.jpg
--------------------------------------------------------------------------------
/utils/ExpressError.js:
--------------------------------------------------------------------------------
1 | class ExpressError extends Error {
2 | constructor(statusCode, message) {
3 | super();
4 | this.statusCode = statusCode;
5 | this.message = message;
6 | }
7 | }
8 |
9 | module.exports = ExpressError;
--------------------------------------------------------------------------------
/routes/phraseAssistant.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 | const phraseCtrl = require('../controllers/phraseAssistant');
4 |
5 | // Render full page (optional)
6 | router.get('/', phraseCtrl.renderIndex);
7 |
8 | // API endpoints used by the widget
9 | router.post('/api/translate', phraseCtrl.translate);
10 | router.post('/api/save', phraseCtrl.saveFavorite);
11 | router.get('/api/favorites', phraseCtrl.getFavorites);
12 |
13 | module.exports = router;
14 |
--------------------------------------------------------------------------------
/models/review.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 | const Schema = mongoose.Schema;
3 |
4 | const reviewSchema = new Schema({
5 | comment: String,
6 | rating: {
7 | type: Number,
8 | min: 1,
9 | max: 5,
10 | },
11 | createdAt: {
12 | type: Date,
13 | default: Date.now(),
14 | },
15 | author: {
16 | type: Schema.Types.ObjectId,
17 | ref: "User",
18 | },
19 | });
20 |
21 | module.exports = mongoose.model("Review", reviewSchema);
22 |
--------------------------------------------------------------------------------
/models/phrase.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose');
2 |
3 | const phraseSchema = new mongoose.Schema({
4 | user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: false },
5 | sourceText: { type: String, required: true },
6 | translatedText: { type: String, required: true },
7 | transliteration: { type: String },
8 | language: { type: String },
9 | category: { type: String },
10 | audioUrl: { type: String },
11 | createdAt: { type: Date, default: Date.now }
12 | });
13 |
14 | module.exports = mongoose.model('Phrase', phraseSchema);
15 |
--------------------------------------------------------------------------------
/tests/setup.js:
--------------------------------------------------------------------------------
1 | // Test Setup and Configuration
2 | require('dotenv').config({ path: '.env.test' });
3 |
4 | // Set test environment
5 | process.env.NODE_ENV = 'test';
6 |
7 | // Mock console methods to reduce noise in test output
8 | global.console = {
9 | ...console,
10 | log: jest.fn(),
11 | debug: jest.fn(),
12 | info: jest.fn(),
13 | warn: jest.fn(),
14 | };
15 |
16 | // Global test timeout
17 | jest.setTimeout(10000);
18 |
19 | // Clean up after all tests
20 | afterAll(async () => {
21 | // Close any open connections
22 | await new Promise(resolve => setTimeout(resolve, 500));
23 | });
24 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: true
2 | contact_links:
3 | - name: 📚 Read the Documentation
4 | url: https://github.com/koushik369mondal/WanderLust/blob/main/README.md
5 | about: Check the README for setup instructions and project information
6 | - name: 💬 Ask a Question
7 | url: https://github.com/koushik369mondal/WanderLust/discussions
8 | about: Use GitHub Discussions for questions and general discussion
9 | - name: 🤝 Contributing Guidelines
10 | url: https://github.com/koushik369mondal/WanderLust/blob/main/CONTRIBUTION.md
11 | about: Learn how to contribute to this project
12 |
--------------------------------------------------------------------------------
/schema.js:
--------------------------------------------------------------------------------
1 | const Joi = require('joi');
2 |
3 | module.exports.listingSchema = Joi.object({
4 | listing: Joi.object({
5 | title: Joi.string().required(),
6 | description: Joi.string().required(),
7 | location: Joi.string().required(),
8 | country: Joi.string().required(),
9 | price: Joi.number().required().min(0),
10 | image: Joi.string().allow("", null)
11 | }).required(),
12 | });
13 |
14 | module.exports.reviewSchema = Joi.object({
15 | review: Joi.object({
16 | rating: Joi.number().required().min(1).max(5),
17 | comment: Joi.string().required(),
18 | }).required(),
19 | });
20 |
--------------------------------------------------------------------------------
/views/includes/flash.ejs:
--------------------------------------------------------------------------------
1 | <% if (typeof success !== 'undefined' && success && success.length) { %>
2 |
3 | <%= success %>
4 |
5 |
6 | <% } %>
7 |
8 | <% if (typeof error !== 'undefined' && error && error.length) { %>
9 |
10 | <%= error %>
11 |
12 |
13 | <% } %>
--------------------------------------------------------------------------------
/routes/newsletter.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const router = express.Router();
3 | const newsletterController = require("../controllers/newsletter.js");
4 | const { isLoggedIn } = require("../middleware.js");
5 |
6 | // Subscribe to newsletter
7 | router.post("/subscribe", newsletterController.subscribe);
8 |
9 | // Unsubscribe from newsletter
10 | router.post("/unsubscribe", newsletterController.unsubscribe);
11 |
12 | // Newsletter management page
13 | router.get("/", (req, res) => {
14 | res.render("newsletter", { title: "Newsletter" });
15 | });
16 |
17 | // Get newsletter statistics (admin only)
18 | router.get("/stats", isLoggedIn, newsletterController.getStats);
19 |
20 | module.exports = router;
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # MongoDB Configuration
2 | ATLAS_DB_URL=your_mongodb_atlas_connection_string
3 |
4 | # Cloudinary Configuration (for image uploads)
5 | CLOUD_NAME=your_cloudinary_cloud_name
6 | CLOUD_API_KEY=your_cloudinary_api_key
7 | CLOUD_API_SECRET=your_cloudinary_api_secret
8 |
9 | # Mapbox Configuration (for maps)
10 | MAP_TOKEN=your_mapbox_access_token
11 |
12 | # Session Configuration
13 | SECRET=your_session_secret_key
14 |
15 | # Google OAuth (optional)
16 | GOOGLE_CLIENT_ID=your_google_client_id
17 | GOOGLE_CLIENT_SECRET=your_google_client_secret
18 |
19 | # Weather API Configuration
20 | WEATHER_API_KEY=your_openweathermap_api_key
21 |
22 | # Holiday API Configuration (optional)
23 | HOLIDAY_API_KEY=your_calendarific_api_key
24 |
25 | # Port Configuration
26 | PORT=8080
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "WanderLust - Travel Planner",
3 | "short_name": "WanderLust",
4 | "description": "Plan and manage your perfect trips with offline access",
5 | "start_url": "/",
6 | "display": "standalone",
7 | "background_color": "#ffffff",
8 | "theme_color": "#fe424d",
9 | "orientation": "portrait-primary",
10 | "scope": "/",
11 | "icons": [
12 | {
13 | "src": "/images/travel_cover-1500x1000.jpeg",
14 | "sizes": "192x192",
15 | "type": "image/jpeg",
16 | "purpose": "any maskable"
17 | },
18 | {
19 | "src": "/images/travel_cover-1500x1000.jpeg",
20 | "sizes": "512x512",
21 | "type": "image/jpeg",
22 | "purpose": "any maskable"
23 | }
24 | ],
25 | "categories": ["travel", "lifestyle", "productivity"],
26 | "lang": "en-US",
27 | "dir": "ltr"
28 | }
29 |
--------------------------------------------------------------------------------
/tests/utils/ExpressError.test.js:
--------------------------------------------------------------------------------
1 | const ExpressError = require('../utils/ExpressError');
2 |
3 | describe('ExpressError Utility', () => {
4 | it('should create error with status and message', () => {
5 | const error = new ExpressError(404, 'Not Found');
6 |
7 | expect(error.statusCode).toBe(404);
8 | expect(error.message).toBe('Not Found');
9 | expect(error).toBeInstanceOf(Error);
10 | });
11 |
12 | it('should create error with custom status code', () => {
13 | const error = new ExpressError(500, 'Internal Server Error');
14 |
15 | expect(error.statusCode).toBe(500);
16 | expect(error.message).toBe('Internal Server Error');
17 | });
18 |
19 | it('should create error with 400 Bad Request', () => {
20 | const error = new ExpressError(400, 'Bad Request');
21 |
22 | expect(error.statusCode).toBe(400);
23 | expect(error.message).toBe('Bad Request');
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/models/newsletter.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 | const Schema = mongoose.Schema;
3 |
4 | const newsletterSchema = new Schema({
5 | email: {
6 | type: String,
7 | required: true,
8 | unique: true,
9 | lowercase: true,
10 | trim: true,
11 | match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, 'Please enter a valid email address']
12 | },
13 | subscribedAt: {
14 | type: Date,
15 | default: Date.now
16 | },
17 | isActive: {
18 | type: Boolean,
19 | default: true
20 | },
21 | source: {
22 | type: String,
23 | default: 'footer',
24 | enum: ['footer', 'popup', 'signup', 'newsletter-page']
25 | }
26 | });
27 |
28 | // Create indexes for better performance
29 | // Email index is automatically created due to unique: true
30 | newsletterSchema.index({ subscribedAt: -1 });
31 |
32 | module.exports = mongoose.model("Newsletter", newsletterSchema);
--------------------------------------------------------------------------------
/models/searchLog.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 | const Schema = mongoose.Schema;
3 |
4 | const searchLogSchema = new Schema({
5 | query: {
6 | type: String,
7 | required: true,
8 | trim: true,
9 | maxlength: 200
10 | },
11 | resultsCount: {
12 | type: Number,
13 | required: true,
14 | min: 0
15 | },
16 | userAgent: {
17 | type: String,
18 | default: ''
19 | },
20 | ipAddress: {
21 | type: String,
22 | default: ''
23 | },
24 | timestamp: {
25 | type: Date,
26 | default: Date.now
27 | },
28 | category: {
29 | type: String,
30 | default: ''
31 | }
32 | });
33 |
34 | // Create indexes for better performance
35 | searchLogSchema.index({ timestamp: -1 });
36 | searchLogSchema.index({ query: 1 });
37 | searchLogSchema.index({ resultsCount: 1 });
38 |
39 | module.exports = mongoose.model("SearchLog", searchLogSchema);
--------------------------------------------------------------------------------
/cloudConfig.js:
--------------------------------------------------------------------------------
1 | const cloudinary = require("cloudinary").v2;
2 | const CloudinaryStorage = require("multer-storage-cloudinary");
3 |
4 | // Check if required environment variables are set
5 | if (!process.env.CLOUD_NAME || !process.env.CLOUD_API_KEY || !process.env.CLOUD_API_SECRET) {
6 | console.warn("⚠️ Cloudinary environment variables are missing in cloudConfig.js.");
7 | console.warn("ℹ️ Image uploads will not work properly without valid Cloudinary credentials.");
8 | }
9 |
10 | cloudinary.config({
11 | cloud_name: process.env.CLOUD_NAME || "dummy_cloud_name",
12 | api_key: process.env.CLOUD_API_KEY || "dummy_api_key",
13 | api_secret: process.env.CLOUD_API_SECRET || "dummy_api_secret",
14 | });
15 |
16 | const storage = CloudinaryStorage({
17 | cloudinary: cloudinary,
18 | params: {
19 | folder: 'wanderlust_DEV',
20 | allowedFormats: ['jpeg', 'png', 'jpg'],
21 | },
22 | });
23 |
24 | module.exports = {
25 | cloudinary,
26 | storage,
27 | }
--------------------------------------------------------------------------------
/routes/review.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const router = express.Router({ mergeParams: true });
3 | const wrapAsync = require("../utils/wrapAsync.js");
4 | const ExpressError = require("../utils/ExpressError.js");
5 | const Review = require("../models/review.js");
6 | const Listing = require("../models/listing.js");
7 | const {
8 | validateReview,
9 | isLoggedIn,
10 | isReviewAuthor,
11 | } = require("../middleware.js");
12 |
13 | const reviewController = require("../controllers/reviews.js");
14 |
15 | // Reviews -> Post Route
16 | router.post(
17 | "/",
18 | isLoggedIn,
19 | validateReview,
20 | wrapAsync(reviewController.createReview)
21 | );
22 |
23 | // Delete Review Route
24 | router.delete(
25 | "/:reviewId",
26 | isLoggedIn,
27 | isReviewAuthor,
28 | wrapAsync(reviewController.destroyReview)
29 | );
30 |
31 | // Translate Review Route
32 | router.get(
33 | "/translate/:reviewId",
34 | wrapAsync(reviewController.translateReview)
35 | );
36 |
37 | module.exports = router;
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 koushik369mondal
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./views/**/*.ejs",
5 | "./public/**/*.js",
6 | "./public/**/*.html"
7 | ],
8 | theme: {
9 | extend: {
10 | colors: {
11 | primary: '#007bff',
12 | secondary: '#6c757d',
13 | success: '#28a745',
14 | danger: '#dc3545',
15 | warning: '#ffc107',
16 | info: '#17a2b8',
17 | // Premium brand colors for glassmorphism
18 | brand1: '#0d1b2a',
19 | brand2: '#1b263b',
20 | gold: '#ffc371',
21 | coral: '#ff5f6d',
22 | aqua: '#00c8ff',
23 | glass: 'rgba(255, 255, 255, 0.1)',
24 | 'glass-dark': 'rgba(0, 0, 0, 0.2)',
25 | },
26 | fontFamily: {
27 | sans: ['system-ui', '-apple-system', 'Segoe UI', 'Roboto', 'Arial', 'sans-serif'],
28 | },
29 | backdropBlur: {
30 | xs: '2px',
31 | },
32 | },
33 | },
34 | plugins: [],
35 | // Prefix to avoid conflicts with Bootstrap
36 | prefix: 'tw-',
37 | // Don't reset Bootstrap styles
38 | corePlugins: {
39 | preflight: false,
40 | },
41 | }
42 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/issue_template.yml:
--------------------------------------------------------------------------------
1 | name: 📝 Create an Issue
2 | description: Report a bug, request a feature, or suggest an improvement
3 | body:
4 | - type: markdown
5 | attributes:
6 | value: |
7 | ## 👋 Welcome to WanderLust!
8 |
9 | Thank you for contributing! Please provide clear information about your issue.
10 |
11 | - type: textarea
12 | attributes:
13 | label: 📋 Description
14 | description: Clearly describe what you want to report or suggest
15 | placeholder: |
16 | Be specific and clear:
17 | - What is the issue or feature?
18 | - What should happen?
19 | - What actually happens (if bug)?
20 |
21 | Examples:
22 | • "Fix typo 'accomodation' → 'accommodation' in navbar.ejs line 23"
23 | • "Add search bar to filter listings by location and price"
24 | • "Security: JWT token validation missing in /api/user routes"
25 | validations:
26 | required: true
27 |
28 | - type: textarea
29 | attributes:
30 | label: 📸 Screenshots (Optional)
31 | description: Add screenshots, mockups, or error messages if helpful
32 | placeholder: Drag and drop images here
--------------------------------------------------------------------------------
/models/wishlist.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 | const Schema = mongoose.Schema;
3 |
4 | const wishlistSchema = new Schema({
5 | user: {
6 | type: Schema.Types.ObjectId,
7 | ref: "User",
8 | required: true
9 | },
10 | listing: {
11 | type: Schema.Types.ObjectId,
12 | ref: "Listing",
13 | required: true
14 | },
15 | addedAt: {
16 | type: Date,
17 | default: Date.now
18 | },
19 | notes: {
20 | type: String,
21 | maxlength: 200,
22 | default: ""
23 | },
24 | tags: [{
25 | type: String,
26 | maxlength: 20
27 | }],
28 | priority: {
29 | type: String,
30 | enum: ['low', 'medium', 'high'],
31 | default: 'medium'
32 | },
33 | isPrivate: {
34 | type: Boolean,
35 | default: false
36 | }
37 | });
38 |
39 | // Create compound index to prevent duplicate entries
40 | wishlistSchema.index({ user: 1, listing: 1 }, { unique: true });
41 |
42 | // Index for efficient queries
43 | wishlistSchema.index({ user: 1, addedAt: -1 });
44 | wishlistSchema.index({ user: 1, priority: 1 });
45 |
46 | module.exports = mongoose.model("Wishlist", wishlistSchema);
--------------------------------------------------------------------------------
/init/index.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 | const initData = require("./data.js");
3 | const Listing = require("../models/listing.js");
4 | const { updateListingCoordinates } = require("../utils/updateCoordinates.js");
5 | const dotenv = require("dotenv");
6 | dotenv.config({ quiet: true });
7 |
8 | const MONGO_URL = process.env.ATLAS_DB_URL;
9 |
10 | main()
11 | .then(() => {
12 | console.log("✅ Database connected");
13 | })
14 | .catch((err) => {
15 | console.error("❌ Database connection failed:", err.message);
16 | });
17 | async function main() {
18 | await mongoose.connect(MONGO_URL);
19 | }
20 |
21 | const initDB = async () => {
22 | await Listing.deleteMany({});
23 | initData.data = initData.data.map((obj) => ({
24 | ...obj,
25 | owner: "68b03abbf434cdd259bd1032",
26 | // Don't set default coordinates here - let updateListingCoordinates handle it
27 | }));
28 | await Listing.insertMany(initData.data);
29 | console.log("✅ Sample data initialized");
30 |
31 | // Now update all listings with proper coordinates
32 | console.log("🗺️ Updating listing coordinates...");
33 | await updateListingCoordinates();
34 | console.log("✅ All coordinates updated!");
35 | };
36 |
37 | initDB();
38 |
--------------------------------------------------------------------------------
/views/users/login.ejs:
--------------------------------------------------------------------------------
1 | <% layout('/layouts/boilerplate') %>
2 |
3 |
--------------------------------------------------------------------------------
/public/offline.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | WanderLust - Offline
7 |
8 |
34 |
35 |
36 |
37 |
38 |
You're Offline
39 |
It looks like you've lost your connection. This page hasn't been cached yet, but you can still access pages you've recently visited.
40 |
41 |
42 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "npm"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 | day: "monday"
8 | time: "09:00"
9 | target-branch: "main"
10 | commit-message:
11 | prefix: "deps"
12 | prefix-development: "deps-dev"
13 | include: "scope"
14 | open-pull-requests-limit: 10
15 | reviewers:
16 | - "koushik369mondal"
17 | labels:
18 | - "dependencies"
19 | - "automated"
20 | - "GSSoC'25"
21 | allow:
22 | - dependency-type: "direct"
23 | - dependency-type: "indirect"
24 | ignore:
25 | # Ignore major version updates for stable packages
26 | - dependency-name: "express"
27 | update-types: ["version-update:semver-major"]
28 | - dependency-name: "mongoose"
29 | update-types: ["version-update:semver-major"]
30 | groups:
31 | security-updates:
32 | patterns:
33 | - "*"
34 | update-types:
35 | - "major"
36 | - "minor"
37 | - "patch"
38 | production-dependencies:
39 | patterns:
40 | - "express*"
41 | - "mongoose*"
42 | - "passport*"
43 | - "cloudinary*"
44 | - "multer*"
45 | - "ejs*"
46 | - "bootstrap*"
47 | update-types:
48 | - "minor"
49 | - "patch"
50 | development-dependencies:
51 | patterns:
52 | - "nodemon*"
53 | - "jest*"
54 | - "eslint*"
55 | - "prettier*"
56 | update-types:
57 | - "major"
58 | - "minor"
59 | - "patch"
--------------------------------------------------------------------------------
/tests/models/user.test.js:
--------------------------------------------------------------------------------
1 | const User = require('../models/user');
2 |
3 | describe('User Model', () => {
4 | describe('Schema Validation', () => {
5 | it('should create a valid user', () => {
6 | const validUser = new User({
7 | email: 'test@example.com',
8 | username: 'testuser'
9 | });
10 |
11 | const error = validUser.validateSync();
12 | expect(error).toBeUndefined();
13 | });
14 |
15 | it('should fail without email', () => {
16 | const user = new User({
17 | username: 'testuser'
18 | });
19 |
20 | const error = user.validateSync();
21 | expect(error.errors.email).toBeDefined();
22 | });
23 |
24 | it('should fail without username', () => {
25 | const user = new User({
26 | email: 'test@example.com'
27 | });
28 |
29 | const error = user.validateSync();
30 | expect(error.errors.username).toBeDefined();
31 | });
32 |
33 | it('should fail with invalid email format', () => {
34 | const user = new User({
35 | email: 'invalid-email',
36 | username: 'testuser'
37 | });
38 |
39 | const error = user.validateSync();
40 | expect(error.errors.email).toBeDefined();
41 | });
42 |
43 | it('should have default role as user', () => {
44 | const user = new User({
45 | email: 'test@example.com',
46 | username: 'testuser'
47 | });
48 |
49 | expect(user.role).toBe('user');
50 | });
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/routes/compare.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 | const Listing = require('../models/listing');
4 |
5 | // GET /listings/compare
6 | router.get('/compare', async (req, res) => {
7 | try {
8 | console.log('Compare route accessed'); // Debug log
9 | const listingIds = req.query.ids ? req.query.ids.split(',') : [];
10 |
11 | if (listingIds.length < 2 || listingIds.length > 3) {
12 | req.flash('error', 'Please select 2-3 listings to compare.');
13 | return res.redirect('/listings');
14 | }
15 |
16 | const listings = await Listing.find({
17 | '_id': { $in: listingIds }
18 | }).populate('reviews');
19 |
20 | const processedListings = listings.map(listing => {
21 | const avgRating = listing.reviews.length > 0
22 | ? listing.reviews.reduce((sum, review) => sum + review.rating, 0) / listing.reviews.length
23 | : 0;
24 |
25 | return {
26 | ...listing.toObject(),
27 | avgRating: avgRating.toFixed(1),
28 | amenities: listing.amenities || [],
29 | priceWithTax: Math.round(listing.price * 1.18),
30 | reviewCount: listing.reviews.length
31 | };
32 | });
33 |
34 | res.render('listings/compare', {
35 | listings: processedListings,
36 | title: 'Compare Listings'
37 | });
38 | } catch (error) {
39 | console.error('Comparison error:', error);
40 | req.flash('error', 'Error loading comparison page.');
41 | res.redirect('/listings');
42 | }
43 | });
44 |
45 | module.exports = router; // Make sure this line is present
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: 🚀 Deploy to Production
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | paths-ignore:
8 | - "README.md"
9 | - "LICENSE"
10 | - "CODE_OF_CONDUCT.md"
11 | - "SECURITY.md"
12 | - ".gitignore"
13 | - "docs/**"
14 | - ".github/ISSUE_TEMPLATE/**"
15 |
16 | # Prevent multiple deployments from running simultaneously
17 | concurrency:
18 | group: deploy-production
19 | cancel-in-progress: true
20 |
21 | jobs:
22 | deploy:
23 | runs-on: ubuntu-latest
24 | name: 🌐 Production Deployment
25 |
26 | steps:
27 | - name: 📥 Checkout Code
28 | uses: actions/checkout@v4
29 |
30 | - name: 🟢 Set up Node.js
31 | uses: actions/setup-node@v4
32 | with:
33 | node-version: "20.x" # Use stable LTS version for production
34 | cache: "npm"
35 |
36 | - name: 📦 Install Dependencies
37 | run: npm ci
38 |
39 | - name: 🔒 Run Security Audit
40 | run: npm audit --omit=dev --audit-level high
41 |
42 | - name: 🏗️ Build Application
43 | run: |
44 | echo "🚀 Building WanderLust for production deployment"
45 | echo "📝 Commit: ${{ github.sha }}"
46 | echo "👤 Author: ${{ github.actor }}"
47 | echo "🌿 Branch: ${{ github.ref_name }}"
48 | echo ""
49 | echo "✅ Dependencies installed successfully"
50 | echo "🔒 Security audit passed"
51 | echo "📦 Application ready for deployment"
52 | echo ""
53 | echo "🔗 Render will auto-deploy from this commit to:"
54 | echo " https://wanderlust-c8cb.onrender.com"
55 | echo ""
56 | echo "🎉 Deployment initiated successfully!"
57 |
--------------------------------------------------------------------------------
/tests/utils/wrapAsync.test.js:
--------------------------------------------------------------------------------
1 | const wrapAsync = require('../utils/wrapAsync');
2 |
3 | describe('wrapAsync Utility', () => {
4 | it('should wrap async function and catch errors', async () => {
5 | const asyncFn = async (req, res) => {
6 | throw new Error('Test error');
7 | };
8 |
9 | const wrappedFn = wrapAsync(asyncFn);
10 | const mockReq = {};
11 | const mockRes = {};
12 | const mockNext = jest.fn();
13 |
14 | await wrappedFn(mockReq, mockRes, mockNext);
15 |
16 | expect(mockNext).toHaveBeenCalled();
17 | expect(mockNext.mock.calls[0][0]).toBeInstanceOf(Error);
18 | expect(mockNext.mock.calls[0][0].message).toBe('Test error');
19 | });
20 |
21 | it('should call next with error when async function fails', async () => {
22 | const asyncFn = async () => {
23 | throw new Error('Async error');
24 | };
25 |
26 | const wrappedFn = wrapAsync(asyncFn);
27 | const mockNext = jest.fn();
28 |
29 | await wrappedFn({}, {}, mockNext);
30 |
31 | expect(mockNext).toHaveBeenCalledWith(expect.any(Error));
32 | });
33 |
34 | it('should not call next if no error occurs', async () => {
35 | const asyncFn = async (req, res) => {
36 | res.status(200).send('OK');
37 | };
38 |
39 | const wrappedFn = wrapAsync(asyncFn);
40 | const mockReq = {};
41 | const mockRes = { status: jest.fn().mockReturnThis(), send: jest.fn() };
42 | const mockNext = jest.fn();
43 |
44 | await wrappedFn(mockReq, mockRes, mockNext);
45 |
46 | expect(mockRes.status).toHaveBeenCalledWith(200);
47 | expect(mockNext).not.toHaveBeenCalled();
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/config/passport.js:
--------------------------------------------------------------------------------
1 | const passport = require("passport");
2 | const GoogleStrategy = require("passport-google-oauth20").Strategy;
3 | const User = require("../models/user");
4 |
5 | // Google OAuth Strategy
6 | passport.use(new GoogleStrategy({
7 | clientID: process.env.GOOGLE_CLIENT_ID || "dummy_client_id",
8 | clientSecret: process.env.GOOGLE_CLIENT_SECRET || "dummy_client_secret",
9 | callbackURL: "/auth/google/callback"
10 | }, async (accessToken, refreshToken, profile, done) => {
11 | try {
12 | // Check if user already exists with this Google ID
13 | let existingUser = await User.findOne({ googleId: profile.id });
14 |
15 | if (existingUser) {
16 | return done(null, existingUser);
17 | }
18 |
19 | // Check if user exists with same email
20 | let user = await User.findOne({ email: profile.emails[0].value });
21 |
22 | if (user) {
23 | // Link Google account to existing user
24 | user.googleId = profile.id;
25 | user.displayName = profile.displayName;
26 | user.profilePicture = profile.photos[0]?.value || "";
27 | await user.save();
28 | return done(null, user);
29 | }
30 |
31 | // Create new user
32 | const newUser = new User({
33 | googleId: profile.id,
34 | email: profile.emails[0].value,
35 | displayName: profile.displayName,
36 | profilePicture: profile.photos[0]?.value || "",
37 | username: profile.emails[0].value.split('@')[0] // Use email prefix as username
38 | });
39 |
40 | await newUser.save();
41 | return done(null, newUser);
42 | } catch (error) {
43 | return done(error, null);
44 | }
45 | }));
46 |
47 | module.exports = passport;
48 |
--------------------------------------------------------------------------------
/tests/middleware.test.js:
--------------------------------------------------------------------------------
1 | const request = require('supertest');
2 | const app = require('../app');
3 |
4 | describe('Middleware Tests', () => {
5 | describe('Error Handling Middleware', () => {
6 | it('should handle 404 errors', async () => {
7 | const res = await request(app)
8 | .get('/non-existent-route')
9 | .expect(404);
10 |
11 | expect(res.text).toContain('404');
12 | });
13 | });
14 |
15 | describe('Session Middleware', () => {
16 | it('should set session cookies', async () => {
17 | const res = await request(app)
18 | .get('/listings');
19 |
20 | expect(res.headers['set-cookie']).toBeDefined();
21 | });
22 | });
23 |
24 | describe('Flash Messages', () => {
25 | it('should flash success messages', async () => {
26 | const agent = request.agent(app);
27 |
28 | // First request to set up session
29 | await agent.get('/listings');
30 |
31 | // Flash messages should work after session is established
32 | const res = await agent.get('/login');
33 | expect(res.status).toBe(200);
34 | });
35 | });
36 |
37 | describe('Security Headers', () => {
38 | it('should set security headers with Helmet', async () => {
39 | const res = await request(app)
40 | .get('/listings');
41 |
42 | expect(res.headers['x-content-type-options']).toBeDefined();
43 | });
44 | });
45 |
46 | describe('Method Override', () => {
47 | it('should support method override via query', async () => {
48 | const res = await request(app)
49 | .post('/listings/123456789012345678901234?_method=DELETE')
50 | .expect(302);
51 | });
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/views/phraseAssistant/_widget.ejs:
--------------------------------------------------------------------------------
1 |
47 |
--------------------------------------------------------------------------------
/tests/reviews.test.js:
--------------------------------------------------------------------------------
1 | const request = require('supertest');
2 | const app = require('../app');
3 |
4 | describe('Review Routes', () => {
5 | describe('POST /listings/:id/reviews', () => {
6 | it('should redirect to login when not authenticated', async () => {
7 | const res = await request(app)
8 | .post('/listings/123456789012345678901234/reviews')
9 | .send({
10 | rating: 5,
11 | comment: 'Great place!'
12 | })
13 | .expect(302);
14 |
15 | expect(res.headers.location).toContain('/login');
16 | });
17 |
18 | it('should reject review with invalid rating', async () => {
19 | const res = await request(app)
20 | .post('/listings/123456789012345678901234/reviews')
21 | .send({
22 | rating: 6, // Invalid rating (should be 1-5)
23 | comment: 'Test comment'
24 | });
25 |
26 | expect(res.status).toBe(302);
27 | });
28 |
29 | it('should reject review with missing comment', async () => {
30 | const res = await request(app)
31 | .post('/listings/123456789012345678901234/reviews')
32 | .send({
33 | rating: 5,
34 | comment: ''
35 | });
36 |
37 | expect(res.status).toBe(302);
38 | });
39 | });
40 |
41 | describe('DELETE /listings/:id/reviews/:reviewId', () => {
42 | it('should redirect to login when not authenticated', async () => {
43 | const res = await request(app)
44 | .delete('/listings/123456789012345678901234/reviews/123456789012345678901234')
45 | .expect(302);
46 |
47 | expect(res.headers.location).toContain('/login');
48 | });
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/routes/listing.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | const express = require("express");
4 | const router = express.Router();
5 | const wrapAsync = require("../utils/wrapAsync.js");
6 | const Listing = require("../models/listing.js");
7 | const { isLoggedIn, isOwner, validateListing } = require("../middleware.js");
8 | const listingsController = require("../controllers/listings.js");
9 | const multer = require("multer");
10 | const { storage } = require("../cloudConfig.js");
11 | const upload = multer({ storage });
12 |
13 | router
14 | .route("/")
15 | .get(wrapAsync(listingsController.index))
16 | .post(
17 | isLoggedIn,
18 | upload.single("listing[image]"),
19 | wrapAsync(listingsController.createListing)
20 | );
21 |
22 | // Search suggestions API
23 | router.get("/search/suggestions", wrapAsync(listingsController.getSearchSuggestions));
24 |
25 | // Advanced filtering API
26 | router.get("/api/filter", wrapAsync(listingsController.getFilteredListings));
27 |
28 | // About route
29 | router.get("/about", (req, res) => {
30 | res.render("about", { title: "About Us" });
31 | });
32 |
33 | // New Route - This must come BEFORE the /:id route
34 | router.get("/new", isLoggedIn, listingsController.renderNewForm);
35 |
36 | router
37 | .route("/:id")
38 | .get(wrapAsync(listingsController.showListing))
39 | .put(
40 | isLoggedIn,
41 | isOwner,
42 | upload.single("listing[image]"),
43 | validateListing,
44 | wrapAsync(listingsController.updateListing)
45 | )
46 | .delete(isLoggedIn, isOwner, wrapAsync(listingsController.destroyListing));
47 |
48 | // Edit Route
49 | router.get(
50 | "/:id/edit",
51 | isLoggedIn,
52 | isOwner,
53 | wrapAsync(listingsController.renderEditForm)
54 | );
55 |
56 | // Like/Unlike routes
57 | router.post('/:id/like', isLoggedIn, wrapAsync(listingsController.likeListing));
58 | router.post('/:id/unlike', isLoggedIn, wrapAsync(listingsController.unlikeListing));
59 |
60 | module.exports = router;
61 |
--------------------------------------------------------------------------------
/public/CSS/footer-theme.css:
--------------------------------------------------------------------------------
1 | /* Footer Dark Mode Theme Support */
2 |
3 | /* Light mode (default) footer variables */
4 | :root {
5 | --footer-title-color: #ffffff;
6 | --footer-text-color: #ffffff;
7 | --footer-link-hover-color: #ffffff;
8 | --footer-link-hover-shadow: 0 0 8px rgba(254, 66, 77, 0.8);
9 | --footer-input-bg: rgba(255, 255, 255, 0.15);
10 | --footer-input-color: #ffffff;
11 | --footer-input-placeholder: rgba(255, 255, 255, 0.85);
12 | --footer-input-border: rgba(255, 255, 255, 0.3);
13 | --footer-input-focus-border: rgba(255, 255, 255, 0.6);
14 | --footer-input-focus-bg: rgba(255, 255, 255, 0.2);
15 | --footer-input-focus-shadow: 0 0 0 2px rgba(254, 66, 77, 0.3);
16 | --footer-button-hover: #d63641;
17 | --footer-social-color: rgba(255, 255, 255, 0.8);
18 | --footer-social-hover-color: #ffffff;
19 | --footer-social-hover-shadow: 0 0 12px rgba(254, 66, 77, 0.8);
20 | --footer-border-color: rgba(255, 255, 255, 0.2);
21 | --footer-bottom-color: rgba(255, 255, 255, 0.8);
22 | }
23 |
24 | /* Dark mode footer variables */
25 | [data-theme="dark"] {
26 | --footer-title-color: var(--text-primary);
27 | --footer-text-color: var(--text-secondary);
28 | --footer-link-hover-color: var(--text-primary);
29 | --footer-link-hover-shadow: 0 0 8px rgba(254, 66, 77, 0.6);
30 | --footer-input-bg: rgba(255, 255, 255, 0.1);
31 | --footer-input-color: var(--text-primary);
32 | --footer-input-placeholder: var(--text-muted);
33 | --footer-input-border: rgba(255, 255, 255, 0.2);
34 | --footer-input-focus-border: rgba(255, 255, 255, 0.4);
35 | --footer-input-focus-bg: rgba(255, 255, 255, 0.15);
36 | --footer-input-focus-shadow: 0 0 0 2px rgba(254, 66, 77, 0.4);
37 | --footer-button-hover: #d63641;
38 | --footer-social-color: var(--text-secondary);
39 | --footer-social-hover-color: var(--text-primary);
40 | --footer-social-hover-shadow: 0 0 12px rgba(254, 66, 77, 0.6);
41 | --footer-border-color: rgba(255, 255, 255, 0.1);
42 | --footer-bottom-color: var(--text-muted);
43 | }
--------------------------------------------------------------------------------
/public/CSS/tailwind.input.css:
--------------------------------------------------------------------------------
1 | /* Tailwind CSS Base Styles */
2 | @tailwind base;
3 | @tailwind components;
4 | @tailwind utilities;
5 |
6 | /* Custom Tailwind Components */
7 | @layer components {
8 | .tw-btn-primary {
9 | @apply tw-bg-blue-500 tw-text-white tw-px-4 tw-py-2 tw-rounded tw-font-semibold hover:tw-bg-blue-600 tw-transition;
10 | }
11 |
12 | .tw-card {
13 | @apply tw-bg-white tw-rounded-lg tw-shadow-md tw-p-4 tw-transition hover:tw-shadow-lg;
14 | }
15 |
16 | .tw-badge {
17 | @apply tw-inline-block tw-px-2 tw-py-1 tw-text-xs tw-font-semibold tw-rounded-full;
18 | }
19 |
20 | /* Premium Glassmorphism Effects */
21 | .tw-glass {
22 | @apply tw-backdrop-blur-md tw-bg-white/10 tw-border tw-border-white/20;
23 | }
24 |
25 | .tw-glass-dark {
26 | @apply tw-backdrop-blur-md tw-bg-black/20 tw-border tw-border-white/10;
27 | }
28 |
29 | /* Premium Gradient Buttons */
30 | .tw-btn-gradient {
31 | @apply tw-bg-gradient-to-r tw-from-coral tw-to-gold tw-text-white tw-px-6 tw-py-3 tw-rounded-full tw-font-semibold tw-shadow-lg hover:tw-shadow-xl tw-transition-all tw-duration-300 hover:tw-scale-105;
32 | }
33 |
34 | .tw-btn-glass {
35 | @apply tw-glass tw-px-6 tw-py-3 tw-rounded-full tw-font-semibold tw-shadow-md hover:tw-shadow-lg tw-transition-all tw-duration-300 hover:tw-scale-105;
36 | }
37 |
38 | /* Category Pills */
39 | .tw-category-pill {
40 | @apply tw-glass tw-px-4 tw-py-2 tw-rounded-full tw-text-sm tw-font-medium tw-whitespace-nowrap tw-transition-all tw-duration-300 hover:tw-scale-110 hover:tw-shadow-lg tw-cursor-pointer;
41 | }
42 |
43 | /* Search Bar Glass Effect */
44 | .tw-search-glass {
45 | @apply tw-glass tw-rounded-full tw-shadow-inner tw-transition-all tw-duration-300 focus-within:tw-ring-2 focus-within:tw-ring-aqua focus-within:tw-shadow-xl;
46 | }
47 | }
48 |
49 | /* Custom Utilities */
50 | @layer utilities {
51 | .tw-text-shadow {
52 | text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
53 | }
54 |
55 | .tw-text-shadow-lg {
56 | text-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
57 | }
58 | }
--------------------------------------------------------------------------------
/tests/models/review.test.js:
--------------------------------------------------------------------------------
1 | const Review = require('../models/review');
2 | const mongoose = require('mongoose');
3 |
4 | describe('Review Model', () => {
5 | describe('Schema Validation', () => {
6 | it('should create a valid review', () => {
7 | const validReview = new Review({
8 | comment: 'Great place to stay!',
9 | rating: 5,
10 | author: new mongoose.Types.ObjectId()
11 | });
12 |
13 | const error = validReview.validateSync();
14 | expect(error).toBeUndefined();
15 | });
16 |
17 | it('should fail without comment', () => {
18 | const review = new Review({
19 | rating: 5,
20 | author: new mongoose.Types.ObjectId()
21 | });
22 |
23 | const error = review.validateSync();
24 | expect(error.errors.comment).toBeDefined();
25 | });
26 |
27 | it('should fail without rating', () => {
28 | const review = new Review({
29 | comment: 'Test comment',
30 | author: new mongoose.Types.ObjectId()
31 | });
32 |
33 | const error = review.validateSync();
34 | expect(error.errors.rating).toBeDefined();
35 | });
36 |
37 | it('should fail with rating less than 1', () => {
38 | const review = new Review({
39 | comment: 'Test',
40 | rating: 0,
41 | author: new mongoose.Types.ObjectId()
42 | });
43 |
44 | const error = review.validateSync();
45 | expect(error.errors.rating).toBeDefined();
46 | });
47 |
48 | it('should fail with rating greater than 5', () => {
49 | const review = new Review({
50 | comment: 'Test',
51 | rating: 6,
52 | author: new mongoose.Types.ObjectId()
53 | });
54 |
55 | const error = review.validateSync();
56 | expect(error.errors.rating).toBeDefined();
57 | });
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/tests/map.test.js:
--------------------------------------------------------------------------------
1 | const request = require('supertest');
2 | const app = require('../app');
3 |
4 | describe('Map Integration Tests', () => {
5 | describe('Listing Show Page Map', () => {
6 | it('should load show page and contain map element', async () => {
7 | // This test will check if the show page loads and contains map element
8 | // Note: We need a valid listing ID for this test
9 | console.log('Testing map integration...');
10 |
11 | // First get listings to find a valid ID
12 | const indexRes = await request(app)
13 | .get('/listings')
14 | .expect(200);
15 |
16 | // Check if the page contains map-related elements
17 | expect(indexRes.text).toContain('mapbox');
18 | });
19 |
20 | it('should have mapbox token available', () => {
21 | const mapToken = process.env.MAP_TOKEN;
22 | expect(mapToken).toBeDefined();
23 | expect(mapToken).not.toBe('');
24 | console.log('Map token exists:', !!mapToken);
25 | });
26 |
27 | it('should have proper mapbox CSS and JS references', async () => {
28 | const res = await request(app)
29 | .get('/listings')
30 | .expect(200);
31 |
32 | // Check for Mapbox CSS
33 | expect(res.text).toContain('mapbox-gl.css');
34 | // Check for Mapbox JS
35 | expect(res.text).toContain('mapbox-gl.js');
36 | });
37 | });
38 |
39 | describe('Map JavaScript Logic', () => {
40 | it('should handle missing coordinates gracefully', () => {
41 | // Mock listing without coordinates
42 | const mockListing = {
43 | title: 'Test Listing',
44 | location: 'Test City',
45 | country: 'Test Country'
46 | };
47 |
48 | // This would test the coordinate fallback logic
49 | expect(mockListing.location).toBeDefined();
50 | expect(mockListing.country).toBeDefined();
51 | });
52 | });
53 | });
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | The following versions of Wanderlust are currently supported with security updates:
6 |
7 | | Version | Supported | Notes |
8 | | ------- | ------------------ | ---------------------- |
9 | | 1.0.x | :white_check_mark: | Current stable release |
10 | | < 1.0 | :x: | Pre-release versions |
11 |
12 | _Note: This project is built with Node.js, Express.js, MongoDB, and EJS templating engine._
13 |
14 | ## Reporting a Vulnerability
15 |
16 | We take security vulnerabilities seriously and appreciate your help in keeping Wanderlust secure.
17 |
18 | ### How to Report
19 |
20 | **Please do NOT report security vulnerabilities through public GitHub issues.**
21 |
22 | Instead, please report security vulnerabilities directly via email:
23 |
24 | - **Email:** koushik369mondal@gmail.com
25 | - **Subject:** [SECURITY] Wanderlust Vulnerability Report
26 |
27 | ### What to Include
28 |
29 | When reporting a security vulnerability, please include:
30 |
31 | - Description of the vulnerability
32 | - Steps to reproduce the issue
33 | - Potential impact assessment
34 | - Any suggested fixes or mitigation strategies
35 | - Your contact information for follow-up questions
36 |
37 | ### Response Timeline
38 |
39 | - **Initial Response:** Within 3 business days of receiving your report
40 | - **Status Updates:** We will keep you informed of our progress
41 | - **Resolution:** Timeline depends on complexity, but we prioritize security issues
42 |
43 | ### Our Commitment
44 |
45 | - We will acknowledge receipt of your vulnerability report promptly
46 | - We will investigate all legitimate reports and do our best to quickly fix the problem
47 | - We will notify you before any public disclosure of the vulnerability
48 | - We will credit you (if desired) for responsibly disclosing the issue
49 |
50 | ### Scope
51 |
52 | This security policy applies to:
53 |
54 | - The main Wanderlust application
55 | - All components including Node.js backend, Express routes, MongoDB integrations
56 | - EJS templates and client-side JavaScript
57 | - All dependencies and third-party packages
58 |
59 | Thank you for helping us maintain the security of Wanderlust!
60 |
--------------------------------------------------------------------------------
/models/listing.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 | const Schema = mongoose.Schema;
3 | const Review = require("./review.js");
4 |
5 | const listingSchema = new Schema({
6 | title: { type: String, required: true },
7 | description: String,
8 | image: {
9 | url: String,
10 | filename: String,
11 | },
12 | price: Number,
13 | location: String,
14 | country: String,
15 |
16 | // BADGE FIELDS
17 | isFeatured: { type: Boolean, default: false },
18 | hasDiscount: { type: Boolean, default: false },
19 | avgRating: { type: Number, default: 0 },
20 | hasFeaturedReview: { type: Boolean, default: false },
21 | discountPrice: { type: Number },
22 | createdAt: { type: Date, default: Date.now },
23 |
24 | category: {
25 | type: String,
26 | enum: ['Trending', 'Rooms', 'Iconic Cities', 'Mountains', 'Castles', 'Amazing pool', 'Camping', 'Farms', 'Arctic','Domes','Boats'], // This ensures only these values are accepted
27 | },
28 | bestSeason: {
29 | type: String,
30 | },
31 | travelTip: {
32 | type: String,
33 | },
34 |
35 | reviews: [
36 | {
37 | type: Schema.Types.ObjectId,
38 | ref: "Review",
39 | },
40 | ],
41 | aiSummary: {
42 | type: String,
43 | default: null,
44 | },
45 | aiSummaryLastUpdated: {
46 | type: Date,
47 | default: null,
48 | },
49 | owner: {
50 | type: Schema.Types.ObjectId,
51 | ref: "User",
52 | },
53 | geometry: {
54 | type: {
55 | type: String, // Don't do `{ location: { type: String } }`
56 | enum: ["Point"], // 'location.type' must be 'Point'
57 | required: false,
58 | },
59 | coordinates: {
60 | type: [Number],
61 | required: false,
62 | },
63 | },
64 | // category: {
65 | // type: String,
66 | // enum: [
67 | // "mountains",
68 | // "arctic",
69 | // "farms",
70 | // "deserts",
71 | // ]
72 | // }
73 | likes: [{ type: mongoose.Schema.Types.ObjectId, ref: "User" }],
74 | });
75 |
76 | listingSchema.post("findOneAndDelete", async (listing) => {
77 | if (listing) {
78 | await Review.deleteMany({ _id: { $in: listing.reviews } });
79 | }
80 | });
81 |
82 | const Listing = mongoose.model("Listing", listingSchema);
83 | module.exports = Listing;
84 |
--------------------------------------------------------------------------------
/routes/notifications.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 | const notificationController = require('../controllers/notifications');
4 | const { isLoggedIn } = require('../middleware');
5 |
6 | // Simple rate limiting middleware
7 | const rateLimitMap = new Map();
8 | const rateLimit = (maxRequests = 100, windowMs = 15 * 60 * 1000) => {
9 | return (req, res, next) => {
10 | const clientId = req.ip + ':' + (req.user ? req.user._id : 'anonymous');
11 | const now = Date.now();
12 | const windowStart = now - windowMs;
13 |
14 | if (!rateLimitMap.has(clientId)) {
15 | rateLimitMap.set(clientId, []);
16 | }
17 |
18 | const requests = rateLimitMap.get(clientId).filter(time => time > windowStart);
19 |
20 | if (requests.length >= maxRequests) {
21 | return res.status(429).json({ error: 'Too many requests. Please try again later.' });
22 | }
23 |
24 | requests.push(now);
25 | rateLimitMap.set(clientId, requests);
26 | next();
27 | };
28 | };
29 |
30 | // Middleware to ensure user is logged in for all notification routes
31 | router.use(isLoggedIn);
32 | // Add rate limiting to prevent abuse
33 | router.use(rateLimit(50, 15 * 60 * 1000)); // 50 requests per 15 minutes
34 |
35 | // Routes
36 | router.get('/', notificationController.getNotifications.bind(notificationController));
37 | router.get('/count', notificationController.getUnreadCount.bind(notificationController));
38 | router.get('/stats', notificationController.getStats.bind(notificationController));
39 | router.get('/settings', notificationController.getSettings.bind(notificationController));
40 |
41 | router.put('/settings', notificationController.updateSettings.bind(notificationController));
42 | router.put('/:id/read', notificationController.markAsRead.bind(notificationController));
43 | router.put('/read-all', notificationController.markAllAsRead.bind(notificationController));
44 | router.delete('/:id', notificationController.dismissNotification.bind(notificationController));
45 |
46 | // Test route (only in development)
47 | if (process.env.NODE_ENV !== 'production') {
48 | router.post('/test', notificationController.sendTestNotification.bind(notificationController));
49 | }
50 |
51 | module.exports = router;
--------------------------------------------------------------------------------
/tests/auth.test.js:
--------------------------------------------------------------------------------
1 | const request = require('supertest');
2 | const app = require('../app');
3 |
4 | describe('Authentication Routes', () => {
5 | describe('GET /signup', () => {
6 | it('should render signup page', async () => {
7 | const res = await request(app)
8 | .get('/signup')
9 | .expect('Content-Type', /html/)
10 | .expect(200);
11 |
12 | expect(res.text).toContain('Sign Up');
13 | });
14 | });
15 |
16 | describe('GET /login', () => {
17 | it('should render login page', async () => {
18 | const res = await request(app)
19 | .get('/login')
20 | .expect('Content-Type', /html/)
21 | .expect(200);
22 |
23 | expect(res.text).toContain('Login');
24 | });
25 | });
26 |
27 | describe('POST /signup', () => {
28 | it('should reject signup with missing fields', async () => {
29 | const res = await request(app)
30 | .post('/signup')
31 | .send({
32 | username: '',
33 | email: '',
34 | password: ''
35 | });
36 |
37 | expect(res.status).toBe(302); // Redirect on validation error
38 | });
39 |
40 | it('should reject signup with invalid email', async () => {
41 | const res = await request(app)
42 | .post('/signup')
43 | .send({
44 | username: 'testuser',
45 | email: 'invalid-email',
46 | password: 'password123'
47 | });
48 |
49 | expect(res.status).toBe(302);
50 | });
51 | });
52 |
53 | describe('POST /login', () => {
54 | it('should reject login with invalid credentials', async () => {
55 | const res = await request(app)
56 | .post('/login')
57 | .send({
58 | username: 'nonexistent',
59 | password: 'wrongpassword'
60 | });
61 |
62 | expect(res.status).toBe(302); // Redirect on failed login
63 | });
64 | });
65 |
66 | describe('GET /logout', () => {
67 | it('should logout user and redirect', async () => {
68 | const res = await request(app)
69 | .get('/logout')
70 | .expect(302);
71 |
72 | expect(res.headers.location).toBe('/listings');
73 | });
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/tests/models/listing.test.js:
--------------------------------------------------------------------------------
1 | const Listing = require('../models/listing');
2 | const mongoose = require('mongoose');
3 |
4 | describe('Listing Model', () => {
5 | describe('Schema Validation', () => {
6 | it('should create a valid listing', () => {
7 | const validListing = new Listing({
8 | title: 'Beach House',
9 | description: 'Beautiful beach house with ocean view',
10 | image: { url: 'https://example.com/image.jpg', filename: 'image.jpg' },
11 | price: 200,
12 | location: 'Malibu',
13 | country: 'USA',
14 | owner: new mongoose.Types.ObjectId()
15 | });
16 |
17 | const error = validListing.validateSync();
18 | expect(error).toBeUndefined();
19 | });
20 |
21 | it('should fail without required title', () => {
22 | const listing = new Listing({
23 | description: 'Test description',
24 | price: 100,
25 | location: 'Test',
26 | country: 'Test'
27 | });
28 |
29 | const error = listing.validateSync();
30 | expect(error.errors.title).toBeDefined();
31 | });
32 |
33 | it('should fail without required description', () => {
34 | const listing = new Listing({
35 | title: 'Test Title',
36 | price: 100,
37 | location: 'Test',
38 | country: 'Test'
39 | });
40 |
41 | const error = listing.validateSync();
42 | expect(error.errors.description).toBeDefined();
43 | });
44 |
45 | it('should fail with negative price', () => {
46 | const listing = new Listing({
47 | title: 'Test',
48 | description: 'Test',
49 | price: -100,
50 | location: 'Test',
51 | country: 'Test'
52 | });
53 |
54 | const error = listing.validateSync();
55 | expect(error.errors.price).toBeDefined();
56 | });
57 |
58 | it('should set default image if not provided', () => {
59 | const listing = new Listing({
60 | title: 'Test',
61 | description: 'Test',
62 | price: 100,
63 | location: 'Test',
64 | country: 'Test'
65 | });
66 |
67 | expect(listing.image.url).toContain('default');
68 | });
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/locales/es.json:
--------------------------------------------------------------------------------
1 | {
2 | "wanderlust": "WanderLust",
3 | "explore_world": "Explora el mundo con nosotros",
4 | "search_destinations": "Buscar destinos...",
5 | "search": "Buscar",
6 | "home": "Inicio",
7 | "all_listings": "Todos los Listados",
8 | "add_new_listing": "Agregar Nuevo Listado",
9 | "login": "Iniciar Sesión",
10 | "signup": "Registrarse",
11 | "logout": "Cerrar Sesión",
12 | "profile": "Perfil",
13 | "language": "Idioma",
14 | "english": "English",
15 | "hindi": "हिंदी",
16 | "spanish": "Español",
17 | "french": "Français",
18 | "share_your_journey": "Comparte tu Viaje",
19 | "your_words_inspire": "Tus palabras inspiran a otros viajeros",
20 | "rate_your_experience": "Califica tu Experiencia",
21 | "tell_your_story": "Cuenta tu Historia",
22 | "launch_review": "Enviar Reseña",
23 | "your_review_creates": "Tu reseña crea ondas de inspiración en la comunidad de viajeros",
24 | "traveler_stories": "Historias de Viajeros",
25 | "all_reviews": "Todas las Reseñas",
26 | "average_rating": "Calificación Promedio",
27 | "no_reviews_yet": "Aún no hay reseñas",
28 | "be_first_to_review": "¡Sé el primero en compartir tu experiencia en este lugar increíble!",
29 | "login_to_review": "Iniciar Sesión para Reseñar",
30 | "translate_review": "Traducir Reseña",
31 | "original_review": "Reseña Original",
32 | "translated_review": "Reseña Traducida",
33 | "translation_error": "Error en la traducción. Por favor, inténtalo de nuevo.",
34 | "poor_experience": "Experiencia Pobre",
35 | "below_expectations": "Por Debajo de las Expectativas",
36 | "good_experience": "Buena Experiencia",
37 | "amazing_experience": "Experiencia Increíble",
38 | "absolutely_perfect": "¡Absolutamente Perfecto!",
39 | "location": "Ubicación",
40 | "host": "Anfitrión",
41 | "vibe": "Ambiente",
42 | "value": "Valor",
43 | "what_made_special": "¿Qué hizo especial este lugar? Comparte la magia...",
44 | "location_breathtaking": "¡La ubicación era impresionante! ",
45 | "host_welcoming": "El anfitrión fue increíblemente acogedor. ",
46 | "perfect_relaxation": "Perfecto para relajación y paz. ",
47 | "great_value": "¡Excelente relación calidad-precio! ",
48 | "owned_by": "Propiedad de",
49 | "likes": "Me gusta",
50 | "like": "Me gusta",
51 | "unlike": "No me gusta",
52 | "add_to_wishlist": "Agregar a Lista de Deseos",
53 | "edit_listing": "Editar Listado",
54 | "delete_listing": "Eliminar Listado",
55 | "you_may_also_like": "También te puede gustar",
56 | "where_youll_be": "Donde estarás",
57 | "view_details": "Ver Detalles",
58 | "night": "noche",
59 | "recently": "Recientemente"
60 | }
--------------------------------------------------------------------------------
/views/termCondition.ejs:
--------------------------------------------------------------------------------
1 | <% layout("/layouts/boilerplate") %>
2 |
3 |
4 |
Terms & Conditions – Wanderlust
5 |
Welcome to Wanderlust! By using our website, you agree to the following terms
6 | and conditions. Please read them carefully.
7 |
8 |
1. Acceptance of Terms
9 |
By accessing or using our website, you agree to comply with and be bound by these Terms & Conditions.
10 | If you do not agree, please do not use our site.
11 |
12 |
2. Use of Website
13 |
14 | You must be at least 18 years old or use the site under the supervision of a parent/guardian.
15 | You agree not to use the website for any illegal or unauthorized purposes.
16 | You may not attempt to harm, hack, or disrupt the functioning of the website.
17 |
18 |
19 |
3. Accounts & Registration
20 |
21 | Some features may require you to create an account.
22 | You are responsible for keeping your login details confidential.
23 | Wanderlust is not liable for any loss or damage caused by unauthorized account access.
24 |
25 |
26 |
4. Content & Intellectual Property
27 |
28 | All text, images, logos, and content on Wanderlust belong to us unless stated otherwise.
29 | You may not copy, reproduce, or distribute any content without permission.
30 | User-generated content (reviews, comments, photos) may be used by Wanderlust for promotional purposes.
31 |
32 |
33 |
5. Third-Party Links
34 |
Our website may contain links to external websites. Wanderlust is not responsible for the content, services, or practices of these third-party sites.
35 |
36 |
6. Changes to Terms
37 |
We may update these Terms & Conditions at any time. Continued use of our website means you accept the updated terms.
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/locales/gu.json:
--------------------------------------------------------------------------------
1 | {
2 | "wanderlust": "વંડરલસ્ટ",
3 | "explore_world": "અમારી સાથે વિશ્વનું અન્વેષણ કરો",
4 | "search_destinations": "ગંતવ્યો શોધો...",
5 | "search": "શોધો",
6 | "home": "હોમ",
7 | "all_listings": "બધી યાદીઓ",
8 | "add_new_listing": "નવી યાદી ઉમેરો",
9 | "login": "લોગિન",
10 | "signup": "સાઇનઅપ",
11 | "logout": "લોગઆઉટ",
12 | "profile": "પ્રોફાઇલ",
13 | "language": "ભાષા",
14 | "english": "English",
15 | "hindi": "हिंदी",
16 | "bengali": "বাংলা",
17 | "telugu": "తెలుగు",
18 | "marathi": "मराठी",
19 | "tamil": "தமிழ்",
20 | "gujarati": "ગુજરાતી",
21 | "kannada": "ಕನ್ನಡ",
22 | "malayalam": "മലയാളം",
23 | "punjabi": "ਪੰਜਾਬੀ",
24 | "odia": "ଓଡ଼ିଆ",
25 | "assamese": "অসমীয়া",
26 | "urdu": "اردو",
27 | "share_your_journey": "તમારી યાત્રા શેર કરો",
28 | "your_words_inspire": "તમારા શબ્દો સાથી પ્રવાસીઓને પ્રેરણા આપે છે",
29 | "rate_your_experience": "તમારા અનુભવને રેટ કરો",
30 | "tell_your_story": "તમારી વાર્તા કહો",
31 | "launch_review": "રિવ્યૂ મોકલો",
32 | "your_review_creates": "તમારી રિવ્યૂ પ્રવાસ સમુદાયમાં પ્રેરણાની તરંગો બનાવે છે",
33 | "traveler_stories": "પ્રવાસીઓની વાર્તાઓ",
34 | "all_reviews": "બધી રિવ્યૂઓ",
35 | "average_rating": "સરેરાશ રેટિંગ",
36 | "no_reviews_yet": "હજુ સુધી કોઈ રિવ્યૂ નથી",
37 | "be_first_to_review": "આ અદ્ભુત સ્થળે તમારો અનુભવ શેર કરનાર પ્રથમ વ્યક્તિ બનો!",
38 | "login_to_review": "રિવ્યૂ માટે લોગિન કરો",
39 | "translate_review": "રિવ્યૂનું ભાષાંતર કરો",
40 | "original_review": "મૂળ રિવ્યૂ",
41 | "translated_review": "ભાષાંતરિત રિવ્યૂ",
42 | "translation_error": "ભાષાંતર નિષ્ફળ. કૃપા કરીને ફરી પ્રયાસ કરો.",
43 | "poor_experience": "ખરાબ અનુભવ",
44 | "below_expectations": "અપેક્ષાઓ કરતાં ઓછું",
45 | "good_experience": "સારો અનુભવ",
46 | "amazing_experience": "અદ્ભુત અનુભવ",
47 | "absolutely_perfect": "સંપૂર્ણ રીતે પરફેક્ટ!",
48 | "location": "સ્થાન",
49 | "host": "હોસ્ટ",
50 | "vibe": "વાતાવરણ",
51 | "value": "મૂલ્ય",
52 | "what_made_special": "આ સ્થળને શું ખાસ બનાવ્યું? જાદુ શેર કરો...",
53 | "location_breathtaking": "સ્થાન આશ્ચર્યજનક હતું! ",
54 | "host_welcoming": "હોસ્ટ અવિશ્વસનીય રીતે સ્વાગત કરતો હતો. ",
55 | "perfect_relaxation": "આરામ અને શાંતિ માટે પરફેક્ટ. ",
56 | "great_value": "પૈસા માટે શ્રેષ્ઠ મૂલ્ય! ",
57 | "owned_by": "માલિકી",
58 | "likes": "પસંદ",
59 | "like": "પસંદ કરો",
60 | "unlike": "નાપસંદ કરો",
61 | "add_to_wishlist": "વિશલિસ્ટમાં ઉમેરો",
62 | "edit_listing": "યાદી સંપાદિત કરો",
63 | "delete_listing": "યાદી કાઢી નાખો",
64 | "you_may_also_like": "તમને આ પણ ગમી શકે",
65 | "where_youll_be": "તમે ક્યાં હશો",
66 | "view_details": "વિગતો જુઓ",
67 | "night": "રાત",
68 | "recently": "તાજેતરમાં"
69 | }
--------------------------------------------------------------------------------
/locales/mr.json:
--------------------------------------------------------------------------------
1 | {
2 | "wanderlust": "वंडरलस्ट",
3 | "explore_world": "आमच्यासोबत जगाचा शोध घ्या",
4 | "search_destinations": "गंतव्य शोधा...",
5 | "search": "शोधा",
6 | "home": "होम",
7 | "all_listings": "सर्व यादी",
8 | "add_new_listing": "नवीन यादी जोडा",
9 | "login": "लॉगिन",
10 | "signup": "साइनअप",
11 | "logout": "लॉगआउट",
12 | "profile": "प्रोफाइल",
13 | "language": "भाषा",
14 | "english": "English",
15 | "hindi": "हिंदी",
16 | "bengali": "বাংলা",
17 | "telugu": "తెలుగు",
18 | "marathi": "मराठी",
19 | "tamil": "தமிழ்",
20 | "gujarati": "ગુજરાતી",
21 | "kannada": "ಕನ್ನಡ",
22 | "malayalam": "മലയാളം",
23 | "punjabi": "ਪੰਜਾਬੀ",
24 | "odia": "ଓଡ଼ିଆ",
25 | "assamese": "অসমীয়া",
26 | "urdu": "اردو",
27 | "share_your_journey": "तुमचा प्रवास शेअर करा",
28 | "your_words_inspire": "तुमचे शब्द सहप्रवाशांना प्रेरणा देतात",
29 | "rate_your_experience": "तुमच्या अनुभवाला रेटिंग द्या",
30 | "tell_your_story": "तुमची कहाणी सांगा",
31 | "launch_review": "रिव्यू पाठवा",
32 | "your_review_creates": "तुमचा रिव्यू प्रवास समुदायात प्रेरणेच्या लहरी निर्माण करतो",
33 | "traveler_stories": "प्रवाशांच्या कहाण्या",
34 | "all_reviews": "सर्व रिव्यू",
35 | "average_rating": "सरासरी रेटिंग",
36 | "no_reviews_yet": "अजून कोणतेही रिव्यू नाहीत",
37 | "be_first_to_review": "या अद्भुत ठिकाणी तुमचा अनुभव शेअर करणारे पहिले व्यक्ती व्हा!",
38 | "login_to_review": "रिव्यूसाठी लॉगिन करा",
39 | "translate_review": "रिव्यूचे भाषांतर करा",
40 | "original_review": "मूळ रिव्यू",
41 | "translated_review": "भाषांतरित रिव्यू",
42 | "translation_error": "भाषांतर अयशस्वी. कृपया पुन्हा प्रयत्न करा.",
43 | "poor_experience": "वाईट अनुभव",
44 | "below_expectations": "अपेक्षेपेक्षा कमी",
45 | "good_experience": "चांगला अनुभव",
46 | "amazing_experience": "अद्भुत अनुभव",
47 | "absolutely_perfect": "पूर्णपणे परिपूर्ण!",
48 | "location": "स्थान",
49 | "host": "यजमान",
50 | "vibe": "वातावरण",
51 | "value": "मूल्य",
52 | "what_made_special": "या ठिकाणाला काय खास बनवले? जादू शेअर करा...",
53 | "location_breathtaking": "स्थान चित्तथरारक होते! ",
54 | "host_welcoming": "यजमान आश्चर्यकारकपणे स्वागत करणारे होते. ",
55 | "perfect_relaxation": "विश्रांती आणि शांततेसाठी परिपूर्ण. ",
56 | "great_value": "पैशासाठी उत्तम मूल्य! ",
57 | "owned_by": "मालकी",
58 | "likes": "आवडी",
59 | "like": "आवडते",
60 | "unlike": "आवडत नाही",
61 | "add_to_wishlist": "विशलिस्टमध्ये जोडा",
62 | "edit_listing": "यादी संपादित करा",
63 | "delete_listing": "यादी हटवा",
64 | "you_may_also_like": "तुम्हाला हे देखील आवडू शकते",
65 | "where_youll_be": "तुम्ही कुठे असाल",
66 | "view_details": "तपशील पहा",
67 | "night": "रात्र",
68 | "recently": "अलीकडे"
69 | }
--------------------------------------------------------------------------------
/locales/fr.json:
--------------------------------------------------------------------------------
1 | {
2 | "wanderlust": "WanderLust",
3 | "explore_world": "Explorez le monde avec nous",
4 | "search_destinations": "Rechercher des destinations...",
5 | "search": "Rechercher",
6 | "home": "Accueil",
7 | "all_listings": "Toutes les Annonces",
8 | "add_new_listing": "Ajouter une Nouvelle Annonce",
9 | "login": "Se Connecter",
10 | "signup": "S'inscrire",
11 | "logout": "Se Déconnecter",
12 | "profile": "Profil",
13 | "language": "Langue",
14 | "english": "English",
15 | "hindi": "हिंदी",
16 | "spanish": "Español",
17 | "french": "Français",
18 | "share_your_journey": "Partagez votre Voyage",
19 | "your_words_inspire": "Vos mots inspirent les autres voyageurs",
20 | "rate_your_experience": "Évaluez votre Expérience",
21 | "tell_your_story": "Racontez votre Histoire",
22 | "launch_review": "Envoyer l'Avis",
23 | "your_review_creates": "Votre avis crée des ondulations d'inspiration dans la communauté des voyageurs",
24 | "traveler_stories": "Histoires de Voyageurs",
25 | "all_reviews": "Tous les Avis",
26 | "average_rating": "Note Moyenne",
27 | "no_reviews_yet": "Aucun avis pour le moment",
28 | "be_first_to_review": "Soyez le premier à partager votre expérience dans cet endroit incroyable !",
29 | "login_to_review": "Se Connecter pour Évaluer",
30 | "translate_review": "Traduire l'Avis",
31 | "original_review": "Avis Original",
32 | "translated_review": "Avis Traduit",
33 | "translation_error": "Échec de la traduction. Veuillez réessayer.",
34 | "poor_experience": "Expérience Médiocre",
35 | "below_expectations": "En Dessous des Attentes",
36 | "good_experience": "Bonne Expérience",
37 | "amazing_experience": "Expérience Incroyable",
38 | "absolutely_perfect": "Absolument Parfait !",
39 | "location": "Emplacement",
40 | "host": "Hôte",
41 | "vibe": "Ambiance",
42 | "value": "Valeur",
43 | "what_made_special": "Qu'est-ce qui a rendu cet endroit spécial ? Partagez la magie...",
44 | "location_breathtaking": "L'emplacement était à couper le souffle ! ",
45 | "host_welcoming": "L'hôte était incroyablement accueillant. ",
46 | "perfect_relaxation": "Parfait pour la détente et la paix. ",
47 | "great_value": "Excellent rapport qualité-prix ! ",
48 | "owned_by": "Propriété de",
49 | "likes": "J'aime",
50 | "like": "J'aime",
51 | "unlike": "Je n'aime pas",
52 | "add_to_wishlist": "Ajouter à la Liste de Souhaits",
53 | "edit_listing": "Modifier l'Annonce",
54 | "delete_listing": "Supprimer l'Annonce",
55 | "you_may_also_like": "Vous pourriez aussi aimer",
56 | "where_youll_be": "Où vous serez",
57 | "view_details": "Voir les Détails",
58 | "night": "nuit",
59 | "recently": "Récemment"
60 | }
--------------------------------------------------------------------------------
/locales/pa.json:
--------------------------------------------------------------------------------
1 | {
2 | "wanderlust": "ਵੰਡਰਲਸਟ",
3 | "explore_world": "ਸਾਡੇ ਨਾਲ ਦੁਨੀਆ ਦੀ ਖੋਜ ਕਰੋ",
4 | "search_destinations": "ਮੰਜ਼ਿਲਾਂ ਖੋਜੋ...",
5 | "search": "ਖੋਜੋ",
6 | "home": "ਘਰ",
7 | "all_listings": "ਸਾਰੀਆਂ ਸੂਚੀਆਂ",
8 | "add_new_listing": "ਨਵੀਂ ਸੂਚੀ ਸ਼ਾਮਲ ਕਰੋ",
9 | "login": "ਲਾਗਇਨ",
10 | "signup": "ਸਾਈਨਅਪ",
11 | "logout": "ਲਾਗਆਉਟ",
12 | "profile": "ਪ੍ਰੋਫਾਈਲ",
13 | "language": "ਭਾਸ਼ਾ",
14 | "english": "English",
15 | "hindi": "हिंदी",
16 | "bengali": "বাংলা",
17 | "telugu": "తెలుగు",
18 | "marathi": "मराठी",
19 | "tamil": "தமிழ்",
20 | "gujarati": "ગુજરાતી",
21 | "kannada": "ಕನ್ನಡ",
22 | "malayalam": "മലയാളം",
23 | "punjabi": "ਪੰਜਾਬੀ",
24 | "odia": "ଓଡ଼ିଆ",
25 | "assamese": "অসমীয়া",
26 | "urdu": "اردو",
27 | "share_your_journey": "ਆਪਣਾ ਸਫ਼ਰ ਸਾਂਝਾ ਕਰੋ",
28 | "your_words_inspire": "ਤੁਹਾਡੇ ਸ਼ਬਦ ਸਾਥੀ ਯਾਤਰੀਆਂ ਨੂੰ ਪ੍ਰੇਰਿਤ ਕਰਦੇ ਹਨ",
29 | "rate_your_experience": "ਆਪਣੇ ਤਜਰਬੇ ਨੂੰ ਰੇਟ ਕਰੋ",
30 | "tell_your_story": "ਆਪਣੀ ਕਹਾਣੀ ਦੱਸੋ",
31 | "launch_review": "ਰਿਵਿਊ ਭੇਜੋ",
32 | "your_review_creates": "ਤੁਹਾਡਾ ਰਿਵਿਊ ਯਾਤਰਾ ਭਾਈਚਾਰੇ ਵਿੱਚ ਪ੍ਰੇਰਣਾ ਦੀਆਂ ਲਹਿਰਾਂ ਬਣਾਉਂਦਾ ਹੈ",
33 | "traveler_stories": "ਯਾਤਰੀਆਂ ਦੀਆਂ ਕਹਾਣੀਆਂ",
34 | "all_reviews": "ਸਾਰੇ ਰਿਵਿਊ",
35 | "average_rating": "ਔਸਤ ਰੇਟਿੰਗ",
36 | "no_reviews_yet": "ਅਜੇ ਤੱਕ ਕੋਈ ਰਿਵਿਊ ਨਹੀਂ",
37 | "be_first_to_review": "ਇਸ ਸ਼ਾਨਦਾਰ ਜਗ੍ਹਾ 'ਤੇ ਆਪਣਾ ਤਜਰਬਾ ਸਾਂਝਾ ਕਰਨ ਵਾਲੇ ਪਹਿਲੇ ਵਿਅਕਤੀ ਬਣੋ!",
38 | "login_to_review": "ਰਿਵਿਊ ਲਈ ਲਾਗਇਨ ਕਰੋ",
39 | "translate_review": "ਰਿਵਿਊ ਦਾ ਅਨੁਵਾਦ ਕਰੋ",
40 | "original_review": "ਅਸਲ ਰਿਵਿਊ",
41 | "translated_review": "ਅਨੁਵਾਦਿਤ ਰਿਵਿਊ",
42 | "translation_error": "ਅਨੁਵਾਦ ਅਸਫਲ। ਕਿਰਪਾ ਕਰਕੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।",
43 | "poor_experience": "ਮਾੜਾ ਤਜਰਬਾ",
44 | "below_expectations": "ਉਮੀਦਾਂ ਤੋਂ ਘੱਟ",
45 | "good_experience": "ਚੰਗਾ ਤਜਰਬਾ",
46 | "amazing_experience": "ਸ਼ਾਨਦਾਰ ਤਜਰਬਾ",
47 | "absolutely_perfect": "ਬਿਲਕੁਲ ਸੰਪੂਰਨ!",
48 | "location": "ਸਥਾਨ",
49 | "host": "ਮੇਜ਼ਬਾਨ",
50 | "vibe": "ਮਾਹੌਲ",
51 | "value": "ਮੁੱਲ",
52 | "what_made_special": "ਇਸ ਜਗ੍ਹਾ ਨੂੰ ਕੀ ਖਾਸ ਬਣਾਇਆ? ਜਾਦੂ ਸਾਂਝਾ ਕਰੋ...",
53 | "location_breathtaking": "ਸਥਾਨ ਸਾਹ ਲੈਣ ਵਾਲਾ ਸੀ! ",
54 | "host_welcoming": "ਮੇਜ਼ਬਾਨ ਅਵਿਸ਼ਵਾਸਯੋਗ ਤੌਰ 'ਤੇ ਸਵਾਗਤ ਕਰਨ ਵਾਲਾ ਸੀ। ",
55 | "perfect_relaxation": "ਆਰਾਮ ਅਤੇ ਸ਼ਾਂਤੀ ਲਈ ਸੰਪੂਰਨ। ",
56 | "great_value": "ਪੈਸੇ ਲਈ ਸ਼ਾਨਦਾਰ ਮੁੱਲ! ",
57 | "owned_by": "ਮਾਲਕੀ",
58 | "likes": "ਪਸੰਦ",
59 | "like": "ਪਸੰਦ ਕਰੋ",
60 | "unlike": "ਨਾਪਸੰਦ ਕਰੋ",
61 | "add_to_wishlist": "ਵਿਸ਼ਲਿਸਟ ਵਿੱਚ ਸ਼ਾਮਲ ਕਰੋ",
62 | "edit_listing": "ਸੂਚੀ ਸੰਪਾਦਿਤ ਕਰੋ",
63 | "delete_listing": "ਸੂਚੀ ਮਿਟਾਓ",
64 | "you_may_also_like": "ਤੁਹਾਨੂੰ ਇਹ ਵੀ ਪਸੰਦ ਆ ਸਕਦਾ ਹੈ",
65 | "where_youll_be": "ਤੁਸੀਂ ਕਿੱਥੇ ਹੋਵੋਗੇ",
66 | "view_details": "ਵੇਰਵੇ ਵੇਖੋ",
67 | "night": "ਰਾਤ",
68 | "recently": "ਹਾਲ ਹੀ ਵਿੱਚ"
69 | }
--------------------------------------------------------------------------------
/locales/bn.json:
--------------------------------------------------------------------------------
1 | {
2 | "wanderlust": "ওয়ান্ডারলাস্ট",
3 | "explore_world": "আমাদের সাথে বিশ্ব অন্বেষণ করুন",
4 | "search_destinations": "গন্তব্য খুঁজুন...",
5 | "search": "খুঁজুন",
6 | "home": "হোম",
7 | "all_listings": "সব তালিকা",
8 | "add_new_listing": "নতুন তালিকা যোগ করুন",
9 | "login": "লগইন",
10 | "signup": "সাইনআপ",
11 | "logout": "লগআউট",
12 | "profile": "প্রোফাইল",
13 | "language": "ভাষা",
14 | "english": "English",
15 | "hindi": "हिंदी",
16 | "bengali": "বাংলা",
17 | "telugu": "తెలుగు",
18 | "marathi": "मराठी",
19 | "tamil": "தமிழ்",
20 | "gujarati": "ગુજરાતી",
21 | "kannada": "ಕನ್ನಡ",
22 | "malayalam": "മലയാളം",
23 | "punjabi": "ਪੰਜਾਬੀ",
24 | "odia": "ଓଡ଼ିଆ",
25 | "assamese": "অসমীয়া",
26 | "urdu": "اردو",
27 | "share_your_journey": "আপনার যাত্রা শেয়ার করুন",
28 | "your_words_inspire": "আপনার কথা সহযাত্রীদের অনুপ্রাণিত করে",
29 | "rate_your_experience": "আপনার অভিজ্ঞতা রেট করুন",
30 | "tell_your_story": "আপনার গল্প বলুন",
31 | "launch_review": "রিভিউ পাঠান",
32 | "your_review_creates": "আপনার রিভিউ ভ্রমণ সম্প্রদায়ে অনুপ্রেরণার তরঙ্গ সৃষ্টি করে",
33 | "traveler_stories": "ভ্রমণকারীর গল্প",
34 | "all_reviews": "সব রিভিউ",
35 | "average_rating": "গড় রেটিং",
36 | "no_reviews_yet": "এখনও কোন রিভিউ নেই",
37 | "be_first_to_review": "এই অসাধারণ জায়গায় আপনার অভিজ্ঞতা শেয়ার করা প্রথম ব্যক্তি হন!",
38 | "login_to_review": "রিভিউর জন্য লগইন করুন",
39 | "translate_review": "রিভিউ অনুবাদ করুন",
40 | "original_review": "মূল রিভিউ",
41 | "translated_review": "অনুবাদিত রিভিউ",
42 | "translation_error": "অনুবাদ ব্যর্থ। আবার চেষ্টা করুন।",
43 | "poor_experience": "খারাপ অভিজ্ঞতা",
44 | "below_expectations": "প্রত্যাশার চেয়ে কম",
45 | "good_experience": "ভাল অভিজ্ঞতা",
46 | "amazing_experience": "অসাধারণ অভিজ্ঞতা",
47 | "absolutely_perfect": "একদম নিখুঁত!",
48 | "location": "অবস্থান",
49 | "host": "হোস্ট",
50 | "vibe": "পরিবেশ",
51 | "value": "মূল্য",
52 | "what_made_special": "এই জায়গাটি কী বিশেষ করেছে? জাদু শেয়ার করুন...",
53 | "location_breathtaking": "অবস্থানটি মনমুগ্ধকর ছিল! ",
54 | "host_welcoming": "হোস্ট অবিশ্বাস্যভাবে স্বাগত জানিয়েছিল। ",
55 | "perfect_relaxation": "বিশ্রাম এবং শান্তির জন্য নিখুঁত। ",
56 | "great_value": "টাকার জন্য দুর্দান্ত মূল্য! ",
57 | "owned_by": "মালিকানা",
58 | "likes": "পছন্দ",
59 | "like": "পছন্দ করুন",
60 | "unlike": "অপছন্দ করুন",
61 | "add_to_wishlist": "উইশলিস্টে যোগ করুন",
62 | "edit_listing": "তালিকা সম্পাদনা করুন",
63 | "delete_listing": "তালিকা মুছুন",
64 | "you_may_also_like": "আপনি এটাও পছন্দ করতে পারেন",
65 | "where_youll_be": "আপনি কোথায় থাকবেন",
66 | "view_details": "বিস্তারিত দেখুন",
67 | "night": "রাত",
68 | "recently": "সম্প্রতি"
69 | }
--------------------------------------------------------------------------------
/locales/ur.json:
--------------------------------------------------------------------------------
1 | {
2 | "wanderlust": "وینڈرلسٹ",
3 | "explore_world": "ہمارے ساتھ دنیا کی تلاش کریں",
4 | "search_destinations": "منزلیں تلاش کریں...",
5 | "search": "تلاش کریں",
6 | "home": "ہوم",
7 | "all_listings": "تمام فہرستیں",
8 | "add_new_listing": "نئی فہرست شامل کریں",
9 | "login": "لاگ ان",
10 | "signup": "سائن اپ",
11 | "logout": "لاگ آؤٹ",
12 | "profile": "پروفائل",
13 | "language": "زبان",
14 | "english": "English",
15 | "hindi": "हिंदी",
16 | "bengali": "বাংলা",
17 | "telugu": "తెలుగు",
18 | "marathi": "मराठी",
19 | "tamil": "தமிழ்",
20 | "gujarati": "ગુજરાતી",
21 | "kannada": "ಕನ್ನಡ",
22 | "malayalam": "മലയാളം",
23 | "punjabi": "ਪੰਜਾਬੀ",
24 | "odia": "ଓଡ଼ିଆ",
25 | "assamese": "অসমীয়া",
26 | "urdu": "اردو",
27 | "share_your_journey": "اپنا سفر شیئر کریں",
28 | "your_words_inspire": "آپ کے الفاظ ساتھی مسافروں کو متاثر کرتے ہیں",
29 | "rate_your_experience": "اپنے تجربے کو ریٹ کریں",
30 | "tell_your_story": "اپنی کہانی بتائیں",
31 | "launch_review": "جائزہ بھیجیں",
32 | "your_review_creates": "آپ کا جائزہ سفری کمیونٹی میں تحریک کی لہریں پیدا کرتا ہے",
33 | "traveler_stories": "مسافروں کی کہانیاں",
34 | "all_reviews": "تمام جائزے",
35 | "average_rating": "اوسط ریٹنگ",
36 | "no_reviews_yet": "ابھی تک کوئی جائزہ نہیں",
37 | "be_first_to_review": "اس حیرت انگیز جگہ پر اپنا تجربہ شیئر کرنے والے پہلے شخص بنیں!",
38 | "login_to_review": "جائزے کے لیے لاگ ان کریں",
39 | "translate_review": "جائزے کا ترجمہ کریں",
40 | "original_review": "اصل جائزہ",
41 | "translated_review": "ترجمہ شدہ جائزہ",
42 | "translation_error": "ترجمہ ناکام۔ براہ کرم دوبارہ کوشش کریں۔",
43 | "poor_experience": "برا تجربہ",
44 | "below_expectations": "توقعات سے کم",
45 | "good_experience": "اچھا تجربہ",
46 | "amazing_experience": "حیرت انگیز تجربہ",
47 | "absolutely_perfect": "بالکل کامل!",
48 | "location": "مقام",
49 | "host": "میزبان",
50 | "vibe": "ماحول",
51 | "value": "قیمت",
52 | "what_made_special": "اس جگہ کو کیا خاص بنایا؟ جادو شیئر کریں...",
53 | "location_breathtaking": "مقام دم گھٹنے والا تھا! ",
54 | "host_welcoming": "میزبان ناقابل یقین حد تک خوش آمدید کہنے والا تھا۔ ",
55 | "perfect_relaxation": "آرام اور سکون کے لیے کامل۔ ",
56 | "great_value": "پیسے کے لیے بہترین قیمت! ",
57 | "owned_by": "ملکیت",
58 | "likes": "پسند",
59 | "like": "پسند کریں",
60 | "unlike": "ناپسند کریں",
61 | "add_to_wishlist": "خواہشات کی فہرست میں شامل کریں",
62 | "edit_listing": "فہرست میں ترمیم کریں",
63 | "delete_listing": "فہرست حذف کریں",
64 | "you_may_also_like": "آپ کو یہ بھی پسند آ سکتا ہے",
65 | "where_youll_be": "آپ کہاں ہوں گے",
66 | "view_details": "تفصیلات دیکھیں",
67 | "night": "رات",
68 | "recently": "حال ہی میں"
69 | }
--------------------------------------------------------------------------------
/locales/as.json:
--------------------------------------------------------------------------------
1 | {
2 | "wanderlust": "ৱাণ্ডাৰলাষ্ট",
3 | "explore_world": "আমাৰ লগত পৃথিৱী অন্বেষণ কৰক",
4 | "search_destinations": "গন্তব্য বিচাৰক...",
5 | "search": "বিচাৰক",
6 | "home": "ঘৰ",
7 | "all_listings": "সকলো তালিকা",
8 | "add_new_listing": "নতুন তালিকা যোগ কৰক",
9 | "login": "লগইন",
10 | "signup": "চাইনআপ",
11 | "logout": "লগআউট",
12 | "profile": "প্ৰফাইল",
13 | "language": "ভাষা",
14 | "english": "English",
15 | "hindi": "हिंदी",
16 | "bengali": "বাংলা",
17 | "telugu": "తెలుగు",
18 | "marathi": "मराठी",
19 | "tamil": "தமிழ்",
20 | "gujarati": "ગુજરાતી",
21 | "kannada": "ಕನ್ನಡ",
22 | "malayalam": "മലയാളം",
23 | "punjabi": "ਪੰਜਾਬੀ",
24 | "odia": "ଓଡ଼ିଆ",
25 | "assamese": "অসমীয়া",
26 | "urdu": "اردو",
27 | "share_your_journey": "আপোনাৰ যাত্ৰা ভাগ কৰক",
28 | "your_words_inspire": "আপোনাৰ কথাই সহযাত্ৰীসকলক অনুপ্ৰাণিত কৰে",
29 | "rate_your_experience": "আপোনাৰ অভিজ্ঞতা ৰেট কৰক",
30 | "tell_your_story": "আপোনাৰ কাহিনী কওক",
31 | "launch_review": "পৰ্যালোচনা পঠিয়াওক",
32 | "your_review_creates": "আপোনাৰ পৰ্যালোচনাই ভ্ৰমণ সম্প্ৰদায়ত অনুপ্ৰেৰণাৰ ঢৌ সৃষ্টি কৰে",
33 | "traveler_stories": "ভ্ৰমণকাৰীৰ কাহিনী",
34 | "all_reviews": "সকলো পৰ্যালোচনা",
35 | "average_rating": "গড় ৰেটিং",
36 | "no_reviews_yet": "এতিয়ালৈকে কোনো পৰ্যালোচনা নাই",
37 | "be_first_to_review": "এই আচৰিত ঠাইত আপোনাৰ অভিজ্ঞতা ভাগ কৰা প্ৰথম ব্যক্তি হওক!",
38 | "login_to_review": "পৰ্যালোচনাৰ বাবে লগইন কৰক",
39 | "translate_review": "পৰ্যালোচনা অনুবাদ কৰক",
40 | "original_review": "মূল পৰ্যালোচনা",
41 | "translated_review": "অনুবাদিত পৰ্যালোচনা",
42 | "translation_error": "অনুবাদ বিফল। অনুগ্ৰহ কৰি পুনৰ চেষ্টা কৰক।",
43 | "poor_experience": "বেয়া অভিজ্ঞতা",
44 | "below_expectations": "প্ৰত্যাশাতকৈ কম",
45 | "good_experience": "ভাল অভিজ্ঞতা",
46 | "amazing_experience": "আচৰিত অভিজ্ঞতা",
47 | "absolutely_perfect": "একেবাৰে নিখুঁত!",
48 | "location": "স্থান",
49 | "host": "আয়োজক",
50 | "vibe": "পৰিৱেশ",
51 | "value": "মূল্য",
52 | "what_made_special": "এই ঠাইক কিহে বিশেষ কৰিলে? যাদু ভাগ কৰক...",
53 | "location_breathtaking": "স্থানটো উশাহ লোৱাৰ দৰে আছিল! ",
54 | "host_welcoming": "আয়োজকে অবিশ্বাস্যভাৱে আদৰণি জনাইছিল। ",
55 | "perfect_relaxation": "বিশ্ৰাম আৰু শান্তিৰ বাবে নিখুঁত। ",
56 | "great_value": "টকাৰ বাবে উত্তম মূল্য! ",
57 | "owned_by": "মালিকানা",
58 | "likes": "পছন্দ",
59 | "like": "পছন্দ কৰক",
60 | "unlike": "অপছন্দ কৰক",
61 | "add_to_wishlist": "ইচ্ছা তালিকাত যোগ কৰক",
62 | "edit_listing": "তালিকা সম্পাদনা কৰক",
63 | "delete_listing": "তালিকা মচক",
64 | "you_may_also_like": "আপুনি ইয়াকো পছন্দ কৰিব পাৰে",
65 | "where_youll_be": "আপুনি ক'ত থাকিব",
66 | "view_details": "বিৱৰণ চাওক",
67 | "night": "ৰাতি",
68 | "recently": "সম্প্ৰতি"
69 | }
--------------------------------------------------------------------------------
/locales/te.json:
--------------------------------------------------------------------------------
1 | {
2 | "wanderlust": "వాండర్లస్ట్",
3 | "explore_world": "మాతో ప్రపంచాన్ని అన్వేషించండి",
4 | "search_destinations": "గమ్యస్థానాలను వెతకండి...",
5 | "search": "వెతకండి",
6 | "home": "హోమ్",
7 | "all_listings": "అన్ని జాబితాలు",
8 | "add_new_listing": "కొత్త జాబితా జోడించండి",
9 | "login": "లాగిన్",
10 | "signup": "సైన్అప్",
11 | "logout": "లాగౌట్",
12 | "profile": "ప్రొఫైల్",
13 | "language": "భాష",
14 | "english": "English",
15 | "hindi": "हिंदी",
16 | "bengali": "বাংলা",
17 | "telugu": "తెలుగు",
18 | "marathi": "मराठी",
19 | "tamil": "தமிழ்",
20 | "gujarati": "ગુજરાતી",
21 | "kannada": "ಕನ್ನಡ",
22 | "malayalam": "മലയാളം",
23 | "punjabi": "ਪੰਜਾਬੀ",
24 | "odia": "ଓଡ଼ିଆ",
25 | "assamese": "অসমীয়া",
26 | "urdu": "اردو",
27 | "share_your_journey": "మీ ప్రయాణాన్ని పంచుకోండి",
28 | "your_words_inspire": "మీ మాటలు తోటి ప్రయాణికులను ప్రేరేపిస్తాయి",
29 | "rate_your_experience": "మీ అనుభవాన్ని రేట్ చేయండి",
30 | "tell_your_story": "మీ కథ చెప్పండి",
31 | "launch_review": "రివ్యూ పంపండి",
32 | "your_review_creates": "మీ రివ్యూ ప్రయాణ సమాజంలో ప్రేరణ తరంగాలను సృష్టిస్తుంది",
33 | "traveler_stories": "ప్రయాణికుల కథలు",
34 | "all_reviews": "అన్ని రివ్యూలు",
35 | "average_rating": "సగటు రేటింగ్",
36 | "no_reviews_yet": "ఇంకా రివ్యూలు లేవు",
37 | "be_first_to_review": "ఈ అద్భుతమైన ప్రదేశంలో మీ అనుభవాన్ని పంచుకునే మొదటి వ్యక్తి అవ్వండి!",
38 | "login_to_review": "రివ్యూ కోసం లాగిన్ చేయండి",
39 | "translate_review": "రివ్యూ అనువదించండి",
40 | "original_review": "అసలు రివ్యూ",
41 | "translated_review": "అనువదించిన రివ్యూ",
42 | "translation_error": "అనువాదం విఫలమైంది. దయచేసి మళ్లీ ప్రయత్నించండి.",
43 | "poor_experience": "చెడు అనుభవం",
44 | "below_expectations": "అంచనాల కంటే తక్కువ",
45 | "good_experience": "మంచి అనుభవం",
46 | "amazing_experience": "అద్భుతమైన అనుభవం",
47 | "absolutely_perfect": "పూర్తిగా పరిపూర్ణం!",
48 | "location": "స్థానం",
49 | "host": "హోస్ట్",
50 | "vibe": "వాతావరణం",
51 | "value": "విలువ",
52 | "what_made_special": "ఈ ప్రదేశాన్ని ప్రత్యేకంగా చేసింది ఏమిటి? మాయాజాలాన్ని పంచుకోండి...",
53 | "location_breathtaking": "స్థానం ఆశ్చర్యకరంగా ఉంది! ",
54 | "host_welcoming": "హోస్ట్ అద్భుతంగా స్వాగతం పలికారు. ",
55 | "perfect_relaxation": "విశ్రాంతి మరియు శాంతికి పరిపూర్ణం. ",
56 | "great_value": "డబ్బుకు గొప్ప విలువ! ",
57 | "owned_by": "యాజమాన్యం",
58 | "likes": "ఇష్టాలు",
59 | "like": "ఇష్టపడండి",
60 | "unlike": "ఇష్టపడవద్దు",
61 | "add_to_wishlist": "విష్లిస్ట్కు జోడించండి",
62 | "edit_listing": "జాబితాను సవరించండి",
63 | "delete_listing": "జాబితాను తొలగించండి",
64 | "you_may_also_like": "మీరు దీన్ని కూడా ఇష్టపడవచ్చు",
65 | "where_youll_be": "మీరు ఎక్కడ ఉంటారు",
66 | "view_details": "వివరాలు చూడండి",
67 | "night": "రాత్రి",
68 | "recently": "ఇటీవల"
69 | }
--------------------------------------------------------------------------------
/locales/or.json:
--------------------------------------------------------------------------------
1 | {
2 | "wanderlust": "ୱାଣ୍ଡରଲଷ୍ଟ",
3 | "explore_world": "ଆମ ସହିତ ବିଶ୍ୱ ଅନ୍ବେଷଣ କରନ୍ତୁ",
4 | "search_destinations": "ଗନ୍ତବ୍ୟ ସ୍ଥାନ ଖୋଜନ୍ତୁ...",
5 | "search": "ଖୋଜନ୍ତୁ",
6 | "home": "ଘର",
7 | "all_listings": "ସମସ୍ତ ତାଲିକା",
8 | "add_new_listing": "ନୂତନ ତାଲିକା ଯୋଗ କରନ୍ତୁ",
9 | "login": "ଲଗଇନ",
10 | "signup": "ସାଇନଅପ",
11 | "logout": "ଲଗଆଉଟ",
12 | "profile": "ପ୍ରୋଫାଇଲ",
13 | "language": "ଭାଷା",
14 | "english": "English",
15 | "hindi": "हिंदी",
16 | "bengali": "বাংলা",
17 | "telugu": "తెలుగు",
18 | "marathi": "मराठी",
19 | "tamil": "தமிழ்",
20 | "gujarati": "ગુજરાતી",
21 | "kannada": "ಕನ್ನಡ",
22 | "malayalam": "മലയാളം",
23 | "punjabi": "ਪੰਜਾਬੀ",
24 | "odia": "ଓଡ଼ିଆ",
25 | "assamese": "অসমীয়া",
26 | "urdu": "اردو",
27 | "share_your_journey": "ଆପଣଙ୍କ ଯାତ୍ରା ସାଝା କରନ୍ତୁ",
28 | "your_words_inspire": "ଆପଣଙ୍କ ଶବ୍ଦ ସହଯାତ୍ରୀମାନଙ୍କୁ ପ୍ରେରଣା ଦିଏ",
29 | "rate_your_experience": "ଆପଣଙ୍କ ଅଭିଜ୍ଞତାକୁ ରେଟ କରନ୍ତୁ",
30 | "tell_your_story": "ଆପଣଙ୍କ କାହାଣୀ କୁହନ୍ତୁ",
31 | "launch_review": "ସମୀକ୍ଷା ପଠାନ୍ତୁ",
32 | "your_review_creates": "ଆପଣଙ୍କ ସମୀକ୍ଷା ଯାତ୍ରା ସମ୍ପ୍ରଦାୟରେ ପ୍ରେରଣାର ତରଙ୍ଗ ସୃଷ୍ଟି କରେ",
33 | "traveler_stories": "ଯାତ୍ରୀମାନଙ୍କ କାହାଣୀ",
34 | "all_reviews": "ସମସ୍ତ ସମୀକ୍ଷା",
35 | "average_rating": "ହାରାହାରି ରେଟିଂ",
36 | "no_reviews_yet": "ଏପର୍ଯ୍ୟନ୍ତ କୌଣସି ସମୀକ୍ଷା ନାହିଁ",
37 | "be_first_to_review": "ଏହି ଅଦ୍ଭୁତ ସ୍ଥାନରେ ଆପଣଙ୍କ ଅଭିଜ୍ଞତା ସାଝା କରୁଥିବା ପ୍ରଥମ ବ୍ୟକ୍ତି ହୁଅନ୍ତୁ!",
38 | "login_to_review": "ସମୀକ୍ଷା ପାଇଁ ଲଗଇନ କରନ୍ତୁ",
39 | "translate_review": "ସମୀକ୍ଷା ଅନୁବାଦ କରନ୍ତୁ",
40 | "original_review": "ମୂଳ ସମୀକ୍ଷା",
41 | "translated_review": "ଅନୁବାଦିତ ସମୀକ୍ଷା",
42 | "translation_error": "ଅନୁବାଦ ବିଫଳ। ଦୟାକରି ପୁନର୍ବାର ଚେଷ୍ଟା କରନ୍ତୁ।",
43 | "poor_experience": "ଖରାପ ଅଭିଜ୍ଞତା",
44 | "below_expectations": "ଆଶାଠାରୁ କମ",
45 | "good_experience": "ଭଲ ଅଭିଜ୍ଞତା",
46 | "amazing_experience": "ଅଦ୍ଭୁତ ଅଭିଜ୍ଞତା",
47 | "absolutely_perfect": "ସମ୍ପୂର୍ଣ୍ଣ ରୂପେ ସିଦ୍ଧ!",
48 | "location": "ସ୍ଥାନ",
49 | "host": "ଆୟୋଜକ",
50 | "vibe": "ପରିବେଶ",
51 | "value": "ମୂଲ୍ୟ",
52 | "what_made_special": "ଏହି ସ୍ଥାନକୁ କଣ ବିଶେଷ କଲା? ଯାଦୁ ସାଝା କରନ୍ତୁ...",
53 | "location_breathtaking": "ସ୍ଥାନଟି ନିଶ୍ୱାସ ବନ୍ଦ କରିଦେଉଥିଲା! ",
54 | "host_welcoming": "ଆୟୋଜକ ଅବିଶ୍ୱାସନୀୟ ଭାବରେ ସ୍ୱାଗତ କରିଥିଲେ। ",
55 | "perfect_relaxation": "ବିଶ୍ରାମ ଏବଂ ଶାନ୍ତି ପାଇଁ ସିଦ୍ଧ। ",
56 | "great_value": "ଟଙ୍କା ପାଇଁ ଉତ୍ତମ ମୂଲ୍ୟ! ",
57 | "owned_by": "ମାଲିକାନା",
58 | "likes": "ପସନ୍ଦ",
59 | "like": "ପସନ୍ଦ କରନ୍ତୁ",
60 | "unlike": "ନାପସନ୍ଦ କରନ୍ତୁ",
61 | "add_to_wishlist": "ଇଚ୍ଛା ତାଲିକାରେ ଯୋଗ କରନ୍ତୁ",
62 | "edit_listing": "ତାଲିକା ସମ୍ପାଦନା କରନ୍ତୁ",
63 | "delete_listing": "ତାଲିକା ଡିଲିଟ କରନ୍ତୁ",
64 | "you_may_also_like": "ଆପଣ ଏହା ମଧ୍ୟ ପସନ୍ଦ କରିପାରନ୍ତି",
65 | "where_youll_be": "ଆପଣ କେଉଁଠାରେ ରହିବେ",
66 | "view_details": "ବିବରଣୀ ଦେଖନ୍ତୁ",
67 | "night": "ରାତି",
68 | "recently": "ସମ୍ପ୍ରତି"
69 | }
--------------------------------------------------------------------------------
/locales/kn.json:
--------------------------------------------------------------------------------
1 | {
2 | "wanderlust": "ವಾಂಡರ್ಲಸ್ಟ್",
3 | "explore_world": "ನಮ್ಮೊಂದಿಗೆ ಜಗತ್ತನ್ನು ಅನ್ವೇಷಿಸಿ",
4 | "search_destinations": "ಗಮ್ಯಸ್ಥಾನಗಳನ್ನು ಹುಡುಕಿ...",
5 | "search": "ಹುಡುಕಿ",
6 | "home": "ಮುಖ್ಯಪುಟ",
7 | "all_listings": "ಎಲ್ಲಾ ಪಟ್ಟಿಗಳು",
8 | "add_new_listing": "ಹೊಸ ಪಟ್ಟಿ ಸೇರಿಸಿ",
9 | "login": "ಲಾಗಿನ್",
10 | "signup": "ಸೈನ್ಅಪ್",
11 | "logout": "ಲಾಗ್ಔಟ್",
12 | "profile": "ಪ್ರೊಫೈಲ್",
13 | "language": "ಭಾಷೆ",
14 | "english": "English",
15 | "hindi": "हिंदी",
16 | "bengali": "বাংলা",
17 | "telugu": "తెలుగు",
18 | "marathi": "मराठी",
19 | "tamil": "தமிழ்",
20 | "gujarati": "ગુજરાતી",
21 | "kannada": "ಕನ್ನಡ",
22 | "malayalam": "മലയാളം",
23 | "punjabi": "ਪੰਜਾਬੀ",
24 | "odia": "ଓଡ଼ିଆ",
25 | "assamese": "অসমীয়া",
26 | "urdu": "اردو",
27 | "share_your_journey": "ನಿಮ್ಮ ಪ್ರಯಾಣವನ್ನು ಹಂಚಿಕೊಳ್ಳಿ",
28 | "your_words_inspire": "ನಿಮ್ಮ ಮಾತುಗಳು ಸಹ ಪ್ರಯಾಣಿಕರನ್ನು ಪ್ರೇರೇಪಿಸುತ್ತವೆ",
29 | "rate_your_experience": "ನಿಮ್ಮ ಅನುಭವವನ್ನು ರೇಟ್ ಮಾಡಿ",
30 | "tell_your_story": "ನಿಮ್ಮ ಕಥೆಯನ್ನು ಹೇಳಿ",
31 | "launch_review": "ವಿಮರ್ಶೆ ಕಳುಹಿಸಿ",
32 | "your_review_creates": "ನಿಮ್ಮ ವಿಮರ್ಶೆ ಪ್ರಯಾಣ ಸಮುದಾಯದಲ್ಲಿ ಪ್ರೇರಣೆಯ ಅಲೆಗಳನ್ನು ಸೃಷ್ಟಿಸುತ್ತದೆ",
33 | "traveler_stories": "ಪ್ರಯಾಣಿಕರ ಕಥೆಗಳು",
34 | "all_reviews": "ಎಲ್ಲಾ ವಿಮರ್ಶೆಗಳು",
35 | "average_rating": "ಸರಾಸರಿ ರೇಟಿಂಗ್",
36 | "no_reviews_yet": "ಇನ್ನೂ ಯಾವುದೇ ವಿಮರ್ಶೆಗಳಿಲ್ಲ",
37 | "be_first_to_review": "ಈ ಅದ್ಭುತ ಸ್ಥಳದಲ್ಲಿ ನಿಮ್ಮ ಅನುಭವವನ್ನು ಹಂಚಿಕೊಳ್ಳುವ ಮೊದಲ ವ್ಯಕ್ತಿಯಾಗಿರಿ!",
38 | "login_to_review": "ವಿಮರ್ಶೆಗಾಗಿ ಲಾಗಿನ್ ಮಾಡಿ",
39 | "translate_review": "ವಿಮರ್ಶೆಯನ್ನು ಅನುವಾದಿಸಿ",
40 | "original_review": "ಮೂಲ ವಿಮರ್ಶೆ",
41 | "translated_review": "ಅನುವಾದಿತ ವಿಮರ್ಶೆ",
42 | "translation_error": "ಅನುವಾದ ವಿಫಲವಾಗಿದೆ. ದಯವಿಟ್ಟು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ.",
43 | "poor_experience": "ಕಳಪೆ ಅನುಭವ",
44 | "below_expectations": "ನಿರೀಕ್ಷೆಗಳಿಗಿಂತ ಕಡಿಮೆ",
45 | "good_experience": "ಒಳ್ಳೆಯ ಅನುಭವ",
46 | "amazing_experience": "ಅದ್ಭುತ ಅನುಭವ",
47 | "absolutely_perfect": "ಸಂಪೂರ್ಣವಾಗಿ ಪರಿಪೂರ್ಣ!",
48 | "location": "ಸ್ಥಳ",
49 | "host": "ಆತಿಥೇಯ",
50 | "vibe": "ವಾತಾವರಣ",
51 | "value": "ಮೌಲ್ಯ",
52 | "what_made_special": "ಈ ಸ್ಥಳವನ್ನು ವಿಶೇಷವಾಗಿಸಿದ್ದು ಏನು? ಮಾಯಾಜಾಲವನ್ನು ಹಂಚಿಕೊಳ್ಳಿ...",
53 | "location_breathtaking": "ಸ್ಥಳವು ಉಸಿರುಕಟ್ಟಿಸುವಂತಿತ್ತು! ",
54 | "host_welcoming": "ಆತಿಥೇಯರು ನಂಬಲಾಗದಷ್ಟು ಸ್ವಾಗತಿಸಿದರು. ",
55 | "perfect_relaxation": "ವಿಶ್ರಾಂತಿ ಮತ್ತು ಶಾಂತಿಗೆ ಪರಿಪೂರ್ಣ. ",
56 | "great_value": "ಹಣಕ್ಕೆ ಉತ್ತಮ ಮೌಲ್ಯ! ",
57 | "owned_by": "ಮಾಲೀಕತ್ವ",
58 | "likes": "ಇಷ್ಟಗಳು",
59 | "like": "ಇಷ್ಟ",
60 | "unlike": "ಇಷ್ಟವಿಲ್ಲ",
61 | "add_to_wishlist": "ವಿಶ್ಲಿಸ್ಟ್ಗೆ ಸೇರಿಸಿ",
62 | "edit_listing": "ಪಟ್ಟಿಯನ್ನು ಸಂಪಾದಿಸಿ",
63 | "delete_listing": "ಪಟ್ಟಿಯನ್ನು ಅಳಿಸಿ",
64 | "you_may_also_like": "ನೀವು ಇದನ್ನೂ ಇಷ್ಟಪಡಬಹುದು",
65 | "where_youll_be": "ನೀವು ಎಲ್ಲಿರುತ್ತೀರಿ",
66 | "view_details": "ವಿವರಗಳನ್ನು ವೀಕ್ಷಿಸಿ",
67 | "night": "ರಾತ್ರಿ",
68 | "recently": "ಇತ್ತೀಚೆಗೆ"
69 | }
--------------------------------------------------------------------------------
/views/includes/category-bar.ejs:
--------------------------------------------------------------------------------
1 |
2 |
34 |
35 |
--------------------------------------------------------------------------------
/locales/ml.json:
--------------------------------------------------------------------------------
1 | {
2 | "wanderlust": "വാണ്ടർലസ്റ്റ്",
3 | "explore_world": "ഞങ്ങളോടൊപ്പം ലോകം പര്യവേക്ഷണം ചെയ്യുക",
4 | "search_destinations": "ലക്ഷ്യസ്ഥാനങ്ങൾ തിരയുക...",
5 | "search": "തിരയുക",
6 | "home": "ഹോം",
7 | "all_listings": "എല്ലാ ലിസ്റ്റിംഗുകളും",
8 | "add_new_listing": "പുതിയ ലിസ്റ്റിംഗ് ചേർക്കുക",
9 | "login": "ലോഗിൻ",
10 | "signup": "സൈൻഅപ്പ്",
11 | "logout": "ലോഗൗട്ട്",
12 | "profile": "പ്രൊഫൈൽ",
13 | "language": "ഭാഷ",
14 | "english": "English",
15 | "hindi": "हिंदी",
16 | "bengali": "বাংলা",
17 | "telugu": "తెలుగు",
18 | "marathi": "मराठी",
19 | "tamil": "தமிழ்",
20 | "gujarati": "ગુજરાતી",
21 | "kannada": "ಕನ್ನಡ",
22 | "malayalam": "മലയാളം",
23 | "punjabi": "ਪੰਜਾਬੀ",
24 | "odia": "ଓଡ଼ିଆ",
25 | "assamese": "অসমীয়া",
26 | "urdu": "اردو",
27 | "share_your_journey": "നിങ്ങളുടെ യാത്ര പങ്കിടുക",
28 | "your_words_inspire": "നിങ്ങളുടെ വാക്കുകൾ സഹയാത്രികരെ പ്രചോദിപ്പിക്കുന്നു",
29 | "rate_your_experience": "നിങ്ങളുടെ അനുഭവം റേറ്റ് ചെയ്യുക",
30 | "tell_your_story": "നിങ്ങളുടെ കഥ പറയുക",
31 | "launch_review": "റിവ്യൂ അയയ്ക്കുക",
32 | "your_review_creates": "നിങ്ങളുടെ റിവ്യൂ യാത്രാ സമൂഹത്തിൽ പ്രചോദന തരംഗങ്ങൾ സൃഷ്ടിക്കുന്നു",
33 | "traveler_stories": "യാത്രികരുടെ കഥകൾ",
34 | "all_reviews": "എല്ലാ റിവ്യൂകളും",
35 | "average_rating": "ശരാശരി റേറ്റിംഗ്",
36 | "no_reviews_yet": "ഇതുവരെ റിവ്യൂകളൊന്നുമില്ല",
37 | "be_first_to_review": "ഈ അത്ഭുതകരമായ സ്ഥലത്ത് നിങ്ങളുടെ അനുഭവം പങ്കിടുന്ന ആദ്യത്തെ വ്യക്തിയാകുക!",
38 | "login_to_review": "റിവ്യൂവിനായി ലോഗിൻ ചെയ്യുക",
39 | "translate_review": "റിവ്യൂ വിവർത്തനം ചെയ്യുക",
40 | "original_review": "യഥാർത്ഥ റിവ്യൂ",
41 | "translated_review": "വിവർത്തനം ചെയ്ത റിവ്യൂ",
42 | "translation_error": "വിവർത്തനം പരാജയപ്പെട്ടു. ദയവായി വീണ്ടും ശ്രമിക്കുക.",
43 | "poor_experience": "മോശം അനുഭവം",
44 | "below_expectations": "പ്രതീക്ഷകൾക്ക് താഴെ",
45 | "good_experience": "നല്ല അനുഭവം",
46 | "amazing_experience": "അത്ഭുതകരമായ അനുഭവം",
47 | "absolutely_perfect": "തികച്ചും പരിപൂർണ്ണം!",
48 | "location": "സ്ഥലം",
49 | "host": "ആതിഥേയൻ",
50 | "vibe": "അന്തരീക്ഷം",
51 | "value": "മൂല്യം",
52 | "what_made_special": "ഈ സ്ഥലത്തെ പ്രത്യേകമാക്കിയത് എന്താണ്? മാജിക് പങ്കിടുക...",
53 | "location_breathtaking": "സ്ഥലം ശ്വാസം മുട്ടിക്കുന്നതായിരുന്നു! ",
54 | "host_welcoming": "ആതിഥേയൻ അവിശ്വസനീയമായി സ്വാഗതം ചെയ്തു. ",
55 | "perfect_relaxation": "വിശ്രമത്തിനും സമാധാനത്തിനും പരിപൂർണ്ണം. ",
56 | "great_value": "പണത്തിന് മികച്ച മൂല്യം! ",
57 | "owned_by": "ഉടമസ്ഥത",
58 | "likes": "ഇഷ്ടങ്ങൾ",
59 | "like": "ഇഷ്ടം",
60 | "unlike": "ഇഷ്ടമല്ല",
61 | "add_to_wishlist": "വിഷ്ലിസ്റ്റിൽ ചേർക്കുക",
62 | "edit_listing": "ലിസ്റ്റിംഗ് എഡിറ്റ് ചെയ്യുക",
63 | "delete_listing": "ലിസ്റ്റിംഗ് ഇല്ലാതാക്കുക",
64 | "you_may_also_like": "നിങ്ങൾക്ക് ഇതും ഇഷ്ടപ്പെടാം",
65 | "where_youll_be": "നിങ്ങൾ എവിടെയായിരിക്കും",
66 | "view_details": "വിശദാംശങ്ങൾ കാണുക",
67 | "night": "രാത്രി",
68 | "recently": "അടുത്തിടെ"
69 | }
--------------------------------------------------------------------------------
/routes/weather.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 | const weatherService = require('../services/weatherService');
4 | const Listing = require('../models/listing');
5 |
6 | // Main weather page
7 | router.get('/', async (req, res) => {
8 | try {
9 | // Fetch unique locations from listings for the dropdown
10 | let destinations = await Listing.distinct('location');
11 |
12 | // Fallback destinations if no listings
13 | if (!destinations || destinations.length === 0) {
14 | destinations = [
15 | 'Paris', 'Tokyo', 'New York', 'London', 'Sydney', 'Rome', 'Barcelona', 'Amsterdam',
16 | 'Dubai', 'Singapore', 'Bangkok', 'Mumbai', 'Cape Town', 'Rio de Janeiro', 'Toronto'
17 | ];
18 | }
19 |
20 | res.render('weather', {
21 | title: 'Weather Information',
22 | currentUser: req.user,
23 | destinations: destinations.sort() // Sort alphabetically
24 | });
25 | } catch (error) {
26 | console.error('Error fetching destinations:', error);
27 | // Fallback destinations on error
28 | const fallbackDestinations = [
29 | 'Paris', 'Tokyo', 'New York', 'London', 'Sydney', 'Rome', 'Barcelona', 'Amsterdam',
30 | 'Dubai', 'Singapore', 'Bangkok', 'Mumbai', 'Cape Town', 'Rio de Janeiro', 'Toronto'
31 | ];
32 | res.render('weather', {
33 | title: 'Weather Information',
34 | currentUser: req.user,
35 | destinations: fallbackDestinations.sort()
36 | });
37 | }
38 | });
39 |
40 | // Get weather for specific coordinates
41 | router.get('/current/:lat/:lon', async (req, res) => {
42 | try {
43 | const { lat, lon } = req.params;
44 | const weather = await weatherService.getCurrentWeather(parseFloat(lat), parseFloat(lon));
45 | res.json(weather);
46 | } catch (error) {
47 | res.status(500).json({ error: 'Weather service unavailable' });
48 | }
49 | });
50 |
51 | // Get forecast for specific coordinates
52 | router.get('/forecast/:lat/:lon', async (req, res) => {
53 | try {
54 | const { lat, lon } = req.params;
55 | const forecast = await weatherService.getForecast(parseFloat(lat), parseFloat(lon));
56 | res.json(forecast);
57 | } catch (error) {
58 | res.status(500).json({ error: 'Forecast service unavailable' });
59 | }
60 | });
61 |
62 | // Search weather by location name
63 | router.get('/search/:location', async (req, res) => {
64 | try {
65 | const { location } = req.params;
66 | const weather = await weatherService.getWeatherByLocation(location);
67 | res.json(weather);
68 | } catch (error) {
69 | res.status(500).json({ error: 'Weather search failed' });
70 | }
71 | });
72 |
73 | module.exports = router;
74 |
--------------------------------------------------------------------------------
/middleware.js:
--------------------------------------------------------------------------------
1 | const Listing = require("./models/listing");
2 | const Review = require("./models/review");
3 | const { listingSchema, reviewSchema } = require("./schema.js");
4 | const ExpressError = require("./utils/ExpressError.js");
5 | module.exports.isLoggedIn = (req, res, next) => {
6 | if (!req.isAuthenticated()) {
7 | req.session.redirectUrl = req.originalUrl;
8 | req.flash("error", "You must be logged in to create a listing!");
9 | return res.redirect("/login");
10 | }
11 | next();
12 | };
13 |
14 | module.exports.saveRedirectUrl = (req, res, next) => {
15 | if (req.session.redirectUrl) {
16 | res.locals.redirectUrl = req.session.redirectUrl;
17 | }
18 | next();
19 | };
20 |
21 | module.exports.isOwner = async (req, res, next) => {
22 | let { id } = req.params;
23 | let listing = await Listing.findById(id);
24 | if (!listing.owner._id.equals(res.locals.currentUser._id)) {
25 | req.flash("error", "You are not the owner of this listing!");
26 | return res.redirect(`/listings/${id}`);
27 | }
28 | next();
29 | };
30 |
31 | module.exports.validateListing = (req, res, next) => {
32 | let { error } = listingSchema.validate(req.body);
33 | if (error) {
34 | let errMsg = error.details.map((el) => el.message).join(",");
35 | throw new ExpressError(400, errMsg);
36 | } else {
37 | next();
38 | }
39 | };
40 |
41 | module.exports.validateReview = (req, res, next) => {
42 | let { error } = reviewSchema.validate(req.body);
43 | if (error) {
44 | let errMsg = error.details.map((el) => el.message).join(",");
45 | throw new ExpressError(400, errMsg);
46 | } else {
47 | next();
48 | }
49 | };
50 |
51 | module.exports.isReviewAuthor = async (req, res, next) => {
52 | let { id, reviewId } = req.params;
53 | let review = await Review.findById(reviewId);
54 | if (!review.author._id.equals(res.locals.currentUser._id)) {
55 | req.flash("error", "You are not the author of this review!");
56 | return res.redirect(`/listings/${id}`);
57 | }
58 | next();
59 | };
60 |
61 | // Badge checking middleware - runs after user actions that might earn badges
62 | module.exports.checkForNewBadges = async (req, res, next) => {
63 | if (req.user) {
64 | try {
65 | const BadgeService = require('./services/badgeService');
66 | const newBadges = await BadgeService.checkAndAwardBadges(req.user._id);
67 |
68 | if (newBadges && newBadges.length > 0) {
69 | const badgeNames = newBadges.join(', ');
70 | req.flash('success', `🎉 Congratulations! You earned new badge(s): ${badgeNames}`);
71 | }
72 | } catch (error) {
73 | console.error('Error checking for badges:', error);
74 | // Don't let badge errors break the main flow
75 | }
76 | }
77 | next();
78 | };
79 |
--------------------------------------------------------------------------------
/tests/listings.test.js:
--------------------------------------------------------------------------------
1 | const request = require('supertest');
2 | const app = require('../app');
3 |
4 | describe('Listing Routes', () => {
5 | describe('GET /listings', () => {
6 | it('should return listings index page', async () => {
7 | const res = await request(app)
8 | .get('/listings')
9 | .expect('Content-Type', /html/)
10 | .expect(200);
11 |
12 | expect(res.text).toContain('All Listings');
13 | });
14 | });
15 |
16 | describe('GET /listings/new', () => {
17 | it('should redirect to login when not authenticated', async () => {
18 | const res = await request(app)
19 | .get('/listings/new')
20 | .expect(302);
21 |
22 | expect(res.headers.location).toContain('/login');
23 | });
24 | });
25 |
26 | describe('GET /listings/:id', () => {
27 | it('should return 404 for invalid listing ID', async () => {
28 | const res = await request(app)
29 | .get('/listings/invalid-id-123')
30 | .expect(500); // MongoDB will throw error for invalid ID
31 | });
32 | });
33 |
34 | describe('POST /listings', () => {
35 | it('should redirect to login when not authenticated', async () => {
36 | const res = await request(app)
37 | .post('/listings')
38 | .send({
39 | title: 'Test Listing',
40 | description: 'Test Description',
41 | price: 100,
42 | location: 'Test Location',
43 | country: 'Test Country'
44 | })
45 | .expect(302);
46 |
47 | expect(res.headers.location).toContain('/login');
48 | });
49 |
50 | it('should reject listing with missing required fields', async () => {
51 | const res = await request(app)
52 | .post('/listings')
53 | .send({
54 | title: '',
55 | description: ''
56 | });
57 |
58 | expect(res.status).toBe(302);
59 | });
60 | });
61 |
62 | describe('GET /listings/:id/edit', () => {
63 | it('should redirect to login when not authenticated', async () => {
64 | const res = await request(app)
65 | .get('/listings/123456789012345678901234/edit')
66 | .expect(302);
67 |
68 | expect(res.headers.location).toContain('/login');
69 | });
70 | });
71 |
72 | describe('DELETE /listings/:id', () => {
73 | it('should redirect to login when not authenticated', async () => {
74 | const res = await request(app)
75 | .delete('/listings/123456789012345678901234')
76 | .expect(302);
77 |
78 | expect(res.headers.location).toContain('/login');
79 | });
80 | });
81 | });
82 |
--------------------------------------------------------------------------------
/locales/ta.json:
--------------------------------------------------------------------------------
1 | {
2 | "wanderlust": "வாண்டர்லஸ்ட்",
3 | "explore_world": "எங்களுடன் உலகை ஆராயுங்கள்",
4 | "search_destinations": "இலக்குகளைத் தேடுங்கள்...",
5 | "search": "தேடுங்கள்",
6 | "home": "முகப்பு",
7 | "all_listings": "அனைத்து பட்டியல்கள்",
8 | "add_new_listing": "புதிய பட்டியல் சேர்க்கவும்",
9 | "login": "உள்நுழைவு",
10 | "signup": "பதிவு செய்யவும்",
11 | "logout": "வெளியேறு",
12 | "profile": "சுயவிவரம்",
13 | "language": "மொழி",
14 | "english": "English",
15 | "hindi": "हिंदी",
16 | "bengali": "বাংলা",
17 | "telugu": "తెలుగు",
18 | "marathi": "मराठी",
19 | "tamil": "தமிழ்",
20 | "gujarati": "ગુજરાતી",
21 | "kannada": "ಕನ್ನಡ",
22 | "malayalam": "മലയാളം",
23 | "punjabi": "ਪੰਜਾਬੀ",
24 | "odia": "ଓଡ଼ିଆ",
25 | "assamese": "অসমীয়া",
26 | "urdu": "اردو",
27 | "share_your_journey": "உங்கள் பயணத்தைப் பகிருங்கள்",
28 | "your_words_inspire": "உங்கள் வார்த்தைகள் சக பயணிகளை ஊக்குவிக்கின்றன",
29 | "rate_your_experience": "உங்கள் அனுபவத்தை மதிப்பிடுங்கள்",
30 | "tell_your_story": "உங்கள் கதையைச் சொல்லுங்கள்",
31 | "launch_review": "மதிப்பாய்வு அனுப்பவும்",
32 | "your_review_creates": "உங்கள் மதிப்பாய்வு பயண சமூகத்தில் உத்வேக அலைகளை உருவாக்குகிறது",
33 | "traveler_stories": "பயணிகளின் கதைகள்",
34 | "all_reviews": "அனைத்து மதிப்பாய்வுகள்",
35 | "average_rating": "சராசரி மதிப்பீடு",
36 | "no_reviews_yet": "இன்னும் மதிப்பாய்வுகள் இல்லை",
37 | "be_first_to_review": "இந்த அற்புதமான இடத்தில் உங்கள் அனுபவத்தைப் பகிரும் முதல் நபராக இருங்கள்!",
38 | "login_to_review": "மதிப்பாய்வுக்கு உள்நுழையவும்",
39 | "translate_review": "மதிப்பாய்வை மொழிபெயர்க்கவும்",
40 | "original_review": "அசல் மதிப்பாய்வு",
41 | "translated_review": "மொழிபெயர்க்கப்பட்ட மதிப்பாய்வு",
42 | "translation_error": "மொழிபெயர்ப்பு தோல்வியடைந்தது. மீண்டும் முயற்சிக்கவும்.",
43 | "poor_experience": "மோசமான அனுபவம்",
44 | "below_expectations": "எதிர்பார்ப்புகளுக்குக் கீழே",
45 | "good_experience": "நல்ல அனுபவம்",
46 | "amazing_experience": "அற்புதமான அனுபவம்",
47 | "absolutely_perfect": "முற்றிலும் சரியானது!",
48 | "location": "இடம்",
49 | "host": "புரவலர்",
50 | "vibe": "சூழல்",
51 | "value": "மதிப்பு",
52 | "what_made_special": "இந்த இடத்தை எது சிறப்பாக்கியது? மாயாஜாலத்தைப் பகிருங்கள்...",
53 | "location_breathtaking": "இடம் மூச்சடைக்கக்கூடியதாக இருந்தது! ",
54 | "host_welcoming": "புரவலர் நம்பமுடியாத அளவிற்கு வரவேற்பு அளித்தார். ",
55 | "perfect_relaxation": "ஓய்வு மற்றும் அமைதிக்கு சரியானது. ",
56 | "great_value": "பணத்திற்கு சிறந்த மதிப்பு! ",
57 | "owned_by": "உரிமையாளர்",
58 | "likes": "விருப்பங்கள்",
59 | "like": "விரும்பு",
60 | "unlike": "விரும்பாதே",
61 | "add_to_wishlist": "விருப்பப்பட்டியலில் சேர்க்கவும்",
62 | "edit_listing": "பட்டியலைத் திருத்தவும்",
63 | "delete_listing": "பட்டியலை நீக்கவும்",
64 | "you_may_also_like": "நீங்கள் இதையும் விரும்பலாம்",
65 | "where_youll_be": "நீங்கள் எங்கே இருப்பீர்கள்",
66 | "view_details": "விவரங்களைப் பார்க்கவும்",
67 | "night": "இரவு",
68 | "recently": "சமீபத்தில்"
69 | }
--------------------------------------------------------------------------------
/public/CSS/loading.css:
--------------------------------------------------------------------------------
1 | /* Loading Overlay */
2 | .loading-overlay {
3 | position: fixed;
4 | top: 0;
5 | left: 0;
6 | width: 100%;
7 | height: 100%;
8 | background-color: var(--loading-overlay-bg, rgba(0, 0, 0, 0.7));
9 | display: flex;
10 | justify-content: center;
11 | align-items: center;
12 | z-index: 9999;
13 | backdrop-filter: blur(3px);
14 | transition: background-color 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
15 | }
16 |
17 | [data-theme="dark"] .loading-overlay {
18 | --loading-overlay-bg: rgba(0, 0, 0, 0.85);
19 | }
20 |
21 | /* Loading Spinner */
22 | .loading-spinner {
23 | width: 60px;
24 | height: 60px;
25 | border: 5px solid var(--loading-spinner-border, rgba(255, 255, 255, 0.3));
26 | border-top: 5px solid var(--loading-spinner-top, #28a745);
27 | border-radius: 50%;
28 | animation: spin 1s linear infinite;
29 | box-shadow: 0 0 20px var(--loading-spinner-glow, rgba(40, 167, 69, 0.5));
30 | transition: border-color 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
31 | box-shadow 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
32 | }
33 |
34 | [data-theme="dark"] .loading-spinner {
35 | --loading-spinner-border: rgba(255, 255, 255, 0.2);
36 | --loading-spinner-top: var(--accent-color);
37 | --loading-spinner-glow: rgba(254, 66, 77, 0.6);
38 | }
39 |
40 | @keyframes spin {
41 | 0% { transform: rotate(0deg); }
42 | 100% { transform: rotate(360deg); }
43 | }
44 |
45 | /* Button Loading State */
46 | .btn-loading {
47 | position: relative;
48 | pointer-events: none;
49 | opacity: 0.8;
50 | cursor: not-allowed;
51 | }
52 |
53 | .btn-loading::before {
54 | content: "";
55 | position: absolute;
56 | top: 0;
57 | left: 0;
58 | right: 0;
59 | bottom: 0;
60 | background-color: var(--btn-loading-overlay, rgba(0, 0, 0, 0.1));
61 | border-radius: inherit;
62 | z-index: 1;
63 | transition: background-color 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
64 | }
65 |
66 | .btn-loading::after {
67 | content: "";
68 | position: absolute;
69 | width: 18px;
70 | height: 18px;
71 | top: 50%;
72 | left: 50%;
73 | margin-left: -9px;
74 | margin-top: -9px;
75 | border: 2px solid var(--btn-loading-border, rgba(255, 255, 255, 0.3));
76 | border-top-color: var(--btn-loading-top, #ffffff);
77 | border-radius: 50%;
78 | animation: button-loading-spinner 0.6s linear infinite;
79 | z-index: 2;
80 | transition: border-color 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
81 | }
82 |
83 | [data-theme="dark"] .btn-loading::before {
84 | --btn-loading-overlay: rgba(255, 255, 255, 0.1);
85 | }
86 |
87 | [data-theme="dark"] .btn-loading::after {
88 | --btn-loading-border: rgba(255, 255, 255, 0.2);
89 | --btn-loading-top: var(--text-primary);
90 | }
91 |
92 | @keyframes button-loading-spinner {
93 | 0% { transform: rotate(0deg); }
94 | 100% { transform: rotate(360deg); }
95 | }
96 |
97 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "engines": {
3 | "node": ">=18.0.0"
4 | },
5 | "name": "wanderlust",
6 | "version": "1.0.0",
7 | "main": "app.js",
8 | "scripts": {
9 | "test": "jest --coverage --detectOpenHandles",
10 | "test:watch": "jest --watch",
11 | "test:unit": "jest tests/models tests/utils",
12 | "test:integration": "jest tests/auth.test.js tests/listings.test.js tests/reviews.test.js",
13 | "start": "node app.js",
14 | "dev": "nodemon app.js",
15 | "build:css": "tailwindcss -i ./public/CSS/tailwind.input.css -o ./public/CSS/tailwind.output.css --minify",
16 | "watch:css": "tailwindcss -i ./public/CSS/tailwind.input.css -o ./public/CSS/tailwind.output.css --watch",
17 | "dev:all": "npm run watch:css & nodemon app.js"
18 | },
19 | "keywords": [
20 | "travel",
21 | "nodejs",
22 | "express",
23 | "mongodb",
24 | "ejs"
25 | ],
26 | "author": "Kaushik Mandal",
27 | "license": "MIT",
28 | "description": "Wanderlust - a travel experience sharing platform built with Node.js, Express, MongoDB, and EJS.",
29 | "dependencies": {
30 | "@google-cloud/translate": "^9.3.0",
31 | "@mapbox/mapbox-sdk": "^0.16.2",
32 | "@playwright/test": "^1.57.0",
33 | "axios": "^1.13.2",
34 | "cloudinary": "^2.8.0",
35 | "connect-flash": "^0.1.1",
36 | "connect-mongo": "^6.0.0",
37 | "cookie-parser": "^1.4.7",
38 | "dotenv": "^17.2.3",
39 | "ejs": "^3.1.10",
40 | "ejs-mate": "^4.0.0",
41 | "express": "^4.22.1",
42 | "express-session": "^1.18.1",
43 | "express-validator": "^7.3.1",
44 | "google-translate-api-x": "^10.7.2",
45 | "helmet": "^8.1.0",
46 | "i18n": "^0.15.3",
47 | "joi": "^18.0.2",
48 | "jspdf": "^3.0.4",
49 | "kill-port": "^2.0.1",
50 | "lodash": "^4.17.21",
51 | "method-override": "^3.0.0",
52 | "mongodb": "^7.0.0",
53 | "mongoose": "^8.20.2",
54 | "multer": "^2.0.2",
55 | "multer-storage-cloudinary": "^2.2.1",
56 | "node-cron": "^4.2.1",
57 | "node-fetch": "^3.3.2",
58 | "openai": "^6.10.0",
59 | "passport": "^0.7.0",
60 | "passport-google-oauth20": "^2.0.0",
61 | "passport-local": "^1.0.0",
62 | "passport-local-mongoose": "^9.0.1",
63 | "playwright": "^1.56.0",
64 | "socket.io": "^4.8.1"
65 | },
66 | "devDependencies": {
67 | "autoprefixer": "^10.4.23",
68 | "babel-plugin-istanbul": "^7.0.1",
69 | "jest": "^30.2.0",
70 | "js-yaml": "^4.1.1",
71 | "nodemon": "^3.1.11",
72 | "postcss": "^8.5.6",
73 | "supertest": "^7.1.4",
74 | "tailwindcss": "^4.1.18"
75 | },
76 | "jest": {
77 | "testEnvironment": "node",
78 | "setupFilesAfterEnv": [
79 | "/tests/setup.js"
80 | ],
81 | "coverageDirectory": "coverage",
82 | "collectCoverageFrom": [
83 | "**/*.js",
84 | "!node_modules/**",
85 | "!coverage/**",
86 | "!tests/**",
87 | "!jest.config.js"
88 | ],
89 | "testMatch": [
90 | "**/tests/**/*.test.js"
91 | ],
92 | "verbose": true
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/controllers/reviews.js:
--------------------------------------------------------------------------------
1 | const Listing = require("../models/listing");
2 | const Review = require("../models/review");
3 | const aiSummarizationService = require("../services/aiSummarizationService");
4 | const translate = require('google-translate-api-x');
5 |
6 | module.exports.createReview = async (req, res) => {
7 | let listing = await Listing.findById(req.params.id);
8 | let newReview = new Review(req.body.review);
9 | newReview.author = req.user._id;
10 |
11 | listing.reviews.push(newReview);
12 |
13 | await newReview.save();
14 | await listing.save();
15 |
16 | // Regenerate AI summary after adding review
17 | try {
18 | const populatedListing = await Listing.findById(req.params.id).populate({
19 | path: "reviews",
20 | populate: { path: "author" },
21 | });
22 | const newSummary = await aiSummarizationService.generateSummary(populatedListing.reviews, populatedListing.title);
23 | await Listing.findByIdAndUpdate(req.params.id, {
24 | aiSummary: newSummary,
25 | aiSummaryLastUpdated: new Date()
26 | });
27 | console.log('AI summary updated after adding review');
28 | } catch (error) {
29 | console.log('AI summary update failed after adding review:', error.message);
30 | }
31 |
32 | req.flash("success", "New review created!");
33 | res.redirect(`/listings/${listing._id}`);
34 | };
35 |
36 | module.exports.destroyReview = async (req, res) => {
37 | let { id, reviewId } = req.params;
38 |
39 | await Listing.findByIdAndUpdate(id, { $pull: { reviews: reviewId } });
40 | await Review.findByIdAndDelete(reviewId);
41 |
42 | // Regenerate AI summary after removing review
43 | try {
44 | const populatedListing = await Listing.findById(id).populate({
45 | path: "reviews",
46 | populate: { path: "author" },
47 | });
48 | const newSummary = await aiSummarizationService.generateSummary(populatedListing.reviews, populatedListing.title);
49 | await Listing.findByIdAndUpdate(id, {
50 | aiSummary: newSummary,
51 | aiSummaryLastUpdated: new Date()
52 | });
53 | console.log('AI summary updated after removing review');
54 | } catch (error) {
55 | console.log('AI summary update failed after removing review:', error.message);
56 | }
57 |
58 | req.flash("success", "Review deleted!");
59 | res.redirect(`/listings/${id}`);
60 | };
61 |
62 | module.exports.translateReview = async (req, res) => {
63 | try {
64 | const { reviewId } = req.params;
65 | const review = await Review.findById(reviewId);
66 |
67 | if (!review) {
68 | return res.status(404).json({ error: 'Review not found' });
69 | }
70 |
71 | const targetLang = req.getLocale();
72 | if (targetLang === 'en') {
73 | return res.json({ translatedText: review.comment });
74 | }
75 |
76 | const result = await translate(review.comment, { to: targetLang });
77 | res.json({ translatedText: result.text });
78 | } catch (error) {
79 | console.error('Translation error:', error);
80 | res.status(500).json({ error: 'Translation failed' });
81 | }
82 | };
83 |
--------------------------------------------------------------------------------
/views/privacy.ejs:
--------------------------------------------------------------------------------
1 | <% layout("/layouts/boilerplate") %>
2 |
3 |
4 |
5 |
Privacy Policy
6 |
Last Updated:
7 | <%= new Date().toLocaleDateString() %>
8 |
9 |
10 |
11 | At Wanderlust , we value your trust and are committed to protecting your personal information.
12 | This Privacy Policy explains what data we collect, how we use it, and the choices you have regarding your
13 | information.
14 |
15 |
16 |
1. Information We Collect
17 |
18 | Account Information: Name, email address, and password when you register.
19 | Usage Data: Pages visited, time spent, and interactions on the site.
20 | Cookies: Small files used to keep you logged in and enhance your browsing experience.
21 |
22 |
23 |
2. How We Use Your Information
24 |
25 | Provide and maintain your account.
26 | Improve the functionality and user experience of Wanderlust.
27 | Send you service updates and notifications (only with your consent).
28 |
29 |
30 |
3. Sharing of Information
31 |
32 | We do not sell or rent your personal information. We may share limited data only with:
33 |
34 |
35 | Service Providers (such as hosting and analytics platforms).
36 | Third-party APIs like Google Maps for location-based features.
37 |
38 |
39 |
4. Data Protection
40 |
41 | We use reasonable security measures to protect your data. However, please note that no online system is completely
42 | secure.
43 |
44 |
45 |
5. Your Rights
46 |
47 | Access, update, or delete your personal information.
48 | Opt out of receiving promotional emails at any time.
49 |
50 |
51 |
6. Third-Party Links
52 |
53 | Our website may contain links to external websites. We are not responsible for their privacy practices.
54 |
55 |
56 |
7. Contact Us
57 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/.github/workflows/stale.yml:
--------------------------------------------------------------------------------
1 | # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time.
2 | #
3 | # You can adjust the behavior by modifying this file.
4 | # For more information, see:
5 | # https://github.com/actions/stale
6 |
7 | name: "🗂️ Mark stale issues and pull requests"
8 |
9 | on:
10 | schedule:
11 | - cron: '0 12 * * *' # Run daily at 12:00 UTC
12 | workflow_dispatch: # Allow manual triggering
13 |
14 | jobs:
15 | stale:
16 | name: "Mark stale items"
17 | runs-on: ubuntu-latest
18 | permissions:
19 | issues: write
20 | pull-requests: write
21 |
22 | steps:
23 | - name: "Mark stale issues and PRs"
24 | uses: actions/stale@v9
25 | with:
26 | repo-token: ${{ secrets.GITHUB_TOKEN }}
27 |
28 | # Issues configuration
29 | days-before-issue-stale: 60
30 | days-before-issue-close: 14
31 | stale-issue-message: |
32 | 👋 Hello! This issue has been automatically marked as stale because it has not had recent activity.
33 |
34 | 🏃♂️ **To keep this issue active:**
35 | - Add a comment with updates or questions
36 | - Reference it in a pull request
37 | - Add the `keep-open` label
38 |
39 | 🗑️ **If no activity occurs within 14 days, this issue will be automatically closed.**
40 |
41 | Thank you for contributing to WanderLust! 🌍✈️
42 | close-issue-message: |
43 | 🔒 This issue has been automatically closed due to inactivity.
44 |
45 | If you believe this issue is still relevant, please feel free to reopen it or create a new issue with updated information.
46 |
47 | Thank you for your contribution to WanderLust! 🌍
48 | stale-issue-label: 'stale'
49 |
50 | # Pull requests configuration
51 | days-before-pr-stale: 30
52 | days-before-pr-close: 7
53 | stale-pr-message: |
54 | 👋 Hello! This pull request has been automatically marked as stale because it has not had recent activity.
55 |
56 | 🔄 **To keep this PR active:**
57 | - Push new commits
58 | - Add a comment with updates
59 | - Request a review
60 | - Add the `keep-open` label
61 |
62 | 🗑️ **If no activity occurs within 7 days, this PR will be automatically closed.**
63 |
64 | Thank you for contributing to WanderLust! 🌍✈️
65 | close-pr-message: |
66 | 🔒 This pull request has been automatically closed due to inactivity.
67 |
68 | If you'd like to continue with this PR, please feel free to reopen it and address any feedback.
69 |
70 | Thank you for your contribution to WanderLust! 🌍
71 | stale-pr-label: 'stale'
72 |
73 | # Exempt labels
74 | exempt-issue-labels: 'keep-open,bug,enhancement,documentation,help-wanted,good-first-issue,priority-high'
75 | exempt-pr-labels: 'keep-open,work-in-progress,review-requested,priority-high'
76 |
77 | # Other options
78 | remove-stale-when-updated: true
79 | delete-branch: false
80 |
--------------------------------------------------------------------------------
/routes/packingList.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 | const { isLoggedIn } = require('../middleware');
4 | const PackingListService = require('../services/packingListService');
5 | const User = require('../models/user');
6 |
7 | // GET /packing-list - Show packing list input form
8 | router.get('/', isLoggedIn, (req, res) => {
9 | res.render('packingList/form', {
10 | title: 'AI-Powered Packing List Generator',
11 | user: req.user
12 | });
13 | });
14 |
15 | // POST /packing-list/generate - Generate packing list using AI
16 | router.post('/generate', isLoggedIn, async (req, res) => {
17 | try {
18 | const { destination, duration, travelType, activities } = req.body;
19 |
20 | // Validate inputs
21 | const tripData = {
22 | destination: destination.trim(),
23 | duration: parseInt(duration),
24 | travelType: travelType.toLowerCase(),
25 | activities: Array.isArray(activities) ? activities : [activities]
26 | };
27 |
28 | const validation = PackingListService.validateTripData(tripData);
29 | if (!validation.isValid) {
30 | return res.status(400).render('packingList/form', {
31 | title: 'AI-Powered Packing List Generator',
32 | errors: validation.errors,
33 | formData: req.body
34 | });
35 | }
36 |
37 | // Generate packing list
38 | const packingList = await PackingListService.generatePackingList(tripData);
39 |
40 | res.render('packingList/result', {
41 | title: 'Your Personalized Packing List',
42 | packingList,
43 | user: req.user
44 | });
45 | } catch (error) {
46 | console.error('Packing list generation error:', error);
47 | res.status(500).render('packingList/form', {
48 | title: 'AI-Powered Packing List Generator',
49 | errors: ['Failed to generate packing list. Please try again later.'],
50 | formData: req.body
51 | });
52 | }
53 | });
54 |
55 | // POST /packing-list/save - Save packing list to user's trip plans
56 | router.post('/save', isLoggedIn, async (req, res) => {
57 | try {
58 | const { packingList, tripDetails } = req.body;
59 |
60 | if (!packingList || !tripDetails) {
61 | return res.status(400).json({ error: 'Missing packing list or trip details' });
62 | }
63 |
64 | const user = await User.findById(req.user._id);
65 |
66 | if (!user.tripPlans) {
67 | user.tripPlans = [];
68 | }
69 |
70 | // Add packing list to trip plan
71 | const newTripPlan = {
72 | destination: tripDetails.destination,
73 | startDate: tripDetails.startDate ? new Date(tripDetails.startDate) : new Date(),
74 | endDate: tripDetails.endDate ? new Date(tripDetails.endDate) : new Date(),
75 | travelers: tripDetails.travelers || 1,
76 | budgetType: tripDetails.budgetType || 'moderate',
77 | packingList: JSON.parse(packingList),
78 | total: tripDetails.total || 0,
79 | status: 'planned',
80 | createdAt: new Date()
81 | };
82 |
83 | user.tripPlans.push(newTripPlan);
84 | await user.save();
85 |
86 | res.json({ success: true, message: 'Packing list saved to your trips!' });
87 | } catch (error) {
88 | console.error('Save packing list error:', error);
89 | res.status(500).json({ error: 'Failed to save packing list' });
90 | }
91 | });
92 |
93 | module.exports = router;
94 |
--------------------------------------------------------------------------------
/.github/workflows/issue-create-automate-message.yml:
--------------------------------------------------------------------------------
1 | name: Smart Auto Comment on Issue
2 |
3 | on:
4 | issues:
5 | types: [opened]
6 |
7 | permissions:
8 | issues: write
9 |
10 | jobs:
11 | comment:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Generate Personalized Comment
15 | uses: actions/github-script@v7
16 | with:
17 | script: |
18 | const issueNumber = context.issue.number;
19 | const username = context.payload.issue.user.login;
20 | const title = context.payload.issue.title.toLowerCase();
21 | const body = context.payload.issue.body ? context.payload.issue.body.toLowerCase() : '';
22 | const labels = context.payload.issue.labels.map(label => label.name);
23 |
24 | let personalizedMessage = `### Hello @${username}! 👋\n\n`;
25 |
26 | // Detect issue type and personalize accordingly
27 | if (title.includes('bug') || title.includes('error') || title.includes('issue')) {
28 | personalizedMessage += `Thanks for reporting this bug! 🐛 Your detailed report helps us improve the project.\n\n`;
29 | personalizedMessage += `To help us resolve this quickly, please ensure you've included:\n`;
30 | personalizedMessage += `- Steps to reproduce the issue\n`;
31 | personalizedMessage += `- Expected vs actual behavior\n`;
32 | personalizedMessage += `- Your environment details\n\n`;
33 | } else if (title.includes('feature') || title.includes('enhancement') || title.includes('request')) {
34 | personalizedMessage += `Great feature suggestion! 💡 We appreciate community input on improving the project.\n\n`;
35 | personalizedMessage += `Your idea will be reviewed by our team. Meanwhile, feel free to:\n`;
36 | personalizedMessage += `- Check our [roadmap](ROADMAP.md) for planned features\n`;
37 | personalizedMessage += `- Join discussions with other contributors\n\n`;
38 | } else if (title.includes('question') || title.includes('help') || title.includes('how')) {
39 | personalizedMessage += `Thanks for your question! 🤔 We're here to help.\n\n`;
40 | personalizedMessage += `Before diving in, you might find answers in:\n`;
41 | personalizedMessage += `- Our [documentation](docs/)\n`;
42 | personalizedMessage += `- [FAQ section](FAQ.md)\n`;
43 | personalizedMessage += `- [Previous discussions](../../discussions)\n\n`;
44 | } else {
45 | personalizedMessage += `Thank you for opening this issue! We appreciate your contribution to the project.\n\n`;
46 | }
47 |
48 | // Add standard footer
49 | personalizedMessage += `> Please review our [Contributing Guidelines](CONTRIBUTING.md) and [Code of Conduct](CODE_OF_CONDUCT.md) before making any contributions.\n\n`;
50 | personalizedMessage += `We'll review this as soon as possible! ✨`;
51 |
52 | await github.rest.issues.createComment({
53 | owner: context.repo.owner,
54 | repo: context.repo.repo,
55 | issue_number: issueNumber,
56 | body: personalizedMessage
57 | });
58 |
59 | console.log('Personalized comment added successfully.');
--------------------------------------------------------------------------------
/routes/user.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const router = express.Router();
3 | const User = require("../models/user.js");
4 | const wrapAsync = require("../utils/wrapAsync.js");
5 | const passport = require("passport");
6 | const { saveRedirectUrl, isLoggedIn } = require("../middleware.js");
7 |
8 | const userController = require("../controllers/users.js");
9 |
10 | router
11 | .route("/signup")
12 | .get(userController.renderSignupForm)
13 | .post(wrapAsync(userController.signup));
14 |
15 | router
16 | .route("/login")
17 | .get(userController.renderLoginForm)
18 | .post(
19 | saveRedirectUrl,
20 | passport.authenticate("local", {
21 | failureRedirect: "/login",
22 | failureFlash: true,
23 | }),
24 | userController.login
25 | );
26 |
27 | router.get("/logout", userController.logout);
28 |
29 | // Google OAuth routes
30 | router.get("/auth/google",
31 | passport.authenticate("google", { scope: ["profile", "email"] })
32 | );
33 |
34 | router.get("/auth/google/callback",
35 | passport.authenticate("google", { failureRedirect: "/signup" }),
36 | userController.googleCallback
37 | );
38 |
39 |
40 | router.get("/profile/likes", isLoggedIn, userController.showLikedListings);
41 |
42 | // Wishlist Routes (place before general profile routes)
43 | router.get("/profile/wishlist", isLoggedIn, wrapAsync(userController.showWishlist));
44 | router.post("/profile/wishlist/:listingId", isLoggedIn, wrapAsync(userController.addToWishlist));
45 | router.get("/profile/wishlist/add/:listingId", isLoggedIn, wrapAsync(userController.addToWishlist));
46 | router.delete("/profile/wishlist/:listingId", isLoggedIn, wrapAsync(userController.removeFromWishlist));
47 |
48 | // Vacation Slots Route
49 | router.get("/profile/vacation-slots", isLoggedIn, wrapAsync(userController.showVacationSlots));
50 |
51 | // Enhanced Profile Routes
52 | router
53 | .route("/profile")
54 | .get(isLoggedIn, userController.renderProfile)
55 | .put(isLoggedIn, userController.updateProfile);
56 |
57 | // Travel Goals Routes
58 | router.post("/profile/travel-goals", isLoggedIn, wrapAsync(userController.addTravelGoal));
59 | router.patch("/profile/travel-goals/:goalId/complete", isLoggedIn, wrapAsync(userController.completeTravelGoal));
60 | router.delete("/profile/travel-goals/:goalId", isLoggedIn, wrapAsync(userController.deleteTravelGoal));
61 |
62 | // Achievements Route
63 | router.get("/achievements", isLoggedIn, wrapAsync(userController.showAchievements));
64 |
65 | // Leaderboard Route
66 | router.get("/leaderboard", isLoggedIn, wrapAsync(userController.showLeaderboard));
67 |
68 | // Travel Journal Routes
69 | router.get("/profile/travel-journal", isLoggedIn, wrapAsync(userController.showTravelJournal));
70 | router.post("/profile/travel-journal", isLoggedIn, wrapAsync(userController.addTravelMemory));
71 | router.patch("/profile/travel-journal/:memoryId", isLoggedIn, wrapAsync(userController.updateTravelMemory));
72 | router.delete("/profile/travel-journal/:memoryId", isLoggedIn, wrapAsync(userController.deleteTravelMemory));
73 |
74 | // Smart Travel Recommendations Route
75 | router.get("/recommendations", (req, res) => {
76 | console.log("Direct route handler called");
77 | res.send("Direct route handler working!");
78 | });
79 |
80 | // Root route - redirect to listings
81 | router.get("/", (req, res) => {
82 | res.redirect("/listings");
83 | });
84 |
85 | module.exports = router;
86 |
--------------------------------------------------------------------------------
/routes/safety.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const router = express.Router();
3 | const wrapAsync = require("../utils/wrapAsync.js");
4 | const { isLoggedIn, isAdmin } = require("../middleware.js");
5 | const scamsController = require("../controllers/scams.js");
6 | const multer = require("multer");
7 | const { storage } = require("../cloudConfig.js");
8 | const upload = multer({ storage });
9 |
10 | // Validation middleware
11 | const { body } = require('express-validator');
12 |
13 | // Validation rules for scam report
14 | const validateScamReport = [
15 | body('scamReport.title')
16 | .trim()
17 | .isLength({ min: 5, max: 200 })
18 | .withMessage('Title must be between 5 and 200 characters'),
19 | body('scamReport.location')
20 | .trim()
21 | .isLength({ min: 2, max: 100 })
22 | .withMessage('Location must be between 2 and 100 characters'),
23 | body('scamReport.country')
24 | .trim()
25 | .isLength({ min: 2, max: 100 })
26 | .withMessage('Country is required'),
27 | body('scamReport.description')
28 | .trim()
29 | .isLength({ min: 20, max: 2000 })
30 | .withMessage('Description must be between 20 and 2000 characters'),
31 | body('scamReport.category')
32 | .isIn(['Overpricing', 'Fake Guide', 'Fraud', 'Theft', 'Unsafe Area', 'Transportation Scam', 'Accommodation Scam', 'Tour Scam', 'Other'])
33 | .withMessage('Please select a valid category'),
34 | body('scamReport.severity')
35 | .isIn(['low', 'medium', 'high', 'critical'])
36 | .withMessage('Please select a valid severity level'),
37 | body('scamReport.incidentDate')
38 | .isISO8601()
39 | .withMessage('Please provide a valid incident date')
40 | .custom((value) => {
41 | const incidentDate = new Date(value);
42 | const now = new Date();
43 | const oneYearAgo = new Date();
44 | oneYearAgo.setFullYear(now.getFullYear() - 1);
45 |
46 | if (incidentDate > now) {
47 | throw new Error('Incident date cannot be in the future');
48 | }
49 | if (incidentDate < oneYearAgo) {
50 | throw new Error('Incident date cannot be more than 1 year ago');
51 | }
52 | return true;
53 | })
54 | ];
55 |
56 | // Routes
57 | router
58 | .route("/")
59 | .get(wrapAsync(scamsController.getSafetyAlerts)) // Safety alerts feed
60 | .post(
61 | isLoggedIn,
62 | upload.array("evidence", 5), // Allow up to 5 evidence files
63 | validateScamReport,
64 | wrapAsync(scamsController.createScamReport)
65 | );
66 |
67 | // New report form
68 | router.get("/new", isLoggedIn, scamsController.renderNewForm);
69 |
70 | // Individual report routes
71 | router
72 | .route("/:id")
73 | .get(wrapAsync(scamsController.showScamReport))
74 | .put(
75 | isLoggedIn,
76 | upload.array("evidence", 5),
77 | validateScamReport,
78 | wrapAsync(scamsController.updateScamReport)
79 | )
80 | .delete(isLoggedIn, wrapAsync(scamsController.deleteScamReport));
81 |
82 | // Edit form
83 | router.get("/:id/edit", isLoggedIn, wrapAsync(scamsController.renderEditForm));
84 |
85 | // Voting routes
86 | router.post("/:id/upvote", isLoggedIn, wrapAsync(scamsController.upvoteReport));
87 | router.post("/:id/downvote", isLoggedIn, wrapAsync(scamsController.downvoteReport));
88 |
89 | // Admin routes - TODO: Implement admin verification UI
90 | // router.put("/:id/verify", isLoggedIn, isAdmin, wrapAsync(scamsController.verifyReport));
91 |
92 | // API routes
93 | router.get("/api/location-alerts", wrapAsync(scamsController.getLocationAlerts));
94 |
95 | module.exports = router;
96 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # 🌍 Wanderlust — Code of Conduct
2 |
3 | 
4 | 
5 | 
6 | 
7 |
8 | ---
9 |
10 | ## ✨ **Welcome!**
11 |
12 | Hey there, awesome dev! 👋
13 | Welcome to **Wanderlust**—part of **GirlScript Summer of Code 2025**.
14 |
15 | We’re building a community that is:
16 |
17 | - **Friendly** 💛
18 | - **Inclusive** 🌈
19 | - **Supportive** 🤝
20 |
21 | Whether you’re a newbie or a pro, your **voice matters**. Let’s create something amazing **together**. 🚀
22 |
23 | ---
24 |
25 | ## 🌟 **Our Core Principles**
26 |
27 | We expect everyone in our community to:
28 |
29 | | Principle | Emoji | Why It Matters |
30 | | ------------------------------ | ----- | -------------------------------------------- |
31 | | **Respect Others** | 🙏 | Treat everyone kindly & considerately |
32 | | **Communicate Constructively** | 💬 | Help, guide, and provide feedback politely |
33 | | **Be Inclusive** | 🌏 | Welcome all backgrounds, skills & identities |
34 | | **Show Empathy** | ❤️ | Understand different perspectives |
35 | | **Stay Positive** | ✨ | Keep the vibes uplifting and motivating |
36 |
37 | > _"Kindness is contagious—spread it around!"_ 😎
38 |
39 | ---
40 |
41 | ## ❌ **Unacceptable Behavior**
42 |
43 | We take these seriously:
44 |
45 | - Harassment, discrimination, or exclusion of any kind 🚫
46 | - Personal attacks, insults, or derogatory comments 💢
47 | - Unwelcome sexual attention or advances ⚠️
48 | - Intimidation, stalking, or threats 😨
49 | - Disruptive, trolling, or disrespectful behavior 🛑
50 |
51 | > **Pro Tip:** If it feels wrong in real life, don’t type it here. 🧠
52 |
53 | ---
54 |
55 | ## 📢 **Reporting Issues**
56 |
57 | If something bad happens, **don’t stress**—we’ve got your back!
58 |
59 | - Contact the team at: **[koushik369mondal@gmail.com](mailto:koushik369mondal@gmail.com)**
60 | - Your report will be **confidential & handled carefully** 🔒
61 | - Optionally, include screenshots or context for faster action 🖼️
62 |
63 | 
64 |
65 | ---
66 |
67 | ## ⚖️ **Enforcement Responsibilities**
68 |
69 | Maintainers are responsible for:
70 |
71 | - Clarifying rules 📜
72 | - Taking fair & appropriate action ✅
73 | - Ensuring a safe & inclusive environment for all 🌟
74 |
75 | ---
76 |
77 | ## 🌐 **Scope of This Code**
78 |
79 | This Code of Conduct applies to **all places related to Wanderlust**:
80 |
81 | - Repository & branches
82 | - Issues & Pull Requests
83 | - Discussions, chats, and community forums
84 | - Any contributor interactions
85 |
86 | > Think of it as your **safety net in the Wanderlust world** 🕸️
87 |
88 | ---
89 |
90 | ## 💖 **Thank You!**
91 |
92 | Thanks for helping us make **Wanderlust** a **fun, safe, and inspiring space** for all contributors!
93 |
94 | Let’s code, create, and explore together. 🌟✨
95 |
96 | 
97 |
--------------------------------------------------------------------------------
/views/error.ejs:
--------------------------------------------------------------------------------
1 | <% layout("/layouts/boilerplate") %>
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
404
13 |
Oops! You've wandered off the beaten path
14 |
15 |
16 |
17 |
18 | It looks like the destination you're looking for doesn't exist on our map.
19 | Don't worry, even the best explorers sometimes take a wrong turn!
20 |
21 |
22 |
23 |
24 | Let's get you back on track to discover amazing places.
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | Explore Listings
35 |
36 |
37 |
38 | Go Back
39 |
40 |
41 |
42 |
43 |
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/views/phraseAssistant/index.ejs:
--------------------------------------------------------------------------------
1 | <% layout('layouts/boilerplate') %>
2 |
3 |
4 |
<%= title %>
5 |
6 |
7 |
8 |
9 |
10 |
Translate Custom Phrase
11 |
12 |
13 | Enter your phrase in English
14 |
15 |
16 |
17 |
18 |
19 | Target Language (ISO code)
20 |
21 |
22 |
23 | Category
24 |
25 | General
26 | Directions
27 | Dining
28 | Emergency
29 | Etiquette
30 |
31 |
32 |
33 |
34 |
Translate
35 |
36 |
37 |
38 |
39 |
Translation
40 |
41 |
42 |
Play Audio
43 |
Save to Favorites
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
Quick Phrases
53 |
Select a category and language to see common phrases.
54 |
55 |
56 |
57 | Category
58 |
59 | Greetings
60 | Directions
61 | Dining
62 | Emergency
63 | Etiquette
64 |
65 |
66 |
67 | Language
68 |
69 |
70 |
71 |
72 |
Load Phrases
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
My Travel Phrases
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
--------------------------------------------------------------------------------
/public/CSS/holiday.css:
--------------------------------------------------------------------------------
1 | /* Holiday Calendar Specific Styles */
2 | .holiday-calendar {
3 | background: var(--bg-secondary);
4 | min-height: 100vh;
5 | padding: 20px 0;
6 | }
7 |
8 | /* Dark mode theme variables for holiday styles */
9 | [data-theme="dark"] {
10 | --filter-chip-bg: rgba(254, 66, 77, 0.2);
11 | --filter-chip-border: rgba(254, 66, 77, 0.4);
12 | --export-btn-hover: #d63641;
13 | }
14 |
15 | .holiday-stats {
16 | display: grid;
17 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
18 | gap: 20px;
19 | margin-bottom: 30px;
20 | }
21 |
22 | .stat-card {
23 | background: var(--bg-primary);
24 | padding: 20px;
25 | border-radius: 10px;
26 | text-align: center;
27 | box-shadow: 0 2px 10px var(--shadow);
28 | }
29 |
30 | .stat-number {
31 | font-size: 2rem;
32 | font-weight: bold;
33 | color: var(--accent-color);
34 | }
35 |
36 | .stat-label {
37 | color: var(--text-secondary);
38 | font-size: 0.9rem;
39 | margin-top: 5px;
40 | }
41 |
42 | .filter-section {
43 | background: var(--bg-primary);
44 | padding: 20px;
45 | border-radius: 10px;
46 | margin-bottom: 20px;
47 | box-shadow: 0 2px 10px var(--shadow);
48 | }
49 |
50 | .filter-chips {
51 | display: flex;
52 | flex-wrap: wrap;
53 | gap: 10px;
54 | margin-top: 15px;
55 | }
56 |
57 | .filter-chip {
58 | background: var(--filter-chip-bg, rgba(254, 66, 77, 0.1));
59 | color: var(--accent-color);
60 | border: 1px solid var(--filter-chip-border, rgba(254, 66, 77, 0.3));
61 | padding: 8px 16px;
62 | border-radius: 20px;
63 | cursor: pointer;
64 | transition: all 0.3s ease;
65 | font-size: 0.9rem;
66 | }
67 |
68 | .filter-chip:hover,
69 | .filter-chip.active {
70 | background: var(--accent-color);
71 | color: white;
72 | }
73 |
74 | .holiday-timeline {
75 | position: relative;
76 | padding-left: 30px;
77 | }
78 |
79 | .holiday-timeline::before {
80 | content: '';
81 | position: absolute;
82 | left: 15px;
83 | top: 0;
84 | bottom: 0;
85 | width: 2px;
86 | background: var(--accent-color);
87 | }
88 |
89 | .timeline-item {
90 | position: relative;
91 | margin-bottom: 30px;
92 | }
93 |
94 | .timeline-item::before {
95 | content: '';
96 | position: absolute;
97 | left: -23px;
98 | top: 10px;
99 | width: 12px;
100 | height: 12px;
101 | border-radius: 50%;
102 | background: var(--accent-color);
103 | border: 3px solid var(--bg-primary);
104 | }
105 |
106 | .export-options {
107 | margin-top: 30px;
108 | text-align: center;
109 | }
110 |
111 | .export-btn {
112 | background: var(--accent-color);
113 | color: white;
114 | border: none;
115 | padding: 12px 24px;
116 | border-radius: 25px;
117 | margin: 0 10px;
118 | cursor: pointer;
119 | transition: all 0.3s ease;
120 | }
121 |
122 | .export-btn:hover {
123 | background: var(--export-btn-hover, #e63946);
124 | transform: translateY(-2px);
125 | }
126 |
127 | /* Mobile Responsiveness */
128 | @media (max-width: 768px) {
129 | .holiday-grid {
130 | grid-template-columns: 1fr;
131 | }
132 |
133 | .filter-chips {
134 | justify-content: center;
135 | }
136 |
137 | .export-btn {
138 | display: block;
139 | width: 100%;
140 | margin: 10px 0;
141 | }
142 | }
143 |
144 | /* Dark theme support */
145 | [data-theme="dark"] .holiday-calendar {
146 | background: var(--bg-primary);
147 | }
148 |
149 | [data-theme="dark"] .stat-card,
150 | [data-theme="dark"] .filter-section,
151 | [data-theme="dark"] .holiday-card {
152 | background: var(--bg-secondary);
153 | color: var(--text-primary);
154 | }
155 |
156 | [data-theme="dark"] .holiday-type,
157 | [data-theme="dark"] .stat-label {
158 | color: var(--text-secondary);
159 | }
--------------------------------------------------------------------------------
/tests/README.md:
--------------------------------------------------------------------------------
1 | # WanderLust Test Suite
2 |
3 | This directory contains comprehensive test cases for the WanderLust application.
4 |
5 | ## Test Structure
6 |
7 | ```
8 | tests/
9 | ├── setup.js # Jest setup and configuration
10 | ├── auth.test.js # Authentication route tests
11 | ├── listings.test.js # Listing route tests
12 | ├── reviews.test.js # Review route tests
13 | ├── middleware.test.js # Middleware tests
14 | ├── schema.test.js # Joi schema validation tests
15 | ├── models/
16 | │ ├── listing.test.js # Listing model tests
17 | │ ├── review.test.js # Review model tests
18 | │ └── user.test.js # User model tests
19 | └── utils/
20 | ├── ExpressError.test.js # ExpressError utility tests
21 | └── wrapAsync.test.js # wrapAsync utility tests
22 | ```
23 |
24 | ## Running Tests
25 |
26 | ### Run all tests
27 |
28 | ```bash
29 | npm test
30 | ```
31 |
32 | ### Run tests in watch mode
33 |
34 | ```bash
35 | npm run test:watch
36 | ```
37 |
38 | ### Run unit tests only
39 |
40 | ```bash
41 | npm run test:unit
42 | ```
43 |
44 | ### Run integration tests only
45 |
46 | ```bash
47 | npm run test:integration
48 | ```
49 |
50 | ### Run tests with coverage
51 |
52 | ```bash
53 | npm test -- --coverage
54 | ```
55 |
56 | ## Test Categories
57 |
58 | ### 1. Route Tests
59 |
60 | - **auth.test.js**: Tests for signup, login, logout routes
61 | - **listings.test.js**: Tests for listing CRUD operations
62 | - **reviews.test.js**: Tests for review creation and deletion
63 |
64 | ### 2. Model Tests
65 |
66 | - **listing.test.js**: Tests for Listing model validation
67 | - **review.test.js**: Tests for Review model validation
68 | - **user.test.js**: Tests for User model validation
69 |
70 | ### 3. Utility Tests
71 |
72 | - **ExpressError.test.js**: Tests for custom error handling
73 | - **wrapAsync.test.js**: Tests for async error wrapper
74 |
75 | ### 4. Middleware Tests
76 |
77 | - **middleware.test.js**: Tests for error handling, session, flash messages, security headers
78 |
79 | ### 5. Schema Tests
80 |
81 | - **schema.test.js**: Tests for Joi validation schemas
82 |
83 | ## Test Coverage
84 |
85 | The test suite aims to cover:
86 |
87 | - ✅ Authentication flows
88 | - ✅ CRUD operations for listings and reviews
89 | - ✅ Model validations
90 | - ✅ Error handling
91 | - ✅ Middleware functionality
92 | - ✅ Schema validations
93 | - ✅ Utility functions
94 |
95 | ## Environment Variables
96 |
97 | Tests use `.env.test` file for configuration. Make sure to:
98 |
99 | 1. Copy `.env.test.example` to `.env.test`
100 | 2. Update with appropriate test database credentials
101 | 3. Never commit `.env.test` with real credentials
102 |
103 | ## Notes
104 |
105 | - Tests use `supertest` for HTTP assertions
106 | - Models are tested with validation mocks
107 | - Database operations are mocked in unit tests
108 | - Integration tests may require a test database connection
109 |
110 | ## Adding New Tests
111 |
112 | When adding new features:
113 |
114 | 1. Create corresponding test file in appropriate directory
115 | 2. Follow existing test patterns
116 | 3. Ensure tests are independent and isolated
117 | 4. Update this README with new test descriptions
118 |
119 | ## Continuous Integration
120 |
121 | Tests are automatically run on:
122 |
123 | - Pull requests
124 | - Push to main branch
125 | - Before deployment
126 |
127 | ## Troubleshooting
128 |
129 | ### Port Already in Use
130 |
131 | If you see `EADDRINUSE` error:
132 |
133 | ```bash
134 | npx kill-port 8080
135 | ```
136 |
137 | ### Database Connection Issues
138 |
139 | - Ensure MongoDB is running
140 | - Check `.env.test` database URL
141 | - Verify test database exists
142 |
143 | ### Test Timeout
144 |
145 | Increase timeout in specific tests:
146 |
147 | ```javascript
148 | jest.setTimeout(15000); // 15 seconds
149 | ```
150 |
--------------------------------------------------------------------------------
/views/includes/footer.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | # CodeQL Security Analysis Workflow
2 | # This workflow performs security analysis using GitHub's CodeQL to detect
3 | # potential security vulnerabilities and coding errors in JavaScript/Node.js code
4 |
5 | name: "🔒 CodeQL Security Analysis"
6 |
7 | on:
8 | # Trigger on pushes to main branches
9 | push:
10 | branches: [ "main", "develop" ]
11 | paths:
12 | - '**.js'
13 | - '**.ts'
14 | - '**.json'
15 | - '.github/workflows/codeql.yml'
16 |
17 | # Trigger on pull requests targeting main branches
18 | pull_request:
19 | branches: [ "main", "develop" ]
20 | paths:
21 | - '**.js'
22 | - '**.ts'
23 | - '**.json'
24 | - '.github/workflows/codeql.yml'
25 |
26 | # Run weekly security scans (every Monday at 2 AM UTC)
27 | schedule:
28 | - cron: '0 2 * * 1'
29 |
30 | # Allow manual triggering of the workflow
31 | workflow_dispatch:
32 |
33 | jobs:
34 | analyze:
35 | name: 🛡️ Security Analysis
36 | runs-on: ubuntu-latest
37 | timeout-minutes: 30 # Prevent workflow from running too long
38 |
39 | # Required permissions for CodeQL
40 | permissions:
41 | actions: read
42 | contents: read
43 | security-events: write
44 |
45 | strategy:
46 | fail-fast: false
47 | matrix:
48 | # Analyze JavaScript/TypeScript code
49 | language: [ 'javascript-typescript' ]
50 | include:
51 | - language: 'javascript-typescript'
52 | build-mode: none
53 |
54 | steps:
55 | # Step 1: Checkout the repository
56 | - name: 📥 Checkout Repository
57 | uses: actions/checkout@v4
58 |
59 | # Step 2: Initialize CodeQL analysis
60 | - name: 🔧 Initialize CodeQL
61 | uses: github/codeql-action/init@v2
62 | with:
63 | languages: ${{ matrix.language }}
64 | # Use built-in query suite for security analysis
65 | queries: +security-and-quality
66 | # Add config file if needed
67 | # config-file: ./.github/codeql/codeql-config.yml
68 |
69 | # Step 3: Set up Node.js for dependency analysis
70 | - name: 🟢 Setup Node.js
71 | uses: actions/setup-node@v4
72 | with:
73 | node-version: 'lts/*'
74 | cache: 'npm'
75 |
76 | # Step 4: Install dependencies (needed for complete analysis)
77 | - name: 📦 Install Dependencies
78 | run: |
79 | echo "Installing dependencies for comprehensive security analysis..."
80 | # Use npm ci for faster, more reliable builds in CI
81 | npm ci --ignore-scripts --only=production
82 | continue-on-error: true
83 |
84 | # Step 5: Build the application (helps with analysis)
85 | - name: 🏗️ Build Application
86 | run: |
87 | echo "Building application for enhanced CodeQL analysis..."
88 | # Only build if build script exists
89 | if npm run --silent build --dry-run 2>/dev/null; then
90 | npm run build
91 | else
92 | echo "No build script found, skipping build step"
93 | fi
94 | continue-on-error: true
95 |
96 | # Step 6: Perform CodeQL Analysis
97 | - name: 🔍 Perform CodeQL Analysis
98 | uses: github/codeql-action/analyze@v2
99 | with:
100 | category: "/language:${{matrix.language}}"
101 | # Upload results even if there are no findings
102 | upload: always
103 |
104 | # Step 7: Security scan summary
105 | - name: ✅ Security Scan Summary
106 | if: always()
107 | run: |
108 | echo "🔒 CodeQL Security Analysis completed!"
109 | echo "✓ Repository scanned for security vulnerabilities"
110 | echo "✓ Code quality issues analyzed"
111 | echo "✓ Results automatically uploaded to GitHub Security tab"
112 | echo ""
113 | echo "📊 Check the Security tab in your repository for detailed results"
114 | echo "🛡️ Any security issues found will appear as alerts"
115 | echo "🔗 View results at: https://github.com/${{ github.repository }}/security/code-scanning"
--------------------------------------------------------------------------------
/tests/schema.test.js:
--------------------------------------------------------------------------------
1 | const { listingSchema, reviewSchema } = require('../schema');
2 |
3 | describe('Schema Validation', () => {
4 | describe('Listing Schema', () => {
5 | it('should validate a valid listing', () => {
6 | const validListing = {
7 | listing: {
8 | title: 'Test Listing',
9 | description: 'Test Description',
10 | price: 100,
11 | location: 'Test Location',
12 | country: 'Test Country'
13 | }
14 | };
15 |
16 | const { error } = listingSchema.validate(validListing);
17 | expect(error).toBeUndefined();
18 | });
19 |
20 | it('should reject listing without title', () => {
21 | const invalidListing = {
22 | listing: {
23 | description: 'Test',
24 | price: 100,
25 | location: 'Test',
26 | country: 'Test'
27 | }
28 | };
29 |
30 | const { error } = listingSchema.validate(invalidListing);
31 | expect(error).toBeDefined();
32 | });
33 |
34 | it('should reject listing with negative price', () => {
35 | const invalidListing = {
36 | listing: {
37 | title: 'Test',
38 | description: 'Test',
39 | price: -100,
40 | location: 'Test',
41 | country: 'Test'
42 | }
43 | };
44 |
45 | const { error } = listingSchema.validate(invalidListing);
46 | expect(error).toBeDefined();
47 | });
48 |
49 | it('should reject listing with empty description', () => {
50 | const invalidListing = {
51 | listing: {
52 | title: 'Test',
53 | description: '',
54 | price: 100,
55 | location: 'Test',
56 | country: 'Test'
57 | }
58 | };
59 |
60 | const { error } = listingSchema.validate(invalidListing);
61 | expect(error).toBeDefined();
62 | });
63 | });
64 |
65 | describe('Review Schema', () => {
66 | it('should validate a valid review', () => {
67 | const validReview = {
68 | review: {
69 | rating: 5,
70 | comment: 'Great place!'
71 | }
72 | };
73 |
74 | const { error } = reviewSchema.validate(validReview);
75 | expect(error).toBeUndefined();
76 | });
77 |
78 | it('should reject review without rating', () => {
79 | const invalidReview = {
80 | review: {
81 | comment: 'Test'
82 | }
83 | };
84 |
85 | const { error } = reviewSchema.validate(invalidReview);
86 | expect(error).toBeDefined();
87 | });
88 |
89 | it('should reject review with rating > 5', () => {
90 | const invalidReview = {
91 | review: {
92 | rating: 6,
93 | comment: 'Test'
94 | }
95 | };
96 |
97 | const { error } = reviewSchema.validate(invalidReview);
98 | expect(error).toBeDefined();
99 | });
100 |
101 | it('should reject review with rating < 1', () => {
102 | const invalidReview = {
103 | review: {
104 | rating: 0,
105 | comment: 'Test'
106 | }
107 | };
108 |
109 | const { error } = reviewSchema.validate(invalidReview);
110 | expect(error).toBeDefined();
111 | });
112 |
113 | it('should reject review without comment', () => {
114 | const invalidReview = {
115 | review: {
116 | rating: 5
117 | }
118 | };
119 |
120 | const { error } = reviewSchema.validate(invalidReview);
121 | expect(error).toBeDefined();
122 | });
123 | });
124 | });
125 |
--------------------------------------------------------------------------------
/utils/updateCoordinates.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose');
2 | const Listing = require('../models/listing');
3 | require('dotenv').config();
4 |
5 | // Country-based coordinate mappings
6 | const countryCoordinates = {
7 | 'india': [77.2090, 28.6139],
8 | 'united states': [-95.7129, 37.0902],
9 | 'usa': [-95.7129, 37.0902],
10 | 'italy': [12.5674, 41.8719],
11 | 'mexico': [-102.5528, 23.6345],
12 | 'switzerland': [8.2275, 46.8182],
13 | 'tanzania': [34.8888, -6.3690],
14 | 'netherlands': [5.2913, 52.1326],
15 | 'fiji': [179.4144, -16.5780],
16 | 'united kingdom': [-3.4360, 55.3781],
17 | 'uk': [-3.4360, 55.3781],
18 | 'indonesia': [113.9213, -0.7893],
19 | 'canada': [-106.3468, 56.1304],
20 | 'thailand': [100.9925, 15.8700],
21 | 'united arab emirates': [53.8478, 23.4241],
22 | 'greece': [21.8243, 39.0742],
23 | 'costa rica': [-83.7534, 9.7489],
24 | 'japan': [138.2529, 36.2048],
25 | 'maldives': [73.2207, 3.2028]
26 | };
27 |
28 | // City-specific coordinates for better accuracy
29 | const cityCoordinates = {
30 | 'new york city': [-74.006, 40.7128],
31 | 'malibu': [-118.7798, 34.0259],
32 | 'aspen': [-106.8175, 39.1911],
33 | 'florence': [11.2558, 43.7696],
34 | 'portland': [-122.6784, 45.5152],
35 | 'cancun': [-86.8515, 21.1619],
36 | 'lake tahoe': [-120.0324, 39.0968],
37 | 'los angeles': [-118.2437, 34.0522],
38 | 'verbier': [7.2284, 46.0963],
39 | 'serengeti national park': [34.8333, -2.3333],
40 | 'amsterdam': [4.9041, 52.3676],
41 | 'fiji': [179.4144, -16.5780],
42 | 'cotswolds': [-1.8094, 51.8330],
43 | 'boston': [-71.0589, 42.3601],
44 | 'bali': [115.0920, -8.4095],
45 | 'banff': [-115.5708, 51.1784],
46 | 'miami': [-80.1918, 25.7617],
47 | 'phuket': [98.3923, 7.8804],
48 | 'scottish highlands': [-4.2026, 57.2707],
49 | 'dubai': [55.2708, 25.2048],
50 | 'montana': [-110.3626, 46.9219],
51 | 'mykonos': [25.3289, 37.4467],
52 | 'costa rica': [-83.7534, 9.7489],
53 | 'charleston': [-79.9311, 32.7765],
54 | 'tokyo': [139.6917, 35.6895],
55 | 'new hampshire': [-71.5376, 43.4525],
56 | 'maldives': [73.2207, 3.2028]
57 | };
58 |
59 | async function updateListingCoordinates(shouldDisconnect = false) {
60 | try {
61 | // Only connect if not already connected
62 | if (mongoose.connection.readyState !== 1) {
63 | await mongoose.connect(process.env.ATLAS_DB_URL);
64 | console.log('✅ Connected to MongoDB');
65 | }
66 |
67 | const listings = await Listing.find({});
68 | let updateCount = 0;
69 |
70 | for (const listing of listings) {
71 | let coordinates = [77.2090, 28.6139]; // Default Delhi
72 |
73 | // Try city-specific coordinates first
74 | const locationKey = (listing.location || '').toLowerCase();
75 | if (cityCoordinates[locationKey]) {
76 | coordinates = cityCoordinates[locationKey];
77 | } else {
78 | // Fall back to country coordinates
79 | const countryKey = (listing.country || '').toLowerCase();
80 | if (countryCoordinates[countryKey]) {
81 | coordinates = countryCoordinates[countryKey];
82 | }
83 | }
84 |
85 | // Update the listing
86 | await Listing.findByIdAndUpdate(listing._id, {
87 | geometry: {
88 | type: 'Point',
89 | coordinates: coordinates
90 | }
91 | });
92 |
93 | updateCount++;
94 | }
95 |
96 | console.log(`✅ Updated coordinates for ${updateCount} listings`);
97 | } catch (error) {
98 | console.error('❌ Error updating coordinates:', error);
99 | } finally {
100 | // Only disconnect if explicitly requested (e.g., when run as standalone script)
101 | if (shouldDisconnect) {
102 | await mongoose.disconnect();
103 | console.log('✅ Disconnected from MongoDB');
104 | }
105 | }
106 | }
107 |
108 | module.exports = { updateListingCoordinates };
109 |
110 | // Run the update if script is executed directly
111 | if (require.main === module) {
112 | console.log('🚀 Starting coordinate update process...');
113 | updateListingCoordinates(true).then(() => {
114 | console.log('✅ Update complete - all listings now have coordinates!');
115 | process.exit(0);
116 | }).catch(err => {
117 | console.error('❌ Update failed:', err);
118 | process.exit(1);
119 | });
120 | }
--------------------------------------------------------------------------------
/views/users/liked.ejs:
--------------------------------------------------------------------------------
1 | <% layout('/layouts/boilerplate') %>
2 |
3 |
94 |
95 |
96 |
97 |
98 |
<%= name %>'s Liked Listings
99 |
All the places you've saved in one spot!
100 |
101 |
102 |
103 |
104 | <% if (likedListings && likedListings.length > 0) { %>
105 | <% for(let listing of likedListings) { %>
106 |
122 | <% } %>
123 | <% } else { %>
124 |
125 |
You haven't liked any listings yet. Start exploring to save your favorites!
126 |
127 | <% } %>
128 |
129 |
--------------------------------------------------------------------------------
/views/listings/edit.ejs:
--------------------------------------------------------------------------------
1 | <% layout('/layouts/boilerplate') %>
2 |
3 |
4 |
5 |
Edit your Listing
6 |
81 |
82 |
--------------------------------------------------------------------------------
/controllers/newsletter.js:
--------------------------------------------------------------------------------
1 | const Newsletter = require("../models/newsletter.js");
2 |
3 | // Subscribe to newsletter
4 | module.exports.subscribe = async (req, res) => {
5 | try {
6 | const { email } = req.body;
7 |
8 | // Validate email
9 | if (!email) {
10 | req.flash("error", "Please provide an email address.");
11 | return res.redirect(req.get("Referrer") || "/");
12 | }
13 |
14 | // Check if email already exists
15 | const existingSubscriber = await Newsletter.findOne({ email: email.toLowerCase() });
16 |
17 | if (existingSubscriber) {
18 | if (existingSubscriber.isActive) {
19 | req.flash("error", "This email is already subscribed to our newsletter!");
20 | return res.redirect(req.get("Referrer") || "/");
21 | } else {
22 | // Reactivate subscription
23 | existingSubscriber.isActive = true;
24 | existingSubscriber.subscribedAt = new Date();
25 | await existingSubscriber.save();
26 | req.flash("success", "Welcome back! Your newsletter subscription has been reactivated.");
27 | return res.redirect(req.get("Referrer") || "/");
28 | }
29 | }
30 |
31 | // Create new subscription
32 | const newSubscriber = new Newsletter({
33 | email: email.toLowerCase(),
34 | source: req.body.source || 'footer'
35 | });
36 |
37 | await newSubscriber.save();
38 | req.flash("success", "🎉 Thank you for subscribing! You'll receive travel tips and exclusive deals.");
39 | res.redirect(req.get("Referrer") || "/");
40 |
41 | } catch (error) {
42 | console.error("Newsletter subscription error:", error);
43 |
44 | if (error.name === 'ValidationError') {
45 | req.flash("error", "Please enter a valid email address.");
46 | } else if (error.code === 11000) {
47 | req.flash("error", "This email is already subscribed to our newsletter!");
48 | } else {
49 | req.flash("error", "Something went wrong. Please try again later.");
50 | }
51 |
52 | res.redirect(req.get("Referrer") || "/");
53 | }
54 | };
55 |
56 | // Unsubscribe from newsletter
57 | module.exports.unsubscribe = async (req, res) => {
58 | try {
59 | const { email } = req.body;
60 |
61 | if (!email) {
62 | req.flash("error", "Please provide an email address.");
63 | return res.redirect(req.get("Referrer") || "/");
64 | }
65 |
66 | const subscriber = await Newsletter.findOne({ email: email.toLowerCase() });
67 |
68 | if (!subscriber) {
69 | req.flash("error", "Email address not found in our newsletter list.");
70 | return res.redirect(req.get("Referrer") || "/");
71 | }
72 |
73 | if (!subscriber.isActive) {
74 | req.flash("error", "This email is already unsubscribed.");
75 | return res.redirect(req.get("Referrer") || "/");
76 | }
77 |
78 | subscriber.isActive = false;
79 | await subscriber.save();
80 |
81 | req.flash("success", "You have been successfully unsubscribed from our newsletter.");
82 | res.redirect(req.get("Referrer") || "/");
83 |
84 | } catch (error) {
85 | console.error("Newsletter unsubscribe error:", error);
86 | req.flash("error", "Something went wrong. Please try again later.");
87 | res.redirect(req.get("Referrer") || "/");
88 | }
89 | };
90 |
91 | // Get newsletter statistics (admin only)
92 | module.exports.getStats = async (req, res) => {
93 | try {
94 | const totalSubscribers = await Newsletter.countDocuments({ isActive: true });
95 | const recentSubscribers = await Newsletter.countDocuments({
96 | isActive: true,
97 | subscribedAt: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) } // Last 30 days
98 | });
99 |
100 | const subscribersBySource = await Newsletter.aggregate([
101 | { $match: { isActive: true } },
102 | { $group: { _id: "$source", count: { $sum: 1 } } }
103 | ]);
104 |
105 | res.json({
106 | totalSubscribers,
107 | recentSubscribers,
108 | subscribersBySource
109 | });
110 |
111 | } catch (error) {
112 | console.error("Newsletter stats error:", error);
113 | res.status(500).json({ error: "Failed to fetch newsletter statistics" });
114 | }
115 | };
--------------------------------------------------------------------------------
/services/badgeService.js:
--------------------------------------------------------------------------------
1 | const BadgeDefinition = require('../models/badgeDefinition');
2 | const User = require('../models/user');
3 | const Listing = require('../models/listing');
4 | const Review = require('../models/review');
5 |
6 | class BadgeService {
7 | static async checkAndAwardBadges(userId) {
8 | try {
9 | const user = await User.findById(userId).populate('badges');
10 | if (!user) return false;
11 |
12 | const badges = await BadgeDefinition.find({ isActive: true });
13 | let newBadgesAwarded = [];
14 |
15 | for (const badgeDefinition of badges) {
16 | // Check if user already has this badge
17 | const hasBadge = user.badges.some(badge => badge.name === badgeDefinition.name);
18 | if (hasBadge) continue;
19 |
20 | const meetsRequirement = await this.checkBadgeRequirement(user, badgeDefinition);
21 | if (meetsRequirement) {
22 | await user.awardBadge({
23 | name: badgeDefinition.name,
24 | description: badgeDefinition.description,
25 | icon: badgeDefinition.icon,
26 | category: badgeDefinition.category
27 | });
28 | newBadgesAwarded.push(badgeDefinition.name);
29 | }
30 | }
31 |
32 | return newBadgesAwarded;
33 | } catch (error) {
34 | console.error('Error checking badges:', error);
35 | return false;
36 | }
37 | }
38 |
39 | static async checkBadgeRequirement(user, badgeDefinition) {
40 | const { criteria } = badgeDefinition;
41 |
42 | switch (criteria.type) {
43 | case 'profile_completion':
44 | return user.profileCompletion >= criteria.threshold;
45 |
46 | case 'listing_count':
47 | const listingCount = await Listing.countDocuments({ owner: user._id });
48 | return listingCount >= criteria.threshold;
49 |
50 | case 'review_count':
51 | const reviewCount = await Review.countDocuments({ author: user._id });
52 | return reviewCount >= criteria.threshold;
53 |
54 | case 'destination_count':
55 | return user.favoriteDestinations.length >= criteria.threshold;
56 |
57 | case 'social_engagement':
58 | const socialLinks = user.socialLinks;
59 | const filledLinks = Object.values(socialLinks).filter(link => link && link.trim() !== '').length;
60 | return filledLinks >= criteria.threshold;
61 |
62 | case 'time_based':
63 | const daysSinceJoin = Math.floor((new Date() - user.joinDate) / (1000 * 60 * 60 * 24));
64 | return daysSinceJoin >= criteria.threshold;
65 |
66 | default:
67 | return false;
68 | }
69 | }
70 |
71 | static async updateUserStats(userId) {
72 | try {
73 | const user = await User.findById(userId);
74 | if (!user) return false;
75 |
76 | // Count user's listings and reviews
77 | const [listingCount, reviewCount] = await Promise.all([
78 | Listing.countDocuments({ owner: userId }),
79 | Review.countDocuments({ author: userId })
80 | ]);
81 |
82 | // Update stats
83 | user.travelStats.totalListings = listingCount;
84 | user.travelStats.totalReviews = reviewCount;
85 | user.travelStats.countriesVisited = user.favoriteDestinations.length;
86 |
87 | await user.save();
88 | return true;
89 | } catch (error) {
90 | console.error('Error updating user stats:', error);
91 | return false;
92 | }
93 | }
94 |
95 | static getBadgeRarityColor(rarity) {
96 | const colors = {
97 | common: '#6c757d',
98 | rare: '#17a2b8',
99 | epic: '#6f42c1',
100 | legendary: '#fd7e14'
101 | };
102 | return colors[rarity] || colors.common;
103 | }
104 |
105 | static getBadgeRarityGradient(rarity) {
106 | const gradients = {
107 | common: 'linear-gradient(135deg, #6c757d, #adb5bd)',
108 | rare: 'linear-gradient(135deg, #17a2b8, #20c997)',
109 | epic: 'linear-gradient(135deg, #6f42c1, #e83e8c)',
110 | legendary: 'linear-gradient(135deg, #fd7e14, #ffc107)'
111 | };
112 | return gradients[rarity] || gradients.common;
113 | }
114 | }
115 |
116 | module.exports = BadgeService;
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | # Build & Test Workflow
2 | # This workflow builds the application and runs tests to ensure everything works correctly
3 | # Uses npm ci for faster, reliable, reproducible builds
4 |
5 | name: "🏗️ Build & Test"
6 |
7 | # Trigger this workflow on push and pull requests to main branches
8 | on:
9 | push:
10 | branches: [ "main", "develop" ]
11 | pull_request:
12 | branches: [ "main", "develop" ]
13 |
14 | jobs:
15 | build-and-test:
16 | name: 🚀 Build and Test Application
17 | runs-on: ubuntu-latest
18 |
19 | # Test against multiple Node.js versions for compatibility
20 | strategy:
21 | matrix:
22 | node-version: [18.x, 20.x, 22.x] # Use LTS and current stable versions
23 |
24 | steps:
25 | # Step 1: Get the latest code from the repository
26 | - name: 📥 Checkout Repository
27 | uses: actions/checkout@v4
28 |
29 | # Step 2: Set up Node.js environment
30 | - name: 🟢 Setup Node.js ${{ matrix.node-version }}
31 | uses: actions/setup-node@v4
32 | with:
33 | node-version: ${{ matrix.node-version }}
34 | # Use npm cache for faster builds
35 | cache: 'npm'
36 |
37 | # Step 3: Clean install dependencies (faster and more reliable than npm install)
38 | - name: 📦 Install Dependencies
39 | run: |
40 | echo "Installing dependencies with npm ci for reproducible builds..."
41 | npm ci
42 |
43 | # Step 4: Build the application
44 | - name: 🏗️ Build Application
45 | run: |
46 | # Check if build script exists in package.json
47 | if npm run --silent build --dry-run 2>/dev/null; then
48 | echo "Building application..."
49 | npm run build
50 | else
51 | echo "⚠️ No build script found in package.json"
52 | echo "If your project needs compilation, add a build script to package.json"
53 | echo "Skipping build step..."
54 | fi
55 |
56 | # Step 5: Run tests
57 | - name: 🧪 Run Tests
58 | run: |
59 | # Check if test script exists in package.json
60 | if npm run --silent test --dry-run 2>/dev/null; then
61 | echo "Running tests..."
62 | npm test
63 | else
64 | echo "⚠️ No test script found in package.json"
65 | echo "Consider adding tests to improve code reliability"
66 | echo "You can use testing frameworks like Jest, Mocha, or Vitest"
67 | echo "Skipping test step..."
68 | fi
69 |
70 | # Step 6: Upload build artifacts (if build folder exists)
71 | - name: 📤 Upload Build Artifacts
72 | if: success()
73 | uses: actions/upload-artifact@v4
74 | with:
75 | name: build-files-node-${{ matrix.node-version }}
76 | path: |
77 | build/
78 | dist/
79 | public/
80 | # Keep artifacts for 7 days
81 | retention-days: 7
82 | continue-on-error: true
83 |
84 | # Step 7: Show build summary
85 | - name: ✅ Build Summary
86 | if: always()
87 | run: |
88 | echo "🎉 Build and test workflow completed!"
89 | echo "✓ Node.js ${{ matrix.node-version }} environment ready"
90 | echo "✓ Dependencies installed with npm ci"
91 | echo "✓ Build process completed"
92 | echo "✓ Tests executed"
93 | echo "Ready for deployment! 🚀"
94 |
95 | # Summary job that consolidates all matrix results into a single status check
96 | ci-summary:
97 | name: "CI / Build and Test"
98 | runs-on: ubuntu-latest
99 | needs: build-and-test
100 | if: always()
101 |
102 | steps:
103 | - name: 📋 Check CI Status
104 | run: |
105 | echo "Checking overall CI status..."
106 | echo "Matrix job results: ${{ needs.build-and-test.result }}"
107 |
108 | if [ "${{ needs.build-and-test.result }}" = "success" ]; then
109 | echo "✅ All CI checks passed successfully!"
110 | echo "🎉 Build and tests completed across all Node.js versions"
111 | exit 0
112 | elif [ "${{ needs.build-and-test.result }}" = "failure" ]; then
113 | echo "❌ Some CI checks failed"
114 | echo "Please check the individual job results above"
115 | exit 1
116 | elif [ "${{ needs.build-and-test.result }}" = "cancelled" ]; then
117 | echo "⏹️ CI checks were cancelled"
118 | exit 1
119 | else
120 | echo "⚠️ CI checks completed with warnings or were skipped"
121 | echo "Job result: ${{ needs.build-and-test.result }}"
122 | exit 0
123 | fi
--------------------------------------------------------------------------------
/services/weatherService.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | // Using built-in fetch instead of axios
4 |
5 | class WeatherService {
6 | constructor() {
7 | this.apiKey = process.env.WEATHER_API_KEY || 'demo_key';
8 | this.baseUrl = 'https://api.openweathermap.org/data/2.5';
9 | this.cache = new Map();
10 | this.cacheTimeout = 10 * 60 * 1000; // 10 minutes
11 | }
12 |
13 | async getCurrentWeather(lat, lon) {
14 | const cacheKey = `current_${lat}_${lon}`;
15 | const cached = this.cache.get(cacheKey);
16 |
17 | if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
18 | return cached.data;
19 | }
20 |
21 | try {
22 | const url = `${this.baseUrl}/weather?lat=${lat}&lon=${lon}&appid=${this.apiKey}&units=metric`;
23 | const response = await fetch(url);
24 | const data = await response.json();
25 |
26 | const weatherData = {
27 | temperature: Math.round(data.main.temp),
28 | feelsLike: Math.round(data.main.feels_like),
29 | humidity: data.main.humidity,
30 | condition: data.weather[0].main,
31 | description: data.weather[0].description,
32 | icon: data.weather[0].icon,
33 | windSpeed: data.wind?.speed || 0
34 | };
35 |
36 | this.cache.set(cacheKey, {
37 | data: weatherData,
38 | timestamp: Date.now()
39 | });
40 |
41 | return weatherData;
42 | } catch (error) {
43 | console.error('Weather API error:', error.message);
44 | return this.getFallbackWeather();
45 | }
46 | }
47 |
48 | async getForecast(lat, lon) {
49 | const cacheKey = `forecast_${lat}_${lon}`;
50 | const cached = this.cache.get(cacheKey);
51 |
52 | if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
53 | return cached.data;
54 | }
55 |
56 | try {
57 | const url = `${this.baseUrl}/forecast?lat=${lat}&lon=${lon}&appid=${this.apiKey}&units=metric`;
58 | const response = await fetch(url);
59 | const data = await response.json();
60 |
61 | const forecast = data.list.slice(0, 7).map(item => ({
62 | date: new Date(item.dt * 1000).toLocaleDateString(),
63 | temperature: Math.round(item.main.temp),
64 | condition: item.weather[0].main,
65 | icon: item.weather[0].icon,
66 | description: item.weather[0].description
67 | }));
68 |
69 | this.cache.set(cacheKey, {
70 | data: forecast,
71 | timestamp: Date.now()
72 | });
73 |
74 | return forecast;
75 | } catch (error) {
76 | console.error('Forecast API error:', error.message);
77 | return [];
78 | }
79 | }
80 |
81 | getBestTimeToVisit(location, country) {
82 | const seasonalData = {
83 | 'italy': 'Spring (Apr-Jun) & Fall (Sep-Oct)',
84 | 'france': 'Spring (Apr-Jun) & Fall (Sep-Nov)',
85 | 'japan': 'Spring (Mar-May) & Fall (Sep-Nov)',
86 | 'thailand': 'Cool Season (Nov-Feb)',
87 | 'india': 'Winter (Oct-Mar)',
88 | 'greece': 'Spring (Apr-Jun) & Fall (Sep-Oct)',
89 | 'spain': 'Spring (Mar-May) & Fall (Sep-Nov)',
90 | 'united states': 'Varies by region - Spring & Fall generally best',
91 | 'canada': 'Summer (Jun-Aug)',
92 | 'australia': 'Spring (Sep-Nov) & Fall (Mar-May)',
93 | 'default': 'Spring & Fall seasons typically ideal'
94 | };
95 |
96 | const countryKey = country?.toLowerCase() || 'default';
97 | return seasonalData[countryKey] || seasonalData['default'];
98 | }
99 |
100 | getFallbackWeather() {
101 | return {
102 | temperature: 22,
103 | feelsLike: 24,
104 | humidity: 65,
105 | condition: 'Clear',
106 | description: 'clear sky',
107 | icon: '01d',
108 | windSpeed: 3.5
109 | };
110 | }
111 |
112 | getWeatherIcon(iconCode) {
113 | const iconMap = {
114 | '01d': '☀️', '01n': '🌙',
115 | '02d': '⛅', '02n': '☁️',
116 | '03d': '☁️', '03n': '☁️',
117 | '04d': '☁️', '04n': '☁️',
118 | '09d': '🌧️', '09n': '🌧️',
119 | '10d': '🌦️', '10n': '🌧️',
120 | '11d': '⛈️', '11n': '⛈️',
121 | '13d': '❄️', '13n': '❄️',
122 | '50d': '🌫️', '50n': '🌫️'
123 | };
124 | return iconMap[iconCode] || '🌤️';
125 | }
126 | }
127 |
128 | module.exports = new WeatherService();
--------------------------------------------------------------------------------
/services/aiSummarizationService.js:
--------------------------------------------------------------------------------
1 | const OpenAI = require('openai');
2 |
3 | // Check for OpenAI API key
4 | let openai = null;
5 | if (process.env.OPENAI_API_KEY && process.env.OPENAI_API_KEY !== 'your_openai_api_key_here') {
6 | openai = new OpenAI({
7 | apiKey: process.env.OPENAI_API_KEY,
8 | });
9 | } else {
10 | console.warn('⚠️ OPENAI_API_KEY not set! AI summarization will be disabled.');
11 | }
12 |
13 | class AISummarizationService {
14 | /**
15 | * Generate AI summary from reviews
16 | * @param {Array} reviews - Array of review objects with comment and rating
17 | * @param {String} listingTitle - Title of the listing
18 | * @returns {String} - AI generated summary
19 | */
20 | static async generateSummary(reviews, listingTitle) {
21 | if (!reviews || reviews.length === 0) {
22 | return "No reviews available yet. Be the first to share your experience!";
23 | }
24 |
25 | if (reviews.length < 2) {
26 | return "Limited reviews available. More feedback needed for AI summary.";
27 | }
28 |
29 | // If OpenAI is not available, return fallback summary
30 | if (!openai) {
31 | console.log('Using fallback summary generation (OpenAI not configured)');
32 | const avgRating = reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;
33 | if (avgRating >= 4.5) {
34 | return `This destination has received excellent reviews with an average rating of ${avgRating.toFixed(1)}/5. Travelers consistently praise the experience and recommend it highly.`;
35 | } else if (avgRating >= 4.0) {
36 | return `This destination has good reviews with an average rating of ${avgRating.toFixed(1)}/5. Most travelers had positive experiences with some room for improvement.`;
37 | } else {
38 | return `This destination has mixed reviews with an average rating of ${avgRating.toFixed(1)}/5. Experiences vary among travelers.`;
39 | }
40 | }
41 |
42 | try {
43 | // Prepare reviews text for AI
44 | const reviewsText = reviews.map(review =>
45 | `Rating: ${review.rating}/5\nComment: ${review.comment}`
46 | ).join('\n\n');
47 |
48 | const prompt = `You are an AI travel assistant. Based on the following reviews for "${listingTitle}", create a concise, engaging summary (2-3 sentences) that highlights the key experiences, pros, and any common themes. Focus on what travelers loved and any important considerations. Keep it positive and informative.
49 |
50 | Reviews:
51 | ${reviewsText}
52 |
53 | Summary:`;
54 |
55 | const response = await openai.chat.completions.create({
56 | model: 'gpt-3.5-turbo',
57 | messages: [
58 | {
59 | role: 'system',
60 | content: 'You are a helpful travel assistant that creates engaging summaries from user reviews.'
61 | },
62 | {
63 | role: 'user',
64 | content: prompt
65 | }
66 | ],
67 | max_tokens: 200,
68 | temperature: 0.7,
69 | });
70 |
71 | const summary = response.choices[0].message.content.trim();
72 |
73 | // Validate summary length
74 | if (summary.length < 20) {
75 | throw new Error('Summary too short');
76 | }
77 |
78 | return summary;
79 | } catch (error) {
80 | console.error('AI Summarization Error:', error.message);
81 |
82 | // Fallback summary based on average rating
83 | const avgRating = reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;
84 | if (avgRating >= 4.5) {
85 | return `This destination has received excellent reviews with an average rating of ${avgRating.toFixed(1)}/5. Travelers consistently praise the experience and recommend it highly.`;
86 | } else if (avgRating >= 4.0) {
87 | return `This destination has good reviews with an average rating of ${avgRating.toFixed(1)}/5. Most travelers had positive experiences with some room for improvement.`;
88 | } else {
89 | return `This destination has mixed reviews with an average rating of ${avgRating.toFixed(1)}/5. Experiences vary among travelers.`;
90 | }
91 | }
92 | }
93 |
94 | /**
95 | * Check if summary needs updating
96 | * @param {Date} lastUpdated - Last update timestamp
97 | * @param {Number} reviewCount - Current review count
98 | * @returns {Boolean} - Whether update is needed
99 | */
100 | static needsUpdate(lastUpdated, reviewCount) {
101 | if (!lastUpdated) return true;
102 |
103 | const now = new Date();
104 | const hoursSinceUpdate = (now - lastUpdated) / (1000 * 60 * 60);
105 |
106 | // Update if more than 24 hours old, or if significant new reviews (every 5 new reviews)
107 | return hoursSinceUpdate > 24 || (reviewCount > 0 && reviewCount % 5 === 0);
108 | }
109 | }
110 |
111 | module.exports = AISummarizationService;
112 |
--------------------------------------------------------------------------------
/routes/currency.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const router = express.Router();
3 |
4 | // Currency converter main page
5 | router.get("/", (req, res) => {
6 | const currencies = [
7 | { code: 'USD', name: 'US Dollar', symbol: '$', flag: '🇺🇸' },
8 | { code: 'EUR', name: 'Euro', symbol: '€', flag: '🇪🇺' },
9 | { code: 'GBP', name: 'British Pound', symbol: '£', flag: '🇬🇧' },
10 | { code: 'INR', name: 'Indian Rupee', symbol: '₹', flag: '🇮🇳' },
11 | { code: 'JPY', name: 'Japanese Yen', symbol: '¥', flag: '🇯🇵' },
12 | { code: 'CAD', name: 'Canadian Dollar', symbol: 'C$', flag: '🇨🇦' },
13 | { code: 'AUD', name: 'Australian Dollar', symbol: 'A$', flag: '🇦🇺' },
14 | { code: 'CHF', name: 'Swiss Franc', symbol: 'CHF', flag: '🇨🇭' },
15 | { code: 'CNY', name: 'Chinese Yuan', symbol: '¥', flag: '🇨🇳' },
16 | { code: 'KRW', name: 'South Korean Won', symbol: '₩', flag: '🇰🇷' },
17 | { code: 'BRL', name: 'Brazilian Real', symbol: 'R$', flag: '🇧🇷' },
18 | { code: 'MXN', name: 'Mexican Peso', symbol: '$', flag: '🇲🇽' },
19 | { code: 'SGD', name: 'Singapore Dollar', symbol: 'S$', flag: '🇸🇬' },
20 | { code: 'HKD', name: 'Hong Kong Dollar', symbol: 'HK$', flag: '🇭🇰' },
21 | { code: 'NZD', name: 'New Zealand Dollar', symbol: 'NZ$', flag: '🇳🇿' },
22 | { code: 'SEK', name: 'Swedish Krona', symbol: 'kr', flag: '🇸🇪' },
23 | { code: 'NOK', name: 'Norwegian Krone', symbol: 'kr', flag: '🇳🇴' },
24 | { code: 'DKK', name: 'Danish Krone', symbol: 'kr', flag: '🇩🇰' },
25 | { code: 'PLN', name: 'Polish Złoty', symbol: 'zł', flag: '🇵🇱' },
26 | { code: 'CZK', name: 'Czech Koruna', symbol: 'Kč', flag: '🇨🇿' }
27 | ];
28 |
29 | res.render("currency/index", {
30 | title: "Currency Converter",
31 | currencies: currencies
32 | });
33 | });
34 |
35 | // API endpoint for currency conversion
36 | router.get("/api/convert", async (req, res) => {
37 | try {
38 | const { from, to, amount } = req.query;
39 |
40 | if (!from || !to || !amount) {
41 | return res.status(400).json({
42 | success: false,
43 | error: 'Missing required parameters: from, to, amount'
44 | });
45 | }
46 |
47 | const numAmount = parseFloat(amount);
48 | if (isNaN(numAmount) || numAmount < 0) {
49 | return res.status(400).json({
50 | success: false,
51 | error: 'Invalid amount. Must be a positive number.'
52 | });
53 | }
54 |
55 | // Use the existing trip planner service for exchange rates
56 | const tripPlannerService = require("../services/tripPlannerService");
57 | const rates = await tripPlannerService.getExchangeRates(from);
58 |
59 | if (!rates || !rates[to]) {
60 | return res.status(400).json({
61 | success: false,
62 | error: `Exchange rate not available for ${from} to ${to}`
63 | });
64 | }
65 |
66 | const rate = rates[to];
67 | const convertedAmount = numAmount * rate;
68 |
69 | res.json({
70 | success: true,
71 | conversion: {
72 | from: from,
73 | to: to,
74 | amount: numAmount,
75 | rate: rate,
76 | result: Math.round(convertedAmount * 100) / 100, // Round to 2 decimal places
77 | timestamp: new Date().toISOString()
78 | }
79 | });
80 | } catch (error) {
81 | console.error('Currency conversion error:', error);
82 | res.status(500).json({
83 | success: false,
84 | error: 'Failed to convert currency'
85 | });
86 | }
87 | });
88 |
89 | // API endpoint for exchange rates
90 | router.get("/api/rates/:base", async (req, res) => {
91 | try {
92 | const { base } = req.params;
93 |
94 | const tripPlannerService = require("../services/tripPlannerService");
95 | const rates = await tripPlannerService.getExchangeRates(base);
96 |
97 | if (!rates) {
98 | return res.status(400).json({
99 | success: false,
100 | error: `Exchange rates not available for ${base}`
101 | });
102 | }
103 |
104 | res.json({
105 | success: true,
106 | base: base,
107 | rates: rates,
108 | timestamp: new Date().toISOString()
109 | });
110 | } catch (error) {
111 | console.error('Exchange rates error:', error);
112 | res.status(500).json({
113 | success: false,
114 | error: 'Failed to fetch exchange rates'
115 | });
116 | }
117 | });
118 |
119 | module.exports = router;
120 |
--------------------------------------------------------------------------------
/views/includes/theme-toggle.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
9 |
10 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/models/badgeDefinition.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 | const Schema = mongoose.Schema;
3 |
4 | const badgeDefinitionSchema = new Schema({
5 | name: {
6 | type: String,
7 | required: true,
8 | unique: true
9 | },
10 | description: {
11 | type: String,
12 | required: true
13 | },
14 | icon: {
15 | type: String,
16 | required: true
17 | },
18 | category: {
19 | type: String,
20 | enum: ['explorer', 'reviewer', 'host', 'social', 'milestone'],
21 | required: true
22 | },
23 | criteria: {
24 | type: {
25 | type: String,
26 | enum: ['listing_count', 'review_count', 'destination_count', 'profile_completion', 'social_engagement', 'time_based'],
27 | required: true
28 | },
29 | threshold: {
30 | type: Number,
31 | required: true
32 | }
33 | },
34 | rarity: {
35 | type: String,
36 | enum: ['common', 'rare', 'epic', 'legendary'],
37 | default: 'common'
38 | },
39 | isActive: {
40 | type: Boolean,
41 | default: true
42 | },
43 | createdAt: {
44 | type: Date,
45 | default: Date.now
46 | }
47 | });
48 |
49 | // Define default badges
50 | const defaultBadges = [
51 | {
52 | name: "Welcome Traveler",
53 | description: "Complete your profile",
54 | icon: "fa-user-check",
55 | category: "milestone",
56 | criteria: { type: "profile_completion", threshold: 80 },
57 | rarity: "common"
58 | },
59 | {
60 | name: "First Steps",
61 | description: "Create your first listing",
62 | icon: "fa-home",
63 | category: "host",
64 | criteria: { type: "listing_count", threshold: 1 },
65 | rarity: "common"
66 | },
67 | {
68 | name: "Rising Host",
69 | description: "Create 5 listings",
70 | icon: "fa-building",
71 | category: "host",
72 | criteria: { type: "listing_count", threshold: 5 },
73 | rarity: "rare"
74 | },
75 | {
76 | name: "Property Mogul",
77 | description: "Create 20 listings",
78 | icon: "fa-city",
79 | category: "host",
80 | criteria: { type: "listing_count", threshold: 20 },
81 | rarity: "epic"
82 | },
83 | {
84 | name: "Review Rookie",
85 | description: "Write your first review",
86 | icon: "fa-star",
87 | category: "reviewer",
88 | criteria: { type: "review_count", threshold: 1 },
89 | rarity: "common"
90 | },
91 | {
92 | name: "Seasoned Critic",
93 | description: "Write 10 reviews",
94 | icon: "fa-edit",
95 | category: "reviewer",
96 | criteria: { type: "review_count", threshold: 10 },
97 | rarity: "rare"
98 | },
99 | {
100 | name: "Review Master",
101 | description: "Write 50 reviews",
102 | icon: "fa-award",
103 | category: "reviewer",
104 | criteria: { type: "review_count", threshold: 50 },
105 | rarity: "epic"
106 | },
107 | {
108 | name: "Globe Trotter",
109 | description: "Add 10 favorite destinations",
110 | icon: "fa-globe",
111 | category: "explorer",
112 | criteria: { type: "destination_count", threshold: 10 },
113 | rarity: "rare"
114 | },
115 | {
116 | name: "World Explorer",
117 | description: "Add 25 favorite destinations",
118 | icon: "fa-map",
119 | category: "explorer",
120 | criteria: { type: "destination_count", threshold: 25 },
121 | rarity: "epic"
122 | },
123 | {
124 | name: "Travel Legend",
125 | description: "Add 50 favorite destinations",
126 | icon: "fa-compass",
127 | category: "explorer",
128 | criteria: { type: "destination_count", threshold: 50 },
129 | rarity: "legendary"
130 | },
131 | {
132 | name: "Social Butterfly",
133 | description: "Complete all social links",
134 | icon: "fa-users",
135 | category: "social",
136 | criteria: { type: "social_engagement", threshold: 4 },
137 | rarity: "rare"
138 | },
139 | {
140 | name: "Veteran Member",
141 | description: "Member for 1 year",
142 | icon: "fa-calendar-alt",
143 | category: "milestone",
144 | criteria: { type: "time_based", threshold: 365 },
145 | rarity: "epic"
146 | }
147 | ];
148 |
149 | // Static method to initialize default badges
150 | badgeDefinitionSchema.statics.initializeDefaults = async function() {
151 | for (const badge of defaultBadges) {
152 | await this.findOneAndUpdate(
153 | { name: badge.name },
154 | badge,
155 | { upsert: true, new: true }
156 | );
157 | }
158 | };
159 |
160 | module.exports = mongoose.model("BadgeDefinition", badgeDefinitionSchema);
--------------------------------------------------------------------------------