code refactor

This commit is contained in:
Mark Pinkster 2026-01-10 15:30:05 +01:00
parent 751ebcd438
commit 80e38ef7df
15 changed files with 1377 additions and 744 deletions

559
api.php
View File

@ -1,555 +1,68 @@
<?php
// 1. SESSION CONFIGURATION (MUST BE AT THE VERY TOP)
$now = time();
$midnight_timestamp = strtotime('tomorrow midnight') - 1;
$duration = $midnight_timestamp - $now;
/**
* API Router - Main entry point for all API requests
*
* This file acts as a router that dispatches requests to the appropriate handlers.
*/
ini_set('session.gc_maxlifetime', $duration);
ini_set('session.cookie_lifetime', $duration);
// Load bootstrap (session, WordPress, autoload, env)
require_once __DIR__ . '/api/bootstrap.php';
session_set_cookie_params([
'lifetime' => $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; }
// Unknown action
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);

79
api/actions/orders.php Normal file
View File

@ -0,0 +1,79 @@
<?php
/**
* Orders Action - Create orders via WooCommerce
*/
/**
* Handle create_order action
*
* @return void
*/
function handleCreateOrder(): void
{
$input = json_decode(file_get_contents('php://input'), true);
try {
$woocommerce = getWooCommerce();
$db = getDatabase();
$email = $input['billing']['email'];
$mediacode = $input['mediacode_internal'] ?? 'Geen';
$input['payment_method'] = 'cod';
$input['payment_method_title'] = 'Sales Panel Order';
$input['status'] = 'on-hold';
// Check for existing customer
$existing = $woocommerce->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()]);
}
}

38
api/actions/postcode.php Normal file
View File

@ -0,0 +1,38 @@
<?php
/**
* Postcode Action - Handle postcode lookup requests
*/
require_once __DIR__ . '/../services/PostcodeService.php';
/**
* Handle postcode_check action
*
* @return void
*/
function handlePostcodeCheck(): void
{
try {
$postcode = $_GET['postcode'] ?? '';
$number = $_GET['number'] ?? '';
$result = PostcodeService::lookup($postcode, $number);
if (!$result['success']) {
http_response_code($result['http_code']);
$response = ['error' => $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'
]);
}
}

57
api/actions/products.php Normal file
View File

@ -0,0 +1,57 @@
<?php
/**
* Products Action - Get products with enriched data
*/
require_once __DIR__ . '/../services/UpsellService.php';
/**
* Handle get_products action
*
* @return void
*/
function handleGetProducts(): void
{
try {
$woocommerce = getWooCommerce();
$products = $woocommerce->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([]);
}
}

64
api/bootstrap.php Normal file
View File

@ -0,0 +1,64 @@
<?php
/**
* Bootstrap file - Session configuration and WordPress loading
*/
// 1. SESSION CONFIGURATION (MUST BE AT THE VERY TOP)
$now = time();
$midnight_timestamp = strtotime('tomorrow midnight') - 1;
$duration = $midnight_timestamp - $now;
ini_set('session.gc_maxlifetime', $duration);
ini_set('session.cookie_lifetime', $duration);
session_set_cookie_params([
'lifetime' => $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);

54
api/config.php Normal file
View File

@ -0,0 +1,54 @@
<?php
/**
* Configuration file - Database connection and WooCommerce client
*/
use Automattic\WooCommerce\Client;
/**
* Get database connection
* @return mysqli
*/
function getDatabase(): mysqli
{
static $db = null;
if ($db === null) {
$db = new mysqli(
$_ENV['DB_HOST'],
$_ENV['DB_USER'],
$_ENV['DB_PASS'],
$_ENV['DB_NAME']
);
if ($db->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;
}

88
api/middleware/auth.php Normal file
View File

@ -0,0 +1,88 @@
<?php
/**
* Authentication middleware and handlers
*/
/**
* Handle login action
* @param mysqli $db Database connection
* @return void
*/
function handleLogin(mysqli $db): void
{
$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
]));
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;
}
}

View File

@ -0,0 +1,72 @@
<?php
/**
* Postcode Service - Handles postcode lookup via postcode.tech API
*/
class PostcodeService
{
/**
* Lookup address by postcode and house number
*
* @param string $postcode
* @param string $number
* @return array
*/
public static function lookup(string $postcode, string $number): array
{
$postcode = str_replace(' ', '', $postcode);
if (empty($postcode) || empty($number)) {
return [
'success' => 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
];
}
}

View File

@ -0,0 +1,263 @@
<?php
/**
* Upsell Service - Handles CUW (UpsellWP) campaign data
*/
class UpsellService
{
/**
* Build a map: product_id => related product ids from CUW (UpsellWP) campaigns.
* Uses {$wpdb->prefix}cuw_campaigns table.
*
* @return array<int, int[]>
*/
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<int, 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<int[]>
*/
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)));
}
}

View File

