Added mongodb storage and fixed search

This commit is contained in:
Tim Lappe 2025-05-20 07:21:48 +02:00
parent d028c67c68
commit 9cf1a287a7
17 changed files with 1114 additions and 678 deletions

View File

@ -12,4 +12,11 @@ The Project follows the latest coding standards for next js projects
This Project will use as less modules and node packages as possible. This leads to the following rules: This Project will use as less modules and node packages as possible. This leads to the following rules:
- No Tailwind - No Tailwind
- No SCSS / SASS - No SCSS / SASS
# Code Style
This project follows clean code rules:
- SOLID Principles
- No else statements
- readable name

12
Dockerfile.dev Normal file
View File

@ -0,0 +1,12 @@
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
# Install dependencies
RUN npm install
# CMD will be executed when container starts
CMD ["npm", "run", "dev"]

61
docker-compose.yml Normal file
View File

@ -0,0 +1,61 @@
version: '3'
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
hostname: evwiki.test
volumes:
- .:/app
- /app/node_modules
depends_on:
- mongodb
environment:
- MONGODB_URI=mongodb://mongodb:27017/evwiki
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.entrypoints=web"
- "traefik.http.routers.app.rule=Host(`evwiki.test`)"
- "traefik.http.services.app.loadbalancer.server.port=3000"
networks:
- proxy
mongodb:
image: mongo:latest
hostname: mongodb.evwiki.test
volumes:
- mongodb_data:/data/db
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.mongodb.entrypoints=mongodb"
- "traefik.http.routers.mongodb.rule=Host(`mongodb.evwiki.test`)"
- "traefik.http.services.mongodb.loadbalancer.server.port=27017"
mongo-express:
image: mongo-express:latest
hostname: mongo-express.evwiki.test
depends_on:
- mongodb
environment:
- ME_CONFIG_MONGODB_SERVER=mongodb
- ME_CONFIG_MONGODB_PORT=27017
- ME_CONFIG_MONGODB_ENABLE_ADMIN=true
- ME_CONFIG_BASICAUTH_USERNAME=admin
- ME_CONFIG_BASICAUTH_PASSWORD=pass
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.mongo-express.entrypoints=web"
- "traefik.http.routers.mongo-express.rule=Host(`mongo-express.evwiki.test`)"
- "traefik.http.services.mongo-express.loadbalancer.server.port=8081"
networks:
proxy:
external: true
volumes:
mongodb_data:

140
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"mongodb": "^6.16.0",
"next": "15.3.2", "next": "15.3.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
@ -649,6 +650,15 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@mongodb-js/saslprep": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.2.2.tgz",
"integrity": "sha512-EB0O3SCSNRUFk66iRCpI+cXzIjdswfCs7F6nOC3RAGJ7xr5YhaicvsRwJ9eyzYvYRlCSDUO/c7g4yNulxKC1WA==",
"license": "MIT",
"dependencies": {
"sparse-bitfield": "^3.0.3"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.10", "version": "0.2.10",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.10.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.10.tgz",
@ -945,6 +955,21 @@
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
}, },
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
"license": "MIT"
},
"node_modules/@types/whatwg-url": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
"license": "MIT",
"dependencies": {
"@types/webidl-conversions": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.32.1", "version": "8.32.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz",
@ -1747,6 +1772,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/bson": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/bson/-/bson-6.10.3.tgz",
"integrity": "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=16.20.1"
}
},
"node_modules/busboy": { "node_modules/busboy": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@ -3763,6 +3797,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
"license": "MIT"
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -3810,6 +3850,62 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/mongodb": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.16.0.tgz",
"integrity": "sha512-D1PNcdT0y4Grhou5Zi/qgipZOYeWrhLEpk33n3nm6LGtz61jvO88WlrWCK/bigMjpnOdAUKKQwsGIl0NtWMyYw==",
"license": "Apache-2.0",
"dependencies": {
"@mongodb-js/saslprep": "^1.1.9",
"bson": "^6.10.3",
"mongodb-connection-string-url": "^3.0.0"
},
"engines": {
"node": ">=16.20.1"
},
"peerDependencies": {
"@aws-sdk/credential-providers": "^3.188.0",
"@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
"gcp-metadata": "^5.2.0",
"kerberos": "^2.0.1",
"mongodb-client-encryption": ">=6.0.0 <7",
"snappy": "^7.2.2",
"socks": "^2.7.1"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-providers": {
"optional": true
},
"@mongodb-js/zstd": {
"optional": true
},
"gcp-metadata": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"snappy": {
"optional": true
},
"socks": {
"optional": true
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
"license": "Apache-2.0",
"dependencies": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^14.1.0 || ^13.0.0"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -4226,7 +4322,6 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -4683,6 +4778,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"license": "MIT",
"dependencies": {
"memory-pager": "^1.0.2"
}
},
"node_modules/stable-hash": { "node_modules/stable-hash": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@ -4941,6 +5045,18 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@ -5147,6 +5263,28 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"mongodb": "^6.16.0",
"next": "15.3.2", "next": "15.3.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"

View File

@ -1,22 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { CarRepository } from '../../../../../backend/repositories/CarRepository';
const carRepository = new CarRepository();
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const brandId = params.id;
try {
const carModels = await carRepository.getCarModelsByBrandId(brandId);
return NextResponse.json({ models: carModels, revisions: [] });
} catch (error) {
console.error(`Error fetching car models for brand ${brandId}:`, error);
return NextResponse.json(
{ error: 'Failed to fetch car models' },
{ status: 500 }
);
}
}

View File

@ -1,17 +0,0 @@
import { NextResponse } from 'next/server';
import { CarRepository } from '../../../../backend/repositories/CarRepository';
const carRepository = new CarRepository();
export async function GET() {
try {
const brands = await carRepository.getAllBrands();
return NextResponse.json(brands);
} catch (error) {
console.error('Error fetching brands:', error);
return NextResponse.json(
{ error: 'Failed to fetch brands' },
{ status: 500 }
);
}
}

View File

@ -6,27 +6,8 @@ const carRepository = new CarRepository();
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('query') || ''; const query = searchParams.get('query') || '';
const category = searchParams.get('category') || '';
const startYear = searchParams.get('startYear') ? parseInt(searchParams.get('startYear')!) : 0;
const endYear = searchParams.get('endYear') ? parseInt(searchParams.get('endYear')!) : new Date().getFullYear();
try { try {
let results; let results = await carRepository.searchCarsByName(query);
// If category is provided, search by category
if (category) {
const models = await carRepository.getCarsByCategory(category);
results = { models, revisions: [] };
}
// If startYear and endYear are provided (and not the default values), search by year range
else if (startYear > 0 || endYear < new Date().getFullYear()) {
const models = await carRepository.getCarsByYearRange(startYear, endYear);
results = { models, revisions: [] };
}
// Otherwise search by name (using query)
else {
results = await carRepository.searchCarsByName(query);
}
return NextResponse.json(results); return NextResponse.json(results);
} catch (error) { } catch (error) {

View File

@ -0,0 +1,111 @@
'use client';
import Link from 'next/link';
const styles = {
brandSection: {
marginTop: '2rem',
textAlign: 'center' as const,
color: '#2e4057',
position: 'relative' as const
},
brandSectionBar: {
content: '',
position: 'absolute' as const,
top: '-15px',
left: '50%',
transform: 'translateX(-50%)',
width: '50px',
height: '3px',
backgroundColor: '#edae49',
borderRadius: '3px'
},
brandList: {
marginTop: '0.8rem',
display: 'flex',
justifyContent: 'center',
flexWrap: 'wrap' as const,
gap: '1.2rem'
},
brandLink: {
color: '#083d77',
textDecoration: 'none',
fontWeight: '500',
padding: '0.5rem 1rem',
borderRadius: '20px',
transition: 'all 0.2s ease',
backgroundColor: 'white',
border: '1px solid rgba(8, 61, 119, 0.1)'
}
};
export default function BrandList() {
return (
<div style={styles.brandSection}>
<div style={styles.brandSectionBar}></div>
<p>Popular brands</p>
<div style={styles.brandList}>
<Link
href="/results?brand=1"
style={styles.brandLink}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = '#edae49';
e.currentTarget.style.color = '#2e4057';
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = 'white';
e.currentTarget.style.color = '#083d77';
}}
>Tesla</Link>
<Link
href="/results?brand=2"
style={styles.brandLink}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = '#edae49';
e.currentTarget.style.color = '#2e4057';
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = 'white';
e.currentTarget.style.color = '#083d77';
}}
>BMW</Link>
<Link
href="/results?brand=3"
style={styles.brandLink}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = '#edae49';
e.currentTarget.style.color = '#2e4057';
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = 'white';
e.currentTarget.style.color = '#083d77';
}}
>Toyota</Link>
<Link
href="/results?brand=4"
style={styles.brandLink}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = '#edae49';
e.currentTarget.style.color = '#2e4057';
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = 'white';
e.currentTarget.style.color = '#083d77';
}}
>Audi</Link>
<Link
href="/results?brand=5"
style={styles.brandLink}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = '#edae49';
e.currentTarget.style.color = '#2e4057';
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = 'white';
e.currentTarget.style.color = '#083d77';
}}
>Mercedes</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,103 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
type SearchBarProps = {
initialQuery?: string;
};
export default function SearchBar({ initialQuery = '' }: SearchBarProps) {
const [query, setQuery] = useState(initialQuery);
const router = useRouter();
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (query.trim()) {
router.push(`/results?query=${encodeURIComponent(query)}`);
}
};
const styles = {
searchContainer: {
width: '100%',
maxWidth: '42rem',
margin: '0 auto 1.5rem'
},
searchForm: {
display: 'flex',
width: '100%',
position: 'relative'
},
searchInput: {
width: '100%',
padding: '1.2rem 1.5rem',
fontSize: '1.2rem',
border: '2px solid transparent',
borderRadius: '30px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
outline: 'none',
transition: 'all 0.3s ease',
backgroundColor: 'white',
color: '#2e4057'
},
searchButton: {
position: 'absolute',
right: '12px',
top: '50%',
transform: 'translateY(-50%)',
width: '45px',
height: '45px',
backgroundColor: 'transparent',
color: '#083d77',
border: 'none',
borderRadius: '50%',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}
} as const;
return (
<div style={styles.searchContainer}>
<form onSubmit={handleSubmit} style={styles.searchForm}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for electric vehicles..."
style={styles.searchInput}
aria-label="Search for electric vehicles"
onFocus={(e) => {
e.target.style.boxShadow = '0 6px 16px rgba(8, 61, 119, 0.2)';
e.target.style.borderColor = '#083d77';
}}
onBlur={(e) => {
e.target.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.1)';
e.target.style.borderColor = 'transparent';
}}
/>
<button
type="submit"
style={styles.searchButton}
aria-label="Search"
onMouseOver={(e) => {
e.currentTarget.style.color = '#2e4057';
e.currentTarget.style.transform = 'translateY(-50%) scale(1.05)';
}}
onMouseOut={(e) => {
e.currentTarget.style.color = '#083d77';
e.currentTarget.style.transform = 'translateY(-50%)';
}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
</svg>
</button>
</form>
</div>
);
}

