Align POS header with other apps: AI assistant, register button, sidebar
Deploy Ladill POS / deploy (push) Successful in 47s

- Add the Afia AI assistant to the header (topbar AI button + slide-over),
  matching every other Ladill app. Ports config/afia.php, AfiaService, a POS
  AiController (pos.ai.chat) scoped to register/sales/products, and includes
  the slide-over in the app layout. The afia() Alpine component already existed.
- Hide the header "Open register" button while on the register page.
- Move Settings to a pinned bottom section of the sidebar, like other apps.
- Commit the updated POS logo + launcher icon assets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-24 07:08:43 +00:00
co-authored by Claude Opus 4.8
parent b7c7a32ee6
commit e9a0c92308
12 changed files with 345 additions and 26 deletions
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Pos;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
use App\Models\PosProduct;
use App\Models\PosSale;
use App\Services\Afia\AfiaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AiController extends Controller
{
use ScopesToAccount;
public function chat(Request $request, AfiaService $afia): JsonResponse
{
$validated = $request->validate([
'message' => ['required', 'string', 'max:2000'],
'history' => ['nullable', 'array', 'max:20'],
'history.*.role' => ['nullable', 'string'],
'history.*.text' => ['nullable', 'string'],
]);
if (! $afia->enabled()) {
return response()->json(['message' => 'Afia is not available right now.'], 503);
}
try {
$reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context($request));
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502);
}
return response()->json(['reply' => $reply]);
}
/** @return array<string,mixed> */
private function context(Request $request): array
{
$owner = $this->ownerRef($request);
$currency = strtoupper((string) config('pos.default_currency', 'GHS'));
$todaySales = PosSale::owned($owner)
->where('status', PosSale::STATUS_PAID)
->where('paid_at', '>=', now()->startOfDay());
$todayTotalMinor = (int) (clone $todaySales)->sum('total_minor');
return [
'signed_in' => 'yes',
'today_takings' => $currency.' '.number_format($todayTotalMinor / 100, 2),
'today_sales_count' => (clone $todaySales)->count(),
'active_products' => PosProduct::owned($owner)->active()->count(),
'pending_sales' => PosSale::owned($owner)->where('status', PosSale::STATUS_PENDING)->count(),
];
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Services\Afia;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Afia Ladill in-app AI assistant scoped to POS. Calls the configured
* provider (OpenAI/Anthropic) directly with the app's own key.
*/
class AfiaService
{
public function enabled(): bool
{
return (bool) config('afia.enabled', true) && (string) config('afia.api_key', '') !== '';
}
/**
* @param array<int,array{role:string,text:string}> $history
* @param array<string,mixed> $context
*/
public function chat(string $message, array $history, array $context): string
{
if (! $this->enabled()) {
throw new RuntimeException('Afia is not configured.');
}
$provider = (string) config('afia.provider', 'openai');
$model = (string) config('afia.model', 'gpt-4o-mini');
$apiKey = (string) config('afia.api_key');
$messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]];
foreach (array_slice($history, -8) as $turn) {
$role = ($turn['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user';
$text = trim((string) ($turn['text'] ?? ''));
if ($text !== '') {
$messages[] = ['role' => $role, 'content' => $text];
}
}
$messages[] = ['role' => 'user', 'content' => $message];
return $provider === 'anthropic'
? $this->viaAnthropic($model, $apiKey, $messages)
: $this->viaOpenAi($model, $apiKey, $messages);
}
private function viaOpenAi(string $model, string $apiKey, array $messages): string
{
$res = Http::withToken($apiKey)->acceptJson()->timeout(45)
->post('https://api.openai.com/v1/chat/completions', [
'model' => $model,
'temperature' => 0.3,
'max_tokens' => 600,
'messages' => $messages,
]);
if ($res->failed()) {
throw new RuntimeException('OpenAI request failed: '.$res->status());
}
return trim((string) $res->json('choices.0.message.content', ''));
}
private function viaAnthropic(string $model, string $apiKey, array $messages): string
{
$system = $messages[0]['content'];
$turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system'));
$res = Http::withHeaders([
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
])->acceptJson()->timeout(45)->post('https://api.anthropic.com/v1/messages', [
'model' => $model,
'max_tokens' => 600,
'system' => $system,
'messages' => array_map(fn ($m) => ['role' => $m['role'], 'content' => $m['content']], $turns),
]);
if ($res->failed()) {
throw new RuntimeException('Anthropic request failed: '.$res->status());
}
return trim((string) $res->json('content.0.text', ''));
}
/** @param array<string,mixed> $context */
private function systemPrompt(array $context): string
{
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
return <<<PROMPT
You are Afia, the assistant inside Ladill POS (pos.ladill.com).
Help users run the in-store register taking sales, charging by cash or Ladill Pay, managing the product catalog, and reading their sales history. Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
What Ladill POS does and where things live:
- Overview (Dashboard): today's takings, number of sales, active products, and recent sales.
- Register: the point-of-sale screen add catalog products or a quick custom amount to the cart, then charge by cash or Ladill Pay (MoMo/card).
- Products: the local catalog (name, SKU, price). Products can also be imported from Ladill CRM in Settings.
- Sales: history of completed sales with receipts; a paid sale can be turned into a Ladill Invoice.
- Settings: store details, default currency, and importing products from CRM or the Merchant storefront.
Rules:
- Only answer questions about Ladill POS the register, sales, products, checkout (cash/Ladill Pay), receipts, and settings.
- If asked about contacts, invoices, domains, hosting, email mailboxes, or QR codes, briefly say those live in other Ladill apps and suggest the app launcher.
- Never invent figures (takings, counts) use the user context below or tell them where to check.
Current user context:
{$ctx}
PROMPT;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
// Afia — Ladill in-app AI assistant, scoped to POS. Provisioned per app via
// its own key (falls back to OPENAI_API_KEY). See App\Services\Afia\AfiaService.
'product' => env('AFIA_PRODUCT', 'pos'),
'enabled' => (bool) env('AFIA_ENABLED', true),
'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic
'model' => env('AFIA_MODEL', 'gpt-4o-mini'),
'api_key' => env('AFIA_API_KEY', env('OPENAI_API_KEY')),
];
+5 -6
View File
@@ -15,7 +15,7 @@
} }
.cls-3 { .cls-3 {
fill: #868f77; fill: #2a9779;
} }
.cls-4 { .cls-4 {
@@ -23,9 +23,8 @@
} }
</style> </style>
</defs> </defs>
<circle class="cls-4" cx="99.4" cy="109" r="98"/> <path class="cls-1" d="M111.4,249.7c-.1,2.1-.3,4.2-.3,6.3,0,56.7,46,102.7,102.7,102.7s102.7-46,102.7-102.7-.2-4.2-.3-6.3H111.4Z"/>
<rect class="cls-2" x="1.4" y="11" width="83.2" height="338"/> <path class="cls-3" d="M152.8,264.8c-.1-2.1-.3-4.2-.3-6.3,0-56.7,46-102.7,102.7-102.7s102.7,46,102.7,102.7-.2,4.2-.3,6.3h-204.9Z"/>
<path class="cls-3" d="M84.6,205.7V12.2C37.5,19.4,1.4,59.9,1.4,109s36.1,89.6,83.2,96.7Z"/> <path class="cls-2" d="M111,43c-2.1-.1-4.2-.3-6.3-.3C48,42.7,2,88.7,2,145.5s46,102.7,102.7,102.7,4.2-.2,6.3-.3V43Z"/>
<path class="cls-1" d="M123.4,245c-.1,2-.3,4-.3,6,0,54.1,43.9,98,98,98s98-43.9,98-98-.2-4-.3-6H123.4Z"/> <path class="cls-4" d="M94.8,1.6c2.1-.1,4.2-.3,6.3-.3,56.7,0,102.7,46,102.7,102.7s-46,102.7-102.7,102.7-4.2-.2-6.3-.3V1.6Z"/>
<path class="cls-1" d="M162.9,238.5c-.1-2-.3-4-.3-6,0-54.1,43.9-98,98-98s98,43.9,98,98-.2,4-.3,6h-195.3Z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 888 B

