diff --git a/admin.php b/admin.php
new file mode 100644
index 0000000..7d3de08
--- /dev/null
+++ b/admin.php
@@ -0,0 +1,974 @@
+exec($sql);
+ $ready = true;
+
+ return;
+ }
+ }
+
+ db()->exec(
+ "CREATE TABLE IF NOT EXISTS admin_users (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ full_name VARCHAR(120) NOT NULL,
+ email VARCHAR(160) NOT NULL UNIQUE,
+ password_hash VARCHAR(255) NOT NULL,
+ last_login_at TIMESTAMP NULL 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_admin_email (email),
+ INDEX idx_admin_created_at (created_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
+ );
+
+ $ready = true;
+}
+
+function admin_has_users(): bool
+{
+ admin_ensure_auth_schema();
+
+ $stmt = db()->query('SELECT COUNT(*) AS total FROM admin_users');
+ $row = $stmt->fetch() ?: [];
+
+ return (int)($row['total'] ?? 0) > 0;
+}
+
+function admin_current_user(): ?array
+{
+ $sessionUser = $_SESSION[STORE_ADMIN_SESSION_KEY] ?? null;
+ if (!is_array($sessionUser)) {
+ return null;
+ }
+
+ $id = (int)($sessionUser['id'] ?? 0);
+ $fullName = store_sanitize_line((string)($sessionUser['full_name'] ?? ''), 120);
+ $email = store_lower(trim((string)($sessionUser['email'] ?? '')));
+
+ if ($id <= 0 || $fullName === '' || $email === '') {
+ unset($_SESSION[STORE_ADMIN_SESSION_KEY]);
+
+ return null;
+ }
+
+ return [
+ 'id' => $id,
+ 'full_name' => $fullName,
+ 'email' => $email,
+ ];
+}
+
+function admin_store_session(array $user): void
+{
+ $_SESSION[STORE_ADMIN_SESSION_KEY] = [
+ 'id' => (int)($user['id'] ?? 0),
+ 'full_name' => store_sanitize_line((string)($user['full_name'] ?? ''), 120),
+ 'email' => store_lower(trim((string)($user['email'] ?? ''))),
+ ];
+
+ session_regenerate_id(true);
+}
+
+function admin_logout(): void
+{
+ unset($_SESSION[STORE_ADMIN_SESSION_KEY]);
+ session_regenerate_id(true);
+}
+
+function admin_is_authenticated(): bool
+{
+ return admin_current_user() !== null;
+}
+
+function admin_csrf_token(): string
+{
+ $token = $_SESSION[STORE_ADMIN_CSRF_KEY] ?? '';
+ if (!is_string($token) || $token === '') {
+ try {
+ $token = bin2hex(random_bytes(32));
+ } catch (Throwable $exception) {
+ $token = sha1(session_id() . '|' . microtime(true));
+ }
+
+ $_SESSION[STORE_ADMIN_CSRF_KEY] = $token;
+ }
+
+ return $token;
+}
+
+function admin_verify_csrf(?string $token): bool
+{
+ if (!is_string($token) || $token === '') {
+ return false;
+ }
+
+ return hash_equals(admin_csrf_token(), $token);
+}
+
+function admin_auth_form_defaults(array $source = []): array
+{
+ return [
+ 'full_name' => store_sanitize_line((string)($source['full_name'] ?? ''), 120),
+ 'email' => store_lower(trim((string)($source['email'] ?? ''))),
+ ];
+}
+
+function admin_password_error(string $password): string
+{
+ if (store_strlen($password) < 8) {
+ return 'Password admin minimal 8 karakter.';
+ }
+
+ return '';
+}
+
+function admin_create_first_user(array $source): array
+{
+ admin_ensure_auth_schema();
+
+ $form = admin_auth_form_defaults($source);
+ $password = (string)($source['password'] ?? '');
+ $confirmPassword = (string)($source['confirm_password'] ?? '');
+
+ if (admin_has_users()) {
+ return [
+ 'success' => false,
+ 'message' => 'Akun admin sudah dibuat. Silakan login dengan email dan password admin.',
+ 'form' => ['full_name' => '', 'email' => $form['email']],
+ ];
+ }
+
+ if (store_strlen($form['full_name']) < 3) {
+ return [
+ 'success' => false,
+ 'message' => 'Nama admin minimal 3 karakter.',
+ 'form' => $form,
+ ];
+ }
+
+ if (!filter_var($form['email'], FILTER_VALIDATE_EMAIL)) {
+ return [
+ 'success' => false,
+ 'message' => 'Masukkan email admin yang valid.',
+ 'form' => $form,
+ ];
+ }
+
+ $passwordError = admin_password_error($password);
+ if ($passwordError !== '') {
+ return [
+ 'success' => false,
+ 'message' => $passwordError,
+ 'form' => $form,
+ ];
+ }
+
+ if ($password !== $confirmPassword) {
+ return [
+ 'success' => false,
+ 'message' => 'Konfirmasi password admin belum cocok.',
+ 'form' => $form,
+ ];
+ }
+
+ $stmt = db()->prepare(
+ 'INSERT INTO admin_users (full_name, email, password_hash, last_login_at) VALUES (:full_name, :email, :password_hash, NOW())'
+ );
+ $stmt->bindValue(':full_name', $form['full_name']);
+ $stmt->bindValue(':email', $form['email']);
+ $stmt->bindValue(':password_hash', password_hash($password, PASSWORD_DEFAULT));
+ $stmt->execute();
+
+ $user = [
+ 'id' => (int)db()->lastInsertId(),
+ 'full_name' => $form['full_name'],
+ 'email' => $form['email'],
+ ];
+ admin_store_session($user);
+
+ return [
+ 'success' => true,
+ 'message' => 'Akun admin pertama berhasil dibuat dan sesi login sudah aktif.',
+ 'form' => ['full_name' => '', 'email' => ''],
+ ];
+}
+
+function admin_attempt_login(array $source): array
+{
+ admin_ensure_auth_schema();
+
+ $form = admin_auth_form_defaults($source);
+ $password = (string)($source['password'] ?? '');
+
+ if (!admin_has_users()) {
+ return [
+ 'success' => false,
+ 'message' => 'Belum ada akun admin. Buat akun admin pertama terlebih dahulu.',
+ 'form' => $form,
+ ];
+ }
+
+ if (!filter_var($form['email'], FILTER_VALIDATE_EMAIL) || $password === '') {
+ return [
+ 'success' => false,
+ 'message' => 'Masukkan email admin dan password yang benar.',
+ 'form' => $form,
+ ];
+ }
+
+ $stmt = db()->prepare('SELECT id, full_name, email, password_hash FROM admin_users WHERE email = :email LIMIT 1');
+ $stmt->bindValue(':email', $form['email']);
+ $stmt->execute();
+ $user = $stmt->fetch();
+
+ if (!$user || !password_verify($password, (string)($user['password_hash'] ?? ''))) {
+ return [
+ 'success' => false,
+ 'message' => 'Email admin atau password tidak cocok.',
+ 'form' => $form,
+ ];
+ }
+
+ $update = db()->prepare('UPDATE admin_users SET last_login_at = NOW() WHERE id = :id LIMIT 1');
+ $update->bindValue(':id', (int)($user['id'] ?? 0), PDO::PARAM_INT);
+ $update->execute();
+
+ if (password_needs_rehash((string)($user['password_hash'] ?? ''), PASSWORD_DEFAULT)) {
+ $rehash = db()->prepare('UPDATE admin_users SET password_hash = :password_hash WHERE id = :id LIMIT 1');
+ $rehash->bindValue(':password_hash', password_hash($password, PASSWORD_DEFAULT));
+ $rehash->bindValue(':id', (int)($user['id'] ?? 0), PDO::PARAM_INT);
+ $rehash->execute();
+ }
+
+ admin_store_session($user);
+
+ return [
+ 'success' => true,
+ 'message' => 'Login admin berhasil.',
+ 'form' => ['full_name' => '', 'email' => ''],
+ ];
+}
+
+function admin_status_options(): array
+{
+ $options = [];
+ foreach (store_status_steps() as $step) {
+ $value = (string)($step['value'] ?? '');
+ $label = (string)($step['label'] ?? $value);
+ if ($value !== '') {
+ $options[$value] = $label;
+ }
+ }
+ $options['Batal'] = 'Batal';
+
+ return $options;
+}
+
+function admin_filters_from_source(array $source): array
+{
+ $statuses = admin_status_options();
+ $search = store_sanitize_line((string)($source['search'] ?? ''), 120);
+ $status = store_sanitize_line((string)($source['status'] ?? 'all'), 40);
+ $selected = store_sanitize_line((string)($source['selected'] ?? ''), 30);
+
+ if ($status !== 'all' && !isset($statuses[$status])) {
+ $status = 'all';
+ }
+
+ return [
+ 'search' => $search,
+ 'status' => $status,
+ 'selected' => $selected,
+ ];
+}
+
+function admin_url(array $filters, array $overrides = []): string
+{
+ $params = $filters;
+ foreach ($overrides as $key => $value) {
+ if (is_string($key) && is_string($value)) {
+ $params[$key] = $value;
+ }
+ }
+
+ $query = [];
+ foreach ($params as $key => $value) {
+ if (!is_string($key) || !is_string($value) || $value === '') {
+ continue;
+ }
+ if ($key === 'status' && $value === 'all') {
+ continue;
+ }
+ $query[$key] = $value;
+ }
+
+ return 'admin.php' . ($query ? '?' . http_build_query($query) : '');
+}
+
+function admin_order_dashboard(): array
+{
+ store_ensure_schema();
+
+ $stmt = db()->query(
+ "SELECT
+ COUNT(*) AS total_orders,
+ COALESCE(SUM(CASE WHEN status = 'Menunggu Pembayaran' THEN 1 ELSE 0 END), 0) AS pending_orders,
+ COALESCE(SUM(CASE WHEN status IN ('Diproses', 'Dikirim') THEN 1 ELSE 0 END), 0) AS active_orders,
+ COALESCE(SUM(CASE WHEN status = 'Selesai' THEN 1 ELSE 0 END), 0) AS completed_orders,
+ COALESCE(SUM(grand_total), 0) AS gross_revenue,
+ COALESCE(SUM(CASE WHEN created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) THEN 1 ELSE 0 END), 0) AS recent_orders
+ FROM orders"
+ );
+
+ $row = $stmt->fetch() ?: [];
+
+ return [
+ 'total_orders' => (int)($row['total_orders'] ?? 0),
+ 'pending_orders' => (int)($row['pending_orders'] ?? 0),
+ 'active_orders' => (int)($row['active_orders'] ?? 0),
+ 'completed_orders' => (int)($row['completed_orders'] ?? 0),
+ 'gross_revenue' => (float)($row['gross_revenue'] ?? 0),
+ 'recent_orders' => (int)($row['recent_orders'] ?? 0),
+ ];
+}
+
+function admin_list_orders(array $filters): array
+{
+ store_ensure_schema();
+
+ $conditions = [];
+ $params = [];
+
+ if (($filters['search'] ?? '') !== '') {
+ $conditions[] = '(order_number LIKE :search OR customer_name LIKE :search OR email LIKE :search OR phone LIKE :search)';
+ $params['search'] = '%' . $filters['search'] . '%';
+ }
+
+ if (($filters['status'] ?? 'all') !== 'all') {
+ $conditions[] = 'status = :status';
+ $params['status'] = $filters['status'];
+ }
+
+ $sql = 'SELECT order_number, customer_name, email, phone, status, grand_total, payment_method, items_json, created_at, updated_at FROM orders';
+ if ($conditions) {
+ $sql .= ' WHERE ' . implode(' AND ', $conditions);
+ }
+ $sql .= ' ORDER BY created_at DESC LIMIT 100';
+
+ $stmt = db()->prepare($sql);
+ foreach ($params as $key => $value) {
+ $stmt->bindValue(':' . $key, $value);
+ }
+ $stmt->execute();
+
+ $methods = store_payment_methods();
+ $orders = $stmt->fetchAll() ?: [];
+ foreach ($orders as &$order) {
+ $items = json_decode((string)($order['items_json'] ?? '[]'), true);
+ $itemCount = 0;
+ if (is_array($items)) {
+ foreach ($items as $item) {
+ if (is_array($item)) {
+ $itemCount += (int)($item['quantity'] ?? 0);
+ }
+ }
+ }
+ $methodKey = (string)($order['payment_method'] ?? '');
+ $order['payment_method_label'] = $methods[$methodKey]['label'] ?? $methodKey;
+ $order['item_count'] = $itemCount;
+ }
+ unset($order);
+
+ return $orders;
+}
+
+function admin_update_order_status(string $orderNumber, string $status): array
+{
+ $orderNumber = store_sanitize_line($orderNumber, 30);
+ $status = store_sanitize_line($status, 40);
+ $options = admin_status_options();
+
+ if ($orderNumber === '') {
+ return ['success' => false, 'message' => 'Kode pesanan tidak valid.'];
+ }
+
+ if (!isset($options[$status])) {
+ return ['success' => false, 'message' => 'Status pesanan tidak dikenali.'];
+ }
+
+ $existing = store_find_order($orderNumber);
+ if (!$existing) {
+ return ['success' => false, 'message' => 'Pesanan tidak ditemukan.'];
+ }
+
+ if ((string)$existing['status'] === $status) {
+ return ['success' => true, 'message' => 'Status pesanan sudah berada di tahap ' . $options[$status] . '.'];
+ }
+
+ $stmt = db()->prepare('UPDATE orders SET status = :status WHERE order_number = :order_number LIMIT 1');
+ $stmt->bindValue(':status', $status);
+ $stmt->bindValue(':order_number', $orderNumber);
+ $stmt->execute();
+
+ return [
+ 'success' => true,
+ 'message' => 'Status pesanan ' . $orderNumber . ' diubah ke ' . $options[$status] . '.',
+ ];
+}
+
+function admin_catalog_snapshot(): array
+{
+ $categories = store_categories();
+ $snapshot = [];
+
+ foreach ($categories as $slug => $category) {
+ if ($slug === 'all') {
+ continue;
+ }
+
+ $snapshot[$slug] = [
+ 'slug' => $slug,
+ 'label' => (string)($category['label'] ?? $slug),
+ 'description' => (string)($category['description'] ?? ''),
+ 'count' => 0,
+ 'min_price' => null,
+ 'max_price' => null,
+ 'products' => [],
+ ];
+ }
+
+ foreach (store_products() as $product) {
+ $categoryKey = (string)($product['category'] ?? '');
+ if (!isset($snapshot[$categoryKey])) {
+ continue;
+ }
+
+ $price = (float)($product['price'] ?? 0);
+ $snapshot[$categoryKey]['count']++;
+ $snapshot[$categoryKey]['min_price'] = $snapshot[$categoryKey]['min_price'] === null
+ ? $price
+ : min((float)$snapshot[$categoryKey]['min_price'], $price);
+ $snapshot[$categoryKey]['max_price'] = $snapshot[$categoryKey]['max_price'] === null
+ ? $price
+ : max((float)$snapshot[$categoryKey]['max_price'], $price);
+ $snapshot[$categoryKey]['products'][] = [
+ 'name' => (string)($product['name'] ?? ''),
+ 'price' => $price,
+ 'lead_time' => (string)($product['lead_time'] ?? ''),
+ 'slug' => (string)($product['slug'] ?? ''),
+ ];
+ }
+
+ return array_values($snapshot);
+}
+
+$authForm = admin_auth_form_defaults();
+$authError = '';
+$filters = admin_filters_from_source($_GET);
+
+if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
+ $action = store_sanitize_line((string)($_POST['action'] ?? ''), 40);
+
+ if (!admin_verify_csrf((string)($_POST['csrf_token'] ?? ''))) {
+ if ($action === 'setup_admin' || $action === 'login_admin') {
+ $authForm = admin_auth_form_defaults($_POST);
+ $authError = 'Sesi keamanan sudah berakhir. Muat ulang halaman admin lalu coba lagi.';
+ } else {
+ store_flash('danger', 'Sesi keamanan admin sudah berakhir. Coba ulangi tindakannya.');
+ header('Location: admin.php');
+ exit;
+ }
+ } elseif ($action === 'logout_admin') {
+ admin_logout();
+ store_flash('success', 'Sesi admin berhasil ditutup.');
+ header('Location: admin.php');
+ exit;
+ } elseif ($action === 'setup_admin') {
+ $result = admin_create_first_user($_POST);
+ if (!empty($result['success'])) {
+ store_flash('success', (string)($result['message'] ?? 'Akun admin berhasil dibuat.'));
+ header('Location: admin.php');
+ exit;
+ }
+
+ $authForm = is_array($result['form'] ?? null) ? $result['form'] : admin_auth_form_defaults($_POST);
+ $authError = (string)($result['message'] ?? 'Setup admin gagal diproses.');
+ } elseif ($action === 'login_admin') {
+ $result = admin_attempt_login($_POST);
+ if (!empty($result['success'])) {
+ store_flash('success', (string)($result['message'] ?? 'Login admin berhasil.'));
+ header('Location: admin.php');
+ exit;
+ }
+
+ $authForm = is_array($result['form'] ?? null) ? $result['form'] : admin_auth_form_defaults($_POST);
+ $authError = (string)($result['message'] ?? 'Login admin gagal diproses.');
+ } elseif ($action === 'update_status') {
+ if (!admin_is_authenticated()) {
+ store_flash('danger', 'Silakan login admin terlebih dahulu.');
+ header('Location: admin.php');
+ exit;
+ }
+
+ $filters = admin_filters_from_source($_POST);
+ $result = admin_update_order_status(
+ (string)($_POST['order_number'] ?? ''),
+ (string)($_POST['status_value'] ?? '')
+ );
+
+ store_flash(!empty($result['success']) ? 'success' : 'danger', (string)($result['message'] ?? 'Aksi admin gagal diproses.'));
+ header('Location: ' . admin_url($filters, ['selected' => (string)($_POST['order_number'] ?? '')]));
+ exit;
+ } else {
+ store_flash('danger', 'Aksi admin tidak dikenali.');
+ header('Location: admin.php');
+ exit;
+ }
+}
+
+$adminUser = admin_current_user();
+
+if (!$adminUser) {
+ $hasAdminAccount = admin_has_users();
+
+ store_page_start(
+ $hasAdminAccount ? 'Login Admin' : 'Setup Admin',
+ $hasAdminAccount
+ ? 'Masuk ke dashboard admin untuk memantau dan memperbarui pesanan bakery.'
+ : 'Buat akun admin pertama untuk melindungi dashboard operasional bakery.',
+ ['noindex' => true]
+ );
+ ?>
+
+
+
+
Admin access
+
= $hasAdminAccount ? 'Masuk ke dashboard internal yang sudah dilindungi.' : 'Aktifkan login admin sebelum panel dipakai rutin.' ?>
+
+ = $hasAdminAccount
+ ? 'Dashboard order sekarang butuh sesi login, jadi update status pesanan tidak lagi terbuka untuk publik.'
+ : 'Langkah setup ini hanya muncul sekali untuk membuat akun admin pertama. Setelah tersimpan, halaman otomatis berubah jadi form login.' ?>
+
+
+
= h($authError) ?>
+
+
+ = $hasAdminAccount ? 'Akses aman:' : 'Setup sekali:' ?>
+ = $hasAdminAccount
+ ? 'gunakan email admin dan password yang sudah dibuat untuk membuka dashboard.'
+ : 'buat email admin internal dan password minimal 8 karakter agar halaman admin tidak lagi terbuka bebas.' ?>
+
+
+
+
+
+ = $hasAdminAccount ? 'Login required' : 'Setup pertama' ?>
+ = $hasAdminAccount ? 'Masukkan email dan password admin.' : 'Buat akun admin internal untuk toko ini.' ?>
+
+
+
+ 1 Gunakan email admin yang dibuat saat setup pertama.
+ 2 Password admin disimpan sebagai hash aman di MySQL.
+ 3 Setelah login, kamu bisa lanjut mengubah status order dari dashboard.
+
+ 1 Buat satu akun admin internal dengan email yang aktif.
+ 2 Gunakan password minimal 8 karakter agar panel tidak lagi terbuka publik.
+ 3 Sesudah akun pertama tersimpan, halaman ini otomatis berubah jadi form login.
+
+
+
+
+
+
+ true]
+);
+?>
+
+
+
+
Internal admin
+
Pantau order masuk tanpa panel yang berat.
+
+ Slice ini fokus ke kebutuhan operasional paling dekat: melihat antrean pesanan, membuka detail customer,
+ lalu mengubah status order saat pembayaran masuk, produksi dimulai, atau pengiriman selesai.
+
+
+ Akses aman aktif: dashboard ini sekarang memerlukan login admin.
+ Sesi aktif untuk = h($adminUser['full_name']) ?> (= h($adminUser['email']) ?>).
+
+
+
+
+ Ruang lingkup iterasi ini
+ Order management dulu, catalog CRUD nanti.
+
+ 1 Dashboard sekarang dilindungi login berbasis session.
+ 2 Status order bisa diubah dari dashboard admin.
+ 3 Produk dan kategori tampil sebagai snapshot read-only.
+
+
+ Login sebagai = h($adminUser['full_name']) ?> • = h($adminUser['email']) ?>
+
+
+
+
+
+
+
+
+
+
= h((string)$dashboard['total_orders']) ?>
+
total pesanan tersimpan
+
+
+
+
+
= h((string)$dashboard['pending_orders']) ?>
+
menunggu pembayaran
+
+
+
+
+
= h((string)$dashboard['active_orders']) ?>
+
sedang diproses / dikirim
+
+
+
+
+
= h(store_money((float)$dashboard['gross_revenue'])) ?>
+
gross value seluruh order
+
+
+
+
+
+
+
+
+
+
Filter antrean
+
Cari order yang perlu ditindak.
+
Gunakan pencarian berdasarkan kode order, nama customer, email, atau telepon.
+
+
Pesanan 7 hari terakhir: = h((string)$dashboard['recent_orders']) ?>
+
+
+
+
+
+
+
+
+
Belum ada hasil
+
Tidak ada pesanan yang cocok dengan filter ini.
+
Coba reset pencarian atau ubah status filter untuk melihat antrean yang lain.
+
+
+
+
+
Order queue
+
= h((string)count($orders)) ?> pesanan tampil di dashboard.
+
Klik detail untuk membuka panel ringkasan dan mengubah status order.
+
+
+
+
+
+ Pesanan
+ Customer
+ Status
+ Total
+ Aksi
+
+
+
+
+
+
+ = h((string)$order['order_number']) ?>
+ = h((string)$order['item_count']) ?> item • = h((string)$order['payment_method_label']) ?>
+
+
+ = h((string)$order['customer_name']) ?>
+ = h((string)$order['email']) ?>
+ = h((string)$order['phone']) ?>
+
+
+ = h((string)$order['status']) ?>
+
+
+ = h(store_money((float)$order['grand_total'])) ?>
+ = h(store_format_datetime((string)$order['created_at'])) ?>
+
+
+ Detail
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Pesanan dipilih
+
= h((string)$selectedOrder['order_number']) ?>
+
Dibuat pada = h(store_format_datetime((string)$selectedOrder['created_at'])) ?>
+
+
= h((string)$selectedOrder['status']) ?>
+
+
+
+
+
+
Data customer
+
= h((string)$selectedOrder['customer_name']) ?>
+
= h((string)$selectedOrder['email']) ?>
+
= h((string)$selectedOrder['phone']) ?>
+
= nl2br(h((string)$selectedOrder['address'])) ?>
+
+
+
+
Pembayaran & catatan
+
= h((string)$selectedOrder['payment_method_label']) ?>
+
= h((string)$selectedOrder['payment_instruction']) ?>
+
+
Catatan customer: = h((string)$selectedOrder['note']) ?>
+
+
+
+
+
Ringkasan item
+
+
+
+ = h((string)$item['name']) ?> × = h((string)$item['quantity']) ?>
+ = h(store_money((float)$item['line_total'])) ?>
+
+
+
+ Subtotal
+ = h(store_money((float)$selectedOrder['subtotal'])) ?>
+
+
+ Ongkir
+ = h(store_money((float)$selectedOrder['shipping_fee'])) ?>
+
+
+ Grand total
+ = h(store_money((float)$selectedOrder['grand_total'])) ?>
+
+
+
+
+
+
+
+
Progress customer-facing
+
+ $step): ?>
+
+
+
+
+
= h((string)$step['label']) ?>
+
= h((string)$step['description']) ?>
+
+
+
+
+
+
+
+
Status batal
+
Pesanan ini sudah dibatalkan, jadi timeline customer tidak lagi berjalan.
+
+
+
+
+
+
Pilih pesanan
+
Belum ada order yang dibuka.
+
Klik tombol detail pada tabel agar admin bisa melihat item, data customer, dan mengganti status pesanan.
+
+
+
+
+
+
+
+
+
Catalog snapshot
+
Produk dan kategori tampil sebagai referensi operasional.
+
Di slice ini catalog belum editable dari admin. Fokusnya masih order handling agar alur transaksi sudah bisa dijalankan dulu.
+
+
+
+
+
+
+
= h((string)$category['label']) ?>
+
= h((string)$category['count']) ?>
+
produk aktif di kategori ini
+
= h((string)$category['description']) ?>
+
+
= h(store_money((float)$category['min_price'])) ?> — = h(store_money((float)$category['max_price'])) ?>
+
+
+
+
+
+
+
+
+
Produk aktif
+
= h((string)count(store_products())) ?> produk ada di katalog saat ini.
+
Langkah berikutnya bila dibutuhkan: pindahkan katalog ke tabel DB agar admin bisa menambah/edit produk dari UI.
+
+
+
+
+
+ Produk
+ Kategori
+ Lead time
+ Harga
+
+
+
+
+
+
+ = h((string)$product['name']) ?>
+ = h((string)$product['slug']) ?>
+
+ = h((string)$product['category_label']) ?>
+ = h((string)$product['lead_time']) ?>
+ = h(store_money((float)$product['price'])) ?>
+
+
+
+
+
+
+
+
+
Sekut Bakery favicon
+ Rounded square bakery icon with SB monogram.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SB
+
diff --git a/db/migrations/20260526_create_admin_users.sql b/db/migrations/20260526_create_admin_users.sql
new file mode 100644
index 0000000..eee4f79
--- /dev/null
+++ b/db/migrations/20260526_create_admin_users.sql
@@ -0,0 +1,12 @@
+/* Admin auth table for the Sekut Bakery dashboard. */
+CREATE TABLE IF NOT EXISTS admin_users (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ full_name VARCHAR(120) NOT NULL,
+ email VARCHAR(160) NOT NULL UNIQUE,
+ password_hash VARCHAR(255) NOT NULL,
+ last_login_at TIMESTAMP NULL 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_admin_email (email),
+ INDEX idx_admin_created_at (created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/order_status.php b/order_status.php
index 7a4add6..db40b68 100644
--- a/order_status.php
+++ b/order_status.php
@@ -207,7 +207,7 @@ store_page_start('Status Pesanan', 'Lacak status pesanan menggunakan order numbe
1 Masuk dari checkout otomatis akan mengisi kode pesanan terbaru.
2 Untuk kunjungan berikutnya, pelanggan cukup ingat order number dan email.
- 3 Status berikutnya bisa diubah admin di iterasi lanjutan.
+ 3 Status pesanan kini bisa diperbarui lewat admin lite internal.
diff --git a/robots.txt b/robots.txt
new file mode 100644
index 0000000..369c779
--- /dev/null
+++ b/robots.txt
@@ -0,0 +1,5 @@
+User-agent: *
+Allow: /
+Disallow: /checkout.php
+Disallow: /order_status.php
+Disallow: /admin.php
diff --git a/store.php b/store.php
index d13b7c7..b2113bc 100644
--- a/store.php
+++ b/store.php
@@ -7,6 +7,11 @@ if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
+if (basename((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === basename(__FILE__)) {
+ header('Location: index.php');
+ exit;
+}
+
const STORE_CART_KEY = 'sekut_cart';
const STORE_FLASH_KEY = 'sekut_flash';
const STORE_LAST_ORDER_KEY = 'sekut_last_order';
@@ -723,6 +728,7 @@ function store_page_start(string $title, string $description = '', array $option
$metaDescription = $description !== '' ? $description : $projectDescription;
$fullTitle = trim($title) !== '' ? $title . ' • ' . $projectName : $projectName;
$cssVersion = file_exists(__DIR__ . '/assets/css/custom.css') ? (string)filemtime(__DIR__ . '/assets/css/custom.css') : (string)time();
+ $faviconVersion = file_exists(__DIR__ . '/assets/images/favicon.svg') ? (string)filemtime(__DIR__ . '/assets/images/favicon.svg') : $cssVersion;
$currentPath = basename(parse_url($_SERVER['REQUEST_URI'] ?? '/index.php', PHP_URL_PATH) ?: 'index.php');
if ($currentPath === '' || $currentPath === '/') {
$currentPath = 'index.php';
@@ -735,6 +741,7 @@ function store_page_start(string $title, string $description = '', array $option
echo '';
echo ' ';
echo ' ';
+ echo ' ';
echo '' . h($fullTitle) . ' ';
echo $robots;
@@ -754,6 +761,7 @@ function store_page_start(string $title, string $description = '', array $option
echo ' ';
echo ' ';
echo ' ';
+ echo ' ';
echo ' ';
echo ' ';
echo '';