Convert to template with dynamic configuration

Add comprehensive environment-based configuration system:
- Created config.js module to centralize all environment variables
- Updated server.js to use config module for all settings
- Added /api/config endpoint to expose company info to frontend
- Created init.js to dynamically inject config into HTML pages
- Updated .env.example with comprehensive configuration options
- Added data attributes to index.html for dynamic content
- Updated Dockerfile to include config.js

This allows users to customize:
- Company name, tagline, and description
- Branding colors (primary, accent, dark)
- Contact information (email, phone, address, hours)
- Social media links
- About page content
- Services offered

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mikael Westöö
2026-01-23 23:26:57 +01:00
parent d6523bc4b1
commit ac21e428a5
6 changed files with 370 additions and 33 deletions

View File

@@ -1,16 +1,110 @@
# ===================================
# AI Recruitment Site - Configuration
# ===================================
# Copy this file to .env and customize for your company
# ===================================
# Company Information
# ===================================
COMPANY_NAME=Your Recruitment Firm
COMPANY_TAGLINE=Finding the Perfect Match for Your Career
COMPANY_DESCRIPTION=We specialize in connecting talented professionals with exceptional opportunities across various industries.
# ===================================
# Company Branding (Colors)
# ===================================
# Primary brand color (e.g., #2563EB for blue)
PRIMARY_COLOR=#2563EB
# Accent/success color (e.g., #059669 for green)
ACCENT_COLOR=#059669
# Dark/secondary color (e.g., #1E293B)
DARK_COLOR=#1E293B
# ===================================
# Contact Information
# ===================================
CONTACT_EMAIL=info@yourcompany.com
CONTACT_PHONE=+1 (555) 123-4567
CONTACT_ADDRESS=123 Business St, Suite 100, City, State 12345
# Social Media Links (leave empty to hide)
SOCIAL_LINKEDIN=https://linkedin.com/company/yourcompany
SOCIAL_TWITTER=https://twitter.com/yourcompany
SOCIAL_FACEBOOK=
# ===================================
# Deployment Configuration
# ===================================
# Your custom subdomain (e.g., 'yourname' becomes yourname.recruitai.startanaicompany.com)
SUBDOMAIN=yourname
# Your Gitea username and repository name
GITEA_USERNAME=your-gitea-username
GITEA_REPO_NAME=ai-recruit-site-template
# ===================================
# Application Settings
# ===================================
NODE_ENV=production
PORT=3000
# Session secret (will be auto-generated if empty)
SESSION_SECRET=
# ===================================
# Database Configuration # Database Configuration
# ===================================
# PostgreSQL connection details
DB_HOST=postgres DB_HOST=postgres
DB_PORT=5432 DB_PORT=5432
DB_NAME=recruitment DB_NAME=recruitment
DB_USER=postgres DB_USER=postgres
DB_PASSWORD=changeme123 # Database password (will be auto-generated if empty)
DB_PASSWORD=
# Application Configuration # ===================================
PORT=3000 # API Tokens (for deployment script)
NODE_ENV=production # ===================================
# These should be set as environment variables, NOT in this file
# COOLIFY_API_TOKEN=your-coolify-api-token
# GITEA_API_TOKEN=your-gitea-api-token
# Session Secret (CHANGE THIS IN PRODUCTION!) # ===================================
SESSION_SECRET=your-very-secret-session-key-change-this-in-production # Feature Configuration
# ===================================
# Maximum CV file size in MB
MAX_CV_SIZE_MB=5
# Optional: Application URL # Allowed CV file types (comma-separated)
APP_URL=http://localhost:3000 ALLOWED_CV_TYPES=application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document
# ===================================
# About Page Content
# ===================================
ABOUT_MISSION=Our mission is to bridge the gap between exceptional talent and outstanding opportunities.
ABOUT_VISION=We envision a world where every professional finds their perfect career match.
ABOUT_VALUES=Integrity, Excellence, Innovation, Partnership
# ===================================
# Services Offered (comma-separated)
# ===================================
SERVICES_LIST=Executive Search,Contract Staffing,Permanent Placement,Career Consulting,Talent Assessment,Industry Expertise
# ===================================
# Contact Page Settings
# ===================================
# Email address where contact form submissions are sent
CONTACT_FORM_RECIPIENT=info@yourcompany.com
# Business hours
BUSINESS_HOURS=Monday - Friday: 9:00 AM - 6:00 PM
# ===================================
# Email Configuration (Optional)
# ===================================
# If you want to send email notifications
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@yourcompany.com

