diff --git a/admin-portal.php b/admin-portal.php
new file mode 100644
index 0000000..09f272e
--- /dev/null
+++ b/admin-portal.php
@@ -0,0 +1,289 @@
+')
+ && !str_starts_with($lower, 'javascript:')
+ && !str_starts_with($lower, 'vbscript:');
+}
+
+$portalUrl = app_url('admin-portal.php');
+
+if (isset($_GET['logout'])) {
+ unset($_SESSION['is_admin_logged_in']);
+ set_flash('success', 'Admin berhasil logout.');
+ header('Location: ' . $portalUrl);
+ exit;
+}
+
+$loginError = '';
+$passwordErrors = [];
+$brandingErrors = [];
+$brandingForm = [
+ 'brand_logo_url' => app_setting('brand_logo_url'),
+ 'brand_favicon_url' => app_setting('brand_favicon_url'),
+];
+
+if (!is_admin_logged_in() && $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'login') {
+ $username = trim((string) ($_POST['username'] ?? ''));
+ $password = (string) ($_POST['password'] ?? '');
+
+ if (verify_admin_login($username, $password)) {
+ $_SESSION['is_admin_logged_in'] = true;
+ set_flash('success', 'Login admin berhasil. Kamu bisa mengatur iklan, sandi, dan branding sekarang.');
+ header('Location: ' . $portalUrl);
+ exit;
+ }
+
+ $loginError = 'Username atau password admin tidak cocok.';
+}
+
+if (is_admin_logged_in() && $_SERVER['REQUEST_METHOD'] === 'POST') {
+ $action = (string) ($_POST['action'] ?? '');
+
+ if ($action === 'save_ads') {
+ save_setting('ads_head', trim((string) ($_POST['ads_head'] ?? '')));
+ save_setting('ads_body', trim((string) ($_POST['ads_body'] ?? '')));
+ set_flash('success', 'Kode iklan berhasil disimpan dan akan dimuat di semua halaman.');
+ header('Location: ' . $portalUrl);
+ exit;
+ }
+
+ if ($action === 'change_password') {
+ $currentPassword = (string) ($_POST['current_password'] ?? '');
+ $newPassword = (string) ($_POST['new_password'] ?? '');
+ $confirmPassword = (string) ($_POST['confirm_password'] ?? '');
+
+ if (!verify_admin_login(admin_username(), $currentPassword)) {
+ $passwordErrors['current_password'] = 'Password admin saat ini tidak cocok.';
+ }
+
+ if (strlen($newPassword) < 8) {
+ $passwordErrors['new_password'] = 'Password baru minimal 8 karakter.';
+ }
+
+ if ($confirmPassword === '' || $newPassword !== $confirmPassword) {
+ $passwordErrors['confirm_password'] = 'Konfirmasi password harus sama dengan password baru.';
+ }
+
+ if (!$passwordErrors) {
+ save_admin_password($newPassword);
+ set_flash('success', 'Password admin berhasil diubah. Password default tidak dipakai lagi.');
+ header('Location: ' . $portalUrl);
+ exit;
+ }
+ }
+
+ if ($action === 'save_branding') {
+ $brandingForm['brand_logo_url'] = trim((string) ($_POST['brand_logo_url'] ?? ''));
+ $brandingForm['brand_favicon_url'] = trim((string) ($_POST['brand_favicon_url'] ?? ''));
+
+ foreach ($brandingForm as $field => $value) {
+ if (strlen($value) > 500) {
+ $brandingErrors[$field] = 'URL terlalu panjang. Maksimal 500 karakter.';
+ continue;
+ }
+
+ if (!admin_setting_url_looks_safe($value)) {
+ $brandingErrors[$field] = 'Nilai tidak valid. Gunakan URL biasa atau path file publik.';
+ }
+ }
+
+ if (!$brandingErrors) {
+ save_setting('brand_logo_url', $brandingForm['brand_logo_url']);
+ save_setting('brand_favicon_url', $brandingForm['brand_favicon_url']);
+ set_flash('success', 'Branding website berhasil disimpan. Logo dan favicon baru langsung dipakai.');
+ header('Location: ' . $portalUrl);
+ exit;
+ }
+ }
+}
+
+$adsHead = app_setting('ads_head');
+$adsBody = app_setting('ads_body');
+$logoPreviewUrl = public_asset_url($brandingForm['brand_logo_url']);
+$faviconPreviewUrl = public_asset_url($brandingForm['brand_favicon_url']);
+
+render_page_start([
+ 'title' => 'Admin portal tersembunyi',
+ 'description' => 'Login admin untuk mengelola script iklan global, password admin, dan branding website.',
+ 'page' => 'admin-portal',
+ 'robots' => 'noindex, nofollow',
+]);
+render_flash(consume_flash());
+?>
+
+
+
+
+
+
+
Hidden admin
+
Login panel admin
+
Panel ini tetap tersembunyi via slug khusus. Setelah login, admin bisa mengelola script iklan global, mengganti sandi admin, dan mengatur logo/favikon website.
+
+
= e($loginError) ?>
+
+
+
+
Akses awal MVP ini: = e(admin_username()) ?> / = e(admin_default_password_hint()) ?> . Setelah masuk, segera ganti password admin di panel.
+
+
Password admin sudah memakai versi custom yang tersimpan di database, jadi hint default tidak lagi dipakai.
+
+
+
+
+
+
+
Admin portal
+
Pengaturan global website
+
Semua pengaturan di halaman ini berlaku global untuk semua halaman: script iklan, password admin, dan branding website.
+
+
+
= $adsHead !== '' ? 'HEAD aktif' : 'HEAD kosong' ?>
+
= $adsBody !== '' ? 'BODY aktif' : 'BODY kosong' ?>
+
= admin_has_custom_password() ? 'Password custom aktif' : 'Masih password default' ?>
+
Logout
+
+
+
+
+ Untuk shared hosting/cPanel, isi logo URL dan favicon URL dengan path publik seperti assets/images/logo.png atau URL penuh. Kalau app dipasang di subfolder, path relatif akan ikut menyesuaikan otomatis.
+
+
+
+
+
+
+
+
Script iklan global
+
Tempel kode JavaScript/HTML iklan yang akan dirender di semua halaman.
+
+
Ads
+
+
+
+
+
+
+
+
+
+
Keamanan admin
+
Ganti password admin agar akses slug tersembunyi ini lebih aman.
+
+
Security
+
+
+
+
+
Password saat ini
+
+
= e($passwordErrors['current_password']) ?>
+
+
+
Password baru
+
+
= e($passwordErrors['new_password']) ?>
+
+
+
Konfirmasi password baru
+
+
= e($passwordErrors['confirm_password']) ?>
+
+ Username admin tetap = e(admin_username()) ?> . Yang diubah di sini hanya password-nya.
+
+ Ubah password admin
+
+
+
+
+
+
+
+
+
+
Logo & ikon website
+
Atur logo yang muncul di navbar/footer dan favicon yang tampil di tab browser.
+
+
Branding
+
+
+
+
+
Logo URL / path publik
+
+
= e($brandingErrors['brand_logo_url']) ?>
+
+
+
Favicon URL / path publik
+
+
= e($brandingErrors['brand_favicon_url']) ?>
+
+
+
+
Preview logo
+
+
+
+
Belum ada logo custom. Saat ini website masih memakai inisial brand.
+
+
+
+
Preview favicon
+
+
+
+
Belum ada favicon custom. Tab browser masih memakai icon default browser.
+
+
+
+
+ Simpan branding
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/css/custom.css b/assets/css/custom.css
index 789132e..95bbef1 100644
--- a/assets/css/custom.css
+++ b/assets/css/custom.css
@@ -1,403 +1,906 @@
-body {
- background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
- background-size: 400% 400%;
- animation: gradient 15s ease infinite;
- color: #212529;
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
- font-size: 14px;
- margin: 0;
- min-height: 100vh;
+
+:root {
+ --bg: #f7f7f5;
+ --bg-strong: #eef6f4;
+ --surface: rgba(255, 255, 255, 0.92);
+ --surface-solid: #ffffff;
+ --surface-muted: #f3f5f4;
+ --surface-glass: rgba(255, 255, 255, 0.74);
+ --text: #111827;
+ --muted: #5b6472;
+ --line: rgba(148, 163, 184, 0.18);
+ --line-strong: rgba(148, 163, 184, 0.34);
+ --accent: #111827;
+ --accent-2: #0f766e;
+ --accent-2-soft: rgba(15, 118, 110, 0.1);
+ --accent-3: #ea580c;
+ --accent-soft: rgba(17, 24, 39, 0.06);
+ --danger-soft: #fef2f2;
+ --danger-line: #fecaca;
+ --warn-soft: #fff7ed;
+ --warn-line: #fed7aa;
+ --ok-soft: #f0fdf4;
+ --ok-line: #bbf7d0;
+ --shadow-sm: 0 10px 30px rgba(15, 23, 42, 0.06);
+ --shadow: 0 18px 46px rgba(15, 23, 42, 0.08);
+ --shadow-strong: 0 24px 60px rgba(15, 23, 42, 0.12);
+ --radius-sm: 12px;
+ --radius-md: 20px;
+ --radius-lg: 28px;
}
-.main-wrapper {
- display: flex;
+html {
+ scroll-behavior: smooth;
+}
+
+body.app-body {
+ background:
+ radial-gradient(circle at top left, rgba(15, 118, 110, 0.08), transparent 28%),
+ radial-gradient(circle at top right, rgba(234, 88, 12, 0.08), transparent 24%),
+ linear-gradient(180deg, #fbfbfa 0%, #f6f7f8 38%, #f3f5f4 100%);
+ color: var(--text);
+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ letter-spacing: -0.01em;
+}
+
+main {
+ min-height: calc(100vh - 140px);
+}
+
+.site-header,
+.site-footer {
+ background: rgba(247, 247, 245, 0.86);
+ backdrop-filter: blur(16px);
+}
+
+.site-header {
+ border-color: rgba(148, 163, 184, 0.12) !important;
+}
+
+.nav-shell {
+ background: rgba(255, 255, 255, 0.62);
+ border: 1px solid rgba(255, 255, 255, 0.85);
+ border-radius: 999px;
+ padding: 0.5rem 0.9rem;
+ box-shadow: var(--shadow-sm);
+}
+
+.navbar-brand {
+ letter-spacing: -0.03em;
+}
+
+
+.navbar-toggler {
+ border: 1px solid rgba(148, 163, 184, 0.28);
+ background: rgba(255, 255, 255, 0.92);
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
+ z-index: 3;
+}
+
+.navbar-toggler:focus {
+ box-shadow: 0 0 0 0.22rem rgba(15, 118, 110, 0.16);
+}
+
+.navbar-toggler-icon {
+ width: 1.2rem;
+ height: 1.2rem;
+}
+
+.brand-mark {
+ width: 2.55rem;
+ height: 2.55rem;
+ border-radius: 18px;
+ display: inline-flex;
align-items: center;
justify-content: center;
- min-height: 100vh;
- width: 100%;
- padding: 20px;
- box-sizing: border-box;
- position: relative;
- z-index: 1;
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
+ color: #fff;
+ font-size: 1rem;
+ font-weight: 800;
+ box-shadow: 0 14px 26px rgba(17, 24, 39, 0.18);
}
-@keyframes gradient {
- 0% {
- background-position: 0% 50%;
- }
- 50% {
- background-position: 100% 50%;
- }
- 100% {
- background-position: 0% 50%;
- }
+.brand-mark-sm {
+ width: 2.15rem;
+ height: 2.15rem;
+ border-radius: 14px;
+ font-size: 0.92rem;
}
-.chat-container {
- width: 100%;
- max-width: 600px;
- background: rgba(255, 255, 255, 0.85);
- border: 1px solid rgba(255, 255, 255, 0.3);
- border-radius: 20px;
- display: flex;
- flex-direction: column;
- height: 85vh;
- box-shadow: 0 20px 40px rgba(0,0,0,0.2);
- backdrop-filter: blur(15px);
- -webkit-backdrop-filter: blur(15px);
- overflow: hidden;
+.brand-title,
+.brand-subtitle {
+ display: block;
+ line-height: 1.05;
}
-.chat-header {
- padding: 1.5rem;
- border-bottom: 1px solid rgba(0, 0, 0, 0.05);
- background: rgba(255, 255, 255, 0.5);
- font-weight: 700;
- font-size: 1.1rem;
- display: flex;
- justify-content: space-between;
- align-items: center;
+.brand-title {
+ font-size: 1rem;
}
-.chat-messages {
- flex: 1;
- overflow-y: auto;
- padding: 1.5rem;
- display: flex;
- flex-direction: column;
- gap: 1.25rem;
+.brand-subtitle {
+ color: var(--muted);
+ font-size: 0.74rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
}
-/* Custom Scrollbar */
-::-webkit-scrollbar {
- width: 6px;
+.nav-link {
+ color: var(--muted);
+ font-weight: 600;
+ padding-inline: 0.9rem !important;
}
-::-webkit-scrollbar-track {
+.nav-link.active,
+.nav-link:hover {
+ color: var(--text);
+}
+
+.hero-section,
+.dashboard-head {
background: transparent;
}
-::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.3);
- border-radius: 10px;
+.hero-shell,
+.dashboard-hero {
+ position: relative;
}
-::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.5);
-}
-
-.message {
- max-width: 85%;
- padding: 0.85rem 1.1rem;
- border-radius: 16px;
- line-height: 1.5;
- font-size: 0.95rem;
- box-shadow: 0 4px 15px rgba(0,0,0,0.05);
- animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
-}
-
-@keyframes fadeIn {
- from { opacity: 0; transform: translateY(20px) scale(0.95); }
- to { opacity: 1; transform: translateY(0) scale(1); }
-}
-
-.message.visitor {
- align-self: flex-end;
- background: linear-gradient(135deg, #212529 0%, #343a40 100%);
- color: #fff;
- border-bottom-right-radius: 4px;
-}
-
-.message.bot {
- align-self: flex-start;
- background: #ffffff;
- color: #212529;
- border-bottom-left-radius: 4px;
-}
-
-.chat-input-area {
- padding: 1.25rem;
- background: rgba(255, 255, 255, 0.5);
- border-top: 1px solid rgba(0, 0, 0, 0.05);
-}
-
-.chat-input-area form {
- display: flex;
- gap: 0.75rem;
-}
-
-.chat-input-area input {
- flex: 1;
- border: 1px solid rgba(0, 0, 0, 0.1);
- border-radius: 12px;
- padding: 0.75rem 1rem;
- outline: none;
- background: rgba(255, 255, 255, 0.9);
- transition: all 0.3s ease;
-}
-
-.chat-input-area input:focus {
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
-}
-
-.chat-input-area button {
- background: #212529;
- color: #fff;
- border: none;
- padding: 0.75rem 1.5rem;
- border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
- transition: all 0.3s ease;
-}
-
-.chat-input-area button:hover {
- background: #000;
- transform: translateY(-2px);
- box-shadow: 0 5px 15px rgba(0,0,0,0.2);
-}
-
-/* Background Animations */
-.bg-animations {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- z-index: 0;
- overflow: hidden;
+.hero-blur {
+ position: absolute;
+ width: 18rem;
+ height: 18rem;
+ border-radius: 999px;
+ filter: blur(12px);
+ opacity: 0.8;
pointer-events: none;
}
-.blob {
- position: absolute;
- width: 500px;
- height: 500px;
- background: rgba(255, 255, 255, 0.2);
- border-radius: 50%;
- filter: blur(80px);
- animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
+.hero-blur-one {
+ top: -4rem;
+ right: 14%;
+ background: rgba(15, 118, 110, 0.14);
}
-.blob-1 {
- top: -10%;
- left: -10%;
- background: rgba(238, 119, 82, 0.4);
+.hero-blur-two {
+ bottom: -5rem;
+ right: -2rem;
+ background: rgba(234, 88, 12, 0.12);
}
-.blob-2 {
- bottom: -10%;
- right: -10%;
- background: rgba(35, 166, 213, 0.4);
- animation-delay: -7s;
- width: 600px;
- height: 600px;
+.eyebrow {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ font-size: 0.78rem;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--accent-2);
}
-.blob-3 {
- top: 40%;
- left: 30%;
- background: rgba(231, 60, 126, 0.3);
- animation-delay: -14s;
- width: 450px;
- height: 450px;
+.eyebrow::before {
+ content: "";
+ width: 2rem;
+ height: 1px;
+ background: rgba(15, 118, 110, 0.4);
}
-@keyframes move {
- 0% { transform: translate(0, 0) rotate(0deg) scale(1); }
- 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
- 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
- 100% { transform: translate(0, 0) rotate(360deg) scale(1); }
+.display-title,
+.section-title {
+ color: var(--text);
+ letter-spacing: -0.045em;
+ line-height: 1.02;
}
-.header-link {
- font-size: 14px;
+.display-title {
+ font-size: clamp(2.55rem, 5vw, 4.7rem);
+ max-width: 10.8ch;
+}
+
+.section-title {
+ font-size: clamp(1.85rem, 3vw, 2.75rem);
+}
+
+.section-copy {
+ max-width: 58ch;
+}
+
+.lead {
+ max-width: 58ch;
+ font-size: 1.08rem;
+}
+
+.surface-card,
+.feature-card,
+.process-card,
+.metric-card,
+.detail-box,
+.cta-strip,
+.list-card,
+.quick-stat-card,
+.dashboard-stat-card {
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow);
+}
+
+.surface-card {
+ padding: 1.45rem;
+}
+
+.surface-subsection {
+ background: rgba(255, 255, 255, 0.66);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ padding: 1rem 1.1rem;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
+}
+
+.metric-card,
+.quick-stat-card,
+.dashboard-stat-card {
+ padding: 1rem 1.05rem;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(243, 245, 244, 0.92));
+}
+
+.dashboard-stat-primary {
+ background: linear-gradient(135deg, rgba(17, 24, 39, 0.98), rgba(15, 118, 110, 0.96));
color: #fff;
- text-decoration: none;
- background: rgba(0, 0, 0, 0.2);
- padding: 0.5rem 1rem;
- border-radius: 8px;
- transition: all 0.3s ease;
}
-.header-link:hover {
- background: rgba(0, 0, 0, 0.4);
- text-decoration: none;
+.dashboard-stat-primary .metric-label,
+.dashboard-stat-primary .small,
+.dashboard-stat-primary .text-muted {
+ color: rgba(255, 255, 255, 0.76) !important;
}
-/* Admin Styles */
-.admin-container {
- max-width: 900px;
- margin: 3rem auto;
- padding: 2.5rem;
- background: rgba(255, 255, 255, 0.85);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- border-radius: 24px;
- box-shadow: 0 20px 50px rgba(0,0,0,0.15);
- border: 1px solid rgba(255, 255, 255, 0.4);
+.dashboard-stat-alert {
+ border-color: rgba(234, 88, 12, 0.24);
+ background: linear-gradient(180deg, rgba(255, 247, 237, 0.95), rgba(255, 237, 213, 0.85));
+}
+
+.metric-label,
+.detail-label {
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--muted);
+ margin-bottom: 0.35rem;
+ font-weight: 700;
+}
+
+.metric-value,
+.detail-value {
+ font-size: 1.9rem;
+ font-weight: 800;
+ letter-spacing: -0.05em;
+}
+
+.detail-number {
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.hero-note span,
+.dashboard-note-item,
+.preview-note-item {
+ position: relative;
+ padding-left: 0.95rem;
+}
+
+.hero-note span::before,
+.dashboard-note-item::before,
+.preview-note-item::before {
+ content: "";
+ width: 0.42rem;
+ height: 0.42rem;
+ border-radius: 999px;
+ background: var(--accent-2);
+ position: absolute;
+ left: 0;
+ top: 0.42rem;
+}
+
+.hero-trust-grid {
+ max-width: 52rem;
+}
+
+.quick-stat-card {
+ min-height: 100%;
+}
+
+.quick-stat-value {
+ font-size: 1.05rem;
+ font-weight: 800;
+ color: var(--text);
+ margin-bottom: 0.35rem;
+}
+
+.quick-stat-label {
+ font-size: 0.88rem;
+ color: var(--muted);
+ line-height: 1.45;
+}
+
+.hero-preview-card {
+ position: relative;
+ overflow: hidden;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(244, 247, 246, 0.94)),
+ linear-gradient(135deg, rgba(15, 118, 110, 0.06), transparent 55%);
+ border-radius: var(--radius-lg);
+}
+
+.hero-preview-card::after {
+ content: "";
+ position: absolute;
+ inset: auto -3rem -3rem auto;
+ width: 11rem;
+ height: 11rem;
+ border-radius: 30px;
+ background: linear-gradient(135deg, rgba(17, 24, 39, 0.08), rgba(15, 118, 110, 0.14));
+ transform: rotate(18deg);
+}
+
+.mini-list-preview,
+.mini-list {
position: relative;
z-index: 1;
}
-.admin-container h1 {
- margin-top: 0;
- color: #212529;
- font-weight: 800;
-}
-
-.table {
- width: 100%;
- border-collapse: separate;
- border-spacing: 0 8px;
- margin-top: 1.5rem;
-}
-
-.table th {
- background: transparent;
- border: none;
- padding: 1rem;
- color: #6c757d;
- font-weight: 600;
- text-transform: uppercase;
- font-size: 0.75rem;
- letter-spacing: 1px;
-}
-
-.table td {
- background: #fff;
- padding: 1rem;
- border: none;
-}
-
-.table tr td:first-child { border-radius: 12px 0 0 12px; }
-.table tr td:last-child { border-radius: 0 12px 12px 0; }
-
-.form-group {
- margin-bottom: 1.25rem;
-}
-
-.form-group label {
- display: block;
- margin-bottom: 0.5rem;
- font-weight: 600;
- font-size: 0.9rem;
-}
-
-.form-control {
- width: 100%;
- padding: 0.75rem 1rem;
- border: 1px solid rgba(0, 0, 0, 0.1);
- border-radius: 12px;
- background: #fff;
- transition: all 0.3s ease;
- box-sizing: border-box;
-}
-
-.form-control:focus {
- outline: none;
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
-}
-
-.header-container {
+.preview-row,
+.list-row {
display: flex;
justify-content: space-between;
align-items: center;
-}
-
-.header-links {
- display: flex;
gap: 1rem;
+ padding: 0.9rem 0;
+ border-top: 1px solid var(--line);
+ color: inherit;
}
-.admin-card {
- background: rgba(255, 255, 255, 0.6);
- padding: 2rem;
- border-radius: 20px;
- border: 1px solid rgba(255, 255, 255, 0.5);
- margin-bottom: 2.5rem;
- box-shadow: 0 10px 30px rgba(0,0,0,0.05);
+.preview-row:first-of-type,
+.list-row:first-of-type {
+ border-top: 0;
}
-.admin-card h3 {
- margin-top: 0;
- margin-bottom: 1.5rem;
+.preview-row:hover,
+.list-row:hover {
+ opacity: 0.92;
+}
+
+.preview-note-card,
+.empty-inline {
+ border: 1px dashed var(--line-strong);
+ border-radius: var(--radius-sm);
+ padding: 1rem;
+ color: var(--muted);
+ background: rgba(255, 255, 255, 0.68);
+}
+
+.feature-card,
+.process-card,
+.detail-box,
+.cta-strip,
+.list-card,
+.auth-side,
+.auth-form-card {
+ backdrop-filter: blur(12px);
+}
+
+.feature-card,
+.process-card {
+ padding: 1.3rem;
+}
+
+.feature-card-accent {
+ position: relative;
+ overflow: hidden;
+}
+
+.feature-card-accent::after {
+ content: "";
+ position: absolute;
+ inset: auto -1.8rem -2.5rem auto;
+ width: 7rem;
+ height: 7rem;
+ border-radius: 24px;
+ background: linear-gradient(135deg, rgba(15, 118, 110, 0.08), rgba(234, 88, 12, 0.08));
+ transform: rotate(24deg);
+}
+
+.feature-icon-pill,
+.process-step {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ border-radius: 999px;
+ background: var(--accent-2-soft);
+ color: var(--accent-2);
+ font-weight: 800;
+ font-size: 0.84rem;
+}
+
+.process-step {
+ margin-bottom: 0.9rem;
+}
+
+.workflow-panel {
+ padding: 1.15rem;
+}
+
+.cta-strip {
+ padding: 1.45rem 1.5rem;
+}
+
+.cta-strong {
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(240, 249, 247, 0.92));
+}
+
+.summary-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ padding: 0.58rem 0.9rem;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.72);
+ color: var(--text);
+ font-size: 0.9rem;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
+}
+
+.summary-chip-alert {
+ border-color: var(--danger-line);
+ background: var(--danger-soft);
+}
+
+.badge-soft {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.48rem 0.82rem;
+ border-radius: 999px;
+ background: rgba(15, 118, 110, 0.08);
+ border: 1px solid rgba(15, 118, 110, 0.16);
+ color: var(--accent-2);
+ font-size: 0.78rem;
font-weight: 700;
}
-.btn-delete {
- background: #dc3545;
- color: white;
- border: none;
- padding: 0.25rem 0.5rem;
- border-radius: 4px;
- cursor: pointer;
+.form-label {
+ font-size: 0.92rem;
+ font-weight: 700;
+ color: var(--text);
}
-.btn-add {
- background: #212529;
- color: white;
- border: none;
- padding: 0.5rem 1rem;
- border-radius: 4px;
- cursor: pointer;
- margin-top: 1rem;
+.form-control,
+.form-select {
+ min-height: 48px;
+ border-color: var(--line-strong);
+ border-radius: 14px;
+ box-shadow: none;
+ background: rgba(255, 255, 255, 0.92);
}
-.btn-save {
- background: #0088cc;
- color: white;
- border: none;
- padding: 0.8rem 1.5rem;
- border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
- width: 100%;
- transition: all 0.3s ease;
+textarea.form-control {
+ min-height: auto;
}
-.webhook-url {
- font-size: 0.85em;
- color: #555;
- margin-top: 0.5rem;
+.form-control:focus,
+.form-select:focus,
+.btn:focus,
+.nav-link:focus {
+ box-shadow: 0 0 0 0.24rem rgba(15, 118, 110, 0.12);
+ border-color: rgba(15, 118, 110, 0.44);
}
-.history-table-container {
- overflow-x: auto;
- background: rgba(255, 255, 255, 0.4);
- padding: 1rem;
- border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.3);
+.btn {
+ border-radius: 14px;
+ font-weight: 700;
+ padding-inline: 1rem;
}
-.history-table {
- width: 100%;
+.btn-dark {
+ background: linear-gradient(135deg, var(--accent) 0%, #1f3b4d 100%);
+ border-color: transparent;
+ box-shadow: 0 14px 28px rgba(17, 24, 39, 0.18);
}
-.history-table-time {
- width: 15%;
+.btn-dark:hover,
+.btn-dark:focus {
+ background: linear-gradient(135deg, #0b1120 0%, #173041 100%);
+ border-color: transparent;
+}
+
+.btn-outline-secondary {
+ border-color: rgba(148, 163, 184, 0.34);
+ color: var(--text);
+ background: rgba(255, 255, 255, 0.66);
+}
+
+.btn-outline-secondary:hover,
+.btn-outline-secondary:focus {
+ background: rgba(17, 24, 39, 0.04);
+ color: var(--text);
+ border-color: rgba(15, 118, 110, 0.26);
+}
+
+.sticky-card {
+ position: sticky;
+ top: 6.2rem;
+}
+
+.form-helper-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.55rem;
+}
+
+.dashboard-summary-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 1rem;
+}
+
+.dashboard-summary-grid-wide {
+ align-items: stretch;
+}
+
+.dashboard-note-card {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(239, 246, 244, 0.9));
+}
+
+.dashboard-form-card,
+.dashboard-list-panel,
+.admin-info-card {
+ background: rgba(255, 255, 255, 0.84);
+}
+
+.list-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 1rem;
+}
+
+.list-card {
+ padding: 1.15rem;
+ transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
+}
+
+.list-card:hover {
+ transform: translateY(-2px);
+ border-color: rgba(15, 118, 110, 0.26);
+}
+
+.list-card-highlight {
+ border-color: rgba(15, 118, 110, 0.34);
+ box-shadow: 0 0 0 1px rgba(15, 118, 110, 0.22), var(--shadow-strong);
+}
+
+.list-meta-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.85rem;
+}
+
+.detail-metadata {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.7rem 1rem;
+ color: var(--muted);
+ font-size: 0.88rem;
+}
+
+.detail-metadata span {
+ position: relative;
+ padding-left: 0.8rem;
+}
+
+.detail-metadata span::before {
+ content: "";
+ position: absolute;
+ width: 0.28rem;
+ height: 0.28rem;
+ border-radius: 999px;
+ background: rgba(15, 118, 110, 0.5);
+ left: 0;
+ top: 0.45rem;
+}
+
+.detail-metadata.stacked {
+ flex-direction: column;
+ gap: 0.55rem;
+}
+
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.42rem 0.76rem;
+ border-radius: 999px;
+ font-size: 0.8rem;
+ font-weight: 800;
white-space: nowrap;
- font-size: 0.85em;
- color: #555;
+ border: 1px solid transparent;
}
-.history-table-user {
- width: 35%;
- background: rgba(255, 255, 255, 0.3);
- border-radius: 8px;
- padding: 8px;
+.status-pill.large {
+ font-size: 0.95rem;
+ padding: 0.58rem 0.95rem;
}
-.history-table-ai {
- width: 50%;
- background: rgba(255, 255, 255, 0.5);
- border-radius: 8px;
- padding: 8px;
+.status-overdue {
+ background: var(--danger-soft);
+ color: #991b1b;
+ border-color: var(--danger-line);
}
-.no-messages {
- text-align: center;
- color: #777;
-}
\ No newline at end of file
+.status-soon {
+ background: var(--warn-soft);
+ color: #9a3412;
+ border-color: var(--warn-line);
+}
+
+.status-ok {
+ background: var(--ok-soft);
+ color: #166534;
+ border-color: var(--ok-line);
+}
+
+.status-neutral {
+ background: #f5f5f5;
+ color: #52525b;
+ border-color: #e4e4e7;
+}
+
+.muted-panel {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(244, 247, 246, 0.92));
+}
+
+.empty-state {
+ border: 1px dashed var(--line-strong);
+ border-radius: var(--radius-md);
+ background: rgba(255, 255, 255, 0.68);
+}
+
+.detail-card,
+.admin-card {
+ padding: 1.5rem;
+}
+
+.detail-box {
+ padding: 1rem;
+ height: 100%;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(243, 245, 244, 0.88));
+}
+
+.side-panel {
+ position: sticky;
+ top: 6.2rem;
+}
+
+.filter-button.is-active {
+ background: linear-gradient(135deg, var(--accent) 0%, #173041 100%);
+ color: #fff;
+ border-color: transparent;
+}
+
+.list-card.is-hidden {
+ display: none;
+}
+
+.app-toast {
+ min-width: min(92vw, 360px);
+}
+
+code {
+ background: #f4f4f5;
+ border: 1px solid #e4e4e7;
+ border-radius: 6px;
+ padding: 0.15rem 0.4rem;
+}
+
+.nav-user-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.48rem 0.85rem;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.82);
+ border: 1px solid var(--line);
+ color: var(--text);
+ font-size: 0.88rem;
+ font-weight: 700;
+}
+
+.auth-shell {
+ position: relative;
+}
+
+.auth-side,
+.auth-form-card {
+ height: 100%;
+}
+
+.auth-side {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(236, 246, 243, 0.94));
+}
+
+.auth-form-card {
+ max-width: 34rem;
+ margin-inline: auto;
+ background: rgba(255, 255, 255, 0.92);
+}
+
+.auth-feature-item {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ padding: 0.95rem 1rem;
+ border-radius: 16px;
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.6);
+}
+
+.auth-feature-item strong {
+ color: var(--text);
+}
+
+.auth-feature-item span {
+ color: var(--muted);
+ line-height: 1.5;
+}
+
+.footer-shell {
+ gap: 2rem;
+}
+
+.footer-brand {
+ max-width: 38rem;
+}
+
+.section-soft {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.52));
+}
+
+@media (max-width: 1199.98px) {
+ .dashboard-summary-grid,
+ .list-meta-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 991.98px) {
+ .nav-shell {
+ border-radius: 28px;
+ align-items: flex-start !important;
+ }
+
+ .navbar-toggler {
+ margin-left: auto;
+ flex-shrink: 0;
+ }
+
+ .navbar-collapse {
+ width: 100%;
+ flex-basis: 100%;
+ padding-top: 0.9rem;
+ }
+
+ .navbar-nav {
+ align-items: stretch !important;
+ }
+
+ .navbar-nav .nav-link,
+ .navbar-nav .btn,
+ .nav-user-badge {
+ width: 100%;
+ }
+
+ .navbar-nav .btn {
+ justify-content: center;
+ }
+
+
+ .sticky-card,
+ .side-panel {
+ position: static;
+ }
+
+ .dashboard-summary-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 767.98px) {
+ .nav-shell {
+ padding: 0.7rem 0.9rem;
+ }
+
+ .brand-subtitle {
+ display: none;
+ }
+
+ .list-meta-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 575.98px) {
+ .surface-card,
+ .detail-card,
+ .admin-card,
+ .workflow-panel {
+ padding: 1rem;
+ }
+
+ .preview-row,
+ .list-row {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .display-title {
+ max-width: none;
+ }
+}
+
+
+.brand-mark-image {
+ padding: 0.2rem;
+ background: rgba(255, 255, 255, 0.92);
+}
+
+.brand-mark img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ border-radius: inherit;
+}
+
+.admin-section-card {
+ height: 100%;
+}
+
+.admin-branding-preview {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+ padding: 1rem;
+ border: 1px dashed rgba(148, 163, 184, 0.35);
+ border-radius: var(--radius-sm);
+ background: rgba(255, 255, 255, 0.72);
+}
+
+.admin-brand-preview,
+.admin-favicon-preview {
+ display: block;
+ background: #fff;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+}
+
+.admin-brand-preview {
+ width: auto;
+ max-width: 100%;
+ max-height: 64px;
+ padding: 0.5rem 0.75rem;
+}
+
+.admin-favicon-preview {
+ width: 40px;
+ height: 40px;
+ object-fit: contain;
+ padding: 0.35rem;
+}
+
+@media (max-width: 767.98px) {
+ .admin-branding-preview {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/assets/js/main.js b/assets/js/main.js
index d349598..54b1322 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -1,39 +1,105 @@
document.addEventListener('DOMContentLoaded', () => {
- const chatForm = document.getElementById('chat-form');
- const chatInput = document.getElementById('chat-input');
- const chatMessages = document.getElementById('chat-messages');
-
- const appendMessage = (text, sender) => {
- const msgDiv = document.createElement('div');
- msgDiv.classList.add('message', sender);
- msgDiv.textContent = text;
- chatMessages.appendChild(msgDiv);
- chatMessages.scrollTop = chatMessages.scrollHeight;
- };
-
- chatForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const message = chatInput.value.trim();
- if (!message) return;
-
- appendMessage(message, 'visitor');
- chatInput.value = '';
-
- try {
- const response = await fetch('api/chat.php', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ message })
- });
- const data = await response.json();
-
- // Artificial delay for realism
- setTimeout(() => {
- appendMessage(data.reply, 'bot');
- }, 500);
- } catch (error) {
- console.error('Error:', error);
- appendMessage("Sorry, something went wrong. Please try again.", 'bot');
+ const toastElements = document.querySelectorAll('.toast');
+ toastElements.forEach((toastElement) => {
+ if (window.bootstrap && bootstrap.Toast) {
+ const toast = new bootstrap.Toast(toastElement);
+ toast.show();
}
});
+
+ const navToggleButton = document.querySelector('.navbar-toggler');
+ const navCollapse = document.getElementById('mainNav');
+
+ const syncNavToggleState = () => {
+ if (!navToggleButton || !navCollapse) return;
+ const expanded = navCollapse.classList.contains('show');
+ navToggleButton.setAttribute('aria-expanded', expanded ? 'true' : 'false');
+ navToggleButton.classList.toggle('is-open', expanded);
+ };
+
+ if (navToggleButton && navCollapse) {
+ navToggleButton.addEventListener('click', (event) => {
+ if (window.matchMedia('(min-width: 992px)').matches) return;
+
+ event.preventDefault();
+
+ if (window.bootstrap && bootstrap.Collapse) {
+ const collapse = bootstrap.Collapse.getOrCreateInstance(navCollapse, { toggle: false });
+ if (navCollapse.classList.contains('show')) {
+ collapse.hide();
+ } else {
+ collapse.show();
+ }
+ return;
+ }
+
+ navCollapse.classList.toggle('show');
+ syncNavToggleState();
+ });
+
+ navCollapse.addEventListener('shown.bs.collapse', syncNavToggleState);
+ navCollapse.addEventListener('hidden.bs.collapse', syncNavToggleState);
+
+ navCollapse.querySelectorAll('a, button').forEach((element) => {
+ element.addEventListener('click', () => {
+ if (!navCollapse.classList.contains('show') || window.matchMedia('(min-width: 992px)').matches) return;
+
+ if (window.bootstrap && bootstrap.Collapse) {
+ const collapse = bootstrap.Collapse.getOrCreateInstance(navCollapse, { toggle: false });
+ collapse.hide();
+ } else {
+ navCollapse.classList.remove('show');
+ syncNavToggleState();
+ }
+ });
+ });
+
+ syncNavToggleState();
+ }
+
+ const serviceSelect = document.querySelector('[data-service-select]');
+ const intervalInput = document.querySelector('[data-interval-input]');
+ const applyDefaultButton = document.querySelector('[data-apply-default]');
+
+ const applyDefaultInterval = () => {
+ if (!serviceSelect || !intervalInput) return;
+ const selectedOption = serviceSelect.options[serviceSelect.selectedIndex];
+ const defaultDays = selectedOption ? selectedOption.getAttribute('data-default-days') : '';
+ if (defaultDays) {
+ intervalInput.value = defaultDays;
+ }
+ };
+
+ if (applyDefaultButton) {
+ applyDefaultButton.addEventListener('click', applyDefaultInterval);
+ }
+
+ const filterButtons = document.querySelectorAll('.filter-button');
+ const listCards = document.querySelectorAll('[data-reminder-list] .list-card');
+
+ filterButtons.forEach((button) => {
+ button.addEventListener('click', () => {
+ filterButtons.forEach((item) => {
+ item.classList.remove('is-active');
+ if (item.classList.contains('btn-dark')) return;
+ });
+
+ filterButtons.forEach((item) => {
+ if (item !== button) {
+ item.classList.remove('btn-dark');
+ item.classList.add('btn-outline-secondary');
+ }
+ });
+
+ button.classList.add('is-active', 'btn-dark');
+ button.classList.remove('btn-outline-secondary');
+
+ const filter = button.getAttribute('data-filter');
+ listCards.forEach((card) => {
+ const status = card.getAttribute('data-status');
+ const show = filter === 'all' || status === filter;
+ card.classList.toggle('is-hidden', !show);
+ });
+ });
+ });
});
diff --git a/dashboard.php b/dashboard.php
new file mode 100644
index 0000000..ded53d6
--- /dev/null
+++ b/dashboard.php
@@ -0,0 +1,237 @@
+ '',
+ 'vehicle_category' => 'Motor',
+ 'plate_number' => '',
+ 'service_name' => 'Ganti oli mesin',
+ 'last_service_date' => date('Y-m-d'),
+ 'reminder_interval_days' => 90,
+ 'odometer_km' => '',
+ 'notes' => '',
+];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $formData = array_merge($formData, $_POST);
+ $result = validate_service_payload($_POST);
+ $errors = $result['errors'];
+ $formData = array_merge($formData, $result['clean']);
+
+ if (!$errors) {
+ $newId = create_service($result['clean']);
+ set_flash('success', 'Catatan servis berhasil disimpan. Hanya akun kamu yang bisa melihat reminder ini.');
+ header('Location: ' . app_url('dashboard.php') . '?created=' . $newId);
+ exit;
+ }
+}
+
+$userId = current_user_id();
+$userName = current_user_name();
+$services = fetch_services_for_user($userId);
+$summary = dashboard_summary_for_user($userId);
+$createdId = isset($_GET['created']) ? (int) $_GET['created'] : 0;
+$catalog = service_catalog();
+
+render_page_start([
+ 'title' => 'Dashboard reminder servis',
+ 'description' => 'Input servis kendaraan, lihat reminder dashboard, dan cek status item servis yang akan jatuh tempo.',
+ 'page' => 'dashboard',
+]);
+render_flash(consume_flash());
+?>
+
+
+
+
+
Dashboard pribadi
+
Halo, = e($userName) ?>. Semua catatan servis di sini hanya milik akun kamu.
+
Kamu bisa menambah reminder baru, memantau item yang telat, lalu membuka detail tanpa khawatir data user lain ikut terlihat.
+
+
+
Total catatan
+
= (int) $summary['total_services'] ?>
+
semua item servis aktif
+
+
+
Terlambat
+
= (int) $summary['overdue_count'] ?>
+
prioritas untuk dicek lebih dulu
+
+
+
Segera jatuh tempo
+
= (int) $summary['due_soon_count'] ?>
+
siapkan jadwal servis berikutnya
+
+
+
+
+
+
Cara paling gampang pakai dashboard
+
Masukkan satu reminder per item servis.
+
+
Isi nama kendaraan dan tanggal servis terakhir.
+
Pilih item servis, misalnya oli mesin atau CVT.
+
Atur interval hari agar dashboard menghitung due date berikutnya.
+
+
+ Privat per akun
+ Cocok untuk pemula
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Daftar reminder servis
+
Semua catatan di bawah ini hanya milik akun = e($userName) ?>.
+
+
+ Semua
+ Terlambat
+ Segera
+ Aman
+
+
+
+
+
+
+
+
+
+
= e($service['vehicle_category']) ?>= !empty($service['plate_number']) ? ' · ' . e($service['plate_number']) : '' ?>
+
= e($service['vehicle_name']) ?>
+
= e($service['service_name']) ?>
+
+
= e($state['label']) ?>
+
+
+
+
= $service['odometer_km'] ? number_format((int) $service['odometer_km']) . ' km' : 'Kilometer belum diisi' ?>
+
Buka detail
+
+
+
+
+
+
+
Belum ada reminder di akun ini
+
Mulai dari satu catatan sederhana — misalnya ganti oli mesin — lalu dashboard akan langsung menghitung servis berikutnya untuk akun kamu sendiri.
+
+
+
+
+
+
+
Data kamu tetap privat
+
Dashboard ini hanya menampilkan reminder milik akun yang sedang login, jadi catatan servis pengguna lain tidak ikut terlihat di sini.
+
+
+ Aman dipakai untuk banyak akun.
+
+
+
+
+
+
+
+
diff --git a/healthz.php b/healthz.php
new file mode 100644
index 0000000..d24217d
--- /dev/null
+++ b/healthz.php
@@ -0,0 +1,12 @@
+ 'ok',
+ 'app' => 'ServisIngat',
+ 'time_utc' => gmdate('c'),
+], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
diff --git a/includes/bootstrap.php b/includes/bootstrap.php
new file mode 100644
index 0000000..52c3a3a
--- /dev/null
+++ b/includes/bootstrap.php
@@ -0,0 +1,714 @@
+prepare(
+ 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table_name AND COLUMN_NAME = :column_name'
+ );
+ $stmt->execute([
+ ':schema' => DB_NAME,
+ ':table_name' => $table,
+ ':column_name' => $column,
+ ]);
+
+ return (int) $stmt->fetchColumn() > 0;
+}
+
+function schema_index_exists(string $table, string $index): bool
+{
+ $stmt = db()->prepare(
+ 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table_name AND INDEX_NAME = :index_name'
+ );
+ $stmt->execute([
+ ':schema' => DB_NAME,
+ ':table_name' => $table,
+ ':index_name' => $index,
+ ]);
+
+ return (int) $stmt->fetchColumn() > 0;
+}
+
+function ensure_column_exists(string $table, string $column, string $definition): void
+{
+ if (!schema_column_exists($table, $column)) {
+ db()->exec(sprintf('ALTER TABLE %s ADD COLUMN %s %s', $table, $column, $definition));
+ }
+}
+
+function ensure_index_exists(string $table, string $index, string $definition): void
+{
+ if (!schema_index_exists($table, $index)) {
+ db()->exec(sprintf('ALTER TABLE %s ADD INDEX %s %s', $table, $index, $definition));
+ }
+}
+
+function ensure_app_schema(): void
+{
+ static $done = false;
+ if ($done) {
+ return;
+ }
+
+ $usersSql = <<<'SQL'
+CREATE TABLE IF NOT EXISTS app_users (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(120) NOT NULL,
+ email VARCHAR(190) NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uniq_user_email (email)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+SQL;
+
+ $itemsSql = <<<'SQL'
+CREATE TABLE IF NOT EXISTS service_tracker_items (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ item_type ENUM('service','setting') NOT NULL DEFAULT 'service',
+ user_id INT UNSIGNED DEFAULT NULL,
+ slug_key VARCHAR(120) DEFAULT NULL,
+ vehicle_name VARCHAR(120) DEFAULT NULL,
+ vehicle_category VARCHAR(20) DEFAULT NULL,
+ plate_number VARCHAR(40) DEFAULT NULL,
+ service_name VARCHAR(80) DEFAULT NULL,
+ last_service_date DATE DEFAULT NULL,
+ reminder_interval_days INT DEFAULT NULL,
+ next_due_date DATE DEFAULT NULL,
+ odometer_km INT DEFAULT NULL,
+ notes TEXT DEFAULT NULL,
+ content_longtext LONGTEXT DEFAULT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uniq_item_slug (item_type, slug_key),
+ KEY idx_service_due (item_type, next_due_date),
+ KEY idx_vehicle_name (vehicle_name),
+ KEY idx_user_service_due (user_id, item_type, next_due_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+SQL;
+
+ db()->exec($usersSql);
+ db()->exec($itemsSql);
+
+ ensure_column_exists('service_tracker_items', 'user_id', 'INT UNSIGNED DEFAULT NULL AFTER item_type');
+ ensure_index_exists('service_tracker_items', 'idx_user_service_due', '(user_id, item_type, next_due_date)');
+
+ $done = true;
+}
+
+ensure_app_schema();
+
+function service_catalog(): array
+{
+ return [
+ 'Ganti oli mesin' => 90,
+ 'Filter udara' => 120,
+ 'Bersihin CVT' => 90,
+ 'Vanbelt' => 180,
+ 'Ganti oli gardan' => 180,
+ 'Busi' => 180,
+ 'Tune up ringan' => 120,
+ 'Cek rem & kampas' => 90,
+ ];
+}
+
+function normalize_email(string $email): string
+{
+ return strtolower(trim($email));
+}
+
+function current_user(): ?array
+{
+ $user = $_SESSION['user'] ?? null;
+ return is_array($user) ? $user : null;
+}
+
+function current_user_id(): int
+{
+ return (int) ((current_user()['id'] ?? 0));
+}
+
+function current_user_name(): string
+{
+ $user = current_user();
+ if (!$user) {
+ return '';
+ }
+
+ $name = trim((string) ($user['name'] ?? ''));
+ if ($name !== '') {
+ return $name;
+ }
+
+ return (string) ($user['email'] ?? '');
+}
+
+function is_user_logged_in(): bool
+{
+ return current_user_id() > 0;
+}
+
+function login_user(array $user): void
+{
+ $_SESSION['user'] = [
+ 'id' => (int) ($user['id'] ?? 0),
+ 'name' => (string) ($user['name'] ?? ''),
+ 'email' => (string) ($user['email'] ?? ''),
+ ];
+ session_regenerate_id(true);
+}
+
+function logout_user(): void
+{
+ unset($_SESSION['user']);
+ session_regenerate_id(true);
+}
+
+function auth_redirect_target(string $default = ''): string
+{
+ $fallback = $default !== '' ? $default : app_url('dashboard.php');
+ if (!str_starts_with($fallback, '/') && preg_match('#^[a-z][a-z0-9+.-]*://#i', $fallback) !== 1) {
+ $fallback = app_url($fallback);
+ }
+
+ $target = trim((string) ($_POST['redirect'] ?? $_GET['redirect'] ?? ''));
+ if ($target === '') {
+ return $fallback;
+ }
+
+ if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $target) === 1 || str_starts_with($target, '//')) {
+ return $fallback;
+ }
+
+ return str_starts_with($target, '/') ? $target : app_url($target);
+}
+
+function require_user_login(): void
+{
+ if (is_user_logged_in()) {
+ return;
+ }
+
+ set_flash('warning', 'Silakan login dulu supaya catatan servis hanya terlihat oleh akun kamu sendiri.');
+ $target = (string) ($_SERVER['REQUEST_URI'] ?? app_url('dashboard.php'));
+ header('Location: ' . app_url('login.php') . '?redirect=' . urlencode($target));
+ exit;
+}
+
+function find_user_by_email(string $email): ?array
+{
+ $stmt = db()->prepare('SELECT * FROM app_users WHERE email = :email LIMIT 1');
+ $stmt->execute([
+ ':email' => normalize_email($email),
+ ]);
+
+ $row = $stmt->fetch();
+ return $row ?: null;
+}
+
+function create_user_account(string $name, string $email, string $password): int
+{
+ $stmt = db()->prepare(
+ 'INSERT INTO app_users (name, email, password_hash) VALUES (:name, :email, :password_hash)'
+ );
+ $stmt->execute([
+ ':name' => trim($name),
+ ':email' => normalize_email($email),
+ ':password_hash' => password_hash($password, PASSWORD_DEFAULT),
+ ]);
+
+ return (int) db()->lastInsertId();
+}
+
+function verify_user_login(string $email, string $password): ?array
+{
+ $user = find_user_by_email($email);
+ if (!$user) {
+ return null;
+ }
+
+ return password_verify($password, (string) $user['password_hash']) ? $user : null;
+}
+
+function app_setting(string $key, string $default = ''): string
+{
+ $stmt = db()->prepare('SELECT content_longtext FROM service_tracker_items WHERE item_type = :type AND slug_key = :slug LIMIT 1');
+ $stmt->execute([
+ ':type' => 'setting',
+ ':slug' => $key,
+ ]);
+ $value = $stmt->fetchColumn();
+
+ return is_string($value) ? $value : $default;
+}
+
+function save_setting(string $key, string $value): void
+{
+ $stmt = db()->prepare(
+ 'INSERT INTO service_tracker_items (item_type, slug_key, content_longtext) VALUES (:type, :slug, :content)
+ ON DUPLICATE KEY UPDATE content_longtext = VALUES(content_longtext), updated_at = CURRENT_TIMESTAMP'
+ );
+ $stmt->execute([
+ ':type' => 'setting',
+ ':slug' => $key,
+ ':content' => $value,
+ ]);
+}
+
+function head_ad_code(): string
+{
+ return app_setting('ads_head');
+}
+
+function body_ad_code(): string
+{
+ return app_setting('ads_body');
+}
+
+function app_project_name(): string
+{
+ return app_env('PROJECT_NAME', 'ServisIngat');
+}
+
+function app_brand_initial(): string
+{
+ $projectName = trim(app_project_name());
+ if ($projectName === '') {
+ return 'S';
+ }
+
+ if (function_exists('mb_substr')) {
+ return mb_strtoupper((string) mb_substr($projectName, 0, 1, 'UTF-8'), 'UTF-8');
+ }
+
+ return strtoupper(substr($projectName, 0, 1));
+}
+
+function app_logo_url(): string
+{
+ return app_setting('brand_logo_url');
+}
+
+function app_favicon_url(): string
+{
+ return app_setting('brand_favicon_url');
+}
+
+function set_flash(string $type, string $message): void
+{
+ $_SESSION['flash'] = [
+ 'type' => $type,
+ 'message' => $message,
+ ];
+}
+
+function consume_flash(): ?array
+{
+ if (empty($_SESSION['flash']) || !is_array($_SESSION['flash'])) {
+ return null;
+ }
+
+ $flash = $_SESSION['flash'];
+ unset($_SESSION['flash']);
+ return $flash;
+}
+
+function empty_dashboard_summary(): array
+{
+ return [
+ 'total_services' => 0,
+ 'overdue_count' => 0,
+ 'due_soon_count' => 0,
+ 'last_update' => null,
+ ];
+}
+
+function dashboard_summary(): array
+{
+ $stmt = db()->query(
+ "SELECT
+ SUM(CASE WHEN item_type = 'service' THEN 1 ELSE 0 END) AS total_services,
+ SUM(CASE WHEN item_type = 'service' AND next_due_date < CURDATE() THEN 1 ELSE 0 END) AS overdue_count,
+ SUM(CASE WHEN item_type = 'service' AND next_due_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 14 DAY) THEN 1 ELSE 0 END) AS due_soon_count,
+ MAX(CASE WHEN item_type = 'service' THEN updated_at ELSE NULL END) AS last_update
+ FROM service_tracker_items"
+ );
+
+ $row = $stmt->fetch() ?: [];
+ return [
+ 'total_services' => (int) ($row['total_services'] ?? 0),
+ 'overdue_count' => (int) ($row['overdue_count'] ?? 0),
+ 'due_soon_count' => (int) ($row['due_soon_count'] ?? 0),
+ 'last_update' => $row['last_update'] ?? null,
+ ];
+}
+
+function dashboard_summary_for_user(int $userId): array
+{
+ if ($userId <= 0) {
+ return empty_dashboard_summary();
+ }
+
+ $stmt = db()->prepare(
+ "SELECT
+ COUNT(*) AS total_services,
+ SUM(CASE WHEN next_due_date < CURDATE() THEN 1 ELSE 0 END) AS overdue_count,
+ SUM(CASE WHEN next_due_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 14 DAY) THEN 1 ELSE 0 END) AS due_soon_count,
+ MAX(updated_at) AS last_update
+ FROM service_tracker_items
+ WHERE item_type = 'service' AND user_id = :user_id"
+ );
+ $stmt->execute([
+ ':user_id' => $userId,
+ ]);
+
+ $row = $stmt->fetch() ?: [];
+ return [
+ 'total_services' => (int) ($row['total_services'] ?? 0),
+ 'overdue_count' => (int) ($row['overdue_count'] ?? 0),
+ 'due_soon_count' => (int) ($row['due_soon_count'] ?? 0),
+ 'last_update' => $row['last_update'] ?? null,
+ ];
+}
+
+function fetch_services(int $limit = 100): array
+{
+ $stmt = db()->prepare(
+ 'SELECT * FROM service_tracker_items WHERE item_type = :type ORDER BY next_due_date IS NULL, next_due_date ASC, updated_at DESC LIMIT :limit'
+ );
+ $stmt->bindValue(':type', 'service');
+ $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
+ $stmt->execute();
+ return $stmt->fetchAll();
+}
+
+function fetch_services_for_user(int $userId, int $limit = 100): array
+{
+ if ($userId <= 0) {
+ return [];
+ }
+
+ $stmt = db()->prepare(
+ 'SELECT * FROM service_tracker_items WHERE item_type = :type AND user_id = :user_id ORDER BY next_due_date IS NULL, next_due_date ASC, updated_at DESC LIMIT :limit'
+ );
+ $stmt->bindValue(':type', 'service');
+ $stmt->bindValue(':user_id', $userId, PDO::PARAM_INT);
+ $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
+ $stmt->execute();
+ return $stmt->fetchAll();
+}
+
+function fetch_recent_services(int $limit = 5): array
+{
+ $stmt = db()->prepare(
+ 'SELECT * FROM service_tracker_items WHERE item_type = :type ORDER BY created_at DESC LIMIT :limit'
+ );
+ $stmt->bindValue(':type', 'service');
+ $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
+ $stmt->execute();
+ return $stmt->fetchAll();
+}
+
+function fetch_recent_services_for_user(int $userId, int $limit = 5): array
+{
+ if ($userId <= 0) {
+ return [];
+ }
+
+ $stmt = db()->prepare(
+ 'SELECT * FROM service_tracker_items WHERE item_type = :type AND user_id = :user_id ORDER BY created_at DESC LIMIT :limit'
+ );
+ $stmt->bindValue(':type', 'service');
+ $stmt->bindValue(':user_id', $userId, PDO::PARAM_INT);
+ $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
+ $stmt->execute();
+ return $stmt->fetchAll();
+}
+
+function fetch_service_by_id(int $id): ?array
+{
+ $stmt = db()->prepare('SELECT * FROM service_tracker_items WHERE item_type = :type AND id = :id LIMIT 1');
+ $stmt->execute([
+ ':type' => 'service',
+ ':id' => $id,
+ ]);
+ $row = $stmt->fetch();
+ return $row ?: null;
+}
+
+function fetch_service_by_id_for_user(int $userId, int $id): ?array
+{
+ if ($userId <= 0 || $id <= 0) {
+ return null;
+ }
+
+ $stmt = db()->prepare('SELECT * FROM service_tracker_items WHERE item_type = :type AND user_id = :user_id AND id = :id LIMIT 1');
+ $stmt->execute([
+ ':type' => 'service',
+ ':user_id' => $userId,
+ ':id' => $id,
+ ]);
+ $row = $stmt->fetch();
+ return $row ?: null;
+}
+
+function due_state(?string $dateValue): array
+{
+ if (!$dateValue) {
+ return [
+ 'label' => 'Belum dijadwalkan',
+ 'class' => 'status-neutral',
+ 'tone' => 'secondary',
+ 'description' => 'Atur tanggal servis berikutnya untuk mulai dipantau.',
+ ];
+ }
+
+ $today = new DateTimeImmutable('today');
+ $dueDate = new DateTimeImmutable($dateValue);
+ $diff = (int) $today->diff($dueDate)->format('%r%a');
+
+ if ($diff < 0) {
+ $days = abs($diff);
+ return [
+ 'label' => 'Terlambat ' . $days . ' hari',
+ 'class' => 'status-overdue',
+ 'tone' => 'danger',
+ 'description' => 'Sudah melewati jadwal, sebaiknya segera ditindaklanjuti.',
+ ];
+ }
+
+ if ($diff <= 14) {
+ return [
+ 'label' => 'Jatuh tempo ' . $diff . ' hari lagi',
+ 'class' => 'status-soon',
+ 'tone' => 'warning',
+ 'description' => 'Sudah dekat dengan jadwal berikutnya, cocok masuk prioritas.',
+ ];
+ }
+
+ return [
+ 'label' => 'Aman ' . $diff . ' hari lagi',
+ 'class' => 'status-ok',
+ 'tone' => 'success',
+ 'description' => 'Masih aman, tapi sudah tercatat untuk pengingat berikutnya.',
+ ];
+}
+
+function validate_service_payload(array $data): array
+{
+ $catalog = service_catalog();
+ $vehicleName = trim((string) ($data['vehicle_name'] ?? ''));
+ $vehicleCategory = trim((string) ($data['vehicle_category'] ?? ''));
+ $plateNumber = strtoupper(trim((string) ($data['plate_number'] ?? '')));
+ $serviceName = trim((string) ($data['service_name'] ?? ''));
+ $lastServiceDate = trim((string) ($data['last_service_date'] ?? ''));
+ $intervalDays = (int) ($data['reminder_interval_days'] ?? 0);
+ $odometerKm = trim((string) ($data['odometer_km'] ?? ''));
+ $notes = trim((string) ($data['notes'] ?? ''));
+
+ $errors = [];
+
+ if ($vehicleName === '' || strlen($vehicleName) < 3) {
+ $errors['vehicle_name'] = 'Nama kendaraan minimal 3 karakter.';
+ }
+
+ if (!in_array($vehicleCategory, ['Motor', 'Mobil'], true)) {
+ $errors['vehicle_category'] = 'Pilih jenis kendaraan.';
+ }
+
+ if ($serviceName === '') {
+ $errors['service_name'] = 'Pilih item servis utama.';
+ }
+
+ if ($lastServiceDate === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $lastServiceDate)) {
+ $errors['last_service_date'] = 'Tanggal servis terakhir wajib diisi.';
+ }
+
+ if ($intervalDays < 14 || $intervalDays > 365) {
+ $errors['reminder_interval_days'] = 'Interval pengingat harus 14–365 hari.';
+ }
+
+ if ($odometerKm !== '' && (!ctype_digit($odometerKm) || (int) $odometerKm < 0)) {
+ $errors['odometer_km'] = 'Kilometer harus berupa angka positif.';
+ }
+
+ $nextDueDate = null;
+ if (!isset($errors['last_service_date']) && !isset($errors['reminder_interval_days'])) {
+ try {
+ $nextDueDate = (new DateTimeImmutable($lastServiceDate))
+ ->modify('+' . $intervalDays . ' days')
+ ->format('Y-m-d');
+ } catch (Throwable $exception) {
+ $errors['last_service_date'] = 'Tanggal servis terakhir tidak valid.';
+ }
+ }
+
+ return [
+ 'errors' => $errors,
+ 'clean' => [
+ 'vehicle_name' => $vehicleName,
+ 'vehicle_category' => $vehicleCategory,
+ 'plate_number' => $plateNumber,
+ 'service_name' => $serviceName,
+ 'last_service_date' => $lastServiceDate,
+ 'reminder_interval_days' => $intervalDays,
+ 'next_due_date' => $nextDueDate,
+ 'odometer_km' => $odometerKm === '' ? null : (int) $odometerKm,
+ 'notes' => $notes,
+ 'suggested_days' => $catalog[$serviceName] ?? null,
+ ],
+ ];
+}
+
+function create_service(array $payload): int
+{
+ $userId = current_user_id();
+ if ($userId <= 0) {
+ throw new RuntimeException('User must be logged in before creating a service record.');
+ }
+
+ $stmt = db()->prepare(
+ 'INSERT INTO service_tracker_items (
+ item_type, user_id, vehicle_name, vehicle_category, plate_number, service_name,
+ last_service_date, reminder_interval_days, next_due_date, odometer_km, notes
+ ) VALUES (
+ :item_type, :user_id, :vehicle_name, :vehicle_category, :plate_number, :service_name,
+ :last_service_date, :reminder_interval_days, :next_due_date, :odometer_km, :notes
+ )'
+ );
+
+ $stmt->execute([
+ ':item_type' => 'service',
+ ':user_id' => $userId,
+ ':vehicle_name' => $payload['vehicle_name'],
+ ':vehicle_category' => $payload['vehicle_category'],
+ ':plate_number' => $payload['plate_number'],
+ ':service_name' => $payload['service_name'],
+ ':last_service_date' => $payload['last_service_date'],
+ ':reminder_interval_days' => $payload['reminder_interval_days'],
+ ':next_due_date' => $payload['next_due_date'],
+ ':odometer_km' => $payload['odometer_km'],
+ ':notes' => $payload['notes'],
+ ]);
+
+ return (int) db()->lastInsertId();
+}
+
+function is_admin_logged_in(): bool
+{
+ return !empty($_SESSION['is_admin_logged_in']);
+}
+
+function admin_username(): string
+{
+ return app_env('ADMIN_PORTAL_USER', 'admin');
+}
+
+function admin_default_password_hint(): string
+{
+ return app_env('ADMIN_PORTAL_PASSWORD_HINT', 'servis123!');
+}
+
+function admin_password_hash(): string
+{
+ $storedHash = app_setting('admin_password_hash');
+ if ($storedHash !== '') {
+ return $storedHash;
+ }
+
+ return app_env('ADMIN_PORTAL_PASSWORD_HASH', '$2y$10$riyTXC1R9fEPRr2T18rxUuycZVTjpQVvCDOmRQD4ID1EVWw9fyDHC');
+}
+
+function admin_has_custom_password(): bool
+{
+ return app_setting('admin_password_hash') !== '';
+}
+
+function save_admin_password(string $password): void
+{
+ save_setting('admin_password_hash', password_hash($password, PASSWORD_DEFAULT));
+}
+
+function verify_admin_login(string $username, string $password): bool
+{
+ $expectedUser = admin_username();
+ $normalizedUsername = trim($username);
+
+ return hash_equals($expectedUser, $normalizedUsername) && password_verify($password, admin_password_hash());
+}
diff --git a/includes/layout.php b/includes/layout.php
new file mode 100644
index 0000000..5a993ee
--- /dev/null
+++ b/includes/layout.php
@@ -0,0 +1,162 @@
+
+
+
+
+
+ = e(app_brand_initial()) ?>
+
+
+
+
+
+
+
+
+ = e($title) ?> · = e($projectName) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+= $headCode ?>
+
+
+
+
+
+= $bodyCode ?>
+
+
+
+ 'success',
+ 'warning' => 'warning',
+ 'danger', 'error' => 'danger',
+ default => 'secondary',
+ };
+ ?>
+
+
+
+
+
+
+
+
+ 'Pengingat servis kendaraan yang rapi dan gampang dipakai',
+ 'description' => 'Catat servis terakhir, hitung jadwal berikutnya, dan lihat pengingat oli, CVT, filter udara, busi, dan gardan langsung di dashboard.',
+ 'page' => 'home',
+]);
+render_flash(consume_flash());
?>
-
-
-
-
-
- New Style
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Analyzing your requirements and generating your website…
-
-
Loading…
+
+
+
+
+
+
+
Maintenance tracker kendaraan
+
Ingat jadwal servis tanpa ribet, dengan dashboard yang terasa rapi dan privat.
+
ServisIngat membantu pemilik motor dan mobil mencatat servis terakhir, menghitung servis berikutnya, lalu menampilkan reminder yang jelas di dashboard pribadi masing-masing.
+
+
+ Multi-user dan privat per akun
+ Reminder langsung di dashboard
+ Detail servis privat per akun
+
+
+
+
+
Privat
+
setiap akun hanya melihat datanya sendiri
+
+
+
+
+
Ringkas
+
buat catatan baru hanya beberapa field inti
+
+
+
+
+
Siap pakai
+
cocok untuk pemilik kendaraan dan bengkel kecil
+
+
+
+
+
+
+
+
+
Ringkasan saat ini
+
= $isLoggedIn ? 'Akun kamu siap dipakai' : 'Preview dashboard pribadi' ?>
+
= $isLoggedIn ? 'Snapshot singkat dari reminder kamu.' : 'Begitu daftar, setiap user masuk ke ruang reminder masing-masing.' ?>
+
+
= $isLoggedIn ? 'Privat' : 'Multi-user' ?>
+
+
+
+
+
Total catatan
+
= (int) $summary['total_services'] ?>
+
item servis aktif di akun ini
+
+
+
Perlu tindakan
+
= (int) $summary['overdue_count'] ?>
+
reminder yang sudah lewat
+
+
+
Segera due
+
= (int) $summary['due_soon_count'] ?>
+
perlu dicek dalam waktu dekat
+
+
+
+
+
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
-
-
- Page updated: = htmlspecialchars($now) ?> (UTC)
-
-
-
+
+
+
+
+
+
+
Fokus MVP
+
Alur sederhana yang langsung berguna
+
Bukan cuma landing page — sekarang user bisa punya akun sendiri, input jadwal servis, melihat reminder jatuh tempo, dan membuka detail tiap catatan tanpa bercampur dengan user lain.
+
+
+
+
+ 01
+ Akun privat
+ Setiap pengguna mendaftar dan login ke akun sendiri, sehingga catatan servis tersimpan terpisah dan lebih aman dipakai ramai-ramai.
+
+
+
+
+ 02
+ Reminder dashboard
+ Catatan otomatis diklasifikasikan menjadi terlambat, segera jatuh tempo, atau masih aman agar pengguna tahu mana yang perlu action.
+
+
+
+
+ 03
+ Detail servis privat
+ Setiap catatan punya halaman detail sendiri agar riwayat servis lebih rapi, mudah dicek, dan tetap terpisah per akun.
+
+
+
+
+
+
+
+
+
+
+
Cara kerja
+
Buat pemilik kendaraan maupun bengkel kecil
+
Dashboard ini sengaja dibuat ringkas: setiap item servis berdiri sebagai satu reminder yang mudah dicek kapan saja, dengan privasi akun tetap terjaga.
+
+
Contoh item servis yang umum
+
+ Oli mesin
+ Filter udara
+ CVT
+ Busi
+ Gardan
+
+
+
+
+
+
+
+
+
01
+
Daftar akun
+
User membuat akun sendiri dulu supaya data bisa dipisahkan dengan aman.
+
+
+
+
+
02
+
Catat servis terakhir
+
Masukkan nama kendaraan, item servis, tanggal, dan interval reminder.
+
+
+
+
+
03
+
Pantau reminder pribadi
+
Setiap akun hanya melihat reminder miliknya sendiri di dashboard dan halaman detail.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Siap dipakai sekarang sebagai MVP reminder servis multi-user.
+
Versi ini sudah punya register, login, dashboard privat, detail privat, dan alur input reminder yang lebih rapi.
+
+
+
+
+
+
diff --git a/login.php b/login.php
new file mode 100644
index 0000000..0fb0267
--- /dev/null
+++ b/login.php
@@ -0,0 +1,109 @@
+ '',
+];
+$redirectTarget = auth_redirect_target(app_url('dashboard.php'));
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $formData['email'] = trim((string) ($_POST['email'] ?? ''));
+ $password = (string) ($_POST['password'] ?? '');
+ $redirectTarget = auth_redirect_target(app_url('dashboard.php'));
+
+ if (!filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
+ $errors['email'] = 'Masukkan email yang valid.';
+ }
+
+ if ($password === '') {
+ $errors['password'] = 'Password wajib diisi.';
+ }
+
+ if (!$errors) {
+ $user = verify_user_login($formData['email'], $password);
+ if ($user) {
+ login_user($user);
+ set_flash('success', 'Login berhasil. Sekarang kamu hanya melihat data servis milik akun ini.');
+ header('Location: ' . $redirectTarget);
+ exit;
+ }
+
+ $errors['login'] = 'Email atau password tidak cocok.';
+ }
+}
+
+render_page_start([
+ 'title' => 'Login akun',
+ 'description' => 'Masuk ke akun ServisIngat untuk melihat dashboard reminder servis milik kamu sendiri.',
+ 'page' => 'login',
+ 'robots' => 'noindex, nofollow',
+ 'body_class' => 'page-auth',
+]);
+render_flash(consume_flash());
+?>
+
+
+
+
+
+
Login
+
Masuk ke dashboard privat kamu
+
Begitu login, kamu hanya melihat catatan servis yang dibuat oleh akun kamu sendiri.
+
+
+ Privasi per akun
+ Data user lain tidak tampil di dashboard kamu.
+
+
+ Reminder jelas
+ Status terlambat, segera, dan aman tampil langsung setelah masuk.
+
+
+ Alur sederhana
+ Cocok untuk pemilik kendaraan yang ingin cepat input tanpa setting rumit.
+
+
+
+
+
+
+
+
+
diff --git a/logout.php b/logout.php
new file mode 100644
index 0000000..4eba709
--- /dev/null
+++ b/logout.php
@@ -0,0 +1,8 @@
+ '',
+ 'email' => '',
+];
+$redirectTarget = auth_redirect_target(app_url('dashboard.php'));
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $formData['name'] = trim((string) ($_POST['name'] ?? ''));
+ $formData['email'] = trim((string) ($_POST['email'] ?? ''));
+ $password = (string) ($_POST['password'] ?? '');
+ $passwordConfirm = (string) ($_POST['password_confirm'] ?? '');
+ $redirectTarget = auth_redirect_target(app_url('dashboard.php'));
+
+ if ($formData['name'] === '' || strlen($formData['name']) < 3) {
+ $errors['name'] = 'Nama minimal 3 karakter.';
+ }
+
+ if (!filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
+ $errors['email'] = 'Masukkan email yang valid.';
+ } elseif (find_user_by_email($formData['email'])) {
+ $errors['email'] = 'Email ini sudah terdaftar. Silakan login saja.';
+ }
+
+ if (strlen($password) < 6) {
+ $errors['password'] = 'Password minimal 6 karakter.';
+ }
+
+ if ($passwordConfirm === '' || $password !== $passwordConfirm) {
+ $errors['password_confirm'] = 'Konfirmasi password harus sama.';
+ }
+
+ if (!$errors) {
+ $userId = create_user_account($formData['name'], $formData['email'], $password);
+ $user = find_user_by_email($formData['email']);
+
+ if ($userId > 0 && $user) {
+ login_user($user);
+ set_flash('success', 'Akun berhasil dibuat. Sekarang setiap catatan servis kamu tersimpan privat.');
+ header('Location: ' . $redirectTarget);
+ exit;
+ }
+
+ $errors['register'] = 'Akun belum berhasil dibuat. Coba lagi.';
+ }
+}
+
+render_page_start([
+ 'title' => 'Daftar akun baru',
+ 'description' => 'Buat akun ServisIngat agar data reminder servis tiap pengguna tersimpan terpisah.',
+ 'page' => 'register',
+ 'robots' => 'noindex, nofollow',
+ 'body_class' => 'page-auth',
+]);
+render_flash(consume_flash());
+?>
+
+
+
+
+
+
Daftar
+
Buat akun ServisIngat
+
Satu akun = satu ruang dashboard sendiri. Jadi kalau user lain input data, catatan kamu tetap tidak terlihat oleh mereka.
+
+
+ Langsung siap pakai
+ Setelah daftar kamu bisa langsung masuk dan membuat reminder pertama.
+
+
+ Cocok untuk pemula
+ Field yang diminta hanya yang penting: kendaraan, item servis, tanggal, dan interval.
+
+
+ Multi-user aman
+ Arsitektur datanya sudah dipisah per akun supaya dashboard terasa personal.
+
+
+
+
+
+
+
+
+
diff --git a/service.php b/service.php
new file mode 100644
index 0000000..1bef5a8
--- /dev/null
+++ b/service.php
@@ -0,0 +1,98 @@
+ 0 ? fetch_service_by_id_for_user(current_user_id(), $id) : null;
+
+if (!$service) {
+ http_response_code(404);
+}
+
+render_page_start([
+ 'title' => $service ? 'Detail reminder servis' : 'Catatan tidak ditemukan',
+ 'description' => 'Lihat detail item servis kendaraan beserta status reminder dan jadwal servis berikutnya.',
+ 'page' => 'detail',
+ 'robots' => 'noindex, nofollow',
+]);
+render_flash(consume_flash());
+?>
+
+
+
+
+
404
+
Catatan servis tidak ditemukan
+
Catatan ini mungkin bukan milik akun kamu, atau link yang dibuka sudah tidak valid.
+
Kembali ke dashboard
+
+
+
+
+
+
+
+
+
Detail reminder privat
+
= e($service['vehicle_name']) ?>
+
= e($service['service_name']) ?>= !empty($service['plate_number']) ? ' · ' . e($service['plate_number']) : '' ?>
+
+
= e($state['label']) ?>
+
+
+
+
+
+
Jenis kendaraan
+
= e($service['vehicle_category']) ?>
+
+
+
+
+
Servis terakhir
+
= e(date('d M Y', strtotime((string) $service['last_service_date']))) ?>
+
+
+
+
+
Servis berikutnya
+
= e(date('d M Y', strtotime((string) $service['next_due_date']))) ?>
+
+
+
+
+
+
Ringkasan reminder
+
+ Interval pengingat: = (int) $service['reminder_interval_days'] ?> hari
+ Status dashboard: = e($state['description']) ?>
+ Kilometer terakhir: = $service['odometer_km'] ? number_format((int) $service['odometer_km']) . ' km' : 'Belum dicatat' ?>
+
+
+
+
+
Catatan servis
+
= $service['notes'] ? e($service['notes']) : 'Belum ada catatan tambahan untuk item servis ini.' ?>
+
+
+
+
+
+
+
Privasi akun
+
Halaman detail ini hanya menampilkan catatan milik akun yang sedang login, jadi data user lain tidak ikut terlihat.
+
+
+
+
+
+
+