@extends('layouts.dashboard') @section('content') @php /** * Vendor loyalty surfaces: KPIs, the owner give-back impact meter, the per-reward * effective-% table, the catalog manager (add/edit/delete) and recent redemptions. * * @var array $allRewards every reward (active + inactive) * @var array $rewards the current pager window of $allRewards (catalog table) * @var array $pager paginate() metadata for the catalog table * @var array $activeRewards active rewards only (for the meter/table) * @var array $menuItems id,name,price for the free-item picker * @var array $kpis issued, redeemed, outstanding, members * @var array $effective ['per_reward'=>[id=>pct], 'blended'=>float] * @var array|null $cheapest cheapest active reward row (or null) * @var array $recent recent redemption rows * @var array $rangeLinks ['options' => [days => label], 'selected' => string] * @var int $rangeDays the selected chart window, in days * @var float|null $pointValue what one point is worth in currency (null = nothing redeemable) * @var float|null $liability outstanding points priced in currency (null when undefined) * @var float|null $redemptionRate programme-to-date % of issued points redeemed * @var array $flowChart @var array $flowMeta points issued vs redeemed per day * @var array $standingChart @var array $standingMeta redeemed vs still outstanding * @var array $bandsChart @var array $bandsMeta member balance distribution * @var array $typeChart @var array $typeMeta points movement by ledger type * @var array|null $restaurant */ use App\Services\Loyalty; $r = $restaurant ?? []; $enabled = (int) ($r['loyalty_enabled'] ?? 0) === 1; $earnRate = (float) ($r['loyalty_earn_rate'] ?? 0); $ptsLabel = (string) ($r['loyalty_points_label'] ?? 'points') ?: 'points'; $currency = setting('currency_symbol', '$'); $blended = (float) ($effective['blended'] ?? 0); $perReward = $effective['per_reward'] ?? []; // Build name/image maps for menu items so the catalog can show the linked dish. $miName = []; $miImage = []; foreach (($menuItems ?? []) as $mi) { $miName[(int) $mi['id']] = $mi['name']; $miImage[(int) $mi['id']] = $mi['image'] ?? ''; } // Cheapest reward figures for the meter copy. $cheapestCost = $cheapest ? (int) $cheapest['points_cost'] : 0; $cheapestPct = ($cheapest && isset($perReward[(int) $cheapest['id']])) ? (float) $perReward[(int) $cheapest['id']] : 0.0; $cheapestSpend = ($earnRate > 0 && $cheapestCost > 0) ? ($cheapestCost / $earnRate) : 0.0; $typeLabel = static fn (string $t): string => status_label('reward_type', $t); $showMoney = $showMoney ?? true; @endphp @php /* .qm-d2: the dashboard density standard (app.css §35) */ @endphp
@foreach ($rangeLinks['options'] as $val => $text) @php $on = ((string) $val === $rangeLinks['selected']); @endphp {{ $text }} @endforeach
@php /* The give-back policy is a money setting, so Settings strips the Loyalty section from a manager's own page — this link would land on nothing. */ @endphp @if ($showMoney) {{ t_raw('vendor.loyalty.settings_link') }} @endif
@if (!$enabled)
{!! t_raw('vendor.loyalty.alert_off', [ ':link' => '' . t('vendor.loyalty.settings_arrow_loyalty') . '', ]) !!}
@endif @php $kpiSparks = $kpiSparks ?? []; @endphp
@partial('kpi-card', ['variant' => 'panel', 'accent' => 'rose', 'icon' => 'fa-gift', 'label' => t_raw('vendor.loyalty.kpi_points_issued'), 'value' => number_format((int) $kpis['issued']), 'href' => route('vendor.orders'), 'spark' => $kpiSparks['issued'] ?? [], 'meta' => '' . t('vendor.loyalty.kpi_points_issued_meta') . '']) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'amber', 'icon' => 'fa-paper-plane', 'label' => t_raw('vendor.loyalty.kpi_points_redeemed'), 'value' => number_format((int) $kpis['redeemed']), 'href' => route('vendor.orders'), 'spark' => $kpiSparks['redeemed'] ?? [], 'meta' => '' . t('vendor.loyalty.kpi_points_redeemed_meta') . '']) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'green', 'icon' => 'fa-coins', 'label' => t_raw('vendor.loyalty.kpi_outstanding_balance'), 'value' => number_format((int) $kpis['outstanding']), 'spark' => $kpiSparks['outstanding'] ?? [], 'meta' => '' . t('vendor.loyalty.kpi_outstanding_meta') . '']) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'teal', 'icon' => 'fa-users', 'label' => t_raw('admin.loyalty.col_members'), 'value' => number_format((int) $kpis['members']), 'spark' => $kpiSparks['members'] ?? [], 'meta' => '' . t('vendor.loyalty.kpi_members_meta') . '']) @php /* What the programme actually owes. A point balance is not a number an owner can reconcile against anything; priced through the reward catalogue it becomes the real cost of every reward still waiting to be claimed. With no active rewards nothing can be redeemed, so the figure is genuinely undefined rather than zero. Owner-only: it is a money figure, so it follows the same boundary as the rest of the takings. */ @endphp @if ($showMoney) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'orange', 'icon' => 'fa-file-invoice-dollar', 'label' => t_raw('vendor.loyalty.kpi_liability'), 'value' => $liability !== null ? money($liability) : "—", 'spark' => $liability !== null ? ($kpiSparks['liability'] ?? []) : [], 'meta' => '' . ($pointValue !== null ? t('vendor.loyalty.kpi_liability_meta', [':rate' => money($pointValue)]) : t('vendor.loyalty.kpi_liability_meta_none')) . '']) @endif @partial('kpi-card', ['variant' => 'panel', 'accent' => 'blue', 'icon' => 'fa-percentage', 'label' => t_raw('vendor.loyalty.kpi_redemption_rate'), 'value' => $redemptionRate !== null ? number_format($redemptionRate, 1) . '%' : "—", 'meta' => '' . t('vendor.loyalty.kpi_redemption_rate_meta') . ''])
@php /* FIND A MEMBER. Points are keyed to the customer's phone number, so the phone is how an operator reaches a balance: the number goes in on the left and the members it matches list right under it as it is typed (name, phone, email, points); the one clicked opens on the right — who they are, their standing, and the owner's adjust fields, which update the balance in place. It sits under the KPIs because it is the one thing a vendor comes here to DO; everything below it is reading. loyalty-members.js drives it; without JS the adjust form still posts and comes back with a flash. */ @endphp
@php /* The heading lives in the left column so the row starts at the top of the card and the workspace on the right runs level with it - no band of white above the panel. */ @endphp