View File

@ -1,57 +1,38 @@
'use client'; 'use client';
import { useState, FormEvent } from 'react'; import SearchBar from './components/SearchBar';
import { useRouter } from 'next/navigation'; import BrandList from './components/BrandList';
import Link from 'next/link';
import styles from './home.module.css';
export default function Home() { export default function Home() {
const [query, setQuery] = useState(''); const styles = {
const router = useRouter(); container: {
minHeight: '100vh',
const handleSubmit = (e: FormEvent) => { display: 'flex',
e.preventDefault(); flexDirection: 'column',
alignItems: 'center',
if (query.trim()) { justifyContent: 'center',
router.push(`/results?query=${encodeURIComponent(query)}`); padding: '2rem',
background: 'linear-gradient(135deg, #ffffff, rgba(8, 61, 119, 0.05))'
},
title: {
fontSize: '3.5rem',
fontWeight: '800',
marginBottom: '2.5rem',
textAlign: 'center',
letterSpacing: '-1px',
background: 'linear-gradient(90deg, #083d77 0%, #2e4057 35%, #d1495b 100%)',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
display: 'inline-block'
} }
}; } as const;
return ( return (
<main className={styles.container}> <main style={styles.container}>
<h1 className={styles.title}> <h1 style={styles.title}>E-WIKI</h1>
<span className={styles.gradientText}>E-WIKI</span> <SearchBar />
</h1> <BrandList />
<div className={styles.searchContainer}>
<form onSubmit={handleSubmit} className={styles.searchForm}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for electric vehicles..."
className={styles.searchInput}
aria-label="Search for electric vehicles"
/>
<button type="submit" className={styles.searchButton}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style={{ marginRight: '8px' }}>
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
</svg>
Search
</button>
</form>
</div>
<div className={styles.brandSection}>
<p>Popular brands</p>
<div className={styles.brandList}>
<Link href="/results?brand=1" className={styles.brandLink}>Tesla</Link>
<Link href="/results?brand=2" className={styles.brandLink}>BMW</Link>
<Link href="/results?brand=3" className={styles.brandLink}>Toyota</Link>
<Link href="/results?brand=4" className={styles.brandLink}>Audi</Link>
<Link href="/results?brand=5" className={styles.brandLink}>Mercedes</Link>
</div>
</div>
</main> </main>
); );
} }

View File

