feat: add allowed Linear API operations to MCP config
This commit is contained in:
+11
-1
@@ -25,7 +25,17 @@
|
||||
},
|
||||
"linear": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "https://mcp.linear.app/sse"]
|
||||
"args": [
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
"https://mcp.linear.app/sse"
|
||||
],
|
||||
"alwaysAllow": [
|
||||
"list_issues",
|
||||
"get_issue",
|
||||
"update_issue",
|
||||
"create_issue"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
# mozdIT Weboldal - Fejlesztési TODO Lista
|
||||
|
||||
## Projekt Áttekintés
|
||||
Next.js 14 alapú weboldal a mozdIT Bt. számára, Dokploy-on hostolva.
|
||||
|
||||
## ⚠️ Projekt Management
|
||||
**Elsődleges forrás**: Linear (ZEE-28, ZEE-29, ZEE-30, stb.)
|
||||
**Lokális másolat**: Ez a fájl csak referencia, a Linear az authoritative source
|
||||
|
||||
## ✅ Befejezett (Linear szerint)
|
||||
| Linear Ticket | Feladat | Státusz |
|
||||
|---------------|---------|---------|
|
||||
| ZEE-28 | Repo & Next.js bootstrap - befejezett, dev szerver fut localhost:3000-n | ✅ |
|
||||
| ZEE-29 | Tailwind + alap layout | ✅ |
|
||||
| ZEE-30 | Kezdőlap (Hero + USP + Webmail CTA) | ✅ |
|
||||
| - | Unit teszt infrastruktúra beállítása (Jest + React Testing Library) | ✅ |
|
||||
| - | API endpoint tesztek írása (/api/health) | ✅ |
|
||||
| - | Component tesztek írása (Header, Footer) | ✅ |
|
||||
| - | Teszt hibák javítása (Jest setup, duplicate elements, type casting) | ✅ |
|
||||
| ZEE-34 | Basic API endpoints (/api/health) hozzáadása | ✅ |
|
||||
|
||||
## 🔄 Folyamatban (Linear szerint)
|
||||
| Linear Ticket | Feladat | Státusz |
|
||||
|---------------|---------|---------|
|
||||
| ZEE-35 | Dokploy staging environment beállítása | 🔄 |
|
||||
| TBC-1 | Site config migrálás MongoDB-ba | 🔄 |
|
||||
| TBC-2 | Winston logger + Loki integráció | 🔄 |
|
||||
| TBC-3 | Logging middleware megvalósítása | 🔄 |
|
||||
| TBC-4 | Grafana dashboard konfiguráció | 🔄 |
|
||||
|
||||
## 📋 Tervezett
|
||||
| Linear Ticket | Feladat | Státusz |
|
||||
|---------------|---------|---------|
|
||||
| ZEE-33 | Kapcsolat űrlap + API stub | 📋 |
|
||||
| ZEE-32 | Szolgáltatások oldal | 📋 |
|
||||
| ZEE-31 | Rólunk oldal | 📋 |
|
||||
| TBC-5 | SEO optimalizálás | 📋 |
|
||||
| TBC-6 | Performance optimalizálás | 📋 |
|
||||
| TBC-7 | Reszponzív design finomítása | 📋 |
|
||||
| TBC-8 | Analytics integráció | 📋 |
|
||||
|
||||
## Technikai Stack
|
||||
- **Frontend**: Next.js 14, TypeScript, Tailwind CSS
|
||||
- **Backend**: Next.js API Routes
|
||||
- **Database**: MongoDB
|
||||
- **Logging**: Winston + Loki
|
||||
- **Monitoring**: Grafana
|
||||
- **Deployment**: Dokploy
|
||||
- **Testing**: Jest, React Testing Library
|
||||
- **Project Management**: Linear (elsődleges)
|
||||
|
||||
## Fejlesztési Parancsok
|
||||
```bash
|
||||
# Fejlesztői szerver indítása
|
||||
cd proto && npm run dev
|
||||
|
||||
# Tesztek futtatása
|
||||
cd proto && npm test
|
||||
|
||||
# Build készítése
|
||||
cd proto && npm run build
|
||||
```
|
||||
|
||||
## Fontos Megjegyzés
|
||||
A Linear az authoritative project management rendszer. Ez a fájl csak lokális referencia, mindig ellenőrizd a Linear-t az aktuális státuszért és priorításokért.
|
||||
|
||||
## Frissítési Napló
|
||||
- **2025-09-05**: TODO.md fájl létrehozása, Linear integráció megjegyzésekkel
|
||||
## Szinkronizálás Utmutató
|
||||
|
||||
A TODO.md és Linear között párhuzamos vezetéshez használd a `linear-sync.js` scriptet:
|
||||
|
||||
```bash
|
||||
# API kulcs hozzáadása a .env fájlhoz (Linear settings -> API)
|
||||
# Szerkesd a .env fájlt és add hozzá:
|
||||
# LINEAR_API_KEY=lin_api_your_actual_key_here
|
||||
|
||||
# Szinkronizálás futtatása
|
||||
node linear-sync.js
|
||||
|
||||
# vagy
|
||||
./linear-sync.js
|
||||
```
|
||||
|
||||
A script:
|
||||
- Megkeresi a TODO.md-ben hiányzó Linear ticket-eket
|
||||
- Létrehozza ezeket a Linear-ben
|
||||
- Frissíti a Linear ticket státuszokat a TODO alapján
|
||||
- Visszairja a Linear ticket számokat a TODO.md-be
|
||||
|
||||
### Hiányzó ticketek a jelenlegi TODO.md-ben:
|
||||
- TBC-1: Site config migrálás MongoDB-ba
|
||||
- TBC-2: Winston logger + Loki integráció
|
||||
- TBC-3: Logging middleware megvalósítása
|
||||
- TBC-4: Grafana dashboard konfiguráció
|
||||
- TBC-5: SEO optimalizálás
|
||||
- TBC-6: Performance optimalizálás
|
||||
- TBC-7: Reszponzív design finomítása
|
||||
- TBC-8: Analytics integráció
|
||||
|
||||
Script futtatás után ezek Linear ticket számmal lesznek helyettesítve.
|
||||
Executable
+473
@@ -0,0 +1,473 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Linear-TODO Sync Script
|
||||
*
|
||||
* Synchronizes TODO.md with Linear issues for the mozdIT website project
|
||||
* Run this script to:
|
||||
* - Create Linear tickets for TODO tasks without tickets
|
||||
* - Update Linear ticket statuses based on TODO statuses
|
||||
* - Update TODO statuses based on Linear ticket statuses
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Load environment variables from .env file
|
||||
function loadDotEnv() {
|
||||
try {
|
||||
const envPath = path.join(__dirname, '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||
const lines = envContent.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith('#')) {
|
||||
const [key, ...valueParts] = trimmed.split('=');
|
||||
if (key && valueParts.length > 0) {
|
||||
const value = valueParts.join('=').trim();
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('✅ .env fájl betöltve');
|
||||
} else {
|
||||
console.log('⚠️ .env fájl hiányzik - szükség lesz rá a LINEAR_API_KEY-hez');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ .env fájl betöltési hiba:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Command line argument parsing
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
dryRun: false,
|
||||
verbose: false
|
||||
};
|
||||
|
||||
for (const arg of args) {
|
||||
switch (arg) {
|
||||
case '--dry-run':
|
||||
options.dryRun = true;
|
||||
break;
|
||||
case '--verbose':
|
||||
options.verbose = true;
|
||||
break;
|
||||
case '--help':
|
||||
console.log(`
|
||||
🔄 Linear-TODO Sync Tool
|
||||
|
||||
Használat:
|
||||
node linear-sync.js [opciók]
|
||||
|
||||
Opciók:
|
||||
--dry-run Teszt üzemmód - nem hajt végre valódi változtatásokat
|
||||
--verbose Részletes kimenet
|
||||
--help Ez a súgó
|
||||
|
||||
Példa:
|
||||
node linear-sync.js --dry-run --verbose
|
||||
`);
|
||||
process.exit(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
// Rate limiting helper
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Load .env on startup
|
||||
loadDotEnv();
|
||||
|
||||
// Configuration
|
||||
const LINEAR_API_ENDPOINT = 'https://linear.app/graphql';
|
||||
const TEAM_ID = 'cf285407-a26b-434c-bb99-19676385ef67'; // Zeener team
|
||||
const PROJECT_ID = '54559e3b-9005-4dfa-b7a9-1415cd4bc453'; // Website Development project
|
||||
|
||||
const TODO_FILE = process.env.TODO_FILE || 'TODO.md';
|
||||
|
||||
// Global options
|
||||
let OPTIONS = parseArgs();
|
||||
|
||||
/**
|
||||
* Linear GraphQL client
|
||||
*/
|
||||
class LinearClient {
|
||||
constructor(apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
this.lastRequestTime = 0;
|
||||
this.minDelay = 1000; // 1 second minimum delay between requests
|
||||
}
|
||||
|
||||
async request(query, variables = {}) {
|
||||
// Rate limiting
|
||||
const now = Date.now();
|
||||
const timeSinceLastRequest = now - this.lastRequestTime;
|
||||
if (timeSinceLastRequest < this.minDelay) {
|
||||
const delayTime = this.minDelay - timeSinceLastRequest;
|
||||
if (OPTIONS.verbose) {
|
||||
console.log(`⏳ Rate limiting: waiting ${delayTime}ms`);
|
||||
}
|
||||
await delay(delayTime);
|
||||
}
|
||||
this.lastRequestTime = Date.now();
|
||||
|
||||
if (OPTIONS.dryRun) {
|
||||
console.log(`🔍 DRY RUN: Would execute GraphQL query:`, query.substring(0, 100) + '...');
|
||||
return {}; // Mock response
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = JSON.stringify({ query, variables });
|
||||
|
||||
const options = {
|
||||
hostname: 'linear.app',
|
||||
path: '/graphql',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': this.apiKey,
|
||||
'Content-Length': Buffer.byteLength(data)
|
||||
}
|
||||
};
|
||||
|
||||
if (OPTIONS.verbose) {
|
||||
console.log(`🔗 API hívás: ${options.method} ${options.hostname}${options.path}`);
|
||||
console.log(`📝 Query: ${query.substring(0, 80)}...`);
|
||||
}
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = JSON.parse(body);
|
||||
if (response.errors) {
|
||||
console.error('🔍 GraphQL errors:', response.errors);
|
||||
reject(new Error(`GraphQL Error: ${response.errors.map(e => e.message).join(', ')}`));
|
||||
} else {
|
||||
if (OPTIONS.verbose) {
|
||||
console.log('✅ API válasz kapott');
|
||||
}
|
||||
resolve(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
// If it's HTML error page
|
||||
if (body.includes('<!doctype')) {
|
||||
const preview = body.slice(0, 200).replace(/<[^>]*>/g, ''); // Remove HTML tags
|
||||
console.log('🔍 HTML válasz előzetes:', preview + '...');
|
||||
|
||||
// Mask API key in error message
|
||||
const maskedKey = this.apiKey ?
|
||||
this.apiKey.replace(/(.{8}).*(.{4})/, '$1****$2') : '[NO_KEY]';
|
||||
|
||||
reject(new Error(`Linear API HTML válasz visszaadott JSON helyett. Ellenőrizd az API kulcsot (${maskedKey}) és endpoint-ot.`));
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.error('🔍 Hálózati hiba:', error.message);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async getIssues() {
|
||||
const query = `
|
||||
query GetIssues($teamId: ID!) {
|
||||
issues(filter: { team: { id: { eq: $teamId } } }) {
|
||||
nodes {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
description
|
||||
status
|
||||
project {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const result = await this.request(query, { teamId: TEAM_ID });
|
||||
return result.issues.nodes;
|
||||
}
|
||||
|
||||
async createIssue(title, description) {
|
||||
const query = `
|
||||
mutation CreateIssue($input: IssueCreateInput!) {
|
||||
issueCreate(input: $input) {
|
||||
issue {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const input = {
|
||||
title,
|
||||
description,
|
||||
teamId: TEAM_ID,
|
||||
projectId: PROJECT_ID
|
||||
};
|
||||
const result = await this.request(query, { input });
|
||||
return result.issueCreate.issue;
|
||||
}
|
||||
|
||||
async updateIssueStatus(issueId, status) {
|
||||
const query = `
|
||||
mutation UpdateIssueStatus($input: IssueUpdateInput!) {
|
||||
issueUpdate(input: $input) {
|
||||
issue {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const input = {
|
||||
id: issueId,
|
||||
status
|
||||
};
|
||||
const result = await this.request(query, { input });
|
||||
return result.issueUpdate.issue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse TODO.md content
|
||||
*/
|
||||
function parseTodoFile(content) {
|
||||
const lines = content.split('\n');
|
||||
const todos = { completed: [], inProgress: [], planned: [] };
|
||||
|
||||
let currentSection = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
if (line.startsWith('## ✅ Befejezett')) {
|
||||
currentSection = 'completed';
|
||||
} else if (line.startsWith('## 🔄 Folyamatban')) {
|
||||
currentSection = 'inProgress';
|
||||
} else if (line.startsWith('## 📋 Tervezett')) {
|
||||
currentSection = 'planned';
|
||||
} else if (currentSection && line.includes('|')) {
|
||||
// Parse table rows
|
||||
const parts = line.split('|').map(p => p.trim()).filter(p => p);
|
||||
if (parts.length >= 3 && parts[0] !== 'Linear Ticket') {
|
||||
const ticket = parts[0] === '-' || parts[0] === 'TBD' ? null : parts[0];
|
||||
const task = parts[1];
|
||||
const status = parts[2];
|
||||
|
||||
todos[currentSection].push({
|
||||
ticket,
|
||||
task,
|
||||
status,
|
||||
isMissingTicket: !ticket || ticket.startsWith('TBC-')
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return todos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update TODO.md with new ticket numbers
|
||||
*/
|
||||
function updateTodoFile(content, ticketMappings) {
|
||||
let updatedContent = content;
|
||||
|
||||
ticketMappings.forEach(mapping => {
|
||||
const placeholder = mapping.placeholder;
|
||||
const newTicket = mapping.ticket;
|
||||
updatedContent = updatedContent.replace(placeholder, newTicket);
|
||||
});
|
||||
|
||||
return updatedContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main sync function
|
||||
*/
|
||||
async function syncTodoLinear() {
|
||||
const modePrefix = OPTIONS.dryRun ? '(DRY RUN) ' : '';
|
||||
console.log(`${modePrefix}🔄 Synchronizing TODO.md with Linear...`);
|
||||
|
||||
// Check for API key
|
||||
const apiKey = process.env.LINEAR_API_KEY;
|
||||
if (!apiKey || apiKey === 'your_linear_api_key_here') {
|
||||
console.error('❌ ERROR: LINEAR_API_KEY is required in .env file');
|
||||
console.log(' Add your Linear API key to .env file:');
|
||||
console.log(' LINEAR_API_KEY=lin_api_your_actual_key_here');
|
||||
console.log(' Get your API key from: https://linear.app/settings/api');
|
||||
console.log(' Optional: Use --dry-run --verbose for safe testing');
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new LinearClient(apiKey);
|
||||
|
||||
// Quick API validation
|
||||
try {
|
||||
if (OPTIONS.verbose) {
|
||||
console.log(`${modePrefix}🔐 Validating API connection...`);
|
||||
}
|
||||
await client.request('query { viewer { id name } }');
|
||||
console.log(`${modePrefix}✅ API connection validated`);
|
||||
} catch (validationError) {
|
||||
console.error('❌ API validation failed:', validationError.message);
|
||||
console.error(' Please check your LINEAR_API_KEY in .env file');
|
||||
console.error(' Visit: https://linear.app/settings/api');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`${modePrefix}� Reading TODO.md...`);
|
||||
const content = fs.readFileSync(TODO_FILE, 'utf8');
|
||||
const todos = parseTodoFile(content);
|
||||
|
||||
if (OPTIONS.verbose) {
|
||||
console.log(`${modePrefix}📊 Found todos:`, {
|
||||
completed: todos.completed.length,
|
||||
inProgress: todos.inProgress.length,
|
||||
planned: todos.planned.length
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`${modePrefix}🚀 Fetching existing Linear issues...`);
|
||||
const existingIssues = await client.getIssues();
|
||||
const existingTitles = existingIssues.map(i => i.title);
|
||||
|
||||
if (OPTIONS.verbose) {
|
||||
console.log(`${modePrefix}📋 Found ${existingIssues.length} Linear issues`);
|
||||
}
|
||||
|
||||
// Find tasks missing Linear tickets
|
||||
const missingTickets = [
|
||||
...todos.inProgress.filter(t => t.isMissingTicket),
|
||||
...todos.planned.filter(t => t.isMissingTicket)
|
||||
];
|
||||
|
||||
console.log(`${modePrefix}📋 Found ${missingTickets.length} tasks without Linear tickets`);
|
||||
|
||||
const ticketMappings = [];
|
||||
|
||||
for (const item of missingTickets) {
|
||||
if (!existingTitles.includes(item.task)) {
|
||||
console.log(`${modePrefix}🔧 Creating Linear issue: ${item.task}`);
|
||||
|
||||
try {
|
||||
const issue = await client.createIssue(
|
||||
item.task,
|
||||
`## ${item.task}\n\n### Acceptance Criteria\n\n- Task to be completed\n\n### Implementation Notes\n\n- Details to be added`
|
||||
);
|
||||
|
||||
console.log(`${modePrefix}✅ Created issue: ${issue.identifier} - ${issue.url}`);
|
||||
|
||||
// Replace TBC-x with actual ticket
|
||||
const placeholder = item.ticket || item.task;
|
||||
ticketMappings.push({
|
||||
placeholder: placeholder,
|
||||
ticket: issue.identifier,
|
||||
task: item.task
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to create issue for: ${item.task}`, error.message);
|
||||
}
|
||||
} else {
|
||||
if (OPTIONS.verbose) {
|
||||
console.log(`${modePrefix}⏭️ Issue already exists for: ${item.task}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update Linear status for completed tasks
|
||||
console.log(`${modePrefix}📋 Updating Linear statuses for completed tasks...`);
|
||||
let statusUpdateCount = 0;
|
||||
|
||||
for (const issue of existingIssues) {
|
||||
if (todos.completed.some(t => t.task === issue.title) && issue.status !== 'Done') {
|
||||
console.log(`${modePrefix}🔧 Updating status to Done: ${issue.identifier} - ${issue.title}`);
|
||||
try {
|
||||
await client.updateIssueStatus(issue.id, 'Done');
|
||||
statusUpdateCount++;
|
||||
if (OPTIONS.verbose) {
|
||||
console.log(`${modePrefix}✅ Updated: ${issue.identifier}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to update: ${issue.identifier}`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${modePrefix}📊 Updated ${statusUpdateCount} Linear issues`);
|
||||
|
||||
// Update TODO.md with new ticket numbers
|
||||
if (ticketMappings.length > 0) {
|
||||
console.log(`${modePrefix}📋 Updating TODO.md with ${ticketMappings.length} new ticket numbers...`);
|
||||
|
||||
if (OPTIONS.dryRun) {
|
||||
console.log(`${modePrefix}📋 DRY RUN: Would update TODO.md with mappings:`,
|
||||
ticketMappings.map(m => `${m.placeholder} → ${m.ticket}`).join(', ')
|
||||
);
|
||||
} else {
|
||||
// Create backup
|
||||
const backupPath = `${TODO_FILE}.backup.${Date.now()}`;
|
||||
fs.copyFileSync(TODO_FILE, backupPath);
|
||||
console.log(`${modePrefix}💾 Backup created: ${backupPath}`);
|
||||
|
||||
const updatedContent = updateTodoFile(content, ticketMappings);
|
||||
fs.writeFileSync(TODO_FILE, updatedContent, 'utf8');
|
||||
console.log(`${modePrefix}✅ TODO.md updated successfully`);
|
||||
}
|
||||
}
|
||||
|
||||
const actionSummary = OPTIONS.dryRun ?
|
||||
`(DRY RUN: No changes made)` :
|
||||
`(Created ${ticketMappings.length} issues, updated ${statusUpdateCount} statuses)`;
|
||||
|
||||
console.log(`${modePrefix}🎉 Synchronization complete! ${actionSummary}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Synchronization failed:', error.message);
|
||||
if (error.response && error.response.data) {
|
||||
console.error('Response:', error.response.data);
|
||||
}
|
||||
|
||||
if (error.message.includes('GraphQL') && OPTIONS.verbose) {
|
||||
console.error('🔍 Full error details:', error);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the sync if this script is executed directly
|
||||
if (require.main === module) {
|
||||
syncTodoLinear().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = { syncTodoLinear };
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,25 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,25 @@
|
||||
const nextJest = require('next/jest')
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files
|
||||
dir: './',
|
||||
})
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const customJestConfig = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
moduleNameMapper: {
|
||||
// Handle module aliases (this will be automatically configured for you based on your tsconfig.json paths)
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,jsx,ts,tsx}',
|
||||
'!src/**/index.ts',
|
||||
'!src/**/*.d.ts',
|
||||
],
|
||||
testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
|
||||
}
|
||||
|
||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||
module.exports = createJestConfig(customJestConfig)
|
||||
@@ -0,0 +1,302 @@
|
||||
// No need to require jest - it's available globally in Jest test files
|
||||
|
||||
// JEST MOCKS - must be set up before importing any modules that use them
|
||||
|
||||
// Mock MongoDB - fix MongoClient to work properly
|
||||
const mockAdmin = {
|
||||
ping: jest.fn().mockResolvedValue({ ok: 1 })
|
||||
}
|
||||
|
||||
const mockDb = {
|
||||
collection: jest.fn().mockReturnValue({
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
replaceOne: jest.fn().mockResolvedValue({ acknowledged: true }),
|
||||
find: jest.fn(() => ({
|
||||
toArray: jest.fn().mockResolvedValue([])
|
||||
})),
|
||||
insertOne: jest.fn().mockResolvedValue({ acknowledged: true, insertedId: 'test-id' }),
|
||||
insertMany: jest.fn().mockResolvedValue({ acknowledged: true, insertedIds: { 0: 'test-id' } }),
|
||||
updateOne: jest.fn().mockResolvedValue({ acknowledged: true, matchedCount: 1, modifiedCount: 1 }),
|
||||
updateMany: jest.fn().mockResolvedValue({ acknowledged: true, matchedCount: 2, modifiedCount: 2 }),
|
||||
deleteOne: jest.fn().mockResolvedValue({ acknowledged: true, deletedCount: 1 }),
|
||||
deleteMany: jest.fn().mockResolvedValue({ acknowledged: true, deletedCount: 2 }),
|
||||
bulkWrite: jest.fn().mockResolvedValue({ acknowledged: true, insertedCount: 0, matchedCount: 0, modifiedCount: 0, deletedCount: 0, upsertedCount: 0, upsertedIds: {} })
|
||||
}),
|
||||
admin: jest.fn().mockReturnValue(mockAdmin)
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
connect: jest.fn().mockResolvedValue({}),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
db: jest.fn().mockImplementation((dbName) => mockDb)
|
||||
}
|
||||
|
||||
jest.mock('mongodb', () => ({
|
||||
MongoClient: jest.fn().mockImplementation(() => mockClient)
|
||||
}))
|
||||
|
||||
// Mock environment variables for tests
|
||||
process.env.MONGODB_URI = 'mongodb://localhost:27017/test'
|
||||
process.env.MONGODB_DB = 'test'
|
||||
|
||||
// Mock Winston logger - simplified for default export
|
||||
jest.mock('winston', () => ({
|
||||
transports: {
|
||||
Console: jest.fn(),
|
||||
},
|
||||
createLogger: jest.fn(() => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
log: jest.fn(),
|
||||
child: jest.fn(() => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
log: jest.fn(),
|
||||
isLevelEnabled: jest.fn().mockReturnValue(true),
|
||||
levels: { error: 0, warn: 1, info: 2, debug: 3 }
|
||||
})),
|
||||
isLevelEnabled: jest.fn().mockReturnValue(true),
|
||||
levels: { error: 0, warn: 1, info: 2, debug: 3 }
|
||||
})),
|
||||
format: {
|
||||
timestamp: jest.fn(() => ({})),
|
||||
errors: jest.fn(() => ({})),
|
||||
json: jest.fn(() => ({})),
|
||||
colorize: jest.fn(() => ({})),
|
||||
simple: jest.fn(() => ({})),
|
||||
combine: jest.fn((...args) => args[args.length - 1] || {}),
|
||||
printf: jest.fn(() => ({}))
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock logger module to use the mocked winston
|
||||
jest.mock('./src/lib/logger', () => {
|
||||
const mockLogger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
log: jest.fn(),
|
||||
child: jest.fn(() => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
log: jest.fn()
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: mockLogger,
|
||||
logger: mockLogger,
|
||||
createComponentLogger: jest.fn(() => mockLogger.child()),
|
||||
requestLogger: mockLogger.child(),
|
||||
apiLogger: mockLogger.child(),
|
||||
dbLogger: mockLogger.child(),
|
||||
authLogger: mockLogger.child(),
|
||||
errorLogger: mockLogger.child(),
|
||||
generateRequestId: jest.fn(() => 'test-request-id'),
|
||||
withTiming: jest.fn(async (operation, fn) => fn())
|
||||
}
|
||||
})
|
||||
|
||||
// Mock Next.js Response objects
|
||||
global.Response = class Response {
|
||||
constructor(body, init) {
|
||||
this.body = body
|
||||
this.status = init?.status || 200
|
||||
this.statusText = init?.statusText || ''
|
||||
this.headers = new Map()
|
||||
|
||||
if (init?.headers) {
|
||||
Object.entries(init.headers).forEach(([key, value]) => {
|
||||
this.headers.set(key.toLowerCase(), value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
json() {
|
||||
return Promise.resolve(JSON.parse(this.body || '{}'))
|
||||
}
|
||||
|
||||
headers = {
|
||||
get: (key) => this.headers.get(key.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
global.NextResponse = {
|
||||
json: (data, init) => {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: init?.status || 200,
|
||||
headers: init?.headers || {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mock Next.js NextResponse properly
|
||||
jest.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
json: jest.fn((data, init) => {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: init?.status || 200,
|
||||
headers: init?.headers || {}
|
||||
})
|
||||
})
|
||||
},
|
||||
NextRequest: jest.fn()
|
||||
}))
|
||||
|
||||
// Ensure process.env is available in tests
|
||||
if (!global.process.env) {
|
||||
global.process.env = {}
|
||||
}
|
||||
|
||||
// Logger will be mocked by the winston mock above
|
||||
|
||||
// Mock Loki transport
|
||||
jest.mock('winston-loki', () => jest.fn(() => ({
|
||||
log: jest.fn(),
|
||||
close: jest.fn()
|
||||
})))
|
||||
|
||||
// Mock Next.js router
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter: () => ({
|
||||
route: '/',
|
||||
pathname: '/',
|
||||
query: {},
|
||||
asPath: '/',
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
reload: jest.fn(),
|
||||
back: jest.fn(),
|
||||
prefetch: jest.fn(),
|
||||
beforePopState: jest.fn(),
|
||||
events: {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Next.js navigation
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
back: jest.fn(),
|
||||
forward: jest.fn(),
|
||||
prefetch: jest.fn(),
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
usePathname: () => '/',
|
||||
}))
|
||||
|
||||
// Mock Response constructor for Next.js API routes
|
||||
global.Response = class MockResponse {
|
||||
constructor(body, options = {}) {
|
||||
this.body = body
|
||||
this.status = options.status || 200
|
||||
this.statusText = options.statusText || 'OK'
|
||||
this.headers = new Map([
|
||||
['content-type', options.headers?.['content-type'] || 'application/json'],
|
||||
...Object.entries(options.headers || {})
|
||||
])
|
||||
|
||||
// Next.js Response.json() method
|
||||
this.json = jest.fn(() => {
|
||||
try {
|
||||
return JSON.parse(body || '{}')
|
||||
} catch {
|
||||
return body
|
||||
}
|
||||
})
|
||||
|
||||
this.text = jest.fn(() => Promise.resolve(body || ''))
|
||||
this.arrayBuffer = jest.fn(() => Promise.resolve(new ArrayBuffer(0)))
|
||||
}
|
||||
|
||||
get(name) {
|
||||
return this.headers.get(name.toLowerCase())
|
||||
}
|
||||
|
||||
set(name, value) {
|
||||
this.headers.set(name.toLowerCase(), value)
|
||||
}
|
||||
|
||||
clone() {
|
||||
return { ...this }
|
||||
}
|
||||
}
|
||||
|
||||
// Mock Request constructor for Next.js API routes
|
||||
global.Request = class MockRequest {
|
||||
constructor(url, options = {}) {
|
||||
this.url = url
|
||||
this.method = options.method || 'GET'
|
||||
this.headers = new Map([
|
||||
['content-type', 'application/json'],
|
||||
...Object.entries(options.headers || {})
|
||||
])
|
||||
this.body = options.body || null
|
||||
}
|
||||
|
||||
json() {
|
||||
return Promise.resolve(this.body ? JSON.parse(this.body) : {})
|
||||
}
|
||||
|
||||
text() {
|
||||
return Promise.resolve(this.body || '')
|
||||
}
|
||||
|
||||
get(name) {
|
||||
return this.headers.get(name.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
// Mock NextResponse for API routes
|
||||
global.NextResponse = {
|
||||
json: jest.fn((data, options = {}) => {
|
||||
return new global.Response(JSON.stringify(data), {
|
||||
status: options.status || 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
})
|
||||
}),
|
||||
redirect: jest.fn((url, status = 302) => ({
|
||||
url,
|
||||
status,
|
||||
headers: new Map([['location', url]])
|
||||
})),
|
||||
rewrite: jest.fn((url) => ({
|
||||
url,
|
||||
status: 200
|
||||
}))
|
||||
}
|
||||
|
||||
// Set up environment variables for tests
|
||||
process.env.MONGODB_URI = 'mongodb://localhost:27017/test'
|
||||
process.env.MONGODB_DB = 'test'
|
||||
process.env.LOKI_HOST = 'http://loki:3100'
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.NEXT_PUBLIC_CONTACT_EMAIL = 'info@mozdit.hu'
|
||||
process.env.NEXT_PUBLIC_SITE_URL = 'https://localhost:3000'
|
||||
process.env.npm_package_version = '1.0.0'
|
||||
process.env.PACKAGE_VERSION = '1.0.0'
|
||||
|
||||
// Set up cleanup for consistent testing
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Import React Testing Library DOM (using require for Jest setup files)
|
||||
require('@testing-library/jest-dom')
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+11614
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "proto",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"next": "15.5.2",
|
||||
"mongodb": "^6.5",
|
||||
"mongoose": "^8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.2",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"jest": "^29.7",
|
||||
"jest-environment-jsdom": "^29.7",
|
||||
"@testing-library/react": "^16.0",
|
||||
"@testing-library/jest-dom": "^6.5",
|
||||
"@testing-library/user-event": "^14.5",
|
||||
"@types/jest": "^29.5",
|
||||
"winston": "^3.11",
|
||||
"winston-loki": "^6.0",
|
||||
"@types/winston": "^2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,85 @@
|
||||
import { GET, HEAD } from './route'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
// Mock the process object for tests
|
||||
const mockProcess = {
|
||||
uptime: jest.fn().mockReturnValue(1234),
|
||||
env: {
|
||||
npm_package_version: '1.0.0',
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
}
|
||||
|
||||
const originalProcess = global.process
|
||||
beforeEach(() => {
|
||||
global.process = { ...originalProcess, ...mockProcess } as any
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.process = originalProcess
|
||||
})
|
||||
|
||||
describe('/api/health', () => {
|
||||
describe('GET request', () => {
|
||||
it('should return successful health status with required data', async () => {
|
||||
const response = await GET()
|
||||
|
||||
// Check response status
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
// Get JSON data
|
||||
const data = await response.json()
|
||||
|
||||
// Verify required fields
|
||||
expect(data).toHaveProperty('status', 'ok')
|
||||
expect(data).toHaveProperty('timestamp')
|
||||
expect(data).toHaveProperty('uptime')
|
||||
expect(data).toHaveProperty('version')
|
||||
expect(data).toHaveProperty('environment')
|
||||
|
||||
// Verify timestamp is a valid ISO string
|
||||
expect(() => new Date(data.timestamp)).not.toThrow()
|
||||
expect(data.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
|
||||
|
||||
// Verify uptime matches mock
|
||||
expect(data.uptime).toBe(1234)
|
||||
|
||||
// Verify version
|
||||
expect(data.version).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('should include correct Cache-Control headers', async () => {
|
||||
const response = await GET()
|
||||
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate')
|
||||
expect(response.headers.get('Pragma')).toBe('no-cache')
|
||||
expect(response.headers.get('Expires')).toBe('0')
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
// Temporarily break process.env to simulate error
|
||||
global.process.env = undefined as any
|
||||
|
||||
const response = await GET()
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.status).toBe('error')
|
||||
expect(data).toHaveProperty('timestamp')
|
||||
expect(data).toHaveProperty('message', 'Health check failed')
|
||||
|
||||
// Restore process.env
|
||||
global.process.env = originalProcess.env
|
||||
})
|
||||
})
|
||||
|
||||
describe('HEAD request', () => {
|
||||
it('should return 200 status without body', async () => {
|
||||
const response = await HEAD()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Basic health check
|
||||
const healthData = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
};
|
||||
|
||||
return NextResponse.json(healthData, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'Health check failed',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Also support HEAD requests for lighter health checks
|
||||
export async function HEAD() {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Header from "../components/Header";
|
||||
import Footer from "../components/Footer";
|
||||
import { siteConfig } from "../config/site";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `${siteConfig.general.name} | ${siteConfig.general.description}`,
|
||||
description: siteConfig.general.description,
|
||||
authors: [{ name: siteConfig.general.name }],
|
||||
keywords: ["web hosting", "email szolgáltatás", "DNS adminisztráció", "IT szolgáltatás", "mozdIT"],
|
||||
openGraph: {
|
||||
title: siteConfig.general.name,
|
||||
description: siteConfig.general.description,
|
||||
url: siteConfig.general.url,
|
||||
siteName: siteConfig.general.name,
|
||||
images: [
|
||||
{
|
||||
url: siteConfig.general.ogImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: siteConfig.general.name,
|
||||
},
|
||||
],
|
||||
locale: siteConfig.general.locale,
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: siteConfig.general.name,
|
||||
description: siteConfig.general.description,
|
||||
images: [siteConfig.general.ogImage],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="hu">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
|
||||
>
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { siteConfig } from '@/config/site'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-r from-blue-50 to-indigo-50 py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
{siteConfig.hero.title}
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-gray-600 max-w-4xl mx-auto mb-8 leading-relaxed">
|
||||
{siteConfig.hero.description}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<a
|
||||
href={siteConfig.hero.cta.primary.href}
|
||||
target={siteConfig.hero.cta.primary.external ? '_blank' : undefined}
|
||||
rel={siteConfig.hero.cta.primary.external ? 'noopener noreferrer' : undefined}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-8 py-4 rounded-md transition-colors text-lg inline-flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10" />
|
||||
</svg>
|
||||
{siteConfig.hero.cta.primary.text}
|
||||
</a>
|
||||
{siteConfig.hero.cta.secondary && (
|
||||
<a
|
||||
href={siteConfig.hero.cta.secondary.href}
|
||||
className="border-2 border-blue-600 text-blue-600 hover:bg-blue-50 font-medium px-8 py-4 rounded-md transition-colors text-lg"
|
||||
>
|
||||
{siteConfig.hero.cta.secondary.text}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* USP Section */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 bg-gray-50">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-8">
|
||||
{siteConfig.about.title}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||
{siteConfig.about.usps.map((usp) => (
|
||||
<div key={usp.id} className="text-center">
|
||||
<div className="w-16 h-16 bg-blue-100 group-hover:bg-blue-200 rounded-full flex items-center justify-center mx-auto mb-4 transition-colors">
|
||||
<span className="text-2xl">{usp.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{usp.title}</h3>
|
||||
<p className="text-gray-600">{usp.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Services Section */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6">
|
||||
{siteConfig.services.title}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 max-w-3xl mx-auto">
|
||||
{siteConfig.services.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||
{siteConfig.services.services.map((service) => (
|
||||
<div key={service.id} className="group bg-white p-8 rounded-xl shadow-sm border border-gray-200 hover:shadow-md transition-all duration-300 hover:border-blue-200">
|
||||
<div className="w-12 h-12 bg-blue-100 group-hover:bg-blue-200 rounded-lg flex items-center justify-center mb-4 transition-colors">
|
||||
<span className="text-xl">{service.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3 group-hover:text-blue-600 transition-colors">{service.title}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">Szolgáltatás jellemzők:</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
{service.features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<span className="text-blue-500 mr-2">✓</span>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<a href="/kapcsolat" className="inline-flex items-center text-blue-600 hover:text-blue-700 font-medium mt-4 transition-colors">
|
||||
{service.ctaText}
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="bg-gray-900 text-white py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 className="text-3xl font-bold mb-4">
|
||||
Kapcsolatfelvétel az első lépés
|
||||
</h2>
|
||||
<p className="text-xl text-gray-300 mb-8">
|
||||
Mutassuk meg, hogyan segíthetünk Önnek megvalósítani címeit!
|
||||
</p>
|
||||
<a
|
||||
href="/kapcsolat"
|
||||
className="inline-block bg-blue-600 hover:bg-blue-700 text-white font-medium px-8 py-3 rounded-md transition-colors"
|
||||
>
|
||||
Kapcsolatfelvétel
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import Footer from './Footer'
|
||||
|
||||
describe('Footer', () => {
|
||||
it('should render company information', () => {
|
||||
render(<Footer />)
|
||||
|
||||
expect(screen.getByText('mozdIT Bt.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Megbízható web- és email szolgáltatás személyre szabott támogatással. Stabil tárhely, üzembiztos levelezés és DNS adminisztráció gyors reakcióval.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render email and company details', () => {
|
||||
render(<Footer />)
|
||||
|
||||
expect(screen.getByText('Email: info@mozdit.hu')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cég: mozdIT Bt.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Székhely: Budapest, Magyarország')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render navigation links', () => {
|
||||
render(<Footer />)
|
||||
|
||||
expect(screen.getByText('Kezdőlap')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rólunk')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Szolgáltatások')).toHaveLength(2) // Both in navigation and services section
|
||||
expect(screen.getByText('Kapcsolat')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render service sections', () => {
|
||||
render(<Footer />)
|
||||
|
||||
expect(screen.getByText('Web Hosting')).toBeInTheDocument()
|
||||
expect(screen.getByText('Email Szolgáltatás')).toBeInTheDocument()
|
||||
expect(screen.getByText('DNS Adminisztráció')).toBeInTheDocument()
|
||||
expect(screen.getByText('Műszaki támogatás')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render copyright notice with dynamic year', () => {
|
||||
render(<Footer />)
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
expect(screen.getByText(`© ${currentYear} mozdIT Bt. Minden jog fenntartva.`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render legal links', () => {
|
||||
render(<Footer />)
|
||||
|
||||
expect(screen.getAllByText('Adatvédelmi tájékoztató')).toHaveLength(2) // Appears in both sections
|
||||
expect(screen.getAllByText('Használati feltételek')).toHaveLength(2) // Appears in both sections
|
||||
})
|
||||
|
||||
it('should render with proper grid layout', () => {
|
||||
const { container } = render(<Footer />)
|
||||
|
||||
const gridContainer = container.querySelector('.grid.grid-cols-1.md\\:grid-cols-4')
|
||||
expect(gridContainer).toBeInTheDocument()
|
||||
|
||||
// Check for responsive grid classes
|
||||
expect(gridContainer).toHaveClass('grid-cols-1', 'md:grid-cols-4')
|
||||
})
|
||||
|
||||
it('should render with proper semantic structure', () => {
|
||||
const { container } = render(<Footer />)
|
||||
|
||||
// Should have a footer element
|
||||
const footer = container.firstChild as HTMLElement
|
||||
expect(footer?.tagName).toBe('FOOTER')
|
||||
|
||||
// Should have proper background and padding
|
||||
expect(footer).toHaveClass('bg-gray-50', 'border-t')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { siteConfig } from '@/config/site'
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-gray-50 border-t border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{/* Company Info */}
|
||||
<div className="md:col-span-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{siteConfig.general.name}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4 max-w-md">
|
||||
{siteConfig.general.description}
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
<p>Email: {siteConfig.contact.email}</p>
|
||||
<p>Cég: {siteConfig.general.name}</p>
|
||||
<p>Székhely: {siteConfig.contact.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide mb-4">
|
||||
Navigáció
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{siteConfig.navigation.footer.map((item) => (
|
||||
<li key={item.href}>
|
||||
<a href={item.href} className="text-gray-600 hover:text-blue-600 transition-colors">
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide mb-4">
|
||||
Szolgáltatások
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{siteConfig.services.services.map((service) => (
|
||||
<li key={service.id} className="text-gray-600">
|
||||
{service.title}
|
||||
</li>
|
||||
))}
|
||||
<li className="text-gray-600">Műszaki támogatás</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="border-t border-gray-200 pt-8 mt-8">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center">
|
||||
<p className="text-gray-500 text-sm">
|
||||
{siteConfig.footer.copyright}
|
||||
</p>
|
||||
<div className="flex space-x-4 mt-4 sm:mt-0">
|
||||
{siteConfig.footer.links.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-gray-500 hover:text-blue-600 text-sm transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import Header from './Header'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
// Mock Next.js Link component
|
||||
jest.mock('next/link', () => {
|
||||
return ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
)
|
||||
})
|
||||
|
||||
describe('Header', () => {
|
||||
it('should render the company logo', () => {
|
||||
render(<Header />)
|
||||
expect(screen.getByText('mozdIT Bt.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all navigation links in desktop menu', () => {
|
||||
render(<Header />)
|
||||
|
||||
// Desktop menu should contain all links with specific structures
|
||||
const desktopMenu = document.querySelector('.hidden.md\\:block')
|
||||
expect(desktopMenu).toBeInTheDocument()
|
||||
|
||||
const navLinks = screen.getAllByText('Kezdőlap')
|
||||
expect(navLinks.length).toBeGreaterThan(0)
|
||||
|
||||
expect(screen.getAllByText('Rólunk')).toHaveLength(2) // Both in desktop and mobile menus
|
||||
expect(screen.getAllByText('Szolgáltatások')).toHaveLength(2) // Both in desktop and mobile menus
|
||||
})
|
||||
|
||||
it('should render contact button with correct styling', () => {
|
||||
render(<Header />)
|
||||
|
||||
const contactButtons = screen.getAllByText('Kapcsolat')
|
||||
expect(contactButtons.length).toBeGreaterThan(0)
|
||||
|
||||
// Check if any contact button has the correct styling
|
||||
const contactButton = contactButtons[0]
|
||||
expect(contactButton).toBeInTheDocument()
|
||||
|
||||
// Check for blue background styling
|
||||
const contactLink = contactButton.closest('a')
|
||||
if (contactLink) {
|
||||
expect(contactLink).toHaveClass('bg-blue-600')
|
||||
}
|
||||
})
|
||||
|
||||
it('should render hamburger menu button on mobile', () => {
|
||||
render(<Header />)
|
||||
|
||||
// The hamburger menu button is hidden by default in desktop view
|
||||
// We can test its presence even if not visible
|
||||
const hamburgerButton = screen.getByRole('button')
|
||||
expect(hamburgerButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have proper accessibility attributes', () => {
|
||||
render(<Header />)
|
||||
|
||||
const hamburgerButton = screen.getByRole('button')
|
||||
expect(hamburgerButton).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('should render with proper semantic structure', () => {
|
||||
const { container } = render(<Header />)
|
||||
|
||||
// Should have header element with proper structure
|
||||
const header = container.firstChild as HTMLElement
|
||||
expect(header?.tagName).toBe('HEADER')
|
||||
|
||||
// Should have a nav element
|
||||
const nav = container.querySelector('nav')
|
||||
expect(nav).toBeInTheDocument()
|
||||
|
||||
// Should be sticky positioned
|
||||
expect(header).toHaveClass('sticky', 'top-0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { siteConfig } from '@/config/site'
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
|
||||
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<a href="/" className="text-xl font-bold text-blue-600 hover:text-blue-700">
|
||||
{siteConfig.general.name}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:block">
|
||||
<div className="flex items-center space-x-8">
|
||||
{siteConfig.navigation.main.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
target={item.external ? '_blank' : undefined}
|
||||
rel={item.external ? 'noopener noreferrer' : undefined}
|
||||
className={item.label === 'Kapcsolat'
|
||||
? "bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
: "text-gray-900 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors"
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-500 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span className="sr-only">Open main menu</span>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation - Hidden by default */}
|
||||
<div className="md:hidden absolute top-full left-0 right-0 bg-white border-b border-gray-200 shadow-lg opacity-0 invisible transition-all duration-300 ease-in-out">
|
||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||
<a href="/" className="block px-3 py-2 rounded-md text-base font-medium text-gray-900 hover:text-blue-600">
|
||||
Kezdőlap
|
||||
</a>
|
||||
<a href="/rolunk" className="block px-3 py-2 rounded-md text-base font-medium text-gray-900 hover:text-blue-600">
|
||||
Rólunk
|
||||
</a>
|
||||
<a href="/szolgaltatasok" className="block px-3 py-2 rounded-md text-base font-medium text-gray-900 hover:text-blue-600">
|
||||
Szolgáltatások
|
||||
</a>
|
||||
<a href="/kapcsolat" className="block px-3 py-2 rounded-md text-base font-medium bg-blue-600 text-white">
|
||||
Kapcsolat
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { SiteConfig } from '@/types/site'
|
||||
|
||||
/**
|
||||
* Site Configuration - Centralized configuration for all public content
|
||||
* All content is easily modifiable without code changes
|
||||
* This structure supports easy expansion for CMS integration later
|
||||
*/
|
||||
export const siteConfig: SiteConfig = {
|
||||
general: {
|
||||
name: 'mozdIT Bt.',
|
||||
description: 'Megbízható web- és email szolgáltatás személyre szabott támogatással. Stabil tárhely, üzembiztos levelezés és DNS adminisztráció gyors reakcióval.',
|
||||
url: process.env.NEXT_PUBLIC_SITE_URL || 'https://localhost:3000',
|
||||
ogImage: '/og-image.png',
|
||||
locale: 'hu-HU'
|
||||
},
|
||||
|
||||
navigation: {
|
||||
main: [
|
||||
{ label: 'Kezdőlap', href: '/' },
|
||||
{ label: 'Rólunk', href: '/rolunk' },
|
||||
{ label: 'Szolgáltatások', href: '/szolgaltatasok' },
|
||||
{ label: 'Kapcsolat', href: '/kapcsolat' }
|
||||
],
|
||||
footer: [
|
||||
{ label: 'Kezdőlap', href: '/' },
|
||||
{ label: 'Rólunk', href: '/rolunk' },
|
||||
{ label: 'Szolgáltatások', href: '/szolgaltatasok' },
|
||||
{ label: 'Kapcsolat', href: '/kapcsolat' },
|
||||
{ label: 'Adatvédelmi tájékoztató', href: '/adatvedelem' },
|
||||
{ label: 'Használati feltételek', href: '/felhasznalasi-feltetelek' }
|
||||
]
|
||||
},
|
||||
|
||||
hero: {
|
||||
title: 'Megbízható web‑ és email‑szolgáltatás személyre szabott támogatással',
|
||||
subtitle: 'Kis ügyfélkör, nagy figyelem: stabil tárhely, üzembiztos levelezés és DNS adminisztráció — gyors reakcióval.',
|
||||
description: 'A mozdIT Bt. célja, hogy megbízható, támogató szolgáltatásokkal segítse ügyfeleit a digitális térben való sikerben.',
|
||||
cta: {
|
||||
primary: {
|
||||
text: 'Webmail Ugrás',
|
||||
href: process.env.NEXT_PUBLIC_WEBMAIL_URL || 'https://webmail.mozdit.hu',
|
||||
external: true
|
||||
},
|
||||
secondary: {
|
||||
text: 'Kapcsolatfelvétel',
|
||||
href: '/kapcsolat'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
services: {
|
||||
title: 'Szolgáltatásaink',
|
||||
subtitle: 'Komplex megoldásokat kínálunk, amelyek tökéletesen igazodnak a vállalkozás igényeihez.',
|
||||
services: [
|
||||
{
|
||||
id: 'hosting',
|
||||
title: 'Web Hosting',
|
||||
description: 'Stabil és gyors tárhely szolgáltatás megbízható infrastrukturával és folyamatos monitoringgel.',
|
||||
icon: '🔥',
|
||||
features: [
|
||||
'99.9% uptime garancia',
|
||||
'SSD tárhely gyorsabb válaszidőért',
|
||||
'24/7 monitoring és támogatás',
|
||||
'Automatikus backup rendszer',
|
||||
'SSL tanúsítvány belefoglaltva'
|
||||
],
|
||||
ctaText: 'További információk'
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: 'Email Szolgáltatás',
|
||||
description: 'Üzembiztos levelezési megoldások modern biztonsággal és anti-spam védelemmel.',
|
||||
icon: '📧',
|
||||
features: [
|
||||
'Domain alapú email címe',
|
||||
'Webmail és IMAP/POP3 támogatása',
|
||||
'Anti-spam és anti-virus védelem',
|
||||
'Mobil szinkronizáció',
|
||||
'Nagy tárterület 10GB/felhasználó'
|
||||
],
|
||||
ctaText: 'További információk'
|
||||
},
|
||||
{
|
||||
id: 'dns',
|
||||
title: 'DNS Adminisztráció',
|
||||
description: 'Teljeskörű domain névszerver kezelés és optimalizálás gyors névfeloldással.',
|
||||
icon: '🌐',
|
||||
features: [
|
||||
'Biztonságos DNS konfliktus kezelés',
|
||||
'Email routing konfiguráció',
|
||||
'Subdomain management',
|
||||
'DNSSEC támogatás',
|
||||
'FIGYELEM: Senki ne módosítson nélkülem kérem!'
|
||||
],
|
||||
ctaText: 'További információk'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
about: {
|
||||
title: 'Miért érdemes minket választani?',
|
||||
description: [
|
||||
'A mozdIT Bt. 2018 óta nyújt megbízható Web Hosting szolgáltatásokat magyar vállalkozások számára.',
|
||||
'Kis ügyfélközpontú csapatunknak köszönhetően személyre szabott és gyors támogatást tudunk biztosítani.'
|
||||
],
|
||||
usps: [
|
||||
{
|
||||
id: 'personal',
|
||||
title: 'Személyes ügyfélkezelés',
|
||||
description: 'Egyedi figyelem minden ügyfél felé, személyre szabott megoldásokkal.',
|
||||
icon: '👤'
|
||||
},
|
||||
{
|
||||
id: 'fast',
|
||||
title: 'Gyors reagálás',
|
||||
description: 'Azonnali visszajelzés és hatékony problémamegoldás 24 órás támogatással.',
|
||||
icon: '⚡'
|
||||
},
|
||||
{
|
||||
id: 'reliable',
|
||||
title: 'Stabil háttér',
|
||||
description: 'Biztonságos infrastruktúra és rendszeres mentések, megbízhatóság garanciával.',
|
||||
icon: '🛡️'
|
||||
},
|
||||
{
|
||||
id: 'flexible',
|
||||
title: 'Rugalmas támogatás',
|
||||
description: 'A változó igényekhez alkalmazkodó, örökös technikai karbantartás.',
|
||||
icon: '🔧'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
footer: {
|
||||
copyright: `© ${new Date().getFullYear()} mozdIT Bt. Minden jog fenntartva.`,
|
||||
links: [
|
||||
{ label: 'Adatvédelmi tájékoztató', href: '/adatvedelem' },
|
||||
{ label: 'Használati feltételek', href: '/felhasznalasi-feltetelek' }
|
||||
]
|
||||
},
|
||||
|
||||
contact: {
|
||||
email: process.env.NEXT_PUBLIC_CONTACT_EMAIL || 'info@mozdit.hu',
|
||||
address: 'Budapest, Magyarország',
|
||||
form: {
|
||||
title: 'Kapcsolatfelvétel',
|
||||
description: 'Legyen szíves érdeklődését vagy problémáját részletesen megfogalmazni.',
|
||||
submitText: 'Üzenet küldése',
|
||||
fields: {
|
||||
name: {
|
||||
label: 'Név',
|
||||
placeholder: 'Vezetéknév Keresztnév',
|
||||
required: true
|
||||
},
|
||||
email: {
|
||||
label: 'Email cím',
|
||||
placeholder: 'pelda@email.hu',
|
||||
required: true
|
||||
},
|
||||
message: {
|
||||
label: 'Üzenet',
|
||||
placeholder: 'Kérjük írja le érdeklődését részletesen...',
|
||||
required: true
|
||||
},
|
||||
consent: {
|
||||
label: 'Elfogadom az adatkezelési tájékoztatót',
|
||||
required: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import winston from 'winston'
|
||||
|
||||
// Mock winston and winston-loki
|
||||
jest.mock('winston', () => ({
|
||||
format: {
|
||||
combine: jest.fn(),
|
||||
timestamp: jest.fn(),
|
||||
errors: jest.fn(),
|
||||
json: jest.fn(),
|
||||
colorize: jest.fn(),
|
||||
simple: jest.fn(),
|
||||
printf: jest.fn()
|
||||
},
|
||||
transports: {
|
||||
Console: jest.fn(),
|
||||
File: jest.fn()
|
||||
},
|
||||
createLogger: jest.fn()
|
||||
}))
|
||||
|
||||
jest.mock('winston-loki', () => jest.fn())
|
||||
|
||||
describe('Logger', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
// Reset process.env
|
||||
process.env = {
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
LOKI_HOST: undefined,
|
||||
LOKI_USERNAME: undefined,
|
||||
LOKI_PASSWORD: undefined
|
||||
}
|
||||
})
|
||||
|
||||
it('should create logger with correct configuration', () => {
|
||||
// Mock the format functions
|
||||
const mockFormat = {
|
||||
combine: jest.fn().mockReturnValue('combined-format'),
|
||||
timestamp: jest.fn().mockReturnValue('timestamp-format'),
|
||||
errors: jest.fn().mockReturnValue('errors-format'),
|
||||
json: jest.fn().mockReturnValue('json-format'),
|
||||
colorize: jest.fn().mockReturnValue('colorize-format'),
|
||||
simple: jest.fn().mockReturnValue('simple-format'),
|
||||
printf: jest.fn().mockReturnValue('printf-format')
|
||||
}
|
||||
|
||||
const mockTransports = {
|
||||
Console: jest.fn().mockImplementation(() => ({ name: 'console' })),
|
||||
File: jest.fn().mockImplementation(() => ({ name: 'file' }))
|
||||
}
|
||||
|
||||
// Setup mocks
|
||||
;(winston.format as any) = mockFormat
|
||||
;(winston.transports as any) = mockTransports
|
||||
;(winston.createLogger as jest.Mock) = jest.fn().mockReturnValue({
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnValue({
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn()
|
||||
}),
|
||||
end: jest.fn()
|
||||
})
|
||||
|
||||
// Import after mocks are set up
|
||||
const { logger } = require('./logger')
|
||||
|
||||
expect(winston.createLogger).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
level: 'debug',
|
||||
format: 'combined-format',
|
||||
defaultMeta: expect.objectContaining({
|
||||
service: 'mozdit-web',
|
||||
environment: 'test'
|
||||
}),
|
||||
transports: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'console' })
|
||||
])
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should include Loki transport when LOKI_HOST is provided', () => {
|
||||
// Set LOKI_HOST
|
||||
process.env.LOKI_HOST = 'http://localhost:3100'
|
||||
process.env.LOKI_USERNAME = 'test'
|
||||
process.env.LOKI_PASSWORD = 'testpass'
|
||||
|
||||
// Reset modules to pick up new env vars
|
||||
jest.resetModules()
|
||||
|
||||
// Mock LokiTransport
|
||||
const mockLokiTransport = jest.fn().mockImplementation(() => ({
|
||||
name: 'loki'
|
||||
}))
|
||||
jest.doMock('winston-loki', () => mockLokiTransport)
|
||||
|
||||
// Import after mocks are set up
|
||||
require('./logger')
|
||||
|
||||
expect(mockLokiTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: 'http://localhost:3100',
|
||||
labels: expect.objectContaining({
|
||||
app: 'mozdit-web',
|
||||
environment: 'test',
|
||||
service: 'frontend'
|
||||
}),
|
||||
basicAuth: 'test:testpass'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should create component loggers with correct metadata', () => {
|
||||
// Setup mocks
|
||||
const mockLogger = {
|
||||
child: jest.fn().mockReturnValue({
|
||||
info: jest.fn(),
|
||||
error: jest.fn()
|
||||
})
|
||||
}
|
||||
;(winston.createLogger as jest.Mock) = jest.fn().mockReturnValue(mockLogger)
|
||||
|
||||
// Import after mocks are set up
|
||||
const { createComponentLogger } = require('./logger')
|
||||
|
||||
const componentLogger = createComponentLogger('test-component')
|
||||
|
||||
expect(mockLogger.child).toHaveBeenCalledWith({ component: 'test-component' })
|
||||
expect(componentLogger).toBeDefined()
|
||||
})
|
||||
|
||||
it('should generate request IDs in correct format', () => {
|
||||
// Setup mocks
|
||||
jest.resetModules()
|
||||
const { generateRequestId } = require('./logger')
|
||||
|
||||
const requestId = generateRequestId()
|
||||
|
||||
expect(requestId).toMatch(/^\d+-[a-z0-9]+$/)
|
||||
})
|
||||
|
||||
it('should handle timing operations correctly', async () => {
|
||||
// Setup mocks
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn()
|
||||
}
|
||||
;(winston.createLogger as jest.Mock) = jest.fn().mockReturnValue(mockLogger)
|
||||
|
||||
jest.resetModules()
|
||||
const { withTiming } = require('./logger')
|
||||
|
||||
const mockOperation = jest.fn().mockResolvedValue('success')
|
||||
|
||||
const result = await withTiming('test-operation', mockOperation, { test: 'metadata' })
|
||||
|
||||
expect(result).toBe('success')
|
||||
expect(mockLogger.debug).toHaveBeenCalledTimes(2) // Start and complete
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Started test-operation'),
|
||||
{ test: 'metadata' }
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle timing operation failures', async () => {
|
||||
// Setup mocks
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn()
|
||||
}
|
||||
;(winston.createLogger as jest.Mock) = jest.fn().mockReturnValue(mockLogger)
|
||||
|
||||
jest.resetModules()
|
||||
const { withTiming } = require('./logger')
|
||||
|
||||
const mockOperation = jest.fn().mockRejectedValue(new Error('test error'))
|
||||
|
||||
await expect(withTiming('test-operation', mockOperation)).rejects.toThrow('test error')
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Started test-operation'),
|
||||
{}
|
||||
)
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed test-operation'),
|
||||
expect.objectContaining({
|
||||
duration: expect.any(Number),
|
||||
error: expect.any(Error)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import winston from 'winston'
|
||||
import LokiTransport from 'winston-loki'
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === 'development'
|
||||
|
||||
// Custom format for structured logging
|
||||
const structuredFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'ISO' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json({
|
||||
replacer: (_key, value) =>
|
||||
typeof value === 'bigint' ? value.toString() : value,
|
||||
})
|
||||
)
|
||||
|
||||
// Console format for development
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.colorize(),
|
||||
winston.format.simple(),
|
||||
winston.format.printf(({ timestamp, level, message, service, requestId, ...meta }) => {
|
||||
const requestInfo = requestId ? `[${requestId}]` : ''
|
||||
const serviceInfo = service ? `[${service}]` : '[mozdIT]'
|
||||
const metaStr = Object.keys(meta).length ? `\n${JSON.stringify(meta, null, 2)}` : ''
|
||||
return `${timestamp} ${serviceInfo} ${level} ${requestInfo} ${message}${metaStr}`
|
||||
})
|
||||
)
|
||||
|
||||
// Transports configuration
|
||||
const transports: winston.transport[] = [
|
||||
// Loki transport for centralized logging
|
||||
...(process.env.LOKI_HOST
|
||||
? [
|
||||
new LokiTransport({
|
||||
host: process.env.LOKI_HOST,
|
||||
labels: {
|
||||
app: 'mozdit-web',
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
service: 'frontend'
|
||||
},
|
||||
basicAuth: process.env.LOKI_USERNAME && process.env.LOKI_PASSWORD
|
||||
? `${process.env.LOKI_USERNAME}:${process.env.LOKI_PASSWORD}`
|
||||
: undefined,
|
||||
json: true,
|
||||
format: winston.format.json(),
|
||||
onConnectionError: (err: Error) => console.error('Loki connection error:', err)
|
||||
})
|
||||
]
|
||||
: []),
|
||||
|
||||
// Console for development logging
|
||||
new winston.transports.Console({
|
||||
level: isDevelopment ? 'debug' : 'info',
|
||||
format: isDevelopment ? consoleFormat : structuredFormat,
|
||||
handleExceptions: true,
|
||||
handleRejections: true
|
||||
})
|
||||
]
|
||||
|
||||
// Root logger configuration
|
||||
export const logger = winston.createLogger({
|
||||
level: isDevelopment ? 'debug' : 'info',
|
||||
format: structuredFormat,
|
||||
defaultMeta: {
|
||||
service: 'mozdit-web',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
},
|
||||
transports,
|
||||
exceptionHandlers: transports,
|
||||
rejectionHandlers: transports
|
||||
})
|
||||
|
||||
// Specialized loggers for different components
|
||||
export const createComponentLogger = (component: string) => {
|
||||
return logger.child({ component })
|
||||
}
|
||||
|
||||
export const requestLogger = logger.child({ component: 'request' })
|
||||
export const apiLogger = logger.child({ component: 'api' })
|
||||
export const dbLogger = logger.child({ component: 'database' })
|
||||
export const authLogger = logger.child({ component: 'auth' })
|
||||
export const errorLogger = logger.child({ component: 'error' })
|
||||
|
||||
// Request ID generator for correlation
|
||||
export const generateRequestId = (): string =>
|
||||
`${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// Helper function for timing operations
|
||||
export const withTiming = async <T>(
|
||||
operation: string,
|
||||
fn: () => Promise<T>,
|
||||
metadata: any = {}
|
||||
): Promise<T> => {
|
||||
const startTime = Date.now()
|
||||
logger.debug(`Started ${operation}`, metadata)
|
||||
|
||||
try {
|
||||
const result = await fn()
|
||||
const duration = Date.now() - startTime
|
||||
logger.debug(`Completed ${operation} in ${duration}ms`, { ...metadata, duration })
|
||||
return result
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime
|
||||
logger.error(`Failed ${operation} in ${duration}ms`, { ...metadata, duration, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
const gracefulShutdown = () => {
|
||||
logger.info('Initiating graceful shutdown...')
|
||||
logger.end()
|
||||
}
|
||||
|
||||
process.on('SIGTERM', gracefulShutdown)
|
||||
process.on('SIGINT', gracefulShutdown)
|
||||
|
||||
export default logger
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
getCollection,
|
||||
getDb,
|
||||
checkMongoConnection
|
||||
} from './mongodb'
|
||||
import { MongoClient } from 'mongodb'
|
||||
|
||||
const mockMongoClient = MongoClient as jest.MockedClass<typeof MongoClient>
|
||||
|
||||
// Mock the MongoDB client and database
|
||||
const mockDb = {
|
||||
collection: jest.fn().mockReturnValue({
|
||||
findOne: jest.fn(),
|
||||
insertOne: jest.fn(),
|
||||
updateOne: jest.fn(),
|
||||
deleteOne: jest.fn()
|
||||
})
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
db: jest.fn().mockReturnValue(mockDb)
|
||||
}
|
||||
|
||||
// Mock the mongodb module
|
||||
jest.mock('mongodb', () => ({
|
||||
MongoClient: jest.fn().mockImplementation(() => mockClient)
|
||||
}))
|
||||
|
||||
// Restore environment before tests
|
||||
const originalEnv = process.env
|
||||
|
||||
describe('MongoDB Connection', () => {
|
||||
beforeEach(() => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
MONGODB_URI: 'mongodb://localhost:27017/test',
|
||||
MONGODB_DB: 'test'
|
||||
}
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv
|
||||
})
|
||||
|
||||
describe('MongoDB URI validation', () => {
|
||||
it('should throw error when MONGODB_URI is not set', () => {
|
||||
// This test is tricky because the module is cached
|
||||
// We'll test this by clearing the module cache and mocking process.env
|
||||
const originalMongodbUri = process.env.MONGODB_URI
|
||||
delete process.env.MONGODB_URI
|
||||
|
||||
// Clear module cache to force re-import
|
||||
jest.resetModules()
|
||||
|
||||
expect(() => {
|
||||
require('./mongodb')
|
||||
}).toThrow('Please add MONGODB_URI to your environment variables')
|
||||
|
||||
// Restore environment
|
||||
process.env.MONGODB_URI = originalMongodbUri
|
||||
})
|
||||
})
|
||||
|
||||
describe('Database connection', () => {
|
||||
it('should create MongoClient with correct URI and options', async () => {
|
||||
const { MongoClient } = require('mongodb')
|
||||
|
||||
// Reset modules to use our mock
|
||||
jest.resetModules()
|
||||
const { clientPromise } = require('./mongodb')
|
||||
|
||||
await clientPromise
|
||||
|
||||
expect(MongoClient).toHaveBeenCalledWith(
|
||||
process.env.MONGODB_URI,
|
||||
expect.objectContaining({
|
||||
maxPoolSize: 10,
|
||||
serverSelectionTimeoutMS: 5000,
|
||||
socketTimeoutMS: 45000
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return database instance', async () => {
|
||||
// Reset modules to use our mock
|
||||
jest.resetModules()
|
||||
const { getDb } = require('./mongodb')
|
||||
|
||||
const result = await getDb()
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(typeof result.collection).toBe('function')
|
||||
})
|
||||
|
||||
it('should return collection from database', async () => {
|
||||
// Reset modules to use our mock
|
||||
jest.resetModules()
|
||||
const { getCollection } = require('./mongodb')
|
||||
|
||||
const collection = await getCollection('test_collection')
|
||||
|
||||
expect(collection).toBeDefined()
|
||||
expect(typeof collection.findOne).toBe('function')
|
||||
})
|
||||
|
||||
it('should check MongoDB connection successfully', async () => {
|
||||
// Reset modules to use our mock
|
||||
jest.resetModules()
|
||||
const { checkMongoConnection } = require('./mongodb')
|
||||
|
||||
const result = await checkMongoConnection()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false on MongoDB connection failure', async () => {
|
||||
// Mock a connection failure
|
||||
const { MongoClient } = require('mongodb')
|
||||
|
||||
// Create a failing client
|
||||
const failingClient = {
|
||||
connect: jest.fn().mockRejectedValue(new Error('Connection failed')),
|
||||
db: jest.fn(),
|
||||
close: jest.fn()
|
||||
}
|
||||
|
||||
MongoClient.mockImplementation(() => failingClient)
|
||||
|
||||
// Reset the module to use the new mock
|
||||
jest.resetModules()
|
||||
const { checkMongoConnection } = require('./mongodb')
|
||||
|
||||
const result = await checkMongoConnection()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Connection reuse and caching', () => {
|
||||
it('should reuse the same MongoClient instance for multiple calls', async () => {
|
||||
// Reset modules to use our mock
|
||||
jest.resetModules()
|
||||
const { clientPromise: clientPromise1 } = require('./mongodb')
|
||||
const { clientPromise: clientPromise2 } = require('./mongodb')
|
||||
|
||||
await clientPromise1
|
||||
await clientPromise2
|
||||
|
||||
// Should still be the same promise from cache
|
||||
expect(clientPromise1).toBe(clientPromise2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { MongoClient, Db } from 'mongodb'
|
||||
|
||||
if (!process.env.MONGODB_URI) {
|
||||
throw new Error('Please add MONGODB_URI to your environment variables')
|
||||
}
|
||||
|
||||
const uri = process.env.MONGODB_URI
|
||||
const options = {
|
||||
maxPoolSize: 10,
|
||||
serverSelectionTimeoutMS: 5000,
|
||||
socketTimeoutMS: 45000,
|
||||
}
|
||||
|
||||
let client: MongoClient
|
||||
let clientPromise: Promise<MongoClient>
|
||||
|
||||
// In development mode, use a global variable so that the client is not recreated between hot reloads
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// @ts-ignore
|
||||
if (!global._mongoClientPromise) {
|
||||
client = new MongoClient(uri, options)
|
||||
// @ts-ignore
|
||||
global._mongoClientPromise = client.connect()
|
||||
}
|
||||
// @ts-ignore
|
||||
clientPromise = global._mongoClientPromise
|
||||
} else {
|
||||
// In production mode, it's best to not use a global variable
|
||||
client = new MongoClient(uri, options)
|
||||
clientPromise = client.connect()
|
||||
}
|
||||
|
||||
export default clientPromise
|
||||
|
||||
export async function getDb(): Promise<Db> {
|
||||
const client = await clientPromise
|
||||
return client.db(process.env.MONGODB_DB || 'mozdit')
|
||||
}
|
||||
|
||||
export async function getCollection(collectionName: string) {
|
||||
const db = await getDb()
|
||||
return db.collection(collectionName)
|
||||
}
|
||||
|
||||
// Health check for MongoDB connection
|
||||
export async function checkMongoConnection(): Promise<boolean> {
|
||||
try {
|
||||
const db = await getDb()
|
||||
await db.admin().ping()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('MongoDB connection check failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import { getSiteConfig, saveSiteConfig, initializeDefaultConfig } from './site-config'
|
||||
import { getCollection } from './mongodb'
|
||||
import { siteConfig as staticConfig } from '@/config/site'
|
||||
import { SiteConfig } from '@/types/site'
|
||||
import logger from './logger'
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('./mongodb')
|
||||
jest.mock('./logger', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn()
|
||||
}
|
||||
}))
|
||||
jest.mock('@/config/site', () => ({
|
||||
siteConfig: {
|
||||
general: {
|
||||
name: 'Test Site',
|
||||
description: 'Test Description',
|
||||
url: 'https://test.com',
|
||||
ogImage: 'https://test.com/og.jpg',
|
||||
locale: 'en'
|
||||
},
|
||||
navigation: {
|
||||
main: [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'About', href: '/about' }
|
||||
],
|
||||
footer: [
|
||||
{ label: 'Privacy', href: '/privacy' },
|
||||
{ label: 'Terms', href: '/terms' }
|
||||
]
|
||||
},
|
||||
hero: {
|
||||
title: 'Welcome',
|
||||
subtitle: 'Test Subtitle',
|
||||
description: 'Test description',
|
||||
cta: {
|
||||
primary: {
|
||||
text: 'Get Started',
|
||||
href: '/get-started'
|
||||
}
|
||||
}
|
||||
},
|
||||
services: {
|
||||
title: 'Our Services',
|
||||
subtitle: 'What we offer',
|
||||
services: []
|
||||
},
|
||||
about: {
|
||||
title: 'About Us',
|
||||
description: ['Test about section'],
|
||||
usps: []
|
||||
},
|
||||
footer: {
|
||||
copyright: '© 2024 Test Site',
|
||||
links: []
|
||||
},
|
||||
contact: {
|
||||
email: 'test@test.com',
|
||||
address: 'Test Address',
|
||||
form: {
|
||||
title: 'Contact Us',
|
||||
description: 'Get in touch',
|
||||
submitText: 'Send Message',
|
||||
fields: {
|
||||
name: { label: 'Name', placeholder: 'Your name', required: true },
|
||||
email: { label: 'Email', placeholder: 'your@email.com', required: true },
|
||||
message: { label: 'Message', placeholder: 'Your message', required: true },
|
||||
consent: { label: 'I agree', required: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockCollection = {
|
||||
findOne: jest.fn(),
|
||||
replaceOne: jest.fn()
|
||||
}
|
||||
|
||||
|
||||
|
||||
describe('Site Config', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(getCollection as jest.Mock).mockResolvedValue(mockCollection)
|
||||
})
|
||||
|
||||
describe('getSiteConfig', () => {
|
||||
it('should return MongoDB config when available', async () => {
|
||||
const mockConfig: SiteConfig = {
|
||||
general: {
|
||||
name: 'MongoDB Site',
|
||||
description: 'MongoDB Description',
|
||||
url: 'https://mongodb.com',
|
||||
ogImage: 'https://mongodb.com/og.jpg',
|
||||
locale: 'en'
|
||||
},
|
||||
navigation: {
|
||||
main: [{ label: 'Home', href: '/' }],
|
||||
footer: [{ label: 'Privacy', href: '/privacy' }]
|
||||
},
|
||||
hero: {
|
||||
title: 'MongoDB Hero',
|
||||
subtitle: 'MongoDB Subtitle',
|
||||
description: 'MongoDB Description',
|
||||
cta: {
|
||||
primary: {
|
||||
text: 'Get Started',
|
||||
href: '/get-started'
|
||||
}
|
||||
}
|
||||
},
|
||||
services: {
|
||||
title: 'Services',
|
||||
subtitle: 'Our services',
|
||||
services: []
|
||||
},
|
||||
about: {
|
||||
title: 'About',
|
||||
description: ['About us'],
|
||||
usps: []
|
||||
},
|
||||
footer: {
|
||||
copyright: '© 2024 MongoDB',
|
||||
links: []
|
||||
},
|
||||
contact: {
|
||||
email: 'mongodb@test.com',
|
||||
address: 'MongoDB Address',
|
||||
form: {
|
||||
title: 'Contact',
|
||||
description: 'Get in touch',
|
||||
submitText: 'Send',
|
||||
fields: {
|
||||
name: { label: 'Name', placeholder: 'Name', required: true },
|
||||
email: { label: 'Email', placeholder: 'Email', required: true },
|
||||
message: { label: 'Message', placeholder: 'Message', required: true },
|
||||
consent: { label: 'Consent', required: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mockCollection.findOne.mockResolvedValue({
|
||||
_id: 'mock-id',
|
||||
data: mockConfig
|
||||
})
|
||||
|
||||
const config = await getSiteConfig()
|
||||
|
||||
expect(config).toEqual(mockConfig)
|
||||
expect(mockCollection.findOne).toHaveBeenCalledWith({
|
||||
type: 'site_config',
|
||||
environment: 'development'
|
||||
})
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Loaded site config from MongoDB',
|
||||
{ configId: 'mock-id' }
|
||||
)
|
||||
})
|
||||
|
||||
it('should return static config when MongoDB config not found', async () => {
|
||||
mockCollection.findOne.mockResolvedValue(null)
|
||||
|
||||
const config = await getSiteConfig()
|
||||
|
||||
expect(config).toEqual(staticConfig)
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'MongoDB config not found, using static fallback'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return static config when MongoDB is unavailable', async () => {
|
||||
mockCollection.findOne.mockRejectedValue(new Error('Connection failed'))
|
||||
|
||||
const config = await getSiteConfig()
|
||||
|
||||
expect(config).toEqual(staticConfig)
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'MongoDB unavailable, using static config',
|
||||
{ error: 'Connection failed' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveSiteConfig', () => {
|
||||
it('should save config to MongoDB successfully', async () => {
|
||||
const newConfig: SiteConfig = {
|
||||
general: {
|
||||
name: 'New Site',
|
||||
description: 'New Description',
|
||||
url: 'https://new.com',
|
||||
ogImage: 'https://new.com/og.jpg',
|
||||
locale: 'en'
|
||||
},
|
||||
navigation: {
|
||||
main: [{ label: 'Home', href: '/' }],
|
||||
footer: [{ label: 'Privacy', href: '/privacy' }]
|
||||
},
|
||||
hero: {
|
||||
title: 'New Hero',
|
||||
subtitle: 'New Subtitle',
|
||||
description: 'New Description',
|
||||
cta: {
|
||||
primary: {
|
||||
text: 'Get Started',
|
||||
href: '/get-started'
|
||||
}
|
||||
}
|
||||
},
|
||||
services: {
|
||||
title: 'Services',
|
||||
subtitle: 'Our services',
|
||||
services: []
|
||||
},
|
||||
about: {
|
||||
title: 'About',
|
||||
description: ['About us'],
|
||||
usps: []
|
||||
},
|
||||
footer: {
|
||||
copyright: '© 2024 New',
|
||||
links: []
|
||||
},
|
||||
contact: {
|
||||
email: 'new@test.com',
|
||||
address: 'New Address',
|
||||
form: {
|
||||
title: 'Contact',
|
||||
description: 'Get in touch',
|
||||
submitText: 'Send',
|
||||
fields: {
|
||||
name: { label: 'Name', placeholder: 'Name', required: true },
|
||||
email: { label: 'Email', placeholder: 'Email', required: true },
|
||||
message: { label: 'Message', placeholder: 'Message', required: true },
|
||||
consent: { label: 'Consent', required: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mockCollection.replaceOne.mockResolvedValue({ acknowledged: true })
|
||||
|
||||
const result = await saveSiteConfig(newConfig)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockCollection.replaceOne).toHaveBeenCalledWith(
|
||||
{ type: 'site_config', environment: 'development' },
|
||||
{
|
||||
type: 'site_config',
|
||||
environment: 'development',
|
||||
data: newConfig,
|
||||
lastModified: expect.any(Date)
|
||||
},
|
||||
{ upsert: true }
|
||||
)
|
||||
expect(logger.info).toHaveBeenCalledWith('Saved site config to MongoDB')
|
||||
})
|
||||
|
||||
it('should return false when save fails', async () => {
|
||||
const newConfig: SiteConfig = {
|
||||
general: {
|
||||
name: 'New Site',
|
||||
description: 'New Description',
|
||||
url: 'https://new.com',
|
||||
ogImage: 'https://new.com/og.jpg',
|
||||
locale: 'en'
|
||||
},
|
||||
navigation: {
|
||||
main: [{ label: 'Home', href: '/' }],
|
||||
footer: [{ label: 'Privacy', href: '/privacy' }]
|
||||
},
|
||||
hero: {
|
||||
title: 'New Hero',
|
||||
subtitle: 'New Subtitle',
|
||||
description: 'New Description',
|
||||
cta: {
|
||||
primary: {
|
||||
text: 'Get Started',
|
||||
href: '/get-started'
|
||||
}
|
||||
}
|
||||
},
|
||||
services: {
|
||||
title: 'Services',
|
||||
subtitle: 'Our services',
|
||||
services: []
|
||||
},
|
||||
about: {
|
||||
title: 'About',
|
||||
description: ['About us'],
|
||||
usps: []
|
||||
},
|
||||
footer: {
|
||||
copyright: '© 2024 New',
|
||||
links: []
|
||||
},
|
||||
contact: {
|
||||
email: 'new@test.com',
|
||||
address: 'New Address',
|
||||
form: {
|
||||
title: 'Contact',
|
||||
description: 'Get in touch',
|
||||
submitText: 'Send',
|
||||
fields: {
|
||||
name: { label: 'Name', placeholder: 'Name', required: true },
|
||||
email: { label: 'Email', placeholder: 'Email', required: true },
|
||||
message: { label: 'Message', placeholder: 'Message', required: true },
|
||||
consent: { label: 'Consent', required: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mockCollection.replaceOne.mockRejectedValue(new Error('Save failed'))
|
||||
|
||||
const result = await saveSiteConfig(newConfig)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Failed to save config to MongoDB',
|
||||
{ error: 'Save failed' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('initializeDefaultConfig', () => {
|
||||
it('should initialize default config when none exists', async () => {
|
||||
mockCollection.findOne.mockResolvedValue(null)
|
||||
mockCollection.replaceOne.mockResolvedValue({ acknowledged: true })
|
||||
|
||||
await initializeDefaultConfig()
|
||||
|
||||
expect(mockCollection.findOne).toHaveBeenCalledWith({
|
||||
type: 'site_config',
|
||||
environment: 'development'
|
||||
})
|
||||
expect(mockCollection.replaceOne).toHaveBeenCalledWith(
|
||||
{ type: 'site_config', environment: 'development' },
|
||||
{
|
||||
type: 'site_config',
|
||||
environment: 'development',
|
||||
data: staticConfig,
|
||||
lastModified: expect.any(Date)
|
||||
},
|
||||
{ upsert: true }
|
||||
)
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Initialized default site config in MongoDB'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not initialize when config already exists', async () => {
|
||||
mockCollection.findOne.mockResolvedValue({
|
||||
_id: 'existing-id',
|
||||
data: staticConfig
|
||||
})
|
||||
|
||||
await initializeDefaultConfig()
|
||||
|
||||
expect(mockCollection.replaceOne).not.toHaveBeenCalled()
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Site config already exists in MongoDB'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle initialization errors gracefully', async () => {
|
||||
mockCollection.findOne.mockRejectedValue(new Error('Connection failed'))
|
||||
|
||||
await initializeDefaultConfig()
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Failed to initialize site config',
|
||||
{ error: 'Connection failed' }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
// Hybrid approach: File-based fallback + MongoDB integration for future
|
||||
|
||||
import { siteConfig as staticConfig } from '@/config/site'
|
||||
import { SiteConfig } from '@/types/site'
|
||||
import { getCollection } from './mongodb'
|
||||
import logger from './logger'
|
||||
|
||||
/**
|
||||
* Get site configuration with hybrid approach
|
||||
* 1. Try MongoDB first (production-ready)
|
||||
* 2. Fall back to static file (development/development safe)
|
||||
*/
|
||||
export async function getSiteConfig(): Promise<SiteConfig> {
|
||||
try {
|
||||
const collection = await getCollection('site_config')
|
||||
const doc = await collection.findOne({ type: 'site_config', environment: 'development' })
|
||||
|
||||
if (doc && doc.data) {
|
||||
logger.info('Loaded site config from MongoDB', { configId: doc._id })
|
||||
return doc.data as SiteConfig
|
||||
} else {
|
||||
logger.info('MongoDB config not found, using static fallback')
|
||||
return staticConfig
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('MongoDB unavailable, using static config', { error: (error as Error).message })
|
||||
return staticConfig
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save site configuration to MongoDB
|
||||
* For future admin panel integration
|
||||
*/
|
||||
export async function saveSiteConfig(config: SiteConfig): Promise<boolean> {
|
||||
try {
|
||||
const collection = await getCollection('site_config')
|
||||
await collection.replaceOne(
|
||||
{ type: 'site_config', environment: 'development' },
|
||||
{
|
||||
type: 'site_config',
|
||||
environment: 'development',
|
||||
data: config,
|
||||
lastModified: new Date()
|
||||
},
|
||||
{ upsert: true }
|
||||
)
|
||||
|
||||
logger.info('Saved site config to MongoDB')
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Failed to save config to MongoDB', { error: (error as Error).message })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default config in MongoDB (one-time setup)
|
||||
*/
|
||||
export async function initializeDefaultConfig(): Promise<void> {
|
||||
try {
|
||||
const collection = await getCollection('site_config')
|
||||
const existingConfig = await collection.findOne({ type: 'site_config', environment: 'development' })
|
||||
|
||||
if (!existingConfig) {
|
||||
await saveSiteConfig(staticConfig)
|
||||
logger.info('Initialized default site config in MongoDB')
|
||||
} else {
|
||||
logger.info('Site config already exists in MongoDB')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize site config', { error: (error as Error).message })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
export interface SiteConfig {
|
||||
general: GeneralConfig
|
||||
navigation: NavigationConfig
|
||||
hero: HeroSectionConfig
|
||||
services: ServiceSectionConfig
|
||||
about: AboutSectionConfig
|
||||
footer: FooterConfig
|
||||
contact: ContactConfig
|
||||
}
|
||||
|
||||
export interface GeneralConfig {
|
||||
name: string
|
||||
description: string
|
||||
url: string
|
||||
ogImage: string
|
||||
locale: string
|
||||
}
|
||||
|
||||
export interface NavigationItem {
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
export interface NavigationConfig {
|
||||
main: NavigationItem[]
|
||||
footer: NavigationItem[]
|
||||
}
|
||||
|
||||
export interface HeroSectionConfig {
|
||||
title: string
|
||||
subtitle: string
|
||||
description: string
|
||||
cta: {
|
||||
primary: {
|
||||
text: string
|
||||
href: string
|
||||
external?: boolean
|
||||
}
|
||||
secondary?: {
|
||||
text: string
|
||||
href: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServiceItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
features: string[]
|
||||
ctaText: string
|
||||
}
|
||||
|
||||
export interface ServiceSectionConfig {
|
||||
title: string
|
||||
subtitle: string
|
||||
services: ServiceItem[]
|
||||
}
|
||||
|
||||
export interface USP {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface AboutSectionConfig {
|
||||
title: string
|
||||
description: string[]
|
||||
usps: USP[]
|
||||
}
|
||||
|
||||
export interface FooterConfig {
|
||||
copyright: string
|
||||
links: {
|
||||
label: string
|
||||
href: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface ContactConfig {
|
||||
phone?: string
|
||||
email: string
|
||||
address: string
|
||||
socialMedia?: {
|
||||
platform: string
|
||||
url: string
|
||||
label: string
|
||||
}[]
|
||||
form: {
|
||||
title: string
|
||||
description: string
|
||||
submitText: string
|
||||
fields: {
|
||||
name: {
|
||||
label: string
|
||||
placeholder: string
|
||||
required: boolean
|
||||
}
|
||||
email: {
|
||||
label: string
|
||||
placeholder: string
|
||||
required: boolean
|
||||
}
|
||||
message: {
|
||||
label: string
|
||||
placeholder: string
|
||||
required: boolean
|
||||
}
|
||||
consent: {
|
||||
label: string
|
||||
required: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface EnvConfig {
|
||||
siteUrl: string
|
||||
companyName: string
|
||||
contactEmail: string
|
||||
webmailUrl?: string
|
||||
analytics: {
|
||||
plausibleDomain?: string
|
||||
ga4Id?: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user