View File

@@ -10,6 +10,7 @@ RUN npm install --production
# Copy application code # Copy application code
COPY server.js ./ COPY server.js ./
COPY config.js ./
COPY migrations ./migrations COPY migrations ./migrations
# Copy public directory explicitly # Copy public directory explicitly

96
config.js Normal file
View File

@@ -0,0 +1,96 @@
// Configuration module for AI Recruitment Site Template
// Centralizes all environment variable loading with sensible defaults
const crypto = require('crypto');
// Helper function to generate secure random strings
function generateSecret(length = 64) {
return crypto.randomBytes(length).toString('hex');
}
// Company Information
const company = {
name: process.env.COMPANY_NAME || 'Your Recruitment Firm',
tagline: process.env.COMPANY_TAGLINE || 'Finding the Perfect Match for Your Career',
description: process.env.COMPANY_DESCRIPTION || 'We specialize in connecting talented professionals with exceptional opportunities across various industries.'
};
// Branding Colors
const branding = {
primaryColor: process.env.PRIMARY_COLOR || '#2563EB',
accentColor: process.env.ACCENT_COLOR || '#059669',
darkColor: process.env.DARK_COLOR || '#1E293B'
};
// Contact Information
const contact = {
email: process.env.CONTACT_EMAIL || 'info@yourcompany.com',
phone: process.env.CONTACT_PHONE || '+1 (555) 123-4567',
address: process.env.CONTACT_ADDRESS || '123 Business St, Suite 100, City, State 12345',
businessHours: process.env.BUSINESS_HOURS || 'Monday - Friday: 9:00 AM - 6:00 PM'
};
// Social Media Links
const social = {
linkedin: process.env.SOCIAL_LINKEDIN || '',
twitter: process.env.SOCIAL_TWITTER || '',
facebook: process.env.SOCIAL_FACEBOOK || ''
};
// Application Settings
const app = {
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3000', 10),
sessionSecret: process.env.SESSION_SECRET || generateSecret()
};
// Database Configuration
const database = {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
name: process.env.DB_NAME || 'recruitment',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'changeme123'
};
// Feature Configuration
const features = {
maxCvSizeMB: parseInt(process.env.MAX_CV_SIZE_MB || '5', 10),
allowedCvTypes: (process.env.ALLOWED_CV_TYPES || 'application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document').split(',')
};
// About Page Content
const about = {
mission: process.env.ABOUT_MISSION || 'Our mission is to bridge the gap between exceptional talent and outstanding opportunities.',
vision: process.env.ABOUT_VISION || 'We envision a world where every professional finds their perfect career match.',
values: process.env.ABOUT_VALUES || 'Integrity, Excellence, Innovation, Partnership'
};
// Services
const services = {
list: (process.env.SERVICES_LIST || 'Executive Search,Contract Staffing,Permanent Placement,Career Consulting,Talent Assessment,Industry Expertise').split(',')
};
// Email Configuration
const email = {
smtpHost: process.env.SMTP_HOST || '',
smtpPort: parseInt(process.env.SMTP_PORT || '587', 10),
smtpUser: process.env.SMTP_USER || '',
smtpPassword: process.env.SMTP_PASSWORD || '',
smtpFrom: process.env.SMTP_FROM || 'noreply@yourcompany.com',
contactFormRecipient: process.env.CONTACT_FORM_RECIPIENT || contact.email
};
// Export configuration object
module.exports = {
company,
branding,
contact,
social,
app,
database,
features,
about,
services,
email
};

View File

