Autosave: 20260330-174750
This commit is contained in:
parent
f4a88aa5db
commit
3a0ffdff3d
289
admin-portal.php
Normal file
289
admin-portal.php
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
|
||||||
|
function admin_setting_url_looks_safe(string $value): bool
|
||||||
|
{
|
||||||
|
if ($value === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lower = strtolower($value);
|
||||||
|
return !str_contains($value, '<')
|
||||||
|
&& !str_contains($value, '>')
|
||||||
|
&& !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());
|
||||||
|
?>
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-xl-9 col-lg-10">
|
||||||
|
<?php if (!is_admin_logged_in()): ?>
|
||||||
|
<div class="surface-card admin-card mx-auto" style="max-width: 560px;">
|
||||||
|
<span class="eyebrow">Hidden admin</span>
|
||||||
|
<h1 class="section-title mt-2 mb-2">Login panel admin</h1>
|
||||||
|
<p class="text-secondary">Panel ini tetap tersembunyi via slug khusus. Setelah login, admin bisa mengelola script iklan global, mengganti sandi admin, dan mengatur logo/favikon website.</p>
|
||||||
|
<?php if ($loginError !== ''): ?>
|
||||||
|
<div class="alert alert-danger small" role="alert"><?= e($loginError) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="post" class="vstack gap-3">
|
||||||
|
<input type="hidden" name="action" value="login">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="form-label">Username</label>
|
||||||
|
<input type="text" class="form-control" id="username" name="username" value="<?= e(admin_username()) ?>" autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-dark">Masuk ke panel admin</button>
|
||||||
|
</form>
|
||||||
|
<?php if (!admin_has_custom_password()): ?>
|
||||||
|
<div class="small text-muted mt-3">Akses awal MVP ini: <strong><?= e(admin_username()) ?></strong> / <strong><?= e(admin_default_password_hint()) ?></strong>. Setelah masuk, segera ganti password admin di panel.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="small text-muted mt-3">Password admin sudah memakai versi custom yang tersimpan di database, jadi hint default tidak lagi dipakai.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="surface-card admin-card">
|
||||||
|
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Admin portal</span>
|
||||||
|
<h1 class="section-title mt-2 mb-2">Pengaturan global website</h1>
|
||||||
|
<p class="text-secondary mb-0">Semua pengaturan di halaman ini berlaku global untuk semua halaman: script iklan, password admin, dan branding website.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<span class="summary-chip"><?= $adsHead !== '' ? 'HEAD aktif' : 'HEAD kosong' ?></span>
|
||||||
|
<span class="summary-chip"><?= $adsBody !== '' ? 'BODY aktif' : 'BODY kosong' ?></span>
|
||||||
|
<span class="summary-chip"><?= admin_has_custom_password() ? 'Password custom aktif' : 'Masih password default' ?></span>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="<?= e($portalUrl) ?>?logout=1">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning small" role="alert">
|
||||||
|
Untuk shared hosting/cPanel, isi <strong>logo URL</strong> dan <strong>favicon URL</strong> dengan path publik seperti <code>assets/images/logo.png</code> atau URL penuh. Kalau app dipasang di subfolder, path relatif akan ikut menyesuaikan otomatis.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="surface-subsection admin-section-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="h5 mb-1">Script iklan global</h2>
|
||||||
|
<p class="small text-secondary mb-0">Tempel kode JavaScript/HTML iklan yang akan dirender di semua halaman.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-soft">Ads</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="vstack gap-4">
|
||||||
|
<input type="hidden" name="action" value="save_ads">
|
||||||
|
<div>
|
||||||
|
<label for="ads_head" class="form-label fw-semibold">Slot HEAD</label>
|
||||||
|
<textarea class="form-control font-monospace" id="ads_head" name="ads_head" rows="7" placeholder="Contoh: <script>console.log('ads head')</script>"><?= e($adsHead) ?></textarea>
|
||||||
|
<div class="small text-muted mt-2">Cocok untuk loader iklan, verifikasi, atau script yang memang harus masuk ke <code><head></code>.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="ads_body" class="form-label fw-semibold">Slot BODY</label>
|
||||||
|
<textarea class="form-control font-monospace" id="ads_body" name="ads_body" rows="7" placeholder="Contoh: <script>console.log('ads body')</script>"><?= e($adsBody) ?></textarea>
|
||||||
|
<div class="small text-muted mt-2">Ideal untuk tag yang harus muncul setelah <code><body></code> terbuka.</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-dark">Simpan script iklan</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="surface-subsection admin-section-card h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="h5 mb-1">Keamanan admin</h2>
|
||||||
|
<p class="small text-secondary mb-0">Ganti password admin agar akses slug tersembunyi ini lebih aman.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-soft">Security</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="vstack gap-3" novalidate>
|
||||||
|
<input type="hidden" name="action" value="change_password">
|
||||||
|
<div>
|
||||||
|
<label for="current_password" class="form-label">Password saat ini</label>
|
||||||
|
<input type="password" class="form-control <?= isset($passwordErrors['current_password']) ? 'is-invalid' : '' ?>" id="current_password" name="current_password" autocomplete="current-password">
|
||||||
|
<?php if (isset($passwordErrors['current_password'])): ?><div class="invalid-feedback"><?= e($passwordErrors['current_password']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="new_password" class="form-label">Password baru</label>
|
||||||
|
<input type="password" class="form-control <?= isset($passwordErrors['new_password']) ? 'is-invalid' : '' ?>" id="new_password" name="new_password" autocomplete="new-password">
|
||||||
|
<?php if (isset($passwordErrors['new_password'])): ?><div class="invalid-feedback"><?= e($passwordErrors['new_password']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="confirm_password" class="form-label">Konfirmasi password baru</label>
|
||||||
|
<input type="password" class="form-control <?= isset($passwordErrors['confirm_password']) ? 'is-invalid' : '' ?>" id="confirm_password" name="confirm_password" autocomplete="new-password">
|
||||||
|
<?php if (isset($passwordErrors['confirm_password'])): ?><div class="invalid-feedback"><?= e($passwordErrors['confirm_password']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">Username admin tetap <strong><?= e(admin_username()) ?></strong>. Yang diubah di sini hanya password-nya.</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-dark">Ubah password admin</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="surface-subsection admin-section-card h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="h5 mb-1">Logo & ikon website</h2>
|
||||||
|
<p class="small text-secondary mb-0">Atur logo yang muncul di navbar/footer dan favicon yang tampil di tab browser.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-soft">Branding</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="vstack gap-3" novalidate>
|
||||||
|
<input type="hidden" name="action" value="save_branding">
|
||||||
|
<div>
|
||||||
|
<label for="brand_logo_url" class="form-label">Logo URL / path publik</label>
|
||||||
|
<input type="text" class="form-control <?= isset($brandingErrors['brand_logo_url']) ? 'is-invalid' : '' ?>" id="brand_logo_url" name="brand_logo_url" value="<?= e($brandingForm['brand_logo_url']) ?>" placeholder="Contoh: assets/images/logo.png atau https://.../logo.svg">
|
||||||
|
<?php if (isset($brandingErrors['brand_logo_url'])): ?><div class="invalid-feedback"><?= e($brandingErrors['brand_logo_url']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="brand_favicon_url" class="form-label">Favicon URL / path publik</label>
|
||||||
|
<input type="text" class="form-control <?= isset($brandingErrors['brand_favicon_url']) ? 'is-invalid' : '' ?>" id="brand_favicon_url" name="brand_favicon_url" value="<?= e($brandingForm['brand_favicon_url']) ?>" placeholder="Contoh: assets/images/favicon.ico atau https://.../favicon.png">
|
||||||
|
<?php if (isset($brandingErrors['brand_favicon_url'])): ?><div class="invalid-feedback"><?= e($brandingErrors['brand_favicon_url']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="admin-branding-preview">
|
||||||
|
<div>
|
||||||
|
<div class="small text-uppercase text-muted mb-2">Preview logo</div>
|
||||||
|
<?php if ($logoPreviewUrl !== ''): ?>
|
||||||
|
<img class="admin-brand-preview" src="<?= e($logoPreviewUrl) ?>" alt="Preview logo website">
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="small text-secondary">Belum ada logo custom. Saat ini website masih memakai inisial brand.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="small text-uppercase text-muted mb-2">Preview favicon</div>
|
||||||
|
<?php if ($faviconPreviewUrl !== ''): ?>
|
||||||
|
<img class="admin-favicon-preview" src="<?= e($faviconPreviewUrl) ?>" alt="Preview favicon website">
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="small text-secondary">Belum ada favicon custom. Tab browser masih memakai icon default browser.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-dark">Simpan branding</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php render_page_end(); ?>
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,39 +1,105 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const chatForm = document.getElementById('chat-form');
|
const toastElements = document.querySelectorAll('.toast');
|
||||||
const chatInput = document.getElementById('chat-input');
|
toastElements.forEach((toastElement) => {
|
||||||
const chatMessages = document.getElementById('chat-messages');
|
if (window.bootstrap && bootstrap.Toast) {
|
||||||
|
const toast = new bootstrap.Toast(toastElement);
|
||||||
const appendMessage = (text, sender) => {
|
toast.show();
|
||||||
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 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
237
dashboard.php
Normal file
237
dashboard.php
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
require_user_login();
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
$formData = [
|
||||||
|
'vehicle_name' => '',
|
||||||
|
'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());
|
||||||
|
?>
|
||||||
|
<section class="py-4 py-lg-5 border-bottom dashboard-head dashboard-hero">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-4 align-items-start">
|
||||||
|
<div class="col-xl-7">
|
||||||
|
<span class="eyebrow">Dashboard pribadi</span>
|
||||||
|
<h1 class="section-title mt-2 mb-3">Halo, <?= e($userName) ?>. Semua catatan servis di sini hanya milik akun kamu.</h1>
|
||||||
|
<p class="text-secondary mb-4">Kamu bisa menambah reminder baru, memantau item yang telat, lalu membuka detail tanpa khawatir data user lain ikut terlihat.</p>
|
||||||
|
<div class="dashboard-summary-grid dashboard-summary-grid-wide">
|
||||||
|
<div class="dashboard-stat-card dashboard-stat-primary">
|
||||||
|
<div class="metric-label">Total catatan</div>
|
||||||
|
<div class="metric-value"><?= (int) $summary['total_services'] ?></div>
|
||||||
|
<div class="small text-muted">semua item servis aktif</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-stat-card dashboard-stat-alert">
|
||||||
|
<div class="metric-label">Terlambat</div>
|
||||||
|
<div class="metric-value"><?= (int) $summary['overdue_count'] ?></div>
|
||||||
|
<div class="small text-muted">prioritas untuk dicek lebih dulu</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-stat-card">
|
||||||
|
<div class="metric-label">Segera jatuh tempo</div>
|
||||||
|
<div class="metric-value"><?= (int) $summary['due_soon_count'] ?></div>
|
||||||
|
<div class="small text-muted">siapkan jadwal servis berikutnya</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-5">
|
||||||
|
<div class="surface-card dashboard-note-card h-100">
|
||||||
|
<div class="small text-uppercase text-muted mb-2">Cara paling gampang pakai dashboard</div>
|
||||||
|
<h2 class="h5 mb-3">Masukkan satu reminder per item servis.</h2>
|
||||||
|
<div class="d-flex flex-column gap-2 small text-secondary">
|
||||||
|
<div class="dashboard-note-item">Isi nama kendaraan dan tanggal servis terakhir.</div>
|
||||||
|
<div class="dashboard-note-item">Pilih item servis, misalnya oli mesin atau CVT.</div>
|
||||||
|
<div class="dashboard-note-item">Atur interval hari agar dashboard menghitung due date berikutnya.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 d-flex flex-wrap gap-2">
|
||||||
|
<span class="summary-chip">Privat per akun</span>
|
||||||
|
<span class="summary-chip">Cocok untuk pemula</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="py-4 py-lg-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-4 align-items-start">
|
||||||
|
<div class="col-xl-4">
|
||||||
|
<div class="surface-card sticky-card dashboard-form-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="h4 mb-1">Tambah catatan servis</h2>
|
||||||
|
<p class="small text-secondary mb-0">Isi satu item servis per reminder agar jadwalnya tetap akurat dan gampang dicek nanti.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-soft">Privat</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-helper-row mb-3">
|
||||||
|
<span class="summary-chip">Motor & mobil</span>
|
||||||
|
<span class="summary-chip">Hitung due otomatis</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="vstack gap-3" novalidate>
|
||||||
|
<div>
|
||||||
|
<label for="vehicle_name" class="form-label">Nama kendaraan</label>
|
||||||
|
<input type="text" class="form-control <?= isset($errors['vehicle_name']) ? 'is-invalid' : '' ?>" id="vehicle_name" name="vehicle_name" value="<?= e((string) $formData['vehicle_name']) ?>" placeholder="Contoh: Vario 125 / Avanza Harian">
|
||||||
|
<?php if (isset($errors['vehicle_name'])): ?><div class="invalid-feedback"><?= e($errors['vehicle_name']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label for="vehicle_category" class="form-label">Jenis</label>
|
||||||
|
<select class="form-select <?= isset($errors['vehicle_category']) ? 'is-invalid' : '' ?>" id="vehicle_category" name="vehicle_category">
|
||||||
|
<option value="Motor" <?= ($formData['vehicle_category'] ?? '') === 'Motor' ? 'selected' : '' ?>>Motor</option>
|
||||||
|
<option value="Mobil" <?= ($formData['vehicle_category'] ?? '') === 'Mobil' ? 'selected' : '' ?>>Mobil</option>
|
||||||
|
</select>
|
||||||
|
<?php if (isset($errors['vehicle_category'])): ?><div class="invalid-feedback"><?= e($errors['vehicle_category']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label for="plate_number" class="form-label">Nomor polisi</label>
|
||||||
|
<input type="text" class="form-control" id="plate_number" name="plate_number" value="<?= e((string) $formData['plate_number']) ?>" placeholder="B 1234 CD">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="service_name" class="form-label">Item servis</label>
|
||||||
|
<select class="form-select <?= isset($errors['service_name']) ? 'is-invalid' : '' ?>" id="service_name" name="service_name" data-service-select>
|
||||||
|
<?php foreach ($catalog as $serviceName => $defaultDays): ?>
|
||||||
|
<option value="<?= e($serviceName) ?>" data-default-days="<?= (int) $defaultDays ?>" <?= ($formData['service_name'] ?? '') === $serviceName ? 'selected' : '' ?>><?= e($serviceName) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<?php if (isset($errors['service_name'])): ?><div class="invalid-feedback"><?= e($errors['service_name']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label for="last_service_date" class="form-label">Servis terakhir</label>
|
||||||
|
<input type="date" class="form-control <?= isset($errors['last_service_date']) ? 'is-invalid' : '' ?>" id="last_service_date" name="last_service_date" value="<?= e((string) $formData['last_service_date']) ?>">
|
||||||
|
<?php if (isset($errors['last_service_date'])): ?><div class="invalid-feedback"><?= e($errors['last_service_date']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label for="reminder_interval_days" class="form-label">Interval (hari)</label>
|
||||||
|
<div class="input-group has-validation">
|
||||||
|
<input type="number" class="form-control <?= isset($errors['reminder_interval_days']) ? 'is-invalid' : '' ?>" id="reminder_interval_days" name="reminder_interval_days" min="1" max="730" value="<?= e((string) $formData['reminder_interval_days']) ?>" data-interval-input>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" data-apply-default>Default</button>
|
||||||
|
</div>
|
||||||
|
<?php if (isset($errors['reminder_interval_days'])): ?><div class="invalid-feedback d-block"><?= e($errors['reminder_interval_days']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label for="odometer_km" class="form-label">Kilometer</label>
|
||||||
|
<input type="number" class="form-control <?= isset($errors['odometer_km']) ? 'is-invalid' : '' ?>" id="odometer_km" name="odometer_km" min="0" value="<?= e((string) $formData['odometer_km']) ?>" placeholder="Contoh: 18250">
|
||||||
|
<?php if (isset($errors['odometer_km'])): ?><div class="invalid-feedback"><?= e($errors['odometer_km']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label for="notes" class="form-label">Catatan</label>
|
||||||
|
<input type="text" class="form-control" id="notes" name="notes" value="<?= e((string) $formData['notes']) ?>" placeholder="Contoh: Pakai oli 10W-40">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-dark w-100">Simpan dan aktifkan reminder</button>
|
||||||
|
<div class="small text-muted">Tips: tombol <strong>Default</strong> akan mengisi interval umum sesuai item servis yang dipilih.</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-8">
|
||||||
|
<div class="surface-card mb-4 dashboard-list-panel">
|
||||||
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="h4 mb-1">Daftar reminder servis</h2>
|
||||||
|
<p class="small text-secondary mb-0">Semua catatan di bawah ini hanya milik akun <?= e($userName) ?>.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2" role="tablist" aria-label="Filter status reminder">
|
||||||
|
<button type="button" class="btn btn-sm btn-dark filter-button is-active" data-filter="all">Semua</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary filter-button" data-filter="status-overdue">Terlambat</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary filter-button" data-filter="status-soon">Segera</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary filter-button" data-filter="status-ok">Aman</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php if ($services): ?>
|
||||||
|
<div class="list-grid" data-reminder-list>
|
||||||
|
<?php foreach ($services as $service): ?>
|
||||||
|
<?php $state = due_state($service['next_due_date'] ?? null); ?>
|
||||||
|
<article class="list-card <?= e($state['class']) ?> <?= $createdId === (int) $service['id'] ? 'list-card-highlight' : '' ?>" data-status="<?= e($state['class']) ?>">
|
||||||
|
<div class="list-card-top d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<div class="small text-muted mb-1"><?= e($service['vehicle_category']) ?><?= !empty($service['plate_number']) ? ' · ' . e($service['plate_number']) : '' ?></div>
|
||||||
|
<h3 class="h5 mb-1"><?= e($service['vehicle_name']) ?></h3>
|
||||||
|
<div class="text-secondary small"><?= e($service['service_name']) ?></div>
|
||||||
|
</div>
|
||||||
|
<span class="status-pill <?= e($state['class']) ?>"><?= e($state['label']) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="list-meta-grid mb-3">
|
||||||
|
<div class="detail-box">
|
||||||
|
<div class="detail-label">Terakhir servis</div>
|
||||||
|
<div class="detail-number"><?= e(date('d M Y', strtotime((string) $service['last_service_date']))) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-box">
|
||||||
|
<div class="detail-label">Next due</div>
|
||||||
|
<div class="detail-number"><?= e(date('d M Y', strtotime((string) $service['next_due_date']))) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-box">
|
||||||
|
<div class="detail-label">Interval</div>
|
||||||
|
<div class="detail-number"><?= (int) $service['reminder_interval_days'] ?> hari</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
|
<div class="small text-muted"><?= $service['odometer_km'] ? number_format((int) $service['odometer_km']) . ' km' : 'Kilometer belum diisi' ?></div>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="<?= e(app_url('service.php')) ?>?id=<?= (int) $service['id'] ?>">Buka detail</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="empty-state text-center py-5 px-3">
|
||||||
|
<h3 class="h5 mb-2">Belum ada reminder di akun ini</h3>
|
||||||
|
<p class="text-secondary mb-0">Mulai dari satu catatan sederhana — misalnya ganti oli mesin — lalu dashboard akan langsung menghitung servis berikutnya untuk akun kamu sendiri.</p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="surface-card muted-panel admin-info-card">
|
||||||
|
<div class="row g-3 align-items-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h2 class="h6 mb-1">Data kamu tetap privat</h2>
|
||||||
|
<p class="small text-secondary mb-0">Dashboard ini hanya menampilkan reminder milik akun yang sedang login, jadi catatan servis pengguna lain tidak ikut terlihat di sini.</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-md-end">
|
||||||
|
<span class="small text-muted">Aman dipakai untuk banyak akun.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php render_page_end(); ?>
|
||||||
12
healthz.php
Normal file
12
healthz.php
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/includes/bootstrap.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'ok',
|
||||||
|
'app' => 'ServisIngat',
|
||||||
|
'time_utc' => gmdate('c'),
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
714
includes/bootstrap.php
Normal file
714
includes/bootstrap.php
Normal file
@ -0,0 +1,714 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../db/config.php';
|
||||||
|
|
||||||
|
date_default_timezone_set('UTC');
|
||||||
|
|
||||||
|
function app_env(string $key, string $default = ''): string
|
||||||
|
{
|
||||||
|
$serverValue = $_SERVER[$key] ?? null;
|
||||||
|
if (is_string($serverValue) && $serverValue !== '') {
|
||||||
|
return $serverValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$envValue = getenv($key);
|
||||||
|
return is_string($envValue) && $envValue !== '' ? $envValue : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_base_path(): string
|
||||||
|
{
|
||||||
|
static $basePath = null;
|
||||||
|
if ($basePath !== null) {
|
||||||
|
return $basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scriptName = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? ''));
|
||||||
|
$dir = str_replace('\\', '/', dirname($scriptName));
|
||||||
|
if ($dir === '/' || $dir === '.' || $dir === '\\') {
|
||||||
|
$basePath = '';
|
||||||
|
return $basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
$basePath = rtrim($dir, '/');
|
||||||
|
return $basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_url(string $path = ''): string
|
||||||
|
{
|
||||||
|
$path = trim($path);
|
||||||
|
if ($path !== '' && (preg_match('#^[a-z][a-z0-9+.-]*://#i', $path) === 1 || str_starts_with($path, '//'))) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$basePath = app_base_path();
|
||||||
|
if ($path === '' || $path === '/') {
|
||||||
|
return $basePath !== '' ? $basePath . '/' : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($basePath !== '' ? $basePath : '') . '/' . ltrim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function asset_url(string $path): string
|
||||||
|
{
|
||||||
|
return app_url($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function public_asset_url(string $path): string
|
||||||
|
{
|
||||||
|
$path = trim($path);
|
||||||
|
if ($path === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $path) === 1 || str_starts_with($path, '//') || str_starts_with($path, 'data:')) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app_url(ltrim($path, '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function e(?string $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function schema_column_exists(string $table, string $column): bool
|
||||||
|
{
|
||||||
|
$stmt = db()->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());
|
||||||
|
}
|
||||||
162
includes/layout.php
Normal file
162
includes/layout.php
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
|
function render_brand_mark(string $projectName, string $extraClass = ''): void
|
||||||
|
{
|
||||||
|
$logoUrl = public_asset_url(app_logo_url());
|
||||||
|
$className = trim('brand-mark ' . $extraClass . ($logoUrl !== '' ? ' brand-mark-image' : ''));
|
||||||
|
?>
|
||||||
|
<span class="<?= e($className) ?>">
|
||||||
|
<?php if ($logoUrl !== ''): ?>
|
||||||
|
<img src="<?= e($logoUrl) ?>" alt="<?= e($projectName) ?> logo">
|
||||||
|
<?php else: ?>
|
||||||
|
<?= e(app_brand_initial()) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_page_start(array $options = []): void
|
||||||
|
{
|
||||||
|
$title = $options['title'] ?? 'ServisIngat';
|
||||||
|
$description = $options['description'] ?? app_env('PROJECT_DESCRIPTION', 'Dashboard pengingat servis kendaraan agar tidak telat ganti oli, filter, CVT, gardan, busi, dan servis rutin lainnya.');
|
||||||
|
$page = $options['page'] ?? '';
|
||||||
|
$robots = $options['robots'] ?? 'index, follow';
|
||||||
|
$projectName = app_project_name();
|
||||||
|
$projectDescription = app_env('PROJECT_DESCRIPTION', $description);
|
||||||
|
$projectImageUrl = app_env('PROJECT_IMAGE_URL', '');
|
||||||
|
$isLoggedIn = is_user_logged_in();
|
||||||
|
$userName = current_user_name();
|
||||||
|
$bodyClass = trim('app-body page-' . ($page !== '' ? $page : 'default') . ' ' . (string) ($options['body_class'] ?? ''));
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= e($title) ?> · <?= e($projectName) ?></title>
|
||||||
|
<meta name="description" content="<?= e($description) ?>">
|
||||||
|
<meta name="robots" content="<?= e($robots) ?>">
|
||||||
|
<meta property="og:title" content="<?= e($title . ' · ' . $projectName) ?>">
|
||||||
|
<meta property="og:description" content="<?= e($projectDescription) ?>">
|
||||||
|
<meta property="twitter:title" content="<?= e($title . ' · ' . $projectName) ?>">
|
||||||
|
<meta property="twitter:description" content="<?= e($projectDescription) ?>">
|
||||||
|
<?php if ($projectImageUrl): ?>
|
||||||
|
<meta property="og:image" content="<?= e($projectImageUrl) ?>">
|
||||||
|
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="<?= e(asset_url('assets/css/custom.css')) ?>?v=<?= urlencode((string) filemtime(__DIR__ . '/../assets/css/custom.css')) ?>">
|
||||||
|
<?php $faviconUrl = public_asset_url(app_favicon_url()); ?>
|
||||||
|
<?php if ($faviconUrl !== ''): ?>
|
||||||
|
<link rel="icon" type="image/x-icon" href="<?= e($faviconUrl) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php $headCode = head_ad_code(); ?>
|
||||||
|
<?php if ($headCode !== ''): ?>
|
||||||
|
<?= $headCode ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</head>
|
||||||
|
<body class="<?= e($bodyClass) ?>">
|
||||||
|
<?php $bodyCode = body_ad_code(); ?>
|
||||||
|
<?php if ($bodyCode !== ''): ?>
|
||||||
|
<?= $bodyCode ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<header class="site-header border-bottom sticky-top">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light">
|
||||||
|
<div class="container py-2">
|
||||||
|
<div class="nav-shell w-100 d-flex align-items-center justify-content-between gap-3">
|
||||||
|
<a class="navbar-brand d-flex align-items-center gap-3 fw-semibold text-dark m-0" href="<?= e(app_url()) ?>" aria-label="<?= e($projectName) ?> beranda">
|
||||||
|
<?php render_brand_mark($projectName); ?>
|
||||||
|
<span>
|
||||||
|
<span class="brand-title"><?= e($projectName) ?></span>
|
||||||
|
<span class="brand-subtitle">maintenance tracker</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Buka navigasi">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="mainNav">
|
||||||
|
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||||||
|
<li class="nav-item"><a class="nav-link <?= $page === 'home' ? 'active' : '' ?>" href="<?= e(app_url()) ?>">Beranda</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link <?= in_array($page, ['dashboard', 'detail'], true) ? 'active' : '' ?>" href="<?= e(app_url('dashboard.php')) ?>">Dashboard</a></li>
|
||||||
|
<?php if ($isLoggedIn): ?>
|
||||||
|
<li class="nav-item"><span class="nav-user-badge"><?= e($userName) ?></span></li>
|
||||||
|
<li class="nav-item ms-lg-2"><a class="btn btn-dark btn-sm px-3" href="<?= e(app_url('logout.php')) ?>">Logout</a></li>
|
||||||
|
<?php else: ?>
|
||||||
|
<li class="nav-item"><a class="nav-link <?= $page === 'login' ? 'active' : '' ?>" href="<?= e(app_url('login.php')) ?>">Login</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link <?= $page === 'register' ? 'active' : '' ?>" href="<?= e(app_url('register.php')) ?>">Daftar</a></li>
|
||||||
|
<li class="nav-item ms-lg-2"><a class="btn btn-dark btn-sm px-3" href="<?= e(app_url('register.php')) ?>">Mulai Catat</a></li>
|
||||||
|
<?php endif; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_flash(?array $flash): void
|
||||||
|
{
|
||||||
|
if (!$flash || empty($flash['message'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = (string) ($flash['type'] ?? 'info');
|
||||||
|
$message = (string) $flash['message'];
|
||||||
|
$color = match ($type) {
|
||||||
|
'success' => 'success',
|
||||||
|
'warning' => 'warning',
|
||||||
|
'danger', 'error' => 'danger',
|
||||||
|
default => 'secondary',
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||||
|
<div class="toast align-items-center text-bg-<?= e($color) ?> border-0 show app-toast" role="status" aria-live="polite" aria-atomic="true" data-bs-delay="5000">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body"><?= e($message) ?></div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Tutup"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_page_end(): void
|
||||||
|
{
|
||||||
|
$projectName = app_project_name();
|
||||||
|
?>
|
||||||
|
</main>
|
||||||
|
<footer class="site-footer border-top mt-5">
|
||||||
|
<div class="container py-4 py-lg-5">
|
||||||
|
<div class="footer-shell d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-lg-center">
|
||||||
|
<div>
|
||||||
|
<div class="footer-brand d-flex align-items-center gap-3 mb-2">
|
||||||
|
<?php render_brand_mark($projectName, 'brand-mark-sm'); ?>
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold text-dark"><?= e($projectName) ?></div>
|
||||||
|
<div class="small text-muted">MVP pengingat servis kendaraan multi-user untuk motor, mobil, dan bengkel kecil.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-chips d-flex flex-wrap gap-2">
|
||||||
|
<span class="summary-chip">Dashboard privat</span>
|
||||||
|
<span class="summary-chip">Reminder terstruktur</span>
|
||||||
|
<span class="summary-chip">Multi-user aman</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted text-lg-end">
|
||||||
|
Data servis tiap akun dipisahkan agar tidak saling terlihat.<br>
|
||||||
|
<a class="text-decoration-none" href="<?= e(app_url('healthz.php')) ?>">Health check</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||||
|
<script src="<?= e(asset_url('assets/js/main.js')) ?>?v=<?= urlencode((string) filemtime(__DIR__ . '/../assets/js/main.js')) ?>"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
356
index.php
356
index.php
@ -1,150 +1,218 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@ini_set('display_errors', '1');
|
|
||||||
@error_reporting(E_ALL);
|
|
||||||
@date_default_timezone_set('UTC');
|
|
||||||
|
|
||||||
$phpVersion = PHP_VERSION;
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
$now = date('Y-m-d H:i:s');
|
|
||||||
|
$isLoggedIn = is_user_logged_in();
|
||||||
|
$summary = $isLoggedIn ? dashboard_summary_for_user(current_user_id()) : empty_dashboard_summary();
|
||||||
|
$recentServices = $isLoggedIn ? fetch_recent_services_for_user(current_user_id(), 4) : [];
|
||||||
|
$primaryCtaUrl = $isLoggedIn ? app_url('dashboard.php') : app_url('register.php');
|
||||||
|
$primaryCtaLabel = $isLoggedIn ? 'Buka dashboard saya' : 'Buat akun gratis';
|
||||||
|
|
||||||
|
render_page_start([
|
||||||
|
'title' => '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());
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<section class="hero-section hero-shell py-5 py-lg-6 border-bottom overflow-hidden">
|
||||||
<html lang="en">
|
<div class="container py-lg-4 position-relative">
|
||||||
<head>
|
<div class="hero-blur hero-blur-one"></div>
|
||||||
<meta charset="utf-8" />
|
<div class="hero-blur hero-blur-two"></div>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<div class="row align-items-center g-4 g-lg-5 position-relative">
|
||||||
<title>New Style</title>
|
<div class="col-lg-6">
|
||||||
<?php
|
<span class="eyebrow">Maintenance tracker kendaraan</span>
|
||||||
// Read project preview data from environment
|
<h1 class="display-title mt-3 mb-3">Ingat jadwal servis tanpa ribet, dengan dashboard yang terasa rapi dan privat.</h1>
|
||||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
<p class="lead text-secondary mb-4">ServisIngat membantu pemilik motor dan mobil mencatat servis terakhir, menghitung servis berikutnya, lalu menampilkan reminder yang jelas di dashboard pribadi masing-masing.</p>
|
||||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
<div class="d-flex flex-column flex-sm-row gap-2 mb-4">
|
||||||
?>
|
<a class="btn btn-dark btn-lg px-4" href="<?= $primaryCtaUrl ?>"><?= $primaryCtaLabel ?></a>
|
||||||
<?php if ($projectDescription): ?>
|
<a class="btn btn-outline-secondary btn-lg px-4" href="#fitur">Lihat fitur</a>
|
||||||
<!-- Meta description -->
|
</div>
|
||||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
<div class="hero-note d-flex flex-wrap gap-3 small text-muted mb-4">
|
||||||
<!-- Open Graph meta tags -->
|
<span>Multi-user dan privat per akun</span>
|
||||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
<span>Reminder langsung di dashboard</span>
|
||||||
<!-- Twitter meta tags -->
|
<span>Detail servis privat per akun</span>
|
||||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
</div>
|
||||||
<?php endif; ?>
|
<div class="hero-trust-grid row g-3">
|
||||||
<?php if ($projectImageUrl): ?>
|
<div class="col-sm-4">
|
||||||
<!-- Open Graph image -->
|
<div class="quick-stat-card h-100">
|
||||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
<div class="quick-stat-value">Privat</div>
|
||||||
<!-- Twitter image -->
|
<div class="quick-stat-label">setiap akun hanya melihat datanya sendiri</div>
|
||||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
</div>
|
||||||
<?php endif; ?>
|
</div>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<div class="col-sm-4">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<div class="quick-stat-card h-100">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
<div class="quick-stat-value">Ringkas</div>
|
||||||
<style>
|
<div class="quick-stat-label">buat catatan baru hanya beberapa field inti</div>
|
||||||
:root {
|
</div>
|
||||||
--bg-color-start: #6a11cb;
|
</div>
|
||||||
--bg-color-end: #2575fc;
|
<div class="col-sm-4">
|
||||||
--text-color: #ffffff;
|
<div class="quick-stat-card h-100">
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
<div class="quick-stat-value">Siap pakai</div>
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
<div class="quick-stat-label">cocok untuk pemilik kendaraan dan bengkel kecil</div>
|
||||||
}
|
</div>
|
||||||
body {
|
</div>
|
||||||
margin: 0;
|
</div>
|
||||||
font-family: 'Inter', sans-serif;
|
</div>
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
<div class="col-lg-6">
|
||||||
color: var(--text-color);
|
<div class="hero-preview-card surface-card p-4 p-lg-4">
|
||||||
display: flex;
|
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||||
justify-content: center;
|
<div>
|
||||||
align-items: center;
|
<div class="small text-uppercase text-muted mb-1">Ringkasan saat ini</div>
|
||||||
min-height: 100vh;
|
<h2 class="h4 mb-1"><?= $isLoggedIn ? 'Akun kamu siap dipakai' : 'Preview dashboard pribadi' ?></h2>
|
||||||
text-align: center;
|
<p class="text-secondary small mb-0"><?= $isLoggedIn ? 'Snapshot singkat dari reminder kamu.' : 'Begitu daftar, setiap user masuk ke ruang reminder masing-masing.' ?></p>
|
||||||
overflow: hidden;
|
</div>
|
||||||
position: relative;
|
<span class="badge badge-soft"><?= $isLoggedIn ? 'Privat' : 'Multi-user' ?></span>
|
||||||
}
|
</div>
|
||||||
body::before {
|
|
||||||
content: '';
|
<div class="dashboard-summary-grid mb-4">
|
||||||
position: absolute;
|
<div class="dashboard-stat-card dashboard-stat-primary">
|
||||||
top: 0;
|
<div class="metric-label">Total catatan</div>
|
||||||
left: 0;
|
<div class="metric-value"><?= (int) $summary['total_services'] ?></div>
|
||||||
width: 100%;
|
<div class="small text-muted">item servis aktif di akun ini</div>
|
||||||
height: 100%;
|
</div>
|
||||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
<div class="dashboard-stat-card">
|
||||||
animation: bg-pan 20s linear infinite;
|
<div class="metric-label">Perlu tindakan</div>
|
||||||
z-index: -1;
|
<div class="metric-value"><?= (int) $summary['overdue_count'] ?></div>
|
||||||
}
|
<div class="small text-muted">reminder yang sudah lewat</div>
|
||||||
@keyframes bg-pan {
|
</div>
|
||||||
0% { background-position: 0% 0%; }
|
<div class="dashboard-stat-card">
|
||||||
100% { background-position: 100% 100%; }
|
<div class="metric-label">Segera due</div>
|
||||||
}
|
<div class="metric-value"><?= (int) $summary['due_soon_count'] ?></div>
|
||||||
main {
|
<div class="small text-muted">perlu dicek dalam waktu dekat</div>
|
||||||
padding: 2rem;
|
</div>
|
||||||
}
|
</div>
|
||||||
.card {
|
|
||||||
background: var(--card-bg-color);
|
<div class="mini-list-preview">
|
||||||
border: 1px solid var(--card-border-color);
|
<div class="mini-list-header d-flex justify-content-between align-items-center mb-2">
|
||||||
border-radius: 16px;
|
<span class="fw-semibold"><?= $isLoggedIn ? 'Reminder terbaru kamu' : 'Kenapa aman dipakai ramai-ramai?' ?></span>
|
||||||
padding: 2rem;
|
<a class="small text-decoration-none" href="<?= e($isLoggedIn ? app_url('dashboard.php') : app_url('login.php')) ?>"><?= $isLoggedIn ? 'Buka dashboard' : 'Login' ?></a>
|
||||||
backdrop-filter: blur(20px);
|
</div>
|
||||||
-webkit-backdrop-filter: blur(20px);
|
<?php if ($isLoggedIn && $recentServices): ?>
|
||||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
<?php foreach ($recentServices as $service): ?>
|
||||||
}
|
<?php $state = due_state($service['next_due_date'] ?? null); ?>
|
||||||
.loader {
|
<a href="<?= e(app_url('service.php')) ?>?id=<?= (int) $service['id'] ?>" class="preview-row text-decoration-none">
|
||||||
margin: 1.25rem auto 1.25rem;
|
<div>
|
||||||
width: 48px;
|
<div class="fw-semibold text-dark"><?= e($service['vehicle_name']) ?></div>
|
||||||
height: 48px;
|
<div class="small text-muted"><?= e($service['service_name']) ?></div>
|
||||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
</div>
|
||||||
border-top-color: #fff;
|
<span class="status-pill <?= e($state['class']) ?>"><?= e($state['label']) ?></span>
|
||||||
border-radius: 50%;
|
</a>
|
||||||
animation: spin 1s linear infinite;
|
<?php endforeach; ?>
|
||||||
}
|
<?php elseif ($isLoggedIn): ?>
|
||||||
@keyframes spin {
|
<div class="empty-inline">Belum ada catatan di akun kamu. Tambahkan servis pertama untuk mulai melihat reminder pribadi.</div>
|
||||||
from { transform: rotate(0deg); }
|
<?php else: ?>
|
||||||
to { transform: rotate(360deg); }
|
<div class="preview-note-card">
|
||||||
}
|
<div class="small text-uppercase text-muted mb-2">Privasi default</div>
|
||||||
.hint {
|
<div class="d-flex flex-column gap-2">
|
||||||
opacity: 0.9;
|
<div class="preview-note-item"><strong>User A</strong> melihat dashboard miliknya sendiri.</div>
|
||||||
}
|
<div class="preview-note-item"><strong>User B</strong> tidak bisa melihat catatan User A.</div>
|
||||||
.sr-only {
|
<div class="preview-note-item"><strong>Semua akun</strong> hanya melihat reminder miliknya sendiri.</div>
|
||||||
position: absolute;
|
</div>
|
||||||
width: 1px; height: 1px;
|
</div>
|
||||||
padding: 0; margin: -1px;
|
<?php endif; ?>
|
||||||
overflow: hidden;
|
</div>
|
||||||
clip: rect(0, 0, 0, 0);
|
</div>
|
||||||
white-space: nowrap; border: 0;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 3rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
code {
|
|
||||||
background: rgba(0,0,0,0.2);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<div class="card">
|
|
||||||
<h1>Analyzing your requirements and generating your website…</h1>
|
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
|
||||||
<span class="sr-only">Loading…</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
|
||||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
|
||||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
<footer>
|
</section>
|
||||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
|
||||||
</footer>
|
<section id="fitur" class="py-5">
|
||||||
</body>
|
<div class="container">
|
||||||
</html>
|
<div class="section-intro mb-4 mb-lg-5 text-center text-lg-start">
|
||||||
|
<span class="eyebrow justify-content-center justify-content-lg-start">Fokus MVP</span>
|
||||||
|
<h2 class="section-title mt-2">Alur sederhana yang langsung berguna</h2>
|
||||||
|
<p class="text-secondary mb-0 section-copy">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.</p>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 g-lg-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<article class="feature-card feature-card-accent h-100">
|
||||||
|
<div class="feature-icon-pill">01</div>
|
||||||
|
<h3 class="h5 mt-3">Akun privat</h3>
|
||||||
|
<p class="text-secondary mb-0">Setiap pengguna mendaftar dan login ke akun sendiri, sehingga catatan servis tersimpan terpisah dan lebih aman dipakai ramai-ramai.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<article class="feature-card feature-card-accent h-100">
|
||||||
|
<div class="feature-icon-pill">02</div>
|
||||||
|
<h3 class="h5 mt-3">Reminder dashboard</h3>
|
||||||
|
<p class="text-secondary mb-0">Catatan otomatis diklasifikasikan menjadi terlambat, segera jatuh tempo, atau masih aman agar pengguna tahu mana yang perlu action.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<article class="feature-card feature-card-accent h-100">
|
||||||
|
<div class="feature-icon-pill">03</div>
|
||||||
|
<h3 class="h5 mt-3">Detail servis privat</h3>
|
||||||
|
<p class="text-secondary mb-0">Setiap catatan punya halaman detail sendiri agar riwayat servis lebih rapi, mudah dicek, dan tetap terpisah per akun.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="py-5 border-top border-bottom section-soft">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-4 align-items-center">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<span class="eyebrow">Cara kerja</span>
|
||||||
|
<h2 class="section-title mt-2">Buat pemilik kendaraan maupun bengkel kecil</h2>
|
||||||
|
<p class="text-secondary mb-4">Dashboard ini sengaja dibuat ringkas: setiap item servis berdiri sebagai satu reminder yang mudah dicek kapan saja, dengan privasi akun tetap terjaga.</p>
|
||||||
|
<div class="surface-subsection d-inline-flex flex-column gap-2">
|
||||||
|
<div class="fw-semibold text-dark">Contoh item servis yang umum</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<span class="summary-chip">Oli mesin</span>
|
||||||
|
<span class="summary-chip">Filter udara</span>
|
||||||
|
<span class="summary-chip">CVT</span>
|
||||||
|
<span class="summary-chip">Busi</span>
|
||||||
|
<span class="summary-chip">Gardan</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="workflow-panel surface-card">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="process-card h-100">
|
||||||
|
<div class="process-step">01</div>
|
||||||
|
<h3 class="h6">Daftar akun</h3>
|
||||||
|
<p class="small text-secondary mb-0">User membuat akun sendiri dulu supaya data bisa dipisahkan dengan aman.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="process-card h-100">
|
||||||
|
<div class="process-step">02</div>
|
||||||
|
<h3 class="h6">Catat servis terakhir</h3>
|
||||||
|
<p class="small text-secondary mb-0">Masukkan nama kendaraan, item servis, tanggal, dan interval reminder.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="process-card h-100">
|
||||||
|
<div class="process-step">03</div>
|
||||||
|
<h3 class="h6">Pantau reminder pribadi</h3>
|
||||||
|
<p class="small text-secondary mb-0">Setiap akun hanya melihat reminder miliknya sendiri di dashboard dan halaman detail.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="cta-strip cta-strong d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold text-dark h5 mb-2">Siap dipakai sekarang sebagai MVP reminder servis multi-user.</div>
|
||||||
|
<div class="text-secondary">Versi ini sudah punya register, login, dashboard privat, detail privat, dan alur input reminder yang lebih rapi.</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column flex-sm-row gap-2">
|
||||||
|
<a class="btn btn-dark" href="<?= $primaryCtaUrl ?>"><?= $primaryCtaLabel ?></a>
|
||||||
|
<a class="btn btn-outline-secondary" href="<?= e(app_url('login.php')) ?>"><?= $isLoggedIn ? 'Ganti akun' : 'Saya sudah punya akun' ?></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php render_page_end(); ?>
|
||||||
|
|||||||
109
login.php
Normal file
109
login.php
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
|
||||||
|
if (is_user_logged_in()) {
|
||||||
|
header('Location: ' . auth_redirect_target(app_url('dashboard.php')));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
$formData = [
|
||||||
|
'email' => '',
|
||||||
|
];
|
||||||
|
$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());
|
||||||
|
?>
|
||||||
|
<section class="py-5 py-lg-6">
|
||||||
|
<div class="container">
|
||||||
|
<div class="auth-shell row g-4 align-items-stretch justify-content-center">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="surface-card auth-side h-100">
|
||||||
|
<span class="eyebrow">Login</span>
|
||||||
|
<h1 class="section-title mt-2 mb-3">Masuk ke dashboard privat kamu</h1>
|
||||||
|
<p class="text-secondary mb-4">Begitu login, kamu hanya melihat catatan servis yang dibuat oleh akun kamu sendiri.</p>
|
||||||
|
<div class="auth-feature-list d-flex flex-column gap-3">
|
||||||
|
<div class="auth-feature-item">
|
||||||
|
<strong>Privasi per akun</strong>
|
||||||
|
<span>Data user lain tidak tampil di dashboard kamu.</span>
|
||||||
|
</div>
|
||||||
|
<div class="auth-feature-item">
|
||||||
|
<strong>Reminder jelas</strong>
|
||||||
|
<span>Status terlambat, segera, dan aman tampil langsung setelah masuk.</span>
|
||||||
|
</div>
|
||||||
|
<div class="auth-feature-item">
|
||||||
|
<strong>Alur sederhana</strong>
|
||||||
|
<span>Cocok untuk pemilik kendaraan yang ingin cepat input tanpa setting rumit.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="surface-card auth-card auth-form-card h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="small text-uppercase text-muted mb-1">Welcome back</div>
|
||||||
|
<h2 class="h4 mb-1">Login akun</h2>
|
||||||
|
<p class="small text-secondary mb-0">Masuk untuk lanjut ke dashboard servis pribadi.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-soft">Privat</span>
|
||||||
|
</div>
|
||||||
|
<?php if (isset($errors['login'])): ?>
|
||||||
|
<div class="alert alert-danger" role="alert"><?= e($errors['login']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="post" class="vstack gap-3" novalidate>
|
||||||
|
<input type="hidden" name="redirect" value="<?= e($redirectTarget) ?>">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control <?= isset($errors['email']) ? 'is-invalid' : '' ?>" id="email" name="email" value="<?= e($formData['email']) ?>" placeholder="nama@email.com">
|
||||||
|
<?php if (isset($errors['email'])): ?><div class="invalid-feedback"><?= e($errors['email']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control <?= isset($errors['password']) ? 'is-invalid' : '' ?>" id="password" name="password" placeholder="Masukkan password">
|
||||||
|
<?php if (isset($errors['password'])): ?><div class="invalid-feedback"><?= e($errors['password']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-dark w-100">Login</button>
|
||||||
|
</form>
|
||||||
|
<div class="small text-muted mt-3">Belum punya akun? <a href="<?= e(app_url('register.php')) ?><?= $redirectTarget !== app_url('dashboard.php') ? '?redirect=' . urlencode($redirectTarget) : '' ?>">Daftar di sini</a>.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php render_page_end(); ?>
|
||||||
8
logout.php
Normal file
8
logout.php
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/includes/bootstrap.php';
|
||||||
|
logout_user();
|
||||||
|
set_flash('success', 'Kamu sudah logout dari akun ServisIngat.');
|
||||||
|
header('Location: ' . app_url());
|
||||||
|
exit;
|
||||||
134
register.php
Normal file
134
register.php
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
|
||||||
|
if (is_user_logged_in()) {
|
||||||
|
header('Location: ' . auth_redirect_target(app_url('dashboard.php')));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
$formData = [
|
||||||
|
'name' => '',
|
||||||
|
'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());
|
||||||
|
?>
|
||||||
|
<section class="py-5 py-lg-6">
|
||||||
|
<div class="container">
|
||||||
|
<div class="auth-shell row g-4 align-items-stretch justify-content-center">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="surface-card auth-side h-100">
|
||||||
|
<span class="eyebrow">Daftar</span>
|
||||||
|
<h1 class="section-title mt-2 mb-3">Buat akun ServisIngat</h1>
|
||||||
|
<p class="text-secondary mb-4">Satu akun = satu ruang dashboard sendiri. Jadi kalau user lain input data, catatan kamu tetap tidak terlihat oleh mereka.</p>
|
||||||
|
<div class="auth-feature-list d-flex flex-column gap-3">
|
||||||
|
<div class="auth-feature-item">
|
||||||
|
<strong>Langsung siap pakai</strong>
|
||||||
|
<span>Setelah daftar kamu bisa langsung masuk dan membuat reminder pertama.</span>
|
||||||
|
</div>
|
||||||
|
<div class="auth-feature-item">
|
||||||
|
<strong>Cocok untuk pemula</strong>
|
||||||
|
<span>Field yang diminta hanya yang penting: kendaraan, item servis, tanggal, dan interval.</span>
|
||||||
|
</div>
|
||||||
|
<div class="auth-feature-item">
|
||||||
|
<strong>Multi-user aman</strong>
|
||||||
|
<span>Arsitektur datanya sudah dipisah per akun supaya dashboard terasa personal.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="surface-card auth-card auth-form-card h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="small text-uppercase text-muted mb-1">Create account</div>
|
||||||
|
<h2 class="h4 mb-1">Daftar akun baru</h2>
|
||||||
|
<p class="small text-secondary mb-0">Bikin akun dulu supaya semua reminder tersimpan privat.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-soft">Secure</span>
|
||||||
|
</div>
|
||||||
|
<?php if (isset($errors['register'])): ?>
|
||||||
|
<div class="alert alert-danger" role="alert"><?= e($errors['register']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="post" class="vstack gap-3" novalidate>
|
||||||
|
<input type="hidden" name="redirect" value="<?= e($redirectTarget) ?>">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="form-label">Nama</label>
|
||||||
|
<input type="text" class="form-control <?= isset($errors['name']) ? 'is-invalid' : '' ?>" id="name" name="name" value="<?= e($formData['name']) ?>" placeholder="Nama kamu">
|
||||||
|
<?php if (isset($errors['name'])): ?><div class="invalid-feedback"><?= e($errors['name']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="email" class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control <?= isset($errors['email']) ? 'is-invalid' : '' ?>" id="email" name="email" value="<?= e($formData['email']) ?>" placeholder="nama@email.com">
|
||||||
|
<?php if (isset($errors['email'])): ?><div class="invalid-feedback"><?= e($errors['email']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control <?= isset($errors['password']) ? 'is-invalid' : '' ?>" id="password" name="password" placeholder="Minimal 6 karakter">
|
||||||
|
<?php if (isset($errors['password'])): ?><div class="invalid-feedback"><?= e($errors['password']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password_confirm" class="form-label">Konfirmasi password</label>
|
||||||
|
<input type="password" class="form-control <?= isset($errors['password_confirm']) ? 'is-invalid' : '' ?>" id="password_confirm" name="password_confirm" placeholder="Ulangi password">
|
||||||
|
<?php if (isset($errors['password_confirm'])): ?><div class="invalid-feedback"><?= e($errors['password_confirm']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-dark w-100">Buat akun</button>
|
||||||
|
</form>
|
||||||
|
<div class="small text-muted mt-3">Sudah punya akun? <a href="<?= e(app_url('login.php')) ?><?= $redirectTarget !== app_url('dashboard.php') ? '?redirect=' . urlencode($redirectTarget) : '' ?>">Login di sini</a>.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php render_page_end(); ?>
|
||||||
98
service.php
Normal file
98
service.php
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
require_user_login();
|
||||||
|
|
||||||
|
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
|
||||||
|
$service = $id > 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());
|
||||||
|
?>
|
||||||
|
<section class="py-4 py-lg-5">
|
||||||
|
<div class="container">
|
||||||
|
<?php if (!$service): ?>
|
||||||
|
<div class="surface-card text-center py-5">
|
||||||
|
<span class="eyebrow">404</span>
|
||||||
|
<h1 class="section-title mt-2">Catatan servis tidak ditemukan</h1>
|
||||||
|
<p class="text-secondary mb-4">Catatan ini mungkin bukan milik akun kamu, atau link yang dibuka sudah tidak valid.</p>
|
||||||
|
<a class="btn btn-dark" href="<?= e(app_url('dashboard.php')) ?>">Kembali ke dashboard</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php $state = due_state($service['next_due_date'] ?? null); ?>
|
||||||
|
<div class="row g-4 align-items-start">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="surface-card detail-card">
|
||||||
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-start gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Detail reminder privat</span>
|
||||||
|
<h1 class="section-title mt-2 mb-2"><?= e($service['vehicle_name']) ?></h1>
|
||||||
|
<p class="text-secondary mb-0"><?= e($service['service_name']) ?><?= !empty($service['plate_number']) ? ' · ' . e($service['plate_number']) : '' ?></p>
|
||||||
|
</div>
|
||||||
|
<span class="status-pill <?= e($state['class']) ?> large"><?= e($state['label']) ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="detail-box">
|
||||||
|
<div class="detail-label">Jenis kendaraan</div>
|
||||||
|
<div class="detail-value"><?= e($service['vehicle_category']) ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="detail-box">
|
||||||
|
<div class="detail-label">Servis terakhir</div>
|
||||||
|
<div class="detail-value"><?= e(date('d M Y', strtotime((string) $service['last_service_date']))) ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="detail-box">
|
||||||
|
<div class="detail-label">Servis berikutnya</div>
|
||||||
|
<div class="detail-value"><?= e(date('d M Y', strtotime((string) $service['next_due_date']))) ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="surface-subsection mb-4">
|
||||||
|
<h2 class="h6 mb-3">Ringkasan reminder</h2>
|
||||||
|
<div class="detail-metadata stacked">
|
||||||
|
<span>Interval pengingat: <?= (int) $service['reminder_interval_days'] ?> hari</span>
|
||||||
|
<span>Status dashboard: <?= e($state['description']) ?></span>
|
||||||
|
<span>Kilometer terakhir: <?= $service['odometer_km'] ? number_format((int) $service['odometer_km']) . ' km' : 'Belum dicatat' ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="surface-subsection">
|
||||||
|
<h2 class="h6 mb-3">Catatan servis</h2>
|
||||||
|
<p class="text-secondary mb-0"><?= $service['notes'] ? e($service['notes']) : 'Belum ada catatan tambahan untuk item servis ini.' ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="surface-card side-panel mb-3">
|
||||||
|
<h2 class="h6 mb-3">Aksi cepat</h2>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a class="btn btn-dark" href="<?= e(app_url('dashboard.php')) ?>">Tambah reminder lain</a>
|
||||||
|
<a class="btn btn-outline-secondary" href="<?= e(app_url()) ?>">Kembali ke beranda</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="surface-card muted-panel">
|
||||||
|
<h2 class="h6 mb-2">Privasi akun</h2>
|
||||||
|
<p class="small text-secondary mb-0">Halaman detail ini hanya menampilkan catatan milik akun yang sedang login, jadi data user lain tidak ikut terlihat.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php render_page_end(); ?>
|
||||||
Loading…
x
Reference in New Issue
Block a user