Discountrules added
This commit is contained in:
parent
79d4861e6d
commit
cadd9d962e
2
.gitignore
vendored
2
.gitignore
vendored
@ -8,7 +8,7 @@
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
woo-discount-rules/*
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
.env
|
||||
|
||||
@ -3,9 +3,11 @@
|
||||
* Products Action - Get products with enriched data
|
||||
*
|
||||
* Optimized to fetch only required fields for telesales app
|
||||
* Includes BOGO rules from Woo Discount Rules plugin
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../services/UpsellService.php';
|
||||
require_once __DIR__ . '/../services/DiscountRulesService.php';
|
||||
|
||||
/**
|
||||
* Handle get_products action
|
||||
@ -14,8 +16,27 @@ require_once __DIR__ . '/../services/UpsellService.php';
|
||||
*/
|
||||
function handleGetProducts(): void
|
||||
{
|
||||
$logFile = __DIR__ . '/../../logs/products_debug.log';
|
||||
$logDir = dirname($logFile);
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
$log = function($msg, $data = null) use ($logFile) {
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$logMessage = "[{$timestamp}] {$msg}";
|
||||
if ($data !== null) {
|
||||
$logMessage .= "\n" . print_r($data, true);
|
||||
}
|
||||
$logMessage .= "\n" . str_repeat('-', 80) . "\n";
|
||||
file_put_contents($logFile, $logMessage, FILE_APPEND);
|
||||
};
|
||||
|
||||
try {
|
||||
$log("handleGetProducts() started");
|
||||
|
||||
$woocommerce = getWooCommerce();
|
||||
$log("WooCommerce client initialized");
|
||||
|
||||
// Only fetch fields needed for telesales app
|
||||
$productFields = 'id,name,price,type,upsell_ids,cross_sell_ids';
|
||||
@ -26,12 +47,29 @@ function handleGetProducts(): void
|
||||
'per_page' => 100,
|
||||
'_fields' => $productFields
|
||||
]);
|
||||
$log("Fetched products from WooCommerce", ['count' => count($products)]);
|
||||
|
||||
// Build service maps
|
||||
$cuw_map = UpsellService::buildProductMap();
|
||||
$log("Built CUW map");
|
||||
|
||||
// Get BOGO rules from Woo Discount Rules
|
||||
$log("Creating DiscountRulesService...");
|
||||
$discountService = new DiscountRulesService();
|
||||
$log("DiscountRulesService created");
|
||||
|
||||
$productIds = array_map(fn($p) => (int) $p->id, $products);
|
||||
$log("Product IDs extracted", ['count' => count($productIds)]);
|
||||
|
||||
$log("Calling buildProductBogoMap...");
|
||||
$bogo_map = $discountService->buildProductBogoMap($productIds);
|
||||
$log("BOGO map built", ['products_with_bogo' => count($bogo_map)]);
|
||||
|
||||
$enriched = [];
|
||||
|
||||
foreach ($products as $product) {
|
||||
$productId = (int) $product->id;
|
||||
|
||||
// Get variations for variable products (only needed fields)
|
||||
$variation_details = [];
|
||||
if ($product->type === 'variable') {
|
||||
@ -52,17 +90,20 @@ function handleGetProducts(): void
|
||||
? array_map('intval', (array) $product->cross_sell_ids)
|
||||
: [];
|
||||
|
||||
$cuw_ids = $cuw_map[(int)$product->id] ?? [];
|
||||
$cuw_ids = $cuw_map[$productId] ?? [];
|
||||
|
||||
$recommended_ids = array_values(array_unique(array_filter(array_merge(
|
||||
$upsell_ids,
|
||||
$cross_sell_ids,
|
||||
$cuw_ids
|
||||
))));
|
||||
|
||||
// Get BOGO rules for this product
|
||||
$bogo_rules = $bogo_map[$productId] ?? [];
|
||||
|
||||
// Build minimal product object with only needed fields
|
||||
$enriched[] = [
|
||||
'id' => (int) $product->id,
|
||||
'id' => $productId,
|
||||
'name' => $product->name,
|
||||
'price' => $product->price,
|
||||
'type' => $product->type,
|
||||
@ -70,12 +111,28 @@ function handleGetProducts(): void
|
||||
'cross_sell_ids' => $cross_sell_ids,
|
||||
'variation_details' => $variation_details,
|
||||
'cuw_ids' => $cuw_ids,
|
||||
'recommended_ids' => $recommended_ids
|
||||
'recommended_ids' => $recommended_ids,
|
||||
'bogo_rules' => $bogo_rules
|
||||
];
|
||||
}
|
||||
|
||||
$log("Returning " . count($enriched) . " enriched products");
|
||||
echo json_encode($enriched);
|
||||
} catch (Exception $e) {
|
||||
echo json_encode([]);
|
||||
$log("ERROR in handleGetProducts", [
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
} catch (Error $e) {
|
||||
$log("FATAL ERROR in handleGetProducts", [
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
use Automattic\WooCommerce\Client;
|
||||
|
||||
/**
|
||||
* Get database connection
|
||||
* Get database connection (Sales Panel database)
|
||||
* @return mysqli
|
||||
*/
|
||||
function getDatabase(): mysqli
|
||||
@ -33,6 +33,44 @@ function getDatabase(): mysqli
|
||||
return $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WooCommerce database connection (WordPress/WooCommerce database)
|
||||
* Used for accessing plugin tables like wp_wdr_rules
|
||||
* @return mysqli
|
||||
*/
|
||||
function getWooCommerceDatabase(): mysqli
|
||||
{
|
||||
static $wcDb = null;
|
||||
|
||||
if ($wcDb === null) {
|
||||
$wcDb = new mysqli(
|
||||
$_ENV['WC_DB_HOST'] ?? $_ENV['DB_HOST'],
|
||||
$_ENV['WC_DB_USER'] ?? $_ENV['DB_USER'],
|
||||
$_ENV['WC_DB_PASS'] ?? $_ENV['DB_PASS'],
|
||||
$_ENV['WC_DB_NAME'] ?? $_ENV['DB_NAME']
|
||||
);
|
||||
|
||||
if ($wcDb->connect_error) {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(500);
|
||||
die(json_encode(['error' => 'WooCommerce database connectie mislukt: ' . $wcDb->connect_error]));
|
||||
}
|
||||
|
||||
$wcDb->set_charset('utf8mb4');
|
||||
}
|
||||
|
||||
return $wcDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WooCommerce database table prefix
|
||||
* @return string
|
||||
*/
|
||||
function getWooCommerceDbPrefix(): string
|
||||
{
|
||||
return $_ENV['WC_DB_PREFIX'] ?? 'wp_';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WooCommerce client
|
||||
* @return Client
|
||||
|
||||
542
api/services/DiscountRulesService.php
Normal file
542
api/services/DiscountRulesService.php
Normal file
@ -0,0 +1,542 @@
|
||||
<?php
|
||||
/**
|
||||
* DiscountRulesService - Fetches BOGO rules from Woo Discount Rules plugin
|
||||
*
|
||||
* Queries the wp_wdr_rules table to get active Buy X Get X and Buy X Get Y rules
|
||||
* and maps them to products for the telesales panel.
|
||||
*/
|
||||
|
||||
class DiscountRulesService
|
||||
{
|
||||
private mysqli $db;
|
||||
private string $tablePrefix;
|
||||
private bool $debug = true;
|
||||
private string $logFile;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Use WooCommerce database for accessing plugin tables
|
||||
$this->db = getWooCommerceDatabase();
|
||||
$this->tablePrefix = getWooCommerceDbPrefix();
|
||||
$this->logFile = __DIR__ . '/../../logs/bogo_debug.log';
|
||||
|
||||
// Ensure logs directory exists
|
||||
$logDir = dirname($this->logFile);
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
$this->log("DiscountRulesService initialized", [
|
||||
'tablePrefix' => $this->tablePrefix,
|
||||
'db_connected' => $this->db->ping()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug message
|
||||
*
|
||||
* @param string $message
|
||||
* @param mixed $data
|
||||
*/
|
||||
private function log(string $message, $data = null): void
|
||||
{
|
||||
if (!$this->debug) return;
|
||||
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$logMessage = "[{$timestamp}] {$message}";
|
||||
|
||||
if ($data !== null) {
|
||||
$logMessage .= "\n" . print_r($data, true);
|
||||
}
|
||||
|
||||
$logMessage .= "\n" . str_repeat('-', 80) . "\n";
|
||||
|
||||
file_put_contents($this->logFile, $logMessage, FILE_APPEND);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active BOGO rules mapped by product ID
|
||||
*
|
||||
* @return array<int, array> Map of product_id => array of BOGO rules
|
||||
*/
|
||||
public function getBogoRulesMap(): array
|
||||
{
|
||||
$rules = $this->fetchActiveBogoRules();
|
||||
return $this->mapRulesToProducts($rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch active BOGO rules from database
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function fetchActiveBogoRules(): array
|
||||
{
|
||||
$table = $this->tablePrefix . 'wdr_rules';
|
||||
$currentTime = time();
|
||||
|
||||
$this->log("fetchActiveBogoRules() called", [
|
||||
'table' => $table,
|
||||
'currentTime' => $currentTime,
|
||||
'tablePrefix' => $this->tablePrefix
|
||||
]);
|
||||
|
||||
// First check if table exists
|
||||
$tableCheck = $this->db->query("SHOW TABLES LIKE '{$table}'");
|
||||
if ($tableCheck->num_rows === 0) {
|
||||
$this->log("ERROR: Table {$table} does not exist!");
|
||||
return [];
|
||||
}
|
||||
$this->log("Table {$table} exists");
|
||||
|
||||
$query = "SELECT
|
||||
id,
|
||||
title,
|
||||
filters,
|
||||
buy_x_get_x_adjustments,
|
||||
buy_x_get_y_adjustments,
|
||||
advanced_discount_message
|
||||
FROM {$table}
|
||||
WHERE enabled = 1
|
||||
AND deleted = 0
|
||||
AND (date_from <= ? OR date_from IS NULL)
|
||||
AND (date_to >= ? OR date_to IS NULL)
|
||||
AND (usage_limits > used_limits OR used_limits IS NULL OR usage_limits = 0)
|
||||
AND (
|
||||
(buy_x_get_x_adjustments IS NOT NULL AND buy_x_get_x_adjustments != '{}' AND buy_x_get_x_adjustments != '[]')
|
||||
OR (buy_x_get_y_adjustments IS NOT NULL AND buy_x_get_y_adjustments != '{}' AND buy_x_get_y_adjustments != '[]')
|
||||
)
|
||||
ORDER BY priority ASC";
|
||||
|
||||
$this->log("Executing query", $query);
|
||||
|
||||
$stmt = $this->db->prepare($query);
|
||||
if (!$stmt) {
|
||||
$this->log("ERROR: Failed to prepare statement", $this->db->error);
|
||||
return [];
|
||||
}
|
||||
|
||||
$stmt->bind_param('ii', $currentTime, $currentTime);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
$this->log("ERROR: Failed to execute statement", $stmt->error);
|
||||
$stmt->close();
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$rules = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$rules[] = $row;
|
||||
}
|
||||
|
||||
$this->log("Found " . count($rules) . " BOGO rules", $rules);
|
||||
|
||||
$stmt->close();
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map rules to products based on filters
|
||||
*
|
||||
* @param array $rules
|
||||
* @return array<int, array>
|
||||
*/
|
||||
private function mapRulesToProducts(array $rules): array
|
||||
{
|
||||
$productMap = [];
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$productIds = $this->extractProductIdsFromFilters($rule['filters']);
|
||||
$bogoData = $this->parseBogoAdjustments($rule);
|
||||
|
||||
if (empty($bogoData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($productIds as $productId) {
|
||||
if (!isset($productMap[$productId])) {
|
||||
$productMap[$productId] = [];
|
||||
}
|
||||
$productMap[$productId][] = $bogoData;
|
||||
}
|
||||
}
|
||||
|
||||
return $productMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract product IDs from rule filters
|
||||
*
|
||||
* @param string|null $filtersJson
|
||||
* @return array<int>
|
||||
*/
|
||||
private function extractProductIdsFromFilters(?string $filtersJson): array
|
||||
{
|
||||
if (empty($filtersJson)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$filters = json_decode($filtersJson, true);
|
||||
if (!is_array($filters)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$productIds = [];
|
||||
|
||||
foreach ($filters as $filter) {
|
||||
$type = $filter['type'] ?? '';
|
||||
$value = $filter['value'] ?? [];
|
||||
|
||||
// Direct product filter
|
||||
if ($type === 'products' && !empty($value)) {
|
||||
$productIds = array_merge($productIds, array_map('intval', $value));
|
||||
}
|
||||
|
||||
// Category filter - we'd need to expand this to actual products
|
||||
// For now, we'll handle this separately if needed
|
||||
if ($type === 'product_category' && !empty($value)) {
|
||||
$categoryProducts = $this->getProductsByCategories($value);
|
||||
$productIds = array_merge($productIds, $categoryProducts);
|
||||
}
|
||||
|
||||
// All products filter
|
||||
if ($type === 'all_products') {
|
||||
// Return empty to indicate "all products" - handled differently
|
||||
return ['all'];
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($productIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product IDs by category IDs
|
||||
*
|
||||
* @param array $categoryIds
|
||||
* @return array<int>
|
||||
*/
|
||||
private function getProductsByCategories(array $categoryIds): array
|
||||
{
|
||||
if (empty($categoryIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$table = $this->tablePrefix . 'term_relationships';
|
||||
$placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
|
||||
|
||||
$query = "SELECT DISTINCT object_id
|
||||
FROM {$table}
|
||||
WHERE term_taxonomy_id IN ({$placeholders})";
|
||||
|
||||
$stmt = $this->db->prepare($query);
|
||||
$types = str_repeat('i', count($categoryIds));
|
||||
$stmt->bind_param($types, ...$categoryIds);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$productIds = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$productIds[] = (int) $row['object_id'];
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $productIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse BOGO adjustments from rule
|
||||
*
|
||||
* @param array $rule
|
||||
* @return array|null
|
||||
*/
|
||||
private function parseBogoAdjustments(array $rule): ?array
|
||||
{
|
||||
// Try Buy X Get X first
|
||||
$bxgx = $this->parseBuyXGetX($rule);
|
||||
if ($bxgx) {
|
||||
return $bxgx;
|
||||
}
|
||||
|
||||
// Try Buy X Get Y
|
||||
$bxgy = $this->parseBuyXGetY($rule);
|
||||
if ($bxgy) {
|
||||
return $bxgy;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract badge_text from advanced_discount_message
|
||||
*
|
||||
* @param array $rule
|
||||
* @return string|null
|
||||
*/
|
||||
private function extractBadgeText(array $rule): ?string
|
||||
{
|
||||
$advancedMessage = $rule['advanced_discount_message'] ?? null;
|
||||
if (empty($advancedMessage)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($advancedMessage, true);
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look for badge_text in discount_badge
|
||||
if (isset($data['discount_badge']['badge_text']) && !empty($data['discount_badge']['badge_text'])) {
|
||||
return $data['discount_badge']['badge_text'];
|
||||
}
|
||||
|
||||
// Fallback to badge_text at root level
|
||||
if (isset($data['badge_text']) && !empty($data['badge_text'])) {
|
||||
return $data['badge_text'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Buy X Get X adjustments (e.g., 1+1 free)
|
||||
*
|
||||
* @param array $rule
|
||||
* @return array|null
|
||||
*/
|
||||
private function parseBuyXGetX(array $rule): ?array
|
||||
{
|
||||
$json = $rule['buy_x_get_x_adjustments'] ?? null;
|
||||
if (empty($json) || $json === '{}' || $json === '[]') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($json, true);
|
||||
if (!isset($data['ranges']) || empty($data['ranges'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the first range (most common case)
|
||||
$range = reset($data['ranges']);
|
||||
if (!$range) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$buyQty = (int) ($range['from'] ?? 0);
|
||||
$getQty = (int) ($range['free_qty'] ?? 0);
|
||||
$freeType = $range['free_type'] ?? 'free_product';
|
||||
$freeValue = (float) ($range['free_value'] ?? 100);
|
||||
$recursive = !empty($range['recursive']);
|
||||
|
||||
if ($buyQty <= 0 || $getQty <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate human-readable label (fallback)
|
||||
$label = $this->generateBogoLabel($buyQty, $getQty, $freeType, $freeValue);
|
||||
|
||||
// Extract badge_text from advanced_discount_message
|
||||
$badgeText = $this->extractBadgeText($rule);
|
||||
|
||||
return [
|
||||
'rule_id' => (int) $rule['id'],
|
||||
'rule_title' => $rule['title'],
|
||||
'type' => 'buy_x_get_x',
|
||||
'label' => $label,
|
||||
'badge_text' => $badgeText,
|
||||
'buy_qty' => $buyQty,
|
||||
'get_qty' => $getQty,
|
||||
'discount_type' => $freeType,
|
||||
'discount_value' => $freeValue,
|
||||
'recursive' => $recursive,
|
||||
'get_product_ids' => null // Same product
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Buy X Get Y adjustments (e.g., buy A get B free)
|
||||
*
|
||||
* @param array $rule
|
||||
* @return array|null
|
||||
*/
|
||||
private function parseBuyXGetY(array $rule): ?array
|
||||
{
|
||||
$json = $rule['buy_x_get_y_adjustments'] ?? null;
|
||||
if (empty($json) || $json === '{}' || $json === '[]') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($json, true);
|
||||
|
||||
// Check if it's a valid BXGY rule
|
||||
$type = $data['type'] ?? '';
|
||||
if ($type !== 'bxgy_product' && $type !== 'bxgy_category') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isset($data['ranges']) || empty($data['ranges'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the first range
|
||||
$range = reset($data['ranges']);
|
||||
if (!$range) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$buyQty = (int) ($range['from'] ?? 0);
|
||||
$getQty = (int) ($range['free_qty'] ?? 0);
|
||||
$freeType = $range['free_type'] ?? 'free_product';
|
||||
$freeValue = (float) ($range['free_value'] ?? 100);
|
||||
$getProductIds = $range['products'] ?? [];
|
||||
$recursive = !empty($range['recursive']);
|
||||
|
||||
if ($buyQty <= 0 || $getQty <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate human-readable label (fallback)
|
||||
$label = $this->generateBogoLabel($buyQty, $getQty, $freeType, $freeValue, true);
|
||||
|
||||
// Extract badge_text from advanced_discount_message
|
||||
$badgeText = $this->extractBadgeText($rule);
|
||||
|
||||
return [
|
||||
'rule_id' => (int) $rule['id'],
|
||||
'rule_title' => $rule['title'],
|
||||
'type' => 'buy_x_get_y',
|
||||
'label' => $label,
|
||||
'badge_text' => $badgeText,
|
||||
'buy_qty' => $buyQty,
|
||||
'get_qty' => $getQty,
|
||||
'discount_type' => $freeType,
|
||||
'discount_value' => $freeValue,
|
||||
'recursive' => $recursive,
|
||||
'get_product_ids' => array_map('intval', $getProductIds)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate human-readable BOGO label
|
||||
*
|
||||
* @param int $buyQty
|
||||
* @param int $getQty
|
||||
* @param string $discountType
|
||||
* @param float $discountValue
|
||||
* @param bool $isDifferentProduct
|
||||
* @return string
|
||||
*/
|
||||
private function generateBogoLabel(
|
||||
int $buyQty,
|
||||
int $getQty,
|
||||
string $discountType,
|
||||
float $discountValue,
|
||||
bool $isDifferentProduct = false
|
||||
): string {
|
||||
// Common patterns
|
||||
if ($discountType === 'free_product' || $discountValue >= 100) {
|
||||
if ($buyQty === 1 && $getQty === 1) {
|
||||
return $isDifferentProduct ? 'Koop 1, krijg 1 ander gratis' : '1+1 Gratis';
|
||||
}
|
||||
if ($buyQty === 2 && $getQty === 1) {
|
||||
return $isDifferentProduct ? 'Koop 2, krijg 1 ander gratis' : '2+1 Gratis';
|
||||
}
|
||||
if ($buyQty === 3 && $getQty === 1) {
|
||||
return $isDifferentProduct ? 'Koop 3, krijg 1 ander gratis' : '3+1 Gratis';
|
||||
}
|
||||
return "Koop {$buyQty}, krijg {$getQty} gratis";
|
||||
}
|
||||
|
||||
if ($discountType === 'percentage') {
|
||||
$pct = (int) $discountValue;
|
||||
return "Koop {$buyQty}, krijg {$getQty} met {$pct}% korting";
|
||||
}
|
||||
|
||||
if ($discountType === 'flat') {
|
||||
return "Koop {$buyQty}, krijg {$getQty} met €" . number_format($discountValue, 2, ',', '.') . " korting";
|
||||
}
|
||||
|
||||
return "Koop {$buyQty}, krijg {$getQty}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a complete product map including "all products" rules
|
||||
*
|
||||
* @param array $productIds List of all product IDs to check
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public function buildProductBogoMap(array $productIds): array
|
||||
{
|
||||
$this->log("buildProductBogoMap() called", [
|
||||
'productIds_count' => count($productIds),
|
||||
'productIds_sample' => array_slice($productIds, 0, 5)
|
||||
]);
|
||||
|
||||
try {
|
||||
$rules = $this->fetchActiveBogoRules();
|
||||
$this->log("Fetched rules", ['count' => count($rules)]);
|
||||
|
||||
$productMap = [];
|
||||
$allProductsRules = [];
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$this->log("Processing rule", [
|
||||
'id' => $rule['id'],
|
||||
'title' => $rule['title'],
|
||||
'filters' => $rule['filters']
|
||||
]);
|
||||
|
||||
$filterProductIds = $this->extractProductIdsFromFilters($rule['filters']);
|
||||
$this->log("Extracted filter product IDs", $filterProductIds);
|
||||
|
||||
$bogoData = $this->parseBogoAdjustments($rule);
|
||||
$this->log("Parsed BOGO data", $bogoData);
|
||||
|
||||
if (empty($bogoData)) {
|
||||
$this->log("Skipping rule - no BOGO data");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is an "all products" rule
|
||||
if ($filterProductIds === ['all']) {
|
||||
$this->log("Rule applies to all products");
|
||||
$allProductsRules[] = $bogoData;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($filterProductIds as $productId) {
|
||||
if (!isset($productMap[$productId])) {
|
||||
$productMap[$productId] = [];
|
||||
}
|
||||
$productMap[$productId][] = $bogoData;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply "all products" rules to all products
|
||||
if (!empty($allProductsRules)) {
|
||||
$this->log("Applying 'all products' rules", ['count' => count($allProductsRules)]);
|
||||
foreach ($productIds as $productId) {
|
||||
if (!isset($productMap[$productId])) {
|
||||
$productMap[$productId] = [];
|
||||
}
|
||||
$productMap[$productId] = array_merge($productMap[$productId], $allProductsRules);
|
||||
}
|
||||
}
|
||||
|
||||
$this->log("Final product map", [
|
||||
'products_with_bogo' => count($productMap),
|
||||
'sample' => array_slice($productMap, 0, 3, true)
|
||||
]);
|
||||
|
||||
return $productMap;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->log("ERROR in buildProductBogoMap", [
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
70
index.html
70
index.html
@ -22,6 +22,7 @@
|
||||
</style>
|
||||
<!-- Load JavaScript modules -->
|
||||
<script src="js/services/api.js"></script>
|
||||
<script src="js/services/bogo.js"></script>
|
||||
<script src="js/components/cart.js"></script>
|
||||
<script src="js/components/products.js"></script>
|
||||
<script src="js/components/forms.js"></script>
|
||||
@ -136,7 +137,13 @@
|
||||
<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 class="flex items-center justify-between">
|
||||
<span x-text="p.name"></span>
|
||||
<span x-show="p.bogo_rules && p.bogo_rules.length > 0"
|
||||
x-text="p.bogo_rules[0]?.badge_text || p.bogo_rules[0]?.label"
|
||||
class="ml-2 px-2 py-1 bg-red-500 text-white text-[9px] font-black rounded-full uppercase">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="filteredProducts.length === 0" class="p-4 text-slate-400 text-sm text-center">
|
||||
@ -146,6 +153,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BOGO Actie Banner -->
|
||||
<div x-show="activeProduct && activeProduct.bogo_rules && activeProduct.bogo_rules.length > 0" x-cloak
|
||||
class="mb-6 p-4 bg-gradient-to-r from-red-500 to-pink-500 rounded-2xl text-white shadow-lg">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-white/20 p-2 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-black text-sm uppercase tracking-wide" x-text="activeProduct?.bogo_rules?.[0]?.badge_text || activeProduct?.bogo_rules?.[0]?.label || ''"></p>
|
||||
<p class="text-[10px] opacity-80" x-text="'Actie: ' + (activeProduct?.bogo_rules?.[0]?.rule_title || '')"></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Button for flat/percentage discount BOGO rules -->
|
||||
<button
|
||||
x-show="activeProduct?.bogo_rules?.[0]?.discount_type === 'flat' || activeProduct?.bogo_rules?.[0]?.discount_type === 'percentage'"
|
||||
@click="addBogoDiscountedItem(activeProduct)"
|
||||
:disabled="!canAddBogoDiscountedItem(activeProduct)"
|
||||
class="bg-white text-red-500 px-4 py-2 rounded-xl font-black text-xs uppercase shadow-md hover:bg-red-50 transition disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap">
|
||||
<span x-text="'+ €' + getBogoDiscountedPrice(activeProduct)"></span>
|
||||
</button>
|
||||
</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">
|
||||
<select x-model="selectedVariationId" @change="selectVariation()"
|
||||
@ -269,14 +302,33 @@
|
||||
<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">
|
||||
<div class="flex justify-between items-center group">
|
||||
<div class="flex justify-between items-center group"
|
||||
:class="{
|
||||
'bg-green-900/30 -mx-2 px-2 py-1 rounded-lg': item.isFree,
|
||||
'bg-amber-900/30 -mx-2 px-2 py-1 rounded-lg': item.isDiscounted && !item.isFree
|
||||
}">
|
||||
<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.price"
|
||||
class="text-[11px] font-black text-blue-400"></span>
|
||||
class="text-[11px] font-medium leading-tight"
|
||||
:class="{
|
||||
'text-green-400': item.isFree,
|
||||
'text-amber-400': item.isDiscounted && !item.isFree,
|
||||
'text-slate-300': !item.isFree && !item.isDiscounted
|
||||
}"></span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-show="(item.isFree || item.isDiscounted) && item.originalPrice"
|
||||
x-text="'€' + item.originalPrice"
|
||||
class="text-[10px] text-slate-500 line-through"></span>
|
||||
<span x-text="'€' + item.price"
|
||||
class="text-[11px] font-black"
|
||||
:class="{
|
||||
'text-green-400': item.isFree,
|
||||
'text-amber-400': item.isDiscounted && !item.isFree,
|
||||
'text-blue-400': !item.isFree && !item.isDiscounted
|
||||
}"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="removeFromCart(index)"
|
||||
<button x-show="!item.isFree" @click="removeFromCart(index)"
|
||||
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">
|
||||
@ -284,6 +336,12 @@
|
||||
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>
|
||||
<span x-show="item.isFree && !item.isDiscounted" class="text-green-400 text-[9px] font-black uppercase">
|
||||
Gratis
|
||||
</span>
|
||||
<span x-show="item.isDiscounted && !item.isFree" class="text-amber-400 text-[9px] font-black uppercase">
|
||||
Korting
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@ -27,6 +27,14 @@ function salesApp() {
|
||||
return CartComponent.getComputed().total.call(this);
|
||||
},
|
||||
|
||||
get subtotal() {
|
||||
return CartComponent.getComputed().subtotal.call(this);
|
||||
},
|
||||
|
||||
get freeItemsCount() {
|
||||
return CartComponent.getComputed().freeItemsCount.call(this);
|
||||
},
|
||||
|
||||
get filteredProducts() {
|
||||
return ProductsComponent.getComputed().filteredProducts.call(this);
|
||||
},
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Cart Component - Handles shopping cart functionality
|
||||
* Cart Component - Handles shopping cart functionality with BOGO support
|
||||
*/
|
||||
const CartComponent = {
|
||||
/**
|
||||
@ -9,7 +9,10 @@ const CartComponent = {
|
||||
getInitialState() {
|
||||
return {
|
||||
cart: [],
|
||||
shipping: '8.95'
|
||||
shipping: '8.95',
|
||||
bogoDiscount: 0,
|
||||
bogoFreeItems: [],
|
||||
appliedBogoRules: []
|
||||
};
|
||||
},
|
||||
|
||||
@ -20,7 +23,7 @@ const CartComponent = {
|
||||
getMethods() {
|
||||
return {
|
||||
/**
|
||||
* Add item to cart
|
||||
* Add item to cart and recalculate BOGO
|
||||
* @param {Object} item - Item with id, name, price, and optional variation_id
|
||||
*/
|
||||
addToCart(item) {
|
||||
@ -28,42 +31,213 @@ const CartComponent = {
|
||||
id: parseInt(item.id),
|
||||
variation_id: item.variation_id || 0,
|
||||
name: item.name,
|
||||
price: item.price
|
||||
price: item.price,
|
||||
isFree: item.isFree || false
|
||||
});
|
||||
this.recalculateBogo();
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove item from cart by index
|
||||
* @param {number} index
|
||||
* Remove item from cart by index and recalculate BOGO
|
||||
* @param {number} index
|
||||
*/
|
||||
removeFromCart(index) {
|
||||
const item = this.cart[index];
|
||||
this.cart.splice(index, 1);
|
||||
|
||||
// If removing a paid item, recalculate BOGO
|
||||
if (!item.isFree) {
|
||||
this.recalculateBogo();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if product is in cart
|
||||
* @param {number} id
|
||||
* Check if product is in cart (excluding free items)
|
||||
* @param {number} id
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isInCart(id) {
|
||||
return this.cart.some(i => parseInt(i.id) === parseInt(id));
|
||||
return this.cart.some(i => parseInt(i.id) === parseInt(id) && !i.isFree);
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle upsell product in cart
|
||||
* @param {Object} product
|
||||
* @param {Object} product
|
||||
*/
|
||||
toggleUpsell(product) {
|
||||
const idx = this.cart.findIndex(i => parseInt(i.id) === parseInt(product.id));
|
||||
const idx = this.cart.findIndex(i => parseInt(i.id) === parseInt(product.id) && !i.isFree);
|
||||
if (idx > -1) {
|
||||
this.cart.splice(idx, 1);
|
||||
} else {
|
||||
this.cart.push({
|
||||
id: parseInt(product.id),
|
||||
name: product.name,
|
||||
price: product.price
|
||||
price: product.price,
|
||||
isFree: false
|
||||
});
|
||||
}
|
||||
this.recalculateBogo();
|
||||
},
|
||||
|
||||
/**
|
||||
* Recalculate BOGO discounts based on current cart
|
||||
*/
|
||||
recalculateBogo() {
|
||||
// Remove existing free items from cart
|
||||
this.cart = this.cart.filter(item => !item.isFree);
|
||||
|
||||
// Calculate new BOGO
|
||||
const bogoResult = BogoService.calculateBogoForCart(this.cart, this.products);
|
||||
|
||||
this.bogoDiscount = bogoResult.discountAmount;
|
||||
this.bogoFreeItems = bogoResult.freeItems;
|
||||
this.appliedBogoRules = bogoResult.appliedRules;
|
||||
|
||||
// Add free items to cart
|
||||
for (const freeItem of bogoResult.freeItems) {
|
||||
this.cart.push({
|
||||
id: parseInt(freeItem.id),
|
||||
variation_id: 0,
|
||||
name: freeItem.name,
|
||||
price: freeItem.price,
|
||||
originalPrice: freeItem.originalPrice,
|
||||
isFree: true,
|
||||
ruleId: freeItem.ruleId
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add discounted BOGO item to cart manually
|
||||
* For rules like "2e voor €19,95" where the 2nd item gets a discount
|
||||
* @param {Object} product - The product with BOGO rules
|
||||
*/
|
||||
addBogoDiscountedItem(product) {
|
||||
if (!product || !product.bogo_rules || product.bogo_rules.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rule = product.bogo_rules[0];
|
||||
|
||||
// Check if this is a discount type rule (not free)
|
||||
if (rule.discount_type !== 'flat' && rule.discount_type !== 'percentage') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate discounted price
|
||||
let discountedPrice;
|
||||
let discountLabel;
|
||||
|
||||
if (rule.discount_type === 'flat') {
|
||||
discountedPrice = (parseFloat(product.price) - rule.discount_value).toFixed(2);
|
||||
discountLabel = '€' + rule.discount_value.toFixed(2) + ' korting';
|
||||
} else if (rule.discount_type === 'percentage') {
|
||||
const discount = parseFloat(product.price) * (rule.discount_value / 100);
|
||||
discountedPrice = (parseFloat(product.price) - discount).toFixed(2);
|
||||
discountLabel = rule.discount_value + '% korting';
|
||||
}
|
||||
|
||||
// Add the discounted item to cart (this IS the 2nd item, not a 3rd)
|
||||
// isFree: false because it's not free, just discounted
|
||||
this.cart.push({
|
||||
id: parseInt(product.id),
|
||||
variation_id: 0,
|
||||
name: product.name + ' (' + discountLabel + ')',
|
||||
price: discountedPrice,
|
||||
originalPrice: product.price,
|
||||
isFree: false,
|
||||
isDiscounted: true,
|
||||
ruleId: rule.rule_id
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if BOGO discounted item can be added
|
||||
* For "2e voor €19,95" type rules: user needs 1 item in cart, then can add the 2nd at discount
|
||||
* @param {Object} product
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canAddBogoDiscountedItem(product) {
|
||||
if (!product || !product.bogo_rules || product.bogo_rules.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rule = product.bogo_rules[0];
|
||||
|
||||
// Only for flat or percentage discount types
|
||||
if (rule.discount_type !== 'flat' && rule.discount_type !== 'percentage') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Count how many of this product are in cart (non-discounted items)
|
||||
const fullPriceCount = this.cart.filter(item =>
|
||||
parseInt(item.id) === parseInt(product.id) && !item.isDiscounted
|
||||
).length;
|
||||
|
||||
// Count how many discounted items are already in cart
|
||||
const discountedCount = this.cart.filter(item =>
|
||||
parseInt(item.id) === parseInt(product.id) && item.isDiscounted
|
||||
).length;
|
||||
|
||||
// For "buy 2 get 1 discount" rules:
|
||||
// - buy_qty = 2 means you need to buy 2 total (1 full price + 1 discounted)
|
||||
// - So user needs (buy_qty - get_qty) = 1 full price item to qualify for 1 discounted
|
||||
const fullPriceNeeded = rule.buy_qty - rule.get_qty;
|
||||
|
||||
// Check if we have enough full-price items
|
||||
if (fullPriceCount < fullPriceNeeded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate max allowed discounted items based on full-price items
|
||||
let maxDiscounted = rule.get_qty;
|
||||
if (rule.recursive) {
|
||||
// For each set of (buy_qty - get_qty) full-price items, allow get_qty discounted
|
||||
maxDiscounted = Math.floor(fullPriceCount / fullPriceNeeded) * rule.get_qty;
|
||||
}
|
||||
|
||||
// Can add if we haven't reached the max
|
||||
return discountedCount < maxDiscounted;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the discounted price for BOGO item
|
||||
* @param {Object} product
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getBogoDiscountedPrice(product) {
|
||||
if (!product || !product.bogo_rules || product.bogo_rules.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rule = product.bogo_rules[0];
|
||||
|
||||
if (rule.discount_type === 'flat') {
|
||||
return (parseFloat(product.price) - rule.discount_value).toFixed(2);
|
||||
} else if (rule.discount_type === 'percentage') {
|
||||
const discount = parseFloat(product.price) * (rule.discount_value / 100);
|
||||
return (parseFloat(product.price) - discount).toFixed(2);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get BOGO label for a product
|
||||
* @param {Object} product
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getBogoLabel(product) {
|
||||
return BogoService.getBogoLabel(product);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if product has BOGO rules
|
||||
* @param {Object} product
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasBogoRules(product) {
|
||||
return BogoService.hasBogoRules(product);
|
||||
},
|
||||
|
||||
/**
|
||||
@ -71,6 +245,9 @@ const CartComponent = {
|
||||
*/
|
||||
clearCart() {
|
||||
this.cart = [];
|
||||
this.bogoDiscount = 0;
|
||||
this.bogoFreeItems = [];
|
||||
this.appliedBogoRules = [];
|
||||
},
|
||||
|
||||
/**
|
||||
@ -79,6 +256,9 @@ const CartComponent = {
|
||||
resetCart() {
|
||||
this.cart = [];
|
||||
this.shipping = '8.95';
|
||||
this.bogoDiscount = 0;
|
||||
this.bogoFreeItems = [];
|
||||
this.appliedBogoRules = [];
|
||||
}
|
||||
};
|
||||
},
|
||||
@ -90,12 +270,36 @@ const CartComponent = {
|
||||
getComputed() {
|
||||
return {
|
||||
/**
|
||||
* Calculate total including shipping
|
||||
* Calculate subtotal (items only, no shipping, no BOGO discount)
|
||||
* @returns {string}
|
||||
*/
|
||||
subtotal() {
|
||||
return this.cart
|
||||
.filter(item => !item.isFree)
|
||||
.reduce((sum, item) => sum + parseFloat(item.price), 0)
|
||||
.toFixed(2);
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate total including shipping and BOGO discount
|
||||
* @returns {string}
|
||||
*/
|
||||
total() {
|
||||
const itemsTotal = this.cart.reduce((sum, item) => sum + parseFloat(item.price), 0);
|
||||
return (itemsTotal + (parseFloat(this.shipping) || 0)).toFixed(2);
|
||||
const itemsTotal = this.cart
|
||||
.filter(item => !item.isFree)
|
||||
.reduce((sum, item) => sum + parseFloat(item.price), 0);
|
||||
const shipping = parseFloat(this.shipping) || 0;
|
||||
// Note: BOGO discount is already applied via free items with price 0
|
||||
// So we don't subtract bogoDiscount here
|
||||
return (itemsTotal + shipping).toFixed(2);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get count of free items in cart
|
||||
* @returns {number}
|
||||
*/
|
||||
freeItemsCount() {
|
||||
return this.cart.filter(item => item.isFree).length;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -49,14 +49,19 @@ const ProductsComponent = {
|
||||
this.activeProduct = p;
|
||||
this.variations = p.variation_details || [];
|
||||
this.cart = [];
|
||||
this.bogoDiscount = 0;
|
||||
this.bogoFreeItems = [];
|
||||
this.appliedBogoRules = [];
|
||||
this.selectedVariationId = '';
|
||||
|
||||
if (p.type !== 'variable') {
|
||||
this.cart.push({
|
||||
id: parseInt(p.id),
|
||||
name: p.name,
|
||||
price: p.price
|
||||
price: p.price,
|
||||
isFree: false
|
||||
});
|
||||
this.recalculateBogo();
|
||||
this.loadRecommendations(p);
|
||||
}
|
||||
},
|
||||
@ -72,9 +77,11 @@ const ProductsComponent = {
|
||||
id: parseInt(this.activeProduct.id),
|
||||
variation_id: parseInt(v.id),
|
||||
name: this.activeProduct.name + ' - ' + this.getVarName(v),
|
||||
price: v.price
|
||||
price: v.price,
|
||||
isFree: false
|
||||
}];
|
||||
|
||||
this.recalculateBogo();
|
||||
this.loadRecommendations(this.activeProduct);
|
||||
},
|
||||
|
||||
@ -88,8 +95,10 @@ const ProductsComponent = {
|
||||
this.cart.push({
|
||||
id: parseInt(p.id),
|
||||
name: p.name,
|
||||
price: p.price
|
||||
price: p.price,
|
||||
isFree: false
|
||||
});
|
||||
this.recalculateBogo();
|
||||
this.extraProductId = '';
|
||||
},
|
||||
|
||||
|
||||
189
js/services/bogo.js
Normal file
189
js/services/bogo.js
Normal file
@ -0,0 +1,189 @@
|
||||
/**
|
||||
* BOGO Service - Handles Buy One Get One logic
|
||||
*
|
||||
* Processes BOGO rules from the API and calculates free items/discounts
|
||||
*/
|
||||
const BogoService = {
|
||||
/**
|
||||
* Check if a product has any BOGO rules
|
||||
* @param {Object} product
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasBogoRules(product) {
|
||||
return product.bogo_rules && product.bogo_rules.length > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the primary BOGO rule for a product (first one)
|
||||
* @param {Object} product
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
getPrimaryBogoRule(product) {
|
||||
if (!this.hasBogoRules(product)) return null;
|
||||
return product.bogo_rules[0];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get BOGO label for display
|
||||
* @param {Object} product
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getBogoLabel(product) {
|
||||
const rule = this.getPrimaryBogoRule(product);
|
||||
return rule ? rule.label : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate BOGO discount for cart items
|
||||
*
|
||||
* @param {Array} cartItems - Array of cart items with product data
|
||||
* @param {Array} products - Full products list with BOGO rules
|
||||
* @returns {Object} { freeItems: [], discountAmount: number }
|
||||
*/
|
||||
calculateBogoForCart(cartItems, products) {
|
||||
const result = {
|
||||
freeItems: [],
|
||||
discountAmount: 0,
|
||||
appliedRules: []
|
||||
};
|
||||
|
||||
// Group cart items by product ID
|
||||
const itemsByProduct = this.groupCartItemsByProduct(cartItems);
|
||||
|
||||
// Check each product group for BOGO eligibility
|
||||
for (const [productId, items] of Object.entries(itemsByProduct)) {
|
||||
const product = products.find(p => p.id == productId);
|
||||
if (!product || !this.hasBogoRules(product)) continue;
|
||||
|
||||
const rule = this.getPrimaryBogoRule(product);
|
||||
const quantity = items.length;
|
||||
|
||||
// Calculate how many free items based on the rule
|
||||
const bogoResult = this.applyBogoRule(rule, quantity, product, products);
|
||||
|
||||
if (bogoResult.freeItems.length > 0 || bogoResult.discountAmount > 0) {
|
||||
result.freeItems.push(...bogoResult.freeItems);
|
||||
result.discountAmount += bogoResult.discountAmount;
|
||||
result.appliedRules.push({
|
||||
rule,
|
||||
productId: parseInt(productId),
|
||||
productName: product.name,
|
||||
...bogoResult
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Group cart items by product ID
|
||||
* @param {Array} cartItems
|
||||
* @returns {Object}
|
||||
*/
|
||||
groupCartItemsByProduct(cartItems) {
|
||||
return cartItems.reduce((groups, item) => {
|
||||
const id = item.id;
|
||||
if (!groups[id]) groups[id] = [];
|
||||
groups[id].push(item);
|
||||
return groups;
|
||||
}, {});
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a BOGO rule to calculate free items/discount
|
||||
*
|
||||
* @param {Object} rule - BOGO rule from API
|
||||
* @param {number} quantity - Number of items in cart
|
||||
* @param {Object} product - The product
|
||||
* @param {Array} products - All products (for Buy X Get Y)
|
||||
* @returns {Object}
|
||||
*/
|
||||
applyBogoRule(rule, quantity, product, products) {
|
||||
const result = {
|
||||
freeItems: [],
|
||||
discountAmount: 0,
|
||||
freeQuantity: 0
|
||||
};
|
||||
|
||||
if (quantity < rule.buy_qty) {
|
||||
return result; // Not enough items to trigger BOGO
|
||||
}
|
||||
|
||||
// Calculate how many times the rule applies
|
||||
let timesApplied = 1;
|
||||
if (rule.recursive) {
|
||||
timesApplied = Math.floor(quantity / rule.buy_qty);
|
||||
}
|
||||
|
||||
const freeQty = timesApplied * rule.get_qty;
|
||||
result.freeQuantity = freeQty;
|
||||
|
||||
if (rule.type === 'buy_x_get_x') {
|
||||
// Same product is free or discounted
|
||||
if (rule.discount_type === 'free_product' || rule.discount_value >= 100) {
|
||||
// Fully free - automatically add to cart
|
||||
result.discountAmount = freeQty * parseFloat(product.price);
|
||||
result.freeItems.push({
|
||||
id: product.id,
|
||||
name: product.name + ' (GRATIS)',
|
||||
price: '0.00',
|
||||
originalPrice: product.price,
|
||||
isFree: true,
|
||||
ruleId: rule.rule_id
|
||||
});
|
||||
}
|
||||
// For percentage and flat discounts, we DON'T automatically add items
|
||||
// These are handled manually via addBogoDiscountedItem() in cart.js
|
||||
// The user clicks a button to add the discounted item
|
||||
} else if (rule.type === 'buy_x_get_y' && rule.get_product_ids) {
|
||||
// Different product is free
|
||||
for (const freeProductId of rule.get_product_ids) {
|
||||
const freeProduct = products.find(p => p.id == freeProductId);
|
||||
if (!freeProduct) continue;
|
||||
|
||||
if (rule.discount_type === 'free_product' || rule.discount_value >= 100) {
|
||||
result.discountAmount += freeQty * parseFloat(freeProduct.price);
|
||||
result.freeItems.push({
|
||||
id: freeProduct.id,
|
||||
name: freeProduct.name + ' (GRATIS)',
|
||||
price: '0.00',
|
||||
originalPrice: freeProduct.price,
|
||||
isFree: true,
|
||||
ruleId: rule.rule_id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get suggested free items for a product (for display before adding to cart)
|
||||
*
|
||||
* @param {Object} product
|
||||
* @param {Array} products
|
||||
* @returns {Array}
|
||||
*/
|
||||
getSuggestedFreeItems(product, products) {
|
||||
if (!this.hasBogoRules(product)) return [];
|
||||
|
||||
const rule = this.getPrimaryBogoRule(product);
|
||||
const suggestions = [];
|
||||
|
||||
if (rule.type === 'buy_x_get_y' && rule.get_product_ids) {
|
||||
for (const freeProductId of rule.get_product_ids) {
|
||||
const freeProduct = products.find(p => p.id == freeProductId);
|
||||
if (freeProduct) {
|
||||
suggestions.push({
|
||||
...freeProduct,
|
||||
bogoLabel: `Gratis bij ${rule.buy_qty}x ${product.name}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
};
|
||||
266
logs/bogo_debug.log
Normal file
266
logs/bogo_debug.log
Normal file
@ -0,0 +1,266 @@
|
||||
[2026-01-10 16:49:41] DiscountRulesService initialized
|
||||
Array
|
||||
(
|
||||
[tablePrefix] => wp_
|
||||
[db_connected] => 1
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] buildProductBogoMap() called
|
||||
Array
|
||||
(
|
||||
[productIds_count] => 39
|
||||
[productIds_sample] => Array
|
||||
(
|
||||
[0] => 2483
|
||||
[1] => 2376
|
||||
[2] => 1903
|
||||
[3] => 1831
|
||||
[4] => 1794
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] fetchActiveBogoRules() called
|
||||
Array
|
||||
(
|
||||
[table] => wp_wdr_rules
|
||||
[currentTime] => 1768060181
|
||||
[tablePrefix] => wp_
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Table wp_wdr_rules exists
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Executing query
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
filters,
|
||||
buy_x_get_x_adjustments,
|
||||
buy_x_get_y_adjustments
|
||||
FROM wp_wdr_rules
|
||||
WHERE enabled = 1
|
||||
AND deleted = 0
|
||||
AND (date_from <= ? OR date_from IS NULL)
|
||||
AND (date_to >= ? OR date_to IS NULL)
|
||||
AND (usage_limits > used_limits OR used_limits IS NULL OR usage_limits = 0)
|
||||
AND (
|
||||
(buy_x_get_x_adjustments IS NOT NULL AND buy_x_get_x_adjustments != '{}' AND buy_x_get_x_adjustments != '[]')
|
||||
OR (buy_x_get_y_adjustments IS NOT NULL AND buy_x_get_y_adjustments != '{}' AND buy_x_get_y_adjustments != '[]')
|
||||
)
|
||||
ORDER BY priority ASC
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Found 3 BOGO rules
|
||||
Array
|
||||
(
|
||||
[0] => Array
|
||||
(
|
||||
[id] => 4
|
||||
[title] => 1 + 1 GRATIS
|
||||
[filters] => {"1":{"type":"products","method":"in_list","value":["1117","450","439","461","1110","406"],"product_variants":[],"product_variants_for_sale_badge":[]}}
|
||||
[buy_x_get_x_adjustments] => {"ranges":{"1":{"from":"1","to":"1","free_qty":"1","free_type":"free_product","free_value":"100","recursive":"1"}}}
|
||||
[buy_x_get_y_adjustments] => []
|
||||
)
|
||||
|
||||
[1] => Array
|
||||
(
|
||||
[id] => 6
|
||||
[title] => 2e voor 19,95
|
||||
[filters] => {"1":{"type":"products","method":"in_list","value":["1831"],"product_variants":[],"product_variants_for_sale_badge":[]}}
|
||||
[buy_x_get_x_adjustments] => {"ranges":{"1":{"from":"2","to":"","free_qty":"1","free_type":"flat","free_value":"10"}}}
|
||||
[buy_x_get_y_adjustments] => []
|
||||
)
|
||||
|
||||
[2] => Array
|
||||
(
|
||||
[id] => 7
|
||||
[title] => Invictus One Prestige
|
||||
[filters] => {"1":{"type":"products","method":"in_list","value":["1139","1466","1467"],"product_variants":[1467,1466],"product_variants_for_sale_badge":[1139]}}
|
||||
[buy_x_get_x_adjustments] => []
|
||||
[buy_x_get_y_adjustments] => {"type":"bxgy_product","operator":"product_cumulative","mode":"auto_add","ranges":{"1":{"from":"1","to":"1","products":["2376"],"free_qty":"1","free_type":"free_product","free_value":"","recursive":"1","product_varients":[],"product_variants_for_sale_badge":[]}}}
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Fetched rules
|
||||
Array
|
||||
(
|
||||
[count] => 3
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Processing rule
|
||||
Array
|
||||
(
|
||||
[id] => 4
|
||||
[title] => 1 + 1 GRATIS
|
||||
[filters] => {"1":{"type":"products","method":"in_list","value":["1117","450","439","461","1110","406"],"product_variants":[],"product_variants_for_sale_badge":[]}}
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Extracted filter product IDs
|
||||
Array
|
||||
(
|
||||
[0] => 1117
|
||||
[1] => 450
|
||||
[2] => 439
|
||||
[3] => 461
|
||||
[4] => 1110
|
||||
[5] => 406
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Parsed BOGO data
|
||||
Array
|
||||
(
|
||||
[rule_id] => 4
|
||||
[rule_title] => 1 + 1 GRATIS
|
||||
[type] => buy_x_get_x
|
||||
[label] => 1+1 Gratis
|
||||
[buy_qty] => 1
|
||||
[get_qty] => 1
|
||||
[discount_type] => free_product
|
||||
[discount_value] => 100
|
||||
[recursive] => 1
|
||||
[get_product_ids] =>
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Processing rule
|
||||
Array
|
||||
(
|
||||
[id] => 6
|
||||
[title] => 2e voor 19,95
|
||||
[filters] => {"1":{"type":"products","method":"in_list","value":["1831"],"product_variants":[],"product_variants_for_sale_badge":[]}}
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Extracted filter product IDs
|
||||
Array
|
||||
(
|
||||
[0] => 1831
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Parsed BOGO data
|
||||
Array
|
||||
(
|
||||
[rule_id] => 6
|
||||
[rule_title] => 2e voor 19,95
|
||||
[type] => buy_x_get_x
|
||||
[label] => Koop 2, krijg 1 met €10,00 korting
|
||||
[buy_qty] => 2
|
||||
[get_qty] => 1
|
||||
[discount_type] => flat
|
||||
[discount_value] => 10
|
||||
[recursive] =>
|
||||
[get_product_ids] =>
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Processing rule
|
||||
Array
|
||||
(
|
||||
[id] => 7
|
||||
[title] => Invictus One Prestige
|
||||
[filters] => {"1":{"type":"products","method":"in_list","value":["1139","1466","1467"],"product_variants":[1467,1466],"product_variants_for_sale_badge":[1139]}}
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Extracted filter product IDs
|
||||
Array
|
||||
(
|
||||
[0] => 1139
|
||||
[1] => 1466
|
||||
[2] => 1467
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Parsed BOGO data
|
||||
Array
|
||||
(
|
||||
[rule_id] => 7
|
||||
[rule_title] => Invictus One Prestige
|
||||
[type] => buy_x_get_y
|
||||
[label] => Koop 1, krijg 1 ander gratis
|
||||
[buy_qty] => 1
|
||||
[get_qty] => 1
|
||||
[discount_type] => free_product
|
||||
[discount_value] => 0
|
||||
[recursive] => 1
|
||||
[get_product_ids] => Array
|
||||
(
|
||||
[0] => 2376
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Final product map
|
||||
Array
|
||||
(
|
||||
[products_with_bogo] => 10
|
||||
[sample] => Array
|
||||
(
|
||||
[1117] => Array
|
||||
(
|
||||
[0] => Array
|
||||
(
|
||||
[rule_id] => 4
|
||||
[rule_title] => 1 + 1 GRATIS
|
||||
[type] => buy_x_get_x
|
||||
[label] => 1+1 Gratis
|
||||
[buy_qty] => 1
|
||||
[get_qty] => 1
|
||||
[discount_type] => free_product
|
||||
[discount_value] => 100
|
||||
[recursive] => 1
|
||||
[get_product_ids] =>
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
[450] => Array
|
||||
(
|
||||
[0] => Array
|
||||
(
|
||||
[rule_id] => 4
|
||||
[rule_title] => 1 + 1 GRATIS
|
||||
[type] => buy_x_get_x
|
||||
[label] => 1+1 Gratis
|
||||
[buy_qty] => 1
|
||||
[get_qty] => 1
|
||||
[discount_type] => free_product
|
||||
[discount_value] => 100
|
||||
[recursive] => 1
|
||||
[get_product_ids] =>
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
[439] => Array
|
||||
(
|
||||
[0] => Array
|
||||
(
|
||||
[rule_id] => 4
|
||||
[rule_title] => 1 + 1 GRATIS
|
||||
[type] => buy_x_get_x
|
||||
[label] => 1+1 Gratis
|
||||
[buy_qty] => 1
|
||||
[get_qty] => 1
|
||||
[discount_type] => free_product
|
||||
[discount_value] => 100
|
||||
[recursive] => 1
|
||||
[get_product_ids] =>
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
35
logs/products_debug.log
Normal file
35
logs/products_debug.log
Normal file
@ -0,0 +1,35 @@
|
||||
[2026-01-10 16:49:40] handleGetProducts() started
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:40] WooCommerce client initialized
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Fetched products from WooCommerce
|
||||
Array
|
||||
(
|
||||
[count] => 39
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Built CUW map
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Creating DiscountRulesService...
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] DiscountRulesService created
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Product IDs extracted
|
||||
Array
|
||||
(
|
||||
[count] => 39
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] Calling buildProductBogoMap...
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:41] BOGO map built
|
||||
Array
|
||||
(
|
||||
[products_with_bogo] => 10
|
||||
)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
[2026-01-10 16:49:46] Returning 39 enriched products
|
||||
--------------------------------------------------------------------------------
|
||||
Loading…
x
Reference in New Issue
Block a user