After

Width:  |  Height:  |  Size: 976 B

+15 -16
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 415.1 76.2"> <svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 411.6 76.2">
<defs> <defs>
<style> <style>
.cls-1 { .cls-1 {
@@ -19,7 +19,7 @@
} }
.cls-4 { .cls-4 {
fill: #868f77; fill: #2a9779;
} }
.cls-5 { .cls-5 {
@@ -28,21 +28,20 @@
</style> </style>
</defs> </defs>
<g> <g>
<path class="cls-3" d="M136.4,53.9v8.6h-34.8V12.2h8.6v41.8h26.2,0Z"/> <path class="cls-3" d="M132.4,53.9v8.6h-34.8V12.2h8.6v41.8h26.2,0Z"/>
<path class="cls-3" d="M140.1,44.5c0-2.7.5-5.3,1.5-7.7s2.4-4.4,4.2-6.2,3.9-3.1,6.2-4.2c2.4-1,4.9-1.5,7.7-1.5s5.3.5,7.7,1.5,4.4,2.4,6.2,4.2c1.8,1.8,3.1,3.8,4.2,6.2,1,2.4,1.5,4.9,1.5,7.7v18.1h-8.4v-2c-3.3,2.4-7.1,3.5-11.2,3.5s-5.7-.5-8.1-1.5-4.5-2.4-6.2-4.2-3-3.9-4-6.2c-.9-2.4-1.4-4.9-1.4-7.7,0,0,0,0,.1,0ZM148.5,44.5c0,1.5.3,3,.9,4.4s1.4,2.6,2.4,3.6,2.2,1.8,3.6,2.4c1.4.6,2.8.9,4.4.9s3-.3,4.4-.8c1.4-.6,2.6-1.3,3.6-2.3s1.8-2.2,2.4-3.6c.6-1.4.9-2.9.9-4.5s-.3-3-.9-4.4c-.6-1.4-1.4-2.6-2.4-3.6s-2.2-1.8-3.6-2.4c-1.4-.6-2.8-.9-4.4-.9s-3,.3-4.4.9-2.6,1.4-3.6,2.4-1.8,2.2-2.4,3.6c-.6,1.4-.9,2.8-.9,4.4h0Z"/> <path class="cls-3" d="M136.1,44.5c0-2.7.5-5.3,1.5-7.7s2.4-4.4,4.2-6.2,3.9-3.1,6.2-4.2c2.4-1,4.9-1.5,7.7-1.5s5.3.5,7.7,1.5,4.4,2.4,6.2,4.2c1.8,1.8,3.1,3.8,4.2,6.2,1,2.4,1.5,4.9,1.5,7.7v18.1h-8.4v-2c-3.3,2.4-7.1,3.5-11.2,3.5s-5.7-.5-8.1-1.5-4.5-2.4-6.2-4.2-3-3.9-4-6.2c-.9-2.4-1.4-4.9-1.4-7.7,0,0,0,0,.1,0ZM144.5,44.5c0,1.5.3,3,.9,4.4s1.4,2.6,2.4,3.6,2.2,1.8,3.6,2.4c1.4.6,2.8.9,4.4.9s3-.3,4.4-.8c1.4-.6,2.6-1.3,3.6-2.3s1.8-2.2,2.4-3.6c.6-1.4.9-2.9.9-4.5s-.3-3-.9-4.4c-.6-1.4-1.4-2.6-2.4-3.6s-2.2-1.8-3.6-2.4c-1.4-.6-2.8-.9-4.4-.9s-3,.3-4.4.9-2.6,1.4-3.6,2.4-1.8,2.2-2.4,3.6c-.6,1.4-.9,2.8-.9,4.4h0Z"/>
<path class="cls-3" d="M223.2,44.5c0,2.8-.5,5.4-1.5,7.7-1,2.4-2.4,4.4-4.2,6.2s-3.9,3.1-6.2,4.1-4.9,1.5-7.7,1.5-5.3-.5-7.7-1.5-4.4-2.4-6.2-4.2-3.1-3.8-4.2-6.2c-1-2.4-1.5-4.9-1.5-7.7s.5-5.3,1.5-7.7,2.4-4.4,4.2-6.2,3.8-3.1,6.2-4.2c2.4-1,4.9-1.5,7.7-1.5s2.1.1,3.2.4c1.1.2,2.1.6,3.1.9,1,.4,1.9.8,2.8,1.3.9.5,1.6,1,2.2,1.6V12.2h8.4v32.3h-.1ZM214.8,44.5c0-1.5-.3-3-.9-4.4-.6-1.4-1.4-2.6-2.4-3.6s-2.2-1.8-3.6-2.4c-1.4-.6-2.8-.9-4.4-.9s-3,.3-4.4.9c-1.4.6-2.6,1.4-3.6,2.4s-1.8,2.2-2.4,3.6c-.6,1.4-.9,2.8-.9,4.4s.3,3,.9,4.4c.6,1.4,1.4,2.6,2.4,3.6s2.2,1.8,3.6,2.4c1.4.6,2.8.9,4.4.9s3-.3,4.4-.9c1.4-.6,2.6-1.4,3.6-2.4s1.8-2.2,2.4-3.6c.6-1.4.9-2.8.9-4.4Z"/> <path class="cls-3" d="M219.2,44.5c0,2.8-.5,5.4-1.5,7.7-1,2.4-2.4,4.4-4.2,6.2s-3.9,3.1-6.2,4.1-4.9,1.5-7.7,1.5-5.3-.5-7.7-1.5-4.4-2.4-6.2-4.2-3.1-3.8-4.2-6.2c-1-2.4-1.5-4.9-1.5-7.7s.5-5.3,1.5-7.7,2.4-4.4,4.2-6.2,3.8-3.1,6.2-4.2c2.4-1,4.9-1.5,7.7-1.5s2.1.1,3.2.4c1.1.2,2.1.6,3.1.9,1,.4,1.9.8,2.8,1.3.9.5,1.6,1,2.2,1.6V12.2h8.4v32.3h-.1ZM210.8,44.5c0-1.5-.3-3-.9-4.4-.6-1.4-1.4-2.6-2.4-3.6s-2.2-1.8-3.6-2.4c-1.4-.6-2.8-.9-4.4-.9s-3,.3-4.4.9c-1.4.6-2.6,1.4-3.6,2.4s-1.8,2.2-2.4,3.6c-.6,1.4-.9,2.8-.9,4.4s.3,3,.9,4.4c.6,1.4,1.4,2.6,2.4,3.6s2.2,1.8,3.6,2.4c1.4.6,2.8.9,4.4.9s3-.3,4.4-.9c1.4-.6,2.6-1.4,3.6-2.4s1.8-2.2,2.4-3.6c.6-1.4.9-2.8.9-4.4Z"/>
<path class="cls-3" d="M227.7,17.4c0-1.5.5-2.8,1.5-3.8s2.2-1.5,3.7-1.5,2.8.5,3.8,1.5,1.5,2.3,1.5,3.8-.5,2.7-1.5,3.7-2.3,1.5-3.8,1.5-2.7-.5-3.7-1.5-1.5-2.2-1.5-3.7ZM228.7,26.6h8.4v36h-8.4V26.6Z"/> <path class="cls-3" d="M223.7,17.4c0-1.5.5-2.8,1.5-3.8s2.2-1.5,3.7-1.5,2.8.5,3.8,1.5,1.5,2.3,1.5,3.8-.5,2.7-1.5,3.7-2.3,1.5-3.8,1.5-2.7-.5-3.7-1.5-1.5-2.2-1.5-3.7ZM224.7,26.6h8.4v36h-8.4V26.6Z"/>
<path class="cls-3" d="M242.8,12.2h8.4v50.4h-8.4V12.2Z"/> <path class="cls-3" d="M238.8,12.2h8.4v50.4h-8.4V12.2Z"/>
<path class="cls-3" d="M256.9,12.2h8.4v50.4h-8.4V12.2Z"/> <path class="cls-3" d="M252.9,12.2h8.4v50.4h-8.4V12.2Z"/>
</g> </g>
<circle class="cls-5" cx="22.1" cy="22.1" r="22.1"/>
<rect class="cls-2" width="18.1" height="76.2"/>
<path class="cls-4" d="M18.8,43.9V.3C8.1,1.9,0,11,0,22.1s8.1,20.2,18.8,21.8Z"/>
<path class="cls-1" d="M27.5,52.8c0,.4,0,.9,0,1.3,0,12.2,9.9,22.1,22.1,22.1s22.1-9.9,22.1-22.1,0-.9,0-1.3H27.5Z"/>
<path class="cls-1" d="M36.4,51.3c0-.4,0-.9,0-1.3,0-12.2,9.9-22.1,22.1-22.1s22.1,9.9,22.1,22.1,0,.9,0,1.3h-44Z"/>
<g> <g>
<path class="cls-3" d="M286.8,12.9h15c5.2,0,9.3,1.2,12.2,3.6,2.9,2.4,4.3,6.4,4.3,11.7s-.4,6-1.1,7.6c-.7,1.6-1.8,3.1-3.2,4.3-3,2.4-7.1,3.5-12.2,3.5h-9.9v19.6h-5V12.9ZM310.1,36.5c1.9-1.5,2.9-4.3,2.9-8.3s-1-6.5-2.9-8.1c-2.1-1.5-4.8-2.3-8.1-2.3h-10.1v21h10.1c3.4,0,6.1-.8,8.1-2.3Z"/> <path class="cls-3" d="M282.8,12.9h15c5.2,0,9.3,1.2,12.2,3.6,2.9,2.4,4.3,6.4,4.3,11.7s-.4,6-1.1,7.6c-.7,1.6-1.8,3.1-3.2,4.3-3,2.4-7.1,3.5-12.2,3.5h-9.9v19.6h-5V12.9ZM306.1,36.5c1.9-1.5,2.9-4.3,2.9-8.3s-1-6.5-2.9-8.1c-2.1-1.5-4.8-2.3-8.1-2.3h-10.1v21h10.1c3.4,0,6.1-.8,8.1-2.3Z"/>
<path class="cls-3" d="M321.8,38.1c0-3.7.7-7.2,2.1-10.4,1.4-3.3,3.3-6.1,5.8-8.6,2.4-2.4,5.3-4.4,8.6-5.8,3.3-1.4,6.7-2.1,10.4-2.1s7.2.7,10.4,2.1c3.3,1.4,6.1,3.3,8.6,5.8,2.4,2.4,4.4,5.3,5.8,8.6,1.4,3.3,2.1,6.7,2.1,10.4s-.7,7.2-2.1,10.4c-1.4,3.3-3.3,6.1-5.8,8.6-2.4,2.4-5.3,4.4-8.6,5.8-3.3,1.4-6.7,2.1-10.4,2.1s-7.2-.7-10.4-2.1c-3.3-1.4-6.1-3.3-8.6-5.8-2.4-2.4-4.4-5.3-5.8-8.6-1.4-3.3-2.1-6.7-2.1-10.4ZM326.9,38.1c0,3,.6,5.8,1.7,8.5,1.2,2.7,2.7,5,4.7,7,2,2,4.3,3.6,7,4.7,2.7,1.2,5.5,1.7,8.5,1.7s5.8-.6,8.4-1.7c2.6-1.2,5-2.7,6.9-4.7,2-2,3.6-4.3,4.7-7,1.2-2.7,1.7-5.5,1.7-8.5s-.6-5.8-1.7-8.4c-1.2-2.6-2.7-5-4.7-7-2-2-4.3-3.6-6.9-4.7-2.6-1.2-5.4-1.7-8.4-1.7s-5.8.6-8.5,1.7-5,2.7-7,4.7c-2,2-3.6,4.3-4.7,7-1.2,2.6-1.7,5.5-1.7,8.4Z"/> <path class="cls-3" d="M317.8,38.1c0-3.7.7-7.2,2.1-10.4,1.4-3.3,3.3-6.1,5.8-8.6,2.4-2.4,5.3-4.4,8.6-5.8,3.3-1.4,6.7-2.1,10.4-2.1s7.2.7,10.4,2.1c3.3,1.4,6.1,3.3,8.6,5.8,2.4,2.4,4.4,5.3,5.8,8.6,1.4,3.3,2.1,6.7,2.1,10.4s-.7,7.2-2.1,10.4c-1.4,3.3-3.3,6.1-5.8,8.6-2.4,2.4-5.3,4.4-8.6,5.8-3.3,1.4-6.7,2.1-10.4,2.1s-7.2-.7-10.4-2.1c-3.3-1.4-6.1-3.3-8.6-5.8-2.4-2.4-4.4-5.3-5.8-8.6-1.4-3.3-2.1-6.7-2.1-10.4ZM322.9,38.1c0,3,.6,5.8,1.7,8.5,1.2,2.7,2.7,5,4.7,7,2,2,4.3,3.6,7,4.7,2.7,1.2,5.5,1.7,8.5,1.7s5.8-.6,8.4-1.7c2.6-1.2,5-2.7,6.9-4.7,2-2,3.6-4.3,4.7-7,1.2-2.7,1.7-5.5,1.7-8.5s-.6-5.8-1.7-8.4c-1.2-2.6-2.7-5-4.7-7-2-2-4.3-3.6-6.9-4.7-2.6-1.2-5.4-1.7-8.4-1.7s-5.8.6-8.5,1.7-5,2.7-7,4.7c-2,2-3.6,4.3-4.7,7-1.2,2.6-1.7,5.5-1.7,8.4Z"/>
<path class="cls-3" d="M410,50.4c0-2.4-.6-4.2-1.9-5.7s-2.9-2.6-4.9-3.6c-2-.9-4.2-1.8-6.6-2.5-2.4-.7-4.7-1.6-6.8-2.6-2.2-1-4.1-2.2-5.7-3.6-1.6-1.5-2.7-3.4-3.2-5.8-.2-2.1,0-4,.9-5.9.8-1.9,2-3.5,3.6-4.9,1.6-1.4,3.4-2.5,5.5-3.4,2.1-.8,4.4-1.3,6.7-1.3s4.8.5,6.8,1.4c1.9.9,3.6,2,4.9,3.3,1.3,1.3,2.4,2.6,3.1,4,.7,1.4,1.2,2.6,1.3,3.5h-5.5c-.9-2.3-2.4-4.1-4.3-5.3-2-1.2-4.1-1.8-6.3-1.9-1.5,0-3,.2-4.5.7-1.5.5-2.8,1.2-3.9,2.2-1.1.9-2,2-2.6,3.2-.6,1.2-.8,2.6-.5,4,.4,1.7,1.4,3,2.9,4,1.6,1,3.4,1.9,5.6,2.6,2.2.8,4.4,1.5,6.8,2.3,2.4.8,4.6,1.8,6.6,3,2,1.2,3.7,2.8,5,4.7,1.3,1.9,2,4.4,2,7.5s-.5,4.4-1.4,6.2c-1,1.8-2.2,3.4-3.9,4.6-1.6,1.2-3.5,2.2-5.6,2.8-2.1.6-4.3,1-6.6,1s-4.6-.4-6.8-1.2c-2.2-.8-4.2-2-5.8-3.4-1.7-1.5-3-3.2-4.1-5.3-1-2.1-1.5-4.3-1.5-6.8h5c0,1.6.3,3.2,1,4.6.7,1.4,1.6,2.7,2.8,3.7,1.2,1.1,2.6,1.9,4.2,2.5,1.6.6,3.3.9,5.1.9,1.6,0,3.2-.3,4.7-.6,1.5-.4,2.8-1,4-1.8s2.1-1.8,2.8-3c.7-1.2,1-2.6,1-4.2Z"/> <path class="cls-3" d="M406,50.4c0-2.4-.6-4.2-1.9-5.7s-2.9-2.6-4.9-3.6c-2-.9-4.2-1.8-6.6-2.5-2.4-.7-4.7-1.6-6.8-2.6-2.2-1-4.1-2.2-5.7-3.6-1.6-1.5-2.7-3.4-3.2-5.8-.2-2.1,0-4,.9-5.9.8-1.9,2-3.5,3.6-4.9,1.6-1.4,3.4-2.5,5.5-3.4,2.1-.8,4.4-1.3,6.7-1.3s4.8.5,6.8,1.4c1.9.9,3.6,2,4.9,3.3,1.3,1.3,2.4,2.6,3.1,4,.7,1.4,1.2,2.6,1.3,3.5h-5.5c-.9-2.3-2.4-4.1-4.3-5.3-2-1.2-4.1-1.8-6.3-1.9-1.5,0-3,.2-4.5.7-1.5.5-2.8,1.2-3.9,2.2-1.1.9-2,2-2.6,3.2-.6,1.2-.8,2.6-.5,4,.4,1.7,1.4,3,2.9,4,1.6,1,3.4,1.9,5.6,2.6,2.2.8,4.4,1.5,6.8,2.3,2.4.8,4.6,1.8,6.6,3,2,1.2,3.7,2.8,5,4.7,1.3,1.9,2,4.4,2,7.5s-.5,4.4-1.4,6.2c-1,1.8-2.2,3.4-3.9,4.6-1.6,1.2-3.5,2.2-5.6,2.8-2.1.6-4.3,1-6.6,1s-4.6-.4-6.8-1.2c-2.2-.8-4.2-2-5.8-3.4-1.7-1.5-3-3.2-4.1-5.3-1-2.1-1.5-4.3-1.5-6.8h5c0,1.6.3,3.2,1,4.6.7,1.4,1.6,2.7,2.8,3.7,1.2,1.1,2.6,1.9,4.2,2.5,1.6.6,3.3.9,5.1.9,1.6,0,3.2-.3,4.7-.6,1.5-.4,2.8-1,4-1.8s2.1-1.8,2.8-3c.7-1.2,1-2.6,1-4.2Z"/>
</g> </g>
<path class="cls-1" d="M23.3,53c0,.4,0,.9,0,1.3,0,12.1,9.8,21.9,21.9,21.9s21.9-9.8,21.9-21.9,0-.9,0-1.3H23.3Z"/>
<path class="cls-4" d="M32.1,56.2c0-.4,0-.9,0-1.3,0-12.1,9.8-21.9,21.9-21.9s21.9,9.8,21.9,21.9,0,.9,0,1.3h-43.7Z"/>
<path class="cls-2" d="M23.2,8.9c-.4,0-.9,0-1.3,0C9.8,8.8,0,18.6,0,30.7s9.8,21.9,21.9,21.9.9,0,1.3,0V8.9Z"/>
<path class="cls-5" d="M19.8,0c.4,0,.9,0,1.3,0,12.1,0,21.9,9.8,21.9,21.9s-9.8,21.9-21.9,21.9-.9,0-1.3,0V0Z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -27,5 +27,6 @@
</main> </main>
</div> </div>
</div> </div>
@include('partials.afia')
</body> </body>
</html> </html>
@@ -0,0 +1,15 @@
@props(['compact' => false])
<button type="button"
@click="window.dispatchEvent(new CustomEvent('afia-open'))"
@class([
'inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-400 text-sm font-semibold text-white shadow-sm transition hover:opacity-95',
'h-9 w-9 px-0 lg:h-auto lg:w-auto lg:px-4 lg:py-2' => $compact,
'px-3 py-2 lg:px-4' => ! $compact,
])
aria-label="Open Afia AI assistant">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2.25c.414 0 .75.336.75.75a5.25 5.25 0 0 0 5.25 5.25.75.75 0 0 1 0 1.5A5.25 5.25 0 0 0 12.75 15a.75.75 0 0 1-1.5 0A5.25 5.25 0 0 0 6 9.75a.75.75 0 0 1 0-1.5A5.25 5.25 0 0 0 11.25 3a.75.75 0 0 1 .75-.75Zm6.75 11.25a.75.75 0 0 1 .75.75A2.25 2.25 0 0 0 21.75 16.5a.75.75 0 0 1 0 1.5A2.25 2.25 0 0 0 19.5 20.25a.75.75 0 0 1-1.5 0A2.25 2.25 0 0 0 15.75 18a.75.75 0 0 1 0-1.5A2.25 2.25 0 0 0 18 14.25a.75.75 0 0 1 .75-.75ZM5.25 14.25a.75.75 0 0 1 .75.75 3 3 0 0 0 3 3 .75.75 0 0 1 0 1.5 3 3 0 0 0-3 3 .75.75 0 0 1-1.5 0 3 3 0 0 0-3-3 .75.75 0 0 1 0-1.5 3 3 0 0 0 3-3 .75.75 0 0 1 .75-.75Z"/>
</svg>
<span @class(['sr-only lg:not-sr-only lg:inline' => $compact, 'hidden sm:inline' => ! $compact])>AI</span>
</button>
+106
View File
@@ -0,0 +1,106 @@
@php
$afiaGreeting = "Hi, I'm Afia 👋 Ask me about taking a sale, charging by cash or Ladill Pay, managing products, or your sales history…";
$afiaSuggestions = [
'How do I take a sale?',
'How do I charge with Ladill Pay?',
'How do I add a product?',
'How do I import products from CRM?',
];
@endphp
{{-- Afia Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
<div x-data="afia({
chatUrl: '{{ route('pos.ai.chat') }}',
csrf: '{{ csrf_token() }}',
greeting: @js($afiaGreeting),
suggestions: @js($afiaSuggestions),
})" @afia-open.window="open = true; $nextTick(() => $refs.input && $refs.input.focus())">
<div x-show="open" x-cloak @click="close()" class="fixed inset-0 z-50 bg-slate-900/20"></div>
<section x-show="open" x-cloak
x-transition:enter="transition transform ease-out duration-200"
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
x-transition:leave="transition transform ease-in duration-150"
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
class="fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col bg-white shadow-2xl">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
<div class="flex items-center gap-2.5">
<span class="relative flex h-8 w-8 shrink-0 items-center justify-center">
<svg viewBox="0 0 36 36" class="h-8 w-8" aria-hidden="true">
<defs>
<radialGradient id="afia-orb" cx="40%" cy="35%" r="60%">
<stop offset="0%" stop-color="#a5b4fc"/>
<stop offset="50%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#3730a3"/>
</radialGradient>
</defs>
<circle cx="18" cy="18" r="14" fill="url(#afia-orb)">
<animate attributeName="r" values="14;14.8;14" dur="3s" repeatCount="indefinite"/>
</circle>
<circle cx="18" cy="18" r="11" fill="none" stroke="#c7d2fe" stroke-width=".6" opacity=".5">
<animate attributeName="r" values="11;12.5;11" dur="4s" repeatCount="indefinite"/>
<animate attributeName="opacity" values=".5;.15;.5" dur="4s" repeatCount="indefinite"/>
</circle>
<circle r="1.2" fill="#c7d2fe" opacity=".8">
<animateMotion dur="3s" repeatCount="indefinite" path="M18,8 A10,10 0 1,1 17.99,8" rotate="auto"/>
</circle>
</svg>
<span class="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-emerald-400"></span>
</span>
<div class="leading-tight">
<p class="text-sm font-semibold text-slate-900">Afia</p>
<p class="text-[11px] text-slate-400">POS assistant</p>
</div>
</div>
<div class="flex items-center gap-1">
<a href="{{ ladill_account_url('ai') }}"
class="rounded-lg px-2 py-1 text-[11px] font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-700"
title="AI usage history on your Ladill account">History</a>
<button @click="close()" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto px-4 py-4" x-ref="scroll">
<div class="space-y-3">
<template x-for="(m, i) in messages" :key="i">
<div class="flex" :class="m.role === 'user' ? 'justify-end' : 'justify-start'">
<div class="max-w-[85%] whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed"
:class="m.role === 'user' ? 'bg-indigo-600 text-white rounded-br-md' : 'bg-slate-100 text-slate-700 rounded-bl-md'"
x-html="m.text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\*\*([^*]+?)\*\*/g,'<strong>$1</strong>')"></div>
</div>
</template>
<div x-show="loading" class="flex justify-start">
<div class="rounded-2xl rounded-bl-md bg-slate-100 px-4 py-3">
<div class="flex gap-1">
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:0ms"></span>
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:150ms"></span>
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:300ms"></span>
</div>
</div>
</div>
</div>
<div x-show="messages.length <= 1 && suggestions.length" class="mt-4 space-y-2">
<template x-for="s in suggestions" :key="s">
<button @click="useSuggestion(s)" :disabled="loading"
class="flex w-full items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-left text-[13px] font-medium text-slate-700 transition hover:border-indigo-200 hover:bg-indigo-50 disabled:opacity-50">
<span class="text-indigo-400"></span><span x-text="s"></span>
</button>
</template>
</div>
</div>
<div class="border-t border-slate-100 px-4 pb-4 pt-3">
<form @submit.prevent="send()" class="flex items-end gap-2">
<input type="text" x-ref="input" x-model="input" :disabled="loading" placeholder="Message Afia…"
class="flex-1 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm text-slate-700 placeholder-slate-400 focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
<button type="submit" :disabled="loading || !input.trim()"
class="btn-fab shrink-0 disabled:opacity-40">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18"/></svg>
</button>
</form>
<p class="mt-2 text-center text-[10px] text-slate-400">Afia can make mistakes verify important details.</p>
</div>
</section>
</div>
+10 -2
View File
@@ -14,8 +14,6 @@
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />'], 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />'],
['name' => 'Sales', 'route' => route('pos.sales.index'), 'active' => request()->routeIs('pos.sales.*'), ['name' => 'Sales', 'route' => route('pos.sales.index'), 'active' => request()->routeIs('pos.sales.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />'], 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />'],
['name' => 'Settings', 'route' => route('pos.settings'), 'active' => request()->routeIs('pos.settings*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />'],
]; ];
@endphp @endphp
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4"> <nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
@@ -26,4 +24,14 @@
</a> </a>
@endforeach @endforeach
</nav> </nav>
<div class="shrink-0 border-t border-slate-100 px-3 py-3">
<a href="{{ route('pos.settings') }}"
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ request()->routeIs('pos.settings*') ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="h-[18px] w-[18px] shrink-0 {{ request()->routeIs('pos.settings*') ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<span class="flex-1 truncate">Settings</span>
</a>
</div>
</div> </div>
@@ -1,6 +1,6 @@
{{-- {{--
Standard desktop top-right widgets: Standard desktop top-right widgets:
notifications launcher divider avatar dropdown. AI notifications launcher divider avatar dropdown.
--}} --}}
@php @php
$topbarUser = $user ?? auth()->user(); $topbarUser = $user ?? auth()->user();
@@ -12,6 +12,8 @@
$showUserHeader = $showUser ?? true; $showUserHeader = $showUser ?? true;
@endphp @endphp
@includeIf('partials.afia-button', ['compact' => true])
@includeIf('partials.notification-dropdown') @includeIf('partials.notification-dropdown')
@include('partials.launcher') @include('partials.launcher')
+3 -1
View File
@@ -9,7 +9,9 @@
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg> <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button> </button>
<h1 class="truncate text-base font-semibold text-slate-900 lg:hidden">{{ $heading ?? 'Ladill POS' }}</h1> <h1 class="truncate text-base font-semibold text-slate-900 lg:hidden">{{ $heading ?? 'Ladill POS' }}</h1>
<a href="{{ route('pos.register') }}" class="hidden rounded-xl bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 lg:inline-flex">Open register</a> @unless(request()->routeIs('pos.register'))
<a href="{{ route('pos.register') }}" class="hidden rounded-xl bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 lg:inline-flex">Open register</a>
@endunless
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
+3
View File
@@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\Auth\SsoLoginController; use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\Pos\AiController;
use App\Http\Controllers\Pos\DashboardController; use App\Http\Controllers\Pos\DashboardController;
use App\Http\Controllers\Pos\ProductController; use App\Http\Controllers\Pos\ProductController;
use App\Http\Controllers\Pos\RegisterController; use App\Http\Controllers\Pos\RegisterController;
@@ -28,6 +29,8 @@ Route::get('/sales/{sale}/callback', [SaleController::class, 'callback'])->name(
Route::middleware(['auth', 'platform.session'])->group(function () { Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance'); Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance');
Route::post('/ai/chat', [AiController::class, 'chat'])->middleware('throttle:30,1')->name('pos.ai.chat');
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread'); Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread');
Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read'); Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read');