@ -20,6 +20,12 @@
border-radius: 10px;
}
</style>
<!-- Load JavaScript modules -->
<script src="js/services/api.js"></script>
<script src="js/components/cart.js"></script>
<script src="js/components/products.js"></script>
<script src="js/components/forms.js"></script>
<script src="js/app.js"></script>
</head>
<body class="bg-slate-100 min-h-screen font-sans" x-data="salesApp()">
@ -305,226 +311,6 @@
</div>
</div>
</div>
<script>
function salesApp() {
return {
isLoggedIn: false,
isLoading: true,
currentUser: '',
loginForm: { username: '', password: '' },
products: [],
cart: [],
activeProduct: null,
selectedProductId: '',
selectedVariationId: '',
variations: [],
extraProductId: '',
shipping: '8.95',
submitting: false,
orderComplete: false,
lastOrder: { id: '', name: '', total: '' },
form: { initials: '', lastname: '', postcode: '', houseno: '', suffix: '', street: '', city: '', email: '', phone: '' },
meta: { mediacode: '' },
recommendedOptions: [],
addressError: '',
productSearch: '',
extraProductSearch: '',
get 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;
},
get 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;
},
// THIS RUNS AUTOMATICALLY ON PAGE LOAD
async init() {
this.isLoading = true;
try {
// Lichte sessie-check (geen producten laden)
const res = await fetch('api.php?action=check_session');
const data = await res.json();
if (data.authenticated) {
this.currentUser = data.user || localStorage.getItem('telvero_user') || 'Agent';
// Producten laden en pas daarna isLoggedIn zetten
await this.loadProducts();
this.isLoggedIn = true;
}
} catch (e) {
console.error("Session check failed");
} finally {
this.isLoading = false;
}
},
async doLogin() {
const res = await fetch('api.php?action=login', {
method: 'POST',
body: JSON.stringify(this.loginForm)
});
if (res.ok) {
const data = await res.json();
this.currentUser = data.user;
localStorage.setItem('telvero_user', data.user);
// Producten laden en pas daarna isLoggedIn zetten
await this.loadProducts();
this.isLoggedIn = true;
} else {
alert("Login mislukt");
}
},
async loadProducts() {
try {
const res = await fetch('api.php?action=get_products');
if (res.ok) {
const data = await res.json();
// 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");
}
},
async doLogout() {
await fetch('api.php?action=logout');
localStorage.removeItem('telvero_user'); // Clean up local storage
location.reload();
},
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);
}
},
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.loadUpsells(this.activeProduct);
},
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 = '';
},
loadRecommendations(product) {
this.recommendedOptions = [];
// voorkeur: gecombineerde lijst vanuit API
let ids = [];
if (product.recommended_ids && product.recommended_ids.length) {
ids = product.recommended_ids.map(id => parseInt(id));
} else {
// fallback: combineer 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 op producten die we al in de products-lijst hebben
this.recommendedOptions = this.products.filter(p => ids.includes(parseInt(p.id)));
},
removeFromCart(index) { this.cart.splice(index, 1); },
getVarName(v) { return v.attributes.map(a => a.option).join(' '); },
toggleUpsell(u) {
const idx = this.cart.findIndex(i => parseInt(i.id) === parseInt(u.id));
if (idx > -1) this.cart.splice(idx, 1);
else this.cart.push({ id: parseInt(u.id), name: u.name, price: u.price });
},
isInCart(id) { return this.cart.some(i => parseInt(i.id) === parseInt(id)); },
get total() {
const itemsTotal = this.cart.reduce((sum, item) => sum + parseFloat(item.price), 0);
return (itemsTotal + (parseFloat(this.shipping) || 0)).toFixed(2);
},
formatInitials() { let v = this.form.initials.replace(/[^a-z]/gi, '').toUpperCase(); this.form.initials = v.split('').join('.') + (v ? '.' : ''); },
formatLastname() { this.form.lastname = this.form.lastname.charAt(0).toUpperCase() + this.form.lastname.slice(1); },
async submitOrder() {
this.submitting = true;
const payload = {
mediacode_internal: this.meta.mediacode,
shipping_total: this.shipping,
billing: {
first_name: this.form.initials, last_name: this.form.lastname,
address_1: (this.form.street + ' ' + this.form.houseno + ' ' + (this.form.suffix || '')).trim(),
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: (this.form.street + ' ' + this.form.houseno + ' ' + (this.form.suffix || '')).trim(),
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 }))
};
try {
const res = await fetch('api.php?action=create_order', { method: 'POST', body: JSON.stringify(payload) });
const result = await res.json();
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;
},
resetForNewOrder() {
this.cart = []; this.selectedProductId = ''; this.selectedVariationId = ''; this.shipping = '8.95';
this.form = { initials: '', lastname: '', postcode: '', houseno: '', suffix: '', street: '', city: '', email: '', phone: '' };
this.addressError = '';
this.orderComplete = false;
},
async lookupAddress() {
this.addressError = '';
if (this.form.postcode.length >= 6 && this.form.houseno) {
try {
const res = await fetch(`api.php?action=postcode_check&postcode=${this.form.postcode}&number=${this.form.houseno}`);
const data = await res.json();
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';
}
}
}
}
}
</script>
</body>
</html>
</html>

124
js/app.js Normal file
View File

@ -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()
};
}

102
js/components/cart.js Normal file
View File

@ -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);
}
};
}
};

139
js/components/forms.js Normal file
View File

@ -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;
}
};
}
};

175
js/components/products.js Normal file
View File

@ -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;
}
};
}
};

79
js/services/api.js Normal file
View File

@ -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<Array>}
*/
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();
}
};