@@ -5,11 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ryans Recruit Firm - Find Your Dream Job</title> <title>Ryans Recruit Firm - Find Your Dream Job</title>
<link rel="stylesheet" href="/css/styles.css"> <link rel="stylesheet" href="/css/styles.css">
<script src="/js/init.js"></script>
</head> </head>
<body> <body>
<header> <header>
<nav class="container"> <nav class="container">
<div class="logo">Ryans Recruit Firm</div> <div class="logo" data-company-name>Ryans Recruit Firm</div>
<ul class="nav-links"> <ul class="nav-links">
<li><a href="/" class="active">Home</a></li> <li><a href="/" class="active">Home</a></li>
<li><a href="/about">About</a></li> <li><a href="/about">About</a></li>
@@ -23,8 +24,8 @@
<section class="hero"> <section class="hero">
<div class="container"> <div class="container">
<h1>Your Career Success Is Our Mission</h1> <h1 data-company-tagline>Your Career Success Is Our Mission</h1>
<p>Connecting talented professionals with leading companies worldwide</p> <p data-company-description>Connecting talented professionals with leading companies worldwide</p>
<div style="display: flex; gap: 1rem; justify-content: center; margin-top: 2rem;"> <div style="display: flex; gap: 1rem; justify-content: center; margin-top: 2rem;">
<a href="/jobs" class="btn btn-primary btn-lg">Browse Jobs</a> <a href="/jobs" class="btn btn-primary btn-lg">Browse Jobs</a>
<a href="/contact" class="btn btn-secondary btn-lg">Get in Touch</a> <a href="/contact" class="btn btn-secondary btn-lg">Get in Touch</a>
@@ -35,7 +36,7 @@
<section> <section>
<div class="container"> <div class="container">
<div class="section-title"> <div class="section-title">
<h2>Why Choose Ryans Recruit Firm?</h2> <h2>Why Choose <span data-company-name>Ryans Recruit Firm</span>?</h2>
<p>We're committed to finding the perfect match for both candidates and employers</p> <p>We're committed to finding the perfect match for both candidates and employers</p>
</div> </div>
@@ -198,8 +199,8 @@
<div class="container"> <div class="container">
<div class="footer-content"> <div class="footer-content">
<div class="footer-section"> <div class="footer-section">
<h3>Ryans Recruit Firm</h3> <h3 data-company-name>Ryans Recruit Firm</h3>
<p style="color: rgba(255, 255, 255, 0.8);"> <p style="color: rgba(255, 255, 255, 0.8);" data-company-description>
Your trusted partner in career advancement and talent acquisition. Your trusted partner in career advancement and talent acquisition.
</p> </p>
</div> </div>
@@ -227,9 +228,9 @@
<div class="footer-section"> <div class="footer-section">
<h3>Contact</h3> <h3>Contact</h3>
<ul> <ul>
<li>Email: info@ryansrecruit.com</li> <li>Email: <a href="mailto:info@ryansrecruit.com" data-contact-email>info@ryansrecruit.com</a></li>
<li>Phone: +1 (555) 123-4567</li> <li>Phone: <a href="tel:+15551234567" data-contact-phone>+1 (555) 123-4567</a></li>
<li>Hours: Mon-Fri 9AM-6PM EST</li> <li>Hours: <span data-business-hours>Mon-Fri 9AM-6PM EST</span></li>
</ul> </ul>
</div> </div>
</div> </div>

135
public/js/init.js Normal file
View File

