Login fixed, PC error handling, Product selection improved

This commit is contained in:
Mark Pinkster 2026-01-10 12:35:58 +01:00
parent 6c37a34f69
commit fa8f38272c
2 changed files with 392 additions and 170 deletions

62
api.php
View File

@ -88,7 +88,17 @@ if ($action === 'login') {
exit; exit;
} }
// 7. SECURITY GATE (The ONLY one needed) // 7. SESSION CHECK (lightweight, before security gate)
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]);
}
exit;
}
// 8. SECURITY GATE (The ONLY one needed)
if (!isset($_SESSION['user'])) { if (!isset($_SESSION['user'])) {
http_response_code(403); http_response_code(403);
echo json_encode(['error' => 'Not authenticated']); echo json_encode(['error' => 'Not authenticated']);
@ -371,12 +381,50 @@ function ss_cuw_build_deals_map() {
// --- POSTCODE CHECK --- // --- POSTCODE CHECK ---
if ($action === 'postcode_check') { if ($action === 'postcode_check') {
$postcode = str_replace(' ', '', $_GET['postcode']); try {
$url = "https://postcode.tech/api/v1/postcode?postcode={$postcode}&number=" . $_GET['number']; $postcode = str_replace(' ', '', $_GET['postcode'] ?? '');
$ch = curl_init($url); $number = $_GET['number'] ?? '';
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer " . $_ENV['POSTCODE_TECH_KEY']]); if (empty($postcode) || empty($number)) {
echo curl_exec($ch); 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']);
}
exit; exit;
} }

View File

@ -1,45 +1,71 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="nl"> <html lang="nl">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Telvero Sales Panel V9</title> <title>Telvero Sales Panel V1</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs" defer></script> <script src="https://unpkg.com/alpinejs" defer></script>
<style>[x-cloak] { display: none !important; } .custom-scrollbar::-webkit-scrollbar { width: 4px; } .custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }</style> <style>
[x-cloak] {
display: none !important;
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 10px;
}
</style>
</head> </head>
<body class="bg-slate-100 min-h-screen font-sans" x-data="salesApp()" x-init="init()">
<template x-if="isLoading"> <body class="bg-slate-100 min-h-screen font-sans" x-data="salesApp()">
<template x-if="isLoading">
<div class="fixed inset-0 bg-slate-900 flex items-center justify-center z-[60]"> <div class="fixed inset-0 bg-slate-900 flex items-center justify-center z-[60]">
<div class="text-white font-black animate-pulse">LAAD SESSIE...</div> <div class="text-white font-black animate-pulse">LAAD SESSIE...</div>
</div> </div>
</template> </template>
<template x-if="!isLoggedIn && !isLoading"> <template x-if="!isLoggedIn && !isLoading">
<div class="fixed inset-0 bg-slate-900 flex items-center justify-center p-4 z-50"> <div class="fixed inset-0 bg-slate-900 flex items-center justify-center p-4 z-50">
<div class="bg-white p-10 rounded-[2.5rem] shadow-2xl w-full max-w-md text-center border-t-8 border-blue-600"> <div
<h2 class="text-3xl font-black mb-8 italic tracking-tighter text-slate-800">TELVERO <span class="text-blue-600">LOGIN</span></h2> class="bg-white p-10 rounded-[2.5rem] shadow-2xl w-full max-w-md text-center border-t-8 border-blue-600">
<h2 class="text-3xl font-black mb-8 italic tracking-tighter text-slate-800">TELVERO <span
class="text-blue-600">LOGIN</span></h2>
<div class="space-y-4"> <div class="space-y-4">
<input type="text" x-model="loginForm.username" placeholder="Gebruikersnaam" class="w-full border p-4 rounded-2xl outline-none focus:border-blue-500 bg-slate-50 font-bold"> <input type="text" x-model="loginForm.username" placeholder="Gebruikersnaam"
<input type="password" x-model="loginForm.password" @keyup.enter="doLogin()" placeholder="Wachtwoord" class="w-full border p-4 rounded-2xl outline-none focus:border-blue-500 bg-slate-50"> class="w-full border p-4 rounded-2xl outline-none focus:border-blue-500 bg-slate-50 font-bold">
<button @click="doLogin()" class="w-full bg-blue-600 text-white p-5 rounded-2xl font-black shadow-lg hover:bg-blue-700 transition uppercase tracking-widest text-sm">Inloggen</button> <input type="password" x-model="loginForm.password" @keyup.enter="doLogin()"
placeholder="Wachtwoord"
class="w-full border p-4 rounded-2xl outline-none focus:border-blue-500 bg-slate-50">
<button @click="doLogin()"
class="w-full bg-blue-600 text-white p-5 rounded-2xl font-black shadow-lg hover:bg-blue-700 transition uppercase tracking-widest text-sm">Inloggen</button>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<div x-show="isLoggedIn && !isLoading" x-cloak class="max-w-[1440px] mx-auto p-6"> <div x-show="isLoggedIn && !isLoading" x-cloak class="max-w-[1440px] mx-auto p-6">
<header class="flex justify-between items-center mb-8 bg-white p-6 rounded-3xl shadow-sm border-b-4 border-blue-600"> <header
<h1 class="text-2xl font-black italic tracking-tighter">TELVERO <span class="text-blue-600">PANEL</span></h1> class="flex justify-between items-center mb-8 bg-white p-6 rounded-3xl shadow-sm border-b-4 border-blue-600">
<h1 class="text-2xl font-black italic tracking-tighter">TELVERO <span class="text-blue-600">PANEL</span>
</h1>
<div class="flex items-center gap-6 text-sm font-bold text-slate-400"> <div class="flex items-center gap-6 text-sm font-bold text-slate-400">
<span x-text="'Agent: ' + currentUser"></span> <span x-text="'Agent: ' + currentUser"></span>
<button @click="doLogout()" class="text-red-500 underline uppercase text-xs font-black">Uitloggen</button> <button @click="doLogout()"
class="text-red-500 underline uppercase text-xs font-black">Uitloggen</button>
</div> </div>
</header> </header>
<div class="grid grid-cols-12 gap-8"> <div class="grid grid-cols-12 gap-8">
<div class="col-span-12 lg:col-span-4 bg-white p-8 rounded-[2rem] shadow-sm border border-slate-200"> <div class="col-span-12 lg:col-span-4 bg-white p-8 rounded-[2rem] shadow-sm border border-slate-200">
<div class="mb-8 p-6 bg-blue-50 rounded-2xl border-2 border-blue-100 shadow-inner"> <div class="mb-8 p-6 bg-blue-50 rounded-2xl border-2 border-blue-100 shadow-inner">
<label class="block text-[10px] font-black text-blue-600 uppercase tracking-widest mb-3 italic text-center">Mediacode</label> <label
<select x-model="meta.mediacode" class="w-full border-2 border-white p-4 rounded-xl font-bold text-blue-800 shadow-sm outline-none focus:border-blue-300"> class="block text-[10px] font-black text-blue-600 uppercase tracking-widest mb-3 italic text-center">Mediacode</label>
<select x-model="meta.mediacode"
class="w-full border-2 border-white p-4 rounded-xl font-bold text-blue-800 shadow-sm outline-none focus:border-blue-300">
<option value="">-- KIES MEDIACODE --</option> <option value="">-- KIES MEDIACODE --</option>
<option value="Telvero - Net5">Telvero - Net5</option> <option value="Telvero - Net5">Telvero - Net5</option>
<option value="Telvero - SBS9">Telvero - SBS9</option> <option value="Telvero - SBS9">Telvero - SBS9</option>
@ -49,32 +75,75 @@
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<input type="text" x-model="form.initials" @blur="formatInitials()" placeholder="Voorletters" class="border p-3 rounded-xl w-full bg-slate-50"> <input type="text" x-model="form.initials" @blur="formatInitials()" placeholder="Voorletters"
<input type="text" x-model="form.lastname" @blur="formatLastname()" placeholder="Achternaam" class="border p-3 rounded-xl w-full bg-slate-50"> class="border p-3 rounded-xl w-full">
<input type="text" x-model="form.lastname" @blur="formatLastname()" placeholder="Achternaam"
class="border p-3 rounded-xl w-full">
</div> </div>
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-2">
<input type="text" x-model="form.postcode" placeholder="Postcode" class="border p-3 rounded-xl w-full uppercase font-mono"> <input type="text" x-model="form.postcode" placeholder="Postcode"
<input type="text" x-model="form.houseno" @blur="lookupAddress()" placeholder="Nr." class="border p-3 rounded-xl w-full"> class="border p-3 rounded-xl w-full font-mono">
<input type="text" x-model="form.suffix" placeholder="Toev." class="border p-3 rounded-xl w-full"> <input type="text" x-model="form.houseno" @blur="lookupAddress()" placeholder="Nr."
class="border p-3 rounded-xl w-full">
<input type="text" x-model="form.suffix" placeholder="Toev."
class="border p-3 rounded-xl w-full">
</div> </div>
<input type="text" x-model="form.street" placeholder="Straat" class="w-full border p-3 rounded-xl bg-slate-100 font-bold text-xs" readonly> <p x-show="addressError" x-text="addressError" class="text-red-500 text-xs font-bold mt-1"></p>
<input type="text" x-model="form.city" placeholder="Stad" class="w-full border p-3 rounded-xl bg-slate-100 font-bold text-xs" readonly> <input type="text" x-model="form.street" placeholder="Straat"
<input type="tel" x-model="form.phone" placeholder="Telefoonnummer" class="border p-3 rounded-xl w-full outline-none focus:border-blue-500"> class="w-full border p-3 rounded-xl bg-slate-50">
<input type="email" x-model="form.email" placeholder="E-mail (Verplicht)" class="border-2 border-amber-300 p-3 rounded-xl w-full outline-none focus:border-amber-500 shadow-sm"> <input type="text" x-model="form.city" placeholder="Stad"
class="w-full border p-3 rounded-xl bg-slate-50">
<input type="tel" x-model="form.phone" placeholder="Telefoonnummer"
class="border p-3 rounded-xl w-full outline-none focus:border-blue-500">
<input type="email" x-model="form.email" placeholder="E-mail (Verplicht)"
class="border-2 border-amber-300 p-3 rounded-xl w-full outline-none focus:border-amber-500 shadow-sm">
</div> </div>
</div> </div>
<div class="col-span-12 lg:col-span-5 bg-white p-8 rounded-[2rem] shadow-sm border border-slate-200"> <div class="col-span-12 lg:col-span-5 bg-white p-8 rounded-[2rem] shadow-sm border border-slate-200">
<h2 class="font-bold mb-4 text-slate-400 uppercase text-[10px] tracking-widest border-b pb-2 text-center italic">Producten</h2> <h2
<select x-model="selectedProductId" @change="selectProduct()" class="w-full border-2 border-slate-100 p-5 rounded-2xl font-black text-slate-700 mb-6 bg-slate-50 outline-none focus:border-blue-500 shadow-sm"> class="font-bold mb-4 text-slate-400 uppercase text-[10px] tracking-widest border-b pb-2 text-center italic">
<option value="">-- Kies Hoofdproduct --</option> Producten</h2>
<template x-for="p in products" :key="p.id">
<option :value="p.id" x-text="p.name"></option> <!-- Custom searchable dropdown for main product -->
</template> <div class="relative mb-6" x-data="{ open: false }" @click.away="open = false">
</select> <button type="button" @click="open = !open"
class="w-full border-2 border-slate-100 p-5 rounded-2xl font-black text-slate-700 bg-slate-50 outline-none focus:border-blue-500 shadow-sm text-left flex justify-between items-center">
<span x-text="selectedProductId ? products.find(p => p.id == selectedProductId)?.name : '-- Kies Hoofdproduct --'" class="truncate"></span>
<svg class="w-5 h-5 text-slate-400 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div x-show="open" x-cloak
class="absolute z-50 w-full mt-2 bg-white border-2 border-slate-200 rounded-2xl shadow-xl overflow-hidden">
<div class="p-3 border-b border-slate-100">
<input type="text" x-model="productSearch" placeholder="Zoek product..."
class="w-full border border-slate-200 p-3 rounded-xl text-sm outline-none focus:border-blue-500"
@click.stop>
</div>
<div class="max-h-60 overflow-y-auto custom-scrollbar">
<div @click="selectedProductId = ''; selectProduct(); open = false; productSearch = ''"
class="p-4 hover:bg-slate-50 cursor-pointer text-slate-500 font-medium text-sm">
-- Kies Hoofdproduct --
</div>
<template x-for="p in filteredProducts" :key="p.id">
<div @click="selectedProductId = p.id; selectProduct(); open = false; productSearch = ''"
class="p-4 hover:bg-blue-50 cursor-pointer font-bold text-sm text-slate-700"
:class="{ 'bg-blue-100': selectedProductId == p.id }">
<span x-text="p.name"></span>
</div>
</template>
<div x-show="filteredProducts.length === 0" class="p-4 text-slate-400 text-sm text-center">
Geen producten gevonden
</div>
</div>
</div>
</div>
<div x-show="variations.length > 0" x-cloak class="mb-8 p-6 bg-blue-50 rounded-3xl border border-blue-100 shadow-inner"> <div x-show="variations.length > 0" x-cloak
<select x-model="selectedVariationId" @change="selectVariation()" class="w-full border-2 border-white p-4 rounded-2xl font-bold bg-white text-slate-700 shadow-sm outline-none"> class="mb-8 p-6 bg-blue-50 rounded-3xl border border-blue-100 shadow-inner">
<select x-model="selectedVariationId" @change="selectVariation()"
class="w-full border-2 border-white p-4 rounded-2xl font-bold bg-white text-slate-700 shadow-sm outline-none">
<option value="">-- Kies Optie --</option> <option value="">-- Kies Optie --</option>
<template x-for="v in variations" :key="v.id"> <template x-for="v in variations" :key="v.id">
<option :value="v.id" x-text="getVarName(v) + ' (€' + v.price + ')'"></option> <option :value="v.id" x-text="getVarName(v) + ' (€' + v.price + ')'"></option>
@ -83,47 +152,86 @@
</div> </div>
<div class="mt-8 border-t pt-6"> <div class="mt-8 border-t pt-6">
<h2 class="font-bold mb-4 text-slate-400 uppercase text-[10px] tracking-widest italic text-center">Extra's</h2> <h2 class="font-bold mb-4 text-slate-400 uppercase text-[10px] tracking-widest italic text-center">
Extra's</h2>
<div class="flex gap-2"> <div class="flex gap-2">
<select x-model="extraProductId" class="flex-1 border-2 border-slate-100 p-3 rounded-xl font-bold text-xs bg-slate-50 outline-none focus:border-green-500"> <!-- Custom searchable dropdown for extra products -->
<option value="">-- Voeg product toe --</option> <div class="relative flex-1" x-data="{ openExtra: false }" @click.away="openExtra = false">
<template x-for="p in products" :key="'extra-'+p.id"> <button type="button" @click="openExtra = !openExtra"
<option :value="p.id" x-text="p.name + ' (€' + p.price + ')'"></option> class="w-full border-2 border-slate-100 p-3 rounded-xl font-bold text-xs bg-slate-50 outline-none focus:border-green-500 text-left flex justify-between items-center text-slate-700">
</template> <span x-text="extraProductId ? (products.find(p => p.id == extraProductId)?.name + ' (€' + products.find(p => p.id == extraProductId)?.price + ')') : '-- Voeg product toe --'" class="truncate"></span>
</select> <svg class="w-4 h-4 text-slate-400 transition-transform flex-shrink-0 ml-2" :class="{ 'rotate-180': openExtra }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button @click="addExtraItem()" class="bg-green-600 text-white px-6 py-3 rounded-xl font-black text-[10px] uppercase shadow-md hover:bg-green-700 transition active:scale-95">Add</button> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div x-show="openExtra" x-cloak
class="absolute z-50 w-full mt-2 bg-white border-2 border-slate-200 rounded-2xl shadow-xl overflow-hidden">
<div class="p-3 border-b border-slate-100">
<input type="text" x-model="extraProductSearch" placeholder="Zoek product..."
class="w-full border border-slate-200 p-2 rounded-xl text-sm outline-none focus:border-green-500"
@click.stop>
</div>
<div class="max-h-60 overflow-y-auto custom-scrollbar">
<div @click="extraProductId = ''; openExtra = false; extraProductSearch = ''"
class="p-3 hover:bg-slate-50 cursor-pointer text-slate-500 font-medium text-xs">
-- Voeg product toe --
</div>
<template x-for="p in filteredExtraProducts" :key="'extra-'+p.id">
<div @click="extraProductId = p.id; openExtra = false; extraProductSearch = ''"
class="p-3 hover:bg-green-50 cursor-pointer font-bold text-xs text-slate-700"
:class="{ 'bg-green-100': extraProductId == p.id }">
<span x-text="p.name + ' (€' + p.price + ')'"></span>
</div>
</template>
<div x-show="filteredExtraProducts.length === 0" class="p-3 text-slate-400 text-xs text-center">
Geen producten gevonden
</div>
</div>
</div>
</div>
<button @click="addExtraItem()"
class="bg-green-600 text-white px-6 py-3 rounded-xl font-black text-[10px] uppercase shadow-md hover:bg-green-700 transition active:scale-95">Add</button>
</div> </div>
</div> </div>
<div x-show="recommendedOptions.length > 0" x-cloak class="mt-8 space-y-3"> <div x-show="recommendedOptions.length > 0" x-cloak class="mt-8 space-y-3">
<p class="text-[10px] font-black text-red-500 uppercase tracking-widest italic px-2 text-center">Aanbevolen</p> <p class="text-[10px] font-black text-red-500 uppercase tracking-widest italic px-2 text-center">
Aanbevolen</p>
<template x-for="u in recommendedOptions" :key="u.id"> <template x-for="u in recommendedOptions" :key="u.id">
<div class="flex items-center justify-between p-4 border rounded-2xl bg-slate-50 hover:bg-white transition-all shadow-sm"> <div
<span class="text-xs font-bold text-slate-700" x-text="u.name + ' (€' + u.price + ')'"></span> class="flex items-center justify-between p-4 border rounded-2xl bg-slate-50 hover:bg-white transition-all shadow-sm">
<button <span class="text-xs font-bold text-slate-700"
@click="toggleUpsell(u)" x-text="u.name + ' (€' + u.price + ')'"></span>
:class="isInCart(u.id) ? 'bg-red-500' : 'bg-green-600'" <button @click="toggleUpsell(u)" :class="isInCart(u.id) ? 'bg-red-500' : 'bg-green-600'"
class="text-white px-6 py-2 rounded-xl text-[10px] font-black uppercase shadow-md" class="text-white px-6 py-2 rounded-xl text-[10px] font-black uppercase shadow-md"
x-text="isInCart(u.id) ? 'Verwijder' : 'Voeg toe'"> x-text="isInCart(u.id) ? 'Verwijder' : 'Voeg toe'">
</button> </button>
</div> </div>
</template> </template>
</div> </div>
</div> </div>
<div class="col-span-12 lg:col-span-3"> <div class="col-span-12 lg:col-span-3">
<div class="bg-slate-900 text-white p-8 rounded-[2.5rem] shadow-2xl sticky top-6 border border-slate-800 transition-all duration-500"> <div
class="bg-slate-900 text-white p-8 rounded-[2.5rem] shadow-2xl sticky top-6 border border-slate-800 transition-all duration-500">
<template x-if="orderComplete"> <template x-if="orderComplete">
<div class="text-center py-4"> <div class="text-center py-4">
<div class="bg-green-500/20 text-green-400 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-6 border-2 border-green-500/30 animate-bounce"> <div
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" /></svg> class="bg-green-500/20 text-green-400 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-6 border-2 border-green-500/30 animate-bounce">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3"
d="M5 13l4 4L19 7" />
</svg>
</div> </div>
<h2 class="text-xl font-black mb-2 uppercase tracking-tighter text-white">Order Gelukt!</h2> <h2 class="text-xl font-black mb-2 uppercase tracking-tighter text-white">Order Gelukt!</h2>
<p class="text-slate-400 text-[10px] mb-8 italic uppercase tracking-widest">Klant is opgeslagen</p> <p class="text-slate-400 text-[10px] mb-8 italic uppercase tracking-widest">Klant is
opgeslagen</p>
<div class="bg-slate-800/50 rounded-2xl p-6 mb-8 text-left border border-slate-700/50 space-y-4">
<div
class="bg-slate-800/50 rounded-2xl p-6 mb-8 text-left border border-slate-700/50 space-y-4">
<div class="flex justify-between border-b border-slate-700/50 pb-2"> <div class="flex justify-between border-b border-slate-700/50 pb-2">
<span class="text-[9px] text-slate-500 font-black uppercase">Order ID</span> <span class="text-[9px] text-slate-500 font-black uppercase">Order ID</span>
<span class="text-sm font-black text-blue-400" x-text="'#' + lastOrder.id"></span> <span class="text-sm font-black text-blue-400" x-text="'#' + lastOrder.id"></span>
@ -134,11 +242,13 @@
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-[9px] text-slate-500 font-black uppercase">Bedrag</span> <span class="text-[9px] text-slate-500 font-black uppercase">Bedrag</span>
<span class="text-xl font-black text-green-400" x-text="'€' + lastOrder.total"></span> <span class="text-xl font-black text-green-400"
x-text="'€' + lastOrder.total"></span>
</div> </div>
</div> </div>
<button @click="resetForNewOrder()" class="w-full bg-blue-600 text-white p-5 rounded-2xl font-black shadow-lg hover:bg-blue-500 transition uppercase tracking-widest text-xs active:scale-95"> <button @click="resetForNewOrder()"
class="w-full bg-blue-600 text-white p-5 rounded-2xl font-black shadow-lg hover:bg-blue-500 transition uppercase tracking-widest text-xs active:scale-95">
Start Nieuwe Order Start Nieuwe Order
</button> </button>
</div> </div>
@ -146,25 +256,37 @@
<template x-if="!orderComplete"> <template x-if="!orderComplete">
<div> <div>
<h2 class="font-bold mb-6 border-b border-slate-800 pb-2 text-[10px] uppercase text-slate-500 tracking-widest italic text-center">Samenvatting</h2> <h2
class="font-bold mb-6 border-b border-slate-800 pb-2 text-[10px] uppercase text-slate-500 tracking-widest italic text-center">
<div class="space-y-4 mb-6 min-h-[100px] max-h-[300px] overflow-y-auto pr-2 custom-scrollbar"> Samenvatting</h2>
<div
class="space-y-4 mb-6 min-h-[100px] max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
<template x-for="(item, index) in cart" :key="index"> <template x-for="(item, index) in cart" :key="index">
<div class="flex justify-between items-center group"> <div class="flex justify-between items-center group">
<div class="flex flex-col flex-1"> <div class="flex flex-col flex-1">
<span x-text="item.name" class="text-[11px] font-medium leading-tight text-slate-300"></span> <span x-text="item.name"
<span x-text="'€' + item.price" class="text-[11px] font-black text-blue-400"></span> class="text-[11px] font-medium leading-tight text-slate-300"></span>
<span x-text="'€' + item.price"
class="text-[11px] font-black text-blue-400"></span>
</div> </div>
<button @click="removeFromCart(index)" class="text-slate-600 hover:text-red-500 transition"> <button @click="removeFromCart(index)"
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg> class="text-slate-600 hover:text-red-500 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button> </button>
</div> </div>
</template> </template>
</div> </div>
<div class="mb-6 pt-4 border-t border-slate-800 flex justify-between items-center"> <div class="mb-6 pt-4 border-t border-slate-800 flex justify-between items-center">
<label class="text-[10px] text-slate-500 uppercase font-black italic">Verzendkosten</label> <label
<input type="number" x-model="shipping" step="0.01" class="w-20 bg-slate-800 border border-slate-700 rounded-lg p-1 text-right text-xs font-bold text-blue-400 outline-none"> class="text-[10px] text-slate-500 uppercase font-black italic">Verzendkosten</label>
<input type="number" x-model="shipping" step="0.01"
class="w-20 bg-slate-800 border border-slate-700 rounded-lg p-1 text-right text-xs font-bold text-blue-400 outline-none">
</div> </div>
<div class="flex justify-between items-center mb-8 pt-4 border-t border-slate-800"> <div class="flex justify-between items-center mb-8 pt-4 border-t border-slate-800">
@ -172,8 +294,8 @@
<span class="text-3xl font-black text-green-400" x-text="'€' + total"></span> <span class="text-3xl font-black text-green-400" x-text="'€' + total"></span>
</div> </div>
<button @click="submitOrder()" <button @click="submitOrder()"
:disabled="submitting || !form.email || !meta.mediacode || cart.length === 0" :disabled="submitting || !form.email || !meta.mediacode || cart.length === 0"
class="w-full bg-blue-600 hover:bg-blue-500 p-6 rounded-2xl font-black text-lg shadow-xl disabled:opacity-20 uppercase tracking-tighter transition active:scale-95"> class="w-full bg-blue-600 hover:bg-blue-500 p-6 rounded-2xl font-black text-lg shadow-xl disabled:opacity-20 uppercase tracking-tighter transition active:scale-95">
<span x-text="submitting ? 'BEZIG...' : 'BEVESTIGEN'"></span> <span x-text="submitting ? 'BEZIG...' : 'BEVESTIGEN'"></span>
</button> </button>
@ -186,68 +308,106 @@
<script> <script>
function salesApp() { function salesApp() {
return { return {
isLoggedIn: false, isLoggedIn: false,
isLoading: true, isLoading: true,
currentUser: '', currentUser: '',
loginForm: { username: '', password: '' }, loginForm: { username: '', password: '' },
products: [], products: [],
cart: [], cart: [],
activeProduct: null, activeProduct: null,
selectedProductId: '', selectedProductId: '',
selectedVariationId: '', selectedVariationId: '',
variations: [], variations: [],
extraProductId: '', extraProductId: '',
shipping: '8.95', shipping: '8.95',
submitting: false, submitting: false,
orderComplete: false, orderComplete: false,
lastOrder: { id: '', name: '', total: '' }, lastOrder: { id: '', name: '', total: '' },
form: { initials: '', lastname: '', postcode: '', houseno: '', suffix: '', street: '', city: '', email: '', phone: '' }, form: { initials: '', lastname: '', postcode: '', houseno: '', suffix: '', street: '', city: '', email: '', phone: '' },
meta: { mediacode: '' }, meta: { mediacode: '' },
recommendedOptions: [], recommendedOptions: [],
addressError: '',
productSearch: '',
extraProductSearch: '',
// THIS RUNS AUTOMATICALLY ON PAGE LOAD get filteredProducts() {
async init() { let filtered = this.products;
this.isLoading = true; if (this.productSearch.trim()) {
try { const search = this.productSearch.toLowerCase().trim();
const res = await fetch('api.php?action=get_products'); filtered = this.products.filter(p => p.name.toLowerCase().includes(search));
if (res.ok) { }
this.products = await res.json(); return filtered;
this.isLoggedIn = true; },
this.currentUser = localStorage.getItem('telvero_user') || 'Agent';
}
} catch (e) {
console.error("Session check failed");
} finally {
this.isLoading = false; // Now we know for sure
}
},
async doLogin() { get filteredExtraProducts() {
const res = await fetch('api.php?action=login', { let filtered = this.products;
method: 'POST', if (this.extraProductSearch.trim()) {
body: JSON.stringify(this.loginForm) const search = this.extraProductSearch.toLowerCase().trim();
}); filtered = this.products.filter(p => p.name.toLowerCase().includes(search));
if(res.ok) { }
const data = await res.json(); return filtered;
this.isLoggedIn = true; },
this.currentUser = data.user;
localStorage.setItem('telvero_user', data.user);
await this.init(); // This loads the products and stabilizes the session
} else {
alert("Login mislukt");
}
},
async doLogout() { // THIS RUNS AUTOMATICALLY ON PAGE LOAD
await fetch('api.php?action=logout'); async init() {
localStorage.removeItem('telvero_user'); // Clean up local storage this.isLoading = true;
location.reload(); 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() { selectProduct() {
const p = this.products.find(x => x.id == this.selectedProductId); const p = this.products.find(x => x.id == this.selectedProductId);
if(!p) return; if (!p) return;
this.activeProduct = p; this.variations = p.variation_details || []; this.activeProduct = p; this.variations = p.variation_details || [];
this.cart = []; this.selectedVariationId = ''; this.cart = []; this.selectedVariationId = '';
if (p.type !== 'variable') { if (p.type !== 'variable') {
@ -258,48 +418,48 @@ async init() {
selectVariation() { selectVariation() {
const v = this.variations.find(x => x.id == this.selectedVariationId); const v = this.variations.find(x => x.id == this.selectedVariationId);
if(!v) return; 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.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); this.loadUpsells(this.activeProduct);
}, },
addExtraItem() { addExtraItem() {
const p = this.products.find(x => x.id == this.extraProductId); const p = this.products.find(x => x.id == this.extraProductId);
if(!p) return; if (!p) return;
this.cart.push({ id: parseInt(p.id), name: p.name, price: p.price }); this.cart.push({ id: parseInt(p.id), name: p.name, price: p.price });
this.extraProductId = ''; this.extraProductId = '';
}, },
loadRecommendations(product) { loadRecommendations(product) {
this.recommendedOptions = []; this.recommendedOptions = [];
// voorkeur: gecombineerde lijst vanuit API // voorkeur: gecombineerde lijst vanuit API
let ids = []; let ids = [];
if (product.recommended_ids && product.recommended_ids.length) { if (product.recommended_ids && product.recommended_ids.length) {
ids = product.recommended_ids.map(id => parseInt(id)); ids = product.recommended_ids.map(id => parseInt(id));
} else { } else {
// fallback: combineer Woo upsells + cross-sells // fallback: combineer Woo upsells + cross-sells
const ups = (product.upsell_ids || []).map(id => parseInt(id)); const ups = (product.upsell_ids || []).map(id => parseInt(id));
const crs = (product.cross_sell_ids || []).map(id => parseInt(id)); const crs = (product.cross_sell_ids || []).map(id => parseInt(id));
ids = [...ups, ...crs]; ids = [...ups, ...crs];
} }
ids = [...new Set(ids)].filter(Boolean); ids = [...new Set(ids)].filter(Boolean);
// filter op producten die we al in de products-lijst hebben // filter op producten die we al in de products-lijst hebben
this.recommendedOptions = this.products.filter(p => ids.includes(parseInt(p.id))); this.recommendedOptions = this.products.filter(p => ids.includes(parseInt(p.id)));
}, },
removeFromCart(index) { this.cart.splice(index, 1); }, removeFromCart(index) { this.cart.splice(index, 1); },
getVarName(v) { return v.attributes.map(a => a.option).join(' '); }, getVarName(v) { return v.attributes.map(a => a.option).join(' '); },
toggleUpsell(u) { toggleUpsell(u) {
const idx = this.cart.findIndex(i => parseInt(i.id) === parseInt(u.id)); const idx = this.cart.findIndex(i => parseInt(i.id) === parseInt(u.id));
if(idx > -1) this.cart.splice(idx, 1); if (idx > -1) this.cart.splice(idx, 1);
else this.cart.push({ id: parseInt(u.id), name: u.name, price: u.price }); 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)); }, isInCart(id) { return this.cart.some(i => parseInt(i.id) === parseInt(id)); },
get total() { get total() {
const itemsTotal = this.cart.reduce((sum, item) => sum + parseFloat(item.price), 0); const itemsTotal = this.cart.reduce((sum, item) => sum + parseFloat(item.price), 0);
return (itemsTotal + (parseFloat(this.shipping) || 0)).toFixed(2); return (itemsTotal + (parseFloat(this.shipping) || 0)).toFixed(2);
}, },
@ -312,45 +472,59 @@ loadRecommendations(product) {
const payload = { const payload = {
mediacode_internal: this.meta.mediacode, mediacode_internal: this.meta.mediacode,
shipping_total: this.shipping, shipping_total: this.shipping,
billing: { billing: {
first_name: this.form.initials, last_name: this.form.lastname, first_name: this.form.initials, last_name: this.form.lastname,
address_1: (this.form.street + ' ' + this.form.houseno + ' ' + (this.form.suffix || '')).trim(), 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 city: this.form.city, postcode: this.form.postcode, country: 'NL', email: this.form.email, phone: this.form.phone
}, },
shipping: { shipping: {
first_name: this.form.initials, last_name: this.form.lastname, first_name: this.form.initials, last_name: this.form.lastname,
address_1: (this.form.street + ' ' + this.form.houseno + ' ' + (this.form.suffix || '')).trim(), 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 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 })) line_items: this.cart.map(i => ({ product_id: i.id, variation_id: i.variation_id || 0, quantity: 1 }))
}; };
try { try {
const res = await fetch('api.php?action=create_order', { method: 'POST', body: JSON.stringify(payload) }); const res = await fetch('api.php?action=create_order', { method: 'POST', body: JSON.stringify(payload) });
const result = await res.json(); const result = await res.json();
if(result.success) { if (result.success) {
this.lastOrder = { id: result.order_id, name: this.form.initials + ' ' + this.form.lastname, total: result.total }; this.lastOrder = { id: result.order_id, name: this.form.initials + ' ' + this.form.lastname, total: result.total };
this.orderComplete = true; this.orderComplete = true;
} else { alert("Fout: " + result.error); } } else { alert("Fout: " + result.error); }
} catch(e) { alert("Systeemfout"); } } catch (e) { alert("Systeemfout"); }
this.submitting = false; this.submitting = false;
}, },
resetForNewOrder() { resetForNewOrder() {
this.cart = []; this.selectedProductId = ''; this.selectedVariationId = ''; this.shipping = '8.95'; this.cart = []; this.selectedProductId = ''; this.selectedVariationId = ''; this.shipping = '8.95';
this.form = { initials: '', lastname: '', postcode: '', houseno: '', suffix: '', street: '', city: '', email: '', phone: '' }; this.form = { initials: '', lastname: '', postcode: '', houseno: '', suffix: '', street: '', city: '', email: '', phone: '' };
this.addressError = '';
this.orderComplete = false; this.orderComplete = false;
}, },
async lookupAddress() { async lookupAddress() {
this.addressError = '';
if (this.form.postcode.length >= 6 && this.form.houseno) { if (this.form.postcode.length >= 6 && this.form.houseno) {
const res = await fetch(`api.php?action=postcode_check&postcode=${this.form.postcode}&number=${this.form.houseno}`); try {
const data = await res.json(); const res = await fetch(`api.php?action=postcode_check&postcode=${this.form.postcode}&number=${this.form.houseno}`);
if (data.street) { this.form.street = data.street.toUpperCase(); this.form.city = data.city.toUpperCase(); } 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> </script>
</body> </body>
</html> </html>