KopiBot AI / Dokumentasi HTML
Semua Dokumen GitHub Repo Kembali ke Landing Page
☕ AI Agent Coffee Shop Commerce Platform

Platform AI untuk otomatisasi order, customer service, loyalty customer, Customer CRM, Customer Portal, dan manajemen multi cabang coffee shop.

🚀 Features

  • AI Chatbot Order Menu
  • WhatsApp / Telegram / Discord Integration
  • Multi Branch Management
  • AI Upselling & Promo Recommendation
  • Order via Website & Chat Apps
  • Variant Product & Topping Support
  • Loyalty Point, Customer CRM, dan Customer Portal
  • Multi Currency, Tax & Timezone
  • AI Customer Interaction Automation

💻 Tech Stack

PHP Native • MySQL • OpenAI • Anthropic
WhatsApp Gateway • REST API • LLM AI

☕ Suitable For

Coffee Shop • Cafe • Restaurant • Bakery • Beverage Store

Dibuat & Dikembangkan oleh

Kukuh TW

📧 Email: kukuhtw@gmail.com

📱 WhatsApp: wa.me/628129893706

🌄 Instagram: @kukuhtw

X/Twitter: @kukuhtw

Facebook: facebook.com/kukuhtw

LinkedIn: linkedin.com/in/kukuhtw

GitHub: github.com/kukuhtw/toko_kopi

🌐 Demo: botlelang.com/toko_kopi

© 2026 Kukuh TW. All rights reserved.

Source Viewer

plugins/loyalty-point/LoyaltyPointSkill.php

<?php

declare(strict_types=1);

use App\Helpers\Currency;
use App\Skills\SkillInterface;

class LoyaltyPointSkill implements SkillInterface
{
    public function canHandle(string $intent): bool
    {
        return in_array($intent, ['cek_poin_loyalty', 'pakai_poin_loyalty', 'hapus_poin_loyalty'], true);
    }

    public function handle(array $context): array
    {
        if (($context['intent'] ?? '') === 'hapus_poin_loyalty') {
            return $this->handleClearRedeem($context);
        }

        if (($context['intent'] ?? '') === 'pakai_poin_loyalty') {
            return $this->handleRedeem($context);
        }

        $lang      = (string)($context['language'] ?? 'id');
        $branchId  = (int)($context['branch_id'] ?? 0);
        $customer  = (array)($context['customer'] ?? []);
        $convCtx   = (array)($context['conv_context'] ?? []);
        $repo      = new LoyaltyPointRepository();
        $balance   = $repo->getBalance($branchId, (int)($customer['id'] ?? 0));

        $reply = $lang === 'en'
            ? "Your loyalty balance is *" . number_format((int)$balance['balance_points']) . "* points.\nLifetime points collected: *" . number_format((int)$balance['lifetime_points']) . "*."
            : "Saldo loyalty kamu saat ini *" . number_format((int)$balance['balance_points']) . "* poin.\nTotal poin yang pernah terkumpul: *" . number_format((int)$balance['lifetime_points']) . "*.";

        $convCtx['last_topic'] = 'loyalty_points';

        return [
            'reply'         => $reply,
            'state'         => 'idle',
            'action_result' => $balance,
            'conv_context'  => $convCtx,
        ];
    }

    private function handleRedeem(array $context): array
    {
        $lang      = (string)($context['language'] ?? 'id');
        $branchId  = (int)($context['branch_id'] ?? 0);
        $customer  = (array)($context['customer'] ?? []);
        $cart      = (array)($context['cart'] ?? []);
        $currency  = (string)($context['currency'] ?? 'IDR');
        $ppnRate   = (float)($context['ppn_rate'] ?? 0);
        $convCtx   = (array)($context['conv_context'] ?? []);
        $repo      = new LoyaltyPointRepository();

        $cartModel = new \App\Models\CartModel();
        $items = $cartModel->getItems((int)($cart['id'] ?? 0));
        if (empty($items) || empty($cart['id'])) {
            $reply = $lang === 'en'
                ? 'Your cart is empty. Add items before redeeming points.'
                : 'Keranjangmu kosong. Tambahkan item dulu sebelum memakai poin.';
            return ['reply' => $reply, 'state' => 'idle', 'action_result' => null, 'conv_context' => $convCtx];
        }

        $balance = $repo->getBalance($branchId, (int)($customer['id'] ?? 0));
        $availablePoints = (int)($balance['balance_points'] ?? 0);
        if ($availablePoints <= 0) {
            $reply = $lang === 'en'
                ? 'You do not have any points to redeem yet.'
                : 'Kamu belum punya poin yang bisa dipakai.';
            return ['reply' => $reply, 'state' => 'idle', 'action_result' => $balance, 'conv_context' => $convCtx];
        }

        $settings = LoyaltyPointPlugin::getRedeemSettings($branchId);
        $requestedPoints = $this->extractRequestedPoints((string)($context['message'] ?? ''), $availablePoints, $settings['points_unit']);
        $requestedPoints = min($requestedPoints, $availablePoints);

        if ($requestedPoints < $settings['min_points']) {
            $reply = $lang === 'en'
                ? "Minimum redeem is {$settings['min_points']} points."
                : "Minimal redeem adalah {$settings['min_points']} poin.";
            return ['reply' => $reply, 'state' => 'idle', 'action_result' => $balance, 'conv_context' => $convCtx];
        }

        $subtotal         = array_sum(array_map(static fn(array $i): float => (float)$i['quantity'] * (float)$i['unit_price'], $items));
        $currentLoyalty   = (float)($cart['loyalty_discount_amount'] ?? 0);
        $promoDiscount    = max(0.0, (float)($cart['discount_amount'] ?? 0) - $currentLoyalty);
        $maxDiscountBase  = max(0.0, $subtotal - $promoDiscount);
        $discount         = LoyaltyPointPlugin::calculateRedeemDiscount($requestedPoints, $settings['points_unit'], $settings['value_amount']);
        $discount         = min($discount, $maxDiscountBase);

        if ($discount <= 0) {
            $reply = $lang === 'en'
                ? 'Your cart total is not eligible for point redemption.'
                : 'Total keranjang saat ini belum bisa dipotong dengan poin.';
            return ['reply' => $reply, 'state' => 'idle', 'action_result' => null, 'conv_context' => $convCtx];
        }

        $actualPoints = LoyaltyPointPlugin::calculateRedeemPointsForDiscount($discount, $settings['points_unit'], $settings['value_amount']);
        $repo->applyRedemptionToCart((int)$cart['id'], $actualPoints, $discount);

        $updatedCart = $cartModel->getBySession((string)($cart['session_key'] ?? '')) ?: $cart;
        $convCtx['last_topic'] = 'cart';

        $reply = $lang === 'en'
            ? "Redeemed *{$actualPoints}* points for a discount of *" . Currency::format($discount, $currency) . "*.\n\n"
            : "Berhasil memakai *{$actualPoints}* poin untuk diskon *" . Currency::format($discount, $currency) . "*.\n\n";
        $reply .= $this->buildCartSummary($updatedCart, $items, $lang, $currency, $ppnRate);

        return [
            'reply'         => $reply,
            'state'         => 'idle',
            'action_result' => ['points' => $actualPoints, 'discount' => $discount],
            'conv_context'  => $convCtx,
        ];
    }