{{ t_raw('vendor.loyalty.find_heading') }}

{{ t_raw('vendor.loyalty.find_sub') }}

{{ t_raw('vendor.loyalty.find_hint') }}

@php /* Nothing picked yet: say what the panel is for. */ @endphp

{{ t_raw('vendor.loyalty.find_pick') }}

@php /* The member picked from the matches: who they are, by phone; their standing; and, for the owner, the adjust fields. Issuing points spends the give-back budget, so a manager sees the member but not the form. */ @endphp
@php /* Trends: how points flow in and out day by day, and where every point ever issued now stands. Each card's caption and its accessible summary are the same sentence. */ @endphp
@partial('chart-card', ['heading' => t_raw('vendor.loyalty.chart_points_flow'), 'sub' => $flowMeta['label'], 'cfg' => $flowChart, 'meta' => $flowMeta, 'accent' => 'rose', 'icon' => 'fa-chart-line'])
@partial('chart-card', ['heading' => t_raw('vendor.loyalty.chart_points_standing'), 'sub' => $standingMeta['label'], 'cfg' => $standingChart, 'meta' => $standingMeta, 'accent' => 'green', 'icon' => 'fa-coins'])
@partial('chart-card', ['heading' => t_raw('vendor.loyalty.chart_balance_bands'), 'sub' => $bandsMeta['label'], 'cfg' => $bandsChart, 'meta' => $bandsMeta, 'accent' => 'teal', 'icon' => 'fa-chart-bar'])
@partial('chart-card', ['heading' => t_raw('vendor.loyalty.chart_points_by_type'), 'sub' => $typeMeta['label'], 'cfg' => $typeChart, 'meta' => $typeMeta, 'accent' => 'amber', 'icon' => 'fa-chart-bar'])

{{ t_raw('vendor.loyalty.giveback_rate_heading') }}

{{ t_raw('vendor.loyalty.giveback_rate_sub') }}

{{ t_raw('vendor.settings.loyalty_blended_label') }} {{ number_format($blended, 1) }}%

@if ($cheapest) @php $rewardTitleMarker = '@@qm_reward_title@@'; $meterNote = t_raw('vendor.settings.loyalty_meter_note', [ ':amount' => '' . money($cheapestSpend) . '', ':title' => $rewardTitleMarker, ':cost' => (int) $cheapestCost, ':pct' => '' . number_format($cheapestPct, 1) . '%', ]); echo str_replace($rewardTitleMarker, e($cheapest['title']), $meterNote); @endphp @else {{ t_raw('vendor.loyalty.meter_empty') }} @endif

