Initial build: CareersGateway portal with CRICOS search, auth, Konpare OSHC widget, Kondesk lead push
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
158023df1a
10 changed files with 2917 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
.wrangler/
|
||||||
|
dist/
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
41
migrations/001_init.sql
Normal file
41
migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
full_name TEXT NOT NULL,
|
||||||
|
phone TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS inquiries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
full_name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
phone TEXT,
|
||||||
|
service TEXT,
|
||||||
|
cricos_course_code TEXT,
|
||||||
|
cricos_course_name TEXT,
|
||||||
|
cricos_provider TEXT,
|
||||||
|
message TEXT,
|
||||||
|
kondesk_sent INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cricos_cache (
|
||||||
|
cache_key TEXT PRIMARY KEY,
|
||||||
|
results_json TEXT NOT NULL,
|
||||||
|
cached_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inquiries_user ON inquiries(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cricos_cached ON cricos_cache(cached_at);
|
||||||
1603
package-lock.json
generated
Normal file
1603
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
17
package.json
Normal file
17
package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"name": "careersgateway",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"db:migrate": "wrangler d1 execute careersgateway-db --file=./migrations/001_init.sql"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.12.28"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20260702.1",
|
||||||
|
"wrangler": "^3.114.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/auth.js
Normal file
75
src/auth.js
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
const PBKDF2_ITERATIONS = 100000;
|
||||||
|
|
||||||
|
async function hashPassword(password) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']);
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||||||
|
keyMaterial, 256
|
||||||
|
);
|
||||||
|
const hashArr = Array.from(new Uint8Array(bits));
|
||||||
|
const saltArr = Array.from(salt);
|
||||||
|
return `pbkdf2:${PBKDF2_ITERATIONS}:${btoa(String.fromCharCode(...saltArr))}:${btoa(String.fromCharCode(...hashArr))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyPassword(password, stored) {
|
||||||
|
const [, iters, saltB64, hashB64] = stored.split(':');
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const salt = Uint8Array.from(atob(saltB64), c => c.charCodeAt(0));
|
||||||
|
const expectedHash = Uint8Array.from(atob(hashB64), c => c.charCodeAt(0));
|
||||||
|
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']);
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{ name: 'PBKDF2', salt, iterations: parseInt(iters), hash: 'SHA-256' },
|
||||||
|
keyMaterial, 256
|
||||||
|
);
|
||||||
|
const actualHash = new Uint8Array(bits);
|
||||||
|
if (actualHash.length !== expectedHash.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < actualHash.length; i++) diff |= actualHash[i] ^ expectedHash[i];
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateToken() {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSession(env, userId) {
|
||||||
|
const token = generateToken();
|
||||||
|
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||||
|
await env.SESSIONS.put(token, JSON.stringify({ userId, expires: expires.toISOString() }), {
|
||||||
|
expirationTtl: 30 * 24 * 60 * 60
|
||||||
|
});
|
||||||
|
return { token, expires };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSession(env, token) {
|
||||||
|
if (!token) return null;
|
||||||
|
const data = await env.SESSIONS.get(token);
|
||||||
|
if (!data) return null;
|
||||||
|
const session = JSON.parse(data);
|
||||||
|
if (new Date(session.expires) < new Date()) {
|
||||||
|
await env.SESSIONS.delete(token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentUser(env, c) {
|
||||||
|
const token = getCookie(c, 'session');
|
||||||
|
if (!token) return null;
|
||||||
|
const session = await getSession(env, token);
|
||||||
|
if (!session) return null;
|
||||||
|
const user = await env.DB.prepare('SELECT id, email, full_name, phone FROM users WHERE id = ?')
|
||||||
|
.bind(session.userId).first();
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(c, name) {
|
||||||
|
const header = c.req.header('Cookie') || '';
|
||||||
|
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { hashPassword, verifyPassword, createSession, getSession, getCurrentUser, getCookie };
|
||||||
105
src/cricos.js
Normal file
105
src/cricos.js
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
const CRICOS_BASE = 'https://cricos.education.gov.au';
|
||||||
|
const CACHE_TTL_HOURS = 24;
|
||||||
|
|
||||||
|
async function fetchViewState() {
|
||||||
|
const res = await fetch(`${CRICOS_BASE}/Course/CourseSearch.aspx`, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const html = await res.text();
|
||||||
|
const cookies = res.headers.get('set-cookie') || '';
|
||||||
|
|
||||||
|
const vs = (html.match(/id="__VIEWSTATE"\s+value="([^"]+)"/) || [])[1] || '';
|
||||||
|
const vsgen = (html.match(/id="__VIEWSTATEGENERATOR"\s+value="([^"]+)"/) || [])[1] || '';
|
||||||
|
const ev = (html.match(/id="__EVENTVALIDATION"\s+value="([^"]+)"/) || [])[1] || '';
|
||||||
|
return { vs, vsgen, ev, cookies };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCricosResults(html) {
|
||||||
|
const courses = [];
|
||||||
|
// Match table rows in the results table
|
||||||
|
const tableMatch = html.match(/<table[^>]*id="[^"]*grd[^"]*"[^>]*>([\s\S]*?)<\/table>/i)
|
||||||
|
|| html.match(/<table[^>]*class="[^"]*result[^"]*"[^>]*>([\s\S]*?)<\/table>/i)
|
||||||
|
|| html.match(/<table[^>]*>([\s\S]*?)<\/table>/gi);
|
||||||
|
|
||||||
|
if (!tableMatch) return courses;
|
||||||
|
|
||||||
|
const tableHtml = Array.isArray(tableMatch) ? tableMatch.find(t => t.includes('CRICOS') || t.includes('Course')) || tableMatch[0] : tableMatch[1] || tableMatch[0];
|
||||||
|
|
||||||
|
// Extract all <tr> rows with <td> cells
|
||||||
|
const rows = tableHtml.match(/<tr[^>]*>([\s\S]*?)<\/tr>/gi) || [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const cells = (row.match(/<td[^>]*>([\s\S]*?)<\/td>/gi) || [])
|
||||||
|
.map(td => td.replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/ /g, ' ').trim());
|
||||||
|
if (cells.length >= 4 && cells.some(c => c.length > 2)) {
|
||||||
|
courses.push({
|
||||||
|
cricosCode: cells[0] || '',
|
||||||
|
courseName: cells[1] || '',
|
||||||
|
provider: cells[2] || '',
|
||||||
|
state: cells[3] || '',
|
||||||
|
duration: cells[4] || '',
|
||||||
|
fee: cells[5] || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return courses.filter(c => c.cricosCode || c.courseName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchCricos(env, params) {
|
||||||
|
const cacheKey = JSON.stringify(params);
|
||||||
|
const cached = await env.DB.prepare('SELECT results_json, cached_at FROM cricos_cache WHERE cache_key = ?')
|
||||||
|
.bind(cacheKey).first();
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
const ageHours = (Date.now() - new Date(cached.cached_at).getTime()) / 3600000;
|
||||||
|
if (ageHours < CACHE_TTL_HOURS) {
|
||||||
|
return { results: JSON.parse(cached.results_json), fromCache: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { vs, vsgen, ev, cookies } = await fetchViewState();
|
||||||
|
|
||||||
|
const formData = new URLSearchParams({
|
||||||
|
'__VIEWSTATE': vs,
|
||||||
|
'__VIEWSTATEGENERATOR': vsgen,
|
||||||
|
'__EVENTVALIDATION': ev,
|
||||||
|
'ctl00$ContentPlaceHolder1$txtCourseName': params.courseName || '',
|
||||||
|
'ctl00$ContentPlaceHolder1$txtCRICOSCourseCode': params.cricosCode || '',
|
||||||
|
'ctl00$ContentPlaceHolder1$ddlState': params.state || '',
|
||||||
|
'ctl00$ContentPlaceHolder1$ddlCourseLevel': params.courseLevel || '',
|
||||||
|
'ctl00$ContentPlaceHolder1$ddlBroadField': params.broadField || '',
|
||||||
|
'ctl00$ContentPlaceHolder1$ddlNarrowField': '',
|
||||||
|
'ctl00$ContentPlaceHolder1$ddlDetailedField': '',
|
||||||
|
'ctl00$ContentPlaceHolder1$ddlLanguage': '',
|
||||||
|
'ctl00$ContentPlaceHolder1$btnSearch': 'Search',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${CRICOS_BASE}/Course/CourseSearch.aspx`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': `${CRICOS_BASE}/Course/CourseSearch.aspx`,
|
||||||
|
'Cookie': cookies,
|
||||||
|
},
|
||||||
|
body: formData.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = await res.text();
|
||||||
|
const results = parseCricosResults(html);
|
||||||
|
|
||||||
|
await env.DB.prepare(
|
||||||
|
'INSERT OR REPLACE INTO cricos_cache (cache_key, results_json, cached_at) VALUES (?, ?, datetime("now"))'
|
||||||
|
).bind(cacheKey, JSON.stringify(results)).run();
|
||||||
|
|
||||||
|
return { results, fromCache: false };
|
||||||
|
} catch (e) {
|
||||||
|
if (cached) return { results: JSON.parse(cached.results_json), fromCache: true, stale: true };
|
||||||
|
return { results: [], error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { searchCricos };
|
||||||
231
src/index.js
Normal file
231
src/index.js
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { hashPassword, verifyPassword, createSession, getCurrentUser, getCookie } from './auth.js';
|
||||||
|
import { searchCricos } from './cricos.js';
|
||||||
|
import { pushLeadToKondesk } from './kondesk.js';
|
||||||
|
import {
|
||||||
|
homePage, coursesPage, registerPage, loginPage,
|
||||||
|
dashboardPage, contactPage, healthInsurancePage, servicesPage, esc
|
||||||
|
} from './templates.js';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// ── Auth middleware (attaches user to context) ──────────────────────────────
|
||||||
|
app.use('*', async (c, next) => {
|
||||||
|
c.set('user', await getCurrentUser(c.env, c));
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── HOME ────────────────────────────────────────────────────────────────────
|
||||||
|
app.get('/', c => c.html(homePage(c.get('user'))));
|
||||||
|
|
||||||
|
// ── REGISTER ────────────────────────────────────────────────────────────────
|
||||||
|
app.get('/register', c => {
|
||||||
|
if (c.get('user')) return c.redirect('/dashboard');
|
||||||
|
return c.html(registerPage());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/register', async c => {
|
||||||
|
const form = await c.req.formData();
|
||||||
|
const fullName = (form.get('full_name') || '').trim();
|
||||||
|
const email = (form.get('email') || '').trim().toLowerCase();
|
||||||
|
const phone = (form.get('phone') || '').trim();
|
||||||
|
const password = form.get('password') || '';
|
||||||
|
const confirm = form.get('confirm_password') || '';
|
||||||
|
const vals = { full_name: fullName, email, phone };
|
||||||
|
|
||||||
|
if (!fullName || !email || !password) return c.html(registerPage('All required fields must be filled in.', vals));
|
||||||
|
if (password.length < 8) return c.html(registerPage('Password must be at least 8 characters.', vals));
|
||||||
|
if (password !== confirm) return c.html(registerPage('Passwords do not match.', vals));
|
||||||
|
|
||||||
|
const existing = await c.env.DB.prepare('SELECT id FROM users WHERE email = ?').bind(email).first();
|
||||||
|
if (existing) return c.html(registerPage('An account with this email already exists.', vals));
|
||||||
|
|
||||||
|
const hash = await hashPassword(password);
|
||||||
|
const result = await c.env.DB.prepare(
|
||||||
|
'INSERT INTO users (email, password_hash, full_name, phone) VALUES (?, ?, ?, ?)'
|
||||||
|
).bind(email, hash, fullName, phone).run();
|
||||||
|
|
||||||
|
const { token, expires } = await createSession(c.env, result.meta.last_row_id);
|
||||||
|
c.header('Set-Cookie', `session=${token}; Path=/; HttpOnly; SameSite=Lax; Expires=${expires.toUTCString()}`);
|
||||||
|
return c.redirect('/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── LOGIN ───────────────────────────────────────────────────────────────────
|
||||||
|
app.get('/login', c => {
|
||||||
|
if (c.get('user')) return c.redirect('/dashboard');
|
||||||
|
const redirect = c.req.query('redirect') || '';
|
||||||
|
return c.html(loginPage(null, redirect));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/login', async c => {
|
||||||
|
const form = await c.req.formData();
|
||||||
|
const email = (form.get('email') || '').trim().toLowerCase();
|
||||||
|
const password = form.get('password') || '';
|
||||||
|
const redirect = form.get('redirect') || '/dashboard';
|
||||||
|
|
||||||
|
const user = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(email).first();
|
||||||
|
if (!user || !(await verifyPassword(password, user.password_hash))) {
|
||||||
|
return c.html(loginPage('Incorrect email or password.', redirect));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, expires } = await createSession(c.env, user.id);
|
||||||
|
c.header('Set-Cookie', `session=${token}; Path=/; HttpOnly; SameSite=Lax; Expires=${expires.toUTCString()}`);
|
||||||
|
return c.redirect(redirect.startsWith('/') ? redirect : '/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── LOGOUT ──────────────────────────────────────────────────────────────────
|
||||||
|
app.get('/logout', async c => {
|
||||||
|
const token = getCookie(c, 'session');
|
||||||
|
if (token) await c.env.SESSIONS.delete(token);
|
||||||
|
c.header('Set-Cookie', 'session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
||||||
|
return c.redirect('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── CRICOS COURSE SEARCH ────────────────────────────────────────────────────
|
||||||
|
app.get('/courses', async c => {
|
||||||
|
const params = {
|
||||||
|
courseName: c.req.query('courseName') || '',
|
||||||
|
cricosCode: c.req.query('cricosCode') || '',
|
||||||
|
state: c.req.query('state') || '',
|
||||||
|
courseLevel: c.req.query('courseLevel') || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSearch = Object.values(params).some(v => v);
|
||||||
|
let results = [], fromCache = false, error = null;
|
||||||
|
|
||||||
|
if (hasSearch) {
|
||||||
|
const r = await searchCricos(c.env, params);
|
||||||
|
results = r.results;
|
||||||
|
fromCache = r.fromCache;
|
||||||
|
error = r.error || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.html(coursesPage(c.get('user'), results, params, fromCache, error));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DASHBOARD ───────────────────────────────────────────────────────────────
|
||||||
|
app.get('/dashboard', async c => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (!user) return c.redirect('/login?redirect=/dashboard');
|
||||||
|
const { results } = await c.env.DB.prepare(
|
||||||
|
'SELECT * FROM inquiries WHERE user_id = ? ORDER BY created_at DESC LIMIT 20'
|
||||||
|
).bind(user.id).all();
|
||||||
|
return c.html(dashboardPage(user, results));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/dashboard/save', async c => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (!user) return c.redirect('/login?redirect=/courses');
|
||||||
|
const { code, name, provider } = { code: c.req.query('code'), name: c.req.query('name'), provider: c.req.query('provider') };
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
'INSERT INTO inquiries (user_id, full_name, email, service, cricos_course_code, cricos_course_name, cricos_provider) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
).bind(user.id, user.full_name, user.email, 'Saved Course', code||'', name||'', provider||'').run();
|
||||||
|
return c.redirect('/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/dashboard/profile', async c => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (!user) return c.redirect('/login');
|
||||||
|
const { layout, esc } = await import('./templates.js');
|
||||||
|
return c.html(layout('My Profile', `
|
||||||
|
<section style="padding:40px 24px">
|
||||||
|
<div class="container" style="max-width:560px">
|
||||||
|
<div class="form-card">
|
||||||
|
<h2>My Profile</h2>
|
||||||
|
<p class="sub">Update your account information</p>
|
||||||
|
<form method="POST" action="/dashboard/profile">
|
||||||
|
<div class="form-group"><label>Full Name</label><input type="text" name="full_name" value="${esc(user.full_name)}" required></div>
|
||||||
|
<div class="form-group"><label>Email</label><input type="email" value="${esc(user.email)}" disabled style="background:#f1f5f9;color:#94a3b8"></div>
|
||||||
|
<div class="form-group"><label>Phone</label><input type="tel" name="phone" value="${esc(user.phone||'')}"></div>
|
||||||
|
<div class="form-group"><label>New Password (leave blank to keep current)</label><input type="password" name="password" minlength="8"></div>
|
||||||
|
<button type="submit" class="form-submit">Save Changes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`, user));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/dashboard/profile', async c => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (!user) return c.redirect('/login');
|
||||||
|
const form = await c.req.formData();
|
||||||
|
const fullName = (form.get('full_name') || '').trim();
|
||||||
|
const phone = (form.get('phone') || '').trim();
|
||||||
|
const password = form.get('password') || '';
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
const hash = await hashPassword(password);
|
||||||
|
await c.env.DB.prepare('UPDATE users SET full_name=?, phone=?, password_hash=? WHERE id=?')
|
||||||
|
.bind(fullName, phone, hash, user.id).run();
|
||||||
|
} else {
|
||||||
|
await c.env.DB.prepare('UPDATE users SET full_name=?, phone=? WHERE id=?')
|
||||||
|
.bind(fullName, phone, user.id).run();
|
||||||
|
}
|
||||||
|
return c.redirect('/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── CONTACT / INQUIRY ────────────────────────────────────────────────────────
|
||||||
|
app.get('/contact', c => {
|
||||||
|
const params = {
|
||||||
|
course: c.req.query('course') || '',
|
||||||
|
code: c.req.query('code') || '',
|
||||||
|
provider: c.req.query('provider') || '',
|
||||||
|
service: c.req.query('service') || '',
|
||||||
|
};
|
||||||
|
return c.html(contactPage(c.get('user'), params));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/contact', async c => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const form = await c.req.formData();
|
||||||
|
const fullName = (form.get('full_name') || '').trim();
|
||||||
|
const email = (form.get('email') || '').trim().toLowerCase();
|
||||||
|
const phone = (form.get('phone') || '').trim();
|
||||||
|
const service = form.get('service') || 'General Inquiry';
|
||||||
|
const message = form.get('message') || '';
|
||||||
|
const cricosCode = form.get('cricos_code') || '';
|
||||||
|
const cricosName = form.get('cricos_course') || '';
|
||||||
|
const cricosProvider = form.get('cricos_provider') || '';
|
||||||
|
|
||||||
|
if (!fullName || !email) {
|
||||||
|
return c.html(contactPage(user, {}, false, 'Name and email are required.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const insert = await c.env.DB.prepare(
|
||||||
|
`INSERT INTO inquiries (user_id, full_name, email, phone, service, cricos_course_code, cricos_course_name, cricos_provider, message)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).bind(user?.id || null, fullName, email, phone, service, cricosCode, cricosName, cricosProvider, message).run();
|
||||||
|
|
||||||
|
const lead = { fullName, email, phone, service, cricosCode, courseName: cricosName, provider: cricosProvider, message };
|
||||||
|
const pushed = await pushLeadToKondesk(c.env, lead);
|
||||||
|
|
||||||
|
if (pushed.success) {
|
||||||
|
await c.env.DB.prepare('UPDATE inquiries SET kondesk_sent=1 WHERE id=?')
|
||||||
|
.bind(insert.meta.last_row_id).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = { course: cricosName, code: cricosCode, provider: cricosProvider };
|
||||||
|
return c.html(contactPage(user, params, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── HEALTH INSURANCE (KONPARE WIDGET) ────────────────────────────────────────
|
||||||
|
app.get('/health-insurance', c =>
|
||||||
|
c.html(healthInsurancePage(c.get('user'), c.env.KONPARE_KEY))
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── SERVICES ─────────────────────────────────────────────────────────────────
|
||||||
|
app.get('/services', c => c.redirect('/'));
|
||||||
|
app.get('/services/:slug', c => {
|
||||||
|
const html = servicesPage(c.get('user'), c.req.param('slug'));
|
||||||
|
if (!html) return c.redirect('/');
|
||||||
|
return c.html(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 404 ──────────────────────────────────────────────────────────────────────
|
||||||
|
app.notFound(c => {
|
||||||
|
const { layout } = { layout: (t, b, u) => `<!DOCTYPE html><html><head><title>${t}</title><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="font-family:Arial;text-align:center;padding:80px 24px;color:#1a2744"><h1 style="font-size:4rem">404</h1><p style="color:#64748b;margin:12px 0 24px">Page not found</p><a href="/" style="background:#1a5bb8;color:#fff;padding:10px 24px;border-radius:8px;text-decoration:none">Go Home</a></body></html>` };
|
||||||
|
return c.html(`<!DOCTYPE html><html><head><title>404 Not Found</title><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="font-family:Arial;text-align:center;padding:80px 24px;color:#1a2744"><h1 style="font-size:4rem">404</h1><p style="color:#64748b;margin:12px 0 24px">Page not found</p><a href="/" style="background:#1a5bb8;color:#fff;padding:10px 24px;border-radius:8px;text-decoration:none;font-weight:700">Go Home</a></body></html>`, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
76
src/kondesk.js
Normal file
76
src/kondesk.js
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
// Konpare/Kondesk lead push
|
||||||
|
// The Konpare API key is used for their widget embed (OSHC comparison)
|
||||||
|
// and for posting lead data when a course inquiry is submitted.
|
||||||
|
async function pushLeadToKondesk(env, lead) {
|
||||||
|
const payload = {
|
||||||
|
api_key: env.KONPARE_KEY,
|
||||||
|
lead: {
|
||||||
|
full_name: lead.fullName,
|
||||||
|
email: lead.email,
|
||||||
|
phone: lead.phone || '',
|
||||||
|
service: lead.service || 'Course Inquiry',
|
||||||
|
course_code: lead.cricosCode || '',
|
||||||
|
course_name: lead.courseName || '',
|
||||||
|
provider: lead.provider || '',
|
||||||
|
message: lead.message || '',
|
||||||
|
source: 'careers.bored.investments',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try Konpare lead submission endpoint
|
||||||
|
const endpoints = [
|
||||||
|
'https://app.konpare.online/api/leads',
|
||||||
|
'https://app.konpare.online/api/v1/leads',
|
||||||
|
'https://app.kondesk.com/api/leads',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const url of endpoints) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${env.KONPARE_KEY}`,
|
||||||
|
'x-api-key': env.KONPARE_KEY,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (res.ok) return { success: true, endpoint: url };
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: email notification
|
||||||
|
await sendEmailFallback(env, lead);
|
||||||
|
return { success: false, fallback: 'email' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmailFallback(env, lead) {
|
||||||
|
// Cloudflare Email Routing send — uses MailChannels via Workers
|
||||||
|
const emailBody = `
|
||||||
|
New Lead from Careers Gateway Portal
|
||||||
|
|
||||||
|
Name: ${lead.fullName}
|
||||||
|
Email: ${lead.email}
|
||||||
|
Phone: ${lead.phone || 'N/A'}
|
||||||
|
Service: ${lead.service || 'N/A'}
|
||||||
|
Course: ${lead.courseName || 'N/A'} (${lead.cricosCode || 'N/A'})
|
||||||
|
Provider: ${lead.provider || 'N/A'}
|
||||||
|
Message: ${lead.message || 'N/A'}
|
||||||
|
Submitted: ${new Date().toLocaleString('en-AU', { timeZone: 'Australia/Sydney' })}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('https://api.mailchannels.net/tx/v1/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
personalizations: [{ to: [{ email: env.CONTACT_EMAIL, name: 'Careers Gateway' }] }],
|
||||||
|
from: { email: 'noreply@careersgateway.com.au', name: 'Careers Gateway Portal' },
|
||||||
|
subject: `New Lead: ${lead.fullName} — ${lead.service || 'Course Inquiry'}`,
|
||||||
|
content: [{ type: 'text/plain', value: emailBody }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { pushLeadToKondesk };
|
||||||
743
src/templates.js
Normal file
743
src/templates.js
Normal file
|
|
@ -0,0 +1,743 @@
|
||||||
|
const CSS = `
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:'Segoe UI',Arial,sans-serif;color:#1a2744;background:#f8faff}
|
||||||
|
a{color:#1a5bb8;text-decoration:none}
|
||||||
|
a:hover{text-decoration:underline}
|
||||||
|
|
||||||
|
/* NAV */
|
||||||
|
nav{background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.08);position:sticky;top:0;z-index:100}
|
||||||
|
.nav-inner{max-width:1200px;margin:0 auto;padding:0 24px;display:flex;align-items:center;justify-content:space-between;height:68px}
|
||||||
|
.nav-logo{font-size:1.25rem;font-weight:700;color:#1a2744;display:flex;align-items:center;gap:10px}
|
||||||
|
.nav-logo span{color:#1a5bb8}
|
||||||
|
.nav-links{display:flex;gap:28px;align-items:center;list-style:none}
|
||||||
|
.nav-links a{font-size:.95rem;color:#1a2744;font-weight:500}
|
||||||
|
.nav-links a:hover{color:#1a5bb8;text-decoration:none}
|
||||||
|
.nav-cta{background:#1a5bb8;color:#fff!important;padding:9px 22px;border-radius:6px;font-weight:600!important}
|
||||||
|
.nav-cta:hover{background:#154fa0;text-decoration:none!important}
|
||||||
|
.nav-user{display:flex;align-items:center;gap:16px;font-size:.9rem}
|
||||||
|
|
||||||
|
/* HERO */
|
||||||
|
.hero{background:linear-gradient(135deg,#1a2744 0%,#1a5bb8 100%);color:#fff;padding:80px 24px;text-align:center}
|
||||||
|
.hero h1{font-size:2.8rem;font-weight:800;margin-bottom:16px;line-height:1.2}
|
||||||
|
.hero p{font-size:1.2rem;opacity:.9;max-width:600px;margin:0 auto 32px}
|
||||||
|
.hero-btns{display:flex;gap:16px;justify-content:center;flex-wrap:wrap}
|
||||||
|
.btn{display:inline-block;padding:13px 30px;border-radius:8px;font-weight:700;font-size:1rem;cursor:pointer;border:none;transition:.2s}
|
||||||
|
.btn-white{background:#fff;color:#1a5bb8}
|
||||||
|
.btn-white:hover{background:#e8f0fe;text-decoration:none}
|
||||||
|
.btn-outline{border:2px solid #fff;color:#fff;background:transparent}
|
||||||
|
.btn-outline:hover{background:rgba(255,255,255,.12);text-decoration:none}
|
||||||
|
.btn-primary{background:#1a5bb8;color:#fff}
|
||||||
|
.btn-primary:hover{background:#154fa0;text-decoration:none}
|
||||||
|
.btn-success{background:#16a34a;color:#fff}
|
||||||
|
.btn-success:hover{background:#15803d;text-decoration:none}
|
||||||
|
|
||||||
|
/* SECTIONS */
|
||||||
|
section{padding:64px 24px}
|
||||||
|
.container{max-width:1200px;margin:0 auto}
|
||||||
|
.section-title{font-size:2rem;font-weight:700;text-align:center;margin-bottom:12px;color:#1a2744}
|
||||||
|
.section-sub{text-align:center;color:#64748b;font-size:1.05rem;margin-bottom:48px}
|
||||||
|
|
||||||
|
/* SERVICES GRID */
|
||||||
|
.services-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px}
|
||||||
|
.service-card{background:#fff;border-radius:12px;padding:28px;box-shadow:0 2px 12px rgba(0,0,0,.06);transition:.2s;border:1px solid #e8f0fe}
|
||||||
|
.service-card:hover{transform:translateY(-3px);box-shadow:0 8px 24px rgba(26,91,184,.12)}
|
||||||
|
.service-icon{font-size:2rem;margin-bottom:12px}
|
||||||
|
.service-card h3{font-size:1.1rem;font-weight:700;margin-bottom:8px;color:#1a2744}
|
||||||
|
.service-card p{font-size:.9rem;color:#64748b;line-height:1.6}
|
||||||
|
.service-link{display:inline-block;margin-top:14px;font-size:.9rem;font-weight:600;color:#1a5bb8}
|
||||||
|
|
||||||
|
/* STATS */
|
||||||
|
.stats-bar{background:#1a5bb8;color:#fff;padding:40px 24px}
|
||||||
|
.stats-inner{max-width:1200px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:24px;text-align:center}
|
||||||
|
.stat-num{font-size:2.2rem;font-weight:800}
|
||||||
|
.stat-label{font-size:.9rem;opacity:.85;margin-top:4px}
|
||||||
|
|
||||||
|
/* WHY US */
|
||||||
|
.why-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:20px}
|
||||||
|
.why-card{background:#fff;border-radius:10px;padding:24px;border-left:4px solid #1a5bb8;box-shadow:0 2px 8px rgba(0,0,0,.05)}
|
||||||
|
.why-card h4{font-weight:700;margin-bottom:8px;color:#1a2744}
|
||||||
|
.why-card p{font-size:.9rem;color:#64748b}
|
||||||
|
|
||||||
|
/* TESTIMONIALS */
|
||||||
|
.testi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:24px}
|
||||||
|
.testi-card{background:#fff;border-radius:12px;padding:28px;box-shadow:0 2px 12px rgba(0,0,0,.06)}
|
||||||
|
.stars{color:#f59e0b;font-size:1.1rem;margin-bottom:12px}
|
||||||
|
.testi-text{color:#374151;font-style:italic;line-height:1.7;margin-bottom:16px}
|
||||||
|
.testi-name{font-weight:700;color:#1a2744}
|
||||||
|
|
||||||
|
/* FORMS */
|
||||||
|
.form-card{background:#fff;border-radius:16px;padding:40px;max-width:520px;margin:0 auto;box-shadow:0 4px 24px rgba(0,0,0,.08)}
|
||||||
|
.form-card h2{font-size:1.6rem;font-weight:700;margin-bottom:8px;text-align:center}
|
||||||
|
.form-card .sub{text-align:center;color:#64748b;margin-bottom:28px;font-size:.95rem}
|
||||||
|
.form-group{margin-bottom:18px}
|
||||||
|
.form-group label{display:block;font-weight:600;font-size:.9rem;margin-bottom:6px;color:#1a2744}
|
||||||
|
.form-group input,.form-group select,.form-group textarea{width:100%;padding:11px 14px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.95rem;transition:.2s;background:#fafbff}
|
||||||
|
.form-group input:focus,.form-group select:focus,.form-group textarea:focus{outline:none;border-color:#1a5bb8;background:#fff}
|
||||||
|
.form-group textarea{resize:vertical;min-height:100px}
|
||||||
|
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||||
|
.form-submit{width:100%;padding:13px;background:#1a5bb8;color:#fff;border:none;border-radius:8px;font-size:1rem;font-weight:700;cursor:pointer;transition:.2s;margin-top:8px}
|
||||||
|
.form-submit:hover{background:#154fa0}
|
||||||
|
.form-footer{text-align:center;margin-top:20px;font-size:.9rem;color:#64748b}
|
||||||
|
.alert{padding:12px 16px;border-radius:8px;margin-bottom:18px;font-size:.9rem}
|
||||||
|
.alert-success{background:#dcfce7;color:#16a34a;border:1px solid #bbf7d0}
|
||||||
|
.alert-error{background:#fee2e2;color:#dc2626;border:1px solid #fecaca}
|
||||||
|
.alert-info{background:#dbeafe;color:#1d4ed8;border:1px solid #bfdbfe}
|
||||||
|
|
||||||
|
/* COURSE SEARCH */
|
||||||
|
.search-bar{background:#fff;border-radius:12px;padding:28px;box-shadow:0 2px 12px rgba(0,0,0,.06);margin-bottom:32px}
|
||||||
|
.search-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;align-items:end}
|
||||||
|
.search-grid .btn{padding:11px 24px;font-size:.95rem}
|
||||||
|
.results-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}
|
||||||
|
.results-count{font-size:.95rem;color:#64748b}
|
||||||
|
.course-card{background:#fff;border-radius:10px;padding:22px;box-shadow:0 2px 8px rgba(0,0,0,.05);margin-bottom:16px;border:1px solid #e8f0fe;display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap}
|
||||||
|
.course-info h3{font-size:1.05rem;font-weight:700;color:#1a2744;margin-bottom:6px}
|
||||||
|
.course-meta{display:flex;gap:12px;flex-wrap:wrap;margin-top:8px}
|
||||||
|
.badge{display:inline-block;padding:3px 10px;border-radius:20px;font-size:.8rem;font-weight:600}
|
||||||
|
.badge-blue{background:#dbeafe;color:#1d4ed8}
|
||||||
|
.badge-green{background:#dcfce7;color:#166534}
|
||||||
|
.badge-gray{background:#f3f4f6;color:#374151}
|
||||||
|
.course-actions{display:flex;flex-direction:column;gap:8px;min-width:160px}
|
||||||
|
.btn-sm{padding:8px 16px;font-size:.85rem;border-radius:6px}
|
||||||
|
|
||||||
|
/* DASHBOARD */
|
||||||
|
.dash-grid{display:grid;grid-template-columns:240px 1fr;gap:28px;align-items:start}
|
||||||
|
.dash-sidebar{background:#fff;border-radius:12px;padding:24px;box-shadow:0 2px 8px rgba(0,0,0,.05)}
|
||||||
|
.dash-user{text-align:center;margin-bottom:24px;padding-bottom:20px;border-bottom:1px solid #e8f0fe}
|
||||||
|
.dash-avatar{width:64px;height:64px;background:#1a5bb8;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:1.6rem;font-weight:700;margin:0 auto 12px}
|
||||||
|
.dash-nav{list-style:none}
|
||||||
|
.dash-nav li{margin-bottom:4px}
|
||||||
|
.dash-nav a{display:block;padding:10px 14px;border-radius:8px;font-size:.95rem;color:#374151;font-weight:500;transition:.15s}
|
||||||
|
.dash-nav a:hover,.dash-nav a.active{background:#dbeafe;color:#1a5bb8;text-decoration:none}
|
||||||
|
.dash-main{display:flex;flex-direction:column;gap:24px}
|
||||||
|
.dash-card{background:#fff;border-radius:12px;padding:28px;box-shadow:0 2px 8px rgba(0,0,0,.05)}
|
||||||
|
.dash-card h3{font-size:1.1rem;font-weight:700;margin-bottom:18px;color:#1a2744;padding-bottom:12px;border-bottom:1px solid #e8f0fe}
|
||||||
|
.inquiry-row{padding:14px 0;border-bottom:1px solid #f1f5f9;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px}
|
||||||
|
.inquiry-row:last-child{border-bottom:none}
|
||||||
|
.inquiry-info{font-size:.9rem;color:#374151}
|
||||||
|
.inquiry-date{font-size:.8rem;color:#94a3b8}
|
||||||
|
|
||||||
|
/* FOOTER */
|
||||||
|
footer{background:#1a2744;color:#cbd5e1;padding:48px 24px 24px}
|
||||||
|
.footer-inner{max-width:1200px;margin:0 auto}
|
||||||
|
.footer-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:32px;margin-bottom:40px}
|
||||||
|
.footer-col h4{color:#fff;font-weight:700;margin-bottom:16px}
|
||||||
|
.footer-col ul{list-style:none}
|
||||||
|
.footer-col ul li{margin-bottom:8px}
|
||||||
|
.footer-col ul li a{color:#94a3b8;font-size:.9rem}
|
||||||
|
.footer-col ul li a:hover{color:#fff;text-decoration:none}
|
||||||
|
.footer-contact{font-size:.9rem;line-height:1.8;color:#94a3b8}
|
||||||
|
.footer-bottom{border-top:1px solid #2d3d5a;padding-top:20px;text-align:center;font-size:.85rem;color:#64748b}
|
||||||
|
|
||||||
|
/* KONPARE WIDGET */
|
||||||
|
.widget-wrap{background:#fff;border-radius:12px;padding:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);overflow:hidden}
|
||||||
|
.widget-wrap iframe{width:100%;border:none;min-height:560px;display:block}
|
||||||
|
|
||||||
|
@media(max-width:768px){
|
||||||
|
.hero h1{font-size:1.9rem}
|
||||||
|
.nav-links{display:none}
|
||||||
|
.dash-grid{grid-template-columns:1fr}
|
||||||
|
.form-row{grid-template-columns:1fr}
|
||||||
|
.course-card{flex-direction:column}
|
||||||
|
.course-actions{flex-direction:row;min-width:auto}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function layout(title, body, user = null, extraHead = '') {
|
||||||
|
const navUser = user
|
||||||
|
? `<div class="nav-user"><span>Hi, <strong>${esc(user.full_name.split(' ')[0])}</strong></span><a href="/dashboard" class="btn btn-primary btn-sm" style="padding:7px 16px;font-size:.85rem">Dashboard</a><a href="/logout" style="font-size:.85rem;color:#64748b">Logout</a></div>`
|
||||||
|
: `<div class="nav-user"><a href="/login">Login</a><a href="/register" class="btn btn-primary btn-sm" style="padding:7px 16px;font-size:.85rem">Register</a></div>`;
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>${esc(title)} — Careers Gateway Australia</title>
|
||||||
|
<style>${CSS}</style>
|
||||||
|
${extraHead}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="/" class="nav-logo">🎓 Careers <span>Gateway</span></a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/">Home</a></li>
|
||||||
|
<li><a href="/courses">Find Courses</a></li>
|
||||||
|
<li><a href="/services">Services</a></li>
|
||||||
|
<li><a href="/health-insurance">Health Insurance</a></li>
|
||||||
|
<li><a href="/contact">Contact</a></li>
|
||||||
|
<li><a href="/courses" class="nav-cta">Search CRICOS</a></li>
|
||||||
|
</ul>
|
||||||
|
${navUser}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
${body}
|
||||||
|
<footer>
|
||||||
|
<div class="footer-inner">
|
||||||
|
<div class="footer-grid">
|
||||||
|
<div class="footer-col">
|
||||||
|
<h4>🎓 Careers Gateway</h4>
|
||||||
|
<p class="footer-contact">Your trusted partner for education, migration, and career services in Australia.<br><br>
|
||||||
|
📍 Sydney, NSW<br>📞 +61 2 XXXX XXXX<br>✉️ info@careersgateway.com.au</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer-col">
|
||||||
|
<h4>Services</h4>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/services/education">Education & Visa</a></li>
|
||||||
|
<li><a href="/services/migration">Migration Services</a></li>
|
||||||
|
<li><a href="/services/recruitment">Recruitment & Labour Hire</a></li>
|
||||||
|
<li><a href="/services/rpl">RPL Assessment</a></li>
|
||||||
|
<li><a href="/services/skills">Skills Assessment</a></li>
|
||||||
|
<li><a href="/health-insurance">OSHC/OVHC Insurance</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="footer-col">
|
||||||
|
<h4>Quick Links</h4>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/courses">CRICOS Course Search</a></li>
|
||||||
|
<li><a href="/register">Create Account</a></li>
|
||||||
|
<li><a href="/contact">Book Consultation</a></li>
|
||||||
|
<li><a href="https://careersgateway.com.au">Main Website</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-bottom">
|
||||||
|
© ${new Date().getFullYear()} Careers Gateway Australia. All rights reserved. |
|
||||||
|
<a href="https://careersgateway.com.au" style="color:#94a3b8">careersgateway.com.au</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
return String(str || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function homePage(user) {
|
||||||
|
return layout('Your Gateway to Success', `
|
||||||
|
<div class="hero">
|
||||||
|
<div class="container">
|
||||||
|
<h1>Your Gateway to Success<br>in Australia 🇦🇺</h1>
|
||||||
|
<p>Guiding individuals and businesses through education, migration, and career pathways with certified, multilingual professionals.</p>
|
||||||
|
<div class="hero-btns">
|
||||||
|
<a href="/courses" class="btn btn-white">🔍 Search CRICOS Courses</a>
|
||||||
|
<a href="/contact" class="btn btn-outline">Book Free Consultation</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stats-inner">
|
||||||
|
<div><div class="stat-num">5,000+</div><div class="stat-label">Clients Helped</div></div>
|
||||||
|
<div><div class="stat-num">98%</div><div class="stat-label">Success Rate</div></div>
|
||||||
|
<div><div class="stat-num">15+</div><div class="stat-label">Years Experience</div></div>
|
||||||
|
<div><div class="stat-num">50+</div><div class="stat-label">Partner Institutions</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section style="background:#fff">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="section-title">Our Services</h2>
|
||||||
|
<p class="section-sub">Comprehensive support for every stage of your Australian journey</p>
|
||||||
|
<div class="services-grid">
|
||||||
|
${[
|
||||||
|
['🎓','Education & Visa Services','Expert guidance on student visas, course selection, and institution enrolment through CRICOS-registered providers.','/services/education'],
|
||||||
|
['🌏','Migration Services','Skilled migration, family visas, employer nominations, and permanent residency pathways.','/services/migration'],
|
||||||
|
['💼','Recruitment & Labour Hire','Connecting skilled professionals with Australian employers. RPL-recognised workforce solutions.','/services/recruitment'],
|
||||||
|
['📋','RPL Assessment','Recognition of Prior Learning for tradespeople, nurses, engineers, and other professionals.','/services/rpl'],
|
||||||
|
['✅','Skills Assessment','Formal skills assessments for visa and registration purposes across multiple industry bodies.','/services/skills'],
|
||||||
|
['🏥','Health Insurance (OSHC/OVHC)','Compare and buy Overseas Student Health Cover and Overseas Visitor Health Cover instantly.','/health-insurance'],
|
||||||
|
['💰','Easy Tax Return','Simple, fast Australian tax returns for international students and working holiday visa holders.','/services/tax'],
|
||||||
|
['❤️','Aged Care Services','Placement and support services for aged care pathways and qualifications.','/services/aged-care'],
|
||||||
|
].map(([icon,title,desc,link]) => `
|
||||||
|
<div class="service-card">
|
||||||
|
<div class="service-icon">${icon}</div>
|
||||||
|
<h3>${title}</h3>
|
||||||
|
<p>${desc}</p>
|
||||||
|
<a href="${link}" class="service-link">Learn more →</a>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="background:#f0f6ff">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="section-title">Find Your Course</h2>
|
||||||
|
<p class="section-sub">Search thousands of CRICOS-registered courses from Australian institutions</p>
|
||||||
|
<form action="/courses" method="GET" style="background:#fff;border-radius:12px;padding:32px;box-shadow:0 2px 12px rgba(0,0,0,.06);max-width:800px;margin:0 auto">
|
||||||
|
<div class="search-grid">
|
||||||
|
<div class="form-group" style="margin:0">
|
||||||
|
<label>Course Name</label>
|
||||||
|
<input type="text" name="courseName" placeholder="e.g. Bachelor of Nursing">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0">
|
||||||
|
<label>State</label>
|
||||||
|
<select name="state">
|
||||||
|
<option value="">All States</option>
|
||||||
|
<option>NSW</option><option>VIC</option><option>QLD</option>
|
||||||
|
<option>WA</option><option>SA</option><option>TAS</option>
|
||||||
|
<option>ACT</option><option>NT</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0">
|
||||||
|
<label>Course Level</label>
|
||||||
|
<select name="courseLevel">
|
||||||
|
<option value="">All Levels</option>
|
||||||
|
<option value="1">Bachelor Degree</option>
|
||||||
|
<option value="2">Master Degree</option>
|
||||||
|
<option value="3">Doctoral Degree</option>
|
||||||
|
<option value="4">Diploma</option>
|
||||||
|
<option value="5">Certificate</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:flex-end">
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%;padding:11px">🔍 Search</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="section-title">Why Choose Us?</h2>
|
||||||
|
<p class="section-sub">Five reasons thousands trust Careers Gateway</p>
|
||||||
|
<div class="why-grid">
|
||||||
|
${[
|
||||||
|
['🏅','MARA Registered Agents','All migration advice from registered MARA agents — fully compliant and trustworthy.'],
|
||||||
|
['🌐','Multilingual Support','We speak your language. Services available in English, Hindi, Nepali, and more.'],
|
||||||
|
['🔄','End-to-End Service','From course selection to visa lodgement to arrival — one team, the whole journey.'],
|
||||||
|
['📊','Proven Track Record','98% success rate across thousands of student and migration visa applications.'],
|
||||||
|
['💡','Transparent Pricing','No hidden fees. Clear, upfront pricing with written agreements for all services.'],
|
||||||
|
].map(([icon,title,desc]) => `
|
||||||
|
<div class="why-card">
|
||||||
|
<h4>${icon} ${title}</h4>
|
||||||
|
<p>${desc}</p>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="background:#fff">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="section-title">What Our Clients Say</h2>
|
||||||
|
<p class="section-sub">Real stories from real people</p>
|
||||||
|
<div class="testi-grid">
|
||||||
|
${[
|
||||||
|
['Priya S.','Sydney','Careers Gateway made my student visa process so easy. They found me the perfect nursing course and handled everything. Couldn\'t recommend them more highly!'],
|
||||||
|
['Raj M.','Melbourne','From RPL assessment to skills recognition — the team was professional and quick. I got my qualification recognised within 3 months.'],
|
||||||
|
['Anita K.','Brisbane','The migration team helped my whole family get permanent residency. They were patient, thorough, and available whenever we needed them.'],
|
||||||
|
].map(([name,city,text]) => `
|
||||||
|
<div class="testi-card">
|
||||||
|
<div class="stars">★★★★★</div>
|
||||||
|
<p class="testi-text">"${text}"</p>
|
||||||
|
<div class="testi-name">${name} <span style="color:#94a3b8;font-weight:400">— ${city}</span></div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="background:linear-gradient(135deg,#1a2744,#1a5bb8);color:#fff;text-align:center">
|
||||||
|
<div class="container">
|
||||||
|
<h2 style="font-size:2rem;font-weight:800;margin-bottom:12px">Ready to Start Your Journey?</h2>
|
||||||
|
<p style="opacity:.9;margin-bottom:32px;font-size:1.1rem">Book a free consultation with our experts today</p>
|
||||||
|
<div style="display:flex;gap:16px;justify-content:center;flex-wrap:wrap">
|
||||||
|
<a href="/register" class="btn btn-white">Create Free Account</a>
|
||||||
|
<a href="/contact" class="btn btn-outline">Book Consultation</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coursesPage(user, results, params, fromCache, error) {
|
||||||
|
const hasSearch = Object.values(params).some(v => v);
|
||||||
|
const stateOptions = ['NSW','VIC','QLD','WA','SA','TAS','ACT','NT'];
|
||||||
|
const levelOptions = [['1','Bachelor Degree'],['2','Master Degree'],['3','Doctoral Degree'],['4','Diploma'],['5','Certificate'],['6','Advanced Diploma'],['7','Graduate Certificate'],['8','Graduate Diploma']];
|
||||||
|
|
||||||
|
return layout('CRICOS Course Search', `
|
||||||
|
<section style="background:linear-gradient(135deg,#1a2744,#1a5bb8);padding:40px 24px;color:#fff;text-align:center">
|
||||||
|
<div class="container">
|
||||||
|
<h1 style="font-size:2rem;font-weight:800;margin-bottom:8px">🔍 CRICOS Course Search</h1>
|
||||||
|
<p style="opacity:.9">Search thousands of courses from CRICOS-registered Australian institutions</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div class="container">
|
||||||
|
<form action="/courses" method="GET" class="search-bar">
|
||||||
|
<div class="search-grid">
|
||||||
|
<div class="form-group" style="margin:0">
|
||||||
|
<label>Course Name</label>
|
||||||
|
<input type="text" name="courseName" placeholder="e.g. Bachelor of Nursing" value="${esc(params.courseName||'')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0">
|
||||||
|
<label>CRICOS Code</label>
|
||||||
|
<input type="text" name="cricosCode" placeholder="e.g. 093765C" value="${esc(params.cricosCode||'')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0">
|
||||||
|
<label>State</label>
|
||||||
|
<select name="state">
|
||||||
|
<option value="">All States</option>
|
||||||
|
${stateOptions.map(s => `<option value="${s}"${params.state===s?' selected':''}>${s}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0">
|
||||||
|
<label>Course Level</label>
|
||||||
|
<select name="courseLevel">
|
||||||
|
<option value="">All Levels</option>
|
||||||
|
${levelOptions.map(([v,l]) => `<option value="${v}"${params.courseLevel===v?' selected':''}>${l}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:flex-end;gap:8px">
|
||||||
|
<button type="submit" class="btn btn-primary" style="flex:1;padding:11px">🔍 Search</button>
|
||||||
|
<a href="/courses" class="btn" style="padding:11px 14px;background:#f1f5f9;color:#374151">Clear</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
${!hasSearch ? `
|
||||||
|
<div style="text-align:center;padding:60px 20px;color:#64748b">
|
||||||
|
<div style="font-size:3rem;margin-bottom:16px">🎓</div>
|
||||||
|
<h3 style="font-size:1.3rem;margin-bottom:8px;color:#1a2744">Search CRICOS-Registered Courses</h3>
|
||||||
|
<p>Enter a course name, state, or level above to find courses from Australian institutions</p>
|
||||||
|
</div>
|
||||||
|
` : error ? `
|
||||||
|
<div class="alert alert-error">Unable to fetch CRICOS data right now. Please try again shortly.</div>
|
||||||
|
` : results.length === 0 ? `
|
||||||
|
<div style="text-align:center;padding:60px 20px;color:#64748b">
|
||||||
|
<div style="font-size:3rem;margin-bottom:16px">🔎</div>
|
||||||
|
<h3 style="font-size:1.3rem;margin-bottom:8px;color:#1a2744">No courses found</h3>
|
||||||
|
<p>Try broadening your search — use fewer keywords or remove filters</p>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="results-header">
|
||||||
|
<div class="results-count"><strong>${results.length}</strong> courses found ${fromCache ? '<span style="color:#94a3b8;font-size:.8rem">(cached)</span>' : ''}</div>
|
||||||
|
${user ? '' : '<a href="/register" class="btn btn-primary btn-sm">Save courses — Register free</a>'}
|
||||||
|
</div>
|
||||||
|
${results.map(c => `
|
||||||
|
<div class="course-card">
|
||||||
|
<div class="course-info">
|
||||||
|
<h3>${esc(c.courseName || 'Course')}</h3>
|
||||||
|
<div style="color:#64748b;font-size:.9rem;margin-bottom:6px">${esc(c.provider || '')}</div>
|
||||||
|
<div class="course-meta">
|
||||||
|
${c.cricosCode ? `<span class="badge badge-blue">CRICOS: ${esc(c.cricosCode)}</span>` : ''}
|
||||||
|
${c.state ? `<span class="badge badge-gray">📍 ${esc(c.state)}</span>` : ''}
|
||||||
|
${c.duration ? `<span class="badge badge-gray">⏱ ${esc(c.duration)}</span>` : ''}
|
||||||
|
${c.fee ? `<span class="badge badge-green">💰 ${esc(c.fee)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="course-actions">
|
||||||
|
<a href="/contact?course=${encodeURIComponent(c.courseName||'')}&code=${encodeURIComponent(c.cricosCode||'')}&provider=${encodeURIComponent(c.provider||'')}" class="btn btn-primary btn-sm">Book Consultation</a>
|
||||||
|
${user ? `<a href="/dashboard/save?code=${encodeURIComponent(c.cricosCode||'')}&name=${encodeURIComponent(c.courseName||'')}&provider=${encodeURIComponent(c.provider||'')}" class="btn btn-sm" style="background:#f0f6ff;color:#1a5bb8;text-align:center">💾 Save Course</a>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
`}
|
||||||
|
|
||||||
|
<div style="margin-top:32px;padding:20px;background:#f0f6ff;border-radius:10px;text-align:center">
|
||||||
|
<p style="color:#1a2744;font-weight:600;margin-bottom:8px">Need help choosing the right course?</p>
|
||||||
|
<p style="color:#64748b;font-size:.9rem;margin-bottom:14px">Our education advisors speak your language and know the Australian system inside out</p>
|
||||||
|
<a href="/contact" class="btn btn-primary">Book Free Consultation</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerPage(error, values = {}) {
|
||||||
|
return layout('Create Account', `
|
||||||
|
<section style="padding:60px 24px;background:#f0f6ff;min-height:80vh">
|
||||||
|
<div class="form-card">
|
||||||
|
<h2>Create Your Account</h2>
|
||||||
|
<p class="sub">Track courses, save inquiries, and get personalised support</p>
|
||||||
|
${error ? `<div class="alert alert-error">${esc(error)}</div>` : ''}
|
||||||
|
<form method="POST" action="/register">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Full Name *</label>
|
||||||
|
<input type="text" name="full_name" required placeholder="Your full name" value="${esc(values.full_name||'')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Phone</label>
|
||||||
|
<input type="tel" name="phone" placeholder="+61 4XX XXX XXX" value="${esc(values.phone||'')}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email Address *</label>
|
||||||
|
<input type="email" name="email" required placeholder="you@example.com" value="${esc(values.email||'')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password *</label>
|
||||||
|
<input type="password" name="password" required placeholder="Minimum 8 characters" minlength="8">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Confirm Password *</label>
|
||||||
|
<input type="password" name="confirm_password" required placeholder="Repeat your password">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="form-submit">Create Account</button>
|
||||||
|
</form>
|
||||||
|
<div class="form-footer">Already have an account? <a href="/login">Login here</a></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginPage(error, redirect = '') {
|
||||||
|
return layout('Login', `
|
||||||
|
<section style="padding:60px 24px;background:#f0f6ff;min-height:80vh">
|
||||||
|
<div class="form-card">
|
||||||
|
<h2>Welcome Back</h2>
|
||||||
|
<p class="sub">Login to your Careers Gateway account</p>
|
||||||
|
${error ? `<div class="alert alert-error">${esc(error)}</div>` : ''}
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<input type="hidden" name="redirect" value="${esc(redirect)}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email Address</label>
|
||||||
|
<input type="email" name="email" required placeholder="you@example.com" autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" name="password" required placeholder="Your password">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="form-submit">Login</button>
|
||||||
|
</form>
|
||||||
|
<div class="form-footer">Don't have an account? <a href="/register">Register free</a></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dashboardPage(user, inquiries) {
|
||||||
|
const initials = user.full_name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0,2);
|
||||||
|
return layout('Dashboard', `
|
||||||
|
<section style="padding:40px 24px">
|
||||||
|
<div class="container">
|
||||||
|
<div class="dash-grid">
|
||||||
|
<div class="dash-sidebar">
|
||||||
|
<div class="dash-user">
|
||||||
|
<div class="dash-avatar">${esc(initials)}</div>
|
||||||
|
<div style="font-weight:700;color:#1a2744">${esc(user.full_name)}</div>
|
||||||
|
<div style="font-size:.85rem;color:#94a3b8;margin-top:4px">${esc(user.email)}</div>
|
||||||
|
</div>
|
||||||
|
<ul class="dash-nav">
|
||||||
|
<li><a href="/dashboard" class="active">📊 Overview</a></li>
|
||||||
|
<li><a href="/courses">🔍 Search Courses</a></li>
|
||||||
|
<li><a href="/contact">📝 New Inquiry</a></li>
|
||||||
|
<li><a href="/health-insurance">🏥 Health Insurance</a></li>
|
||||||
|
<li><a href="/dashboard/profile">👤 My Profile</a></li>
|
||||||
|
<li><a href="/logout" style="color:#dc2626">🚪 Logout</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="dash-main">
|
||||||
|
<div class="dash-card">
|
||||||
|
<h3>Welcome back, ${esc(user.full_name.split(' ')[0])}! 👋</h3>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:16px">
|
||||||
|
<div style="background:#f0f6ff;border-radius:8px;padding:16px;text-align:center">
|
||||||
|
<div style="font-size:1.8rem;font-weight:700;color:#1a5bb8">${inquiries.length}</div>
|
||||||
|
<div style="font-size:.85rem;color:#64748b;margin-top:4px">Inquiries</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#f0fdf4;border-radius:8px;padding:16px;text-align:center">
|
||||||
|
<div style="font-size:1.8rem;font-weight:700;color:#16a34a">${inquiries.filter(i=>i.kondesk_sent).length}</div>
|
||||||
|
<div style="font-size:.85rem;color:#64748b;margin-top:4px">Processed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dash-card">
|
||||||
|
<h3>My Inquiries</h3>
|
||||||
|
${inquiries.length === 0 ? `
|
||||||
|
<div style="text-align:center;padding:30px 0;color:#94a3b8">
|
||||||
|
<div style="font-size:2rem;margin-bottom:10px">📋</div>
|
||||||
|
<p>No inquiries yet. <a href="/contact">Book a consultation</a> or <a href="/courses">search courses</a>.</p>
|
||||||
|
</div>
|
||||||
|
` : inquiries.map(i => `
|
||||||
|
<div class="inquiry-row">
|
||||||
|
<div>
|
||||||
|
<div class="inquiry-info"><strong>${esc(i.service||'General Inquiry')}</strong>${i.cricos_course_name ? ` — ${esc(i.cricos_course_name)}` : ''}</div>
|
||||||
|
${i.cricos_provider ? `<div style="font-size:.85rem;color:#64748b">${esc(i.cricos_provider)}</div>` : ''}
|
||||||
|
<div class="inquiry-date">${new Date(i.created_at).toLocaleDateString('en-AU',{day:'numeric',month:'short',year:'numeric'})}</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge ${i.kondesk_sent ? 'badge-green' : 'badge-gray'}">${i.kondesk_sent ? '✓ Processing' : 'Received'}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="dash-card">
|
||||||
|
<h3>Quick Actions</h3>
|
||||||
|
<div style="display:flex;gap:12px;flex-wrap:wrap">
|
||||||
|
<a href="/courses" class="btn btn-primary btn-sm">🔍 Find Courses</a>
|
||||||
|
<a href="/contact" class="btn btn-sm" style="background:#f0f6ff;color:#1a5bb8">📝 Book Consultation</a>
|
||||||
|
<a href="/health-insurance" class="btn btn-sm" style="background:#f0fdf4;color:#16a34a">🏥 OSHC Insurance</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactPage(user, params = {}, success = false, error = '') {
|
||||||
|
const services = ['Education & Visa Services','Migration Services','Recruitment & Labour Hire','RPL Assessment','Skills Assessment','Health Insurance (OSHC/OVHC)','Easy Tax Return','Aged Care Services','Course Inquiry','General Inquiry'];
|
||||||
|
return layout('Contact & Book Consultation', `
|
||||||
|
<section style="background:linear-gradient(135deg,#1a2744,#1a5bb8);padding:40px 24px;color:#fff;text-align:center">
|
||||||
|
<div class="container">
|
||||||
|
<h1 style="font-size:2rem;font-weight:800;margin-bottom:8px">Book a Free Consultation</h1>
|
||||||
|
<p style="opacity:.9">Our team will get back to you within 24 hours</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section style="padding:48px 24px">
|
||||||
|
<div class="container" style="max-width:900px">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 340px;gap:32px;align-items:start" class="contact-layout">
|
||||||
|
<div class="form-card" style="max-width:none">
|
||||||
|
<h2 style="text-align:left">Send Us a Message</h2>
|
||||||
|
<p class="sub" style="text-align:left">Fill in your details and we'll be in touch soon</p>
|
||||||
|
${success ? '<div class="alert alert-success">✅ Your inquiry has been submitted! Our team will contact you within 24 hours.</div>' : ''}
|
||||||
|
${error ? `<div class="alert alert-error">${esc(error)}</div>` : ''}
|
||||||
|
${params.course ? `<div class="alert alert-info">🎓 <strong>${esc(params.course)}</strong>${params.code ? ` (CRICOS: ${esc(params.code)})` : ''} — ${esc(params.provider||'')}</div>` : ''}
|
||||||
|
<form method="POST" action="/contact">
|
||||||
|
<input type="hidden" name="cricos_course" value="${esc(params.course||'')}">
|
||||||
|
<input type="hidden" name="cricos_code" value="${esc(params.code||'')}">
|
||||||
|
<input type="hidden" name="cricos_provider" value="${esc(params.provider||'')}">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Full Name *</label>
|
||||||
|
<input type="text" name="full_name" required placeholder="Your full name" value="${esc(user?.full_name||'')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Phone</label>
|
||||||
|
<input type="tel" name="phone" placeholder="+61 4XX XXX XXX" value="${esc(user?.phone||'')}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email *</label>
|
||||||
|
<input type="email" name="email" required placeholder="you@example.com" value="${esc(user?.email||'')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Service Interested In</label>
|
||||||
|
<select name="service">
|
||||||
|
${services.map(s => `<option value="${s}"${(params.course && s==='Course Inquiry')||(!params.course && s==='General Inquiry')?' selected':''}>${s}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Message</label>
|
||||||
|
<textarea name="message" placeholder="Tell us about your situation and what you're looking to achieve in Australia..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="form-submit">Send Inquiry →</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="background:#fff;border-radius:12px;padding:24px;box-shadow:0 2px 8px rgba(0,0,0,.05);margin-bottom:20px">
|
||||||
|
<h4 style="font-weight:700;margin-bottom:16px;color:#1a2744">📞 Contact Details</h4>
|
||||||
|
<div style="font-size:.9rem;line-height:2;color:#374151">
|
||||||
|
<div>📍 Sydney, NSW, Australia</div>
|
||||||
|
<div>📞 <a href="tel:+61200000000">+61 2 XXXX XXXX</a></div>
|
||||||
|
<div>✉️ <a href="mailto:info@careersgateway.com.au">info@careersgateway.com.au</a></div>
|
||||||
|
<div>🕐 Mon–Fri: 9am–6pm AEST</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#f0f6ff;border-radius:12px;padding:20px">
|
||||||
|
<h4 style="font-weight:700;margin-bottom:10px;color:#1a2744">✅ What Happens Next</h4>
|
||||||
|
<ol style="padding-left:18px;font-size:.9rem;color:#374151;line-height:2">
|
||||||
|
<li>We receive your inquiry</li>
|
||||||
|
<li>A specialist is assigned</li>
|
||||||
|
<li>You're contacted within 24h</li>
|
||||||
|
<li>Free initial consultation</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<style>.contact-layout{grid-template-columns:1fr 340px}@media(max-width:768px){.contact-layout{grid-template-columns:1fr}}</style>
|
||||||
|
`, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthInsurancePage(user, konpareKey) {
|
||||||
|
return layout('Health Insurance — OSHC & OVHC', `
|
||||||
|
<section style="background:linear-gradient(135deg,#064e3b,#059669);padding:48px 24px;color:#fff;text-align:center">
|
||||||
|
<div class="container">
|
||||||
|
<h1 style="font-size:2rem;font-weight:800;margin-bottom:8px">🏥 Health Insurance for Students & Visitors</h1>
|
||||||
|
<p style="opacity:.9">Compare and buy OSHC & OVHC policies instantly through our Konpare-powered portal</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div class="container">
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:20px;margin-bottom:40px">
|
||||||
|
${[
|
||||||
|
['🏥','OSHC','Overseas Student Health Cover','Required for all international student visa holders in Australia'],
|
||||||
|
['🌏','OVHC','Overseas Visitors Health Cover','For visitors, working holiday makers, and sponsored workers'],
|
||||||
|
['💊','Cover Includes','Hospital, GP, Prescriptions','Plus emergency ambulance in most policies'],
|
||||||
|
['⚡','Instant','Online Purchase','Get your certificate of insurance immediately'],
|
||||||
|
].map(([icon,short,title,desc]) => `
|
||||||
|
<div style="background:#fff;border-radius:10px;padding:20px;box-shadow:0 2px 8px rgba(0,0,0,.05);border-top:3px solid #059669">
|
||||||
|
<div style="font-size:1.5rem;margin-bottom:8px">${icon}</div>
|
||||||
|
<div style="font-size:.75rem;font-weight:700;color:#059669;text-transform:uppercase;letter-spacing:.5px">${short}</div>
|
||||||
|
<h4 style="font-weight:700;margin:6px 0;color:#1a2744">${title}</h4>
|
||||||
|
<p style="font-size:.85rem;color:#64748b">${desc}</p>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="font-size:1.5rem;font-weight:700;margin-bottom:20px;color:#1a2744">Compare & Buy Now</h2>
|
||||||
|
<div class="widget-wrap">
|
||||||
|
<iframe
|
||||||
|
src="https://app.konpare.online/widget/?key=${esc(konpareKey)}"
|
||||||
|
title="OSHC OVHC Health Insurance Comparison"
|
||||||
|
allow="payment"
|
||||||
|
loading="lazy">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:24px;padding:20px;background:#f0fdf4;border-radius:10px;text-align:center">
|
||||||
|
<p style="color:#166534;font-size:.9rem">🔒 Secure payments powered by Konpare. Policies from all major Australian health insurers.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
function servicesPage(user, service) {
|
||||||
|
const pages = {
|
||||||
|
education: { title: 'Education & Visa Services', icon: '🎓', desc: 'We help international students find and enrol in CRICOS-registered courses at Australian universities, TAFE colleges, and private providers. Our education counsellors assess your academic background, career goals, and visa eligibility to recommend the best pathway for you.', items: ['CRICOS course selection and application','Student visa (subclass 500) lodgement','Enrolment support and CoE assistance','Pre-departure briefings','Airport pickup and arrival support'] },
|
||||||
|
migration: { title: 'Migration Services', icon: '🌏', desc: 'Our MARA-registered migration agents provide expert advice on all Australian visa subclasses. From skilled migration to family reunification, we handle your case end-to-end.', items: ['Skilled Independent (189, 190, 491)','Employer Nomination Scheme (186, 187)','Family visas (309, 100, 820, 801)','Partner and spouse visas','Bridging and tourist visas'] },
|
||||||
|
recruitment: { title: 'Recruitment & Labour Hire', icon: '💼', desc: 'We connect skilled professionals with Australian employers across healthcare, construction, IT, and hospitality sectors.', items: ['Job placement for skilled migrants','Labour hire for construction and agriculture','Healthcare and nursing recruitment','Resume and interview preparation','Work rights advice'] },
|
||||||
|
rpl: { title: 'RPL Assessment', icon: '📋', desc: 'Recognition of Prior Learning (RPL) lets you convert your overseas work experience into Australian qualifications without re-studying.', items: ['Trade qualifications (Certificate III/IV)','Healthcare and nursing qualifications','Engineering and IT assessments','Portfolio preparation support','Fast-track pathways available'] },
|
||||||
|
skills: { title: 'Skills Assessment', icon: '✅', desc: 'Formal skills assessments required for skilled migration visas, assessed by bodies such as Engineers Australia, TRA, VETASSESS, ANMAC, and more.', items: ['Engineers Australia','Trades Recognition Australia (TRA)','VETASSESS (professional occupations)','ANMAC (nursing and midwifery)','AIM, CPAA, CPA Australia'] },
|
||||||
|
tax: { title: 'Easy Tax Return', icon: '💰', desc: 'Quick, affordable Australian tax returns for international students and working holiday visa holders.', items: ['Tax file number (TFN) applications','Annual tax return lodgement','Working holiday tax refunds','HECS/HELP debt queries','Superannuation withdrawals on departure'] },
|
||||||
|
'aged-care': { title: 'Aged Care Services', icon: '❤️', desc: 'Pathways into Australia\'s growing aged care sector, including qualification recognition and job placement.', items: ['Certificate III in Individual Support','AHPRA registration assistance','Job placement in aged care facilities','Visa pathways for aged care workers','Ongoing compliance support'] },
|
||||||
|
};
|
||||||
|
const page = pages[service];
|
||||||
|
if (!page) return null;
|
||||||
|
return layout(page.title, `
|
||||||
|
<section style="background:linear-gradient(135deg,#1a2744,#1a5bb8);padding:48px 24px;color:#fff;text-align:center">
|
||||||
|
<div class="container">
|
||||||
|
<div style="font-size:3rem;margin-bottom:12px">${page.icon}</div>
|
||||||
|
<h1 style="font-size:2rem;font-weight:800;margin-bottom:8px">${page.title}</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div class="container" style="max-width:900px">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 320px;gap:32px;align-items:start">
|
||||||
|
<div>
|
||||||
|
<p style="font-size:1.05rem;color:#374151;line-height:1.8;margin-bottom:28px">${page.desc}</p>
|
||||||
|
<h3 style="font-size:1.15rem;font-weight:700;margin-bottom:16px;color:#1a2744">What's Included</h3>
|
||||||
|
<ul style="list-style:none;display:flex;flex-direction:column;gap:10px">
|
||||||
|
${page.items.map(item => `<li style="display:flex;align-items:flex-start;gap:10px;font-size:.95rem;color:#374151"><span style="color:#1a5bb8;font-weight:700;margin-top:1px">✓</span>${item}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="form-card" style="max-width:none;padding:28px">
|
||||||
|
<h3 style="font-size:1.1rem;font-weight:700;margin-bottom:16px;text-align:center">Get Started Today</h3>
|
||||||
|
<p style="font-size:.9rem;color:#64748b;text-align:center;margin-bottom:20px">Book a free initial consultation with one of our ${page.title} specialists</p>
|
||||||
|
<a href="/contact?service=${encodeURIComponent(page.title)}" class="btn btn-primary" style="display:block;text-align:center;padding:13px">Book Free Consultation</a>
|
||||||
|
<div style="text-align:center;margin-top:16px;font-size:.85rem;color:#94a3b8">No obligation · Response within 24h</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { layout, esc, homePage, coursesPage, registerPage, loginPage, dashboardPage, contactPage, healthInsurancePage, servicesPage };
|
||||||
21
wrangler.toml
Normal file
21
wrangler.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
name = "careersgateway"
|
||||||
|
main = "src/index.js"
|
||||||
|
compatibility_date = "2024-11-01"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|
||||||
|
[[custom_domains]]
|
||||||
|
pattern = "careers.bored.investments"
|
||||||
|
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
database_name = "careersgateway-db"
|
||||||
|
database_id = "d898aeb4-5568-48d5-ba20-4244e467b5de"
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "SESSIONS"
|
||||||
|
id = "5b40ceec3c6446409351805028e63453"
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
SITE_NAME = "Careers Gateway"
|
||||||
|
CONTACT_EMAIL = "info@careersgateway.com.au"
|
||||||
|
KONPARE_KEY = "ViL_0iMMCdHrXNoEQKFm4P5092ISFcYzEyLmf5MFBKds7Hw5BmORaXu3oGsMB5Lk"
|
||||||
Loading…
Add table
Reference in a new issue