Arsitektur Hybrid: Frontend Vercel + Database Google Spreadsheet:
- Buat Google Spreadsheet di Google Drive Anda, lalu klik menu Extensions (Ekstensi) → Apps Script.
- Tempelkan kode
Code.gs di bawah ini, lalu klik Deploy → New deployment → Web app (pilih Execute as: Me & Who has access: Anyone).
- Salin Web App URL (`https://script.google.com/macros/s/.../exec`) ke variabel
GAS_WEB_APP_URL di dalam file index.html sebelum di-deploy ke Vercel.
File 1: Code.gs (Mendukung Vercel REST API + Google Spreadsheet)
/**
* ============================================================================
* PORTAL INOVASI LAYANAN KPPN LUWUK — GOOGLE APPS SCRIPT + SPREADSHEET DB
* (MENDUKUNG PENUH FRONTEND VERCEL VIA REST API & NATIVE GAS WEB APP)
* ============================================================================
*/
var SPREADSHEET_ID = '';
function doGet(e) {
// Jika dipanggil dari Vercel menggunakan fetch('.../exec?action=bootstrap')
if (e && e.parameter && e.parameter.action === 'bootstrap') {
var data = getPortalBootstrapData();
return ContentService.createTextOutput(JSON.stringify({
status: 'success',
data: data
})).setMimeType(ContentService.MimeType.JSON);
}
// Jika dibuka langsung sebagai halaman Web App Google Apps Script
return HtmlService.createTemplateFromFile('Index')
.evaluate()
.setTitle('Portal KPPN Luwuk — Inovasi Layanan Perbendaharaan')
.setFaviconUrl('https://www.kemenkeu.go.id/favicon.ico')
.addMetaTag('viewport', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
/**
* Endpoint POST untuk melayani request dari Vercel (Verify PIN, Save Apps, Save Settings)
*/
function doPost(e) {
try {
var body = JSON.parse(e.postData.contents || '{}');
var action = body.action;
var result = {};
if (action === 'verifyPin') {
result = verifyAdminPinServer(body.pin);
} else if (action === 'saveApps') {
result = saveAppsDataToSheet(body.apps, body.adminPin);
} else if (action === 'saveSettings') {
result = savePortalSettingsToSheet(body.settings, body.adminPin);
} else {
throw new Error('Action tidak dikenali: ' + action);
}
return ContentService.createTextOutput(JSON.stringify({
status: 'success',
data: result
})).setMimeType(ContentService.MimeType.JSON);
} catch (err) {
return ContentService.createTextOutput(JSON.stringify({
status: 'error',
message: err.message || String(err)
})).setMimeType(ContentService.MimeType.JSON);
}
}
/**
* Mendapatkan atau membuat Spreadsheet Database secara otomatis
*/
function getOrInitSpreadsheet_() {
var ss = null;
var props = PropertiesService.getScriptProperties();
var targetId = SPREADSHEET_ID || props.getProperty('KPPN_SPREADSHEET_ID');
if (targetId) {
try { ss = SpreadsheetApp.openById(targetId); } catch (e) { ss = null; }
}
if (!ss) {
try { ss = SpreadsheetApp.getActiveSpreadsheet(); } catch (e) { ss = null; }
}
if (!ss) {
ss = SpreadsheetApp.create('Database Portal Inovasi KPPN Luwuk (053)');
props.setProperty('KPPN_SPREADSHEET_ID', ss.getId());
}
// Pastikan Sheet 1: "Data_Aplikasi" tersedia
var appSheet = ss.getSheetByName('Data_Aplikasi');
if (!appSheet) {
appSheet = ss.insertSheet('Data_Aplikasi');
appSheet.appendRow(['id', 'name', 'shortName', 'category', 'badge', 'icon', 'gradient', 'url', 'desc']);
appSheet.getRange('A1:I1').setFontWeight('bold').setBackground('#00467f').setFontColor('#ffffff');
appSheet.setFrozenRows(1);
}
// Pastikan Sheet 2: "Pengaturan" tersedia
var setSheet = ss.getSheetByName('Pengaturan');
if (!setSheet) {
setSheet = ss.insertSheet('Pengaturan');
setSheet.appendRow(['Kunci_Pengaturan', 'Nilai', 'Keterangan']);
setSheet.getRange('A1:C1').setFontWeight('bold').setBackground('#064e3b').setFontColor('#ffffff');
setSheet.setFrozenRows(1);
setSheet.appendRow(['portalTitle', 'Portal KPPN Luwuk', 'Judul utama pada widget header']);
setSheet.appendRow(['portalSubtitle', 'Kumpulan Inovasi Layanan Perbendaharaan KPPN Luwuk', 'Subjudul portal']);
setSheet.appendRow(['defaultWallpaper', 'wp-djpb-aurora', 'Tema wallpaper default pengunjung']);
setSheet.appendRow(['defaultDirectOpen', 'false', 'true = langsung buka tab baru, false = kartu info']);
setSheet.appendRow(['adminPin', 'kppn088', 'PIN / Password untuk membuka Mode Admin']);
setSheet.appendRow(['categoriesJson', '[{"id":"Layanan Satker","label":"Layanan Satker","icon":"fa-handshake-angle"},{"id":"Pelaksanaan & Akuntansi","label":"Anggaran & Akuntansi","icon":"fa-chart-pie"},{"id":"SAKTI & Tools","label":"SAKTI & Tools","icon":"fa-screwdriver-wrench"},{"id":"Internal & Survei","label":"Internal & Survei","icon":"fa-clipboard-list"}]', 'Daftar Kategori Layanan (JSON)']);
setSheet.appendRow(['customLogoUrl', '', 'Data URL / Link gambar custom logo portal']);
}
return ss;
}
/**
* Mengambil seluruh data (Daftar Aplikasi + Pengaturan) dari Google Spreadsheet
*/
function getPortalBootstrapData() {
var ss = getOrInitSpreadsheet_();
var appSheet = ss.getSheetByName('Data_Aplikasi');
var setSheet = ss.getSheetByName('Pengaturan');
var apps = [];
var appRows = appSheet.getDataRange().getValues();
for (var i = 1; i < appRows.length; i++) {
var r = appRows[i];
if (r[0] && r[1]) {
apps.push({
id: String(r[0]),
name: String(r[1]),
shortName: String(r[2] || r[1]),
category: String(r[3] || 'Layanan Satker'),
badge: String(r[4] || ''),
icon: String(r[5] || 'fa-rocket'),
gradient: String(r[6] || 'from-sky-400 via-blue-600 to-indigo-700'),
url: String(r[7] || '#'),
desc: String(r[8] || '')
});
}
}
var settings = {};
var setRows = setSheet.getDataRange().getValues();
for (var j = 1; j < setRows.length; j++) {
var key = String(setRows[j][0]).trim();
var val = String(setRows[j][1]).trim();
if (key && key !== 'adminPin') {
settings[key] = val;
}
}
return {
apps: apps.length > 0 ? apps : null,
settings: settings,
spreadsheetUrl: ss.getUrl()
};
}
function verifyAdminPinServer(inputPin) {
var ss = getOrInitSpreadsheet_();
var setSheet = ss.getSheetByName('Pengaturan');
var rows = setSheet.getDataRange().getValues();
var validPin = 'kppn088';
for (var i = 1; i < rows.length; i++) {
if (String(rows[i][0]).trim() === 'adminPin') {
validPin = String(rows[i][1]).trim();
break;
}
}
return { valid: String(inputPin).trim() === validPin };
}
function saveAppsDataToSheet(appsArray, adminPin) {
var auth = verifyAdminPinServer(adminPin);
if (!auth.valid) throw new Error('Akses ditolak: PIN Admin tidak valid.');
var ss = getOrInitSpreadsheet_();
var sheet = ss.getSheetByName('Data_Aplikasi');
sheet.clearContents();
var rows = [['id', 'name', 'shortName', 'category', 'badge', 'icon', 'gradient', 'url', 'desc']];
appsArray.forEach(function(app) {
rows.push([
app.id || '',
app.name || '',
app.shortName || app.name || '',
app.category || 'Layanan Satker',
app.badge || '',
app.icon || 'fa-rocket',
app.gradient || 'from-sky-400 via-blue-600 to-indigo-700',
app.url || '#',
app.desc || ''
]);
});
sheet.getRange(1, 1, rows.length, 9).setValues(rows);
return { status: 'success', count: appsArray.length, spreadsheetUrl: ss.getUrl() };
}
function savePortalSettingsToSheet(newSettings, currentAdminPin) {
var auth = verifyAdminPinServer(currentAdminPin);
if (!auth.valid) throw new Error('Akses ditolak: PIN Admin tidak valid.');
var ss = getOrInitSpreadsheet_();
var setSheet = ss.getSheetByName('Pengaturan');
setSheet.clearContents();
var rows = [
['Kunci_Pengaturan', 'Nilai', 'Keterangan'],
['portalTitle', newSettings.portalTitle || 'Portal KPPN Luwuk', 'Judul utama pada widget header'],
['portalSubtitle', newSettings.portalSubtitle || 'Kumpulan Inovasi Layanan Perbendaharaan KPPN Luwuk', 'Subjudul portal'],
['defaultWallpaper', newSettings.defaultWallpaper || 'wp-djpb-aurora', 'Tema wallpaper default pengunjung'],
['defaultDirectOpen', String(newSettings.defaultDirectOpen || 'false'), 'true = langsung buka tab baru, false = kartu info'],
['adminPin', newSettings.adminPin || currentAdminPin || 'kppn088', 'PIN / Password untuk membuka Mode Admin'],
['categoriesJson', newSettings.categoriesJson || '[]', 'Daftar Kategori Layanan (JSON)'],
['customLogoUrl', newSettings.customLogoUrl || '', 'Data URL / Link gambar custom logo portal']
];
setSheet.getRange(1, 1, rows.length, 3).setValues(rows);
return { status: 'success', spreadsheetUrl: ss.getUrl() };
}