feat: add allowed Linear API operations to MCP config
This commit is contained in:
+11
-1
@@ -25,7 +25,17 @@
|
|||||||
},
|
},
|
||||||
"linear": {
|
"linear": {
|
||||||
"command": "npx",
|
"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