feat(quixzoom-asia): Full rebuild v1.0.0 — 9 languages, Landvex auth, light theme

- Rebuilt all 9 Asian language landing pages (zh-CN, zh-TW, ja, ko, th, vi, id, ms, hi)
- Switched from dark to light theme per MOBILE_FIRST_STANDARD
- Added Landvex Authorization section (VW-style certification flow)
- Added full copy: hero, stats, how-it-works, missions, earnings, FAQ, footer
- Localized currencies and payment methods per market
- Build system: template + JSON translations → static HTML
- Validation script ensures no missing translations or placeholder leaks
- CI/CD pipeline: GitHub Actions → S3 + CloudFront
- Version management: npm version patch/minor/major

BREAKING CHANGE: Old placeholder pages replaced with full content
This commit is contained in:
Bernt
2026-07-08 10:27:21 +00:00
parent bdfd88175c
commit ddc71dc7f2
21 changed files with 2985 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Build quiXzoom Asia pages from templates + translations
* Outputs to dist/ with versioned assets
*/
const fs = require('fs');
const path = require('path');
const LANGUAGES = [
{ code: 'zh-cn', name: 'Simplified Chinese', currency: 'CNY', symbol: '¥' },
{ code: 'zh-tw', name: 'Traditional Chinese', currency: 'TWD', symbol: 'NT$' },
{ code: 'ja', name: 'Japanese', currency: 'JPY', symbol: '¥' },
{ code: 'ko', name: 'Korean', currency: 'KRW', symbol: '₩' },
{ code: 'th', name: 'Thai', currency: 'THB', symbol: '฿' },
{ code: 'vi', name: 'Vietnamese', currency: 'VND', symbol: '₫' },
{ code: 'id', name: 'Indonesian', currency: 'IDR', symbol: 'Rp' },
{ code: 'ms', name: 'Malay', currency: 'MYR', symbol: 'RM' },
{ code: 'hi', name: 'Hindi', currency: 'INR', symbol: '₹' },
];
const VERSION = process.env.VERSION || require('../package.json').version;
const BUILD_TIME = new Date().toISOString();
function loadTemplate() {
return fs.readFileSync(path.join(__dirname, '../src/template.html'), 'utf8');
}
function loadTranslations(langCode) {
const transPath = path.join(__dirname, `../src/translations/${langCode}.json`);
if (!fs.existsSync(transPath)) {
console.error(`❌ Missing translations: ${langCode}`);
process.exit(1);
}
return JSON.parse(fs.readFileSync(transPath, 'utf8'));
}
function interpolate(template, vars) {
// First pass: interpolate vars that contain other placeholders
let result = template;
let changed = true;
let passes = 0;
const MAX_PASSES = 5;
while (changed && passes < MAX_PASSES) {
changed = false;
passes++;
result = result.replace(/\{\{(\w+)\}\}/g, (match, key) => {
if (vars[key] === undefined) {
return match; // keep placeholder for next pass or final check
}
const value = vars[key];
if (value.includes('{{')) {
changed = true;
}
return value;
});
}
// Final check for remaining placeholders
const remaining = result.match(/\{\{(\w+)\}\}/g);
if (remaining) {
const unique = [...new Set(remaining.map(m => m.slice(2, -2)))].filter(k => vars[k] === undefined);
for (const key of unique) {
console.warn(`⚠️ Missing translation key: ${key}`);
}
}
return result;
}
function build() {
console.log(`🔨 Building quiXzoom Asia v${VERSION}...\n`);
const distDir = path.join(__dirname, '../dist');
if (fs.existsSync(distDir)) {
fs.rmSync(distDir, { recursive: true });
}
fs.mkdirSync(distDir, { recursive: true });
const template = loadTemplate();
// Build root language selector
const rootHtml = fs.readFileSync(path.join(__dirname, '../src/index.html'), 'utf8')
.replace('{{VERSION}}', VERSION)
.replace('{{BUILD_TIME}}', BUILD_TIME);
fs.writeFileSync(path.join(distDir, 'index.html'), rootHtml);
// Build each language
for (const lang of LANGUAGES) {
console.log(` 📄 ${lang.code}${lang.name}`);
const translations = loadTranslations(lang.code);
translations.VERSION = VERSION;
translations.BUILD_TIME = BUILD_TIME;
translations.LANG_CODE = lang.code;
translations.LANG_NAME = lang.name;
translations.CURRENCY = lang.currency;
translations.CURRENCY_SYMBOL = lang.symbol;
const html = interpolate(template, translations);
const langDir = path.join(distDir, lang.code);
fs.mkdirSync(langDir, { recursive: true });
fs.writeFileSync(path.join(langDir, 'index.html'), html);
}
// Copy static assets
const assets = ['favicon.svg', 'apple-touch-icon.svg'];
for (const asset of assets) {
const src = path.join(__dirname, `../src/${asset}`);
if (fs.existsSync(src)) {
fs.copyFileSync(src, path.join(distDir, asset));
}
}
// Write build manifest
const manifest = {
version: VERSION,
buildTime: BUILD_TIME,
languages: LANGUAGES.map(l => l.code),
gitCommit: process.env.GIT_COMMIT || 'unknown',
gitBranch: process.env.GIT_BRANCH || 'unknown',
};
fs.writeFileSync(path.join(distDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
console.log(`\n✅ Build complete: ${distDir}/`);
console.log(` ${LANGUAGES.length + 1} pages generated`);
}
build();
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* Deploy to S3 + CloudFront
* Usage: node scripts/deploy.js --env=staging|production
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const ENVIRONMENTS = {
staging: {
bucket: 'quixzoom-asia-staging',
region: 'ap-southeast-1',
distributionId: process.env.CF_DIST_STAGING,
url: 'https://staging.quixzoom.asia',
},
production: {
bucket: 'quixzoom-asia',
region: 'ap-southeast-1',
distributionId: process.env.CF_DIST_PRODUCTION,
url: 'https://quixzoom.asia',
},
};
const [, , envArg] = process.argv;
const env = envArg?.replace('--env=', '') || 'staging';
if (!ENVIRONMENTS[env]) {
console.error(`❌ Unknown environment: ${env}`);
console.error('Usage: node scripts/deploy.js --env=staging|production');
process.exit(1);
}
const config = ENVIRONMENTS[env];
const distDir = path.join(__dirname, '../dist');
const manifestPath = path.join(distDir, 'manifest.json');
if (!fs.existsSync(distDir)) {
console.error('❌ No dist/ directory. Run: npm run build');
process.exit(1);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
console.log(`🚀 Deploying quiXzoom Asia v${manifest.version} to ${env}...`);
console.log(` Bucket: ${config.bucket}`);
console.log(` Region: ${config.region}`);
console.log(` Commit: ${manifest.gitCommit}`);
console.log('');
// Sync to S3
const syncCmd = `aws s3 sync ${distDir}/ s3://${config.bucket}/ \
--delete \
--cache-control "max-age=3600" \
--region ${config.region}`;
console.log('📤 Uploading...');
execSync(syncCmd, { stdio: 'inherit' });
// Set content types
console.log('\n📝 Setting content types...');
execSync(`aws s3 cp s3://${config.bucket}/ s3://${config.bucket}/ \
--recursive --exclude "*" --include "*.html" \
--content-type "text/html; charset=utf-8" \
--metadata-directive REPLACE --region ${config.region}`, { stdio: 'inherit' });
// Invalidate CloudFront
if (config.distributionId) {
console.log('\n🔄 Invalidating CloudFront...');
execSync(`aws cloudfront create-invalidation \
--distribution-id ${config.distributionId} \
--paths "/*" --region ${config.region}`, { stdio: 'inherit' });
}
console.log(`\n✅ Deployed to ${config.url}`);
console.log(` Version: ${manifest.version}`);
console.log(` Build: ${manifest.buildTime}`);
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* Validate translations completeness and HTML output
*/
const fs = require('fs');
const path = require('path');
const LANGUAGES = ['zh-cn', 'zh-tw', 'ja', 'ko', 'th', 'vi', 'id', 'ms', 'hi'];
const REQUIRED_KEYS = [
'TITLE', 'META_DESC', 'NAV_HOW', 'NAV_MISSIONS', 'NAV_EARNINGS',
'NAV_LANDVEX', 'NAV_FAQ', 'CTA_START', 'HERO_BADGE', 'HERO_H1',
'HERO_H1_EMPH', 'HERO_SUB', 'EMAIL_PLACEHOLDER', 'TRUST_LINE',
'STAT1_NUM', 'STAT1_LBL', 'STAT2_NUM', 'STAT2_LBL', 'STAT3_NUM', 'STAT3_LBL',
'STATS_1_VAL', 'STATS_1_LBL', 'STATS_2_VAL', 'STATS_2_LBL',
'STATS_3_VAL', 'STATS_3_LBL', 'STATS_4_VAL', 'STATS_4_LBL',
'HOW_TITLE', 'HOW_SUB', 'STEP1_TITLE', 'STEP1_DESC',
'STEP2_TITLE', 'STEP2_DESC', 'STEP3_TITLE', 'STEP3_DESC',
'MISSIONS_TITLE', 'MISSIONS_SUB', 'M1_TITLE', 'M1_DESC', 'M1_CHIP',
'M2_TITLE', 'M2_DESC', 'M2_CHIP', 'M3_TITLE', 'M3_DESC', 'M3_CHIP',
'EARNINGS_TITLE', 'EARNINGS_SUB', 'TIER1_NAME', 'TIER1_MISSIONS',
'TIER1_F1', 'TIER1_F2', 'TIER1_F3', 'TIER2_NAME', 'TIER2_MISSIONS',
'TIER2_F1', 'TIER2_F2', 'TIER2_F3', 'TIER3_NAME', 'TIER3_MISSIONS',
'TIER3_F1', 'TIER3_F2', 'TIER3_F3',
'AUTH_TITLE', 'AUTH_SUB', 'AUTH_VISUAL_TITLE', 'AUTH_VISUAL_DESC',
'AUTH_B1', 'AUTH_B2', 'AUTH_B3', 'AUTH_B4',
'AUTH_S1_TITLE', 'AUTH_S1_DESC', 'AUTH_S2_TITLE', 'AUTH_S2_DESC',
'AUTH_S3_TITLE', 'AUTH_S3_DESC', 'AUTH_S4_TITLE', 'AUTH_S4_DESC', 'AUTH_CTA',
'FAQ_TITLE', 'FAQ_SUB', 'FAQ_Q1', 'FAQ_A1', 'FAQ_Q2', 'FAQ_A2',
'FAQ_Q3', 'FAQ_A3', 'FAQ_Q4', 'FAQ_A4', 'FAQ_Q5', 'FAQ_A5',
'FOOTER_TAGLINE', 'FOOTER_COL1_TITLE', 'FOOTER_COL1_L1', 'FOOTER_COL1_L2',
'FOOTER_COL1_L3', 'FOOTER_COL1_L4', 'FOOTER_COL2_TITLE', 'FOOTER_COL2_L1',
'FOOTER_COL2_L2', 'FOOTER_COL2_L3', 'FOOTER_COL3_TITLE', 'FOOTER_COL3_L1',
'FOOTER_COL3_L2', 'FOOTER_COL3_L3', 'COPYRIGHT', 'LANG_LABEL',
'PHONE_MISSION', 'PHONE_ADDR', 'PHONE_REWARD', 'PHONE_META',
];
let errors = 0;
console.log('🔍 Validating translations...\n');
for (const lang of LANGUAGES) {
const transPath = path.join(__dirname, `../src/translations/${lang}.json`);
if (!fs.existsSync(transPath)) {
console.error(`❌ Missing: ${lang}.json`);
errors++;
continue;
}
const trans = JSON.parse(fs.readFileSync(transPath, 'utf8'));
const missing = REQUIRED_KEYS.filter(k => !trans[k] || trans[k].trim() === '');
if (missing.length > 0) {
console.error(`${lang}: Missing ${missing.length} keys`);
for (const key of missing.slice(0, 5)) {
console.error(` - ${key}`);
}
if (missing.length > 5) console.error(` ... and ${missing.length - 5} more`);
errors++;
} else {
console.log(`${lang}: Complete`);
}
}
// Check for placeholder leaks
const distDir = path.join(__dirname, '../dist');
if (fs.existsSync(distDir)) {
console.log('\n🔍 Checking for placeholder leaks in dist/...');
for (const lang of LANGUAGES) {
const htmlPath = path.join(distDir, lang, 'index.html');
if (fs.existsSync(htmlPath)) {
const html = fs.readFileSync(htmlPath, 'utf8');
const leaks = html.match(/\{\{\w+\}\}/g);
if (leaks) {
console.error(`${lang}: ${leaks.length} placeholders leaked`);
errors++;
}
}
}
}
if (errors > 0) {
console.log(`\n${errors} error(s) found`);
process.exit(1);
} else {
console.log('\n✅ All validations passed');
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
/**
* Version bump utility
* Usage: node scripts/version.js [patch|minor|major]
*/
const fs = require('fs');
const path = require('path');
const pkgPath = path.join(__dirname, '../package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const [,, bump = 'patch'] = process.argv;
const [major, minor, patch] = pkg.version.split('.').map(Number);
let newVersion;
switch (bump) {
case 'major': newVersion = `${major + 1}.0.0`; break;
case 'minor': newVersion = `${major}.${minor + 1}.0`; break;
case 'patch': newVersion = `${major}.${minor}.${patch + 1}`; break;
default:
console.error('Usage: node scripts/version.js [patch|minor|major]');
process.exit(1);
}
pkg.version = newVersion;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
console.log(`🔖 Version bumped: ${pkg.version}${newVersion}`);
console.log(' Run: git add package.json && git commit -m "release: v' + newVersion + '"');