40087-vm/store.php
2026-05-26 07:35:51 +00:00

839 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
require_once __DIR__ . '/db/config.php';
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';
function app_env(string $key, string $fallback = ''): string
{
$serverValue = $_SERVER[$key] ?? '';
if (is_string($serverValue) && $serverValue !== '') {
return $serverValue;
}
$envValue = getenv($key);
if (is_string($envValue) && $envValue !== '') {
return $envValue;
}
return $fallback;
}
function h($value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
function store_brand(): string
{
return 'SEKUT BAKERY';
}
function store_categories(): array
{
return [
'all' => [
'label' => 'Semua',
'description' => 'Seluruh katalog bakery untuk daily order dan pre-order.',
],
'signature-cakes' => [
'label' => 'Signature Cakes',
'description' => 'Whole cake dan sliced cake untuk momen spesial.',
],
'artisan-bread' => [
'label' => 'Artisan Bread',
'description' => 'Roti harian dengan tekstur bersih dan fermentasi lebih lama.',
],
'cookies-pastry' => [
'label' => 'Cookies & Pastry',
'description' => 'Pilihan pastry dan cookies untuk coffee break dan hampers.',
],
];
}
function store_products(): array
{
return [
'burnt-cheesecake' => [
'slug' => 'burnt-cheesecake',
'name' => 'Burnt Cheesecake',
'category' => 'signature-cakes',
'category_label' => 'Signature Cakes',
'price' => 185000.0,
'unit' => 'whole cake',
'short_description' => 'Cheesecake creamy dengan permukaan karamel gelap dan finish yang clean.',
'description' => 'Whole cake 18 cm dengan rasa vanilla-cream cheese yang lembut dan lapisan atas yang sedikit smoky. Cocok untuk 810 porsi dan ideal untuk dessert keluarga.',
'lead_time' => 'Pre-order 1 hari',
'serves' => '810 porsi',
'tone' => 'ink',
'visual_code' => 'BC',
'highlights' => ['18 cm', 'Best seller', 'Pre-order 1 hari'],
],
'matcha-roll-cake' => [
'slug' => 'matcha-roll-cake',
'name' => 'Matcha Roll Cake',
'category' => 'signature-cakes',
'category_label' => 'Signature Cakes',
'price' => 158000.0,
'unit' => 'roll cake',
'short_description' => 'Sponge cake tipis dengan filling krim matcha yang ringan.',
'description' => 'Roll cake dengan sponge lembut, rasa matcha yang clean, dan krim yang tidak terlalu manis. Cocok untuk sharing di meeting kecil atau hadiah praktis.',
'lead_time' => 'Ready setiap pagi',
'serves' => '68 potong',
'tone' => 'stone',
'visual_code' => 'MR',
'highlights' => ['68 potong', 'Fresh daily', 'Light sweetness'],
],
'sesame-sourdough' => [
'slug' => 'sesame-sourdough',
'name' => 'Sesame Sourdough Loaf',
'category' => 'artisan-bread',
'category_label' => 'Artisan Bread',
'price' => 52000.0,
'unit' => 'loaf',
'short_description' => 'Roti sourdough dengan crust renyah, crumb lembut, dan taburan wijen.',
'description' => 'Loaf fermentasi alami dengan aroma gandum yang lebih dalam. Enak untuk sandwich, toast sarapan, atau stok roti rumah selama 23 hari.',
'lead_time' => 'Ready setiap siang',
'serves' => '8 slice tebal',
'tone' => 'sand',
'visual_code' => 'SD',
'highlights' => ['Fermentasi alami', '8 slice', 'Crust renyah'],
],
'focaccia-olive' => [
'slug' => 'focaccia-olive',
'name' => 'Focaccia Tomato Olive',
'category' => 'artisan-bread',
'category_label' => 'Artisan Bread',
'price' => 48000.0,
'unit' => 'tray',
'short_description' => 'Focaccia savory dengan olive oil, tomat, dan olive hitam.',
'description' => 'Roti focaccia bertekstur empuk dengan permukaan yang sedikit garing. Cocok untuk snack meeting, teman sup, atau dibuat sandwich terbuka.',
'lead_time' => 'Ready terbatas',
'serves' => '45 porsi',
'tone' => 'taupe',
'visual_code' => 'FO',
'highlights' => ['Savory', '45 porsi', 'Ready terbatas'],
],
'butter-croissant' => [
'slug' => 'butter-croissant',
'name' => 'Butter Croissant',
'category' => 'cookies-pastry',
'category_label' => 'Cookies & Pastry',
'price' => 28000.0,
'unit' => 'pcs',
'short_description' => 'Croissant berlapis dengan aroma butter dan tekstur flaky.',
'description' => 'Pastry klasik dengan lapisan tipis yang renyah di luar dan ringan di dalam. Cocok untuk sarapan cepat atau pairing dengan kopi hitam.',
'lead_time' => 'Ready dari pagi',
'serves' => '1 pcs besar',
'tone' => 'stone',
'visual_code' => 'CR',
'highlights' => ['Flaky', 'Fresh baked', 'Coffee pairing'],
],
'almond-cookies-tin' => [
'slug' => 'almond-cookies-tin',
'name' => 'Almond Cookies Tin',
'category' => 'cookies-pastry',
'category_label' => 'Cookies & Pastry',
'price' => 68000.0,
'unit' => 'tin',
'short_description' => 'Cookies renyah dengan potongan almond panggang dalam kemasan kaleng.',
'description' => 'Cookies butter dengan tekstur crisp dan rasa toasted almond yang hangat. Praktis untuk hampers kecil, pantry kantor, atau stok camilan rumah.',
'lead_time' => 'Ready stock',
'serves' => '2024 pcs',
'tone' => 'ink',
'visual_code' => 'AC',
'highlights' => ['2024 pcs', 'Giftable', 'Ready stock'],
],
];
}
function store_product(string $slug): ?array
{
$products = store_products();
return $products[$slug] ?? null;
}
function store_filtered_products(string $category = 'all'): array
{
$products = array_values(store_products());
if ($category === 'all' || !isset(store_categories()[$category])) {
return $products;
}
return array_values(array_filter(
$products,
static fn(array $product): bool => $product['category'] === $category
));
}
function store_related_products(string $slug, string $category, int $limit = 3): array
{
$products = array_values(array_filter(
store_products(),
static fn(array $product): bool => $product['slug'] !== $slug && $product['category'] === $category
));
if (count($products) < $limit) {
$fallback = array_values(array_filter(
store_products(),
static fn(array $product): bool => $product['slug'] !== $slug && $product['category'] !== $category
));
$products = array_merge($products, $fallback);
}
return array_slice($products, 0, $limit);
}
function store_cart(): array
{
if (!isset($_SESSION[STORE_CART_KEY]) || !is_array($_SESSION[STORE_CART_KEY])) {
$_SESSION[STORE_CART_KEY] = [];
}
return $_SESSION[STORE_CART_KEY];
}
function store_set_cart(array $cart): void
{
$_SESSION[STORE_CART_KEY] = $cart;
}
function store_cart_count(): int
{
return array_sum(store_cart());
}
function store_add_to_cart(string $slug, int $quantity = 1): bool
{
$product = store_product($slug);
if (!$product) {
return false;
}
$quantity = max(1, min(20, $quantity));
$cart = store_cart();
$cart[$slug] = min(20, ((int)($cart[$slug] ?? 0)) + $quantity);
store_set_cart($cart);
return true;
}
function store_update_cart(array $quantities): void
{
$products = store_products();
$updated = [];
foreach ($quantities as $slug => $quantity) {
if (!isset($products[$slug])) {
continue;
}
$qty = (int)$quantity;
if ($qty > 0) {
$updated[$slug] = min(20, $qty);
}
}
store_set_cart($updated);
}
function store_remove_from_cart(string $slug): void
{
$cart = store_cart();
unset($cart[$slug]);
store_set_cart($cart);
}
function store_money(float $amount): string
{
return 'Rp ' . number_format($amount, 0, ',', '.');
}
function store_substr(string $value, int $start, int $length): string
{
if (function_exists('mb_substr')) {
return (string)mb_substr($value, $start, $length);
}
return (string)substr($value, $start, $length);
}
function store_strlen(string $value): int
{
if (function_exists('mb_strlen')) {
return (int)mb_strlen($value);
}
return strlen($value);
}
function store_lower(string $value): string
{
if (function_exists('mb_strtolower')) {
return (string)mb_strtolower($value);
}
return strtolower($value);
}
function store_cart_summary(): array
{
$products = store_products();
$lines = [];
foreach (store_cart() as $slug => $quantity) {
if (!isset($products[$slug])) {
continue;
}
$product = $products[$slug];
$lineTotal = ((float)$product['price']) * (int)$quantity;
$lines[] = [
'slug' => $slug,
'quantity' => (int)$quantity,
'product' => $product,
'line_total' => $lineTotal,
];
}
$subtotal = array_reduce(
$lines,
static fn(float $carry, array $line): float => $carry + (float)$line['line_total'],
0.0
);
$shippingFee = $subtotal <= 0 ? 0.0 : ($subtotal >= 250000 ? 0.0 : 18000.0);
return [
'lines' => $lines,
'subtotal' => $subtotal,
'shipping_fee' => $shippingFee,
'grand_total' => $subtotal + $shippingFee,
];
}
function store_payment_methods(): array
{
return [
'bank_transfer' => [
'label' => 'Transfer Bank',
'description' => 'Instruksi pembayaran tampil setelah checkout selesai.',
'instruction' => 'Transfer ke rekening demo bakery lalu simpan bukti bayar untuk verifikasi admin.',
],
'qris' => [
'label' => 'QRIS',
'description' => 'Praktis untuk mobile banking dan e-wallet.',
'instruction' => 'Gunakan QRIS demo yang diberikan admin setelah pesanan masuk ke sistem.',
],
'cod' => [
'label' => 'Bayar di Tempat',
'description' => 'Tersedia untuk pengantaran area terdekat.',
'instruction' => 'Siapkan nominal pas saat kurir mengantarkan pesanan ke alamat Anda.',
],
];
}
function store_status_steps(): array
{
return [
[
'value' => 'Menunggu Pembayaran',
'label' => 'Menunggu Pembayaran',
'description' => 'Pesanan tersimpan dan menunggu pembayaran pelanggan.',
],
[
'value' => 'Diproses',
'label' => 'Diproses',
'description' => 'Kitchen sedang menyiapkan item sesuai pesanan.',
],
[
'value' => 'Dikirim',
'label' => 'Dikirim',
'description' => 'Pesanan sedang diantar ke alamat tujuan.',
],
[
'value' => 'Selesai',
'label' => 'Selesai',
'description' => 'Pesanan telah diterima pelanggan.',
],
];
}
function store_status_index(string $status): int
{
foreach (store_status_steps() as $index => $step) {
if ($step['value'] === $status) {
return $index;
}
}
return -1;
}
function store_status_class(string $status): string
{
return match ($status) {
'Menunggu Pembayaran' => 'status-pill status-pill--pending',
'Diproses' => 'status-pill status-pill--processing',
'Dikirim' => 'status-pill status-pill--shipping',
'Selesai' => 'status-pill status-pill--done',
'Batal' => 'status-pill status-pill--cancelled',
default => 'status-pill',
};
}
function store_flash(string $type, string $message): void
{
$_SESSION[STORE_FLASH_KEY][] = [
'type' => $type,
'message' => $message,
];
}
function store_consume_flashes(): array
{
$flashes = $_SESSION[STORE_FLASH_KEY] ?? [];
unset($_SESSION[STORE_FLASH_KEY]);
return is_array($flashes) ? $flashes : [];
}
function store_input_class(array $errors, string $field): string
{
return isset($errors[$field]) ? ' is-invalid' : '';
}
function store_checkout_defaults(): array
{
return [
'customer_name' => '',
'email' => '',
'phone' => '',
'address' => '',
'note' => '',
'payment_method' => 'bank_transfer',
];
}
function store_sanitize_line(string $value, int $max = 255): string
{
$value = trim(preg_replace('/\s+/', ' ', $value) ?? '');
return store_substr($value, 0, $max);
}
function store_normalize_checkout_input(array $source): array
{
$defaults = store_checkout_defaults();
return [
'customer_name' => store_sanitize_line((string)($source['customer_name'] ?? $defaults['customer_name']), 120),
'email' => store_lower(trim((string)($source['email'] ?? $defaults['email']))),
'phone' => store_sanitize_line((string)($source['phone'] ?? $defaults['phone']), 40),
'address' => trim(store_substr((string)($source['address'] ?? $defaults['address']), 0, 500)),
'note' => trim(store_substr((string)($source['note'] ?? $defaults['note']), 0, 500)),
'payment_method' => store_sanitize_line((string)($source['payment_method'] ?? $defaults['payment_method']), 40),
];
}
function store_validate_checkout_input(array $data): array
{
$errors = [];
$methods = store_payment_methods();
if (store_strlen($data['customer_name']) < 3) {
$errors['customer_name'] = 'Nama minimal 3 karakter.';
}
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Masukkan email yang valid.';
}
$normalizedPhone = preg_replace('/[^0-9+]/', '', $data['phone']) ?? '';
if (strlen($normalizedPhone) < 8) {
$errors['phone'] = 'Nomor telepon minimal 8 digit.';
}
if (store_strlen($data['address']) < 10) {
$errors['address'] = 'Alamat minimal 10 karakter.';
}
if (!isset($methods[$data['payment_method']])) {
$errors['payment_method'] = 'Pilih metode pembayaran yang tersedia.';
}
return $errors;
}
function store_ensure_schema(): void
{
static $ready = false;
if ($ready) {
return;
}
db()->exec(
"CREATE TABLE IF NOT EXISTS orders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_number VARCHAR(30) NOT NULL UNIQUE,
customer_name VARCHAR(120) NOT NULL,
email VARCHAR(160) NOT NULL,
phone VARCHAR(40) NOT NULL,
address TEXT NOT NULL,
note TEXT NULL,
payment_method VARCHAR(40) NOT NULL,
status VARCHAR(40) NOT NULL DEFAULT 'Menunggu Pembayaran',
subtotal DECIMAL(12,2) NOT NULL DEFAULT 0,
shipping_fee DECIMAL(12,2) NOT NULL DEFAULT 0,
grand_total DECIMAL(12,2) NOT NULL DEFAULT 0,
items_json LONGTEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_order_number (order_number),
INDEX idx_email_created_at (email, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$ready = true;
}
function store_generate_order_number(): string
{
store_ensure_schema();
$pdo = db();
for ($attempt = 0; $attempt < 6; $attempt++) {
$candidate = 'SB' . date('ymd') . '-' . (string)random_int(1000, 9999);
$stmt = $pdo->prepare('SELECT id FROM orders WHERE order_number = :order_number LIMIT 1');
$stmt->execute(['order_number' => $candidate]);
if (!$stmt->fetch()) {
return $candidate;
}
}
return 'SB' . date('ymdHis');
}
function store_create_order(array $input): array
{
$data = store_normalize_checkout_input($input);
$errors = store_validate_checkout_input($data);
$summary = store_cart_summary();
if (empty($summary['lines'])) {
$errors['cart'] = 'Keranjang masih kosong. Tambahkan produk terlebih dahulu.';
}
if ($errors) {
return [
'success' => false,
'errors' => $errors,
'data' => $data,
];
}
store_ensure_schema();
$items = array_map(
static function (array $line): array {
return [
'slug' => $line['slug'],
'name' => $line['product']['name'],
'price' => (float)$line['product']['price'],
'quantity' => (int)$line['quantity'],
'line_total' => (float)$line['line_total'],
];
},
$summary['lines']
);
$orderNumber = store_generate_order_number();
$stmt = db()->prepare(
'INSERT INTO orders (
order_number,
customer_name,
email,
phone,
address,
note,
payment_method,
status,
subtotal,
shipping_fee,
grand_total,
items_json
) VALUES (
:order_number,
:customer_name,
:email,
:phone,
:address,
:note,
:payment_method,
:status,
:subtotal,
:shipping_fee,
:grand_total,
:items_json
)'
);
$stmt->bindValue(':order_number', $orderNumber);
$stmt->bindValue(':customer_name', $data['customer_name']);
$stmt->bindValue(':email', $data['email']);
$stmt->bindValue(':phone', $data['phone']);
$stmt->bindValue(':address', $data['address']);
$stmt->bindValue(':note', $data['note'] === '' ? null : $data['note']);
$stmt->bindValue(':payment_method', $data['payment_method']);
$stmt->bindValue(':status', 'Menunggu Pembayaran');
$stmt->bindValue(':subtotal', $summary['subtotal']);
$stmt->bindValue(':shipping_fee', $summary['shipping_fee']);
$stmt->bindValue(':grand_total', $summary['grand_total']);
$stmt->bindValue(':items_json', json_encode($items, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$stmt->execute();
$_SESSION[STORE_CART_KEY] = [];
$_SESSION[STORE_LAST_ORDER_KEY] = [
'order_number' => $orderNumber,
'email' => $data['email'],
];
return [
'success' => true,
'order_number' => $orderNumber,
'data' => $data,
];
}
function store_find_order(string $orderNumber, string $email = ''): ?array
{
$orderNumber = store_sanitize_line($orderNumber, 30);
$email = trim(store_lower($email));
if ($orderNumber === '') {
return null;
}
store_ensure_schema();
$sql = 'SELECT * FROM orders WHERE order_number = :order_number';
$params = ['order_number' => $orderNumber];
if ($email !== '') {
$sql .= ' AND email = :email';
$params['email'] = $email;
}
$sql .= ' LIMIT 1';
$stmt = db()->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue(':' . $key, $value);
}
$stmt->execute();
$order = $stmt->fetch();
if (!$order) {
return null;
}
$order['items'] = json_decode((string)$order['items_json'], true) ?: [];
$paymentMethods = store_payment_methods();
$order['payment_method_label'] = $paymentMethods[$order['payment_method']]['label'] ?? $order['payment_method'];
$order['payment_instruction'] = $paymentMethods[$order['payment_method']]['instruction'] ?? 'Tim admin akan menghubungi Anda untuk instruksi berikutnya.';
return $order;
}
function store_last_order_lookup(): array
{
$lookup = $_SESSION[STORE_LAST_ORDER_KEY] ?? [];
return is_array($lookup) ? $lookup : [];
}
function store_safe_redirect(string $target, string $fallback = 'cart.php'): string
{
$target = trim($target);
if ($target === '') {
return $fallback;
}
$parts = parse_url($target);
if ($parts === false || isset($parts['scheme']) || isset($parts['host'])) {
return $fallback;
}
$path = $parts['path'] ?? '';
if ($path === '' || str_contains($path, '..')) {
return $fallback;
}
$path = ltrim($path, '/');
if ($path === '') {
$path = $fallback;
}
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
return $path . $query;
}
function store_format_datetime(string $value): string
{
$timestamp = strtotime($value);
if ($timestamp === false) {
return $value;
}
$months = [
1 => 'Jan',
2 => 'Feb',
3 => 'Mar',
4 => 'Apr',
5 => 'Mei',
6 => 'Jun',
7 => 'Jul',
8 => 'Agu',
9 => 'Sep',
10 => 'Okt',
11 => 'Nov',
12 => 'Des',
];
$month = $months[(int)date('n', $timestamp)] ?? date('M', $timestamp);
return date('d', $timestamp) . ' ' . $month . ' ' . date('Y, H:i', $timestamp);
}
function store_nav_link(string $href, string $label, string $currentPath): string
{
$active = $currentPath === $href ? ' nav-link active' : ' nav-link';
return '<li class="nav-item"><a class="' . $active . '" href="' . h($href) . '">' . h($label) . '</a></li>';
}
function store_page_start(string $title, string $description = '', array $options = []): void
{
$projectName = app_env('PROJECT_NAME', store_brand());
$projectDescription = app_env('PROJECT_DESCRIPTION', 'Toko online bakery dengan katalog, keranjang, checkout, dan halaman status pesanan.');
$projectImageUrl = app_env('PROJECT_IMAGE_URL', '');
$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';
}
$robots = !empty($options['noindex']) ? '<meta name="robots" content="noindex, nofollow" />' : '';
$cartCount = store_cart_count();
echo '<!doctype html>';
echo '<html lang="id">';
echo '<head>';
echo '<meta charset="utf-8" />';
echo '<meta name="viewport" content="width=device-width, initial-scale=1" />';
echo '<meta name="theme-color" content="#2B2927" />';
echo '<title>' . h($fullTitle) . '</title>';
echo $robots;
if ($metaDescription !== '') {
echo '<meta name="description" content="' . h($metaDescription) . '" />';
echo '<meta property="og:title" content="' . h($fullTitle) . '" />';
echo '<meta property="og:description" content="' . h($metaDescription) . '" />';
echo '<meta name="twitter:title" content="' . h($fullTitle) . '" />';
echo '<meta property="twitter:description" content="' . h($metaDescription) . '" />';
}
if ($projectImageUrl !== '') {
echo '<meta property="og:image" content="' . h($projectImageUrl) . '" />';
echo '<meta property="twitter:image" content="' . h($projectImageUrl) . '" />';
}
echo '<link rel="preconnect" href="https://fonts.googleapis.com">';
echo '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>';
echo '<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">';
echo '<link rel="icon" type="image/svg+xml" href="assets/images/favicon.svg?v=' . h($faviconVersion) . '">';
echo '<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">';
echo '<link rel="stylesheet" href="assets/css/custom.css?v=' . h($cssVersion) . '">';
echo '</head>';
echo '<body>';
echo '<header class="site-header sticky-top">';
echo ' <nav class="navbar navbar-expand-lg navbar-light">';
echo ' <div class="container-xxl">';
echo ' <a class="navbar-brand brand-mark" href="index.php">';
echo ' <span class="brand-mark__title">' . h(store_brand()) . '</span>';
echo ' <span class="brand-mark__subtitle">Online bakery store</span>';
echo ' </a>';
echo ' <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#siteNav" aria-controls="siteNav" aria-expanded="false" aria-label="Toggle navigation">';
echo ' <span class="navbar-toggler-icon"></span>';
echo ' </button>';
echo ' <div class="collapse navbar-collapse" id="siteNav">';
echo ' <ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">';
echo store_nav_link('index.php', 'Katalog', $currentPath);
echo store_nav_link('cart.php', 'Keranjang', $currentPath);
echo store_nav_link('order_status.php', 'Status Pesanan', $currentPath);
echo ' <li class="nav-item ms-lg-2">';
echo ' <a class="btn btn-dark btn-sm btn-cart" href="cart.php">Keranjang <span class="badge rounded-pill text-bg-light ms-2">' . h((string)$cartCount) . '</span></a>';
echo ' </li>';
echo ' </ul>';
echo ' </div>';
echo ' </div>';
echo ' </nav>';
echo '</header>';
$flashes = store_consume_flashes();
if ($flashes) {
echo '<div class="toast-stack position-fixed top-0 end-0 p-3">';
foreach ($flashes as $index => $flash) {
$type = $flash['type'] ?? 'info';
$class = match ($type) {
'success' => 'toast-theme toast-theme--success',
'warning' => 'toast-theme toast-theme--warning',
'danger' => 'toast-theme toast-theme--danger',
default => 'toast-theme',
};
echo '<div class="toast ' . h($class) . '" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="3800">';
echo ' <div class="toast-body">' . h((string)($flash['message'] ?? '')) . '</div>';
echo '</div>';
}
echo '</div>';
}
echo '<main class="site-main">';
echo ' <div class="container-xxl py-4 py-lg-5">';
}
function store_page_end(): void
{
$jsVersion = file_exists(__DIR__ . '/assets/js/main.js') ? (string)filemtime(__DIR__ . '/assets/js/main.js') : (string)time();
echo ' </div>';
echo '</main>';
echo '<footer class="site-footer">';
echo ' <div class="container-xxl d-flex flex-column flex-lg-row justify-content-between gap-3 py-4">';
echo ' <div>';
echo ' <div class="footer-title">' . h(store_brand()) . '</div>';
echo ' <p class="footer-copy">MVP toko online dengan katalog, keranjang, checkout tersimpan, dan pelacakan status pesanan.</p>';
echo ' </div>';
echo ' <div class="footer-links d-flex flex-wrap gap-3">';
echo ' <a href="index.php#catalog">Lihat katalog</a>';
echo ' <a href="cart.php">Buka keranjang</a>';
echo ' <a href="order_status.php">Lacak pesanan</a>';
echo ' </div>';
echo ' </div>';
echo '</footer>';
echo '<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>';
echo '<script src="assets/js/main.js?v=' . h($jsVersion) . '"></script>';
echo '</body>';
echo '</html>';
}