Email digital receipts, add promo settings, and apply tips at charge.
Deploy Ladill POS / deploy (push) Successful in 31s

Send real receipt emails from the customer display and sale page, store
branch promo content for the idle screen, and fold selected tips into totals.
This commit is contained in:
isaacclad
2026-07-15 16:10:20 +00:00
parent 468346b183
commit 600aedb59d
20 changed files with 675 additions and 19 deletions
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Receipt {{ $sale->reference }}</title>
</head>
<body style="margin:0;padding:0;background:#f8fafc;font-family:Figtree,ui-sans-serif,system-ui,-apple-system,sans-serif;color:#0f172a;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f8fafc;padding:24px 12px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:480px;background:#ffffff;border:1px solid #e2e8f0;border-radius:16px;overflow:hidden;">
<tr>
<td style="padding:24px 24px 8px;text-align:center;">
@if ($location?->receiptLogoUrl())
<img src="{{ $location->receiptLogoUrl() }}" alt="" width="72" height="72" style="display:block;margin:0 auto 12px;border-radius:12px;object-fit:contain;background:#f8fafc;">
@endif
<p style="margin:0;font-size:18px;font-weight:700;">
{{ $location?->receipt_header ?: $location?->name ?: 'Receipt' }}
</p>
<p style="margin:8px 0 0;font-size:13px;color:#64748b;">
{{ $sale->reference }} · {{ optional($sale->paid_at ?? $sale->created_at)->format('d M Y, H:i') }}
</p>
</td>
</tr>
<tr>
<td style="padding:8px 24px 0;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="font-size:14px;">
@foreach ($sale->lines as $line)
<tr>
<td style="padding:10px 0;border-bottom:1px solid #f1f5f9;color:#334155;">
{{ $line->quantity }}× {{ $line->name }}
</td>
<td style="padding:10px 0;border-bottom:1px solid #f1f5f9;text-align:right;font-weight:600;white-space:nowrap;">
{{ pos_money($line->line_total_minor, $sale->currency) }}
</td>
</tr>
@endforeach
</table>
</td>
</tr>
<tr>
<td style="padding:16px 24px 24px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="font-size:14px;">
<tr>
<td style="padding:4px 0;color:#64748b;">Subtotal</td>
<td style="padding:4px 0;text-align:right;">{{ pos_money($sale->subtotal_minor, $sale->currency) }}</td>
</tr>
@if ($sale->loyalty_discount_minor)
<tr>
<td style="padding:4px 0;color:#059669;">Loyalty</td>
<td style="padding:4px 0;text-align:right;color:#059669;">{{ pos_money($sale->loyalty_discount_minor, $sale->currency) }}</td>
</tr>
@endif
@if ($sale->tip_minor)
<tr>
<td style="padding:4px 0;color:#64748b;">Tip</td>
<td style="padding:4px 0;text-align:right;">{{ pos_money($sale->tip_minor, $sale->currency) }}</td>
</tr>
@endif
<tr>
<td style="padding:12px 0 0;font-size:18px;font-weight:700;">Total</td>
<td style="padding:12px 0 0;text-align:right;font-size:18px;font-weight:700;color:#4f46e5;">
{{ pos_money($sale->total_minor, $sale->currency) }}
</td>
</tr>
@if ($sale->loyalty_points_earned)
<tr>
<td colspan="2" style="padding:12px 0 0;font-size:12px;color:#b45309;">
You earned {{ $sale->loyalty_points_earned }} loyalty points on this visit.
</td>
</tr>
@endif
</table>
@if ($location?->receipt_footer)
<p style="margin:20px 0 0;font-size:12px;color:#94a3b8;text-align:center;">{{ $location->receipt_footer }}</p>
@endif
<p style="margin:16px 0 0;font-size:11px;color:#cbd5e1;text-align:center;">Powered by Ladill POS</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+11 -3
View File
@@ -228,7 +228,8 @@
<button type="button" @click="sendReceiptEmail()"
class="rounded-xl bg-indigo-500 px-4 py-2.5 text-sm font-semibold hover:bg-indigo-400">Send</button>
</div>
<p x-show="emailSent" class="mt-2 text-sm text-emerald-300">Request sent to the till.</p>
<p x-show="emailSent" class="mt-2 text-sm text-emerald-300">Receipt emailed check your inbox.</p>
<p x-show="emailError" class="mt-2 text-sm text-rose-300" x-text="emailError"></p>
</div>
<p class="text-xs text-slate-500" x-show="state.brand?.footer" x-text="state.brand.footer"></p>
@@ -248,6 +249,7 @@
customTip: '',
receiptEmail: '',
emailSent: false,
emailError: '',
sigSaved: false,
drawing: false,
clockTimer: null,
@@ -373,8 +375,14 @@
async sendReceiptEmail() {
if (!this.receiptEmail) return;
await this.postAction({ type: 'receipt_email', email: this.receiptEmail });
this.emailSent = true;
this.emailSent = false;
this.emailError = '';
const data = await this.postAction({ type: 'receipt_email', email: this.receiptEmail });
if (data.email_sent) {
this.emailSent = true;
} else {
this.emailError = data.message || 'Could not send email.';
}
},
resizeCanvas() {
@@ -139,6 +139,12 @@
<td class="amt">{{ pos_money($sale->loyalty_discount_minor, $sale->currency) }}</td>
</tr>
@endif
@if ($sale->tip_minor)
<tr>
<td colspan="2">Tip</td>
<td class="amt">{{ pos_money($sale->tip_minor, $sale->currency) }}</td>
</tr>
@endif
<tr class="total-row">
<td colspan="2">Total</td>
<td class="amt">{{ pos_money($sale->total_minor, $sale->currency) }}</td>
+19 -5
View File
@@ -109,10 +109,15 @@
<span class="text-emerald-600">Loyalty discount</span>
<span class="font-medium text-emerald-700" x-text="'' + formatMoney(loyaltyDiscountMinor)"></span>
</div>
<div class="flex justify-between text-sm" x-show="tipMinor > 0">
<span class="text-slate-500">Tip</span>
<span class="font-medium text-slate-800" x-text="formatMoney(tipMinor)"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-slate-500">Total</span>
<span class="font-semibold text-slate-900" x-text="formatMoney(payableTotal())"></span>
</div>
<input type="hidden" name="tip_minor" :value="tipMinor || 0">
</div>
<div class="mt-4 space-y-3">
@@ -197,6 +202,8 @@
loyaltyMaxRedeem: 0,
loyaltyDiscountMinor: 0,
loyaltyQuoteTimer: null,
tipMinor: 0,
tipPercent: null,
isRestaurant: !!config.isRestaurant,
currency: config.currency || 'GHS',
cart: [],
@@ -280,7 +287,7 @@
} catch (_) {}
},
payableTotal() {
return Math.max(0, this.cartTotal() - (this.loyaltyDiscountMinor || 0));
return Math.max(0, this.cartTotal() - (this.loyaltyDiscountMinor || 0)) + (this.tipMinor || 0);
},
onGlobalKeydown(e) {
if (this.shouldIgnoreScan(e)) return;
@@ -529,16 +536,23 @@
const action = data.action;
if (!action?.type) return;
if (action.type === 'tip') {
const tip = action.tip_minor != null
? (this.currency + ' ' + (Number(action.tip_minor) / 100).toFixed(2))
this.tipMinor = Number(action.tip_minor) || 0;
this.tipPercent = action.tip_percent ?? null;
const tip = this.tipMinor
? (this.currency + ' ' + (this.tipMinor / 100).toFixed(2))
: ((action.tip_percent ?? 0) + '%');
this.customerActionNote = 'Customer selected tip: ' + tip;
this.customerActionNote = this.tipMinor
? ('Tip applied: ' + tip + ' — will be added at charge.')
: 'Customer declined tip.';
this.scheduleDisplayPush();
} else if (action.type === 'signature') {
this.customerActionNote = 'Customer signature captured.';
} else if (action.type === 'receipt_email' && action.email) {
this.customerActionNote = 'Customer requested receipt email: ' + action.email;
const emailInput = document.getElementById('customer_email');
if (emailInput && !emailInput.value) emailInput.value = action.email;
this.customerActionNote = action.email_sent
? ('Receipt emailed to ' + action.email)
: (action.email_message || ('Receipt email: ' + action.email));
} else if (action.type === 'payment_option') {
this.customerActionNote = 'Customer chose payment: ' + (action.payment_option || '');
}
+15
View File
@@ -30,6 +30,9 @@
@if ($sale->loyalty_discount_minor)
<div><dt class="text-slate-400">Loyalty discount</dt><dd class="text-emerald-700">{{ pos_money($sale->loyalty_discount_minor, $sale->currency) }} ({{ $sale->loyalty_points_redeemed }} pts)</dd></div>
@endif
@if ($sale->tip_minor)
<div><dt class="text-slate-400">Tip</dt><dd class="text-slate-800">{{ pos_money($sale->tip_minor, $sale->currency) }}</dd></div>
@endif
@if ($sale->loyalty_points_earned)
<div><dt class="text-slate-400">Points earned</dt><dd class="text-amber-800">+{{ $sale->loyalty_points_earned }} pts</dd></div>
@endif
@@ -59,6 +62,18 @@
class="inline-flex items-center justify-center rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-slate-800">
Print receipt
</a>
@if ($sale->isPaid())
<form method="post" action="{{ route('pos.sales.email-receipt', $sale) }}" class="flex flex-wrap items-center gap-2">
@csrf
<input type="email" name="email" required
value="{{ old('email', $sale->customer_email) }}"
placeholder="customer@email.com"
class="rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<button type="submit" class="rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50">
Email receipt
</button>
</form>
@endif
</div>
@if ($sale->isPaid() && !empty($invoiceUrl))
+42
View File
@@ -79,6 +79,48 @@
<span class="mt-0.5 block text-xs text-slate-500">Opens the receipt print dialog when you record a cash payment.</span>
</span>
</label>
<div class="border-t border-slate-100 pt-4">
<p class="text-sm font-semibold text-slate-900">Customer screen promo</p>
<p class="mt-1 text-xs text-slate-400">Shown on the dual-screen customer display when the cart is idle. Leave blank to use the branch name and a default thank-you message.</p>
</div>
<div>
<label for="customer_promo_headline" class="text-sm font-medium text-slate-700">Promo headline</label>
<input type="text" id="customer_promo_headline" name="customer_promo_headline" maxlength="160"
value="{{ old('customer_promo_headline', $location->customer_promo_headline) }}"
placeholder="{{ $location->name }}"
class="mt-1.5 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div>
<label for="customer_promo_body" class="text-sm font-medium text-slate-700">Promo message</label>
<textarea id="customer_promo_body" name="customer_promo_body" rows="2" maxlength="500"
placeholder="Thank you for shopping with us."
class="mt-1.5 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">{{ old('customer_promo_body', $location->customer_promo_body) }}</textarea>
</div>
<div>
<label class="text-sm font-medium text-slate-700">Promo image</label>
<p class="mt-0.5 text-xs text-slate-400">Optional. Falls back to the receipt logo when empty.</p>
<div class="mt-3 flex items-center gap-4">
@if ($location->customer_promo_image_path || $location->receipt_logo_path)
<img src="{{ $location->customerPromoImageUrl() }}?v={{ $location->updated_at?->timestamp }}"
alt="Promo preview"
class="h-16 w-16 rounded-xl border border-slate-200 bg-white object-contain p-1">
@else
<div class="flex h-16 w-16 items-center justify-center rounded-xl border border-dashed border-slate-300 text-[10px] text-slate-400">No image</div>
@endif
<div class="min-w-0 flex-1 space-y-2">
<input type="file" name="customer_promo_image" accept="image/png,image/jpeg,image/gif,image/webp"
class="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-indigo-700 hover:file:bg-indigo-100">
@if ($location->customer_promo_image_path)
<label class="inline-flex items-center gap-2 text-xs text-slate-500">
<input type="checkbox" name="remove_customer_promo_image" value="1" class="rounded border-slate-300 text-indigo-600">
Remove promo image
</label>
@endif
</div>
</div>
</div>
<div class="border-t border-slate-100 pt-4">
<p class="text-sm font-semibold text-slate-900">Payment gateway</p>
<p class="mt-1 text-xs text-slate-400">Connect Paystack, Flutterwave, or Hubtel. Online checkouts go 100% to you 0% Ladill fee.</p>