    private function extractRequestedPoints(string $message, int $availablePoints, int $pointsUnit): int
    {
        $lower = mb_strtolower($message, 'UTF-8');
        if (preg_match('/\b(semua|all)\b/u', $lower)) {
            return $availablePoints;
        }
        if (preg_match('/\b(\d+)\b/u', $lower, $m)) {
            return (int)$m[1];
        }

        return $pointsUnit;
    }

    private function buildCartSummary(array $cart, array $items, string $lang, string $currency, float $ppnRate): string
    {
        $lines = [$lang === 'en' ? "Your cart:\n" : "Keranjang kamu:\n"];
        $subtotal = 0.0;

        foreach ($items as $item) {
            $lineTotal = (float)$item['quantity'] * (float)$item['unit_price'];
            $subtotal += $lineTotal;
            $lines[] = "- {$item['name']} x{$item['quantity']} = " . Currency::format($lineTotal, $currency);
        }

        $discount = (float)($cart['discount_amount'] ?? 0);
        $afterDiscount = max(0.0, $subtotal - $discount);
        $ppnAmount = $ppnRate > 0 ? round($afterDiscount * $ppnRate / 100, 2) : 0.0;
        $total = $afterDiscount + $ppnAmount;

        $lines[] = '';
        $lines[] = 'Subtotal: ' . Currency::format($subtotal, $currency);
        if ($discount > 0) {
            $lines[] = ($lang === 'en' ? 'Discount: -' : 'Diskon: -') . Currency::format($discount, $currency);
        }
        if ($ppnAmount > 0) {
            $lines[] = ($lang === 'en' ? "VAT ({$ppnRate}%): " : "PPN ({$ppnRate}%): ") . Currency::format($ppnAmount, $currency);
        }
        $lines[] = 'Total: ' . Currency::format($total, $currency);

        return implode("\n", $lines);
    }

    private function handleClearRedeem(array $context): array
    {
        $lang      = (string)($context['language'] ?? 'id');
        $cart      = (array)($context['cart'] ?? []);
        $currency  = (string)($context['currency'] ?? 'IDR');
        $ppnRate   = (float)($context['ppn_rate'] ?? 0);
        $convCtx   = (array)($context['conv_context'] ?? []);
        $cartId    = (int)($cart['id'] ?? 0);

        if ($cartId <= 0) {
            return [
                'reply' => $lang === 'en' ? 'Cart not found.' : 'Keranjang tidak ditemukan.',
                'state' => 'idle',
                'action_result' => null,
                'conv_context' => $convCtx,
            ];
        }

        (new LoyaltyPointRepository())->clearRedemptionFromCart($cartId);
        $cartModel = new \App\Models\CartModel();
        $updatedCart = $cartModel->getBySession((string)($cart['session_key'] ?? '')) ?: $cart;
        $items = $cartModel->getItems($cartId);

        $reply = $lang === 'en'
            ? "Point redemption removed from your cart.\n\n"
            : "Pemakaian poin di keranjang berhasil dibatalkan.\n\n";
        $reply .= $this->buildCartSummary($updatedCart, $items, $lang, $currency, $ppnRate);

        return [
            'reply' => $reply,
            'state' => 'idle',
            'action_result' => ['cleared' => true],
            'conv_context' => $convCtx,
        ];
    }
}