@ -2,16 +2,33 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { CarService } from '../services/carService'; import { CarService } from '../services/carService';
import { Brand, CarModel, CarRevision } from '../../backend/models'; import { CarModel, CarRevision, Brand } from '../../backend/models';
import SearchBar from '../components/SearchBar';
import styles from './results.module.css';
// Extended interfaces for the frontend display
interface DisplayCarModel extends CarModel {
brand?: {
name: string;
};
}
interface DisplayCarRevision extends CarRevision {
baseModel?: {
brand?: {
name: string;
};
};
}
export default function Results() { export default function Results() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [models, setModels] = useState<CarModel[]>([]); const [models, setModels] = useState<DisplayCarModel[]>([]);
const [revisions, setRevisions] = useState<CarRevision[]>([]); const [revisions, setRevisions] = useState<DisplayCarRevision[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const query = searchParams.get('query') || '';
useEffect(() => { useEffect(() => {
const fetchResults = async () => { const fetchResults = async () => {
@ -56,71 +73,72 @@ export default function Results() {
fetchResults(); fetchResults();
}, [searchParams]); }, [searchParams]);
// Helper function to generate a unique key
const getModelKey = (model: DisplayCarModel) => {
return model._id?.toString() || model.id || `model-${model.name}-${model.productionStartYear}`;
};
const getRevisionKey = (revision: DisplayCarRevision) => {
return revision._id?.toString() || revision.id || `revision-${revision.name}-${revision.releaseYear}`;
};
return ( return (
<main className="min-h-screen p-8 bg-gray-50"> <main className={styles.container}>
<div className="max-w-7xl mx-auto"> <div className={styles.content}>
<div className="flex justify-between items-center mb-8"> <SearchBar initialQuery={query} />
<h1 className="text-3xl font-bold">Search Results</h1>
<Link
href="/"
className="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors"
>
New Search
</Link>
</div>
{loading ? ( {loading ? (
<div className="flex justify-center items-center py-12"> <div className={styles.loader}>
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div> <div className={styles.spinner}></div>
</div> </div>
) : error ? ( ) : error ? (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"> <div className={styles.errorMessage}>
{error} {error}
</div> </div>
) : models.length === 0 && revisions.length === 0 ? ( ) : models.length === 0 && revisions.length === 0 ? (
<div className="bg-white p-8 rounded-lg shadow-md text-center"> <div className={styles.emptyResults}>
<h2 className="text-2xl font-semibold mb-4">No results found</h2> <h2 className={styles.noResultsTitle}>No results found</h2>
<p className="text-gray-600"> <p className={styles.noResultsText}>
Try adjusting your search criteria to find more cars. Try adjusting your search criteria to find more cars.
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-8"> <div>
{models.length > 0 && ( {models.length > 0 && (
<div> <div className={styles.sectionContent}>
<h2 className="text-2xl font-semibold mb-4">Car Models</h2> <h2 className={styles.sectionTitle}>Car Models</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className={styles.grid}>
{models.map((model) => ( {models.map((model) => (
<div <div
key={model.id} key={getModelKey(model)}
className="bg-white rounded-lg shadow-md overflow-hidden" className={styles.card}
> >
<div className="h-48 bg-gray-200 relative"> <div className={styles.cardImage}>
{model.image ? ( {model.image ? (
<img <img
src={model.image} src={model.image}
alt={model.name} alt={model.name}
className="w-full h-full object-cover" className={styles.image}
/> />
) : ( ) : (
<div className="flex items-center justify-center h-full text-gray-500"> <div className={styles.noImage}>
No image available No image available
</div> </div>
)} )}
</div> </div>
<div className="p-4"> <div className={styles.cardContent}>
<div className="flex items-center justify-between mb-2"> <div className={styles.cardHeader}>
<h3 className="text-xl font-bold">{model.name}</h3> <h3 className={styles.cardTitle}>{model.name}</h3>
<span className="text-sm font-medium text-blue-600"> <span className={styles.brandName}>
{model.brand?.name} {model.brand?.name}
</span> </span>
</div> </div>
<p className="text-gray-600 text-sm mb-2">{model.description}</p> <p className={styles.cardDescription}>{model.description}</p>
<div className="flex justify-between items-center text-sm"> <div className={styles.cardFooter}>
<span className="bg-gray-100 px-2 py-1 rounded"> <span className={styles.category}>
{model.category} {model.category}
</span> </span>
<span> <span className={styles.years}>
Since {model.productionStartYear} Since {model.productionStartYear}
{model.productionEndYear {model.productionEndYear
? ` - ${model.productionEndYear}` ? ` - ${model.productionEndYear}`
@ -135,63 +153,63 @@ export default function Results() {
)} )}
{revisions.length > 0 && ( {revisions.length > 0 && (
<div> <div className={styles.sectionContent}>
<h2 className="text-2xl font-semibold mb-4">Car Revisions</h2> <h2 className={styles.sectionTitle}>Car Revisions</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className={styles.grid}>
{revisions.map((revision) => ( {revisions.map((revision) => (
<div <div
key={revision.id} key={getRevisionKey(revision)}
className="bg-white rounded-lg shadow-md overflow-hidden" className={styles.card}
> >
<div className="h-48 bg-gray-200 relative"> <div className={styles.cardImage}>
{revision.images && revision.images.length > 0 ? ( {revision.images && revision.images.length > 0 ? (
<img <img
src={revision.images[0]} src={revision.images[0]}
alt={revision.name} alt={revision.name}
className="w-full h-full object-cover" className={styles.image}
/> />
) : ( ) : (
<div className="flex items-center justify-center h-full text-gray-500"> <div className={styles.noImage}>
No image available No image available
</div> </div>
)} )}
</div> </div>
<div className="p-4"> <div className={styles.cardContent}>
<div className="flex items-center justify-between mb-2"> <div className={styles.cardHeader}>
<h3 className="text-xl font-bold">{revision.name}</h3> <h3 className={styles.cardTitle}>{revision.name}</h3>
<span className="text-sm font-medium text-blue-600"> <span className={styles.brandName}>
{revision.baseModel?.brand?.name} {revision.baseModel?.brand?.name}
</span> </span>
</div> </div>
<div className="grid grid-cols-2 gap-2 mb-2 text-sm"> <div className={styles.specGrid}>
<div> <div>
<span className="font-medium">Engine: </span> <span className={styles.specLabel}>Engine: </span>
{revision.engineTypes?.join(', ')} {revision.engineTypes?.join(', ')}
</div> </div>
<div> <div>
<span className="font-medium">Power: </span> <span className={styles.specLabel}>Power: </span>
{revision.horsePower} HP {revision.horsePower} HP
</div> </div>
<div> <div>
<span className="font-medium">0-100 km/h: </span> <span className={styles.specLabel}>0-100 km/h: </span>
{revision.acceleration0To100}s {revision.acceleration0To100}s
</div> </div>
<div> <div>
<span className="font-medium">Top Speed: </span> <span className={styles.specLabel}>Top Speed: </span>
{revision.topSpeed} km/h {revision.topSpeed} km/h
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-1 mt-2"> <div className={styles.tagContainer}>
{revision.features?.slice(0, 3).map((feature, index) => ( {revision.features?.slice(0, 3).map((feature, index) => (
<span <span
key={index} key={`${getRevisionKey(revision)}-feature-${index}`}
className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded" className={styles.tag}
> >
{feature} {feature}
</span> </span>
))} ))}
{revision.features && revision.features.length > 3 && ( {revision.features && revision.features.length > 3 && (
<span className="text-xs text-gray-500"> <span className={styles.tagCount}>
+{revision.features.length - 3} more +{revision.features.length - 3} more
</span> </span>
)} )}

View File

@ -1,12 +1,8 @@
.resultsContainer { /* Results page specific styles */
.container {
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, var(--background), rgba(8, 61, 119, 0.05));
padding: 2rem; padding: 2rem;
background-color: #f9fafb;
}
.contentContainer {
max-width: 80rem;
margin: 0 auto;
} }
.header { .header {
@ -14,26 +10,38 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 2rem; margin-bottom: 2rem;
max-width: 1200px;
margin: 0 auto 2rem;
} }
.resultsTitle { .title {
font-size: 1.875rem; font-size: 2.5rem;
font-weight: bold; font-weight: 700;
color: var(--color-charcoal);
} }
.backButton { .backButton {
background-color: #3b82f6; background-color: var(--color-yale-blue);
color: white; color: white;
padding: 0.5rem 1rem; padding: 0.6rem 1.2rem;
border-radius: 0.375rem; border-radius: 25px;
transition: background-color 0.2s; text-decoration: none;
font-weight: 600;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
} }
.backButton:hover { .backButton:hover {
background-color: #2563eb; background-color: var(--color-charcoal);
} }
.loadingContainer { .content {
max-width: 1200px;
margin: 0 auto;
}
.loader {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -41,11 +49,11 @@
} }
.spinner { .spinner {
border-radius: 50%;
width: 3rem; width: 3rem;
height: 3rem; height: 3rem;
border: 0.25rem solid rgba(59, 130, 246, 0.1); border: 0.25rem solid rgba(8, 61, 119, 0.1);
border-top-color: #3b82f6; border-top: 0.25rem solid var(--color-yale-blue);
border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@ -54,56 +62,64 @@
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
} }
.errorContainer { .errorMessage {
background-color: #fee2e2; background-color: #fde8e8;
border: 1px solid #f87171; border: 1px solid #f8b4b4;
color: #b91c1c; color: #9b1c1c;
padding: 0.75rem 1rem; padding: 1rem;
border-radius: 0.375rem;
}
.emptyResultsContainer {
background-color: white;
padding: 2rem;
border-radius: 0.5rem; border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
text-align: center; text-align: center;
} }
.emptyResultsTitle { .noResults {
background-color: white;
padding: 2rem;
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
text-align: center;
}
.emptyResults {
text-align: center;
padding: 2rem 0;
}
.noResultsTitle {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--color-charcoal);
} }
.emptyResultsMessage { .noResultsText {
color: #6b7280; color: #4b5563;
}
.resultsSection {
margin-bottom: 2rem;
} }
.sectionTitle { .sectionTitle {
font-size: 1.5rem; font-size: 1.8rem;
font-weight: 600; font-weight: 600;
margin-bottom: 1rem; margin-bottom: 1.5rem;
color: var(--color-charcoal);
} }
.cardsGrid { .sectionContent {
margin-bottom: 2.5rem;
}
.grid {
display: grid; display: grid;
grid-template-columns: repeat(1, 1fr); grid-template-columns: repeat(1, 1fr);
gap: 1.5rem; gap: 1.5rem;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.cardsGrid { .grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.cardsGrid { .grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
} }
@ -111,23 +127,29 @@
.card { .card {
background-color: white; background-color: white;
border-radius: 0.5rem; border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden; overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
.cardImageContainer { .card:hover {
height: 12rem; transform: translateY(-4px);
background-color: #e5e7eb; box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1);
position: relative;
} }
.cardImage { .cardImage {
height: 12rem;
background-color: #f3f4f6;
position: relative;
}
.image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
.noImageContainer { .noImage {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -148,17 +170,18 @@
.cardTitle { .cardTitle {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: bold; font-weight: 700;
color: var(--color-charcoal);
} }
.brandName { .brandName {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: #3b82f6; color: var(--color-yale-blue);
} }
.cardDescription { .cardDescription {
color: #6b7280; color: #4b5563;
font-size: 0.875rem; font-size: 0.875rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@ -174,9 +197,14 @@
background-color: #f3f4f6; background-color: #f3f4f6;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 0.25rem; border-radius: 0.25rem;
color: #4b5563;
} }
.specsGrid { .years {
color: #4b5563;
}
.specGrid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 0.5rem; gap: 0.5rem;
@ -186,16 +214,17 @@
.specLabel { .specLabel {
font-weight: 500; font-weight: 500;
color: var(--color-charcoal);
} }
.featuresList { .tagContainer {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.25rem; gap: 0.25rem;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.featureTag { .tag {
background-color: #dbeafe; background-color: #dbeafe;
color: #1e40af; color: #1e40af;
font-size: 0.75rem; font-size: 0.75rem;
@ -203,7 +232,7 @@
border-radius: 0.25rem; border-radius: 0.25rem;
} }
.moreFeatures { .tagCount {
font-size: 0.75rem; font-size: 0.75rem;
color: #6b7280; color: #6b7280;
} }

View File

@ -1,7 +1,9 @@
import { CarModel } from './CarModel'; import { CarModel } from './CarModel';
import { ObjectId } from 'mongodb';
export interface Brand { export interface Brand {
id: string; _id?: ObjectId;
id?: string;
name: string; name: string;
logo: string; logo: string;
description: string; description: string;
@ -9,5 +11,4 @@ export interface Brand {
headquarters: string; headquarters: string;
website: string; website: string;
carModels?: CarModel[]; carModels?: CarModel[];
} }

View File

@ -1,14 +1,16 @@
import { Brand } from './Brand'; import { Brand } from './Brand';
import { CarRevision } from './CarRevision'; import { CarRevision } from './CarRevision';
import { ObjectId } from 'mongodb';
export interface CarModel { export interface CarModel {
id: string; _id?: ObjectId;
id?: string;
name: string; name: string;
brand: Brand;
productionStartYear: number; productionStartYear: number;
productionEndYear?: number; productionEndYear?: number;
category?: string; category?: string;
description?: string; description?: string;
image?: string; image?: string;
revisions?: CarRevision[]; revisions?: CarRevision[];
brandId?: ObjectId;
} }

View File

@ -1,4 +1,5 @@
import { CarModel } from './CarModel'; import { CarModel } from './CarModel';
import { ObjectId } from 'mongodb';
export interface Dimensions { export interface Dimensions {
length: number; length: number;
@ -8,9 +9,9 @@ export interface Dimensions {
} }
export interface CarRevision { export interface CarRevision {
id: string; _id?: ObjectId;
id?: string;
name: string; name: string;
baseModel: CarModel;
releaseYear: number; releaseYear: number;
engineTypes: string[]; engineTypes: string[];
horsePower: number; horsePower: number;
@ -22,4 +23,5 @@ export interface CarRevision {
weight: number; weight: number;
features: string[]; features: string[];
images: string[]; images: string[];
modelId?: ObjectId;
} }

View File

@ -1,512 +1,540 @@
import { Brand, CarModel, CarRevision } from '../models'; import { Brand, CarModel, CarRevision } from '../models';
import { MongoClient, Db, Collection, ObjectId } from 'mongodb';
// Dummy brands
const brands: Brand[] = [
{
id: '1',
name: 'Tesla',
logo: 'https://example.com/tesla-logo.png',
description: 'American electric vehicle and clean energy company',
foundedYear: 2003,
headquarters: 'Palo Alto, California, United States',
website: 'https://www.tesla.com',
},
{
id: '2',
name: 'BMW',
logo: 'https://example.com/bmw-logo.png',
description: 'German luxury automobile and motorcycle manufacturer',
foundedYear: 1916,
headquarters: 'Munich, Germany',
website: 'https://www.bmw.com',
},
{
id: '3',
name: 'Toyota',
logo: 'https://example.com/toyota-logo.png',
description: 'Japanese multinational automotive manufacturer',
foundedYear: 1937,
headquarters: 'Toyota City, Japan',
website: 'https://www.toyota.com',
},
{
id: '4',
name: 'Audi',
logo: 'https://example.com/audi-logo.png',
description: 'German luxury automobile manufacturer',
foundedYear: 1909,
headquarters: 'Ingolstadt, Germany',
website: 'https://www.audi.com',
},
{
id: '5',
name: 'Mercedes-Benz',
logo: 'https://example.com/mercedes-logo.png',
description: 'German global automobile marque and a division of Daimler AG',
foundedYear: 1926,
headquarters: 'Stuttgart, Germany',
website: 'https://www.mercedes-benz.com',
},
];
// Dummy car models (without revisions yet)
const carModels: CarModel[] = [
{
id: '1',
name: 'Model S',
brand: brands[0], // Tesla
productionStartYear: 2012,
category: 'Sedan',
description: 'All-electric five-door liftback sedan',
image: 'https://example.com/tesla-model-s.jpg',
},
{
id: '2',
name: 'Model 3',
brand: brands[0], // Tesla
productionStartYear: 2017,
category: 'Sedan',
description: 'All-electric four-door sedan',
image: 'https://example.com/tesla-model-3.jpg',
},
{
id: '3',
name: '3 Series',
brand: brands[1], // BMW
productionStartYear: 1975,
category: 'Sedan',
description: 'Compact executive car',
image: 'https://example.com/bmw-3-series.jpg',
},
{
id: '4',
name: 'X5',
brand: brands[1], // BMW
productionStartYear: 1999,
category: 'SUV',
description: 'Mid-size luxury SUV',
image: 'https://example.com/bmw-x5.jpg',
},
{
id: '5',
name: 'Camry',
brand: brands[2], // Toyota
productionStartYear: 1982,
category: 'Sedan',
description: 'Mid-size car',
image: 'https://example.com/toyota-camry.jpg',
},
{
id: '6',
name: 'Prius',
brand: brands[2], // Toyota
productionStartYear: 1997,
category: 'Hatchback',
description: 'Hybrid electric mid-size car',
image: 'https://example.com/toyota-prius.jpg',
},
{
id: '7',
name: 'A4',
brand: brands[3], // Audi
productionStartYear: 1994,
category: 'Sedan',
description: 'Compact executive car',
image: 'https://example.com/audi-a4.jpg',
},
{
id: '8',
name: 'Q7',
brand: brands[3], // Audi
productionStartYear: 2005,
category: 'SUV',
description: 'Full-size luxury crossover SUV',
image: 'https://example.com/audi-q7.jpg',
},
{
id: '9',
name: 'C-Class',
brand: brands[4], // Mercedes-Benz
productionStartYear: 1993,
category: 'Sedan',
description: 'Compact executive car',
image: 'https://example.com/mercedes-c-class.jpg',
},
{
id: '10',
name: 'GLE',
brand: brands[4], // Mercedes-Benz
productionStartYear: 2015,
category: 'SUV',
description: 'Mid-size luxury crossover SUV',
image: 'https://example.com/mercedes-gle.jpg',
},
];
// Dummy car revisions
const carRevisions: CarRevision[] = [
{
id: '1',
name: 'Model S Long Range Plus',
baseModel: carModels[0], // Tesla Model S
releaseYear: 2020,
engineTypes: ['Electric'],
horsePower: 670,
torque: 850,
topSpeed: 250,
acceleration0To100: 3.1,
fuelConsumption: 0,
dimensions: {
length: 4970,
width: 1964,
height: 1445,
wheelbase: 2960,
},
weight: 2250,
features: ['Autopilot', 'Premium Interior', 'All-Wheel Drive'],
images: ['https://example.com/tesla-model-s-2020-1.jpg', 'https://example.com/tesla-model-s-2020-2.jpg'],
},
{
id: '2',
name: 'Model S Plaid',
baseModel: carModels[0], // Tesla Model S
releaseYear: 2021,
engineTypes: ['Electric'],
horsePower: 1020,
torque: 1050,
topSpeed: 322,
acceleration0To100: 2.1,
fuelConsumption: 0,
dimensions: {
length: 4970,
width: 1964,
height: 1445,
wheelbase: 2960,
},
weight: 2300,
features: ['Enhanced Autopilot', 'Yoke Steering', 'All-Wheel Drive', 'New Interior Design'],
images: ['https://example.com/tesla-model-s-plaid-1.jpg', 'https://example.com/tesla-model-s-plaid-2.jpg'],
},
{
id: '3',
name: 'Model 3 Standard Range Plus',
baseModel: carModels[1], // Tesla Model 3
releaseYear: 2021,
engineTypes: ['Electric'],
horsePower: 283,
torque: 450,
topSpeed: 225,
acceleration0To100: 5.6,
fuelConsumption: 0,
dimensions: {
length: 4694,
width: 1849,
height: 1443,
wheelbase: 2875,
},
weight: 1750,
features: ['Basic Autopilot', 'Standard Interior', 'Rear-Wheel Drive'],
images: ['https://example.com/tesla-model-3-standard-1.jpg', 'https://example.com/tesla-model-3-standard-2.jpg'],
},
{
id: '4',
name: '330i Sedan',
baseModel: carModels[2], // BMW 3 Series
releaseYear: 2021,
engineTypes: ['Gasoline'],
horsePower: 255,
torque: 400,
topSpeed: 209,
acceleration0To100: 5.6,
fuelConsumption: 7.1,
dimensions: {
length: 4709,
width: 1827,
height: 1435,
wheelbase: 2851,
},
weight: 1620,
features: ['LED Headlights', 'iDrive Infotainment System', 'Leather Seats'],
images: ['https://example.com/bmw-330i-1.jpg', 'https://example.com/bmw-330i-2.jpg'],
},
{
id: '5',
name: 'M340i Sedan',
baseModel: carModels[2], // BMW 3 Series
releaseYear: 2021,
engineTypes: ['Gasoline'],
horsePower: 382,
torque: 500,
topSpeed: 250,
acceleration0To100: 4.4,
fuelConsumption: 8.0,
dimensions: {
length: 4709,
width: 1827,
height: 1435,
wheelbase: 2851,
},
weight: 1670,
features: ['M Sport Differential', 'M Sport Brakes', 'Adaptive M Suspension'],
images: ['https://example.com/bmw-m340i-1.jpg', 'https://example.com/bmw-m340i-2.jpg'],
},
{
id: '6',
name: 'X5 xDrive40i',
baseModel: carModels[3], // BMW X5
releaseYear: 2021,
engineTypes: ['Gasoline'],
horsePower: 335,
torque: 450,
topSpeed: 243,
acceleration0To100: 5.5,
fuelConsumption: 9.2,
dimensions: {
length: 4922,
width: 2004,
height: 1745,
wheelbase: 2975,
},
weight: 2260,
features: ['Panoramic Roof', 'Head-Up Display', 'Gesture Control'],
images: ['https://example.com/bmw-x5-xdrive40i-1.jpg', 'https://example.com/bmw-x5-xdrive40i-2.jpg'],
},
{
id: '7',
name: 'Camry LE',
baseModel: carModels[4], // Toyota Camry
releaseYear: 2021,
engineTypes: ['Gasoline'],
horsePower: 203,
torque: 250,
topSpeed: 210,
acceleration0To100: 8.1,
fuelConsumption: 7.6,
dimensions: {
length: 4880,
width: 1840,
height: 1445,
wheelbase: 2825,
},
weight: 1580,
features: ['Toyota Safety Sense', 'Apple CarPlay', 'Android Auto'],
images: ['https://example.com/toyota-camry-le-1.jpg', 'https://example.com/toyota-camry-le-2.jpg'],
},
{
id: '8',
name: 'Camry Hybrid',
baseModel: carModels[4], // Toyota Camry
releaseYear: 2021,
engineTypes: ['Hybrid'],
horsePower: 208,
torque: 220,
topSpeed: 180,
acceleration0To100: 7.8,
fuelConsumption: 4.2,
dimensions: {
length: 4880,
width: 1840,
height: 1445,
wheelbase: 2825,
},
weight: 1680,
features: ['Regenerative Braking', 'EV Mode', 'Energy Monitor'],
images: ['https://example.com/toyota-camry-hybrid-1.jpg', 'https://example.com/toyota-camry-hybrid-2.jpg'],
},
{
id: '9',
name: 'Prius Prime',
baseModel: carModels[5], // Toyota Prius
releaseYear: 2021,
engineTypes: ['Plug-in Hybrid'],
horsePower: 121,
torque: 142,
topSpeed: 165,
acceleration0To100: 10.5,
fuelConsumption: 1.8,
dimensions: {
length: 4645,
width: 1760,
height: 1470,
wheelbase: 2700,
},
weight: 1530,
features: ['Electric Range of 40 km', 'Touch-sensitive controls', 'Quad-LED projector headlights'],
images: ['https://example.com/toyota-prius-prime-1.jpg', 'https://example.com/toyota-prius-prime-2.jpg'],
},
{
id: '10',
name: 'A4 Prestige',
baseModel: carModels[6], // Audi A4
releaseYear: 2021,
engineTypes: ['Gasoline'],
horsePower: 261,
torque: 370,
topSpeed: 210,
acceleration0To100: 5.5,
fuelConsumption: 7.5,
dimensions: {
length: 4762,
width: 1847,
height: 1435,
wheelbase: 2820,
},
weight: 1640,
features: ['Audi Virtual Cockpit', 'Bang & Olufsen Sound System', 'Adaptive Cruise Control'],
images: ['https://example.com/audi-a4-prestige-1.jpg', 'https://example.com/audi-a4-prestige-2.jpg'],
},
];
// Connect revisions to models
const connectRevisionsToModels = () => {
// Tesla Model S revisions
carModels[0].revisions = [carRevisions[0], carRevisions[1]];
// Tesla Model 3 revisions
carModels[1].revisions = [carRevisions[2]];
// BMW 3 Series revisions
carModels[2].revisions = [carRevisions[3], carRevisions[4]];
// BMW X5 revisions
carModels[3].revisions = [carRevisions[5]];
// Toyota Camry revisions
carModels[4].revisions = [carRevisions[6], carRevisions[7]];
// Toyota Prius revisions
carModels[5].revisions = [carRevisions[8]];
// Audi A4 revisions
carModels[6].revisions = [carRevisions[9]];
};
// Connect car models to brands
const connectModelsToBrands = () => {
// Tesla models
brands[0].carModels = [carModels[0], carModels[1]];
// BMW models
brands[1].carModels = [carModels[2], carModels[3]];
// Toyota models
brands[2].carModels = [carModels[4], carModels[5]];
// Audi models
brands[3].carModels = [carModels[6], carModels[7]];
// Mercedes models
brands[4].carModels = [carModels[8], carModels[9]];
};
// Initialize connections
connectRevisionsToModels();
connectModelsToBrands();
// Car Repository Service
export class CarRepository { export class CarRepository {
private client: MongoClient;
private db: Db | null = null;
private brandsCollection: Collection<Brand> | null = null;
private carModelsCollection: Collection<CarModel> | null = null;
private carRevisionsCollection: Collection<CarRevision> | null = null;
constructor(private mongoUrl: string = process.env.MONGODB_URI || 'mongodb://localhost:27017/evwiki') {
this.client = new MongoClient(this.mongoUrl);
}
/**
* Initialize the database connection
*/
async connect(): Promise<void> {
if (!this.db) {
await this.client.connect();
this.db = this.client.db();
this.brandsCollection = this.db.collection<Brand>('brands');
this.carModelsCollection = this.db.collection<CarModel>('carModels');
this.carRevisionsCollection = this.db.collection<CarRevision>('carRevisions');
// Check if data exists, if not initialize with default data
const brandsCount = await this.brandsCollection.countDocuments();
if (brandsCount === 0) {
await this.initializeDefaultData();
}
}
}
/**
* Close the database connection
*/
async disconnect(): Promise<void> {
if (this.client) {
await this.client.close();
this.db = null;
this.brandsCollection = null;
this.carModelsCollection = null;
this.carRevisionsCollection = null;
}
}
/**
* Initialize the database with default data if empty
*/
private async initializeDefaultData(): Promise<void> {
if (!this.db || !this.brandsCollection || !this.carModelsCollection || !this.carRevisionsCollection) {
throw new Error('Database not initialized');
}
// Insert default brands
await this.brandsCollection.insertMany([
{
name: 'Tesla',
logo: 'https://example.com/tesla-logo.png',
description: 'American electric vehicle and clean energy company',
foundedYear: 2003,
headquarters: 'Palo Alto, California, United States',
website: 'https://www.tesla.com',
},
{
name: 'BMW',
logo: 'https://example.com/bmw-logo.png',
description: 'German luxury automobile and motorcycle manufacturer',
foundedYear: 1916,
headquarters: 'Munich, Germany',
website: 'https://www.bmw.com',
},
{
name: 'Toyota',
logo: 'https://example.com/toyota-logo.png',
description: 'Japanese multinational automotive manufacturer',
foundedYear: 1937,
headquarters: 'Toyota City, Japan',
website: 'https://www.toyota.com',
},
{
name: 'Audi',
logo: 'https://example.com/audi-logo.png',
description: 'German luxury automobile manufacturer',
foundedYear: 1909,
headquarters: 'Ingolstadt, Germany',
website: 'https://www.audi.com',
},
{
name: 'Mercedes-Benz',
logo: 'https://example.com/mercedes-logo.png',
description: 'German global automobile marque and a division of Daimler AG',
foundedYear: 1926,
headquarters: 'Stuttgart, Germany',
website: 'https://www.mercedes-benz.com',
},
]);
// Get the inserted brands to connect them with models later
const brands = await this.brandsCollection.find().toArray();
// Insert default car models
await this.carModelsCollection.insertMany([
{
name: 'Model S',
productionStartYear: 2012,
category: 'Sedan',
description: 'All-electric five-door liftback sedan',
image: 'https://example.com/tesla-model-s.jpg',
brandId: brands[0]._id,
},
{
name: 'Model 3',
productionStartYear: 2017,
category: 'Sedan',
description: 'All-electric four-door sedan',
image: 'https://example.com/tesla-model-3.jpg',
brandId: brands[0]._id,
},
{
name: '3 Series',
productionStartYear: 1975,
category: 'Sedan',
description: 'Compact executive car',
image: 'https://example.com/bmw-3-series.jpg',
brandId: brands[1]._id,
},
{
name: 'X5',
productionStartYear: 1999,
category: 'SUV',
description: 'Mid-size luxury SUV',
image: 'https://example.com/bmw-x5.jpg',
brandId: brands[1]._id,
},
{
name: 'Camry',
productionStartYear: 1982,
category: 'Sedan',
description: 'Mid-size car',
image: 'https://example.com/toyota-camry.jpg',
brandId: brands[2]._id,
},
{
name: 'Prius',
productionStartYear: 1997,
category: 'Hatchback',
description: 'Hybrid electric mid-size car',
image: 'https://example.com/toyota-prius.jpg',
brandId: brands[2]._id,
},
{
name: 'A4',
productionStartYear: 1994,
category: 'Sedan',
description: 'Compact executive car',
image: 'https://example.com/audi-a4.jpg',
brandId: brands[3]._id,
},
{
name: 'Q7',
productionStartYear: 2005,
category: 'SUV',
description: 'Full-size luxury crossover SUV',
image: 'https://example.com/audi-q7.jpg',
brandId: brands[3]._id,
},
{
name: 'C-Class',
productionStartYear: 1993,
category: 'Sedan',
description: 'Compact executive car',
image: 'https://example.com/mercedes-c-class.jpg',
brandId: brands[4]._id,
},
{
name: 'GLE',
productionStartYear: 2015,
category: 'SUV',
description: 'Mid-size luxury crossover SUV',
image: 'https://example.com/mercedes-gle.jpg',
brandId: brands[4]._id,
},
]);
// Get the inserted models to connect them with revisions
const models = await this.carModelsCollection.find().toArray();
// Find models by name for easy reference
const findModelByName = (name: string) => models.find(model => model.name === name);
// Insert default car revisions
await this.carRevisionsCollection.insertMany([
{
name: 'Model S Long Range Plus',
releaseYear: 2020,
engineTypes: ['Electric'],
horsePower: 670,
torque: 850,
topSpeed: 250,
acceleration0To100: 3.1,
fuelConsumption: 0,
dimensions: {
length: 4970,
width: 1964,
height: 1445,
wheelbase: 2960,
},
weight: 2250,
features: ['Autopilot', 'Premium Interior', 'All-Wheel Drive'],
images: ['https://example.com/tesla-model-s-2020-1.jpg', 'https://example.com/tesla-model-s-2020-2.jpg'],
modelId: findModelByName('Model S')?._id,
},
{
name: 'Model S Plaid',
releaseYear: 2021,
engineTypes: ['Electric'],
horsePower: 1020,
torque: 1050,
topSpeed: 322,
acceleration0To100: 2.1,
fuelConsumption: 0,
dimensions: {
length: 4970,
width: 1964,
height: 1445,
wheelbase: 2960,
},
weight: 2300,
features: ['Enhanced Autopilot', 'Yoke Steering', 'All-Wheel Drive', 'New Interior Design'],
images: ['https://example.com/tesla-model-s-plaid-1.jpg', 'https://example.com/tesla-model-s-plaid-2.jpg'],
modelId: findModelByName('Model S')?._id,
},
{
name: 'Model 3 Standard Range Plus',
releaseYear: 2021,
engineTypes: ['Electric'],
horsePower: 283,
torque: 450,
topSpeed: 225,
acceleration0To100: 5.6,
fuelConsumption: 0,
dimensions: {
length: 4694,
width: 1849,
height: 1443,
wheelbase: 2875,
},
weight: 1750,
features: ['Basic Autopilot', 'Standard Interior', 'Rear-Wheel Drive'],
images: ['https://example.com/tesla-model-3-standard-1.jpg', 'https://example.com/tesla-model-3-standard-2.jpg'],
modelId: findModelByName('Model 3')?._id,
},
{
name: '330i Sedan',
releaseYear: 2021,
engineTypes: ['Gasoline'],
horsePower: 255,
torque: 400,
topSpeed: 209,
acceleration0To100: 5.6,
fuelConsumption: 7.1,
dimensions: {
length: 4709,
width: 1827,
height: 1435,
wheelbase: 2851,
},
weight: 1620,
features: ['LED Headlights', 'iDrive Infotainment System', 'Leather Seats'],
images: ['https://example.com/bmw-330i-1.jpg', 'https://example.com/bmw-330i-2.jpg'],
modelId: findModelByName('3 Series')?._id,
},
{
name: 'M340i Sedan',
releaseYear: 2021,
engineTypes: ['Gasoline'],
horsePower: 382,
torque: 500,
topSpeed: 250,
acceleration0To100: 4.4,
fuelConsumption: 8.0,
dimensions: {
length: 4709,
width: 1827,
height: 1435,
wheelbase: 2851,
},
weight: 1670,
features: ['M Sport Differential', 'M Sport Brakes', 'Adaptive M Suspension'],
images: ['https://example.com/bmw-m340i-1.jpg', 'https://example.com/bmw-m340i-2.jpg'],
modelId: findModelByName('3 Series')?._id,
},
{
name: 'X5 xDrive40i',
releaseYear: 2021,
engineTypes: ['Gasoline'],
horsePower: 335,
torque: 450,
topSpeed: 243,
acceleration0To100: 5.5,
fuelConsumption: 9.2,
dimensions: {
length: 4922,
width: 2004,
height: 1745,
wheelbase: 2975,
},
weight: 2260,
features: ['Panoramic Roof', 'Head-Up Display', 'Gesture Control'],
images: ['https://example.com/bmw-x5-xdrive40i-1.jpg', 'https://example.com/bmw-x5-xdrive40i-2.jpg'],
modelId: findModelByName('X5')?._id,
},
{
name: 'Camry LE',
releaseYear: 2021,
engineTypes: ['Gasoline'],
horsePower: 203,
torque: 250,
topSpeed: 210,
acceleration0To100: 8.1,
fuelConsumption: 7.6,
dimensions: {
length: 4880,
width: 1840,
height: 1445,
wheelbase: 2825,
},
weight: 1580,
features: ['Toyota Safety Sense', 'Apple CarPlay', 'Android Auto'],
images: ['https://example.com/toyota-camry-le-1.jpg', 'https://example.com/toyota-camry-le-2.jpg'],
modelId: findModelByName('Camry')?._id,
},
{
name: 'Camry Hybrid',
releaseYear: 2021,
engineTypes: ['Hybrid'],
horsePower: 208,
torque: 220,
topSpeed: 180,
acceleration0To100: 7.8,
fuelConsumption: 4.2,
dimensions: {
length: 4880,
width: 1840,
height: 1445,
wheelbase: 2825,
},
weight: 1680,
features: ['Regenerative Braking', 'EV Mode', 'Energy Monitor'],
images: ['https://example.com/toyota-camry-hybrid-1.jpg', 'https://example.com/toyota-camry-hybrid-2.jpg'],
modelId: findModelByName('Camry')?._id,
},
{
name: 'Prius Prime',
releaseYear: 2021,
engineTypes: ['Plug-in Hybrid'],
horsePower: 121,
torque: 142,
topSpeed: 165,
acceleration0To100: 10.5,
fuelConsumption: 1.8,
dimensions: {
length: 4645,
width: 1760,
height: 1470,
wheelbase: 2700,
},
weight: 1530,
features: ['Electric Range of 40 km', 'Touch-sensitive controls', 'Quad-LED projector headlights'],
images: ['https://example.com/toyota-prius-prime-1.jpg', 'https://example.com/toyota-prius-prime-2.jpg'],
modelId: findModelByName('Prius')?._id,
},
{
name: 'A4 Prestige',
releaseYear: 2021,
engineTypes: ['Gasoline'],
horsePower: 261,
torque: 370,
topSpeed: 210,
acceleration0To100: 5.5,
fuelConsumption: 7.5,
dimensions: {
length: 4762,
width: 1847,
height: 1435,
wheelbase: 2820,
},
weight: 1640,
features: ['Audi Virtual Cockpit', 'Bang & Olufsen Sound System', 'Adaptive Cruise Control'],
images: ['https://example.com/audi-a4-prestige-1.jpg', 'https://example.com/audi-a4-prestige-2.jpg'],
modelId: findModelByName('A4')?._id,
},
]);
}
/** /**
* Get all brands * Get all brands
*/ */
getAllBrands(): Promise<Brand[]> { async getAllBrands(): Promise<Brand[]> {
return Promise.resolve(brands); await this.connect();
if (!this.brandsCollection) throw new Error('Database not initialized');
return this.brandsCollection.find().toArray();
} }
/** /**
* Get brand by ID * Get brand by ID
*/ */
getBrandById(id: string): Promise<Brand | null> { async getBrandById(id: string): Promise<Brand | null> {
const brand = brands.find(brand => brand.id === id); await this.connect();
return Promise.resolve(brand || null); if (!this.brandsCollection) throw new Error('Database not initialized');
return this.brandsCollection.findOne({ _id: new ObjectId(id) });
} }
/** /**
* Get all car models * Get all car models
*/ */
getAllCarModels(): Promise<CarModel[]> { async getAllCarModels(): Promise<CarModel[]> {
return Promise.resolve(carModels); await this.connect();
if (!this.carModelsCollection) throw new Error('Database not initialized');
return this.carModelsCollection.find().toArray();
} }
/** /**
* Get car models by brand ID * Get car models by brand ID
*/ */
getCarModelsByBrandId(brandId: string): Promise<CarModel[]> { async getCarModelsByBrandId(brandId: string): Promise<CarModel[]> {
const brand = brands.find(brand => brand.id === brandId); await this.connect();
return Promise.resolve(brand?.carModels || []); if (!this.carModelsCollection) throw new Error('Database not initialized');
return this.carModelsCollection.find({ brandId: new ObjectId(brandId) }).toArray();
} }
/** /**
* Get car model by ID * Get car model by ID
*/ */
getCarModelById(id: string): Promise<CarModel | null> { async getCarModelById(id: string): Promise<CarModel | null> {
const carModel = carModels.find(model => model.id === id); await this.connect();
return Promise.resolve(carModel || null); if (!this.carModelsCollection) throw new Error('Database not initialized');
return this.carModelsCollection.findOne({ _id: new ObjectId(id) });
} }
/** /**
* Get all car revisions * Get all car revisions
*/ */
getAllCarRevisions(): Promise<CarRevision[]> { async getAllCarRevisions(): Promise<CarRevision[]> {
return Promise.resolve(carRevisions); await this.connect();
if (!this.carRevisionsCollection) throw new Error('Database not initialized');
return this.carRevisionsCollection.find().toArray();
} }
/** /**
* Get car revisions by model ID * Get car revisions by model ID
*/ */
getCarRevisionsByModelId(modelId: string): Promise<CarRevision[]> { async getCarRevisionsByModelId(modelId: string): Promise<CarRevision[]> {
const carModel = carModels.find(model => model.id === modelId); await this.connect();
return Promise.resolve(carModel?.revisions || []); if (!this.carRevisionsCollection) throw new Error('Database not initialized');
return this.carRevisionsCollection.find({ modelId: new ObjectId(modelId) }).toArray();
} }
/** /**
* Get car revision by ID * Get car revision by ID
*/ */
getCarRevisionById(id: string): Promise<CarRevision | null> { async getCarRevisionById(id: string): Promise<CarRevision | null> {
const carRevision = carRevisions.find(revision => revision.id === id); await this.connect();
return Promise.resolve(carRevision || null); if (!this.carRevisionsCollection) throw new Error('Database not initialized');
return this.carRevisionsCollection.findOne({ _id: new ObjectId(id) });
} }
/** /**
* Search cars by name (searches both models and revisions) * Search cars by name (searches both models and revisions)
*/ */
searchCarsByName(name: string): Promise<{ models: CarModel[], revisions: CarRevision[] }> { async searchCarsByName(name: string): Promise<{ models: CarModel[], revisions: CarRevision[], brands: Brand[] }> {
await this.connect();
if (!this.brandsCollection || !this.carModelsCollection || !this.carRevisionsCollection) {
throw new Error('Database not initialized');
}
const lowercaseName = name.toLowerCase(); const lowercaseName = name.toLowerCase();
const matchingModels = carModels.filter(model => const matchingModels = await this.carModelsCollection.find({
model.name.toLowerCase().includes(lowercaseName) name: { $regex: new RegExp(lowercaseName, 'i') }
); }).toArray();
const matchingRevisions = carRevisions.filter(revision => const matchingRevisions = await this.carRevisionsCollection.find({
revision.name.toLowerCase().includes(lowercaseName) name: { $regex: new RegExp(lowercaseName, 'i') }
); }).toArray();
return Promise.resolve({ const matchingBrands = await this.brandsCollection.find({
name: { $regex: new RegExp(lowercaseName, 'i') }
}).toArray();
return {
models: matchingModels, models: matchingModels,
revisions: matchingRevisions revisions: matchingRevisions,
}); brands: matchingBrands
};
} }
/** /**
* Get cars by category * Get cars by category
*/ */
getCarsByCategory(category: string): Promise<CarModel[]> { async getCarsByCategory(category: string): Promise<CarModel[]> {
const matchingModels = carModels.filter(model => await this.connect();
model.category?.toLowerCase() === category.toLowerCase() if (!this.carModelsCollection) throw new Error('Database not initialized');
);
return Promise.resolve(matchingModels); return this.carModelsCollection.find({
category: { $regex: new RegExp(`^${category}$`, 'i') }
}).toArray();
} }
/** /**
* Get cars by year range * Get cars by year range
*/ */
getCarsByYearRange(startYear: number, endYear: number): Promise<CarModel[]> { async getCarsByYearRange(startYear: number, endYear: number): Promise<CarModel[]> {
const matchingModels = carModels.filter(model => { await this.connect();
const startYearMatch = !model.productionStartYear || model.productionStartYear <= endYear; if (!this.carModelsCollection) throw new Error('Database not initialized');
const endYearMatch = !model.productionEndYear || model.productionEndYear >= startYear;
return startYearMatch && endYearMatch;
});
return Promise.resolve(matchingModels); return this.carModelsCollection.find({
$or: [
{ productionStartYear: { $lte: endYear, $gte: startYear } },
{
productionStartYear: { $lte: endYear },
productionEndYear: { $gte: startYear }
},
{
productionStartYear: { $lte: endYear },
productionEndYear: { $exists: false }
}
]
}).toArray();
} }
} }