839 lines
28 KiB
PHP
839 lines
28 KiB
PHP
<?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 8–10 porsi dan ideal untuk dessert keluarga.',
|
||
'lead_time' => 'Pre-order 1 hari',
|
||
'serves' => '8–10 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' => '6–8 potong',
|
||
'tone' => 'stone',
|
||
'visual_code' => 'MR',
|
||
'highlights' => ['6–8 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 2–3 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' => '4–5 porsi',
|
||
'tone' => 'taupe',
|
||
'visual_code' => 'FO',
|
||
'highlights' => ['Savory', '4–5 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' => '20–24 pcs',
|
||
'tone' => 'ink',
|
||
'visual_code' => 'AC',
|
||
'highlights' => ['20–24 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>';
|
||
}
|