From 80e38ef7df3924a48092d49a5aae1c7a2fdab9e8 Mon Sep 17 00:00:00 2001 From: Mark Pinkster Date: Sat, 10 Jan 2026 15:30:05 +0100 Subject: [PATCH] code refactor --- api.php | 559 ++----------------------------- api/actions/orders.php | 79 +++++ api/actions/postcode.php | 38 +++ api/actions/products.php | 57 ++++ api/bootstrap.php | 64 ++++ api/config.php | 54 +++ api/middleware/auth.php | 88 +++++ api/services/PostcodeService.php | 72 ++++ api/services/UpsellService.php | 263 +++++++++++++++ index.html | 228 +------------ js/app.js | 124 +++++++ js/components/cart.js | 102 ++++++ js/components/forms.js | 139 ++++++++ js/components/products.js | 175 ++++++++++ js/services/api.js | 79 +++++ 15 files changed, 1377 insertions(+), 744 deletions(-) create mode 100644 api/actions/orders.php create mode 100644 api/actions/postcode.php create mode 100644 api/actions/products.php create mode 100644 api/bootstrap.php create mode 100644 api/config.php create mode 100644 api/middleware/auth.php create mode 100644 api/services/PostcodeService.php create mode 100644 api/services/UpsellService.php create mode 100644 js/app.js create mode 100644 js/components/cart.js create mode 100644 js/components/forms.js create mode 100644 js/components/products.js create mode 100644 js/services/api.js diff --git a/api.php b/api.php index 0d5b513..01cd22d 100644 --- a/api.php +++ b/api.php @@ -1,555 +1,68 @@ $duration, - 'path' => '/', - 'secure' => isset($_SERVER['HTTPS']), - 'httponly' => true, - 'samesite' => 'Lax' -]); +// Load configuration (database, WooCommerce) +require_once __DIR__ . '/api/config.php'; -if (session_status() === PHP_SESSION_NONE) { - session_start(); -} +// Load authentication middleware +require_once __DIR__ . '/api/middleware/auth.php'; -// 2. RECOVERY LOGIC (Check cookie before WP loads) -if (!isset($_SESSION['user']) && isset($_COOKIE['telvero_remember'])) { - $decoded = json_decode(base64_decode($_COOKIE['telvero_remember']), true); - if ($decoded && $decoded['expires'] > time()) { - $_SESSION['user'] = $decoded['user']; - $_SESSION['full_name'] = $decoded['full_name']; - } -} - -// 3. CAPTURE DATA FOR WP PROTECTION -$cap_user = $_SESSION['user'] ?? null; -$cap_name = $_SESSION['full_name'] ?? null; - -// 4. LOAD WORDPRESS -$wp_load = __DIR__ . '/wp-load.php'; -if (!file_exists($wp_load)) { $wp_load = dirname(__DIR__) . '/wp-load.php'; } -if (file_exists($wp_load)) { require_once $wp_load; } - -// 5. RESTORE DATA -if ($cap_user && !isset($_SESSION['user'])) { - $_SESSION['user'] = $cap_user; - $_SESSION['full_name'] = $cap_name; -} - -// 6. REST OF THE BOOTSTRAP -require __DIR__ . '/vendor/autoload.php'; -if (file_exists(__DIR__ . '/.env')) { - $dotenv = Dotenv\Dotenv::createImmutable(__DIR__); - $dotenv->load(); -} - -use Automattic\WooCommerce\Client; +// Set JSON content type for all responses header('Content-Type: application/json'); -$db = new mysqli($_ENV['DB_HOST'], $_ENV['DB_USER'], $_ENV['DB_PASS'], $_ENV['DB_NAME']); -if ($db->connect_error) { die(json_encode(['error' => 'Database connectie mislukt'])); } - +// Get the requested action $action = $_GET['action'] ?? ''; -// 6. LOGIN ACTION +// Route: Login (no auth required) if ($action === 'login') { - $input = json_decode(file_get_contents('php://input'), true); - $username = $input['username'] ?? ''; - $stmt = $db->prepare("SELECT password, full_name FROM sales_users WHERE username = ?"); - $stmt->bind_param("s", $username); - $stmt->execute(); - $res = $stmt->get_result()->fetch_assoc(); - - if ($res && password_verify($input['password'], $res['password'])) { - $_SESSION['user'] = $username; - $_SESSION['full_name'] = $res['full_name']; - - // Recovery cookie payload - $cookie_payload = base64_encode(json_encode([ - 'user' => $username, - 'full_name' => $res['full_name'], - 'expires' => $midnight_timestamp - ])); - - // Use the explicit $midnight_timestamp variable - setcookie('telvero_remember', $cookie_payload, $midnight_timestamp, '/', '', isset($_SERVER['HTTPS']), true); - - echo json_encode(['success' => true, 'user' => $res['full_name']]); - } else { - http_response_code(401); echo json_encode(['error' => 'Login mislukt']); - } + $db = getDatabase(); + handleLogin($db); exit; } -// 7. SESSION CHECK (lightweight, before security gate) +// Route: Check Session (no auth required) if ($action === 'check_session') { - if (isset($_SESSION['user'])) { - echo json_encode(['authenticated' => true, 'user' => $_SESSION['full_name'] ?? $_SESSION['user']]); - } else { - echo json_encode(['authenticated' => false]); - } + handleCheckSession(); exit; } -// 8. SECURITY GATE (The ONLY one needed) -if (!isset($_SESSION['user'])) { - http_response_code(403); - echo json_encode(['error' => 'Not authenticated']); +// Security Gate - All routes below require authentication +requireAuth(); + +// Route: Logout +if ($action === 'logout') { + handleLogout(); exit; } -// 8. LOGOUT -if ($action === 'logout') { - session_destroy(); - setcookie('telvero_remember', '', time() - 3600, '/'); - echo json_encode(['success' => true]); - exit; -} - -$woocommerce = new Client($_ENV['WC_URL'], $_ENV['WC_KEY'], $_ENV['WC_SECRET'], ['version' => 'wc/v3', 'verify_ssl' => false]); - -function ss_get_upsellwp_recommended_product_ids($trigger_product_id) { - if (!function_exists('get_option')) { - return []; - } - - global $wpdb; - - $trigger_product_id = (int) $trigger_product_id; - if ($trigger_product_id <= 0) return []; - - // 1) Vind "campaign posts" waar in meta_value ergens dit product_id voorkomt. - // We weten niet exact welke meta_key UpsellWP gebruikt, dus zoeken we breed op meta_key LIKE '%upsellwp%'. - // Daarnaast zoeken we op bekende patronen in serialized/json/plain. - $like_json = '%' . $wpdb->esc_like('"' . $trigger_product_id . '"') . '%'; - $like_plain = '%' . $wpdb->esc_like((string) $trigger_product_id) . '%'; - $like_serial = '%' . $wpdb->esc_like('i:' . $trigger_product_id . ';') . '%'; - - $campaign_post_ids = $wpdb->get_col( - $wpdb->prepare( - " - SELECT DISTINCT post_id - FROM {$wpdb->postmeta} - WHERE meta_key LIKE %s - AND ( - meta_value LIKE %s - OR meta_value LIKE %s - OR meta_value LIKE %s - ) - ", - '%upsellwp%', - $like_json, - $like_serial, - $like_plain - ) - ); - - if (empty($campaign_post_ids)) { - return []; - } - - // 2) Verzamel uit die campaigns alle product IDs die genoemd worden (offers / bundles / buy-more-save-more tiers). - $recommended = []; - - foreach ($campaign_post_ids as $cid) { - $cid = (int) $cid; - - $rows = $wpdb->get_results( - $wpdb->prepare( - " - SELECT meta_key, meta_value - FROM {$wpdb->postmeta} - WHERE post_id = %d - ", - $cid - ) - ); - - foreach ($rows as $r) { - $key = (string) $r->meta_key; - - // Kleine bias naar keys die logisch product/offers bevatten - // maar alsnog niet te streng, want add-ons gebruiken soms andere keys. - if (!preg_match('/product|products|offer|offers|bundle|bmsm|tier|rule|condition|trigger|cart|recommend/i', $key) - && stripos($key, 'upsellwp') === false - ) { - continue; - } - - $val = (string) $r->meta_value; - - // Extract alle integers uit json/serialized/plain - if (preg_match_all('/\b\d+\b/', $val, $m)) { - foreach ($m[0] as $id) { - $id = (int) $id; - if ($id > 0) $recommended[] = $id; - } - } - } - } - - // 3) Opschonen: unieke IDs, trigger zelf eruit, en alleen echte WC producten (best effort). - $recommended = array_values(array_unique(array_filter($recommended))); - $recommended = array_values(array_diff($recommended, [$trigger_product_id])); - - // Filter op bestaande producten (scheelt false positives zoals campaign IDs) - $recommended = array_values(array_filter($recommended, function($id){ - return get_post_type($id) === 'product' || get_post_type($id) === 'product_variation'; - })); - - return $recommended; -} - -/** - * Build a map: product_id => related product ids from CUW (UpsellWP) campaigns. - * Uses {$wpdb->prefix}cuw_campaigns table (as in your screenshot). - */ -function ss_cuw_build_product_map() { - if (!function_exists('get_post_type')) return []; - - global $wpdb; - - $table = $wpdb->prefix . 'cuw_campaigns'; - // Safety: table might not exist - $exists = $wpdb->get_var( $wpdb->prepare("SHOW TABLES LIKE %s", $table) ); - if ($exists !== $table) return []; - - $rows = $wpdb->get_results(" - SELECT id, type, filters, data - FROM {$table} - WHERE enabled = 1 - ORDER BY priority DESC, id DESC - "); - - if (empty($rows)) return []; - - $map = []; - - foreach ($rows as $row) { - $filters = json_decode((string) $row->filters, true); - if (!is_array($filters)) continue; - - // Extract product lists from filters: - // Example (your screenshot): - // {"relation":"or","176...":{"type":"products","method":"in_list","values":["1831"]}} - $filterProductLists = []; - - foreach ($filters as $k => $v) { - if ($k === 'relation') continue; - if (!is_array($v)) continue; - - $type = $v['type'] ?? ''; - $method = $v['method'] ?? ''; - $values = $v['values'] ?? []; - - if ($type !== 'products') continue; - if (!in_array($method, ['in_list', 'in', 'include', 'equals'], true)) continue; - if (!is_array($values)) continue; - - $ids = array_values(array_unique(array_map('intval', $values))); - $ids = array_values(array_filter($ids)); - - if (!empty($ids)) { - $filterProductLists[] = $ids; - } - } - - if (empty($filterProductLists)) continue; - - // For each list: if a product is in that list, relate it to the other products in that list - foreach ($filterProductLists as $list) { - foreach ($list as $pid) { - $related = array_values(array_diff($list, [$pid])); - if (empty($related)) continue; - - // Keep only real Woo products - $related = array_values(array_filter($related, function($id){ - $pt = get_post_type($id); - return $pt === 'product' || $pt === 'product_variation'; - })); - - if (empty($related)) continue; - - if (!isset($map[$pid])) $map[$pid] = []; - $map[$pid] = array_values(array_unique(array_merge($map[$pid], $related))); - } - } - } - - return $map; -} - -function ss_cuw_build_deals_map() { - global $wpdb; - - $table = $wpdb->prefix . 'cuw_campaigns'; - - $exists = $wpdb->get_var( $wpdb->prepare("SHOW TABLES LIKE %s", $table) ); - if ($exists !== $table) return []; - - $rows = $wpdb->get_results(" - SELECT id, type, title, priority, filters, data - FROM {$table} - WHERE enabled = 1 - ORDER BY priority DESC, id DESC - "); - - if (empty($rows)) return []; - - $map = []; - - foreach ($rows as $row) { - $type = (string) $row->type; - if ($type !== 'buy_more_save_more') { - continue; - } - - $filters = json_decode((string) $row->filters, true); - $data = json_decode((string) $row->data, true); - - if (!is_array($filters) || !is_array($data)) continue; - - // 1) Product IDs uit filters halen (zoals jouw screenshot: type=products, method=in_list, values=[...]) - $product_ids = []; - - foreach ($filters as $k => $v) { - if ($k === 'relation') continue; - if (!is_array($v)) continue; - - $f_type = $v['type'] ?? ''; - $f_method = $v['method'] ?? ''; - $values = $v['values'] ?? []; - - if ($f_type !== 'products') continue; - if (!in_array($f_method, ['in_list', 'in', 'include', 'equals'], true)) continue; - if (!is_array($values)) continue; - - foreach ($values as $pid) { - $pid = (int) $pid; - if ($pid > 0) $product_ids[] = $pid; - } - } - - $product_ids = array_values(array_unique(array_filter($product_ids))); - if (empty($product_ids)) continue; - - // 2) Deal details uit data halen - $cta_text = $data['template']['cta_text'] ?? ''; - $display_location = $data['display_location'] ?? ($data['template']['display_location'] ?? ''); - - $discounts = $data['discounts'] ?? []; - if (!is_array($discounts) || empty($discounts)) continue; - - foreach ($discounts as $disc) { - if (!is_array($disc)) continue; - - $qty = isset($disc['qty']) ? (int) $disc['qty'] : 0; - $dType = isset($disc['type']) ? (string) $disc['type'] : ''; - $value = isset($disc['value']) ? (float) $disc['value'] : 0.0; - - if ($qty <= 0 || $dType === '' || $value <= 0) continue; - - $deal = [ - 'campaign_id' => (int) $row->id, - 'campaign_type' => $type, - 'title' => (string) $row->title, - 'priority' => (int) $row->priority, - 'qty' => $qty, - 'discount_type' => $dType, // fixed_price - 'value' => $value, // 10 (korting op 2e in jouw geval) - 'cta_text' => (string) $cta_text, - 'display_location' => (string) $display_location, - ]; - - foreach ($product_ids as $pid) { - if (!isset($map[$pid])) $map[$pid] = []; - $map[$pid][] = $deal; - } - } - } - - return $map; -} - - -// --- POSTCODE CHECK --- +// Route: Postcode Check if ($action === 'postcode_check') { - try { - $postcode = str_replace(' ', '', $_GET['postcode'] ?? ''); - $number = $_GET['number'] ?? ''; - - if (empty($postcode) || empty($number)) { - http_response_code(400); - echo json_encode(['error' => 'Postcode en huisnummer zijn verplicht']); - exit; - } - - $url = "https://postcode.tech/api/v1/postcode?postcode={$postcode}&number={$number}"; - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer " . $_ENV['POSTCODE_TECH_KEY']]); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $curlError = curl_error($ch); - curl_close($ch); - - if ($curlError || $httpCode >= 500 ) { - http_response_code(503); - echo json_encode(['error' => 'Postcode service niet bereikbaar, vul straat en woonplaats zelf in', 'details' => $curlError]); - exit; - } - - if ($httpCode >= 400) { - http_response_code($httpCode); - $decoded = json_decode($response, true); - if ($decoded && isset($decoded['error'])) { - echo json_encode(['error' => $decoded['error']]); - } else { - echo json_encode(['error' => 'Postcode niet gevonden of ongeldige invoer, vul straat en woonplaats zelf in']); - } - exit; - } - - echo $response; - } catch (Exception $e) { - http_response_code(500); - echo json_encode(['error' => 'Er is een fout opgetreden bij het ophalen van adresgegevens, vul straat en woonplaats zelf in']); - } + require_once __DIR__ . '/api/actions/postcode.php'; + handlePostcodeCheck(); exit; } -// --- GET PRODUCTS --- -// --- GET PRODUCTS --- +// Route: Get Products if ($action === 'get_products') { - try { - $products = $woocommerce->get('products', ['status' => 'publish', 'per_page' => 100]); - $cuw_map = ss_cuw_build_product_map(); - - $enriched = []; - - foreach ($products as $product) { - - // variations (bestaand) - $variation_details = ($product->type === 'variable') - ? (array) $woocommerce->get("products/{$product->id}/variations", ['per_page' => 50]) - : []; - - // wpupsell IDs uit meta_data halen (heuristisch, werkt bij veel plugins) - $wpupsell_ids = []; - if (!empty($product->meta_data) && is_array($product->meta_data)) { - foreach ($product->meta_data as $md) { - $key = isset($md->key) ? (string) $md->key : ''; - if ($key === '' || !preg_match('/wp\s*upsell|wpupsell|upsell\s*campaign/i', $key)) { - continue; - } - - $val = $md->value ?? null; - - // value kan array/object/string zijn - if (is_array($val)) { - $flat = json_encode($val); - if (preg_match_all('/\b\d+\b/', $flat, $m)) { - foreach ($m[0] as $id) $wpupsell_ids[] = (int) $id; - } - } elseif (is_object($val)) { - $flat = json_encode($val); - if (preg_match_all('/\b\d+\b/', $flat, $m)) { - foreach ($m[0] as $id) $wpupsell_ids[] = (int) $id; - } - } else { - $flat = (string) $val; - if (preg_match_all('/\b\d+\b/', $flat, $m)) { - foreach ($m[0] as $id) $wpupsell_ids[] = (int) $id; - } - } - } - } - - // upsell + cross-sell + wpupsell bundelen - $upsell_ids = !empty($product->upsell_ids) ? array_map('intval', (array) $product->upsell_ids) : []; - $cross_sell_ids = !empty($product->cross_sell_ids) ? array_map('intval', (array) $product->cross_sell_ids) : []; - - $cuw_ids = $cuw_map[(int)$product->id] ?? []; - - $recommended_ids = array_values(array_unique(array_filter(array_merge( - $upsell_ids, - $cross_sell_ids, - $cuw_ids - )))); - - // product naar array + velden toevoegen - $p = (array) $product; - $p['variation_details'] = $variation_details; - - // expliciet meesturen voor frontend - $p['cuw_ids'] = $cuw_ids; - $p['recommended_ids'] = $recommended_ids; - - $enriched[] = $p; - } - - echo json_encode($enriched); - } catch (Exception $e) { - echo json_encode([]); - } + require_once __DIR__ . '/api/actions/products.php'; + handleGetProducts(); exit; } - -// --- CREATE ORDER --- +// Route: Create Order if ($action === 'create_order') { - $input = json_decode(file_get_contents('php://input'), true); - try { - $email = $input['billing']['email']; - $mediacode = $input['mediacode_internal'] ?? 'Geen'; - - $input['payment_method'] = 'cod'; - $input['payment_method_title'] = 'Sales Panel Order'; - $input['status'] = 'on-hold'; - - $existing = $woocommerce->get('customers', ['email' => $email]); - $input['customer_id'] = !empty($existing) ? $existing[0]->id : 0; - - $shipping_incl = (float)($input['shipping_total'] ?? 0); - if ($shipping_incl > 0) { - $input['shipping_lines'] = [[ - 'method_id' => 'flat_rate', - 'method_title' => 'Verzendkosten', - 'total' => number_format($shipping_incl / 1.21, 4, '.', '') - ]]; - } - - $input['customer_note'] = "Agent: {$_SESSION['user']} | Mediacode: $mediacode"; - - // --- ATTRIBUTION DATA (GEFIXTE SYNTAX) --- - $input['meta_data'][] = ['key' => '_wc_order_attribution_source_type', 'value' => 'utm']; - $input['meta_data'][] = ['key' => '_wc_order_attribution_utm_source', 'value' => 'SalesPanel']; - $input['meta_data'][] = ['key' => '_wc_order_attribution_utm_campaign', 'value' => $mediacode]; - $input['meta_data'][] = ['key' => 'Mediacode', 'value' => $mediacode]; - $input['meta_data'][] = ['key' => 'Bron', 'value' => 'SalesPanel']; - - $order = $woocommerce->post('orders', $input); - - $action_type = 'order_created'; - $log_stmt = $db->prepare("INSERT INTO sales_logs (username, action_type, order_id, amount, mediacode, customer_email, created_at) VALUES (?, ?, ?, ?, ?, ?, NOW())"); - $log_stmt->bind_param("ssidss", $_SESSION['user'], $action_type, $order->id, $order->total, $mediacode, $email); - $log_stmt->execute(); - - echo json_encode(['success' => true, 'order_id' => $order->id, 'total' => $order->total]); - } catch (Exception $e) { - http_response_code(422); echo json_encode(['error' => $e->getMessage()]); - } + require_once __DIR__ . '/api/actions/orders.php'; + handleCreateOrder(); exit; } -if ($action === 'logout') { session_destroy(); echo json_encode(['success' => true]); exit; } \ No newline at end of file +// Unknown action +http_response_code(400); +echo json_encode(['error' => 'Unknown action']); diff --git a/api/actions/orders.php b/api/actions/orders.php new file mode 100644 index 0000000..13b0833 --- /dev/null +++ b/api/actions/orders.php @@ -0,0 +1,79 @@ +get('customers', ['email' => $email]); + $input['customer_id'] = !empty($existing) ? $existing[0]->id : 0; + + // Handle shipping costs + $shipping_incl = (float)($input['shipping_total'] ?? 0); + if ($shipping_incl > 0) { + $input['shipping_lines'] = [[ + 'method_id' => 'flat_rate', + 'method_title' => 'Verzendkosten', + 'total' => number_format($shipping_incl / 1.21, 4, '.', '') + ]]; + } + + // Add customer note with agent info + $input['customer_note'] = "Agent: {$_SESSION['user']} | Mediacode: $mediacode"; + + // Add attribution metadata + $input['meta_data'][] = ['key' => '_wc_order_attribution_source_type', 'value' => 'utm']; + $input['meta_data'][] = ['key' => '_wc_order_attribution_utm_source', 'value' => 'SalesPanel']; + $input['meta_data'][] = ['key' => '_wc_order_attribution_utm_campaign', 'value' => $mediacode]; + $input['meta_data'][] = ['key' => 'Mediacode', 'value' => $mediacode]; + $input['meta_data'][] = ['key' => 'Bron', 'value' => 'SalesPanel']; + + // Create the order + $order = $woocommerce->post('orders', $input); + + // Log the order + $action_type = 'order_created'; + $log_stmt = $db->prepare( + "INSERT INTO sales_logs (username, action_type, order_id, amount, mediacode, customer_email, created_at) + VALUES (?, ?, ?, ?, ?, ?, NOW())" + ); + $log_stmt->bind_param( + "ssidss", + $_SESSION['user'], + $action_type, + $order->id, + $order->total, + $mediacode, + $email + ); + $log_stmt->execute(); + + echo json_encode([ + 'success' => true, + 'order_id' => $order->id, + 'total' => $order->total + ]); + } catch (Exception $e) { + http_response_code(422); + echo json_encode(['error' => $e->getMessage()]); + } +} diff --git a/api/actions/postcode.php b/api/actions/postcode.php new file mode 100644 index 0000000..184102d --- /dev/null +++ b/api/actions/postcode.php @@ -0,0 +1,38 @@ + $result['error']]; + if (isset($result['details'])) { + $response['details'] = $result['details']; + } + echo json_encode($response); + return; + } + + echo json_encode($result['data']); + } catch (Exception $e) { + http_response_code(500); + echo json_encode([ + 'error' => 'Er is een fout opgetreden bij het ophalen van adresgegevens, vul straat en woonplaats zelf in' + ]); + } +} diff --git a/api/actions/products.php b/api/actions/products.php new file mode 100644 index 0000000..9da7d8c --- /dev/null +++ b/api/actions/products.php @@ -0,0 +1,57 @@ +get('products', ['status' => 'publish', 'per_page' => 100]); + $cuw_map = UpsellService::buildProductMap(); + + $enriched = []; + + foreach ($products as $product) { + // Get variations for variable products + $variation_details = ($product->type === 'variable') + ? (array) $woocommerce->get("products/{$product->id}/variations", ['per_page' => 50]) + : []; + + // Combine upsell + cross-sell + CUW IDs + $upsell_ids = !empty($product->upsell_ids) + ? array_map('intval', (array) $product->upsell_ids) + : []; + $cross_sell_ids = !empty($product->cross_sell_ids) + ? array_map('intval', (array) $product->cross_sell_ids) + : []; + + $cuw_ids = $cuw_map[(int)$product->id] ?? []; + + $recommended_ids = array_values(array_unique(array_filter(array_merge( + $upsell_ids, + $cross_sell_ids, + $cuw_ids + )))); + + // Convert product to array and add fields + $p = (array) $product; + $p['variation_details'] = $variation_details; + $p['cuw_ids'] = $cuw_ids; + $p['recommended_ids'] = $recommended_ids; + + $enriched[] = $p; + } + + echo json_encode($enriched); + } catch (Exception $e) { + echo json_encode([]); + } +} diff --git a/api/bootstrap.php b/api/bootstrap.php new file mode 100644 index 0000000..156527c --- /dev/null +++ b/api/bootstrap.php @@ -0,0 +1,64 @@ + $duration, + 'path' => '/', + 'secure' => isset($_SERVER['HTTPS']), + 'httponly' => true, + 'samesite' => 'Lax' +]); + +if (session_status() === PHP_SESSION_NONE) { + session_start(); +} + +// 2. RECOVERY LOGIC (Check cookie before WP loads) +if (!isset($_SESSION['user']) && isset($_COOKIE['telvero_remember'])) { + $decoded = json_decode(base64_decode($_COOKIE['telvero_remember']), true); + if ($decoded && $decoded['expires'] > time()) { + $_SESSION['user'] = $decoded['user']; + $_SESSION['full_name'] = $decoded['full_name']; + } +} + +// 3. CAPTURE DATA FOR WP PROTECTION +$cap_user = $_SESSION['user'] ?? null; +$cap_name = $_SESSION['full_name'] ?? null; + +// 4. LOAD WORDPRESS +$wp_load = dirname(__DIR__) . '/wp-load.php'; +if (!file_exists($wp_load)) { + $wp_load = dirname(dirname(__DIR__)) . '/wp-load.php'; +} +if (file_exists($wp_load)) { + require_once $wp_load; +} + +// 5. RESTORE DATA +if ($cap_user && !isset($_SESSION['user'])) { + $_SESSION['user'] = $cap_user; + $_SESSION['full_name'] = $cap_name; +} + +// 6. LOAD COMPOSER AUTOLOAD +require dirname(__DIR__) . '/vendor/autoload.php'; + +// 7. LOAD ENVIRONMENT VARIABLES +if (file_exists(dirname(__DIR__) . '/.env')) { + $dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__)); + $dotenv->load(); +} + +// Export midnight timestamp for use in other files +define('MIDNIGHT_TIMESTAMP', $midnight_timestamp); diff --git a/api/config.php b/api/config.php new file mode 100644 index 0000000..f274ebe --- /dev/null +++ b/api/config.php @@ -0,0 +1,54 @@ +connect_error) { + header('Content-Type: application/json'); + http_response_code(500); + die(json_encode(['error' => 'Database connectie mislukt'])); + } + + $db->set_charset('utf8mb4'); + } + + return $db; +} + +/** + * Get WooCommerce client + * @return Client + */ +function getWooCommerce(): Client +{ + static $woocommerce = null; + + if ($woocommerce === null) { + $woocommerce = new Client( + $_ENV['WC_URL'], + $_ENV['WC_KEY'], + $_ENV['WC_SECRET'], + ['version' => 'wc/v3', 'verify_ssl' => false] + ); + } + + return $woocommerce; +} diff --git a/api/middleware/auth.php b/api/middleware/auth.php new file mode 100644 index 0000000..4243122 --- /dev/null +++ b/api/middleware/auth.php @@ -0,0 +1,88 @@ +prepare("SELECT password, full_name FROM sales_users WHERE username = ?"); + $stmt->bind_param("s", $username); + $stmt->execute(); + $res = $stmt->get_result()->fetch_assoc(); + + if ($res && password_verify($input['password'], $res['password'])) { + $_SESSION['user'] = $username; + $_SESSION['full_name'] = $res['full_name']; + + // Recovery cookie payload + $cookie_payload = base64_encode(json_encode([ + 'user' => $username, + 'full_name' => $res['full_name'], + 'expires' => MIDNIGHT_TIMESTAMP + ])); + + setcookie('telvero_remember', $cookie_payload, MIDNIGHT_TIMESTAMP, '/', '', isset($_SERVER['HTTPS']), true); + + echo json_encode(['success' => true, 'user' => $res['full_name']]); + } else { + http_response_code(401); + echo json_encode(['error' => 'Login mislukt']); + } +} + +/** + * Handle session check action + * @return void + */ +function handleCheckSession(): void +{ + if (isset($_SESSION['user'])) { + echo json_encode([ + 'authenticated' => true, + 'user' => $_SESSION['full_name'] ?? $_SESSION['user'] + ]); + } else { + echo json_encode(['authenticated' => false]); + } +} + +/** + * Handle logout action + * @return void + */ +function handleLogout(): void +{ + session_destroy(); + setcookie('telvero_remember', '', time() - 3600, '/'); + echo json_encode(['success' => true]); +} + +/** + * Check if user is authenticated + * @return bool + */ +function isAuthenticated(): bool +{ + return isset($_SESSION['user']); +} + +/** + * Require authentication - exits if not authenticated + * @return void + */ +function requireAuth(): void +{ + if (!isAuthenticated()) { + http_response_code(403); + echo json_encode(['error' => 'Not authenticated']); + exit; + } +} diff --git a/api/services/PostcodeService.php b/api/services/PostcodeService.php new file mode 100644 index 0000000..69aa91d --- /dev/null +++ b/api/services/PostcodeService.php @@ -0,0 +1,72 @@ + false, + 'error' => 'Postcode en huisnummer zijn verplicht', + 'http_code' => 400 + ]; + } + + $url = "https://postcode.tech/api/v1/postcode?postcode={$postcode}&number={$number}"; + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + "Authorization: Bearer " . $_ENV['POSTCODE_TECH_KEY'] + ]); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($curlError || $httpCode >= 500) { + return [ + 'success' => false, + 'error' => 'Postcode service niet bereikbaar, vul straat en woonplaats zelf in', + 'details' => $curlError, + 'http_code' => 503 + ]; + } + + if ($httpCode >= 400) { + $decoded = json_decode($response, true); + $errorMessage = ($decoded && isset($decoded['error'])) + ? $decoded['error'] + : 'Postcode niet gevonden of ongeldige invoer, vul straat en woonplaats zelf in'; + + return [ + 'success' => false, + 'error' => $errorMessage, + 'http_code' => $httpCode + ]; + } + + $data = json_decode($response, true); + + return [ + 'success' => true, + 'data' => $data, + 'http_code' => 200 + ]; + } +} diff --git a/api/services/UpsellService.php b/api/services/UpsellService.php new file mode 100644 index 0000000..a46ecce --- /dev/null +++ b/api/services/UpsellService.php @@ -0,0 +1,263 @@ + related product ids from CUW (UpsellWP) campaigns. + * Uses {$wpdb->prefix}cuw_campaigns table. + * + * @return array + */ + public static function buildProductMap(): array + { + if (!function_exists('get_post_type')) { + return []; + } + + global $wpdb; + + $table = $wpdb->prefix . 'cuw_campaigns'; + + // Safety: table might not exist + $exists = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)); + if ($exists !== $table) { + return []; + } + + $rows = $wpdb->get_results(" + SELECT id, type, filters, data + FROM {$table} + WHERE enabled = 1 + ORDER BY priority DESC, id DESC + "); + + if (empty($rows)) { + return []; + } + + $map = []; + + foreach ($rows as $row) { + $filters = json_decode((string) $row->filters, true); + if (!is_array($filters)) { + continue; + } + + $filterProductLists = self::extractProductListsFromFilters($filters); + + if (empty($filterProductLists)) { + continue; + } + + // For each list: if a product is in that list, relate it to the other products in that list + foreach ($filterProductLists as $list) { + foreach ($list as $pid) { + $related = array_values(array_diff($list, [$pid])); + if (empty($related)) { + continue; + } + + // Keep only real Woo products + $related = array_values(array_filter($related, function ($id) { + $pt = get_post_type($id); + return $pt === 'product' || $pt === 'product_variation'; + })); + + if (empty($related)) { + continue; + } + + if (!isset($map[$pid])) { + $map[$pid] = []; + } + $map[$pid] = array_values(array_unique(array_merge($map[$pid], $related))); + } + } + } + + return $map; + } + + /** + * Build deals map for buy_more_save_more campaigns + * + * @return array + */ + public static function buildDealsMap(): array + { + global $wpdb; + + $table = $wpdb->prefix . 'cuw_campaigns'; + + $exists = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)); + if ($exists !== $table) { + return []; + } + + $rows = $wpdb->get_results(" + SELECT id, type, title, priority, filters, data + FROM {$table} + WHERE enabled = 1 + ORDER BY priority DESC, id DESC + "); + + if (empty($rows)) { + return []; + } + + $map = []; + + foreach ($rows as $row) { + $type = (string) $row->type; + if ($type !== 'buy_more_save_more') { + continue; + } + + $filters = json_decode((string) $row->filters, true); + $data = json_decode((string) $row->data, true); + + if (!is_array($filters) || !is_array($data)) { + continue; + } + + $product_ids = self::extractProductIdsFromFilters($filters); + + if (empty($product_ids)) { + continue; + } + + // Deal details from data + $cta_text = $data['template']['cta_text'] ?? ''; + $display_location = $data['display_location'] ?? ($data['template']['display_location'] ?? ''); + + $discounts = $data['discounts'] ?? []; + if (!is_array($discounts) || empty($discounts)) { + continue; + } + + foreach ($discounts as $disc) { + if (!is_array($disc)) { + continue; + } + + $qty = isset($disc['qty']) ? (int) $disc['qty'] : 0; + $dType = isset($disc['type']) ? (string) $disc['type'] : ''; + $value = isset($disc['value']) ? (float) $disc['value'] : 0.0; + + if ($qty <= 0 || $dType === '' || $value <= 0) { + continue; + } + + $deal = [ + 'campaign_id' => (int) $row->id, + 'campaign_type' => $type, + 'title' => (string) $row->title, + 'priority' => (int) $row->priority, + 'qty' => $qty, + 'discount_type' => $dType, + 'value' => $value, + 'cta_text' => (string) $cta_text, + 'display_location' => (string) $display_location, + ]; + + foreach ($product_ids as $pid) { + if (!isset($map[$pid])) { + $map[$pid] = []; + } + $map[$pid][] = $deal; + } + } + } + + return $map; + } + + /** + * Extract product lists from campaign filters + * + * @param array $filters + * @return array + */ + private static function extractProductListsFromFilters(array $filters): array + { + $filterProductLists = []; + + foreach ($filters as $k => $v) { + if ($k === 'relation') { + continue; + } + if (!is_array($v)) { + continue; + } + + $type = $v['type'] ?? ''; + $method = $v['method'] ?? ''; + $values = $v['values'] ?? []; + + if ($type !== 'products') { + continue; + } + if (!in_array($method, ['in_list', 'in', 'include', 'equals'], true)) { + continue; + } + if (!is_array($values)) { + continue; + } + + $ids = array_values(array_unique(array_map('intval', $values))); + $ids = array_values(array_filter($ids)); + + if (!empty($ids)) { + $filterProductLists[] = $ids; + } + } + + return $filterProductLists; + } + + /** + * Extract product IDs from campaign filters + * + * @param array $filters + * @return int[] + */ + private static function extractProductIdsFromFilters(array $filters): array + { + $product_ids = []; + + foreach ($filters as $k => $v) { + if ($k === 'relation') { + continue; + } + if (!is_array($v)) { + continue; + } + + $f_type = $v['type'] ?? ''; + $f_method = $v['method'] ?? ''; + $values = $v['values'] ?? []; + + if ($f_type !== 'products') { + continue; + } + if (!in_array($f_method, ['in_list', 'in', 'include', 'equals'], true)) { + continue; + } + if (!is_array($values)) { + continue; + } + + foreach ($values as $pid) { + $pid = (int) $pid; + if ($pid > 0) { + $product_ids[] = $pid; + } + } + } + + return array_values(array_unique(array_filter($product_ids))); + } +} diff --git a/index.html b/index.html index eb4b7ee..75d3689 100644 --- a/index.html +++ b/index.html @@ -20,6 +20,12 @@ border-radius: 10px; } + + + + + + @@ -305,226 +311,6 @@ - - - \ No newline at end of file + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..dbbfe2c --- /dev/null +++ b/js/app.js @@ -0,0 +1,124 @@ +/** + * Main Application - Alpine.js Sales Panel App + * + * This file combines all components into the main Alpine.js application. + */ + +function salesApp() { + return { + // Authentication state + isLoggedIn: false, + isLoading: true, + currentUser: '', + loginForm: { username: '', password: '' }, + + // Order state + submitting: false, + orderComplete: false, + lastOrder: { id: '', name: '', total: '' }, + + // Include state from components + ...CartComponent.getInitialState(), + ...ProductsComponent.getInitialState(), + ...FormsComponent.getInitialState(), + + // Computed properties + get total() { + return CartComponent.getComputed().total.call(this); + }, + + get filteredProducts() { + return ProductsComponent.getComputed().filteredProducts.call(this); + }, + + get filteredExtraProducts() { + return ProductsComponent.getComputed().filteredExtraProducts.call(this); + }, + + /** + * Initialize the application + * Runs automatically on page load + */ + async init() { + this.isLoading = true; + try { + const data = await ApiService.checkSession(); + if (data.authenticated) { + this.currentUser = data.user || localStorage.getItem('telvero_user') || 'Agent'; + await this.loadProducts(); + this.isLoggedIn = true; + } + } catch (e) { + console.error("Session check failed"); + } finally { + this.isLoading = false; + } + }, + + /** + * Handle user login + */ + async doLogin() { + const result = await ApiService.login(this.loginForm.username, this.loginForm.password); + if (result.ok) { + this.currentUser = result.data.user; + localStorage.setItem('telvero_user', result.data.user); + await this.loadProducts(); + this.isLoggedIn = true; + } else { + alert("Login mislukt"); + } + }, + + /** + * Handle user logout + */ + async doLogout() { + await ApiService.logout(); + localStorage.removeItem('telvero_user'); + location.reload(); + }, + + /** + * Submit order to API + */ + async submitOrder() { + this.submitting = true; + + try { + const payload = this.buildOrderPayload(); + const result = await ApiService.createOrder(payload); + + if (result.success) { + this.lastOrder = { + id: result.order_id, + name: this.form.initials + ' ' + this.form.lastname, + total: result.total + }; + this.orderComplete = true; + } else { + alert("Fout: " + result.error); + } + } catch (e) { + alert("Systeemfout"); + } + + this.submitting = false; + }, + + /** + * Reset everything for a new order + */ + resetForNewOrder() { + this.resetCart(); + this.resetProducts(); + this.resetForm(); + this.orderComplete = false; + }, + + // Include methods from components + ...CartComponent.getMethods(), + ...ProductsComponent.getMethods(), + ...FormsComponent.getMethods() + }; +} diff --git a/js/components/cart.js b/js/components/cart.js new file mode 100644 index 0000000..0502db2 --- /dev/null +++ b/js/components/cart.js @@ -0,0 +1,102 @@ +/** + * Cart Component - Handles shopping cart functionality + */ +const CartComponent = { + /** + * Initialize cart state + * @returns {Object} + */ + getInitialState() { + return { + cart: [], + shipping: '8.95' + }; + }, + + /** + * Get cart methods for Alpine.js component + * @returns {Object} + */ + getMethods() { + return { + /** + * Add item to cart + * @param {Object} item - Item with id, name, price, and optional variation_id + */ + addToCart(item) { + this.cart.push({ + id: parseInt(item.id), + variation_id: item.variation_id || 0, + name: item.name, + price: item.price + }); + }, + + /** + * Remove item from cart by index + * @param {number} index + */ + removeFromCart(index) { + this.cart.splice(index, 1); + }, + + /** + * Check if product is in cart + * @param {number} id + * @returns {boolean} + */ + isInCart(id) { + return this.cart.some(i => parseInt(i.id) === parseInt(id)); + }, + + /** + * Toggle upsell product in cart + * @param {Object} product + */ + toggleUpsell(product) { + const idx = this.cart.findIndex(i => parseInt(i.id) === parseInt(product.id)); + if (idx > -1) { + this.cart.splice(idx, 1); + } else { + this.cart.push({ + id: parseInt(product.id), + name: product.name, + price: product.price + }); + } + }, + + /** + * Clear the cart + */ + clearCart() { + this.cart = []; + }, + + /** + * Reset cart and shipping for new order + */ + resetCart() { + this.cart = []; + this.shipping = '8.95'; + } + }; + }, + + /** + * Get computed properties for Alpine.js component + * @returns {Object} + */ + getComputed() { + return { + /** + * Calculate total including shipping + * @returns {string} + */ + total() { + const itemsTotal = this.cart.reduce((sum, item) => sum + parseFloat(item.price), 0); + return (itemsTotal + (parseFloat(this.shipping) || 0)).toFixed(2); + } + }; + } +}; diff --git a/js/components/forms.js b/js/components/forms.js new file mode 100644 index 0000000..6126508 --- /dev/null +++ b/js/components/forms.js @@ -0,0 +1,139 @@ +/** + * Forms Component - Handles form data and validation + */ +const FormsComponent = { + /** + * Initialize forms state + * @returns {Object} + */ + getInitialState() { + return { + form: { + initials: '', + lastname: '', + postcode: '', + houseno: '', + suffix: '', + street: '', + city: '', + email: '', + phone: '' + }, + meta: { + mediacode: '' + }, + addressError: '' + }; + }, + + /** + * Get forms methods for Alpine.js component + * @returns {Object} + */ + getMethods() { + return { + /** + * Format initials with dots (e.g., "JK" -> "J.K.") + */ + formatInitials() { + let v = this.form.initials.replace(/[^a-z]/gi, '').toUpperCase(); + this.form.initials = v.split('').join('.') + (v ? '.' : ''); + }, + + /** + * Capitalize first letter of lastname + */ + formatLastname() { + this.form.lastname = this.form.lastname.charAt(0).toUpperCase() + this.form.lastname.slice(1); + }, + + /** + * Lookup address by postcode and house number + */ + async lookupAddress() { + this.addressError = ''; + + if (this.form.postcode.length >= 6 && this.form.houseno) { + try { + const data = await ApiService.lookupPostcode(this.form.postcode, this.form.houseno); + + if (data.street) { + this.form.street = data.street.toUpperCase(); + this.form.city = data.city.toUpperCase(); + } else if (data.error) { + this.addressError = data.error; + } else { + this.addressError = 'Adres niet gevonden'; + } + } catch (e) { + this.addressError = 'Fout bij ophalen adres'; + } + } + }, + + /** + * Reset form for new order + */ + resetForm() { + this.form = { + initials: '', + lastname: '', + postcode: '', + houseno: '', + suffix: '', + street: '', + city: '', + email: '', + phone: '' + }; + this.addressError = ''; + }, + + /** + * Build order payload from form data + * @returns {Object} + */ + buildOrderPayload() { + const address = (this.form.street + ' ' + this.form.houseno + ' ' + (this.form.suffix || '')).trim(); + + return { + mediacode_internal: this.meta.mediacode, + shipping_total: this.shipping, + billing: { + first_name: this.form.initials, + last_name: this.form.lastname, + address_1: address, + city: this.form.city, + postcode: this.form.postcode, + country: 'NL', + email: this.form.email, + phone: this.form.phone + }, + shipping: { + first_name: this.form.initials, + last_name: this.form.lastname, + address_1: address, + city: this.form.city, + postcode: this.form.postcode, + country: 'NL', + email: this.form.email, + phone: this.form.phone + }, + line_items: this.cart.map(i => ({ + product_id: i.id, + variation_id: i.variation_id || 0, + quantity: 1 + })) + }; + }, + + /** + * Validate form before submission + * @returns {boolean} + */ + isFormValid() { + return this.form.email && this.meta.mediacode && this.cart.length > 0; + } + }; + } +}; diff --git a/js/components/products.js b/js/components/products.js new file mode 100644 index 0000000..0faa930 --- /dev/null +++ b/js/components/products.js @@ -0,0 +1,175 @@ +/** + * Products Component - Handles product selection and recommendations + */ +const ProductsComponent = { + /** + * Initialize products state + * @returns {Object} + */ + getInitialState() { + return { + products: [], + activeProduct: null, + selectedProductId: '', + selectedVariationId: '', + variations: [], + extraProductId: '', + recommendedOptions: [], + productSearch: '', + extraProductSearch: '' + }; + }, + + /** + * Get products methods for Alpine.js component + * @returns {Object} + */ + getMethods() { + return { + /** + * Load products from API + */ + async loadProducts() { + try { + const data = await ApiService.getProducts(); + // Sort products alphabetically by name + this.products = data.sort((a, b) => a.name.localeCompare(b.name, 'nl')); + } catch (e) { + console.error("Failed to load products"); + } + }, + + /** + * Handle main product selection + */ + selectProduct() { + const p = this.products.find(x => x.id == this.selectedProductId); + if (!p) return; + + this.activeProduct = p; + this.variations = p.variation_details || []; + this.cart = []; + this.selectedVariationId = ''; + + if (p.type !== 'variable') { + this.cart.push({ + id: parseInt(p.id), + name: p.name, + price: p.price + }); + this.loadRecommendations(p); + } + }, + + /** + * Handle variation selection + */ + selectVariation() { + const v = this.variations.find(x => x.id == this.selectedVariationId); + if (!v) return; + + this.cart = [{ + id: parseInt(this.activeProduct.id), + variation_id: parseInt(v.id), + name: this.activeProduct.name + ' - ' + this.getVarName(v), + price: v.price + }]; + + this.loadRecommendations(this.activeProduct); + }, + + /** + * Add extra product to cart + */ + addExtraItem() { + const p = this.products.find(x => x.id == this.extraProductId); + if (!p) return; + + this.cart.push({ + id: parseInt(p.id), + name: p.name, + price: p.price + }); + this.extraProductId = ''; + }, + + /** + * Load product recommendations + * @param {Object} product + */ + loadRecommendations(product) { + this.recommendedOptions = []; + + // Prefer combined list from API + let ids = []; + if (product.recommended_ids && product.recommended_ids.length) { + ids = product.recommended_ids.map(id => parseInt(id)); + } else { + // Fallback: combine Woo upsells + cross-sells + const ups = (product.upsell_ids || []).map(id => parseInt(id)); + const crs = (product.cross_sell_ids || []).map(id => parseInt(id)); + ids = [...ups, ...crs]; + } + + ids = [...new Set(ids)].filter(Boolean); + + // Filter to products we have in our products list + this.recommendedOptions = this.products.filter(p => ids.includes(parseInt(p.id))); + }, + + /** + * Get variation name from attributes + * @param {Object} v - Variation object + * @returns {string} + */ + getVarName(v) { + return v.attributes.map(a => a.option).join(' '); + }, + + /** + * Reset product selection for new order + */ + resetProducts() { + this.selectedProductId = ''; + this.selectedVariationId = ''; + this.activeProduct = null; + this.variations = []; + this.recommendedOptions = []; + } + }; + }, + + /** + * Get computed properties for Alpine.js component + * @returns {Object} + */ + getComputed() { + return { + /** + * Filter products by search term + * @returns {Array} + */ + filteredProducts() { + let filtered = this.products; + if (this.productSearch.trim()) { + const search = this.productSearch.toLowerCase().trim(); + filtered = this.products.filter(p => p.name.toLowerCase().includes(search)); + } + return filtered; + }, + + /** + * Filter extra products by search term + * @returns {Array} + */ + filteredExtraProducts() { + let filtered = this.products; + if (this.extraProductSearch.trim()) { + const search = this.extraProductSearch.toLowerCase().trim(); + filtered = this.products.filter(p => p.name.toLowerCase().includes(search)); + } + return filtered; + } + }; + } +}; diff --git a/js/services/api.js b/js/services/api.js new file mode 100644 index 0000000..fa601e7 --- /dev/null +++ b/js/services/api.js @@ -0,0 +1,79 @@ +/** + * API Service - Handles all API communication + */ +const ApiService = { + /** + * Base URL for API calls + */ + baseUrl: 'api.php', + + /** + * Check if user session is valid + * @returns {Promise<{authenticated: boolean, user?: string}>} + */ + async checkSession() { + const res = await fetch(`${this.baseUrl}?action=check_session`); + return res.json(); + }, + + /** + * Login user + * @param {string} username + * @param {string} password + * @returns {Promise<{success?: boolean, user?: string, error?: string}>} + */ + async login(username, password) { + const res = await fetch(`${this.baseUrl}?action=login`, { + method: 'POST', + body: JSON.stringify({ username, password }) + }); + return { ok: res.ok, data: await res.json() }; + }, + + /** + * Logout user + * @returns {Promise<{success: boolean}>} + */ + async logout() { + const res = await fetch(`${this.baseUrl}?action=logout`); + return res.json(); + }, + + /** + * Get all products + * @returns {Promise} + */ + async getProducts() { + const res = await fetch(`${this.baseUrl}?action=get_products`); + if (res.ok) { + return res.json(); + } + return []; + }, + + /** + * Lookup address by postcode and house number + * @param {string} postcode + * @param {string} number + * @returns {Promise<{street?: string, city?: string, error?: string}>} + */ + async lookupPostcode(postcode, number) { + const res = await fetch( + `${this.baseUrl}?action=postcode_check&postcode=${encodeURIComponent(postcode)}&number=${encodeURIComponent(number)}` + ); + return res.json(); + }, + + /** + * Create a new order + * @param {Object} orderData + * @returns {Promise<{success?: boolean, order_id?: number, total?: string, error?: string}>} + */ + async createOrder(orderData) { + const res = await fetch(`${this.baseUrl}?action=create_order`, { + method: 'POST', + body: JSON.stringify(orderData) + }); + return res.json(); + } +};