From 3a0ffdff3d1280c37803fd4e1cad75c976994057 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Mon, 30 Mar 2026 17:47:50 +0000 Subject: [PATCH] Autosave: 20260330-174750 --- admin-portal.php | 289 ++++++++++ assets/css/custom.css | 1163 ++++++++++++++++++++++++++++------------ assets/js/main.js | 136 +++-- dashboard.php | 237 ++++++++ healthz.php | 12 + includes/bootstrap.php | 714 ++++++++++++++++++++++++ includes/layout.php | 162 ++++++ index.php | 356 +++++++----- login.php | 109 ++++ logout.php | 8 + register.php | 134 +++++ service.php | 98 ++++ 12 files changed, 2909 insertions(+), 509 deletions(-) create mode 100644 admin-portal.php create mode 100644 dashboard.php create mode 100644 healthz.php create mode 100644 includes/bootstrap.php create mode 100644 includes/layout.php create mode 100644 login.php create mode 100644 logout.php create mode 100644 register.php create mode 100644 service.php 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.

+ + + +
+ +
+ + +
+
+ + +
+ +
+ +
Akses awal MVP ini: / . 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.

+
+
+ + + + Logout +
+
+ + + +
+
+
+
+
+

Script iklan global

+

Tempel kode JavaScript/HTML iklan yang akan dirender di semua halaman.

+
+ Ads +
+
+ +
+ + +
Cocok untuk loader iklan, verifikasi, atau script yang memang harus masuk ke <head>.
+
+
+ + +
Ideal untuk tag yang harus muncul setelah <body> terbuka.
+
+
+ +
+
+
+
+ +
+
+
+
+

Keamanan admin

+

Ganti password admin agar akses slug tersembunyi ini lebih aman.

+
+ Security +
+
+ +
+ + +
+
+
+ + +
+
+
+ + +
+
+
Username admin tetap . Yang diubah di sini hanya password-nya.
+
+ +
+
+
+
+ +
+
+
+
+

Logo & ikon website

+

Atur logo yang muncul di navbar/footer dan favicon yang tampil di tab browser.

+
+ Branding +
+
+ +
+ + +
+
+
+ + +
+
+
+
+
Preview logo
+ + Preview logo website + +
Belum ada logo custom. Saat ini website masih memakai inisial brand.
+ +
+
+
Preview favicon
+ + Preview favicon website + +
Belum ada favicon custom. Tab browser masih memakai icon default browser.
+ +
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ 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, . 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
+
+
semua item servis aktif
+
+
+
Terlambat
+
+
prioritas untuk dicek lebih dulu
+
+
+
Segera jatuh tempo
+
+
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 +
+
+
+
+
+
+ +
+
+
+
+
+
+
+

Tambah catatan servis

+

Isi satu item servis per reminder agar jadwalnya tetap akurat dan gampang dicek nanti.

+
+ Privat +
+
+ Motor & mobil + Hitung due otomatis +
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + +
+
+
+
+
+
+ + +
+
+
+ + +
+
+ +
Tips: tombol Default akan mengisi interval umum sesuai item servis yang dipilih.
+
+
+
+
+
+
+
+

Daftar reminder servis

+

Semua catatan di bawah ini hanya milik akun .

+
+
+ + + + +
+
+ +
+ + +
+
+
+
+

+
+
+ +
+
+
+
Terakhir servis
+
+
+
+
Next due
+
+
+
+
Interval
+
hari
+
+
+
+
+ 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($projectName) ?> logo + + + + + + + + + + + <?= e($title) ?> · <?= e($projectName) ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ '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.

+
+ + Lihat fitur +
+
+ 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
+

+

+
+ +
+ +
+
+
Total catatan
+
+
item servis aktif di akun ini
+
+
+
Perlu tindakan
+
+
reminder yang sudah lewat
+
+
+
Segera due
+
+
perlu dicek dalam waktu dekat
+
+
+ +
+
+ + +
+ + + + +
+
+
+
+ +
+ + +
Belum ada catatan di akun kamu. Tambahkan servis pertama untuk mulai melihat reminder pribadi.
+ +
+
Privasi default
+
+
User A melihat dashboard miliknya sendiri.
+
User B tidak bisa melihat catatan User A.
+
Semua akun hanya melihat reminder miliknya sendiri.
+
+
+ +
+
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — 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. +
+
+
+
+
+
+
+
+
Welcome back
+

Login akun

+

Masuk untuk lanjut ke dashboard servis pribadi.

+
+ Privat +
+ + + +
+ +
+ + +
+
+
+ + +
+
+ +
+
Belum punya akun? Daftar di sini.
+
+
+
+
+
+ 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. +
+
+
+
+
+
+
+
+
Create account
+

Daftar akun baru

+

Bikin akun dulu supaya semua reminder tersimpan privat.

+
+ Secure +
+ + + +
+ +
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ +
+
Sudah punya akun? Login di sini.
+
+
+
+
+
+ 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 +

+

+
+ +
+ +
+
+
+
Jenis kendaraan
+
+
+
+
+
+
Servis terakhir
+
+
+
+
+
+
Servis berikutnya
+
+
+
+
+ +
+

Ringkasan reminder

+ +
+ +
+

Catatan servis

+

+
+
+
+
+
+

Aksi cepat

+ +
+
+

Privasi akun

+

Halaman detail ini hanya menampilkan catatan milik akun yang sedang login, jadi data user lain tidak ikut terlihat.

+
+
+
+ +
+
+