@@ -0,0 +1,135 @@
// Initialize company configuration
// This file loads company configuration from the API and updates the page
let siteConfig = {};
async function loadSiteConfig() {
try {
const response = await fetch('/api/config');
siteConfig = await response.json();
// Update company information
updateCompanyInfo();
// Update branding (colors)
updateBranding();
// Update contact information
updateContactInfo();
// Update social media links
updateSocialLinks();
console.log('Site configuration loaded successfully');
} catch (err) {
console.error('Failed to load site configuration:', err);
}
}
function updateCompanyInfo() {
// Update company name
const companyNameElements = document.querySelectorAll('[data-company-name]');
companyNameElements.forEach(el => {
el.textContent = siteConfig.company.name;
});
// Update company tagline
const taglineElements = document.querySelectorAll('[data-company-tagline]');
taglineElements.forEach(el => {
el.textContent = siteConfig.company.tagline;
});
// Update company description
const descElements = document.querySelectorAll('[data-company-description]');
descElements.forEach(el => {
el.textContent = siteConfig.company.description;
});
// Update page title
const titleElement = document.querySelector('title');
if (titleElement && titleElement.textContent.includes('Ryans Recruit')) {
titleElement.textContent = titleElement.textContent.replace(/Ryans Recruit Firm|Ryans Recruit/g, siteConfig.company.name);
}
}
function updateBranding() {
// Set CSS custom properties for branding colors
const root = document.documentElement;
root.style.setProperty('--primary-color', siteConfig.branding.primaryColor);
root.style.setProperty('--accent-color', siteConfig.branding.accentColor);
root.style.setProperty('--secondary-color', siteConfig.branding.darkColor);
}
function updateContactInfo() {
// Update email
const emailElements = document.querySelectorAll('[data-contact-email]');
emailElements.forEach(el => {
if (el.tagName === 'A') {
el.href = `mailto:${siteConfig.contact.email}`;
}
el.textContent = siteConfig.contact.email;
});
// Update phone
const phoneElements = document.querySelectorAll('[data-contact-phone]');
phoneElements.forEach(el => {
if (el.tagName === 'A') {
el.href = `tel:${siteConfig.contact.phone}`;
}
el.textContent = siteConfig.contact.phone;
});
// Update address
const addressElements = document.querySelectorAll('[data-contact-address]');
addressElements.forEach(el => {
el.textContent = siteConfig.contact.address;
});
// Update business hours
const hoursElements = document.querySelectorAll('[data-business-hours]');
hoursElements.forEach(el => {
el.textContent = siteConfig.contact.businessHours;
});
}
function updateSocialLinks() {
// Update LinkedIn
const linkedinElements = document.querySelectorAll('[data-social-linkedin]');
linkedinElements.forEach(el => {
if (siteConfig.social.linkedin) {
el.href = siteConfig.social.linkedin;
el.style.display = '';
} else {
el.style.display = 'none';
}
});
// Update Twitter
const twitterElements = document.querySelectorAll('[data-social-twitter]');
twitterElements.forEach(el => {
if (siteConfig.social.twitter) {
el.href = siteConfig.social.twitter;
el.style.display = '';
} else {
el.style.display = 'none';
}
});
// Update Facebook
const facebookElements = document.querySelectorAll('[data-social-facebook]');
facebookElements.forEach(el => {
if (siteConfig.social.facebook) {
el.href = siteConfig.social.facebook;
el.style.display = '';
} else {
el.style.display = 'none';
}
});
}
// Load configuration when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadSiteConfig);
} else {
loadSiteConfig();
}

View File

@@ -6,17 +6,18 @@ const multer = require('multer');
const path = require('path'); const path = require('path');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const fs = require('fs'); const fs = require('fs');
const config = require('./config');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = config.app.port;
// PostgreSQL connection // PostgreSQL connection
const pool = new Pool({ const pool = new Pool({
host: process.env.DB_HOST || 'postgres', host: config.database.host,
port: process.env.DB_PORT || 5432, port: config.database.port,
database: process.env.DB_NAME || 'recruitment', database: config.database.name,
user: process.env.DB_USER || 'postgres', user: config.database.user,
password: process.env.DB_PASSWORD || 'postgres', password: config.database.password,
max: 20, max: 20,
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000, connectionTimeoutMillis: 2000,
@@ -64,11 +65,11 @@ app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(cookieParser()); app.use(cookieParser());
app.use(session({ app.use(session({
secret: process.env.SESSION_SECRET || 'recruitment-site-secret-key-change-in-production', secret: config.app.sessionSecret,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
secure: false, // Set to true if using HTTPS secure: config.app.nodeEnv === 'production', // Automatically enable for production
httpOnly: true, httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours maxAge: 24 * 60 * 60 * 1000 // 24 hours
} }
@@ -106,19 +107,16 @@ app.use((req, res, next) => {
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
limits: { limits: {
fileSize: 5 * 1024 * 1024, // 5MB limit fileSize: config.features.maxCvSizeMB * 1024 * 1024,
}, },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
const allowedTypes = /pdf|doc|docx/; const allowedTypes = config.features.allowedCvTypes;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); const isAllowed = allowedTypes.includes(file.mimetype);
const mimetype = allowedTypes.test(file.mimetype) ||
file.mimetype === 'application/msword' ||
file.mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
if (mimetype && extname) { if (isAllowed) {
return cb(null, true); return cb(null, true);
} }
cb(new Error('Only PDF, DOC, and DOCX files are allowed')); cb(new Error('File type not allowed. Only PDF, DOC, and DOCX files are accepted.'));
} }
}); });
@@ -135,6 +133,18 @@ const requireAuth = (req, res, next) => {
// PUBLIC API ENDPOINTS // PUBLIC API ENDPOINTS
// ============================================ // ============================================
// Get company configuration for frontend
app.get('/api/config', (req, res) => {
res.json({
company: config.company,
branding: config.branding,
contact: config.contact,
social: config.social,
about: config.about,
services: config.services
});
});
// Get all active job postings // Get all active job postings
app.get('/api/jobs', async (req, res) => { app.get('/api/jobs', async (req, res) => {
try { try {