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:
@@ -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();
|
||||
@@ -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}`);
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 + '"');
|
||||
Reference in New Issue
Block a user