@if (count($activeRewards) > 0)

{{ t_raw('vendor.loyalty.effective_table_heading') }}

@php /* No rows-per-page control here: this table is the active slice of the very catalogue the manager below pages over, so a second pager on the same data would fight the first. The give-back column is drawn as a percentage plus a bar, so it carries its numeric value in data-sort-value. */ @endphp @foreach ($activeRewards as $rw) @php $val = Loyalty::rewardValue($rw); $cost = (int) $rw['points_cost']; $spend = ($earnRate > 0 && $cost > 0) ? ($cost / $earnRate) : 0.0; $pct = (float) ($perReward[(int) $rw['id']] ?? 0); @endphp @endforeach
{{ t_raw('vendor.loyalty.col_reward') }}{{ t_raw('common.type') }}{{ t_raw('common.value') }}{{ t_raw('vendor.loyalty.col_points') }}{{ t_raw('vendor.loyalty.col_spend_to_earn') }}{{ t_raw('vendor.loyalty.col_giveback') }}
{{ $rw['title'] }} {!! $typeLabel($rw['type']) !!} {{ money($val) }} {{ (int) $cost }} {{ money($spend) }} {{ number_format($pct, 1) }}%
@endif

{{ t_raw('vendor.loyalty.catalog_heading') }}

@php /* Reward CRUD prices the give-back, so LoyaltyController keeps saveReward, deleteReward and adjust to the owner. A manager reads the catalogue instead of editing it — the same boundary the liability KPI above already draws. */ @endphp @if ($showMoney) @endif
@if (count($rewards) > 0)
@if ($showMoney)@endif @foreach ($rewards as $rw) @php $isFree = ($rw['type'] ?? '') === 'free_item'; $val = Loyalty::rewardValue($rw); $rwMiId = (int) ($rw['menu_item_id'] ?? 0); $rwImg = $isFree ? ($miImage[$rwMiId] ?? '') : ''; $rwMiName = $isFree ? ($miName[$rwMiId] ?? '') : ''; @endphp @if ($showMoney) @endif @endforeach
{{ t_raw('common.title') }}{{ t_raw('common.type') }}{{ t_raw('common.value') }}{{ t_raw('vendor.loyalty.col_points') }}{{ t_raw('common.active') }}{{ t_raw('common.actions') }}
{{ $rw['title'] }} @if ($rwMiName !== ''){{ $rwMiName }}@endif
{!! $typeLabel($rw['type']) !!} {{ money($val) }} {{ (int) $rw['points_cost'] }} {!! (int) $rw['is_active'] === 1 ? t('common.yes') : t('common.no') !!}
@csrf
@partial('table-toolbar', ['pager' => $pager, 'base' => '/vendor/loyalty']) @else

{{ t_raw('vendor.loyalty.empty_no_rewards_title') }}

{{ t_raw('vendor.loyalty.empty_no_rewards_text') }}

@endif

{{ t_raw('vendor.loyalty.recent_redemptions_heading') }}

@php /* A deliberately bounded "latest ten" strip rather than a paged list — the page already carries one pager, on the reward catalogue, and both would read the same ?page= parameter. When renders via fmt_datetime, so that cell carries its raw timestamp in data-sort-value. */ @endphp @if ($recent) @foreach ($recent as $t) @endforeach @else @partial('table-empty', ['colspan' => 4, 'icon' => 'fa-gift', 'msg' => t_raw('vendor.loyalty.empty_no_redemptions')]) @endif
{{ t_raw('common.customer') }}{{ t_raw('vendor.loyalty.col_reward') }}{{ t_raw('vendor.loyalty.col_points') }}{{ t_raw('common.when') }}
{!! brandmark_html($t['customer_avatar'] ?? null, (string) ($t['customer_name'] ?? t_raw('common.customer')), 'md', 'qm-brandmark--round') !!}
{{ $t['customer_name'] ?? t_raw('common.customer') }}@if (!empty($t['customer_phone'])){{ (string) $t['customer_phone'] }}@endif
{!! or_dash($t['note'] ?? null) !!} {{ number_format(abs((int) $t['points'])) }} {!! fmt_datetime($t['created_at']) !!}
@if ($showMoney) @endif
@php /* /.qm-d2 */ @endphp @endsection