Initial Ladill Give extraction — online giving pages at give.ladill.com.
Donation checkout via Ladill Pay (9% fee), church/giving QR pages, SSO, and platform scan forwarding. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../**/*.blade.php';
|
||||
@source '../**/*.js';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Figtree', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* QR create/show: flush mobile action bar to the physical screen bottom (mobile only) */
|
||||
@media (max-width: 1023px) {
|
||||
.mobile-action-bar {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.mobile-action-bar__toolbar {
|
||||
display: flex;
|
||||
height: 4rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-top: 1px solid rgb(226 232 240);
|
||||
background-color: #fff;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.mobile-action-bar__inset-fill {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
height: env(safe-area-inset-bottom, 0px);
|
||||
min-height: max(env(safe-area-inset-bottom, 0px), 20px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra white bleed behind the bar for any remaining viewport gap on mobile */
|
||||
.qr-mobile-bottom-bleed {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 48;
|
||||
pointer-events: none;
|
||||
background-color: #fff;
|
||||
height: calc(4rem + env(safe-area-inset-bottom, 0px) + 24px);
|
||||
min-height: calc(4rem + 24px);
|
||||
}
|
||||
|
||||
html.qr-mobile-page {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
html.qr-mobile-page body {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* Payment QR landing: lock scroll and keep the amount sheet flush to the screen bottom */
|
||||
@media (max-width: 767px) {
|
||||
html.mini-payment-page,
|
||||
html.mini-payment-page body {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html.mini-payment-page body {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.mini-payment-sheet {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.mini-payment-bottom-bleed {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 19;
|
||||
pointer-events: none;
|
||||
background-color: #fff;
|
||||
height: calc(12rem + env(safe-area-inset-bottom, 0px));
|
||||
min-height: 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-stats-card {
|
||||
width: calc(66.666667% - 0.5rem);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background-image: linear-gradient(to right, rgb(79 70 229), rgb(124 58 237));
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-primary-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary-lg {
|
||||
padding: 0.625rem 1.25rem;
|
||||
}
|
||||
|
||||
.btn-fab {
|
||||
display: inline-flex;
|
||||
height: 2.75rem;
|
||||
width: 2.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background-image: linear-gradient(to right, rgb(79 70 229), rgb(124 58 237));
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-fab:hover {
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.mobile-stats-card {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import './bootstrap';
|
||||
|
||||
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
|
||||
|
||||
registerLadillModalHelpers();
|
||||
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
import collapse from '@alpinejs/collapse';
|
||||
import QRCodeStyling from 'qr-code-styling';
|
||||
window.QRCodeStyling = QRCodeStyling;
|
||||
import qrcode from 'qrcode-generator';
|
||||
window.qrcode = qrcode;
|
||||
|
||||
Alpine.plugin(collapse);
|
||||
|
||||
Alpine.data('notificationDropdown', (config = {}) => ({
|
||||
open: false,
|
||||
loading: false,
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
unreadUrl: config.unreadUrl || '/notifications/unread',
|
||||
markReadUrl: config.markReadUrl || '/notifications/__ID__/read',
|
||||
markAllReadUrl: config.markAllReadUrl || '/notifications/mark-all-read',
|
||||
indexUrl: config.indexUrl || '/notifications',
|
||||
csrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
|
||||
init() {
|
||||
this.fetchUnread();
|
||||
setInterval(() => this.fetchUnread(), 60000);
|
||||
},
|
||||
|
||||
async fetchUnread() {
|
||||
try {
|
||||
const res = await fetch(this.unreadUrl, {
|
||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
const data = await res.json();
|
||||
this.notifications = data.notifications || [];
|
||||
this.unreadCount = data.unread_count || 0;
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch notifications', e);
|
||||
}
|
||||
},
|
||||
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
if (this.open) {
|
||||
this.loading = true;
|
||||
this.fetchUnread().finally(() => { this.loading = false; });
|
||||
}
|
||||
},
|
||||
|
||||
async markRead(id) {
|
||||
const url = this.markReadUrl.replace('__ID__', id);
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||||
} catch (e) {
|
||||
console.error('Failed to mark notification as read', e);
|
||||
}
|
||||
},
|
||||
|
||||
async markAllRead() {
|
||||
try {
|
||||
await fetch(this.markAllReadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
this.notifications = [];
|
||||
this.unreadCount = 0;
|
||||
} catch (e) {
|
||||
console.error('Failed to mark all notifications as read', e);
|
||||
}
|
||||
},
|
||||
|
||||
getIconBg(icon) {
|
||||
const map = { domain: 'bg-emerald-50', hosting: 'bg-violet-50', email: 'bg-pink-50', billing: 'bg-amber-50', success: 'bg-green-50' };
|
||||
return map[icon] || 'bg-slate-100';
|
||||
},
|
||||
|
||||
getIconColor(icon) {
|
||||
const map = { domain: 'text-emerald-600', hosting: 'text-violet-600', email: 'text-pink-600', billing: 'text-amber-600', success: 'text-green-600' };
|
||||
return map[icon] || 'text-slate-500';
|
||||
},
|
||||
}));
|
||||
|
||||
// Afia — Ladill in-app AI assistant (slide-over chat). Opened via the topbar AI button
|
||||
// which dispatches a window 'afia-open' event. Greeting/suggestions are passed from Blade.
|
||||
Alpine.data('afia', (config = {}) => ({
|
||||
open: false,
|
||||
input: '',
|
||||
loading: false,
|
||||
messages: [
|
||||
{ role: 'assistant', text: config.greeting || "Hi, I'm Afia 👋 How can I help?" },
|
||||
],
|
||||
suggestions: config.suggestions || [],
|
||||
init() {
|
||||
window.addEventListener('afia-open', () => {
|
||||
this.open = true;
|
||||
this.$nextTick(() => this.$refs.input && this.$refs.input.focus());
|
||||
});
|
||||
},
|
||||
close() { this.open = false; },
|
||||
useSuggestion(s) { this.input = s; this.send(); },
|
||||
scrollDown() {
|
||||
this.$nextTick(() => { const el = this.$refs.scroll; if (el) el.scrollTop = el.scrollHeight; });
|
||||
},
|
||||
async send() {
|
||||
const text = this.input.trim();
|
||||
if (!text || this.loading) return;
|
||||
const history = this.messages.map((m) => ({ role: m.role, text: m.text }));
|
||||
this.messages.push({ role: 'user', text });
|
||||
this.input = '';
|
||||
this.loading = true;
|
||||
this.scrollDown();
|
||||
try {
|
||||
const res = await fetch(config.chatUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': config.csrf, 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ message: text, history }),
|
||||
});
|
||||
const data = await res.json();
|
||||
this.messages.push({ role: 'assistant', text: data.reply || data.message || 'Sorry, I could not respond.' });
|
||||
} catch (e) {
|
||||
this.messages.push({ role: 'assistant', text: 'Network error — please try again.' });
|
||||
}
|
||||
this.loading = false;
|
||||
this.scrollDown();
|
||||
},
|
||||
}));
|
||||
|
||||
function mobileKeyboardBottomOffset() {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop));
|
||||
}
|
||||
|
||||
Alpine.data('miniPaymentLanding', (config = {}) => ({
|
||||
amount: config.amount ?? '',
|
||||
loading: false,
|
||||
errorMsg: config.errorMsg ?? '',
|
||||
showSheet: false,
|
||||
checkoutUrl: '',
|
||||
paymentSheetStyle: '',
|
||||
sheetBleedStyle: '',
|
||||
|
||||
init() {
|
||||
this._syncSheet = () => {
|
||||
if (window.innerWidth >= 768) {
|
||||
this.paymentSheetStyle = '';
|
||||
this.sheetBleedStyle = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = mobileKeyboardBottomOffset();
|
||||
const safePad = offset > 0 ? '1.25rem' : 'max(1.25rem, env(safe-area-inset-bottom))';
|
||||
this.paymentSheetStyle = `bottom: ${offset}px; padding-bottom: ${safePad};`;
|
||||
this.sheetBleedStyle = `bottom: ${offset}px;`;
|
||||
};
|
||||
|
||||
this._onViewportChange = () => this._syncSheet();
|
||||
this._onFocusChange = () => {
|
||||
requestAnimationFrame(this._syncSheet);
|
||||
setTimeout(this._syncSheet, 150);
|
||||
setTimeout(this._syncSheet, 350);
|
||||
};
|
||||
|
||||
window.visualViewport?.addEventListener('resize', this._onViewportChange);
|
||||
window.visualViewport?.addEventListener('scroll', this._onViewportChange);
|
||||
document.addEventListener('focusin', this._onFocusChange);
|
||||
document.addEventListener('focusout', this._onFocusChange);
|
||||
|
||||
if (window.innerWidth < 768) {
|
||||
document.documentElement.classList.add('mini-payment-page');
|
||||
}
|
||||
|
||||
this._syncSheet();
|
||||
},
|
||||
|
||||
destroy() {
|
||||
window.visualViewport?.removeEventListener('resize', this._onViewportChange);
|
||||
window.visualViewport?.removeEventListener('scroll', this._onViewportChange);
|
||||
document.removeEventListener('focusin', this._onFocusChange);
|
||||
document.removeEventListener('focusout', this._onFocusChange);
|
||||
document.documentElement.classList.remove('mini-payment-page');
|
||||
},
|
||||
|
||||
async submitPay() {
|
||||
const value = parseFloat(this.amount);
|
||||
if (!value || value <= 0) {
|
||||
this.errorMsg = 'Enter an amount greater than zero.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMsg = '';
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(config.payUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': config.csrf,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify({ amount: value }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.error || data.message) {
|
||||
this.errorMsg = data.error || data.message || 'Could not start payment. Please try again.';
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
if (!data.checkout_url) {
|
||||
this.errorMsg = 'Could not start payment. Please try again.';
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
if (window.innerWidth < 768) {
|
||||
this.checkoutUrl = data.checkout_url;
|
||||
this.showSheet = true;
|
||||
this.loading = false;
|
||||
} else {
|
||||
window.location.href = data.checkout_url;
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorMsg = 'Network error. Please try again.';
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Alpine.data('topbarSearch', (config = {}) => ({
|
||||
query: config.initialQuery || '',
|
||||
results: Array.isArray(config.initialResults) ? config.initialResults : [],
|
||||
open: !!config.openOnInit,
|
||||
loading: false,
|
||||
active: 0,
|
||||
_abort: null,
|
||||
searchUrl: config.searchUrl || '/search',
|
||||
|
||||
init() {
|
||||
if (config.autoFocus) {
|
||||
this.$nextTick(() => this.$refs.input?.focus());
|
||||
}
|
||||
},
|
||||
|
||||
onFocus() {
|
||||
if (this.results.length > 0 || this.query.trim().length >= 2) this.open = true;
|
||||
},
|
||||
|
||||
async search() {
|
||||
const q = this.query.trim();
|
||||
if (q.length < 2) { this.results = []; this.open = false; return; }
|
||||
|
||||
this.loading = true;
|
||||
this.open = true;
|
||||
this.active = 0;
|
||||
|
||||
if (this._abort) this._abort.abort();
|
||||
this._abort = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.searchUrl}?q=${encodeURIComponent(q)}`, {
|
||||
signal: this._abort.signal,
|
||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
const data = await res.json();
|
||||
this.results = data.results || [];
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') this.results = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
moveDown() { if (this.active < this.results.length - 1) this.active++; },
|
||||
moveUp() { if (this.active > 0) this.active--; },
|
||||
go() {
|
||||
if (this.results[this.active]) window.location.href = this.results[this.active].url;
|
||||
},
|
||||
}));
|
||||
|
||||
window.Alpine = Alpine;
|
||||
registerLadillConfirmStore(Alpine);
|
||||
|
||||
Alpine.start();
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Lightweight modal dispatch helpers (optional — balanceGate in layout is primary).
|
||||
*/
|
||||
|
||||
export function openLadillModal(name) {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('open-modal', { detail: name, bubbles: true }));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function closeLadillModal(name) {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('close-modal', { detail: name, bubbles: true }));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ladillNeedsTopup(balance, price) {
|
||||
const normalizedBalance = Number.parseFloat(balance);
|
||||
const normalizedPrice = Number.parseFloat(price);
|
||||
|
||||
if (!Number.isFinite(normalizedBalance) || !Number.isFinite(normalizedPrice)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizedBalance <= 0 || normalizedBalance < normalizedPrice;
|
||||
}
|
||||
|
||||
export function registerLadillConfirmStore(Alpine) {
|
||||
Alpine.store('ladillConfirm', {
|
||||
open: false,
|
||||
title: '',
|
||||
message: '',
|
||||
confirmLabel: 'Confirm',
|
||||
cancelLabel: 'Cancel',
|
||||
variant: 'danger',
|
||||
_resolve: null,
|
||||
|
||||
ask(options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
this.title = options.title || 'Are you sure?';
|
||||
this.message = options.message || '';
|
||||
this.confirmLabel = options.confirmLabel || 'Confirm';
|
||||
this.cancelLabel = options.cancelLabel || 'Cancel';
|
||||
this.variant = options.variant || 'danger';
|
||||
this._resolve = resolve;
|
||||
this.open = true;
|
||||
});
|
||||
},
|
||||
|
||||
answer(confirmed) {
|
||||
if (this._resolve) {
|
||||
this._resolve(confirmed);
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
this._resolve = null;
|
||||
},
|
||||
});
|
||||
|
||||
window.ladillConfirm = (options = {}) => Alpine.store('ladillConfirm').ask(options);
|
||||
}
|
||||
|
||||
export function registerLadillModalHelpers() {
|
||||
window.openLadillModal = openLadillModal;
|
||||
window.closeLadillModal = closeLadillModal;
|
||||
window.ladillNeedsTopup = ladillNeedsTopup;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@props(['title' => 'Ladill Hosting'])
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-slate-100">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? 'Ladill Hosting' }}</title>
|
||||
@include('partials.favicon')
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }">
|
||||
<div class="min-h-screen max-lg:min-h-dvh">
|
||||
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
|
||||
<aside x-cloak class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||||
@include('partials.sidebar')
|
||||
</aside>
|
||||
<div class="flex min-h-screen flex-col min-w-0 lg:pl-64">
|
||||
@include('partials.topbar')
|
||||
<main class="flex-1 p-6 pb-24 lg:p-6 lg:pb-6">
|
||||
@include('partials.flash')
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@auth
|
||||
@include('partials.afia')
|
||||
@endauth
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
@props([
|
||||
'href' => null,
|
||||
'type' => 'button',
|
||||
])
|
||||
|
||||
@php
|
||||
$tag = $href ? 'a' : 'button';
|
||||
@endphp
|
||||
|
||||
<{{ $tag }}
|
||||
@if ($href) href="{{ $href }}" @endif
|
||||
@if ($tag === 'button') type="{{ $type }}" @endif
|
||||
{{ $attributes->class(['btn-primary']) }}
|
||||
>
|
||||
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
{{ $slot }}
|
||||
</{{ $tag }}>
|
||||
@@ -0,0 +1,16 @@
|
||||
@props([
|
||||
'href' => null,
|
||||
'type' => 'button',
|
||||
])
|
||||
|
||||
@php
|
||||
$tag = $href ? 'a' : 'button';
|
||||
@endphp
|
||||
|
||||
<{{ $tag }}
|
||||
@if ($href) href="{{ $href }}" @endif
|
||||
@if ($tag === 'button') type="{{ $type }}" @endif
|
||||
{{ $attributes->class(['btn-primary']) }}
|
||||
>
|
||||
{{ $slot }}
|
||||
</{{ $tag }}>
|
||||
@@ -0,0 +1,69 @@
|
||||
@props([
|
||||
'name',
|
||||
'title',
|
||||
'message' => null,
|
||||
'action',
|
||||
'method' => 'POST',
|
||||
'confirmLabel' => 'Confirm',
|
||||
'cancelLabel' => 'Cancel',
|
||||
'variant' => 'danger',
|
||||
])
|
||||
|
||||
@php
|
||||
$confirmBtnClass = $variant === 'danger'
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-violet-600 hover:bg-violet-700';
|
||||
$iconWrapClass = $variant === 'danger'
|
||||
? 'bg-red-100 text-red-600'
|
||||
: 'bg-violet-100 text-violet-600';
|
||||
@endphp
|
||||
|
||||
@if(isset($trigger))
|
||||
<span @click.stop="$dispatch('open-modal', {{ Js::from($name) }})" class="inline-flex">
|
||||
{{ $trigger }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
<x-modal :name="$name" maxWidth="md">
|
||||
<div class="px-5 pb-6 pt-2 sm:px-6 sm:pb-6 sm:pt-4">
|
||||
<div class="flex flex-col items-center text-center sm:items-start sm:text-left">
|
||||
<div @class(['flex h-12 w-12 items-center justify-center rounded-full', $iconWrapClass])>
|
||||
@if($variant === 'danger')
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/>
|
||||
</svg>
|
||||
@else
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"/>
|
||||
</svg>
|
||||
@endif
|
||||
</div>
|
||||
<h2 class="mt-4 text-lg font-semibold text-slate-900">{{ $title }}</h2>
|
||||
@if($message)
|
||||
<p class="mt-2 text-sm leading-relaxed text-slate-500">{{ $message }}</p>
|
||||
@endif
|
||||
@isset($details)
|
||||
<div class="mt-3 w-full">{{ $details }}</div>
|
||||
@endisset
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ $action }}" class="mt-6 flex flex-col-reverse gap-2.5 sm:flex-row sm:justify-end">
|
||||
@csrf
|
||||
@if(strtoupper($method) !== 'POST')
|
||||
@method($method)
|
||||
@endif
|
||||
@isset($fields)
|
||||
{{ $fields }}
|
||||
@endisset
|
||||
<button type="button"
|
||||
@click="$dispatch('close-modal', {{ Js::from($name) }})"
|
||||
class="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
|
||||
{{ $cancelLabel }}
|
||||
</button>
|
||||
<button type="submit"
|
||||
@class(['rounded-xl px-4 py-2.5 text-sm font-semibold text-white transition', $confirmBtnClass])>
|
||||
{{ $confirmLabel }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-modal>
|
||||
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
@include('partials.favicon')
|
||||
<title>{{ $title ?? 'Hosting Panel' }} - Ladill</title>
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
@php
|
||||
$canViewAccountDetails = auth()->check() && auth()->user()->can('viewAccount', $account);
|
||||
$exitUrl = $canViewAccountDetails
|
||||
? route('hosting.accounts.show', $account)
|
||||
: route('hosting.single-domain');
|
||||
@endphp
|
||||
<body class="bg-slate-100 font-sans antialiased" x-data="{ sidebarOpen: false }">
|
||||
<div class="min-h-screen flex">
|
||||
{{-- Mobile sidebar overlay --}}
|
||||
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
|
||||
|
||||
{{-- Sidebar --}}
|
||||
<aside class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0 bg-white border-r border-slate-200"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||||
@include('hosting.panel.partials.sidebar')
|
||||
</aside>
|
||||
|
||||
{{-- Main content --}}
|
||||
<div class="flex-1 flex flex-col min-w-0 lg:pl-64">
|
||||
{{-- Top bar --}}
|
||||
<header class="sticky top-0 z-20 flex h-14 items-center gap-4 border-b border-slate-200 bg-white/95 backdrop-blur px-4 lg:px-6">
|
||||
<button type="button" @click="sidebarOpen = true" class="lg:hidden -ml-2 p-2 text-slate-500 hover:text-slate-700">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/></svg>
|
||||
</button>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-sm font-semibold text-slate-800 truncate">{{ $header ?? 'Hosting Panel' }}</h1>
|
||||
@unless ($canViewAccountDetails)
|
||||
<p class="text-xs text-slate-500">Developer access</p>
|
||||
@endunless
|
||||
</div>
|
||||
<a href="{{ $exitUrl }}" class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
Exit Panel
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{{-- Flash messages --}}
|
||||
@include('partials.flash')
|
||||
|
||||
{{-- Expired Account Banner --}}
|
||||
@if ($account->isExpired() && $account->isInGracePeriod())
|
||||
<div class="mx-4 mt-4 lg:mx-6 lg:mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<svg class="h-4.5 w-4.5 shrink-0 text-red-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/></svg>
|
||||
<p class="text-sm text-red-800"><span class="font-semibold">Account expired.</span> Your site is offline. Files are preserved until {{ $account->gracePeriodEndsAt()->format('M d, Y') }}.
|
||||
<a href="{{ route('hosting.accounts.show', $account) }}" class="underline font-medium hover:text-red-900">Renew now</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Page content --}}
|
||||
<main class="flex-1 p-4 lg:p-6">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
@php
|
||||
$class = $class ?? 'h-5 w-5';
|
||||
@endphp
|
||||
{!! \App\Support\DomainGlobeIcon::svg($class) !!}
|
||||
@@ -0,0 +1,6 @@
|
||||
@php
|
||||
$class = $class ?? 'h-5 w-5';
|
||||
@endphp
|
||||
<svg class="{{ $class }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m21 0h-7c-1.657 0-3 1.343-3 3v3h13v-3c0-1.657-1.343-3-3-3zm-6.5 4.5c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5zm6.5-.5h-2c-.552 0-1-.448-1-1s.448-1 1-1h2c.552 0 1 .448 1 1s-.448 1-1 1zm-10 6c0 1.657 1.343 3 3 3h7c1.657 0 3-1.343 3-3v-3h-13zm8-1h2c.552 0 1 .448 1 1s-.448 1-1 1h-2c-.552 0-1-.448-1-1s.448-1 1-1zm-4.5-.5c.828 0 1.5.672 1.5 1.5s-.672 1.5-1.5 1.5-1.5-.672-1.5-1.5.672-1.5 1.5-1.5zm-5.5-6.5c0 .552-.447 1-1 1h-1c-1.103 0-2 .897-2 2v2.029l1.307-1.25c.399-.383 1.031-.37 1.414.028s.37 1.031-.027 1.414l-2.301 2.212c-.38.378-.883.569-1.388.569-.51 0-1.021-.193-1.41-.582l-2.288-2.199c-.397-.383-.41-1.016-.027-1.414s1.015-.411 1.414-.028l1.307 1.256v-2.035c-.001-2.206 1.793-4 3.999-4h1c.553 0 1 .448 1 1zm12 14v1c0 2.206-1.794 4-4 4h-2.029l1.25 1.307c.383.398.37 1.031-.027 1.414-.398.382-1.031.371-1.414-.028l-2.212-2.301c-.761-.761-.761-2.023.013-2.798l2.199-2.287c.382-.398 1.015-.412 1.414-.028.397.383.41 1.016.027 1.414l-1.257 1.307h2.036c1.103 0 2-.897 2-2v-1c0-.552.447-1 1-1s1 .448 1 1zm-12-1.5v2.421c-.221.268-1.456 1.079-4.5 1.079s-4.279-.811-4.5-1.079v-2.421c0-.883 1.85-1.5 4.5-1.5s4.5.617 4.5 1.5zm-4.5 5.5c1.93 0 3.421-.311 4.5-.777v2.61c0 1.377-1.641 2.167-4.5 2.167s-4.5-.79-4.5-2.167v-2.61c1.079.466 2.57.777 4.5.777z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,5 @@
|
||||
@props(['class' => 'h-5 w-5'])
|
||||
|
||||
<svg {{ $attributes->merge(['class' => $class]) }} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="m4,11h7v-7h-7v7Zm2-5h3v3h-3v-3Zm14-2h-7v7h7v-7Zm-2,5h-3v-3h3v3Zm-14,11h7v-7h-7v7Zm2-5h3v3h-3v-3Zm-3,7h4v2H3c-1.654,0-3-1.346-3-3v-4h2v4c0,.551.449,1,1,1Zm19-5h2v4c0,1.654-1.346,3-3,3h-4v-2h4c.551,0,1-.449,1-1v-4Zm2-14v4h-2V3c0-.551-.449-1-1-1h-4V0h4c1.654,0,3,1.346,3,3ZM2,7H0V3C0,1.346,1.346,0,3,0h4v2H3c-.551,0-1,.449-1,1v4Zm11,10h3v3h-3v-3Zm4-1v-3h3v3h-3Zm-4-3h3v3h-3v-3Z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,7 @@
|
||||
@php
|
||||
$class = $class ?? 'h-5 w-5';
|
||||
$strokeWidth = $strokeWidth ?? '1.5';
|
||||
@endphp
|
||||
<svg class="{{ $class }}" fill="none" stroke="currentColor" stroke-width="{{ $strokeWidth }}" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,6 @@
|
||||
@php
|
||||
$class = $class ?? 'h-5 w-5';
|
||||
@endphp
|
||||
<svg class="{{ $class }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m24 12c0 6.617-5.383 12-12 12-3.476 0-6.744-1.542-9-4.104v2.104c0 .552-.447 1-1 1s-1-.448-1-1v-4c0-1.103.897-2 2-2h4c.553 0 1 .448 1 1s-.447 1-1 1h-2.991c1.877 2.49 4.837 4 7.991 4 5.514 0 10-4.486 10-10 0-.552.447-1 1-1s1 .448 1 1zm-22 0c0-5.514 4.486-10 10-10 3.154 0 6.115 1.51 7.991 4h-2.991c-.553 0-1 .448-1 1s.447 1 1 1h4c1.103 0 2-.897 2-2v-4c0-.552-.447-1-1-1s-1 .448-1 1v2.104c-2.256-2.562-5.524-4.104-9-4.104-6.617 0-12 5.383-12 12 0 .552.447 1 1 1s1-.448 1-1zm13.5-3.5c1.93 0 3.5 1.57 3.5 3.5s-1.57 3.5-3.5 3.5c-1.386 0-2.685-1.085-3.5-1.949-.815.864-2.114 1.949-3.5 1.949-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5c1.386 0 2.685 1.085 3.5 1.949.815-.864 2.114-1.949 3.5-1.949zm-4.795 3.5c-.67-.736-1.591-1.5-2.205-1.5-.827 0-1.5.673-1.5 1.5s.673 1.5 1.5 1.5c.614 0 1.535-.764 2.205-1.5zm4.795-1.5c-.614 0-1.535.764-2.205 1.5.67.736 1.591 1.5 2.205 1.5.827 0 1.5-.673 1.5-1.5s-.673-1.5-1.5-1.5z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,9 @@
|
||||
@props(['messages'])
|
||||
|
||||
@if ($messages)
|
||||
<ul {{ $attributes->merge(['class' => 'text-sm text-red-600 space-y-1']) }}>
|
||||
@foreach ((array) $messages as $message)
|
||||
<li>{{ $message }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
@@ -0,0 +1,32 @@
|
||||
@props([
|
||||
'title',
|
||||
'subtitle' => null,
|
||||
'backUrl' => null,
|
||||
'badge' => null,
|
||||
'hideAfia' => false,
|
||||
])
|
||||
|
||||
<div class="sticky top-0 z-20 border-b border-slate-200 bg-white/95 backdrop-blur lg:hidden">
|
||||
<div class="flex items-center gap-3 px-4 py-3">
|
||||
@if ($backUrl)
|
||||
<a href="{{ $backUrl }}"
|
||||
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 text-slate-600 transition hover:bg-slate-50">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"/></svg>
|
||||
</a>
|
||||
@endif
|
||||
<div class="min-w-0 flex-1">
|
||||
@if ($subtitle)
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{{ $subtitle }}</p>
|
||||
@endif
|
||||
<h1 class="truncate text-base font-semibold text-slate-900">{{ $title }}</h1>
|
||||
</div>
|
||||
@if ($badge)
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600">{{ $badge }}</span>
|
||||
@endif
|
||||
@unless ($hideAfia)
|
||||
@include('partials.afia-button', ['compact' => true])
|
||||
@endunless
|
||||
{{ $actions ?? '' }}
|
||||
</div>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
@@ -0,0 +1,88 @@
|
||||
@props([
|
||||
'name',
|
||||
'show' => false,
|
||||
'maxWidth' => '2xl'
|
||||
])
|
||||
|
||||
@php
|
||||
$maxWidth = [
|
||||
'sm' => 'sm:max-w-sm',
|
||||
'md' => 'sm:max-w-md',
|
||||
'lg' => 'sm:max-w-lg',
|
||||
'xl' => 'sm:max-w-xl',
|
||||
'2xl' => 'sm:max-w-2xl',
|
||||
][$maxWidth];
|
||||
@endphp
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
show: @js($show),
|
||||
focusables() {
|
||||
let selector = 'a, button, input:not([type=\'hidden\']), textarea, select, details, [tabindex]:not([tabindex=\'-1\'])'
|
||||
return [...$el.querySelectorAll(selector)]
|
||||
.filter(el => ! el.hasAttribute('disabled'))
|
||||
},
|
||||
firstFocusable() { return this.focusables()[0] },
|
||||
lastFocusable() { return this.focusables().slice(-1)[0] },
|
||||
nextFocusable() { return this.focusables()[this.nextFocusableIndex()] || this.firstFocusable() },
|
||||
prevFocusable() { return this.focusables()[this.prevFocusableIndex()] || this.lastFocusable() },
|
||||
nextFocusableIndex() { return (this.focusables().indexOf(document.activeElement) + 1) % (this.focusables().length + 1) },
|
||||
prevFocusableIndex() { return Math.max(0, this.focusables().indexOf(document.activeElement)) -1 },
|
||||
}"
|
||||
x-init="$watch('show', value => {
|
||||
if (value) {
|
||||
document.body.classList.add('overflow-y-hidden');
|
||||
{{ $attributes->has('focusable') ? 'setTimeout(() => firstFocusable().focus(), 100)' : '' }}
|
||||
} else {
|
||||
setTimeout(() => { if (!show) document.body.classList.remove('overflow-y-hidden'); }, 200);
|
||||
}
|
||||
})"
|
||||
x-on:open-modal.window="String($event.detail) === @js($name) ? show = true : null"
|
||||
data-modal-name="{{ $name }}"
|
||||
x-on:close-modal.window="String($event.detail) === @js($name) ? show = false : null"
|
||||
x-on:close.stop="show = false"
|
||||
x-on:keydown.escape.window="show = false"
|
||||
x-on:keydown.tab.prevent="$event.shiftKey || nextFocusable().focus()"
|
||||
x-on:keydown.shift.tab.prevent="prevFocusable().focus()"
|
||||
x-show="show"
|
||||
x-cloak
|
||||
x-transition:enter="transition-opacity ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition-opacity ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-50 flex items-end sm:items-center sm:justify-center"
|
||||
style="background: rgba(0,0,0,0.45); backdrop-filter: blur(3px);"
|
||||
>
|
||||
{{-- Backdrop click to close --}}
|
||||
<div class="absolute inset-0" x-on:click="show = false" data-close-modal="{{ $name }}"></div>
|
||||
|
||||
{{-- Panel: bottom-sheet on mobile, centered card on sm+ --}}
|
||||
<div
|
||||
x-show="show"
|
||||
class="relative z-10 w-full max-h-[90vh] overflow-y-auto rounded-t-3xl bg-white shadow-2xl sm:mb-6 sm:max-h-[85vh] sm:rounded-2xl {{ $maxWidth }} sm:border sm:border-gray-200"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="translate-y-full sm:translate-y-0 sm:opacity-0 sm:scale-95"
|
||||
x-transition:enter-end="translate-y-0 sm:opacity-100 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="translate-y-0 sm:opacity-100 sm:scale-100"
|
||||
x-transition:leave-end="translate-y-full sm:translate-y-0 sm:opacity-0 sm:scale-95"
|
||||
x-on:click.stop
|
||||
>
|
||||
{{-- Drag handle (mobile only) --}}
|
||||
<div class="flex justify-center pb-1 pt-3 sm:hidden">
|
||||
<div class="h-1 w-10 rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
|
||||
{{-- Close button (desktop only) --}}
|
||||
<button @click="show = false" data-close-modal="{{ $name }}" type="button"
|
||||
class="absolute right-3 top-3 z-10 hidden h-8 w-8 items-center justify-center rounded-full text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 sm:flex">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
@props(['variant' => 'banner'])
|
||||
|
||||
<p {{ $attributes->merge(['class' => 'mt-1 text-[10px] leading-snug text-slate-400']) }}>
|
||||
Recommended: {{ \App\Support\Qr\QrCoverImageSpec::label($variant) }}. JPG, PNG, or WebP.
|
||||
</p>
|
||||
@@ -0,0 +1,30 @@
|
||||
@props([
|
||||
'id',
|
||||
'title',
|
||||
'description',
|
||||
])
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<button type="button"
|
||||
@click="toggleSection('{{ $id }}')"
|
||||
class="flex w-full items-start gap-4 px-5 py-4 text-left transition hover:bg-slate-50/80">
|
||||
<span class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
|
||||
{!! $icon ?? '' !!}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-sm font-semibold text-slate-900">{{ $title }}</span>
|
||||
<span class="mt-0.5 block text-xs leading-relaxed text-slate-500">{{ $description }}</span>
|
||||
</span>
|
||||
<svg class="mt-1 h-5 w-5 shrink-0 text-slate-400 transition"
|
||||
:class="openSection === '{{ $id }}' ? 'rotate-180' : ''"
|
||||
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div x-show="openSection === '{{ $id }}'" x-cloak class="border-t border-slate-100">
|
||||
<div class="px-5 py-5">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
@props(['title' => 'Ladill Hosting'])
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-slate-100">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? 'Ladill Hosting' }}</title>
|
||||
@include('partials.favicon')
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }">
|
||||
<div class="min-h-screen max-lg:min-h-dvh">
|
||||
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
|
||||
<aside x-cloak class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||||
@include('partials.sidebar')
|
||||
</aside>
|
||||
<div class="flex min-h-screen flex-col min-w-0 lg:pl-64">
|
||||
@include('partials.topbar')
|
||||
<main class="flex-1 p-6 pb-24 lg:p-6 lg:pb-6">
|
||||
@include('partials.flash')
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@auth
|
||||
@include('partials.afia')
|
||||
@endauth
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,124 @@
|
||||
@props([
|
||||
'id',
|
||||
'title' => 'Add credits',
|
||||
'description' => '',
|
||||
'topupAction',
|
||||
'minTopup' => 5,
|
||||
'suggestedAmount' => 10,
|
||||
'ladillWalletBalance' => 0,
|
||||
'serviceBalance' => null,
|
||||
'serviceBalanceLabel' => 'Current balance',
|
||||
'openOnLoad' => false,
|
||||
'returnUrl' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
// Single-wallet (siloing step 2): there is one Ladill wallet, so "pay from
|
||||
// wallet into service credits" is a meaningless round-trip — top up the one
|
||||
// wallet directly (Paystack). Transfer option removed.
|
||||
$canPayFromWallet = false;
|
||||
@endphp
|
||||
|
||||
<x-modal :name="$id" :show="$openOnLoad" maxWidth="md">
|
||||
<div class="flex min-h-full flex-col sm:min-h-0">
|
||||
<div class="border-b border-slate-100 px-5 py-4 pr-12 sm:px-6 sm:pr-14">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ $title }}</h2>
|
||||
@if($description)
|
||||
<p class="mt-1 text-sm text-slate-500">{{ $description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<form action="{{ $topupAction }}" method="POST" class="flex flex-1 flex-col p-5 sm:p-6"
|
||||
x-data="{
|
||||
method: 'paystack',
|
||||
showSheet: false,
|
||||
checkoutUrl: '',
|
||||
loading: false,
|
||||
errorMsg: '',
|
||||
async submitTopup(event) {
|
||||
if (this.method !== 'paystack' || window.innerWidth >= 768) return;
|
||||
|
||||
event.preventDefault();
|
||||
this.loading = true;
|
||||
this.errorMsg = '';
|
||||
|
||||
try {
|
||||
const res = await fetch(event.target.action, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
body: new FormData(event.target),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error || !data.checkout_url) {
|
||||
this.errorMsg = data.error || 'Unable to start payment. Please try again.';
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.checkoutUrl = data.checkout_url;
|
||||
this.showSheet = true;
|
||||
this.loading = false;
|
||||
} catch (error) {
|
||||
this.errorMsg = 'Network error. Please try again.';
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
}"
|
||||
@submit="submitTopup($event)">
|
||||
@csrf
|
||||
@if($returnUrl)
|
||||
<input type="hidden" name="return_url" value="{{ $returnUrl }}">
|
||||
@endif
|
||||
|
||||
@if($serviceBalance !== null)
|
||||
<div class="mb-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-center">
|
||||
<p class="text-xl font-bold text-slate-900">GHS {{ number_format((float) $serviceBalance, 2) }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $serviceBalanceLabel }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div x-show="errorMsg" x-cloak class="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700" x-text="errorMsg"></div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Amount (GHS)</label>
|
||||
<input type="number" name="amount" min="{{ $minTopup }}" max="5000" step="0.01"
|
||||
value="{{ $suggestedAmount }}"
|
||||
class="mt-1.5 block w-full rounded-xl border border-slate-200 px-3 py-2.5 text-sm focus:border-indigo-400 focus:ring-1 focus:ring-indigo-400"
|
||||
required>
|
||||
<p class="mt-1.5 text-xs text-slate-500">Minimum GHS {{ number_format($minTopup, 2) }}</p>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="payment_method" value="paystack" @if($canPayFromWallet) x-model="method" @endif>
|
||||
|
||||
@if($canPayFromWallet)
|
||||
<div class="mt-4">
|
||||
<p class="mb-2 text-xs font-medium text-slate-600">Payment method</p>
|
||||
<div class="flex rounded-xl border border-slate-200 bg-white p-0.5 text-sm">
|
||||
<button type="button" @click="method = 'paystack'"
|
||||
:class="method === 'paystack' ? 'bg-indigo-600 text-white' : 'text-slate-600'"
|
||||
class="flex-1 rounded-lg py-2 font-medium transition">Paystack</button>
|
||||
<button type="button" @click="method = 'wallet'"
|
||||
:class="method === 'wallet' ? 'bg-indigo-600 text-white' : 'text-slate-600'"
|
||||
class="flex-1 rounded-lg py-2 font-medium transition">Wallet (GHS {{ number_format($ladillWalletBalance, 2) }})</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<p class="mt-4 text-xs text-slate-500">Pay with Paystack.</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 flex flex-col gap-2 sm:mt-auto sm:flex-row sm:justify-end">
|
||||
<button type="button" @click="$dispatch('close-modal', '{{ $id }}')"
|
||||
data-close-modal="{{ $id }}"
|
||||
class="hidden rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 sm:inline-flex">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="loading"
|
||||
class="btn-primary w-full sm:w-auto">
|
||||
<span x-text="loading ? 'Processing…' : 'Add credits'">Add credits</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@include('partials.paystack-sheet')
|
||||
</form>
|
||||
</div>
|
||||
</x-modal>
|
||||
@@ -0,0 +1,52 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Billing — Ladill Email')
|
||||
@section('content')
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Billing</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Your Ladill wallet funds mailboxes across every Ladill app.</p>
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-2xl bg-gradient-to-br from-indigo-700 via-indigo-600 to-blue-500 p-5 text-white shadow-sm sm:col-span-1">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-indigo-100">Wallet balance</p>
|
||||
<p class="mt-1 text-2xl font-semibold">{{ $fmt($balanceMinor) }}</p>
|
||||
<a href="{{ $topupUrl }}" class="mt-3 inline-block rounded-lg bg-white/15 px-3.5 py-1.5 text-xs font-semibold text-white backdrop-blur transition hover:bg-white/25">Add funds</a>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Spent on email</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($spentMinor) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Refunded / credited</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($creditedMinor) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Mailbox subscriptions</h2>
|
||||
<span class="text-xs text-slate-400">priced by storage plan</span>
|
||||
</div>
|
||||
@if(empty($paidMailboxes))
|
||||
<p class="px-5 py-8 text-center text-sm text-slate-400">No paid mailboxes yet — your free allowance covers you.</p>
|
||||
@else
|
||||
<ul class="divide-y divide-slate-50">
|
||||
@foreach($paidMailboxes as $m)
|
||||
<li class="flex items-center justify-between px-5 py-3.5">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $m['address'] ?? '—' }}</p>
|
||||
<p class="text-xs text-slate-400">{{ $m['quota_label'] }} · {{ ucfirst($m['status'] ?? 'active') }}</p>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-slate-700">{{ $fmt($m['price_minor']) }}<span class="text-xs text-slate-400">/mo</span></span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<div class="flex items-center justify-between border-t border-slate-100 px-5 py-3">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-slate-400">Monthly total</span>
|
||||
<span class="text-sm font-semibold text-slate-900">{{ $fmt($monthlyTotalMinor) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-slate-400">Mailboxes beyond your free allowance renew monthly from your wallet. Keep it funded to avoid interruption.</p>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,78 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Developers — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Developers</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">API tokens to manage your mailboxes programmatically.</p>
|
||||
|
||||
@if($newToken)
|
||||
<div class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-5">
|
||||
<p class="text-sm font-semibold text-emerald-900">Your new token — copy it now</p>
|
||||
<p class="mt-1 text-xs text-emerald-700">This is the only time it will be shown.</p>
|
||||
<div class="mt-3 flex items-center gap-2" x-data="{ copied: false }">
|
||||
<code class="flex-1 truncate rounded-lg bg-white px-3 py-2 font-mono text-xs text-slate-800 ring-1 ring-emerald-200">{{ $newToken }}</code>
|
||||
<button @click="navigator.clipboard.writeText('{{ $newToken }}'); copied = true; setTimeout(() => copied = false, 1500)"
|
||||
class="rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700">
|
||||
<span x-show="!copied">Copy</span><span x-show="copied" x-cloak>Copied ✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Create --}}
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Create a token</h2>
|
||||
<form method="POST" action="{{ route('account.developers.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
@csrf
|
||||
<div class="flex-1">
|
||||
<label class="block text-[11px] font-medium text-slate-500">Token name</label>
|
||||
<input type="text" name="name" required placeholder="e.g. CI server"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@error('name')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<button class="btn-primary">Generate token</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Tokens --}}
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Your tokens</h2></div>
|
||||
@forelse($tokens as $token)
|
||||
<div class="flex items-center justify-between px-5 py-3.5 {{ ! $loop->last ? 'border-b border-slate-50' : '' }}">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $token->name }}</p>
|
||||
<p class="text-xs text-slate-400">
|
||||
Created {{ $token->created_at->diffForHumans() }} ·
|
||||
{{ $token->last_used_at ? 'last used '.$token->last_used_at->diffForHumans() : 'never used' }}
|
||||
</p>
|
||||
</div>
|
||||
<x-confirm-dialog
|
||||
:name="'revoke-token-'.$token->id"
|
||||
title="Revoke API token?"
|
||||
:message="'Revoke '.$token->name.'? Apps using this token will stop working.'"
|
||||
:action="route('account.developers.destroy', $token->id)"
|
||||
method="DELETE"
|
||||
confirm-label="Revoke"
|
||||
>
|
||||
<x-slot:trigger>
|
||||
<button type="button" class="text-xs font-medium text-rose-600 hover:underline">Revoke</button>
|
||||
</x-slot:trigger>
|
||||
</x-confirm-dialog>
|
||||
</div>
|
||||
@empty
|
||||
<p class="px-5 py-8 text-center text-sm text-slate-400">No tokens yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- Docs --}}
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-900 p-5 text-slate-200">
|
||||
<h2 class="text-sm font-semibold text-white">Quick start</h2>
|
||||
<p class="mt-1 text-xs text-slate-400">Authenticate with a Bearer token. Base URL:</p>
|
||||
<code class="mt-2 block rounded-lg bg-black/40 px-3 py-2 font-mono text-[11px] text-emerald-300">{{ $apiBase }}</code>
|
||||
<pre class="mt-3 overflow-x-auto rounded-lg bg-black/40 px-3 py-3 font-mono text-[11px] leading-relaxed text-slate-300"><code>curl {{ $apiBase }}/mailboxes \
|
||||
-H "Authorization: Bearer <your-token>" \
|
||||
-H "Accept: application/json"</code></pre>
|
||||
<p class="mt-3 text-[11px] text-slate-400">Endpoints: <span class="font-mono text-slate-300">GET /me</span>, <span class="font-mono text-slate-300">GET /mailboxes</span>. More coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,110 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Settings — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Settings</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Defaults, preferences, and account mailbox linking.</p>
|
||||
|
||||
@if($showMailboxLinkUi ?? (($linkStatus['linked_mailbox'] ?? null) || ($linkStatus['show_reminder'] ?? false)))
|
||||
<div class="mt-6 rounded-2xl border border-indigo-200 bg-white p-5 shadow-sm">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-indigo-50">
|
||||
@include('partials.ladill-pro-icon')
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Link to mailbox</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-slate-500">
|
||||
Your Ladill account uses <strong class="font-medium text-slate-700">{{ $linkStatus['account_email'] ?? $account->email }}</strong>.
|
||||
Link a mailbox so <strong class="font-medium text-slate-700">Ladill Mail</strong> opens with your Ladill sign-in.
|
||||
</p>
|
||||
|
||||
@if($linkStatus['linked_mailbox'] ?? null)
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-sm text-emerald-800">
|
||||
Linked mailbox: <span class="font-semibold">{{ $linkStatus['linked_mailbox'] }}</span>
|
||||
</div>
|
||||
<x-confirm-dialog
|
||||
name="unlink-mailbox"
|
||||
title="Unlink mailbox?"
|
||||
message="Unlink this mailbox from your Ladill account? Ladill Mail will no longer open automatically with Ladill sign-in."
|
||||
:action="route('account.settings.mailbox-unlink')"
|
||||
method="DELETE"
|
||||
confirm-label="Unlink"
|
||||
variant="primary"
|
||||
class="mt-3"
|
||||
>
|
||||
<x-slot:trigger>
|
||||
<button type="button" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-50">
|
||||
Unlink mailbox
|
||||
</button>
|
||||
</x-slot:trigger>
|
||||
</x-confirm-dialog>
|
||||
@elseif(($linkStatus['stage'] ?? '') === 'needs_domain')
|
||||
<p class="mt-3 text-sm text-amber-800">Add and verify an email domain first.</p>
|
||||
<a href="{{ route('email.domains.index') }}" class="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-indigo-700 hover:text-indigo-800">
|
||||
Go to domains
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/></svg>
|
||||
</a>
|
||||
@elseif(($linkStatus['stage'] ?? '') === 'needs_mailbox')
|
||||
<p class="mt-3 text-sm text-amber-800">Create a mailbox on your verified domain first.</p>
|
||||
<a href="{{ route('email.mailboxes.create') }}" class="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-indigo-700 hover:text-indigo-800">
|
||||
Create mailbox
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/></svg>
|
||||
</a>
|
||||
@elseif(count($mailboxOptions) > 0)
|
||||
<form method="POST" action="{{ route('account.settings.mailbox-link') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
@csrf @method('PUT')
|
||||
<div class="min-w-0 flex-1">
|
||||
<label for="mailbox_address" class="block text-[11px] font-medium text-slate-500">Choose mailbox</label>
|
||||
<select id="mailbox_address" name="mailbox_address" required class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
<option value="" disabled @selected(! old('mailbox_address'))>Select a mailbox</option>
|
||||
@foreach($mailboxOptions as $mailbox)
|
||||
<option value="{{ $mailbox['address'] }}" @selected(old('mailbox_address') === $mailbox['address'])>{{ $mailbox['address'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('mailbox_address')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<button class="btn-primary">Link mailbox</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('account.settings.update') }}" class="mt-6 space-y-4">
|
||||
@csrf @method('PUT')
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Mailbox defaults</h2>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Default mailbox quota</label>
|
||||
<select name="default_quota_mb" class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
@php $cur = (int) ($settings->default_quota_mb ?? config('email.default_quota_mb', 10240)); @endphp
|
||||
@foreach([1024 => '1 GB', 5120 => '5 GB', 10240 => '10 GB', 25600 => '25 GB', 51200 => '50 GB'] as $mb => $label)
|
||||
<option value="{{ $mb }}" @selected($cur === $mb)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Notifications email</label>
|
||||
<input type="email" name="notify_email" value="{{ old('notify_email', $settings->notify_email ?? $account->email) }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<label class="flex items-center justify-between">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">Product updates</span>
|
||||
<span class="block text-xs text-slate-400">Occasional emails about new Ladill Email features.</span>
|
||||
</span>
|
||||
<input type="checkbox" name="product_updates" value="1" @checked($settings->product_updates ?? true) class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary">Save settings</button>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,90 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Team — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Team</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Invite people to help manage this account’s mailboxes & domains.</p>
|
||||
|
||||
@if($canManage)
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Invite a teammate</h2>
|
||||
<form method="POST" action="{{ route('account.team.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
@csrf
|
||||
<div class="flex-1">
|
||||
<label class="block text-[11px] font-medium text-slate-500">Email</label>
|
||||
<input type="email" name="email" required placeholder="teammate@example.com"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@error('email')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Role</label>
|
||||
<select name="role" class="mt-1 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary">Send invite</button>
|
||||
</form>
|
||||
<p class="mt-2 text-[11px] text-slate-400">Admins can manage mailboxes and the team. Members can manage mailboxes. Invitees join by signing in with that email.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Members</h2></div>
|
||||
<ul class="divide-y divide-slate-50">
|
||||
<li class="flex items-center justify-between px-5 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{{ strtoupper(substr($account->name ?? $account->email, 0, 1)) }}</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $account->name ?? $account->email }} <span class="text-xs font-normal text-slate-400">(you)</span></p>
|
||||
<p class="text-xs text-slate-400">{{ $account->email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-indigo-50 px-2.5 py-1 text-[11px] font-medium text-indigo-700">Owner</span>
|
||||
</li>
|
||||
|
||||
@forelse($members as $member)
|
||||
<li class="flex items-center justify-between px-5 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">{{ strtoupper(substr($member->email, 0, 1)) }}</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $member->member->name ?? $member->email }}</p>
|
||||
<p class="text-xs text-slate-400">{{ $member->email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if($member->status === 'invited')
|
||||
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">Invited</span>
|
||||
@endif
|
||||
@if($canManage)
|
||||
<form method="POST" action="{{ route('account.team.role', $member) }}">
|
||||
@csrf @method('PATCH')
|
||||
<select name="role" onchange="this.form.submit()" class="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs focus:outline-none">
|
||||
<option value="member" @selected($member->role === 'member')>Member</option>
|
||||
<option value="admin" @selected($member->role === 'admin')>Admin</option>
|
||||
</select>
|
||||
</form>
|
||||
<x-confirm-dialog
|
||||
:name="'remove-member-'.$member->id"
|
||||
title="Remove team member?"
|
||||
:message="'Remove '.$member->email.' from this team?'"
|
||||
:action="route('account.team.destroy', $member)"
|
||||
method="DELETE"
|
||||
confirm-label="Remove"
|
||||
>
|
||||
<x-slot:trigger>
|
||||
<button type="button" class="text-xs font-medium text-rose-600 hover:underline">Remove</button>
|
||||
</x-slot:trigger>
|
||||
</x-confirm-dialog>
|
||||
@else
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium capitalize text-slate-600">{{ $member->role }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-5 py-8 text-center text-sm text-slate-400">No teammates yet.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,27 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Wallet — Ladill Email')
|
||||
@section('content')
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Wallet</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Your Ladill wallet funds mailboxes across every Ladill app.</p>
|
||||
|
||||
<div class="mt-6 rounded-2xl bg-gradient-to-br from-indigo-700 via-indigo-600 to-blue-500 p-6 text-white shadow-sm">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-indigo-100">Balance</p>
|
||||
<p class="mt-1 text-3xl font-semibold">{{ $fmt($balanceMinor) }}</p>
|
||||
<a href="{{ $topupUrl }}" class="mt-4 inline-block rounded-lg bg-white/15 px-4 py-2 text-sm font-semibold text-white backdrop-blur transition hover:bg-white/25">Add funds</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Spent on email</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($spentMinor) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Refunded / credited</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($creditedMinor) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-slate-400">Mailboxes beyond your free allowance are billed monthly from this wallet.</p>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,47 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Overview — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Overview</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Your mailboxes at a glance.</p>
|
||||
</div>
|
||||
<x-btn.create :href="route('email.mailboxes.create')">New mailbox</x-btn.create>
|
||||
</div>
|
||||
|
||||
@if($error)<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $error }}</div>@endif
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-3">
|
||||
@foreach([['Mailboxes', $mailboxCount, route('email.mailboxes.index')], ['Domains', $domainCount, route('email.domains.index')], ['Verified domains', $verifiedDomainCount, route('email.domains.index')]] as [$label, $value, $link])
|
||||
<a href="{{ $link }}" class="rounded-2xl border border-slate-200 bg-white p-5 transition hover:border-indigo-200">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">{{ $label }}</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-slate-900">{{ $value }}</p>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Recent mailboxes</h2>
|
||||
<a href="{{ route('email.mailboxes.index') }}" class="text-xs font-medium text-indigo-600 hover:underline">View all</a>
|
||||
</div>
|
||||
@if(empty($recent))
|
||||
<div class="px-5 py-12 text-center">
|
||||
<p class="text-sm font-semibold text-slate-700">No mailboxes yet</p>
|
||||
<p class="mx-auto mt-1 max-w-sm text-xs text-slate-400">Add a domain, verify it, then create your first mailbox.</p>
|
||||
<a href="{{ route('email.domains.index') }}" class="mt-4 inline-block rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Set up a domain</a>
|
||||
</div>
|
||||
@else
|
||||
<div class="divide-y divide-slate-50">
|
||||
@foreach($recent as $m)
|
||||
<a href="{{ route('email.mailboxes.show', $m['id']) }}" class="flex items-center justify-between px-5 py-3.5 transition hover:bg-slate-50">
|
||||
<span class="text-sm font-medium text-slate-900">{{ $m['address'] }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium capitalize text-slate-600">{{ $m['status'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,30 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Domains — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Email domains</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Add a domain and verify it to create mailboxes on it.</p>
|
||||
@if($error)<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $error }}</div>@endif
|
||||
|
||||
<form method="POST" action="{{ route('email.domains.store') }}" class="mt-5 flex gap-2 rounded-2xl border border-slate-200 bg-white p-2">
|
||||
@csrf
|
||||
<input name="domain" required placeholder="yourdomain.com" class="flex-1 rounded-xl border-0 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-0">
|
||||
<x-btn.create type="submit">Add domain</x-btn.create>
|
||||
</form>
|
||||
|
||||
@if(!empty($domains))
|
||||
<div class="mt-6 divide-y divide-slate-100 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
@foreach($domains as $d)
|
||||
<a href="{{ route('email.domains.show', $d['id']) }}" class="flex items-center justify-between px-5 py-4 transition hover:bg-slate-50">
|
||||
<span class="text-sm font-semibold text-slate-900">{{ $d['domain'] }}</span>
|
||||
@if($d['active'] ?? false)
|
||||
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[11px] font-medium text-emerald-700">Verified</span>
|
||||
@else
|
||||
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">Pending verification</span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,71 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', $domain['domain'].' — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||
<a href="{{ route('email.domains.index') }}" class="hover:text-slate-600">Domains</a><span>/</span>
|
||||
<span class="text-slate-600">{{ $domain['domain'] }}</span>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">{{ $domain['domain'] }}</h1>
|
||||
@if($domain['active'] ?? false)
|
||||
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[11px] font-medium text-emerald-700">Verified</span>
|
||||
@else
|
||||
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">Pending</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@unless($domain['active'] ?? false)
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Publish these DNS records</h2></div>
|
||||
@php $records = $domain['dns_records'] ?? []; @endphp
|
||||
@if(empty($records))
|
||||
<p class="px-5 py-6 text-sm text-slate-400">No DNS records returned. Try refreshing.</p>
|
||||
@else
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-[11px] uppercase tracking-wide text-slate-400">
|
||||
<tr><th class="px-5 py-2 font-medium">Type</th><th class="px-2 py-2 font-medium">Name</th><th class="px-2 py-2 font-medium">Value</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@foreach($records as $r)
|
||||
<tr>
|
||||
<td class="px-5 py-2.5 font-medium text-slate-700">{{ $r['type'] ?? '' }}</td>
|
||||
<td class="px-2 py-2.5 font-mono text-xs text-slate-600">{{ $r['name'] ?? ($r['host'] ?? '@') }}</td>
|
||||
<td class="px-2 py-2.5 max-w-xs truncate font-mono text-xs text-slate-600" title="{{ $r['value'] ?? '' }}">{{ $r['value'] ?? '' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
</div>
|
||||
<form method="POST" action="{{ route('email.domains.verify', $domain['id']) }}" class="mt-4">
|
||||
@csrf
|
||||
<button class="btn-primary">Verify domain</button>
|
||||
<span class="ml-2 text-xs text-slate-400">DNS changes can take time to propagate.</span>
|
||||
</form>
|
||||
@else
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-sm text-slate-600">This domain is verified. <a href="{{ route('email.mailboxes.create') }}" class="font-semibold text-indigo-600 hover:underline">Create a mailbox</a> on it.</p>
|
||||
<dl class="mt-3 flex gap-4 text-xs text-slate-500">
|
||||
<div>SPF <span class="{{ ($domain['spf'] ?? false) ? 'text-emerald-600' : 'text-rose-600' }}">{{ ($domain['spf'] ?? false) ? '✓' : '✗' }}</span></div>
|
||||
<div>DKIM <span class="{{ ($domain['dkim'] ?? false) ? 'text-emerald-600' : 'text-rose-600' }}">{{ ($domain['dkim'] ?? false) ? '✓' : '✗' }}</span></div>
|
||||
<div>DMARC <span class="{{ ($domain['dmarc'] ?? false) ? 'text-emerald-600' : 'text-rose-600' }}">{{ ($domain['dmarc'] ?? false) ? '✓' : '✗' }}</span></div>
|
||||
</dl>
|
||||
</div>
|
||||
@endunless
|
||||
|
||||
<x-confirm-dialog
|
||||
:name="'remove-domain-'.$domain['id']"
|
||||
title="Remove domain?"
|
||||
:message="'Remove '.$domain['domain'].'?'"
|
||||
:action="route('email.domains.destroy', $domain['id'])"
|
||||
method="DELETE"
|
||||
confirm-label="Remove domain"
|
||||
class="mt-4"
|
||||
>
|
||||
<x-slot:trigger>
|
||||
<button type="button" class="text-xs font-medium text-rose-600 hover:underline">Remove domain</button>
|
||||
</x-slot:trigger>
|
||||
</x-confirm-dialog>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,97 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'New mailbox — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Create a mailbox</h1>
|
||||
@if(empty($domains))
|
||||
<div class="mt-6 rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-800">
|
||||
You need a <strong>verified domain</strong> first. <a href="{{ route('email.domains.index') }}" class="font-semibold underline">Set up a domain</a>.
|
||||
</div>
|
||||
@else
|
||||
@php $fmt = fn ($m) => $quota['currency'].' '.number_format($m / 100, 2); @endphp
|
||||
<div x-data="{
|
||||
tiers: @js($quota['tiers']),
|
||||
sel: {{ $quota['default_mb'] }},
|
||||
balance: {{ $walletBalanceMinor }},
|
||||
get tier() { return this.tiers.find(t => t.mb == this.sel) || this.tiers[0]; },
|
||||
get price() { return this.tier.price_minor; },
|
||||
get free() { return this.price === 0; },
|
||||
fmt(m) { return '{{ $quota['currency'] }} ' + (m / 100).toFixed(2); },
|
||||
}">
|
||||
{{-- Pricing banner (reacts to the selected storage plan) --}}
|
||||
<template x-if="!free">
|
||||
<div class="mt-5 rounded-2xl border border-indigo-200 bg-indigo-50 px-4 py-3 text-sm text-indigo-800">
|
||||
This plan is <strong><span x-text="fmt(price)"></span>/month</strong>, charged from your Ladill wallet
|
||||
(balance: <span x-text="fmt(balance)"></span>).
|
||||
<a href="{{ $topupUrl }}" class="font-semibold underline" x-show="balance < price" x-cloak>Top up</a>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="free">
|
||||
<div class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
The <strong>1 GB</strong> plan is <strong>free, forever</strong>. You can upgrade for more storage anytime.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form method="POST" action="{{ route('email.mailboxes.store') }}" class="mt-4 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Domain</label>
|
||||
<select name="email_domain_id" class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
@foreach($domains as $d)<option value="{{ $d['id'] }}">{{ $d['domain'] }}</option>@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Mailbox name</label>
|
||||
<input name="local_part" required value="{{ old('local_part') }}" placeholder="you"
|
||||
class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
@error('local_part')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Display name</label>
|
||||
<input name="display_name" required value="{{ old('display_name') }}" placeholder="Your Name"
|
||||
class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
</div>
|
||||
|
||||
{{-- Storage plan (sets quota + price) --}}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Storage plan</label>
|
||||
<div class="mt-1.5 grid grid-cols-1 gap-2">
|
||||
@foreach($quota['tiers'] as $t)
|
||||
<label class="flex cursor-pointer items-center justify-between rounded-xl border bg-slate-50 px-3.5 py-2.5 text-sm transition"
|
||||
:class="sel == {{ $t['mb'] }} ? 'border-indigo-400 bg-indigo-50 ring-1 ring-indigo-200' : 'border-slate-200 hover:bg-slate-100'">
|
||||
<span class="flex items-center gap-2.5">
|
||||
<input type="radio" name="quota_mb" value="{{ $t['mb'] }}" x-model.number="sel" class="text-indigo-600 focus:ring-indigo-500">
|
||||
<span class="font-medium text-slate-800">{{ $t['label'] }}</span>
|
||||
</span>
|
||||
@if($t['price_minor'] === 0)
|
||||
<span class="font-semibold text-emerald-600">Free</span>
|
||||
@else
|
||||
<span class="text-slate-500">{{ $fmt($t['price_minor']) }}<span class="text-xs">/mo</span></span>
|
||||
@endif
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('quota_mb')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Password</label>
|
||||
<input type="password" name="password" required class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Confirm</label>
|
||||
<input type="password" name="password_confirmation" required class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
@error('password')<p class="text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
|
||||
<button class="btn-primary w-full btn-primary-lg">
|
||||
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
<span x-text="free ? 'Create free mailbox' : ('Create mailbox — ' + fmt(price) + '/mo')">Create mailbox</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,32 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Mailboxes — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Mailboxes</h1>
|
||||
<x-btn.create :href="route('email.mailboxes.create')">New mailbox</x-btn.create>
|
||||
</div>
|
||||
@if($error)<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $error }}</div>@endif
|
||||
|
||||
@if(empty($mailboxes))
|
||||
<div class="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white p-12 text-center">
|
||||
<p class="text-sm font-semibold text-slate-700">No mailboxes yet</p>
|
||||
<p class="mx-auto mt-1 max-w-sm text-xs text-slate-400">Verify a domain, then create a mailbox like you@yourdomain.com.</p>
|
||||
<x-btn.create :href="route('email.mailboxes.create')" class="mt-4">Create mailbox</x-btn.create>
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-6 divide-y divide-slate-100 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
@foreach($mailboxes as $m)
|
||||
<a href="{{ route('email.mailboxes.show', $m['id']) }}" class="flex items-center justify-between gap-3 px-5 py-4 transition hover:bg-slate-50">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-slate-900">{{ $m['address'] }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">{{ $m['display_name'] ?? '' }} · {{ number_format(($m['quota_mb'] ?? 0)/1024, 0) }} GB</p>
|
||||
</div>
|
||||
@php $st = $m['status'] ?? 'pending'; $badge = $st === 'active' ? 'bg-emerald-50 text-emerald-700' : ($st === 'failed' ? 'bg-rose-50 text-rose-700' : 'bg-slate-100 text-slate-600'); @endphp
|
||||
<span class="shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium capitalize {{ $badge }}">{{ $st }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,92 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', $mailbox['address'].' — Ladill Email')
|
||||
@section('content')
|
||||
@php $host = 'mail.'.config('app.platform_domain'); @endphp
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||
<a href="{{ route('email.mailboxes.index') }}" class="hover:text-slate-600">Mailboxes</a><span>/</span>
|
||||
<span class="text-slate-600">{{ $mailbox['address'] }}</span>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">{{ $mailbox['address'] }}</h1>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium capitalize text-slate-600">{{ $mailbox['status'] ?? '' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Connection settings</h2>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Use these in any mail client, or just open webmail.</p>
|
||||
<dl class="mt-4 space-y-2 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Username</dt><dd class="font-mono text-slate-800">{{ $mailbox['address'] }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">IMAP</dt><dd class="font-mono text-slate-800">{{ $host }}:993 (SSL)</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">SMTP</dt><dd class="font-mono text-slate-800">{{ $host }}:587 (STARTTLS)</dd></div>
|
||||
</dl>
|
||||
<a href="https://{{ $host }}" class="btn-primary mt-4">Open Webmail ↗</a>
|
||||
</div>
|
||||
|
||||
@php
|
||||
$quotaMb = (int) ($mailbox['quota_mb'] ?? 0);
|
||||
$usedBytes = (int) ($mailbox['used_bytes'] ?? 0);
|
||||
$price = \App\Support\MailboxPricing::priceMinorFor($quotaMb);
|
||||
$tiers = \App\Support\MailboxPricing::tiers();
|
||||
$maxMb = collect($tiers)->max('mb');
|
||||
$pct = $quotaMb > 0 ? min(100, (int) round($usedBytes / ($quotaMb * 1048576) * 100)) : 0;
|
||||
$fmt = fn ($m) => config('email.currency', 'GHS').' '.number_format($m / 100, 2);
|
||||
@endphp
|
||||
<div class="mt-4 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-slate-900">Storage plan</h2>
|
||||
<p class="mt-0.5 text-sm text-slate-600">
|
||||
<span class="font-semibold">{{ \App\Support\MailboxPricing::label($quotaMb) }}</span>
|
||||
@if($price === 0)
|
||||
<span class="ml-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700">Free</span>
|
||||
@else
|
||||
<span class="ml-1 text-xs text-slate-400">{{ $fmt($price) }}/month</span>
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@if($quotaMb < $maxMb)
|
||||
<a href="{{ route('email.mailboxes.upgrade', $mailbox['id']) }}" class="btn-primary">Upgrade</a>
|
||||
@else
|
||||
<span class="rounded-lg bg-slate-100 px-3 py-2 text-xs font-medium text-slate-500">Top plan</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($quotaMb > 0)
|
||||
<div class="mt-4">
|
||||
<div class="h-2 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full {{ $pct >= 90 ? 'bg-rose-500' : 'bg-indigo-500' }}" style="width: {{ max(2, $pct) }}%"></div>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-slate-400">{{ number_format($usedBytes / 1048576, 0) }} MB of {{ \App\Support\MailboxPricing::label($quotaMb) }} used ({{ $pct }}%)</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Reset password</h2>
|
||||
<form method="POST" action="{{ route('email.mailboxes.password', $mailbox['id']) }}" class="mt-3 grid grid-cols-2 gap-3">
|
||||
@csrf @method('PATCH')
|
||||
<input type="password" name="password" required placeholder="New password" class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
<input type="password" name="password_confirmation" required placeholder="Confirm" class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
<button class="col-span-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Update password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-rose-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-rose-700">Delete mailbox</h2>
|
||||
<p class="mt-0.5 text-xs text-slate-400">This permanently removes the mailbox and its email.</p>
|
||||
<x-confirm-dialog
|
||||
:name="'delete-mailbox-'.$mailbox['id']"
|
||||
title="Delete mailbox?"
|
||||
:message="'Delete '.$mailbox['address'].'? This cannot be undone.'"
|
||||
:action="route('email.mailboxes.destroy', $mailbox['id'])"
|
||||
method="DELETE"
|
||||
confirm-label="Delete mailbox"
|
||||
class="mt-3"
|
||||
>
|
||||
<x-slot:trigger>
|
||||
<button type="button" class="rounded-lg bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-700">Delete mailbox</button>
|
||||
</x-slot:trigger>
|
||||
</x-confirm-dialog>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,52 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Upgrade storage — Ladill Email')
|
||||
@section('content')
|
||||
@php $fmt = fn ($m) => $currency.' '.number_format($m / 100, 2); @endphp
|
||||
<div class="mx-auto max-w-lg">
|
||||
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||
<a href="{{ route('email.mailboxes.show', $mailbox['id']) }}" class="hover:text-slate-600">{{ $mailbox['address'] }}</a><span>/</span>
|
||||
<span class="text-slate-600">Upgrade</span>
|
||||
</div>
|
||||
<h1 class="mt-2 text-xl font-semibold tracking-tight text-slate-900">Upgrade storage</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">
|
||||
Currently on <strong>{{ \App\Support\MailboxPricing::label($currentMb) }}</strong>. Pick a larger plan — billed monthly from your wallet.
|
||||
</p>
|
||||
|
||||
<div x-data="{
|
||||
tiers: @js($tiers),
|
||||
sel: {{ $defaultMb }},
|
||||
balance: {{ $walletBalanceMinor }},
|
||||
get tier() { return this.tiers.find(t => t.mb == this.sel) || this.tiers[0]; },
|
||||
get price() { return this.tier.price_minor; },
|
||||
fmt(m) { return '{{ $currency }} ' + (m / 100).toFixed(2); },
|
||||
}">
|
||||
<div class="mt-5 rounded-2xl border border-indigo-200 bg-indigo-50 px-4 py-3 text-sm text-indigo-800">
|
||||
New plan: <strong><span x-text="fmt(price)"></span>/month</strong>, charged now from your wallet
|
||||
(balance: <span x-text="fmt(balance)"></span>).
|
||||
<a href="{{ $topupUrl }}" class="font-semibold underline" x-show="balance < price" x-cloak>Top up</a>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('email.mailboxes.upgrade.store', $mailbox['id']) }}" class="mt-4 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf @method('PATCH')
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Choose a plan</label>
|
||||
<div class="mt-1.5 grid grid-cols-1 gap-2">
|
||||
@foreach($tiers as $t)
|
||||
<label class="flex cursor-pointer items-center justify-between rounded-xl border bg-slate-50 px-3.5 py-2.5 text-sm transition"
|
||||
:class="sel == {{ $t['mb'] }} ? 'border-indigo-400 bg-indigo-50 ring-1 ring-indigo-200' : 'border-slate-200 hover:bg-slate-100'">
|
||||
<span class="flex items-center gap-2.5">
|
||||
<input type="radio" name="quota_mb" value="{{ $t['mb'] }}" x-model.number="sel" class="text-indigo-600 focus:ring-indigo-500">
|
||||
<span class="font-medium text-slate-800">{{ $t['label'] }}</span>
|
||||
</span>
|
||||
<span class="text-slate-500">{{ $fmt($t['price_minor']) }}<span class="text-xs">/mo</span></span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('quota_mb')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<button class="btn-primary w-full btn-primary-lg"
|
||||
x-text="'Upgrade — ' + fmt(price) + '/mo'">Upgrade</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Signed out — Ladill Email</title>
|
||||
@include('partials.favicon')
|
||||
@vite(['resources/css/app.css'])
|
||||
<meta http-equiv="refresh" content="2;url={{ 'https://'.config('app.platform_domain') }}">
|
||||
</head>
|
||||
<body class="flex h-full items-center justify-center bg-slate-100 font-sans">
|
||||
<div class="text-center">
|
||||
<img src="{{ asset('images/logo/ladillemail-logo.svg') }}" alt="Ladill Email" class="mx-auto h-7 w-auto">
|
||||
<p class="mt-6 text-sm text-slate-600">You’ve been signed out. Redirecting…</p>
|
||||
<a href="{{ 'https://'.config('app.platform_domain') }}" class="mt-2 inline-block text-sm font-medium text-indigo-600 hover:underline">Go to ladill.com</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Overview</x-slot>
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Overview</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Total raised today, active giving pages, and recent donations.</p>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Raised today</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($todayMinor) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Donations today</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ number_format($todayCount) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Giving pages</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $givingPageCount }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-indigo-100 bg-indigo-50/60 p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-indigo-600">Wallet balance</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<h2 class="font-semibold text-slate-900">Recent donations</h2>
|
||||
<a href="{{ route('give.donations.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">View all</a>
|
||||
</div>
|
||||
@if($recentDonations->isEmpty())
|
||||
<p class="px-6 py-8 text-sm text-slate-500">No donations yet. Create a giving page and share your QR.</p>
|
||||
@else
|
||||
<div class="divide-y divide-slate-100">
|
||||
@foreach($recentDonations as $donation)
|
||||
<div class="flex items-center justify-between px-6 py-4">
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">{{ $donation->payer_name ?: 'Anonymous donor' }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $donation->collection_type ?: 'Donation' }} · {{ $donation->qrCode?->label }} · {{ $donation->paid_at?->diffForHumans() }}</p>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-emerald-700">{{ $fmt($donation->merchant_amount_minor) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="font-semibold text-slate-900">Quick links</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
<a href="{{ route('give.giving-pages.create') }}" class="flex items-center justify-between rounded-xl border border-slate-100 px-4 py-3 text-sm hover:bg-slate-50">
|
||||
<span class="font-medium text-slate-700">Create giving page</span>
|
||||
<span class="text-slate-400">→</span>
|
||||
</a>
|
||||
<a href="{{ route('give.giving-pages.index') }}" class="flex items-center justify-between rounded-xl border border-slate-100 px-4 py-3 text-sm hover:bg-slate-50">
|
||||
<span class="font-medium text-slate-700">All giving pages</span>
|
||||
<span class="text-slate-400">→</span>
|
||||
</a>
|
||||
<a href="{{ route('give.payouts') }}" class="flex items-center justify-between rounded-xl border border-slate-100 px-4 py-3 text-sm hover:bg-slate-50">
|
||||
<span class="font-medium text-slate-700">Payouts & withdrawals</span>
|
||||
<span class="text-slate-400">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,58 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Donations</x-slot>
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Donations</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Incoming donations across all your giving pages.</p>
|
||||
</div>
|
||||
<form method="get" class="max-w-md">
|
||||
<input type="search" name="q" value="{{ $search }}" placeholder="Search donor, cause, reference…"
|
||||
class="w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</form>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
@if($donations->isEmpty())
|
||||
<p class="px-6 py-10 text-sm text-slate-500">No donations yet.</p>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-6 py-3">When</th>
|
||||
<th class="px-6 py-3">Donor</th>
|
||||
<th class="px-6 py-3">Cause</th>
|
||||
<th class="px-6 py-3">Giving page</th>
|
||||
<th class="px-6 py-3">Amount</th>
|
||||
<th class="px-6 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
@foreach($donations as $donation)
|
||||
<tr>
|
||||
<td class="px-6 py-4 text-slate-600">{{ $donation->paid_at?->format('M j, g:i A') ?? $donation->created_at->format('M j, g:i A') }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<p class="font-medium text-slate-900">{{ $donation->payer_name ?: 'Anonymous' }}</p>
|
||||
@if($donation->payer_email)
|
||||
<p class="text-xs text-slate-500">{{ $donation->payer_email }}</p>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 text-slate-600">{{ $donation->collection_type ?: 'Donation' }}</td>
|
||||
<td class="px-6 py-4 text-slate-600">{{ $donation->qrCode?->label }}</td>
|
||||
<td class="px-6 py-4 font-semibold text-slate-900">{{ $fmt($donation->status === \App\Models\GiveDonation::STATUS_PAID ? $donation->merchant_amount_minor : $donation->amount_minor) }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium {{ $donation->status === \App\Models\GiveDonation::STATUS_PAID ? 'bg-emerald-50 text-emerald-700' : ($donation->status === \App\Models\GiveDonation::STATUS_FAILED ? 'bg-red-50 text-red-700' : 'bg-amber-50 text-amber-700') }}">
|
||||
{{ ucfirst($donation->status) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($donations->hasPages())
|
||||
<div class="border-t border-slate-100 px-6 py-4">{{ $donations->links() }}</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,106 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Create Giving Page</x-slot>
|
||||
@php
|
||||
$giveOldTypes = old('collection_types', ['Offering','Tithe','Donation','Harvest']);
|
||||
$giveOldOrg = old('org_type', 'church');
|
||||
@endphp
|
||||
<div class="mx-auto max-w-2xl space-y-6" x-data="{
|
||||
orgType: @js($giveOldOrg),
|
||||
orgMeta: {
|
||||
church: { namePh: 'Church name *', descriptorLabel: 'Denomination', descriptorPh: 'e.g. Presbyterian', timesLabel: 'Service times', timesPh: 'e.g. Sundays 9am', presets: ['Offering','Tithe','Donation','Harvest'] },
|
||||
school: { namePh: 'School name *', descriptorLabel: 'Motto', descriptorPh: 'School motto', timesLabel: 'School hours', timesPh: 'Mon–Fri 7:30am', presets: ['School Fees','PTA Levy','Development Fund'] },
|
||||
mosque: { namePh: 'Mosque name *', descriptorLabel: 'Community', descriptorPh: 'Jama\'ah', timesLabel: 'Prayer times', timesPh: 'Jumu\'ah 1pm', presets: ['Sadaqah','Zakat','Waqf'] },
|
||||
ngo: { namePh: 'Organisation name *', descriptorLabel: 'Mission', descriptorPh: 'Mission statement', timesLabel: 'Office hours', timesPh: 'Mon–Fri 9am', presets: ['General Donation','Project Fund','Emergency Relief'] },
|
||||
club: { namePh: 'Club name *', descriptorLabel: 'Tagline', descriptorPh: 'Club tagline', timesLabel: 'Meeting times', timesPh: 'Fridays 5pm', presets: ['Membership Dues','Event Fund','Donation'] },
|
||||
},
|
||||
get cfg() { return this.orgMeta[this.orgType]; },
|
||||
types: @js($giveOldTypes),
|
||||
addInput: '',
|
||||
setOrg(t) { this.orgType = t; this.types = [...this.orgMeta[t].presets]; },
|
||||
addType() { const v = this.addInput.trim(); if (v && !this.types.includes(v)) this.types.push(v); this.addInput = ''; },
|
||||
removeType(i) { this.types.splice(i, 1); }
|
||||
}">
|
||||
<div>
|
||||
<a href="{{ route('give.giving-pages.index') }}" class="text-sm text-slate-500 hover:text-slate-700">← All giving pages</a>
|
||||
<h1 class="mt-2 text-xl font-semibold text-slate-900">Create giving page</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Set up your organisation, causes, and online giving QR.</p>
|
||||
</div>
|
||||
@if(session('error'))
|
||||
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
|
||||
@endif
|
||||
<form method="post" action="{{ route('give.giving-pages.store') }}" enctype="multipart/form-data" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Dashboard label</label>
|
||||
<input type="text" name="label" value="{{ old('label') }}" required maxlength="120" placeholder="e.g. Main church giving"
|
||||
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-semibold text-slate-600">Organisation type</p>
|
||||
<div class="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
||||
@foreach(['church'=>'Church','school'=>'School','mosque'=>'Mosque','ngo'=>'NGO','club'=>'Club'] as $key => $label)
|
||||
<button type="button" @click="setOrg('{{ $key }}')"
|
||||
:class="orgType === '{{ $key }}' ? 'border-indigo-500 bg-indigo-50 text-indigo-700' : 'border-slate-200 bg-white text-slate-500'"
|
||||
class="rounded-xl border-2 px-2 py-2 text-[11px] font-semibold">{{ $label }}</button>
|
||||
@endforeach
|
||||
</div>
|
||||
<input type="hidden" name="org_type" :value="orgType">
|
||||
</div>
|
||||
<input type="text" name="name" value="{{ old('name') }}" required :placeholder="cfg.namePh"
|
||||
class="w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-500" x-text="cfg.descriptorLabel">Descriptor</label>
|
||||
<input type="text" name="denomination" value="{{ old('denomination') }}" :placeholder="cfg.descriptorPh"
|
||||
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</div>
|
||||
<textarea name="description" rows="2" placeholder="Short description"
|
||||
class="w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">{{ old('description') }}</textarea>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input type="text" name="phone" value="{{ old('phone') }}" placeholder="Phone" class="rounded-xl border-slate-200 text-sm">
|
||||
<input type="email" name="email" value="{{ old('email') }}" placeholder="Email" class="rounded-xl border-slate-200 text-sm">
|
||||
</div>
|
||||
<input type="text" name="service_times" value="{{ old('service_times') }}" :placeholder="cfg.timesPh"
|
||||
class="w-full rounded-xl border-slate-200 text-sm">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-500">Logo</label>
|
||||
<input type="file" name="church_logo" accept="image/*" class="mt-1 block w-full text-xs">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-500">Cover image</label>
|
||||
<input type="file" name="church_cover" accept="image/*" class="mt-1 block w-full text-xs">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-500">Brand color</label>
|
||||
<input type="color" name="brand_color" value="{{ old('brand_color', '#1a3a5c') }}" class="mt-1 h-10 w-full rounded-xl border border-slate-200">
|
||||
</div>
|
||||
<label class="flex cursor-pointer items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-900">Accept online giving</p>
|
||||
<p class="text-xs text-slate-500">Supporters can give online (9% platform fee).</p>
|
||||
</div>
|
||||
<input type="checkbox" name="accepts_payment" value="1" @checked(old('accepts_payment', true)) class="h-5 w-5 rounded text-indigo-600">
|
||||
</label>
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-semibold text-slate-600">Giving categories</p>
|
||||
<template x-for="t in types" :key="t">
|
||||
<input type="hidden" name="collection_types[]" :value="t">
|
||||
</template>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<template x-for="(t, i) in types" :key="t">
|
||||
<span class="inline-flex items-center gap-1 rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold">
|
||||
<span x-text="t"></span>
|
||||
<button type="button" @click="removeType(i)" class="text-slate-400 hover:text-red-500">×</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" x-model="addInput" @keydown.enter.prevent="addType()" placeholder="Add category…" class="flex-1 rounded-xl border-slate-200 text-sm">
|
||||
<button type="button" @click="addType()" class="rounded-xl bg-indigo-50 px-3 py-2 text-xs font-semibold text-indigo-600">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Create giving page</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,35 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Giving Pages</x-slot>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Giving pages</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Branded pages with a printable QR for online giving.</p>
|
||||
</div>
|
||||
<x-btn.create :href="route('give.giving-pages.create')">New giving page</x-btn.create>
|
||||
</div>
|
||||
@if($qrCodes->isEmpty())
|
||||
<div class="rounded-2xl border border-dashed border-slate-200 bg-white px-6 py-12 text-center">
|
||||
<p class="text-sm text-slate-500">No giving pages yet.</p>
|
||||
<a href="{{ route('give.giving-pages.create') }}" class="mt-3 inline-block text-sm font-semibold text-indigo-600 hover:text-indigo-800">Create your first giving page</a>
|
||||
</div>
|
||||
@else
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach($qrCodes as $qr)
|
||||
<a href="{{ route('give.giving-pages.show', $qr) }}" class="rounded-2xl border border-slate-200 bg-white p-5 transition hover:border-indigo-200 hover:shadow-sm">
|
||||
<div class="flex items-start gap-4">
|
||||
@if(!empty($previewDataUris[$qr->id]))
|
||||
<img src="{{ $previewDataUris[$qr->id] }}" alt="" class="h-16 w-16 rounded-lg border border-slate-100 bg-white p-1">
|
||||
@endif
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-semibold text-slate-900">{{ $qr->label }}</p>
|
||||
<p class="mt-0.5 truncate text-sm text-slate-500">{{ $qr->content()['name'] ?? 'Organisation' }}</p>
|
||||
<p class="mt-2 text-xs text-slate-400">{{ $qr->is_active ? 'Active' : 'Inactive' }} · /q/{{ $qr->short_code }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,38 @@
|
||||
@php
|
||||
$businessName = $qrCode->content()['business_name'] ?? $qrCode->label;
|
||||
@endphp
|
||||
|
||||
<x-modal name="delete-payment-qr" maxWidth="md">
|
||||
<div class="px-5 pb-6 pt-2 sm:px-6 sm:pb-6 sm:pt-4">
|
||||
<div class="flex flex-col items-center text-center sm:items-start sm:text-left">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-100 text-red-600">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mt-4 text-lg font-semibold text-slate-900">Delete giving page?</h2>
|
||||
<p class="mt-2 text-sm leading-relaxed text-slate-500">
|
||||
<span class="font-medium text-slate-700">{{ $qrCode->label }}</span>
|
||||
@if($businessName !== $qrCode->label)
|
||||
<span class="text-slate-400">· {{ $businessName }}</span>
|
||||
@endif
|
||||
will be removed permanently. The payment link will stop working and printed codes for this till will no longer accept payments.
|
||||
</p>
|
||||
<p class="mt-3 break-all rounded-xl bg-slate-50 px-3 py-2 text-xs text-slate-500">{{ $qrCode->publicUrl() }}</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('give.giving-pages.destroy', $qrCode) }}" class="mt-6 flex flex-col-reverse gap-2.5 sm:flex-row sm:justify-end">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="button"
|
||||
@click="$dispatch('close-modal', 'delete-payment-qr')"
|
||||
class="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="rounded-xl bg-red-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-red-700">
|
||||
Delete giving page
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-modal>
|
||||
@@ -0,0 +1,37 @@
|
||||
@php
|
||||
$shareUrl = $qrCode->publicUrl();
|
||||
$shareEnc = urlencode($shareUrl);
|
||||
$shareText = urlencode($qrCode->label . ' — pay with QR');
|
||||
@endphp
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a href="{{ route('give.giving-pages.download', [$qrCode, 'png']) }}"
|
||||
class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50">PNG</a>
|
||||
<a href="{{ route('give.giving-pages.download', [$qrCode, 'svg']) }}"
|
||||
class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50">SVG</a>
|
||||
<a href="{{ route('give.giving-pages.download', [$qrCode, 'pdf']) }}"
|
||||
class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50">PDF</a>
|
||||
<div class="relative" x-data="{ open: false, copied: false }" @click.outside="open = false">
|
||||
<button type="button"
|
||||
@click="if(navigator.share){navigator.share({title:@js($qrCode->label),url:@js($shareUrl)}).catch(()=>{open=!open})}else{open=!open}"
|
||||
class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Share
|
||||
</button>
|
||||
<div x-show="open" x-cloak
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
class="absolute right-0 z-50 mt-2 w-52 origin-top-right rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl">
|
||||
<a href="https://wa.me/?text={{ $shareText }}%20{{ $shareEnc }}" target="_blank" rel="noopener"
|
||||
@click="open = false"
|
||||
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
|
||||
WhatsApp
|
||||
</a>
|
||||
<button type="button"
|
||||
@click="navigator.clipboard.writeText(@js($shareUrl)).then(() => { copied = true; setTimeout(() => { copied = false; open = false }, 1500) })"
|
||||
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition hover:bg-slate-50"
|
||||
:class="copied ? 'text-emerald-600' : 'text-slate-700'">
|
||||
<span x-text="copied ? 'Copied!' : 'Copy link'">Copy link</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
@php
|
||||
$showDownloads = $showDownloads ?? true;
|
||||
@endphp
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<div class="relative flex items-center justify-center bg-gradient-to-br from-indigo-50/60 via-white to-violet-50/40 px-8 py-10">
|
||||
<div class="w-full max-w-xs">
|
||||
<div class="overflow-hidden rounded-2xl bg-white p-4 shadow-2xl ring-1 ring-black/5">
|
||||
<img src="{{ $previewDataUri }}"
|
||||
alt="Payment QR code for {{ $qrCode->label }}"
|
||||
class="aspect-square w-full object-contain">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($showDownloads)
|
||||
<div class="border-t border-slate-100 bg-slate-50/50 px-6 py-5">
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Download</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<a href="{{ route('give.giving-pages.download', [$qrCode, 'png']) }}"
|
||||
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-indigo-300 hover:bg-indigo-50">
|
||||
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-indigo-700">PNG</span>
|
||||
</a>
|
||||
<a href="{{ route('give.giving-pages.download', [$qrCode, 'svg']) }}"
|
||||
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-violet-300 hover:bg-violet-50">
|
||||
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-violet-700">SVG</span>
|
||||
</a>
|
||||
<a href="{{ route('give.giving-pages.download', [$qrCode, 'pdf']) }}"
|
||||
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-rose-300 hover:bg-rose-50">
|
||||
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-rose-700">PDF</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,86 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">{{ $qrCode->label }}</x-slot>
|
||||
@php $c = $qrCode->content(); @endphp
|
||||
<div class="mx-auto max-w-6xl space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="{{ route('give.giving-pages.index') }}" class="text-sm text-slate-500 hover:text-slate-700">← All giving pages</a>
|
||||
<h1 class="mt-2 text-xl font-semibold text-slate-900">{{ $qrCode->label }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ $c['name'] ?? '' }}@if(!empty($c['denomination'])) · {{ $c['denomination'] }}@endif</p>
|
||||
</div>
|
||||
@include('give.giving-pages.partials.header-actions', ['qrCode' => $qrCode])
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[380px_1fr]">
|
||||
<div class="lg:sticky lg:top-6 lg:self-start">
|
||||
@include('give.giving-pages.partials.preview-card', [
|
||||
'qrCode' => $qrCode,
|
||||
'previewDataUri' => $previewDataUri,
|
||||
'showDownloads' => false,
|
||||
])
|
||||
<p class="mt-4 break-all text-center text-sm text-indigo-600">{{ $qrCode->publicUrl() }}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<form method="post" action="{{ route('give.giving-pages.update', $qrCode) }}" enctype="multipart/form-data" class="space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<h2 class="font-semibold text-slate-900">Settings</h2>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Dashboard label</label>
|
||||
<input type="text" name="label" value="{{ old('label', $qrCode->label) }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Organisation name</label>
|
||||
<input type="text" name="name" value="{{ old('name', $c['name'] ?? '') }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Description / denomination</label>
|
||||
<input type="text" name="denomination" value="{{ old('denomination', $c['denomination'] ?? '') }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
|
||||
</div>
|
||||
<textarea name="description" rows="2" class="w-full rounded-xl border-slate-200 text-sm">{{ old('description', $c['description'] ?? '') }}</textarea>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input type="text" name="phone" value="{{ old('phone', $c['phone'] ?? '') }}" placeholder="Phone" class="rounded-xl border-slate-200 text-sm">
|
||||
<input type="email" name="email" value="{{ old('email', $c['email'] ?? '') }}" placeholder="Email" class="rounded-xl border-slate-200 text-sm">
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="accepts_payment" value="1" @checked(old('accepts_payment', $c['accepts_payment'] ?? false)) class="rounded border-slate-300 text-indigo-600">
|
||||
Accept online giving
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="is_active" value="1" @checked($qrCode->is_active) class="rounded border-slate-300 text-indigo-600">
|
||||
Page is active
|
||||
</label>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs text-slate-500">Replace logo</label>
|
||||
<input type="file" name="church_logo" accept="image/*" class="mt-1 block w-full text-xs">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-500">Replace cover</label>
|
||||
<input type="file" name="church_cover" accept="image/*" class="mt-1 block w-full text-xs">
|
||||
</div>
|
||||
</div>
|
||||
@foreach($c['collection_types'] ?? ['Offering','Tithe','Donation'] as $i => $type)
|
||||
<input type="hidden" name="collection_types[]" value="{{ $type }}">
|
||||
@endforeach
|
||||
<button type="submit" class="btn-primary">Save changes</button>
|
||||
</form>
|
||||
|
||||
<div class="rounded-2xl border border-red-100 bg-red-50/40 p-6">
|
||||
<h2 class="font-semibold text-red-700">Danger zone</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Remove this giving page and deactivate its QR.</p>
|
||||
<button type="button" @click="$dispatch('open-modal', 'delete-giving-page')"
|
||||
class="mt-4 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-semibold text-red-700 hover:bg-red-50">
|
||||
Delete giving page
|
||||
</button>
|
||||
</div>
|
||||
@include('give.giving-pages.partials.delete-modal', ['qrCode' => $qrCode])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,27 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Payouts</x-slot>
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Payouts</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Takings settle into your Ladill wallet (5% fee), then withdraw to bank or MoMo.</p>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Total received (net)</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($revenueMinor) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-indigo-100 bg-indigo-50/60 p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-indigo-600">Available in wallet</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-amber-100 bg-amber-50/60 px-6 py-4 text-sm text-amber-800">
|
||||
Withdraw to bank or MoMo from your Ladill account wallet. Ladill Pay integration will add in-app withdrawal history here.
|
||||
</div>
|
||||
<a href="{{ $accountWalletUrl }}" class="btn-primary">
|
||||
Open wallet & withdraw
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,69 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Settings</x-slot>
|
||||
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Settings</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Notification preferences for your payment QRs.</p>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('account.settings.update') }}" class="mt-6 space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Notifications</h2>
|
||||
<p class="mt-1 text-xs text-slate-500">Choose what we email you about payments and your account.</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="notify_email" class="block text-[11px] font-medium text-slate-500">Notifications email</label>
|
||||
<input type="email" id="notify_email" name="notify_email"
|
||||
value="{{ old('notify_email', $settings->notify_email ?? $account->email) }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@error('notify_email') <p class="mt-1 text-xs text-rose-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<label class="mt-4 flex items-center justify-between gap-4">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">Incoming payments</span>
|
||||
<span class="block text-xs text-slate-400">Email when a customer pays via your payment QR.</span>
|
||||
</span>
|
||||
<input type="checkbox" name="notify_registrations" value="1"
|
||||
@checked(old('notify_registrations', $settings->notify_registrations ?? true))
|
||||
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</label>
|
||||
|
||||
<label class="mt-4 flex items-center justify-between gap-4">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">Payouts & settlements</span>
|
||||
<span class="block text-xs text-slate-400">Email when takings settle into your wallet.</span>
|
||||
</span>
|
||||
<input type="checkbox" name="notify_payouts" value="1"
|
||||
@checked(old('notify_payouts', $settings->notify_payouts ?? true))
|
||||
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</label>
|
||||
|
||||
<label class="mt-4 flex items-center justify-between gap-4">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">Ladill Give updates</span>
|
||||
<span class="block text-xs text-slate-400">Occasional emails about new features and improvements.</span>
|
||||
</span>
|
||||
<input type="checkbox" name="product_updates" value="1"
|
||||
@checked(old('product_updates', $settings->product_updates ?? true))
|
||||
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">
|
||||
Save settings
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-xs text-slate-400">
|
||||
Profile, password, and security are managed on
|
||||
<a href="{{ ladill_account_url('account-settings') }}" class="font-medium text-indigo-600 hover:text-indigo-800">account.ladill.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Signed out — Ladill Give</title>
|
||||
@include('partials.favicon')
|
||||
</head>
|
||||
<body class="flex min-h-screen items-center justify-center bg-slate-100 font-sans antialiased">
|
||||
<div class="text-center">
|
||||
<p class="text-slate-600">You have been signed out of Ladill Give.</p>
|
||||
<a href="{{ route('sso.connect') }}" class="mt-4 inline-block text-sm font-medium text-indigo-600 hover:text-indigo-800">Sign in again</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-slate-100">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>@yield('title', 'Ladill Hosting')</title>
|
||||
@include('partials.favicon')
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }">
|
||||
<div class="min-h-screen max-lg:min-h-dvh">
|
||||
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
|
||||
<aside x-cloak class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||||
@include('partials.sidebar')
|
||||
</aside>
|
||||
<div class="flex min-h-screen flex-col min-w-0 lg:pl-64">
|
||||
@include('partials.topbar')
|
||||
<main class="flex-1 p-6 pb-24 lg:p-6 lg:pb-6">
|
||||
@include('partials.flash')
|
||||
@yield('content')
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@auth
|
||||
@php
|
||||
$navUser = auth()->user();
|
||||
$navInitials = collect(explode(' ', trim((string) $navUser?->name)))
|
||||
->filter()->take(2)
|
||||
->map(fn ($part) => strtoupper(substr($part, 0, 1)))
|
||||
->implode('');
|
||||
$navAcct = 'https://'.config('app.account_domain');
|
||||
@endphp
|
||||
@include('partials.mobile-bottom-nav', [
|
||||
'homeUrl' => route('hosting.dashboard'),
|
||||
'homeActive' => request()->routeIs('hosting.dashboard') || request()->routeIs('hosting.index'),
|
||||
'searchUrl' => route('hosting.dashboard'),
|
||||
'searchActive' => request()->routeIs('hosting.*'),
|
||||
'notificationsUrl' => route('notifications.index'),
|
||||
'notificationsActive' => request()->routeIs('notifications.*'),
|
||||
'unreadUrl' => route('notifications.unread'),
|
||||
'profileActive' => false,
|
||||
'profileName' => $navUser?->name ?? '',
|
||||
'profileSubtitle' => $navUser?->email ?? '',
|
||||
'profileMenuItems' => [
|
||||
['type' => 'link', 'label' => 'Profile', 'href' => $navAcct.'/profile'],
|
||||
['type' => 'link', 'label' => 'Account Settings', 'href' => $navAcct.'/account-settings'],
|
||||
['type' => 'logout', 'label' => 'Logout', 'action' => route('logout')],
|
||||
],
|
||||
'avatarUrl' => $navUser?->avatar_url,
|
||||
'initials' => $navInitials !== '' ? $navInitials : 'U',
|
||||
])
|
||||
@include('partials.afia')
|
||||
@endauth
|
||||
@include('partials.confirm-prompt')
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,265 @@
|
||||
@php
|
||||
$mobileFullScreenPage = request()->routeIs('account.settings')
|
||||
|| request()->routeIs('give.giving-pages.create')
|
||||
|| request()->routeIs('give.giving-pages.show');
|
||||
|
||||
$qrMobilePage = request()->routeIs('give.giving-pages.create') || request()->routeIs('give.giving-pages.show');
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['qr-mobile-page' => $qrMobilePage])>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
@include('partials.favicon')
|
||||
<title>{{ $title ?? 'Dashboard' }} - Ladill</title>
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }">
|
||||
<div class="min-h-screen max-lg:min-h-dvh">
|
||||
{{-- Mobile sidebar overlay --}}
|
||||
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
|
||||
|
||||
{{-- Sidebar — full product nav (Bird and other extracted apps have their
|
||||
own nav in their own apps). --}}
|
||||
@php $sidebarPartial = 'partials.sidebar'; @endphp
|
||||
<aside x-cloak class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||||
@include($sidebarPartial)
|
||||
</aside>
|
||||
|
||||
{{-- Main content --}}
|
||||
<div class="flex min-h-screen flex-col min-w-0 lg:pl-64">
|
||||
<div class="{{ $mobileFullScreenPage ? 'hidden lg:block' : '' }}">
|
||||
@include('partials.topbar-qr')
|
||||
</div>
|
||||
|
||||
<div @class([
|
||||
'px-6 pt-4',
|
||||
'hidden lg:block' => $mobileFullScreenPage,
|
||||
])>
|
||||
@include('partials.flash')
|
||||
</div>
|
||||
|
||||
<main @class([
|
||||
'flex-1 lg:p-6 lg:pb-6',
|
||||
'px-4 pt-4 max-lg:pb-[calc(4rem+max(env(safe-area-inset-bottom,0px),20px))]' => $qrMobilePage,
|
||||
'p-0 pb-24 lg:p-6 lg:pb-6' => $mobileFullScreenPage && ! $qrMobilePage,
|
||||
'p-6 pb-24 lg:pb-6' => ! $mobileFullScreenPage,
|
||||
])>
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{{-- Mobile Bottom Navigation (hidden on QR create/show which have their own action bar) --}}
|
||||
@unless(request()->routeIs('give.giving-pages.create') || request()->routeIs('give.giving-pages.show'))
|
||||
@php
|
||||
$navUser = auth()->user();
|
||||
$navInitials = collect(explode(' ', trim((string) $navUser?->name)))
|
||||
->filter()->take(2)
|
||||
->map(fn ($part) => strtoupper(substr($part, 0, 1)))
|
||||
->implode('');
|
||||
@endphp
|
||||
@include('partials.mobile-bottom-nav', [
|
||||
'homeUrl' => route('give.dashboard'),
|
||||
'homeActive' => request()->routeIs('give.dashboard'),
|
||||
'searchUrl' => route('give.search'),
|
||||
'searchActive' => request()->routeIs('give.search'),
|
||||
'notificationsUrl' => route('notifications.index'),
|
||||
'notificationsActive' => request()->routeIs('notifications.*'),
|
||||
'unreadUrl' => route('notifications.unread'),
|
||||
'profileActive' => request()->routeIs('account.settings'),
|
||||
'profileName' => $navUser?->name ?? '',
|
||||
'profileSubtitle' => $navUser?->email ?? '',
|
||||
'profileMenuItems' => [
|
||||
['type' => 'link', 'label' => 'Settings', 'href' => route('account.settings')],
|
||||
['type' => 'link', 'label' => 'Account Settings', 'href' => ladill_account_url('account-settings')],
|
||||
['type' => 'link', 'label' => 'Overview', 'href' => route('give.dashboard')],
|
||||
['type' => 'logout', 'label' => 'Logout', 'action' => route('logout')],
|
||||
],
|
||||
'avatarUrl' => $navUser?->avatarUrl(),
|
||||
'initials' => $navInitials !== '' ? $navInitials : 'U',
|
||||
])
|
||||
@endunless
|
||||
|
||||
@if ($qrMobilePage)
|
||||
<div class="qr-mobile-bottom-bleed lg:hidden" aria-hidden="true"></div>
|
||||
@endif
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('notificationDropdown', (config = {}) => ({
|
||||
open: false,
|
||||
loading: false,
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
unreadUrl: config.unreadUrl || '/notifications/unread',
|
||||
markReadUrl: config.markReadUrl || '/notifications/__ID__/read',
|
||||
markAllReadUrl: config.markAllReadUrl || '/notifications/mark-all-read',
|
||||
indexUrl: config.indexUrl || '/notifications',
|
||||
csrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
|
||||
init() {
|
||||
this.fetchUnread();
|
||||
setInterval(() => this.fetchUnread(), 60000);
|
||||
},
|
||||
|
||||
async fetchUnread() {
|
||||
try {
|
||||
const res = await fetch(this.unreadUrl, {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await res.json();
|
||||
this.notifications = data.notifications || [];
|
||||
this.unreadCount = data.unread_count || 0;
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch notifications', e);
|
||||
}
|
||||
},
|
||||
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
if (this.open) {
|
||||
this.loading = true;
|
||||
this.fetchUnread().finally(() => this.loading = false);
|
||||
}
|
||||
},
|
||||
|
||||
async markRead(id) {
|
||||
const url = this.markReadUrl.replace('__ID__', id);
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||||
} catch (e) {
|
||||
console.error('Failed to mark notification as read', e);
|
||||
}
|
||||
},
|
||||
|
||||
async markAllRead() {
|
||||
try {
|
||||
await fetch(this.markAllReadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
this.notifications = [];
|
||||
this.unreadCount = 0;
|
||||
} catch (e) {
|
||||
console.error('Failed to mark all notifications as read', e);
|
||||
}
|
||||
},
|
||||
|
||||
getIconBg(icon) {
|
||||
const map = {
|
||||
website: 'bg-blue-50',
|
||||
domain: 'bg-emerald-50',
|
||||
hosting: 'bg-violet-50',
|
||||
email: 'bg-pink-50',
|
||||
billing: 'bg-amber-50',
|
||||
lead: 'bg-cyan-50',
|
||||
success: 'bg-green-50',
|
||||
};
|
||||
return map[icon] || 'bg-slate-100';
|
||||
},
|
||||
|
||||
getIconColor(icon) {
|
||||
const map = {
|
||||
website: 'text-blue-600',
|
||||
domain: 'text-emerald-600',
|
||||
hosting: 'text-violet-600',
|
||||
email: 'text-pink-600',
|
||||
billing: 'text-amber-600',
|
||||
lead: 'text-cyan-600',
|
||||
success: 'text-green-600',
|
||||
};
|
||||
return map[icon] || 'text-slate-500';
|
||||
},
|
||||
}));
|
||||
|
||||
Alpine.data('balanceGate', (config = {}) => ({
|
||||
balance: Number(config.balance ?? 0),
|
||||
price: Number(config.price ?? 0),
|
||||
modalId: config.modalId ?? null,
|
||||
topupUrl: config.topupUrl ?? null,
|
||||
href: config.href ?? null,
|
||||
requiresPayment: Object.prototype.hasOwnProperty.call(config, 'requiresPayment')
|
||||
? config.requiresPayment !== false
|
||||
: true,
|
||||
needsTopupFor(requiresPayment = this.requiresPayment) {
|
||||
if (requiresPayment === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.balance <= 0 || this.balance < this.price;
|
||||
},
|
||||
needsTopup() {
|
||||
return this.needsTopupFor(this.requiresPayment);
|
||||
},
|
||||
openTopup() {
|
||||
if (this.topupUrl) {
|
||||
window.location.href = this.topupUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
if (! this.modalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('open-modal', {
|
||||
detail: this.modalId,
|
||||
bubbles: true,
|
||||
}));
|
||||
},
|
||||
goOrTopup(event, href = null, requiresPayment = null) {
|
||||
const paymentRequired = requiresPayment === null
|
||||
? this.requiresPayment
|
||||
: requiresPayment !== false;
|
||||
|
||||
if (this.needsTopupFor(paymentRequired)) {
|
||||
event?.preventDefault();
|
||||
this.openTopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = href ?? this.href;
|
||||
if (target) {
|
||||
window.location.href = target;
|
||||
}
|
||||
},
|
||||
submitOrTopup(event, form) {
|
||||
if (this.needsTopup()) {
|
||||
event?.preventDefault();
|
||||
this.openTopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event?.type === 'submit') {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetForm = form || event?.target?.closest?.('form') || this.$refs?.createForm;
|
||||
targetForm?.requestSubmit();
|
||||
},
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
@include('partials.afia')
|
||||
@include('partials.sso-keepalive')
|
||||
@include('partials.confirm-prompt')
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,90 @@
|
||||
@extends('mail.notifications.layout')
|
||||
|
||||
@section('email-header')
|
||||
@include('mail.partials.brand-header', ['brand' => 'domains'])
|
||||
@endsection
|
||||
|
||||
@section('email-footer')
|
||||
@include('mail.partials.brand-footer', ['brand' => 'domains'])
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="highlight-box highlight-box-success">
|
||||
<p class="highlight-box-text">Domain Verified!</p>
|
||||
<p class="highlight-box-subtext">{{ $domain->host }} is now active</p>
|
||||
</div>
|
||||
|
||||
<h1 class="email-title">Your Domain is Ready</h1>
|
||||
|
||||
<p class="email-text">
|
||||
Great news! Your domain <strong>{{ $domain->host }}</strong> has been successfully verified
|
||||
and is now fully active on Ladill.
|
||||
</p>
|
||||
|
||||
<div class="email-details">
|
||||
<p class="email-details-title">What's Been Set Up</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 12px 0; border-bottom: 1px solid #e2e8f0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td width="32" valign="top">
|
||||
<span style="display: inline-block; width: 24px; height: 24px; background-color: #dcfce7; border-radius: 50%; text-align: center; line-height: 24px; color: #166534;">✓</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong style="color: #0f172a;">DNS Management</strong><br>
|
||||
<span style="color: #64748b; font-size: 14px;">Your DNS records are now managed by Ladill</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 0; border-bottom: 1px solid #e2e8f0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td width="32" valign="top">
|
||||
<span style="display: inline-block; width: 24px; height: 24px; background-color: #dcfce7; border-radius: 50%; text-align: center; line-height: 24px; color: #166534;">✓</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong style="color: #0f172a;">Email Ready</strong><br>
|
||||
<span style="color: #64748b; font-size: 14px;">You can now create professional email addresses</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td width="32" valign="top">
|
||||
<span style="display: inline-block; width: 24px; height: 24px; background-color: #dbeafe; border-radius: 50%; text-align: center; line-height: 24px; color: #1e40af;">⏳</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong style="color: #0f172a;">SSL Certificate</strong><br>
|
||||
<span style="color: #64748b; font-size: 14px;">Being provisioned automatically (usually within minutes)</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="email-text">
|
||||
<strong>What's next?</strong> You can now:
|
||||
</p>
|
||||
|
||||
<ul style="color: #475569; font-size: 16px; line-height: 1.8; padding-left: 20px;">
|
||||
<li>Create email addresses like <strong>hello@{{ $domain->host }}</strong></li>
|
||||
<li>Connect this domain to your website</li>
|
||||
<li>Manage DNS records from your dashboard</li>
|
||||
</ul>
|
||||
|
||||
<p style="text-align: center; margin-top: 32px;">
|
||||
<a href="{{ $manageUrl }}" class="email-button">Manage Domain</a>
|
||||
<br>
|
||||
<a href="{{ $emailUrl }}" class="email-button-secondary" style="margin-top: 12px;">Create Email Address</a>
|
||||
</p>
|
||||
@endsection
|
||||
@@ -0,0 +1,37 @@
|
||||
@extends('mail.notifications.layout')
|
||||
|
||||
@section('email-header')
|
||||
@include('mail.partials.brand-header', ['brand' => 'events'])
|
||||
@endsection
|
||||
|
||||
@section('email-footer')
|
||||
@include('mail.partials.brand-footer', ['brand' => 'events'])
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="highlight-box highlight-box-success">
|
||||
<p class="highlight-box-text">📋 Programme Outline</p>
|
||||
<p class="highlight-box-subtext">{{ $eventName }}</p>
|
||||
</div>
|
||||
|
||||
<h1 class="email-title">Here's the programme{{ $attendeeName ? ', ' . explode(' ', $attendeeName)[0] : '' }}!</h1>
|
||||
|
||||
<p class="email-text">
|
||||
The organiser of <strong>{{ $eventName }}</strong> has shared the event programme outline with you.
|
||||
Tap below to view the full schedule.
|
||||
</p>
|
||||
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin:24px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="{{ $programmeUrl }}" class="email-button" style="display:inline-block;background:#4f46e5;color:#ffffff;text-decoration:none;padding:12px 28px;border-radius:10px;font-size:14px;font-weight:600;">
|
||||
View programme
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p class="email-text" style="font-size:13px;color:#6b7280;">
|
||||
Or open this link: <a href="{{ $programmeUrl }}" style="color:#4f46e5;">{{ $programmeUrl }}</a>
|
||||
</p>
|
||||
@endsection
|
||||
@@ -0,0 +1,64 @@
|
||||
@extends('mail.notifications.layout')
|
||||
|
||||
@section('email-header')
|
||||
@include('mail.partials.brand-header', ['brand' => 'hosting'])
|
||||
@endsection
|
||||
|
||||
@section('email-footer')
|
||||
@include('mail.partials.brand-footer', ['brand' => 'hosting'])
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<h1 class="email-title">Your hosting is now active! 🖥️</h1>
|
||||
|
||||
<p class="email-text">
|
||||
Your hosting plan has been activated and is ready to use. You can now start uploading files,
|
||||
creating databases, and hosting your websites.
|
||||
</p>
|
||||
|
||||
<div class="highlight-box">
|
||||
<p class="highlight-box-text">{{ $planName }}</p>
|
||||
<p class="highlight-box-subtext">Your hosting is live and ready</p>
|
||||
</div>
|
||||
|
||||
<div class="email-details">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 10px 0; border-bottom: 1px solid #e2e8f0;">
|
||||
<span style="color: #64748b; font-size: 14px;">Plan</span><br>
|
||||
<strong style="color: #0f172a;">{{ $planName }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
@if (!empty($domainName))
|
||||
<tr>
|
||||
<td style="padding: 10px 0; border-bottom: 1px solid #e2e8f0;">
|
||||
<span style="color: #64748b; font-size: 14px;">Domain</span><br>
|
||||
<strong style="color: #0f172a;">{{ $domainName }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<td style="padding: 10px 0; border-bottom: 1px solid #e2e8f0;">
|
||||
<span style="color: #64748b; font-size: 14px;">Activation Date</span><br>
|
||||
<strong style="color: #0f172a;">{{ $activationDate }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 0;">
|
||||
<span style="color: #64748b; font-size: 14px;">Status</span><br>
|
||||
<span class="status-badge status-success">Active</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ $manageUrl }}" class="email-button">Manage Hosting</a>
|
||||
</p>
|
||||
|
||||
<div class="email-divider"></div>
|
||||
|
||||
<p class="email-text" style="font-size: 14px;">
|
||||
<strong>Getting started:</strong> Access your control panel to manage files, databases, email accounts, and more.
|
||||
</p>
|
||||
@endsection
|
||||
@@ -0,0 +1,47 @@
|
||||
@extends('mail.notifications.layout')
|
||||
|
||||
@section('email-header')
|
||||
@include('mail.partials.brand-header', ['brand' => 'hosting'])
|
||||
@endsection
|
||||
|
||||
@section('email-footer')
|
||||
@include('mail.partials.brand-footer', ['brand' => 'hosting'])
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin: 0 0 16px; font-size: 24px; font-weight: 700; color: #0f172a;">You were added to a hosting team</h1>
|
||||
<p style="margin: 0 0 16px; color: #475569; font-size: 15px; line-height: 1.7;">
|
||||
{{ $ownerName }} granted you developer access to the following Ladill hosting environment{{ count($accountLabels) === 1 ? '' : 's' }}:
|
||||
</p>
|
||||
|
||||
<ul style="margin: 0 0 20px; padding-left: 20px; color: #0f172a; font-size: 15px; line-height: 1.8;">
|
||||
@foreach ($accountLabels as $label)
|
||||
<li>{{ $label }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<p style="margin: 0 0 16px; color: #475569; font-size: 15px; line-height: 1.7;">
|
||||
You now have full access to the hosting panel for {{ count($accountLabels) === 1 ? 'this account' : 'these accounts' }} — manage files, domains, databases, SSL, cron jobs, and more.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 16px; color: #475569; font-size: 15px; line-height: 1.7;">
|
||||
To connect via SSH or SFTP, add your public key from the hosting panel <strong>Settings</strong> page after signing in.
|
||||
</p>
|
||||
|
||||
@if ($setupUrl)
|
||||
<div style="margin: 24px 0;">
|
||||
<a href="{{ $setupUrl }}" style="display: inline-block; background: #4f46e5; color: #ffffff; text-decoration: none; font-weight: 600; padding: 12px 18px; border-radius: 10px;">Set your password</a>
|
||||
</div>
|
||||
<p style="margin: 0 0 16px; color: #64748b; font-size: 14px; line-height: 1.7;">
|
||||
Use the button above to activate your account before signing in.
|
||||
</p>
|
||||
@else
|
||||
<div style="margin: 24px 0;">
|
||||
<a href="{{ $loginUrl }}" style="display: inline-block; background: #4f46e5; color: #ffffff; text-decoration: none; font-weight: 600; padding: 12px 18px; border-radius: 10px;">Sign in to Ladill</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p style="margin: 0; color: #64748b; font-size: 14px; line-height: 1.7;">
|
||||
After signing in, open your dashboard here: <a href="{{ $dashboardUrl }}" style="color: #4f46e5;">{{ $dashboardUrl }}</a>
|
||||
</p>
|
||||
@endsection
|
||||
@@ -0,0 +1,56 @@
|
||||
@extends('mail.notifications.layout')
|
||||
|
||||
@section('email-header')
|
||||
@include('mail.partials.brand-header', ['brand' => 'hosting'])
|
||||
@endsection
|
||||
|
||||
@section('email-footer')
|
||||
@include('mail.partials.brand-footer', ['brand' => 'hosting'])
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="highlight-box highlight-box-warning">
|
||||
<p class="highlight-box-text">Hosting Expiring Soon</p>
|
||||
<p class="highlight-box-subtext">{{ $account->primary_domain ?: $account->username }}</p>
|
||||
</div>
|
||||
|
||||
<h1 class="email-title">Your Hosting Plan is Expiring Soon</h1>
|
||||
|
||||
<p class="email-text">
|
||||
Your hosting account for <strong>{{ $account->primary_domain ?: $account->username }}</strong> will expire soon.
|
||||
Renew before the expiry date to keep your website online and avoid service interruption.
|
||||
</p>
|
||||
|
||||
<div class="email-details">
|
||||
<p class="email-details-title">Account Details</p>
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Domain / Account</p>
|
||||
<p class="email-details-value">{{ $account->primary_domain ?: $account->username }}</p>
|
||||
</div>
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Expiration Date</p>
|
||||
<p class="email-details-value">{{ $account->expires_at?->format('F j, Y') ?? 'N/A' }}</p>
|
||||
</div>
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Days Remaining</p>
|
||||
<p class="email-details-value">
|
||||
<span class="status-badge status-warning">{{ $daysRemaining }} days</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="email-text">
|
||||
<strong>What happens if hosting expires?</strong><br>
|
||||
Your website will go offline. Your files are kept for a grace period, but the site will not be accessible until you renew.
|
||||
</p>
|
||||
|
||||
<p style="text-align: center; margin-top: 24px;">
|
||||
<a href="{{ $renewUrl }}" class="email-button">Renew Hosting</a>
|
||||
</p>
|
||||
|
||||
<div class="email-divider"></div>
|
||||
|
||||
<p class="email-text-sm">
|
||||
If you have wallet auto-renew enabled and sufficient balance, your plan may renew automatically on the expiry date.
|
||||
</p>
|
||||
@endsection
|
||||
@@ -0,0 +1,59 @@
|
||||
@extends('mail.notifications.layout')
|
||||
|
||||
@section('email-header')
|
||||
@include('mail.partials.brand-header', ['brand' => 'hosting'])
|
||||
@endsection
|
||||
|
||||
@section('email-footer')
|
||||
@include('mail.partials.brand-footer', ['brand' => 'hosting'])
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="highlight-box highlight-box-warning">
|
||||
<p class="highlight-box-text">Account Suspended</p>
|
||||
<p class="highlight-box-subtext">Action Required</p>
|
||||
</div>
|
||||
|
||||
<h1 class="email-title">Your Hosting Account Has Been Suspended</h1>
|
||||
|
||||
<p class="email-text">
|
||||
We regret to inform you that your hosting account has been suspended. This may be due to an overdue payment, terms of service violation, or other issues that require your attention.
|
||||
</p>
|
||||
|
||||
<div class="email-details">
|
||||
<p class="email-details-title">Account Details</p>
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Account</p>
|
||||
<p class="email-details-value">{{ $account->domain ?? $account->name ?? 'Hosting Account' }}</p>
|
||||
</div>
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Suspended On</p>
|
||||
<p class="email-details-value">{{ $account->suspended_at?->format('F j, Y') ?? now()->format('F j, Y') }}</p>
|
||||
</div>
|
||||
@if($reason ?? null)
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Reason</p>
|
||||
<p class="email-details-value">{{ $reason }}</p>
|
||||
</div>
|
||||
@endif
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Status</p>
|
||||
<p class="email-details-value"><span class="status-badge status-warning">Suspended</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="email-text">
|
||||
<strong>What happens next?</strong><br>
|
||||
Your website and services are currently offline. To restore access, please resolve the issue by logging into your dashboard or contacting our support team.
|
||||
</p>
|
||||
|
||||
<p style="text-align: center; margin-top: 24px;">
|
||||
<a href="{{ $dashboardUrl ?? config('app.url') . '/dashboard' }}" class="email-button">Go to Dashboard</a>
|
||||
</p>
|
||||
|
||||
<div class="email-divider"></div>
|
||||
|
||||
<p class="email-text-sm">
|
||||
If you believe this suspension was made in error, please contact our support team immediately. We're here to help resolve this issue as quickly as possible.
|
||||
</p>
|
||||
@endsection
|
||||
@@ -0,0 +1,404 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>{{ $subject ?? 'Ladill Notification' }}</title>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
background-color: #f8fafc;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.email-wrapper {
|
||||
width: 100%;
|
||||
background-color: #f8fafc;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.email-header {
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
padding: 32px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-logo {
|
||||
max-height: 28px;
|
||||
width: auto;
|
||||
}
|
||||
.email-logo-transfer {
|
||||
max-height: 24px;
|
||||
width: auto;
|
||||
}
|
||||
.email-icon-header {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
padding: 48px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.email-icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.email-header-title {
|
||||
color: #ffffff;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.email-header-subtitle {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
.email-body {
|
||||
padding: 40px;
|
||||
}
|
||||
.email-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.email-text {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: #475569;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
.email-text-sm {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
.email-button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 24px 0;
|
||||
box-shadow: 0 4px 14px 0 rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
.email-button-secondary {
|
||||
display: inline-block;
|
||||
background-color: #f1f5f9;
|
||||
color: #0f172a !important;
|
||||
text-decoration: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 8px 4px;
|
||||
}
|
||||
.email-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, #e2e8f0, transparent);
|
||||
margin: 32px 0;
|
||||
}
|
||||
.email-details {
|
||||
background-color: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin: 24px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.email-details-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
.email-details-row {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.email-details-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.email-details-row:first-of-type {
|
||||
padding-top: 0;
|
||||
}
|
||||
.email-details-label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.email-details-value {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
.email-card {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin: 24px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.email-card-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.email-card-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.email-footer {
|
||||
background-color: #0f172a;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-footer-text {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.email-footer-link {
|
||||
color: #a5b4fc;
|
||||
text-decoration: none;
|
||||
}
|
||||
.email-footer-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.email-footer-links {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.email-footer-links a {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
.email-footer-links a:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
.highlight-box {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
border-radius: 12px;
|
||||
padding: 28px;
|
||||
margin: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.highlight-box-text {
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.highlight-box-subtext {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 14px;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
.highlight-box-success {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
|
||||
}
|
||||
.highlight-box-warning {
|
||||
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%);
|
||||
}
|
||||
.highlight-box-info {
|
||||
background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%);
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.status-success {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.status-warning {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.status-info {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.status-error {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
.checklist-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.checklist-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.checklist-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #dcfce7;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.checklist-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #166534;
|
||||
}
|
||||
.checklist-content {
|
||||
flex: 1;
|
||||
}
|
||||
.checklist-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin: 0 0 2px 0;
|
||||
}
|
||||
.checklist-desc {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
.code-block {
|
||||
background-color: #1e293b;
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
margin: 16px 0;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 14px;
|
||||
color: #e2e8f0;
|
||||
word-break: break-all;
|
||||
}
|
||||
.amount-large {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.amount-currency {
|
||||
font-size: 18px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
@media only screen and (max-width: 620px) {
|
||||
.email-wrapper {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.email-container {
|
||||
border-radius: 12px;
|
||||
}
|
||||
.email-header,
|
||||
.email-icon-header {
|
||||
padding: 24px;
|
||||
}
|
||||
.email-body,
|
||||
.email-footer {
|
||||
padding: 28px 24px;
|
||||
}
|
||||
.email-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
.email-header-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
.amount-large {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-wrapper">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<div class="email-container">
|
||||
{{-- Header with Logo --}}
|
||||
<div class="email-header">
|
||||
@hasSection('email-header')
|
||||
@yield('email-header')
|
||||
@else
|
||||
@include('mail.partials.brand-header', ['brand' => 'ladill'])
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@hasSection('icon-header')
|
||||
@yield('icon-header')
|
||||
@endif
|
||||
|
||||
{{-- Email Body --}}
|
||||
<div class="email-body">
|
||||
@yield('content')
|
||||
</div>
|
||||
|
||||
{{-- Footer --}}
|
||||
<div class="email-footer">
|
||||
@hasSection('email-footer')
|
||||
@yield('email-footer')
|
||||
@else
|
||||
@include('mail.partials.brand-footer', ['brand' => 'ladill'])
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,64 @@
|
||||
@extends('mail.notifications.layout')
|
||||
|
||||
@section('email-header')
|
||||
@include('mail.partials.brand-header', ['brand' => 'hosting'])
|
||||
@endsection
|
||||
|
||||
@section('email-footer')
|
||||
@include('mail.partials.brand-footer', ['brand' => 'hosting'])
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="highlight-box highlight-box-warning">
|
||||
<p class="highlight-box-text">⚠️ SSL Certificate Expiring</p>
|
||||
<p class="highlight-box-subtext">{{ $daysUntilExpiry }} days remaining</p>
|
||||
</div>
|
||||
|
||||
<h1 class="email-title">Action May Be Required</h1>
|
||||
|
||||
<p class="email-text">
|
||||
Your SSL certificate for <strong>{{ $domain->host }}</strong> is expiring soon.
|
||||
While we attempt to auto-renew certificates, please verify your domain is still properly configured.
|
||||
</p>
|
||||
|
||||
<div class="email-details">
|
||||
<p class="email-details-title">Certificate Details</p>
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Domain</p>
|
||||
<p class="email-details-value">{{ $domain->host }}</p>
|
||||
</div>
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Status</p>
|
||||
<p class="email-details-value"><span class="status-badge status-warning">Expiring Soon</span></p>
|
||||
</div>
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Expires</p>
|
||||
<p class="email-details-value">{{ $expiresAt ? $expiresAt->format('F j, Y') : 'Unknown' }}</p>
|
||||
</div>
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Days Remaining</p>
|
||||
<p class="email-details-value">{{ $daysUntilExpiry }} days</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="email-text">
|
||||
<strong>What you should check:</strong>
|
||||
</p>
|
||||
|
||||
<ul style="color: #475569; font-size: 16px; line-height: 1.8; padding-left: 20px;">
|
||||
<li>Ensure your domain's nameservers still point to Ladill</li>
|
||||
<li>Verify your domain hasn't expired at your registrar</li>
|
||||
<li>Check that your website is accessible</li>
|
||||
</ul>
|
||||
|
||||
<p style="text-align: center; margin-top: 32px;">
|
||||
<a href="{{ $manageUrl }}" class="email-button">Check Domain Settings</a>
|
||||
</p>
|
||||
|
||||
<div class="email-divider"></div>
|
||||
|
||||
<p class="email-text-sm">
|
||||
If everything looks correct, no action is needed. We'll automatically attempt to renew your certificate.
|
||||
If renewal fails, we'll notify you immediately.
|
||||
</p>
|
||||
@endsection
|
||||
@@ -0,0 +1,64 @@
|
||||
@extends('mail.notifications.layout')
|
||||
|
||||
@section('email-header')
|
||||
@include('mail.partials.brand-header', ['brand' => 'hosting'])
|
||||
@endsection
|
||||
|
||||
@section('email-footer')
|
||||
@include('mail.partials.brand-footer', ['brand' => 'hosting'])
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="highlight-box highlight-box-success">
|
||||
<p class="highlight-box-text">🔒 SSL Certificate Active</p>
|
||||
<p class="highlight-box-subtext">{{ $domain->host }} is now secure</p>
|
||||
</div>
|
||||
|
||||
<h1 class="email-title">Your Site is Secure</h1>
|
||||
|
||||
<p class="email-text">
|
||||
Your SSL certificate for <strong>{{ $domain->host }}</strong> has been successfully provisioned.
|
||||
Your website is now accessible via HTTPS and visitors will see the secure padlock icon.
|
||||
</p>
|
||||
|
||||
<div class="email-details">
|
||||
<p class="email-details-title">Certificate Details</p>
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Domain</p>
|
||||
<p class="email-details-value">{{ $domain->host }}</p>
|
||||
</div>
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Status</p>
|
||||
<p class="email-details-value"><span class="status-badge status-success">Active</span></p>
|
||||
</div>
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Expires</p>
|
||||
<p class="email-details-value">{{ $expiresAt ? $expiresAt->format('F j, Y') : 'Auto-renewing' }}</p>
|
||||
</div>
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Secure URL</p>
|
||||
<p class="email-details-value"><a href="{{ $websiteUrl }}" style="color: #4f46e5;">{{ $websiteUrl }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="email-text">
|
||||
<strong>What this means:</strong>
|
||||
</p>
|
||||
|
||||
<ul style="color: #475569; font-size: 16px; line-height: 1.8; padding-left: 20px;">
|
||||
<li>All traffic to your site is encrypted</li>
|
||||
<li>Visitors see a secure padlock in their browser</li>
|
||||
<li>Better search engine rankings (Google prefers HTTPS)</li>
|
||||
<li>Certificate auto-renews before expiry</li>
|
||||
</ul>
|
||||
|
||||
<p style="text-align: center; margin-top: 32px;">
|
||||
<a href="{{ $websiteUrl }}" class="email-button">Visit Your Secure Site</a>
|
||||
</p>
|
||||
|
||||
<div class="email-divider"></div>
|
||||
|
||||
<p class="email-text-sm">
|
||||
Your SSL certificate will automatically renew before it expires. No action is required from you.
|
||||
</p>
|
||||
@endsection
|
||||
@@ -0,0 +1,20 @@
|
||||
@php
|
||||
$brandKey = $brand ?? 'ladill';
|
||||
$brandConfig = config("mail_brands.brands.{$brandKey}", config('mail_brands.brands.ladill'));
|
||||
$appBase = rtrim((string) ($brandConfig['app_url'] ?? config('app.url')), '/');
|
||||
$dashboardPath = $brandConfig['dashboard_path'] ?? '/';
|
||||
$accountBase = rtrim((string) ($brandConfig['account_url'] ?? config('mail_brands.account_url', 'https://account.ladill.com')), '/');
|
||||
$supportUrl = $brandConfig['support_url'] ?? $accountBase.'/support-tickets';
|
||||
$homeLabel = $brandConfig['home_label'] ?? parse_url($appBase, PHP_URL_HOST) ?: 'ladill.com';
|
||||
@endphp
|
||||
<p class="email-footer-text">
|
||||
You're receiving this email because you have {{ $brandConfig['footer_account'] ?? 'an account with Ladill' }}.
|
||||
</p>
|
||||
<div class="email-footer-links">
|
||||
<a href="{{ $appBase }}{{ $dashboardPath }}">Dashboard</a>
|
||||
<a href="{{ $supportUrl }}">Support</a>
|
||||
<a href="{{ $appBase }}">{{ $homeLabel }}</a>
|
||||
</div>
|
||||
<p class="email-footer-text" style="margin-top: 20px; color: #64748b;">
|
||||
© {{ date('Y') }} Ladill Technologies. All rights reserved.
|
||||
</p>
|
||||
@@ -0,0 +1,7 @@
|
||||
@php
|
||||
$brandKey = $brand ?? 'ladill';
|
||||
$brandConfig = config("mail_brands.brands.{$brandKey}", config('mail_brands.brands.ladill'));
|
||||
$assetBase = rtrim((string) ($brandConfig['asset_url'] ?? config('app.url')), '/');
|
||||
$logoClass = trim((string) ($brandConfig['logo_class'] ?? 'email-logo'));
|
||||
@endphp
|
||||
<img src="{{ $assetBase }}/images/logo/{{ $brandConfig['logo'] }}" alt="{{ $brandConfig['name'] }}" class="{{ $logoClass }}">
|
||||
@@ -0,0 +1,93 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Notifications</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ $subtitle ?? 'Stay updated on your account activity.' }}</p>
|
||||
</div>
|
||||
@if ($notifications->where('read_at', null)->count() > 0)
|
||||
<form method="POST" action="{{ route('notifications.mark-all-read') }}">
|
||||
@csrf
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3.5 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
|
||||
Mark all as read
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white">
|
||||
@forelse ($notifications as $notification)
|
||||
@php
|
||||
$data = $notification->data;
|
||||
$icon = $data['icon'] ?? 'bell';
|
||||
$iconBg = match ($icon) {
|
||||
'domain' => 'bg-emerald-50',
|
||||
'hosting' => 'bg-violet-50',
|
||||
'email' => 'bg-pink-50',
|
||||
'billing' => 'bg-amber-50',
|
||||
'success' => 'bg-green-50',
|
||||
default => 'bg-slate-100',
|
||||
};
|
||||
$iconColor = match ($icon) {
|
||||
'domain' => 'text-emerald-600',
|
||||
'hosting' => 'text-violet-600',
|
||||
'email' => 'text-pink-600',
|
||||
'billing' => 'text-amber-600',
|
||||
'success' => 'text-green-600',
|
||||
default => 'text-slate-500',
|
||||
};
|
||||
@endphp
|
||||
<div class="flex items-start gap-4 border-b border-gray-100 px-5 py-4 last:border-b-0 {{ $notification->read_at ? 'bg-white' : 'bg-indigo-50/30' }}">
|
||||
<span class="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full {{ $iconBg }}">
|
||||
@if ($icon === 'domain' && view()->exists('components.icons.domain-globe'))
|
||||
@include('components.icons.domain-globe', ['class' => 'h-5 w-5 ' . $iconColor])
|
||||
@elseif ($icon === 'email')
|
||||
<svg class="h-5 w-5 {{ $iconColor }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||
@elseif ($icon === 'hosting')
|
||||
<svg class="h-5 w-5 {{ $iconColor }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/></svg>
|
||||
@elseif ($icon === 'billing')
|
||||
<svg class="h-5 w-5 {{ $iconColor }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z"/></svg>
|
||||
@elseif ($icon === 'success')
|
||||
<svg class="h-5 w-5 {{ $iconColor }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
|
||||
@else
|
||||
<svg class="h-5 w-5 {{ $iconColor }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
|
||||
@endif
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $data['title'] ?? 'Notification' }}</p>
|
||||
<p class="mt-0.5 text-sm text-gray-600">{{ $data['message'] ?? '' }}</p>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-gray-400">{{ $notification->created_at->diffForHumans() }}</span>
|
||||
</div>
|
||||
@if (! empty($data['url']))
|
||||
<a href="{{ $data['url'] }}" class="mt-2 inline-flex text-sm font-medium text-indigo-600 hover:text-indigo-700">
|
||||
View details →
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@if (! $notification->read_at)
|
||||
<form method="POST" action="{{ route('notifications.mark-read', $notification->id) }}" class="shrink-0">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600" title="Mark as read">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-5 py-16 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-300" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-sm font-medium text-gray-900">No notifications</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ $emptyMessage ?? "You're all caught up." }}</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
@if ($notifications->hasPages())
|
||||
<div class="mt-4">{{ $notifications->links() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
@extends('layouts.hosting')
|
||||
|
||||
@section('title', 'Notifications')
|
||||
|
||||
@section('content')
|
||||
@include('notifications._list', [
|
||||
'subtitle' => 'Hosting activity for your account.',
|
||||
'emptyMessage' => "You're all caught up. We'll notify you when something happens with your hosting.",
|
||||
])
|
||||
@endsection
|
||||
@@ -0,0 +1,24 @@
|
||||
@props([
|
||||
'compact' => false,
|
||||
'handler' => 'dispatch',
|
||||
])
|
||||
|
||||
@auth
|
||||
<button type="button"
|
||||
@if ($handler === 'openAfia')
|
||||
@click="openAfia()"
|
||||
@else
|
||||
@click="$dispatch('afia-open')"
|
||||
@endif
|
||||
@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>
|
||||
@endauth
|
||||
@@ -0,0 +1,106 @@
|
||||
@php
|
||||
$afiaGreeting = "Hi, I'm Afia 👋 Ask me about giving pages — creating one, accepting donations, payouts, or the 9% platform fee…";
|
||||
$afiaSuggestions = [
|
||||
'How do I create a giving page?',
|
||||
'How do supporters give online?',
|
||||
'When do payouts reach my wallet?',
|
||||
'What is the platform fee?',
|
||||
];
|
||||
@endphp
|
||||
{{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
|
||||
<div x-data="afia({
|
||||
chatUrl: '{{ route('give.afia.chat') }}',
|
||||
csrf: '{{ csrf_token() }}',
|
||||
greeting: @js($afiaGreeting),
|
||||
suggestions: @js($afiaSuggestions),
|
||||
})">
|
||||
<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">Give 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-text="m.text"></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>
|
||||
@@ -0,0 +1,65 @@
|
||||
<div
|
||||
x-cloak
|
||||
x-show="$store.ladillConfirm.open"
|
||||
x-transition:enter="transition-opacity ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition-opacity ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-[60] flex items-end sm:items-center sm:justify-center"
|
||||
style="background: rgba(0,0,0,0.45); backdrop-filter: blur(3px);"
|
||||
@keydown.escape.window="$store.ladillConfirm.answer(false)"
|
||||
>
|
||||
<div class="absolute inset-0" @click="$store.ladillConfirm.answer(false)"></div>
|
||||
|
||||
<div
|
||||
x-show="$store.ladillConfirm.open"
|
||||
class="relative z-10 w-full max-h-[90vh] overflow-y-auto rounded-t-3xl bg-white shadow-2xl sm:mb-6 sm:max-h-[85vh] sm:max-w-md sm:rounded-2xl sm:border sm:border-gray-200"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="translate-y-full sm:translate-y-0 sm:opacity-0 sm:scale-95"
|
||||
x-transition:enter-end="translate-y-0 sm:opacity-100 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="translate-y-0 sm:opacity-100 sm:scale-100"
|
||||
x-transition:leave-end="translate-y-full sm:translate-y-0 sm:opacity-0 sm:scale-95"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex justify-center pb-1 pt-3 sm:hidden">
|
||||
<div class="h-1 w-10 rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-6 pt-2 sm:px-6 sm:pb-6 sm:pt-4">
|
||||
<div class="flex flex-col items-center text-center sm:items-start sm:text-left">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full"
|
||||
:class="$store.ladillConfirm.variant === 'primary' ? 'bg-violet-100 text-violet-600' : 'bg-red-100 text-red-600'">
|
||||
<template x-if="$store.ladillConfirm.variant === 'primary'">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="$store.ladillConfirm.variant !== 'primary'">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/>
|
||||
</svg>
|
||||
</template>
|
||||
</div>
|
||||
<h2 class="mt-4 text-lg font-semibold text-slate-900" x-text="$store.ladillConfirm.title"></h2>
|
||||
<p x-show="$store.ladillConfirm.message" class="mt-2 whitespace-pre-line text-sm leading-relaxed text-slate-500" x-text="$store.ladillConfirm.message"></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-col-reverse gap-2.5 sm:flex-row sm:justify-end">
|
||||
<button type="button"
|
||||
@click="$store.ladillConfirm.answer(false)"
|
||||
class="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
|
||||
x-text="$store.ladillConfirm.cancelLabel">
|
||||
</button>
|
||||
<button type="button"
|
||||
@click="$store.ladillConfirm.answer(true)"
|
||||
class="rounded-xl px-4 py-2.5 text-sm font-semibold text-white transition"
|
||||
:class="$store.ladillConfirm.variant === 'primary' ? 'bg-violet-600 hover:bg-violet-700' : 'bg-red-600 hover:bg-red-700'"
|
||||
x-text="$store.ladillConfirm.confirmLabel">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
@php $faviconVer = @filemtime(public_path('favicon.ico')) ?: '1'; @endphp
|
||||
<link rel="icon" href="{{ asset('favicon.ico') }}?v={{ $faviconVer }}" sizes="any">
|
||||
<link rel="shortcut icon" href="{{ asset('favicon.ico') }}?v={{ $faviconVer }}">
|
||||
@@ -0,0 +1,31 @@
|
||||
@if (session('success'))
|
||||
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)" x-transition class="mx-6 mt-4">
|
||||
<div class="rounded-lg bg-green-50 border border-green-200 p-4">
|
||||
<p class="text-sm text-green-800">{{ session('success') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('error'))
|
||||
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 6000)" x-transition class="mx-6 mt-4">
|
||||
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
|
||||
<p class="text-sm text-red-800">{{ session('error') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('warning'))
|
||||
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 5000)" x-transition class="mx-6 mt-4">
|
||||
<div class="rounded-lg bg-yellow-50 border border-yellow-200 p-4">
|
||||
<p class="text-sm text-yellow-800">{{ session('warning') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('info'))
|
||||
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 5000)" x-transition class="mx-6 mt-4">
|
||||
<div class="rounded-lg bg-blue-50 border border-blue-200 p-4">
|
||||
<p class="text-sm text-blue-800">{{ session('info') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,2 @@
|
||||
@props(['class' => 'h-5 w-5'])
|
||||
<img src="{{ asset('images/ladill-icons/pro.svg') }}?v={{ @filemtime(public_path('images/ladill-icons/pro.svg')) ?: '1' }}" alt="" @class([$class]) width="14" height="14">
|
||||
@@ -0,0 +1,53 @@
|
||||
@php
|
||||
// Shared Ladill app launcher — IDENTICAL across every app/service.
|
||||
// Driven by config/ladill_launcher.php (fully-extracted apps only) + icons
|
||||
// from public/images/launcher-icons/. Omits the current app (matched by
|
||||
// APP_URL host). To replicate elsewhere, copy this file + the config +
|
||||
// the launcher-icons folder verbatim.
|
||||
$sidebar = (bool) ($sidebar ?? false);
|
||||
$selfHost = parse_url((string) config('app.url'), PHP_URL_HOST);
|
||||
$launcherApps = array_values(array_filter(
|
||||
config('ladill_launcher.apps', []),
|
||||
fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost
|
||||
));
|
||||
@endphp
|
||||
@if (! empty($launcherApps))
|
||||
<div class="relative" x-data="{ appsOpen: false }" @click.outside="appsOpen = false" @keydown.escape.window="appsOpen = false">
|
||||
<button type="button" @click="appsOpen = !appsOpen"
|
||||
@class([
|
||||
'inline-flex items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:bg-slate-50',
|
||||
'p-2' => ! $sidebar,
|
||||
'w-full gap-3 px-3 py-2 text-[13px] hover:text-slate-900' => $sidebar,
|
||||
])
|
||||
:class="appsOpen ? 'bg-slate-50 text-slate-900' : ''" aria-label="All apps">
|
||||
<svg @class(['shrink-0', 'h-5 w-5' => ! $sidebar, 'h-[18px] w-[18px]' => $sidebar]) viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4.5 0.5H1.5C0.947715 0.5 0.5 0.947715 0.5 1.5V4.5C0.5 5.05228 0.947715 5.5 1.5 5.5H4.5C5.05228 5.5 5.5 5.05228 5.5 4.5V1.5C5.5 0.947715 5.05228 0.5 4.5 0.5Z"/>
|
||||
<path d="M12.5 0.5H9.5C8.94772 0.5 8.5 0.947715 8.5 1.5V4.5C8.5 5.05228 8.94772 5.5 9.5 5.5H12.5C13.0523 5.5 13.5 5.05228 13.5 4.5V1.5C13.5 0.947715 13.0523 0.5 12.5 0.5Z"/>
|
||||
<path d="M4.5 8.5H1.5C0.947715 8.5 0.5 8.94772 0.5 9.5V12.5C0.5 13.0523 0.947715 13.5 1.5 13.5H4.5C5.05228 13.5 5.5 13.0523 5.5 12.5V9.5C5.5 8.94772 5.05228 8.5 4.5 8.5Z"/>
|
||||
<path d="M12.5 8.5H9.5C8.94772 8.5 8.5 8.94772 8.5 9.5V12.5C8.5 13.0523 8.94772 13.5 9.5 13.5H12.5C13.0523 13.5 13.5 13.0523 13.5 12.5V9.5C13.5 8.94772 13.0523 8.5 12.5 8.5Z"/>
|
||||
</svg>
|
||||
@if ($sidebar)
|
||||
<span class="font-medium">All Ladill apps</span>
|
||||
@endif
|
||||
</button>
|
||||
<div x-show="appsOpen" x-cloak x-transition.origin.top.right
|
||||
@class([
|
||||
'absolute z-50 w-72 rounded-2xl border border-slate-200 bg-white p-3 shadow-xl',
|
||||
'right-0 mt-2' => ! $sidebar,
|
||||
'bottom-full left-0 mb-2' => $sidebar,
|
||||
])>
|
||||
<p class="px-1 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">All apps</p>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
@foreach ($launcherApps as $app)
|
||||
<a href="{{ $app['url'] }}"
|
||||
class="flex flex-col items-center gap-1.5 rounded-xl px-2 py-3 text-center transition hover:bg-indigo-50">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-100">
|
||||
<img src="{{ asset('images/launcher-icons/'.$app['icon']) }}?v={{ @filemtime(public_path('images/launcher-icons/'.$app['icon'])) ?: '1' }}" alt="" class="h-5 w-5 object-contain" width="20" height="20">
|
||||
</span>
|
||||
<span class="text-[11px] font-medium leading-tight text-slate-700">{{ $app['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,46 @@
|
||||
@if(($mailboxLinkReminder['visible'] ?? false) === true)
|
||||
@php
|
||||
$stage = $mailboxLinkReminder['stage'] ?? 'needs_link';
|
||||
$copy = match ($stage) {
|
||||
'needs_domain' => [
|
||||
'title' => 'Set up Ladill Email',
|
||||
'body' => 'Add and verify an email domain before you can create a mailbox and link it to your Ladill account.',
|
||||
'cta' => 'Add email domain',
|
||||
'url' => route('email.domains.index'),
|
||||
],
|
||||
'needs_mailbox' => [
|
||||
'title' => 'Create your Ladill mailbox',
|
||||
'body' => 'Your domain is ready. Create a mailbox, then link it in Settings so Ladill Mail opens with your Ladill sign-in.',
|
||||
'cta' => 'Create mailbox',
|
||||
'url' => route('email.mailboxes.create'),
|
||||
],
|
||||
default => [
|
||||
'title' => 'Link your Ladill mailbox',
|
||||
'body' => 'Your account uses '.$mailboxLinkReminder['account_email'].' — choose a mailbox in Settings to connect Ladill Mail sign-in.',
|
||||
'cta' => 'Link mailbox',
|
||||
'url' => route('account.settings'),
|
||||
],
|
||||
};
|
||||
@endphp
|
||||
<div class="mb-4 rounded-xl border border-indigo-200 bg-gradient-to-r from-indigo-50 to-sky-50 px-4 py-3.5 text-sm text-slate-800 shadow-sm" role="status">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white shadow-sm" aria-hidden="true">
|
||||
@include('partials.ladill-pro-icon', ['class' => 'h-4 w-4'])
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold text-slate-900">{{ $copy['title'] }}</p>
|
||||
<p class="mt-1 leading-relaxed text-slate-600">{{ $copy['body'] }}</p>
|
||||
<a href="{{ $copy['url'] }}" class="mt-2.5 inline-flex items-center gap-1 text-sm font-semibold text-indigo-700 hover:text-indigo-800">
|
||||
{{ $copy['cta'] }}
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('account.mailbox-link-banner.dismiss') }}" class="shrink-0">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg p-1.5 text-slate-400 transition hover:bg-white/70 hover:text-slate-600" aria-label="Dismiss for now">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="shrink-0 border-t border-slate-100 bg-slate-50/80 px-4 py-2.5 text-center">
|
||||
<p class="text-[11px] text-slate-400">Secured by Paystack. Powered by Ladill Pay</p>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<div class="relative shrink-0 border-b border-slate-100 px-4 pb-3 pt-3">
|
||||
<div class="absolute left-1/2 top-2 h-1 w-10 -translate-x-1/2 rounded-full bg-slate-200 md:hidden"></div>
|
||||
<div class="flex items-center justify-between gap-3 pt-2 md:pt-0">
|
||||
<img src="{{ asset('images/logo/ladillgive-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladillgive-logo.svg')) ?: '1' }}"
|
||||
alt="Ladill Give" class="h-5 w-auto shrink-0">
|
||||
<button type="button"
|
||||
@click="showSheet = false"
|
||||
class="shrink-0 rounded-full p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
|
||||
aria-label="Close">
|
||||
<svg class="h-4 w-4" 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>
|
||||
@@ -0,0 +1,138 @@
|
||||
@php
|
||||
$gridCols = !empty($centerCompose) ? 'grid-cols-5' : 'grid-cols-4';
|
||||
$avatarUrl = $avatarUrl ?? null;
|
||||
$initials = $initials ?? 'U';
|
||||
$notificationsUrl = $notificationsUrl ?? '#';
|
||||
$unreadUrl = $unreadUrl ?? null;
|
||||
$profileUrl = $profileUrl ?? '#';
|
||||
$profileName = trim((string) ($profileName ?? ''));
|
||||
$profileSubtitle = trim((string) ($profileSubtitle ?? ''));
|
||||
$profileMenuItems = $profileMenuItems ?? [];
|
||||
if ($profileMenuItems === [] && $profileUrl !== '#') {
|
||||
$profileMenuItems = [['type' => 'link', 'label' => 'Profile', 'href' => $profileUrl]];
|
||||
}
|
||||
$searchLabel = $searchLabel ?? 'Search';
|
||||
$navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600';
|
||||
@endphp
|
||||
<div x-data="{ profileOpen: false }" class="lg:hidden">
|
||||
<nav class="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white"
|
||||
style="padding-bottom: env(safe-area-inset-bottom, 0px)">
|
||||
<div class="grid h-16 {{ $gridCols }}">
|
||||
<a href="{{ $homeUrl }}"
|
||||
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($homeActive ?? false) }}">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg>
|
||||
<span class="text-[10px] font-medium">Home</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ $searchUrl }}"
|
||||
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($searchActive ?? false) }}">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||
<span class="text-[10px] font-medium">{{ $searchLabel }}</span>
|
||||
</a>
|
||||
|
||||
@if (!empty($centerCompose))
|
||||
<div class="flex items-center justify-center">
|
||||
<button type="button"
|
||||
@click="$dispatch('compose-open')"
|
||||
class="btn-fab -translate-y-1 ring-4 ring-white shadow-md"
|
||||
aria-label="Compose">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<a href="{{ $notificationsUrl }}"
|
||||
@if ($unreadUrl)
|
||||
x-data="{
|
||||
unread: 0,
|
||||
init() {
|
||||
fetch({{ \Illuminate\Support\Js::from($unreadUrl) }}, {
|
||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
}).then(r => r.json()).then(d => this.unread = d.unread_count || 0).catch(() => {});
|
||||
},
|
||||
}"
|
||||
@endif
|
||||
class="relative flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($notificationsActive ?? false) }}">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
|
||||
@if ($unreadUrl)
|
||||
<span x-show="unread > 0" x-cloak x-text="unread > 9 ? '9+' : unread"
|
||||
class="absolute right-3 top-1.5 inline-flex min-w-4 items-center justify-center rounded-full bg-rose-500 px-1 py-0.5 text-[9px] font-semibold text-white"></span>
|
||||
@endif
|
||||
<span class="text-[10px] font-medium">Notifications</span>
|
||||
</a>
|
||||
|
||||
<button type="button"
|
||||
@click="profileOpen = true"
|
||||
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($profileActive ?? false) }}"
|
||||
:class="profileOpen ? 'text-indigo-600' : ''"
|
||||
aria-label="Open profile menu">
|
||||
@if ($avatarUrl)
|
||||
<img src="{{ $avatarUrl }}" alt="" class="h-7 w-7 rounded-full object-cover ring-1 ring-slate-200">
|
||||
@else
|
||||
<span class="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-900 text-[10px] font-semibold text-white">{{ $initials }}</span>
|
||||
@endif
|
||||
<span class="text-[10px] font-medium">Profile</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{{-- Profile menu bottom sheet (matches desktop avatar dropdown) --}}
|
||||
<div x-show="profileOpen" x-cloak class="fixed inset-0 z-50" @keydown.escape.window="profileOpen = false">
|
||||
<div x-show="profileOpen" x-transition.opacity.duration.200ms
|
||||
@click="profileOpen = false"
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-[1px]"></div>
|
||||
|
||||
<div x-show="profileOpen"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="translate-y-full"
|
||||
x-transition:enter-end="translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="translate-y-0"
|
||||
x-transition:leave-end="translate-y-full"
|
||||
class="absolute inset-x-0 bottom-0 rounded-t-2xl border-t border-slate-200 bg-white shadow-2xl"
|
||||
style="padding-bottom: env(safe-area-inset-bottom, 0px)"
|
||||
@click.outside="profileOpen = false">
|
||||
<div class="flex justify-center pt-3">
|
||||
<span class="h-1 w-10 rounded-full bg-slate-200" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
@if ($profileName !== '' || $profileSubtitle !== '')
|
||||
<div class="flex items-center gap-3 border-b border-slate-100 px-5 py-4">
|
||||
@if ($avatarUrl)
|
||||
<img src="{{ $avatarUrl }}" alt="" class="h-12 w-12 rounded-full object-cover ring-1 ring-slate-200">
|
||||
@else
|
||||
<span class="inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-900 text-sm font-semibold text-white">{{ $initials }}</span>
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
@if ($profileName !== '')
|
||||
<p class="truncate text-sm font-semibold text-slate-900">{{ $profileName }}</p>
|
||||
@endif
|
||||
@if ($profileSubtitle !== '')
|
||||
<p class="truncate text-xs text-slate-500">{{ $profileSubtitle }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-1 p-2 pb-4">
|
||||
@foreach ($profileMenuItems as $item)
|
||||
@if (($item['type'] ?? 'link') === 'link')
|
||||
<a href="{{ $item['href'] }}"
|
||||
@click="profileOpen = false"
|
||||
class="block rounded-xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-100">
|
||||
{{ $item['label'] }}
|
||||
</a>
|
||||
@elseif (($item['type'] ?? '') === 'logout')
|
||||
<form method="POST" action="{{ $item['action'] }}" class="border-t border-slate-100 pt-2">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="w-full rounded-xl px-4 py-3 text-left text-sm font-medium text-rose-600 transition hover:bg-rose-50">
|
||||
{{ $item['label'] }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
{{-- Mobile header cart icon — only include when the app has a shopping cart. --}}
|
||||
<a href="{{ $cartUrl }}"
|
||||
class="relative inline-flex items-center justify-center rounded-full border border-slate-200 p-2 text-slate-600 transition hover:bg-slate-50 lg:hidden"
|
||||
aria-label="Cart">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/></svg>
|
||||
@if (($cartCount ?? 0) > 0)
|
||||
<span class="absolute -right-1 -top-1 inline-flex min-w-5 items-center justify-center rounded-full bg-slate-900 px-1.5 py-0.5 text-[10px] font-semibold text-white">{{ $cartCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
@@ -0,0 +1,6 @@
|
||||
@php
|
||||
$title = $mobileTopbarTitle ?? 'Ladill';
|
||||
@endphp
|
||||
<div class="min-w-0 flex-1 lg:hidden">
|
||||
<h1 class="truncate text-base font-semibold text-slate-900">{{ $title }}</h1>
|
||||
</div>
|
||||
@@ -0,0 +1,106 @@
|
||||
{{-- In-app notification bell + dropdown (scoped to this app). --}}
|
||||
<div class="relative hidden lg:block"
|
||||
x-data="notificationDropdown({
|
||||
unreadUrl: {{ \Illuminate\Support\Js::from(route('notifications.unread')) }},
|
||||
markReadUrl: {{ \Illuminate\Support\Js::from(route('notifications.mark-read', ['id' => '__ID__'])) }},
|
||||
markAllReadUrl: {{ \Illuminate\Support\Js::from(route('notifications.mark-all-read')) }},
|
||||
indexUrl: {{ \Illuminate\Support\Js::from(route('notifications.index')) }},
|
||||
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
||||
})"
|
||||
@click.outside="open = false">
|
||||
<button type="button"
|
||||
@click="toggle()"
|
||||
class="relative inline-flex items-center justify-center rounded-full border border-slate-200 p-2.5 text-slate-600 transition hover:bg-slate-50"
|
||||
aria-label="Notifications">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.7" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/>
|
||||
</svg>
|
||||
<span x-show="unreadCount > 0"
|
||||
x-cloak
|
||||
x-text="unreadCount > 9 ? '9+' : unreadCount"
|
||||
class="absolute -right-1 -top-1 inline-flex min-w-5 items-center justify-center rounded-full bg-rose-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"></span>
|
||||
</button>
|
||||
|
||||
<div x-show="open"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 z-50 mt-2 w-80 origin-top-right rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Notifications</h3>
|
||||
<button type="button"
|
||||
x-show="unreadCount > 0"
|
||||
@click="markAllRead()"
|
||||
class="text-xs font-medium text-indigo-600 hover:text-indigo-700">
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
<template x-if="loading">
|
||||
<div class="px-4 py-6 text-center">
|
||||
<svg class="mx-auto h-5 w-5 animate-spin text-slate-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && notifications.length === 0">
|
||||
<div class="px-4 py-8 text-center">
|
||||
<svg class="mx-auto h-10 w-10 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-slate-500">No notifications yet</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && notifications.length > 0">
|
||||
<div class="divide-y divide-slate-100">
|
||||
<template x-for="notification in notifications" :key="notification.id">
|
||||
<a :href="notification.url || '#'"
|
||||
@click="markRead(notification.id)"
|
||||
class="flex items-start gap-3 px-4 py-3 transition hover:bg-slate-50">
|
||||
<span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
:class="getIconBg(notification.icon)">
|
||||
<template x-if="notification.icon === 'domain'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.264.26-2.465.727-3.556"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'hosting'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'email'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'billing'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'success'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
|
||||
</template>
|
||||
<template x-if="!['domain','hosting','email','billing','success'].includes(notification.icon)">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
|
||||
</template>
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-slate-900 truncate" x-text="notification.title"></p>
|
||||
<p class="text-xs text-slate-500 line-clamp-2" x-text="notification.message"></p>
|
||||
<p class="mt-1 text-[11px] text-slate-400" x-text="notification.created_at"></p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-100 px-4 py-2.5">
|
||||
<a :href="indexUrl" class="block text-center text-xs font-medium text-slate-600 hover:text-slate-900">
|
||||
View all notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
{{--
|
||||
Paystack mobile bottom-sheet iframe.
|
||||
Requires Alpine.js ancestor with: showSheet (bool), checkoutUrl (string).
|
||||
On mobile (< md) the sheet slides up; on desktop nothing renders.
|
||||
--}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="showSheet"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-[9999] flex flex-col justify-end md:hidden"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
|
||||
<div class="absolute inset-0 bg-black/60"
|
||||
x-show="showSheet"
|
||||
x-transition:enter="transition-opacity duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition-opacity duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@click="showSheet = false">
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col overflow-hidden rounded-t-2xl bg-white"
|
||||
style="height:90dvh; padding-bottom: env(safe-area-inset-bottom, 0px)"
|
||||
x-show="showSheet"
|
||||
x-transition:enter="transition-transform duration-300 ease-out"
|
||||
x-transition:enter-start="translate-y-full"
|
||||
x-transition:enter-end="translate-y-0"
|
||||
x-transition:leave="transition-transform duration-200 ease-in"
|
||||
x-transition:leave-start="translate-y-0"
|
||||
x-transition:leave-end="translate-y-full">
|
||||
|
||||
@include('partials.mini-paystack-frame-header')
|
||||
|
||||
<iframe :src="showSheet ? checkoutUrl : ''"
|
||||
class="min-h-0 flex-1 w-full border-0"
|
||||
allow="payment"
|
||||
title="Paystack checkout"></iframe>
|
||||
|
||||
@include('partials.mini-paystack-frame-footer')
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
@php
|
||||
$searchUrl = $searchUrl ?? url('/search');
|
||||
@endphp
|
||||
|
||||
<div class="min-h-full bg-white lg:min-h-0 lg:bg-transparent"
|
||||
x-data="topbarSearch({
|
||||
searchUrl: @js($searchUrl),
|
||||
initialQuery: @js($query),
|
||||
initialResults: @js($results),
|
||||
openOnInit: @js($query !== ''),
|
||||
autoFocus: true
|
||||
})">
|
||||
<x-mobile-page-header :title="$heading" subtitle="Search" :back-url="$backUrl">
|
||||
<div class="px-4 pb-3">
|
||||
<div class="relative">
|
||||
<svg class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||
<input x-ref="input"
|
||||
type="text"
|
||||
x-model="query"
|
||||
@focus="onFocus()"
|
||||
@input.debounce.200ms="search()"
|
||||
@keydown.arrow-down.prevent="moveDown()"
|
||||
@keydown.arrow-up.prevent="moveUp()"
|
||||
@keydown.enter.prevent="go()"
|
||||
placeholder="{{ $placeholder }}"
|
||||
class="w-full rounded-2xl border border-slate-200 bg-slate-50 py-3 pl-12 pr-4 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
</x-mobile-page-header>
|
||||
|
||||
<div class="sticky top-0 z-20 hidden border-b border-slate-200 bg-white/95 backdrop-blur lg:block">
|
||||
<div class="mx-auto max-w-4xl px-6 py-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Search</p>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">{{ $heading }}</h1>
|
||||
<div class="relative mt-3">
|
||||
<svg class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||
<input x-ref="input"
|
||||
type="text"
|
||||
x-model="query"
|
||||
@focus="onFocus()"
|
||||
@input.debounce.200ms="search()"
|
||||
@keydown.arrow-down.prevent="moveDown()"
|
||||
@keydown.arrow-up.prevent="moveUp()"
|
||||
@keydown.enter.prevent="go()"
|
||||
placeholder="{{ $placeholder }}"
|
||||
class="w-full rounded-2xl border border-slate-200 bg-slate-50 py-3 pl-12 pr-4 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-4 pb-28 lg:px-6 lg:py-6 lg:pb-6">
|
||||
<template x-if="loading">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-8 text-center text-sm text-slate-500 shadow-sm">
|
||||
Searching…
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && query.trim().length < 2">
|
||||
<div class="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-5 py-10 text-center">
|
||||
<p class="text-sm font-medium text-slate-900">Start typing to search</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ $emptyHint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && query.trim().length >= 2 && results.length === 0">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white px-5 py-10 text-center shadow-sm">
|
||||
<p class="text-sm font-medium text-slate-900">No results for "<span x-text="query"></span>"</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ $emptyHint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && results.length > 0">
|
||||
<div class="space-y-3">
|
||||
<template x-for="(item, idx) in results" :key="item.url">
|
||||
<a :href="item.url"
|
||||
:class="idx === active ? 'border-indigo-200 bg-indigo-50/70 shadow-sm' : 'border-slate-200 bg-white'"
|
||||
@mouseenter="active = idx"
|
||||
class="flex items-start gap-4 rounded-2xl border px-4 py-4 transition hover:border-indigo-200 hover:bg-indigo-50/60">
|
||||
<span class="mt-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl"
|
||||
:class="{
|
||||
'bg-violet-100 text-violet-600': item.type === 'event',
|
||||
'bg-indigo-100 text-indigo-600': item.type === 'programme',
|
||||
'bg-fuchsia-100 text-fuchsia-600': item.type === 'qr',
|
||||
'bg-teal-100 text-teal-600': item.type === 'hosting',
|
||||
'bg-orange-100 text-orange-600': item.type === 'order',
|
||||
'bg-slate-100 text-slate-600': !['event','programme','qr','hosting','order'].includes(item.type),
|
||||
}">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-semibold text-slate-900" x-text="item.title"></span>
|
||||
<span class="mt-1 block text-sm text-slate-500" x-text="item.subtitle"></span>
|
||||
</span>
|
||||
<svg class="mt-1 h-5 w-5 shrink-0 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m9 5 7 7-7 7"/></svg>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
@php
|
||||
$useInternal = $internal ?? false;
|
||||
if ($useInternal) {
|
||||
$supportUrl = route('user.support-tickets.index');
|
||||
$openExternal = false;
|
||||
} else {
|
||||
$supportUrl = function_exists('ladill_account_url')
|
||||
? ladill_account_url('/support-tickets')
|
||||
: 'https://'.config('app.account_domain', 'account.ladill.com').'/support-tickets';
|
||||
$openExternal = true;
|
||||
}
|
||||
@endphp
|
||||
<a href="{{ $supportUrl }}"
|
||||
@if($openExternal) target="_blank" rel="noopener" @endif
|
||||
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] text-slate-600 transition hover:bg-slate-50 hover:text-slate-900">
|
||||
<svg class="h-[18px] w-[18px] shrink-0 text-slate-400 group-hover:text-slate-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.712 4.33a9.027 9.027 0 0 1 1.652 1.306c.51.51.944 1.064 1.306 1.652M16.712 4.33l-3.448 4.138m3.448-4.138a9.014 9.014 0 0 0-9.424 0M19.67 7.288l-4.138 3.448m4.138-3.448a9.014 9.014 0 0 1 0 9.424m-4.138-5.976a3.736 3.736 0 0 0-.88-1.388 3.737 3.737 0 0 0-1.388-.88m2.268 2.268a3.765 3.765 0 0 1 0 2.528m-2.268-4.796a3.765 3.765 0 0 0-2.528 0m4.796 4.796c-.181.506-.475.982-.88 1.388a3.736 3.736 0 0 1-1.388.88m2.268-2.268 4.138 3.448m0 0a9.027 9.027 0 0 1-1.306 1.652c-.51.51-1.064.944-1.652 1.306m0 0-3.448-4.138m3.448 4.138a9.014 9.014 0 0 1-9.424 0m5.976-4.138a3.765 3.765 0 0 1-2.528 0m0 0a3.736 3.736 0 0 1-1.388-.88 3.737 3.737 0 0 1-.88-1.388m2.268 2.268L7.288 19.67m0 0a9.024 9.024 0 0 1-1.652-1.306 9.027 9.027 0 0 1-1.306-1.652m0 0 4.138-3.448M4.33 16.712a9.014 9.014 0 0 1 0-9.424m4.138 5.976a3.765 3.765 0 0 1 0-2.528m0 0c.181-.506.475-.982.88-1.388a3.736 3.736 0 0 1 1.388-.88m-2.268 2.268L4.33 7.288m6.406 1.18L7.288 4.33m0 0a9.024 9.024 0 0 0-1.652 1.306A9.025 9.025 0 0 0 4.33 7.288"/>
|
||||
</svg>
|
||||
<span class="flex-1 truncate">Support</span>
|
||||
@if($openExternal)
|
||||
<span class="text-[10px] text-slate-400" aria-hidden="true">↗</span>
|
||||
@endif
|
||||
</a>
|
||||
@@ -0,0 +1,39 @@
|
||||
<div class="flex h-full flex-col bg-white border-r border-slate-200 text-slate-700">
|
||||
<div class="flex h-16 shrink-0 items-center border-b border-slate-200 px-6">
|
||||
<a href="{{ route('give.dashboard') }}" class="flex items-center">
|
||||
<img src="{{ asset('images/logo/ladillgive-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladillgive-logo.svg')) ?: '1' }}" alt="Ladill Give" class="h-6 w-auto">
|
||||
</a>
|
||||
</div>
|
||||
@php
|
||||
$main = [
|
||||
['name' => 'Overview', 'route' => route('give.dashboard'), 'active' => request()->routeIs('give.dashboard'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12 11.2 3.05c.44-.44 1.15-.44 1.59 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75H9.75v-6a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v6h4.5a.75.75 0 0 0 .75-.75V9.75" />'],
|
||||
['name' => 'Giving Pages', 'route' => route('give.giving-pages.index'), 'active' => request()->routeIs('give.giving-pages.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z"/>'],
|
||||
['name' => 'Donations', 'route' => route('give.donations.index'), 'active' => request()->routeIs('give.donations.*'),
|
||||
'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' => 'Payouts', 'route' => route('give.payouts'), 'active' => request()->routeIs('give.payouts'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z" />'],
|
||||
];
|
||||
@endphp
|
||||
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||
@foreach($main as $item)
|
||||
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? '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 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">{!! $item['icon'] !!}</svg>
|
||||
<span>{{ $item['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
<p class="px-3 pb-1 pt-5 text-[10px] font-bold uppercase tracking-widest text-slate-400">Account</p>
|
||||
@include('partials.sidebar-support')
|
||||
</nav>
|
||||
|
||||
<div class="shrink-0 border-t border-slate-100 px-3 py-3">
|
||||
<a href="{{ route('account.settings') }}"
|
||||
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ request()->routeIs('account.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('account.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-.241.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.991l-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.281Z" /><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>
|
||||
@@ -0,0 +1,13 @@
|
||||
@if (auth()->check())
|
||||
@php $authPing = 'https://'.config('app.auth_domain').'/sso/ping'; @endphp
|
||||
{{-- Same-site pings keep the shared auth.ladill.com session warm while using this app. --}}
|
||||
<iframe src="{{ $authPing }}" hidden width="0" height="0" title=""></iframe>
|
||||
<script>
|
||||
(function () {
|
||||
const pingUrl = @js($authPing);
|
||||
const ping = () => fetch(pingUrl, { credentials: 'include', mode: 'no-cors' }).catch(() => {});
|
||||
ping();
|
||||
setInterval(ping, 5 * 60 * 1000);
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
@@ -0,0 +1,142 @@
|
||||
@php
|
||||
$user = auth()->user();
|
||||
$initials = collect(explode(' ', trim((string) $user?->name)))
|
||||
->filter()
|
||||
->take(2)
|
||||
->map(fn ($part) => strtoupper(substr($part, 0, 1)))
|
||||
->implode('');
|
||||
@endphp
|
||||
|
||||
<header class="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="shrink-0 text-slate-500 hover:text-slate-700 lg:hidden">
|
||||
<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>
|
||||
@include('partials.mobile-topbar-title')
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
@if (isset($accessibleAccounts) && $accessibleAccounts->count() > 1)
|
||||
<div x-data="{ open: false }" class="relative hidden lg:block">
|
||||
<button @click="open = !open" class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">
|
||||
<span class="max-w-[120px] truncate">{{ $actingAccount->name ?? $actingAccount->email }}</span>
|
||||
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7"/></svg>
|
||||
</button>
|
||||
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 z-30 mt-2 w-60 rounded-xl border border-slate-200 bg-white p-1 shadow-lg">
|
||||
<p class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">Switch account</p>
|
||||
@foreach ($accessibleAccounts as $acctOption)
|
||||
<form method="POST" action="{{ route('account.switch') }}">
|
||||
@csrf
|
||||
<input type="hidden" name="account" value="{{ $acctOption->id }}">
|
||||
<button type="submit" class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-50 {{ $acctOption->id === $actingAccount->id ? 'font-semibold text-indigo-700' : 'text-slate-700' }}">
|
||||
<span class="truncate">{{ $acctOption->id === auth()->id() ? 'My account' : ($acctOption->name ?? $acctOption->email) }}</span>
|
||||
@if ($acctOption->id === $actingAccount->id)<span class="text-indigo-600">✓</span>@endif
|
||||
</button>
|
||||
</form>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="relative hidden lg:block"
|
||||
x-data="notificationDropdown({
|
||||
unreadUrl: {{ \Illuminate\Support\Js::from(route('notifications.unread')) }},
|
||||
markReadUrl: {{ \Illuminate\Support\Js::from(route('notifications.mark-read', ['id' => '__ID__'])) }},
|
||||
markAllReadUrl: {{ \Illuminate\Support\Js::from(route('notifications.mark-all-read')) }},
|
||||
indexUrl: {{ \Illuminate\Support\Js::from(route('notifications.index')) }},
|
||||
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
||||
})"
|
||||
@click.outside="open = false">
|
||||
<button type="button"
|
||||
@click="toggle()"
|
||||
class="relative inline-flex items-center justify-center rounded-full border border-slate-200 p-2 text-slate-600 transition hover:bg-slate-50"
|
||||
aria-label="Notifications">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
||||
</svg>
|
||||
<span x-show="unreadCount > 0"
|
||||
x-cloak
|
||||
x-text="unreadCount > 9 ? '9+' : unreadCount"
|
||||
class="absolute -right-1 -top-1 inline-flex min-w-5 items-center justify-center rounded-full bg-rose-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"></span>
|
||||
</button>
|
||||
|
||||
<div x-show="open"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="absolute right-0 z-50 mt-2 w-80 origin-top-right rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Notifications</h3>
|
||||
<button type="button"
|
||||
x-show="unreadCount > 0"
|
||||
@click="markAllRead()"
|
||||
class="text-xs font-medium text-indigo-600 hover:text-indigo-700">
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
<template x-if="loading">
|
||||
<div class="px-4 py-6 text-center text-xs text-slate-400">Loading…</div>
|
||||
</template>
|
||||
<template x-if="!loading && notifications.length === 0">
|
||||
<div class="px-4 py-8 text-center text-sm text-slate-500">No notifications yet</div>
|
||||
</template>
|
||||
<template x-if="!loading && notifications.length > 0">
|
||||
<div class="divide-y divide-slate-100">
|
||||
<template x-for="notification in notifications" :key="notification.id">
|
||||
<a :href="notification.url || '#'"
|
||||
@click="markRead(notification.id)"
|
||||
class="flex items-start gap-3 px-4 py-3 transition hover:bg-slate-50">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-slate-900" x-text="notification.title"></p>
|
||||
<p class="line-clamp-2 text-xs text-slate-500" x-text="notification.message"></p>
|
||||
<p class="mt-1 text-[11px] text-slate-400" x-text="notification.created_at"></p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-100 px-4 py-2.5">
|
||||
<a :href="indexUrl" class="block text-center text-xs font-medium text-slate-600 hover:text-slate-900">
|
||||
View all notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative hidden lg:block" x-data="{ profileOpen: false }" @click.outside="profileOpen = false">
|
||||
<button type="button"
|
||||
@click="profileOpen = !profileOpen"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-2 py-1.5 text-slate-700 hover:bg-slate-50">
|
||||
@if ($user?->avatarUrl())
|
||||
<img src="{{ $user->avatarUrl() }}" alt="Avatar" class="h-8 w-8 rounded-full object-cover ring-1 ring-slate-200">
|
||||
@else
|
||||
<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">
|
||||
{{ $initials !== '' ? $initials : 'U' }}
|
||||
</span>
|
||||
@endif
|
||||
<svg class="h-4 w-4 text-slate-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div x-show="profileOpen" x-cloak x-transition @click.outside="profileOpen = false"
|
||||
class="absolute right-0 z-20 mt-2 w-48 rounded-xl border border-slate-200 bg-white p-1 shadow-lg">
|
||||
<a href="{{ route('account.settings') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Settings</a>
|
||||
<a href="{{ ladill_account_url('account-settings') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Account Settings</a>
|
||||
<a href="{{ route('give.dashboard') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Dashboard</a>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="w-full rounded-lg px-3 py-2 text-left text-sm text-rose-600 hover:bg-rose-50">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@auth
|
||||
@include('partials.afia-button', ['compact' => true])
|
||||
@endauth
|
||||
|
||||
@include('partials.launcher')
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,526 @@
|
||||
@php
|
||||
$user = auth()->user();
|
||||
$initials = collect(explode(' ', trim((string) $user?->name)))
|
||||
->filter()
|
||||
->take(2)
|
||||
->map(fn ($part) => strtoupper(substr($part, 0, 1)))
|
||||
->implode('');
|
||||
|
||||
$rcCartCount = 0;
|
||||
$domainCartCount = 0;
|
||||
$cartCount = 0;
|
||||
$cartRoute = route('user.products.cart');
|
||||
if ($user) {
|
||||
$rcCartCount = \App\Models\RcServiceOrder::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('status', [
|
||||
\App\Models\RcServiceOrder::STATUS_CART,
|
||||
\App\Models\RcServiceOrder::STATUS_PENDING_PAYMENT,
|
||||
])
|
||||
->count();
|
||||
|
||||
$domainCartCount = \App\Models\DomainOrder::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('status', [
|
||||
\App\Models\DomainOrder::STATUS_CART,
|
||||
\App\Models\DomainOrder::STATUS_PENDING_PAYMENT,
|
||||
])
|
||||
->count();
|
||||
|
||||
$cartCount = $rcCartCount + $domainCartCount;
|
||||
|
||||
if ($rcCartCount === 0 && $domainCartCount > 0) {
|
||||
$cartRoute = route('user.domains.cart');
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
<header class="flex items-center justify-between h-16 border-b border-slate-200 bg-white px-6"
|
||||
x-data="afiaChat({
|
||||
chatUrl: {{ \Illuminate\Support\Js::from(route('user.ai.chat')) }},
|
||||
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
||||
currentPage: {{ \Illuminate\Support\Js::from(request()->path()) }},
|
||||
})"
|
||||
@keydown.escape.window="handleEscape()">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden shrink-0 text-slate-500 hover:text-slate-700">
|
||||
<svg class="w-6 h-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>
|
||||
|
||||
{{-- Search Bar --}}
|
||||
<div class="relative hidden w-full max-w-md lg:block" x-data="topbarSearch()" @click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<div class="relative">
|
||||
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||
<input type="text"
|
||||
x-model="query"
|
||||
@focus="onFocus()"
|
||||
@input.debounce.200ms="search()"
|
||||
@keydown.arrow-down.prevent="moveDown()"
|
||||
@keydown.arrow-up.prevent="moveUp()"
|
||||
@keydown.enter.prevent="go()"
|
||||
placeholder="Search domains, hosting, email, SMTP…"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 py-2 pl-9 pr-10 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
|
||||
<kbd class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 rounded-md border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-400 sm:inline-block">/</kbd>
|
||||
</div>
|
||||
|
||||
{{-- Results dropdown --}}
|
||||
<div x-show="open && (results.length > 0 || (query.length >= 2 && !loading))"
|
||||
x-cloak
|
||||
x-transition.opacity.duration.150ms
|
||||
class="absolute left-0 top-full z-30 mt-1.5 w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
<template x-if="loading">
|
||||
<div class="px-4 py-3 text-center text-xs text-slate-400">Searching…</div>
|
||||
</template>
|
||||
<template x-if="!loading && results.length === 0 && query.length >= 2">
|
||||
<div class="px-4 py-3 text-center text-xs text-slate-500">No results for "<span x-text="query" class="font-medium"></span>"</div>
|
||||
</template>
|
||||
<template x-if="!loading && results.length > 0">
|
||||
<ul class="max-h-72 overflow-y-auto py-1">
|
||||
<template x-for="(item, idx) in results" :key="item.url">
|
||||
<li>
|
||||
<a :href="item.url"
|
||||
:class="idx === active ? 'bg-indigo-50 text-indigo-700' : 'text-slate-700'"
|
||||
@mouseenter="active = idx"
|
||||
class="flex items-center gap-3 px-4 py-2.5 text-sm transition hover:bg-indigo-50">
|
||||
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-600': item.type === 'domain',
|
||||
'bg-teal-100 text-teal-600': item.type === 'hosting',
|
||||
'bg-sky-100 text-sky-600': item.type === 'email',
|
||||
'bg-violet-100 text-violet-600': item.type === 'smtp',
|
||||
'bg-amber-100 text-amber-600': item.type === 'ticket',
|
||||
'bg-orange-100 text-orange-600': item.type === 'order',
|
||||
'bg-slate-100 text-slate-600': !['domain','hosting','email','smtp','ticket','order'].includes(item.type),
|
||||
}">
|
||||
<template x-if="item.type === 'domain'">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"/></svg>
|
||||
</template>
|
||||
<template x-if="item.type !== 'domain'">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||
</template>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium leading-tight" x-text="item.title"></span>
|
||||
<span class="block truncate text-xs text-slate-400" x-text="item.subtitle"></span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative hidden lg:block"
|
||||
x-data="notificationDropdown({
|
||||
unreadUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.unread')) }},
|
||||
markReadUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.mark-read', ['id' => '__ID__'])) }},
|
||||
markAllReadUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.mark-all-read')) }},
|
||||
indexUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.index')) }},
|
||||
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
||||
})"
|
||||
@click.outside="open = false">
|
||||
<button type="button"
|
||||
@click="toggle()"
|
||||
class="relative inline-flex items-center justify-center rounded-full border border-slate-200 p-2 text-slate-600 transition hover:bg-slate-50"
|
||||
aria-label="Notifications">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
||||
</svg>
|
||||
<span x-show="unreadCount > 0"
|
||||
x-cloak
|
||||
x-text="unreadCount > 9 ? '9+' : unreadCount"
|
||||
class="absolute -right-1 -top-1 inline-flex min-w-5 items-center justify-center rounded-full bg-rose-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"></span>
|
||||
</button>
|
||||
|
||||
{{-- Notification dropdown --}}
|
||||
<div x-show="open"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 z-50 mt-2 w-80 origin-top-right rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Notifications</h3>
|
||||
<button type="button"
|
||||
x-show="unreadCount > 0"
|
||||
@click="markAllRead()"
|
||||
class="text-xs font-medium text-indigo-600 hover:text-indigo-700">
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
<template x-if="loading">
|
||||
<div class="px-4 py-6 text-center">
|
||||
<svg class="mx-auto h-5 w-5 animate-spin text-slate-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && notifications.length === 0">
|
||||
<div class="px-4 py-8 text-center">
|
||||
<svg class="mx-auto h-10 w-10 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-slate-500">No notifications yet</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && notifications.length > 0">
|
||||
<div class="divide-y divide-slate-100">
|
||||
<template x-for="notification in notifications" :key="notification.id">
|
||||
<a :href="notification.url || '#'"
|
||||
@click="markRead(notification.id)"
|
||||
class="flex items-start gap-3 px-4 py-3 transition hover:bg-slate-50">
|
||||
<span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
:class="getIconBg(notification.icon)">
|
||||
<template x-if="notification.icon === 'website'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15A2.25 2.25 0 0 0 2.25 6.75v10.5A2.25 2.25 0 0 0 4.5 19.5Z"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'domain'">
|
||||
<span class="inline-flex" :class="getIconColor(notification.icon)">
|
||||
{!! \App\Support\DomainGlobeIcon::svg('h-4 w-4') !!}
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'hosting'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'email'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'billing'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'lead'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'success'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
|
||||
</template>
|
||||
<template x-if="!['website','domain','hosting','email','billing','lead','success'].includes(notification.icon)">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
|
||||
</template>
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-slate-900 truncate" x-text="notification.title"></p>
|
||||
<p class="text-xs text-slate-500 line-clamp-2" x-text="notification.message"></p>
|
||||
<p class="mt-1 text-[11px] text-slate-400" x-text="notification.created_at"></p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-100 px-4 py-2.5">
|
||||
<a :href="indexUrl" class="block text-center text-xs font-medium text-slate-600 hover:text-slate-900">
|
||||
View all notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden h-8 w-px bg-slate-200 lg:block"></div>
|
||||
|
||||
<a href="{{ $cartRoute }}"
|
||||
class="hidden items-center gap-2 rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50 lg:inline-flex">
|
||||
<span>Cart</span>
|
||||
@if ($cartCount > 0)
|
||||
<span class="inline-flex min-w-5 items-center justify-center rounded-full bg-slate-900 px-1.5 py-0.5 text-[11px] font-semibold text-white">{{ $cartCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
<div class="relative hidden lg:block">
|
||||
<button type="button"
|
||||
@click="profileOpen = !profileOpen"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-2 py-1.5 text-slate-700 hover:bg-slate-50">
|
||||
@if ($user?->avatarUrl())
|
||||
<img src="{{ $user->avatarUrl() }}" alt="Avatar" class="h-8 w-8 rounded-full object-cover ring-1 ring-slate-200">
|
||||
@else
|
||||
<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">
|
||||
{{ $initials !== '' ? $initials : 'U' }}
|
||||
</span>
|
||||
@endif
|
||||
<svg class="h-4 w-4 text-slate-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div x-show="profileOpen" x-cloak x-transition @click.outside="profileOpen = false"
|
||||
class="absolute right-0 z-20 mt-2 w-48 rounded-xl border border-slate-200 bg-white p-1 shadow-lg">
|
||||
<a href="{{ route('profile.edit') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Profile</a>
|
||||
<a href="{{ route('account.settings') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Account Settings</a>
|
||||
<a href="{{ route('give.dashboard') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Dashboard</a>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="w-full rounded-lg px-3 py-2 text-left text-sm text-rose-600 hover:bg-rose-50">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('partials.mobile-header-cart', [
|
||||
'cartUrl' => $cartRoute,
|
||||
'cartCount' => $cartCount,
|
||||
])
|
||||
|
||||
<button type="button"
|
||||
@click="openAfia()"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-400 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:opacity-95">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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>AI</span>
|
||||
</button>
|
||||
|
||||
{{-- All-apps launcher (shared, config-driven; see resources/views/partials/launcher) --}}
|
||||
@include('partials.launcher')
|
||||
</div>
|
||||
|
||||
{{-- Afia backdrop --}}
|
||||
<div x-show="afiaOpen"
|
||||
style="display: none;"
|
||||
x-transition.opacity.duration.200ms
|
||||
class="fixed inset-0 z-40 bg-black/20 backdrop-blur-[2px]"
|
||||
@click="closeAfia()"></div>
|
||||
|
||||
{{-- Afia slide-over panel --}}
|
||||
<section x-show="afiaOpen"
|
||||
style="display: none;"
|
||||
x-transition:enter="transition transform ease-out duration-250"
|
||||
x-transition:enter-start="translate-x-full"
|
||||
x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transition transform ease-in duration-200"
|
||||
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 sm:rounded-l-2xl">
|
||||
|
||||
{{-- Panel header --}}
|
||||
<div class="flex items-center justify-between 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-h" 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-h)">
|
||||
<animate attributeName="r" values="14;14.8;14" dur="3s" ease="ease-in-out" 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 cx="18" cy="18" r="4" fill="white" opacity=".25">
|
||||
<animate attributeName="r" values="4;5.5;4" dur="2.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".25;.1;.25" dur="2.5s" 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>
|
||||
<circle r=".8" fill="#e0e7ff" opacity=".6">
|
||||
<animateMotion dur="4.5s" repeatCount="indefinite" path="M18,6 A12,12 0 1,0 18.01,6" 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">Online</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<a href="{{ route('user.ai.index') }}"
|
||||
class="rounded-lg px-2.5 py-1.5 text-[11px] font-semibold text-slate-500 transition hover:bg-slate-100 hover:text-slate-700">
|
||||
History
|
||||
</a>
|
||||
<button type="button"
|
||||
@click="closeAfia()"
|
||||
class="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
|
||||
aria-label="Close">
|
||||
<svg class="h-4.5 w-4.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="h-px bg-slate-100"></div>
|
||||
|
||||
{{-- Chat body --}}
|
||||
<div class="flex-1 overflow-y-auto" x-ref="afiaScroll">
|
||||
{{-- Welcome card (shown only before conversation starts) --}}
|
||||
<div x-show="afiaMessages.length <= 1" class="px-5 py-8">
|
||||
<div class="text-center">
|
||||
<span class="mx-auto flex h-20 w-20 items-center justify-center">
|
||||
<svg viewBox="0 0 80 80" class="h-20 w-20" aria-hidden="true">
|
||||
<defs>
|
||||
<radialGradient id="afia-orb-w" cx="38%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#c7d2fe"/>
|
||||
<stop offset="40%" stop-color="#818cf8"/>
|
||||
<stop offset="100%" stop-color="#3730a3"/>
|
||||
</radialGradient>
|
||||
<filter id="afia-glow-w">
|
||||
<feGaussianBlur stdDeviation="3" result="b"/>
|
||||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="40" cy="40" r="28" fill="url(#afia-orb-w)" filter="url(#afia-glow-w)">
|
||||
<animate attributeName="r" values="28;30;28" dur="3s" ease="ease-in-out" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="40" cy="40" r="22" fill="none" stroke="#e0e7ff" stroke-width=".7" opacity=".4">
|
||||
<animate attributeName="r" values="22;25;22" dur="4s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".4;.1;.4" dur="4s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="40" cy="40" r="34" fill="none" stroke="#c7d2fe" stroke-width=".4" opacity=".25">
|
||||
<animate attributeName="r" values="34;37;34" dur="5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".25;.05;.25" dur="5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="40" cy="40" r="8" fill="white" opacity=".18">
|
||||
<animate attributeName="r" values="8;11;8" dur="2.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".18;.06;.18" dur="2.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle r="2" fill="#c7d2fe" opacity=".9">
|
||||
<animateMotion dur="3s" repeatCount="indefinite" path="M40,14 A26,26 0 1,1 39.99,14" rotate="auto"/>
|
||||
</circle>
|
||||
<circle r="1.5" fill="#e0e7ff" opacity=".7">
|
||||
<animateMotion dur="5s" repeatCount="indefinite" path="M40,10 A30,30 0 1,0 40.01,10" rotate="auto"/>
|
||||
</circle>
|
||||
<circle r="1" fill="#a5b4fc" opacity=".5">
|
||||
<animateMotion dur="7s" repeatCount="indefinite" path="M40,6 A34,34 0 1,1 39.99,6" rotate="auto"/>
|
||||
</circle>
|
||||
</svg>
|
||||
</span>
|
||||
<h3 class="mt-5 text-xl font-semibold text-slate-900">How can I help you?</h3>
|
||||
<p class="mt-1.5 text-sm text-slate-400">I can help with websites, domains, hosting, email, and more.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-2">
|
||||
<template x-for="suggestion in afiaSuggestions" :key="suggestion">
|
||||
<button type="button"
|
||||
@click="useSuggestion(suggestion)"
|
||||
:disabled="afiaLoading"
|
||||
class="group flex w-full items-center gap-3 rounded-xl border border-slate-150 bg-white px-4 py-3 text-left text-[13px] font-medium text-slate-700 shadow-sm transition hover:border-indigo-200 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-500 transition group-hover:bg-indigo-100">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5"/></svg>
|
||||
</span>
|
||||
<span x-text="suggestion"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Messages --}}
|
||||
<div x-show="afiaMessages.length > 1" class="space-y-1 px-4 py-4">
|
||||
<template x-for="(message, idx) in afiaMessages" :key="idx">
|
||||
<div class="flex gap-2.5 py-1.5" :class="message.role === 'user' ? 'flex-row-reverse' : ''">
|
||||
{{-- Avatar --}}
|
||||
<template x-if="message.role === 'assistant'">
|
||||
<span class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" class="h-6 w-6" aria-hidden="true">
|
||||
<defs>
|
||||
<radialGradient id="afia-orb-m" 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="12" cy="12" r="10" fill="url(#afia-orb-m)">
|
||||
<animate attributeName="r" values="10;10.6;10" dur="3s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="12" cy="12" r="3" fill="white" opacity=".2">
|
||||
<animate attributeName="r" values="3;4;3" dur="2.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".2;.08;.2" dur="2.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle r=".8" fill="#c7d2fe" opacity=".8">
|
||||
<animateMotion dur="3s" repeatCount="indefinite" path="M12,4 A8,8 0 1,1 11.99,4"/>
|
||||
</circle>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
{{-- Bubble --}}
|
||||
<div class="max-w-[82%] rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed"
|
||||
:class="message.role === 'user'
|
||||
? 'bg-slate-900 text-white rounded-br-md'
|
||||
: 'bg-slate-50 text-slate-700 rounded-bl-md'">
|
||||
<p class="whitespace-pre-line" x-text="message.text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Typing indicator --}}
|
||||
<div x-show="afiaLoading" class="flex gap-2.5 py-1.5">
|
||||
<span class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" class="h-6 w-6" aria-hidden="true">
|
||||
<defs>
|
||||
<radialGradient id="afia-orb-t" 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="12" cy="12" r="10" fill="url(#afia-orb-t)">
|
||||
<animate attributeName="r" values="10;10.6;10" dur="3s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="12" cy="12" r="3" fill="white" opacity=".2">
|
||||
<animate attributeName="r" values="3;4;3" dur="2.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".2;.08;.2" dur="2.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle r=".8" fill="#c7d2fe" opacity=".8">
|
||||
<animateMotion dur="3s" repeatCount="indefinite" path="M12,4 A8,8 0 1,1 11.99,4"/>
|
||||
</circle>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="rounded-2xl rounded-bl-md bg-slate-50 px-4 py-3">
|
||||
<div class="flex items-center 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>
|
||||
|
||||
{{-- Error banner --}}
|
||||
<div x-show="afiaError" x-cloak class="mx-4 mb-2 rounded-lg bg-rose-50 px-3 py-2 text-xs text-rose-600" x-text="afiaError"></div>
|
||||
|
||||
{{-- Input area --}}
|
||||
<div class="border-t border-slate-100 bg-white px-4 pb-4 pt-3">
|
||||
<form @submit.prevent="sendMessage()" class="flex items-end gap-2">
|
||||
<div class="relative flex-1">
|
||||
<input type="text"
|
||||
x-ref="afiaInput"
|
||||
x-model="afiaInput"
|
||||
:disabled="afiaLoading"
|
||||
placeholder="Message Afia..."
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 pr-10 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100/80">
|
||||
</div>
|
||||
<button type="submit"
|
||||
:disabled="afiaLoading || !afiaInput.trim()"
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-900 text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="Send">
|
||||
<svg x-show="!afiaLoading" 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>
|
||||
<svg x-show="afiaLoading" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-2 text-center text-[10px] text-slate-400">Afia can make mistakes. Verify important info.</p>
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
||||
@@ -0,0 +1,2 @@
|
||||
@props(['class' => 'h-8 w-8'])
|
||||
<img src="{{ asset('images/ladill-icons/wordpress.svg') }}?v={{ @filemtime(public_path('images/ladill-icons/wordpress.svg')) ?: '1' }}" alt="WordPress" @class([$class])>
|
||||
@@ -0,0 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@include('partials.favicon')
|
||||
<title>{{ $qrCode->label }}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
background: #f1f5f9;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
#toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.25rem;
|
||||
height: 3.25rem;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
.tb-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
max-width: 65%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tb-download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.9rem;
|
||||
border-radius: 9999px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #cbd5e1;
|
||||
color: #334155;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.tb-download:hover { background: #e2e8f0; }
|
||||
.tb-download svg { width: 0.875rem; height: 0.875rem; flex-shrink: 0; }
|
||||
|
||||
#scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.25rem 0.75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
#loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.875rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8125rem;
|
||||
padding: 4rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
.spinner {
|
||||
width: 2rem; height: 2rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-top-color: #94a3b8;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.pdf-page {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.12);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#bottom {
|
||||
flex-shrink: 0;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.72rem;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="toolbar">
|
||||
<span class="tb-label">{{ $qrCode->label }}</span>
|
||||
@if($allowDownload)
|
||||
<a href="{{ route('qr.public.file', $qrCode->short_code) }}" download class="tb-download">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"/>
|
||||
</svg>
|
||||
Download
|
||||
</a>
|
||||
@else
|
||||
<span></span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div id="scroll">
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
Loading document…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bottom"><span id="page-info"></span></div>
|
||||
|
||||
<script type="module">
|
||||
import * as pdfjsLib from 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.0.379/pdf.min.mjs';
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.0.379/pdf.worker.min.mjs';
|
||||
|
||||
const fileUrl = @json($fileUrl);
|
||||
const scroll = document.getElementById('scroll');
|
||||
const loading = document.getElementById('loading');
|
||||
const pageInfo = document.getElementById('page-info');
|
||||
|
||||
async function main() {
|
||||
const pdf = await pdfjsLib.getDocument(fileUrl).promise;
|
||||
const n = pdf.numPages;
|
||||
|
||||
pageInfo.textContent = n + ' page' + (n !== 1 ? 's' : '');
|
||||
loading.remove();
|
||||
|
||||
const maxW = scroll.clientWidth - 24;
|
||||
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const pg = await pdf.getPage(i);
|
||||
const vp0 = pg.getViewport({ scale: 1 });
|
||||
const scale = Math.min(maxW / vp0.width, 2);
|
||||
const vp = pg.getViewport({ scale });
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'pdf-page';
|
||||
canvas.width = vp.width * dpr;
|
||||
canvas.height = vp.height * dpr;
|
||||
canvas.style.width = vp.width + 'px';
|
||||
canvas.style.height = vp.height + 'px';
|
||||
|
||||
scroll.appendChild(canvas);
|
||||
|
||||
await pg.render({ canvasContext: canvas.getContext('2d'), viewport: pg.getViewport({ scale: scale * dpr }) }).promise;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
loading.innerHTML = '<p style="color:#ef4444;text-align:center;padding:1rem">Failed to load document.</p>';
|
||||
console.error(err);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Thank you for giving</title>
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-emerald-50/40 font-sans antialiased">
|
||||
<main class="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4 py-10 text-center">
|
||||
<div class="rounded-3xl border border-emerald-100 bg-white p-8 shadow-sm">
|
||||
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 text-emerald-600">
|
||||
<svg class="h-7 w-7" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
|
||||
</div>
|
||||
<h1 class="mt-4 text-xl font-bold text-slate-900">Thank you!</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
Your {{ $donation->currency }} {{ number_format($donation->amount_minor / 100, 2) }}
|
||||
{{ $donation->collection_type ? strtolower($donation->collection_type) : 'donation' }} to
|
||||
{{ $qrCode->content()['name'] ?? $qrCode->label }} was received.
|
||||
</p>
|
||||
<p class="mt-4 text-xs text-slate-500">Reference: {{ $donation->reference }}</p>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
@php
|
||||
$c = $qrCode->content();
|
||||
$evColor = $c['brand_color'] ?? '#4f46e5';
|
||||
$evName = $c['name'] ?? $qrCode->label;
|
||||
$isContribution = ($c['mode'] ?? 'ticketing') === 'contributions';
|
||||
$badgeUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=' . urlencode($registration->badge_code);
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ $isContribution ? 'Thank you' : "You're registered" }} — {{ $evName }}</title>
|
||||
@include('partials.favicon')
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 flex items-center justify-center px-4 py-10">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="overflow-hidden rounded-3xl bg-white shadow-xl">
|
||||
<div class="px-6 py-7 text-center" style="background:linear-gradient(160deg, {{ $evColor }} 0%, {{ $evColor }}cc 100%);">
|
||||
<div class="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-white/20">
|
||||
<svg class="h-8 w-8 text-white" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
|
||||
</div>
|
||||
<h1 class="text-xl font-black text-white">{{ $isContribution ? 'Thank you!' : "You're registered!" }}</h1>
|
||||
<p class="mt-1 text-sm text-white/85">{{ $evName }}</p>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-6 text-center">
|
||||
@if($isContribution)
|
||||
<p class="text-sm text-slate-500">Hi <span class="font-semibold text-slate-800">{{ $registration->attendee_name }}</span>, your contribution has been received.</p>
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-slate-200 bg-slate-50 px-5 py-6">
|
||||
<p class="text-[10px] font-bold uppercase tracking-widest text-slate-400">{{ $registration->tier_name }}</p>
|
||||
<p class="mt-1 text-3xl font-black" style="color:{{ $evColor }}">{{ $registration->currency }} {{ number_format($registration->amountCedis(), 2) }}</p>
|
||||
<p class="mt-3 text-[10px] font-bold uppercase tracking-widest text-slate-400">Reference</p>
|
||||
<p class="font-mono text-sm font-bold tracking-[0.15em] text-slate-700">{{ $registration->badge_code }}</p>
|
||||
</div>
|
||||
|
||||
@if(($registration->badge_fields ?? []))
|
||||
<div class="mt-5 space-y-2 text-left text-sm">
|
||||
@foreach(($registration->badge_fields ?? []) as $label => $value)
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2"><span class="text-slate-400">{{ $label }}</span><span class="font-semibold text-slate-800">{{ $value }}</span></div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p class="mt-5 text-xs text-slate-400">A receipt was sent to {{ $registration->attendee_email }}.</p>
|
||||
@else
|
||||
<p class="text-sm text-slate-500">Hi <span class="font-semibold text-slate-800">{{ $registration->attendee_name }}</span>, your spot is confirmed.</p>
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-slate-200 bg-slate-50 px-5 py-5">
|
||||
<img src="{{ $badgeUrl }}" alt="Badge QR" class="mx-auto h-40 w-40 rounded-xl bg-white p-2 shadow-sm">
|
||||
<p class="mt-3 text-[10px] font-bold uppercase tracking-widest text-slate-400">Badge code</p>
|
||||
<p class="font-mono text-2xl font-black tracking-[0.2em]" style="color:{{ $evColor }}">{{ $registration->badge_code }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-2 text-left text-sm">
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2"><span class="text-slate-400">Ticket</span><span class="font-semibold text-slate-800">{{ $registration->tier_name }}</span></div>
|
||||
@if($registration->isPaid())
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2"><span class="text-slate-400">Paid</span><span class="font-semibold text-slate-800">{{ $registration->currency }} {{ number_format($registration->amountCedis(), 2) }}</span></div>
|
||||
@endif
|
||||
@foreach(($registration->badge_fields ?? []) as $label => $value)
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2"><span class="text-slate-400">{{ $label }}</span><span class="font-semibold text-slate-800">{{ $value }}</span></div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<p class="mt-5 text-xs text-slate-400">Show this badge code at check-in. A confirmation was sent to {{ $registration->attendee_email }}.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@include('partials.favicon')
|
||||
<title>QR code inactive</title>
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: system-ui, sans-serif; background: #f8fafc; color: #334155; padding: 2rem; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<h1 style="font-size: 1.25rem; margin: 0 0 0.5rem;">This QR code is not active</h1>
|
||||
<p style="margin: 0; font-size: 0.875rem; color: #64748b;">The owner has paused this link. Try again later.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
||||
<x-user-layout>
|
||||
@php $isContribution = ($qrCode->content()['mode'] ?? 'ticketing') === 'contributions'; @endphp
|
||||
<x-slot name="title">{{ $isContribution ? 'Contributions' : 'Attendees' }} — {{ $qrCode->label }}</x-slot>
|
||||
|
||||
<div class="mx-auto max-w-5xl space-y-6"
|
||||
x-data="{
|
||||
selected: [],
|
||||
toggle(id) { const i = this.selected.indexOf(id); i === -1 ? this.selected.push(id) : this.selected.splice(i, 1); },
|
||||
get qs() { return this.selected.map(id => 'ids[]=' + id).join('&'); },
|
||||
printSelected() { window.open(@js(route('events.badges', $qrCode)) + '?auto=1&' + this.qs, '_blank'); },
|
||||
zplSelected() { window.location = @js(route('events.badges.zpl', $qrCode)) + '?' + this.qs; }
|
||||
}">
|
||||
|
||||
{{-- Flash --}}
|
||||
@foreach(['success', 'error'] as $flash)
|
||||
@if(session($flash))
|
||||
<div class="rounded-xl border px-4 py-3 text-sm {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
|
||||
{{ session($flash) }}
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Header --}}
|
||||
<div>
|
||||
<a href="{{ route('events.show', $qrCode) }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||||
{{ $qrCode->label }}
|
||||
</a>
|
||||
<h1 class="mt-1.5 text-2xl font-bold text-slate-900">{{ $isContribution ? 'Contributions' : 'Attendees' }}</h1>
|
||||
</div>
|
||||
|
||||
{{-- Share programme outline --}}
|
||||
@if($programme)
|
||||
<div class="flex flex-col gap-3 rounded-2xl border border-indigo-100 bg-indigo-50/60 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-100 text-indigo-600">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z"/></svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-900">Programme outline</p>
|
||||
<p class="text-xs text-slate-500">Email & text the <span class="font-medium text-slate-600">{{ $programme->label }}</span> programme link to all confirmed attendees.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('events.attendees.share-programme', $qrCode) }}"
|
||||
x-data="{ busy: false }"
|
||||
@submit="async ($event) => {
|
||||
if (busy) return;
|
||||
if (! await $store.ladillConfirm.ask({
|
||||
title: 'Send programme?',
|
||||
message: 'Send the programme outline to all confirmed attendees by email and SMS?',
|
||||
confirmLabel: 'Send',
|
||||
variant: 'primary',
|
||||
})) { $event.preventDefault(); return; }
|
||||
busy = true;
|
||||
}"
|
||||
class="shrink-0">
|
||||
@csrf
|
||||
<button type="submit" :disabled="busy"
|
||||
class="btn-primary w-full sm:w-auto">
|
||||
<span x-text="busy ? 'Sending…' : 'Share with attendees'">Share with attendees</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Stats --}}
|
||||
<div class="grid {{ $isContribution ? 'grid-cols-2' : 'grid-cols-3' }} gap-3">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($stats['total']) }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $isContribution ? 'Contributions' : 'Registered' }}</p>
|
||||
</div>
|
||||
@unless($isContribution)
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($stats['checked_in']) }}</p>
|
||||
<p class="text-xs text-slate-500">Checked in</p>
|
||||
</div>
|
||||
@endunless
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-2xl font-bold text-slate-900">GHS {{ number_format($stats['revenue'], 2) }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $isContribution ? 'Total raised' : 'Revenue' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Toolbar --}}
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<form method="GET" class="flex items-center gap-2">
|
||||
<input type="text" name="search" value="{{ request('search') }}" placeholder="Search name, email, code…"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<select name="status" onchange="this.form.submit()" class="rounded-xl border border-slate-200 px-3 py-2 text-sm text-slate-600 focus:border-indigo-400 focus:outline-none">
|
||||
<option value="">All</option>
|
||||
<option value="confirmed" @selected(request('status') === 'confirmed')>Confirmed</option>
|
||||
<option value="pending" @selected(request('status') === 'pending')>Pending</option>
|
||||
</select>
|
||||
</form>
|
||||
@unless($isContribution)
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span x-show="selected.length" x-cloak class="text-xs font-medium text-slate-500"><span x-text="selected.length"></span> selected</span>
|
||||
<button type="button" x-show="selected.length" x-cloak @click="printSelected()"
|
||||
class="btn-primary btn-primary-sm">Print selected</button>
|
||||
<button type="button" x-show="selected.length" x-cloak @click="zplSelected()"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-50 transition">ZPL selected</button>
|
||||
<a href="{{ route('events.badges', $qrCode) }}?auto=1" target="_blank"
|
||||
class="rounded-xl bg-slate-900 px-3.5 py-2 text-xs font-semibold text-white hover:bg-slate-800 transition">Print all badges</a>
|
||||
<a href="{{ route('events.badges.zpl', $qrCode) }}"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-50 transition">Download ZPL</a>
|
||||
</div>
|
||||
@endunless
|
||||
</div>
|
||||
|
||||
{{-- Table --}}
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="border-b border-slate-100 bg-slate-50/70 text-left text-xs uppercase tracking-wide text-slate-400">
|
||||
<tr>
|
||||
@unless($isContribution)<th class="px-4 py-3 w-8"></th>@endunless
|
||||
<th class="px-4 py-3">{{ $isContribution ? 'Contributor' : 'Attendee' }}</th>
|
||||
<th class="px-4 py-3">{{ $isContribution ? 'Category' : 'Ticket' }}</th>
|
||||
@unless($isContribution)<th class="px-4 py-3">Badge</th>@endunless
|
||||
<th class="px-4 py-3">Status</th>
|
||||
@unless($isContribution)<th class="px-4 py-3 text-right">Check-in</th>@endunless
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
@forelse($registrations as $reg)
|
||||
<tr class="hover:bg-slate-50/60">
|
||||
@unless($isContribution)
|
||||
<td class="px-4 py-3">
|
||||
@if($reg->status === \App\Models\QrEventRegistration::STATUS_CONFIRMED)
|
||||
<input type="checkbox" :checked="selected.includes({{ $reg->id }})" @change="toggle({{ $reg->id }})"
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
@endif
|
||||
</td>
|
||||
@endunless
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-semibold text-slate-800">{{ $reg->attendee_name }}</p>
|
||||
<p class="text-xs text-slate-400">{{ $reg->attendee_email }}</p>
|
||||
@foreach(($reg->badge_fields ?? []) as $k => $v)
|
||||
<span class="mr-1 inline-block rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">{{ $k }}: {{ $v }}</span>
|
||||
@endforeach
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-slate-700">{{ $reg->tier_name }}</span>
|
||||
@if($reg->isPaid())<p class="text-xs text-slate-400">GHS {{ number_format($reg->amountCedis(), 2) }}</p>@endif
|
||||
</td>
|
||||
@unless($isContribution)
|
||||
<td class="px-4 py-3"><code class="rounded bg-slate-100 px-2 py-0.5 text-xs">{{ $reg->badge_code }}</code></td>
|
||||
@endunless
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {{ $reg->status === 'confirmed' ? 'bg-emerald-50 text-emerald-700' : ($reg->status === 'pending' ? 'bg-amber-50 text-amber-700' : 'bg-slate-100 text-slate-500') }}">
|
||||
{{ ucfirst($reg->status) }}
|
||||
</span>
|
||||
</td>
|
||||
@unless($isContribution)
|
||||
<td class="px-4 py-3 text-right">
|
||||
<form method="POST" action="{{ route('events.attendees.check-in', [$qrCode, $reg]) }}">
|
||||
@csrf @method('PATCH')
|
||||
<button type="submit" class="rounded-lg border px-2.5 py-1 text-xs font-semibold transition {{ $reg->checked_in_at ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50' }}">
|
||||
{{ $reg->checked_in_at ? '✓ In' : 'Check in' }}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
@endunless
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="{{ $isContribution ? 3 : 6 }}" class="px-4 py-12 text-center text-sm text-slate-400">{{ $isContribution ? 'No contributions yet.' : 'No registrations yet.' }}</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($registrations->hasPages())
|
||||
<div class="border-t border-slate-100 px-4 py-3">{{ $registrations->links() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,85 @@
|
||||
@php
|
||||
$c = $qrCode->content();
|
||||
$evColor = $c['brand_color'] ?? '#4f46e5';
|
||||
$evName = $c['name'] ?? $qrCode->label;
|
||||
$hasLogo = !empty($c['logo_path']);
|
||||
$logoUrl = $hasLogo ? route('qr.public.event.logo', $qrCode->short_code) : null;
|
||||
$size = $c['badge_size'] ?? '4x3';
|
||||
// Physical badge dimensions (inches)
|
||||
[$bw, $bh] = match ($size) {
|
||||
'4x6' => ['4in', '6in'],
|
||||
'cr80' => ['3.375in', '2.125in'],
|
||||
default => ['4in', '3in'],
|
||||
};
|
||||
$stack = $size === '4x6'; // tall badge → stack vertically
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@include('partials.favicon')
|
||||
<title>Badges — {{ $evName }}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #e5e7eb; color: #0f172a; }
|
||||
.toolbar { position: sticky; top: 0; display: flex; gap: 10px; justify-content: center; padding: 14px; background: #fff; border-bottom: 1px solid #e2e8f0; }
|
||||
.toolbar button { border: none; border-radius: 10px; padding: 9px 18px; font-size: 13px; font-weight: 700; cursor: pointer; }
|
||||
.btn-print { background: {{ $evColor }}; color: #fff; }
|
||||
.btn-close { background: #f1f5f9; color: #475569; }
|
||||
.sheet { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; padding: 24px; }
|
||||
.badge {
|
||||
width: {{ $bw }}; height: {{ $bh }};
|
||||
background: #fff; border: 1px solid #cbd5e1; border-radius: 10px; overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.badge-head { background: {{ $evColor }}; color: #fff; padding: 8px 10px; display: flex; align-items: center; gap: 7px; }
|
||||
.badge-head img { height: 22px; width: 22px; object-fit: contain; background: #fff; border-radius: 4px; padding: 1px; }
|
||||
.badge-head .ev { font-size: 11px; font-weight: 800; letter-spacing: .02em; line-height: 1.1; }
|
||||
.badge-body { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 6px 10px; }
|
||||
.badge-name { font-size: 22px; font-weight: 900; line-height: 1.05; }
|
||||
.badge-tier { margin-top: 3px; font-size: 11px; font-weight: 700; color: {{ $evColor }}; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.badge-fields { margin-top: 4px; font-size: 9.5px; color: #64748b; }
|
||||
.badge-foot { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; border-top: 1px solid #e2e8f0; }
|
||||
.badge-foot code { font-family: ui-monospace, monospace; font-size: 11px; font-weight: 700; letter-spacing: .12em; }
|
||||
.badge-foot img { height: 44px; width: 44px; }
|
||||
@media print {
|
||||
.toolbar { display: none; }
|
||||
body { background: #fff; }
|
||||
.sheet { padding: 0; gap: 0; }
|
||||
.badge { border: none; border-radius: 0; page-break-after: always; break-after: page; }
|
||||
@page { size: {{ $bw }} {{ $bh }}; margin: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body @if($auto) onload="window.print()" @endif>
|
||||
<div class="toolbar">
|
||||
<button class="btn-print" onclick="window.print()">Print {{ count($registrations) }} badge{{ count($registrations) === 1 ? '' : 's' }}</button>
|
||||
<button class="btn-close" onclick="window.close()">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet">
|
||||
@forelse($registrations as $reg)
|
||||
@php $badgeQr = 'https://api.qrserver.com/v1/create-qr-code/?size=120x120&margin=0&data=' . urlencode($reg->badge_code); @endphp
|
||||
<div class="badge">
|
||||
<div class="badge-head">
|
||||
@if($hasLogo)<img src="{{ $logoUrl }}" alt="">@endif
|
||||
<span class="ev">{{ $evName }}</span>
|
||||
</div>
|
||||
<div class="badge-body">
|
||||
<div class="badge-name">{{ $reg->attendee_name }}</div>
|
||||
<div class="badge-tier">{{ $reg->tier_name }}</div>
|
||||
@if(!empty($reg->badge_fields))
|
||||
<div class="badge-fields">{{ collect($reg->badge_fields)->map(fn($v,$k) => $v)->implode(' · ') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="badge-foot">
|
||||
<code>{{ $reg->badge_code }}</code>
|
||||
<img src="{{ $badgeQr }}" alt="{{ $reg->badge_code }}">
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p style="padding:40px;color:#64748b;">No confirmed attendees to print.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,303 @@
|
||||
<x-user-layout>
|
||||
@php
|
||||
$createType = old('type', $requestedType ?? \App\Models\QrCode::TYPE_EVENT);
|
||||
$isProgramme = $createType === \App\Models\QrCode::TYPE_ITINERARY;
|
||||
$defaultStyle = \App\Support\Qr\QrStyleDefaults::merge(old('style') ?: ($accountDefaultStyle ?? null));
|
||||
@endphp
|
||||
<x-slot name="title">{{ $isProgramme ? 'New programme' : 'Create event' }}</x-slot>
|
||||
|
||||
<div class="mx-auto max-w-6xl space-y-6"
|
||||
x-data="qrCustomizer({
|
||||
previewUrl: @js(route('events.style-preview')),
|
||||
csrf: @js(csrf_token()),
|
||||
shortCode: 'preview',
|
||||
style: @js($defaultStyle),
|
||||
balance: @js((float) $wallet->spendableBalance()),
|
||||
price: @js((float) $pricePerQr),
|
||||
topupUrl: @js($topupUrl),
|
||||
type: @js($createType),
|
||||
wizard: @js(! $isProgramme),
|
||||
})">
|
||||
|
||||
@if(session('error'))
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
{{-- Mobile page header --}}
|
||||
<div class="flex items-center gap-3 py-1 lg:hidden">
|
||||
<a href="{{ $isProgramme ? route('programmes.index') : route('events.index') }}"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white shadow-sm text-slate-500 hover:text-slate-900 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</a>
|
||||
<span class="text-sm font-semibold text-slate-900">{{ $isProgramme ? 'New programme' : 'Create event' }}</span>
|
||||
</div>
|
||||
|
||||
{{-- Desktop page header --}}
|
||||
<div class="hidden lg:block">
|
||||
<a href="{{ $isProgramme ? route('programmes.index') : route('events.index') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||||
{{ $isProgramme ? 'Programmes' : 'Events' }}
|
||||
</a>
|
||||
<h1 class="mt-2 text-2xl font-bold text-slate-900">{{ $isProgramme ? 'New programme' : 'Create event' }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ $isProgramme ? 'Build a schedule outline and share it with attendees.' : 'Set up your event, then design the QR attendees will scan.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@unless($isProgramme)
|
||||
{{-- Event creation wizard steps --}}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-4 sm:px-6">
|
||||
<ol class="flex items-center justify-between gap-2">
|
||||
@foreach(['Event details', 'QR code', 'Review & create'] as $i => $label)
|
||||
@php $n = $i + 1; @endphp
|
||||
<li class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold transition"
|
||||
:class="step >= {{ $n }} ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-400'">{{ $n }}</span>
|
||||
<span class="hidden truncate text-xs font-semibold sm:block"
|
||||
:class="step >= {{ $n }} ? 'text-slate-900' : 'text-slate-400'">{{ $label }}</span>
|
||||
@if($n < 3)
|
||||
<span class="mx-1 hidden h-px flex-1 bg-slate-200 sm:block"></span>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
</div>
|
||||
@endunless
|
||||
|
||||
<form x-ref="createForm" action="{{ route('events.store') }}" method="POST" enctype="multipart/form-data"
|
||||
class="grid grid-cols-1 gap-8"
|
||||
:class="(!wizard || step >= 2) ? 'lg:grid-cols-[380px_1fr]' : ''"
|
||||
@submit="wizard ? wizardSubmit($event) : submitOrTopup($event)">
|
||||
@csrf
|
||||
<input type="hidden" name="type" value="{{ $createType }}">
|
||||
|
||||
{{-- QR preview (step 2 for events, always for programmes) --}}
|
||||
<div class="lg:sticky lg:top-6 lg:self-start"
|
||||
x-show="!wizard || step === 2 || step === 3"
|
||||
x-cloak
|
||||
:class="(!wizard || step >= 2) ? 'block' : 'hidden'">
|
||||
<div class="hidden lg:block">
|
||||
@include('qr-codes.partials.qr-preview-card', ['showDownloads' => false])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5 pb-4 lg:pb-0"
|
||||
:class="wizard && step >= 2 ? 'lg:col-start-2' : (wizard && step === 1 ? 'lg:col-span-full' : '')">
|
||||
|
||||
{{-- Step 1 / Programme: Event details --}}
|
||||
<div x-show="!wizard || step === 1" class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<div class="border-b border-slate-100 bg-slate-50/70 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">{{ $isProgramme ? 'Programme details' : 'Event details' }}</h2>
|
||||
<p class="mt-0.5 text-xs text-slate-400">{{ $isProgramme ? 'Title, schedule, and venue for this outline.' : 'Name, dates, ticketing, and registration settings.' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-5">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-600">Label</label>
|
||||
<input type="text" name="label" value="{{ old('label') }}" required maxlength="120"
|
||||
class="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30"
|
||||
placeholder="{{ $isProgramme ? 'e.g. Annual conference programme' : 'e.g. Tech summit 2026' }}">
|
||||
</div>
|
||||
|
||||
<div x-data="{
|
||||
slug: @js(old('custom_short_code', '')),
|
||||
status: '',
|
||||
timer: null,
|
||||
checkUrl: @js(route('events.check-slug')),
|
||||
baseUrl: @js(\App\Models\QrCode::publicBaseUrl() . '/q'),
|
||||
check() {
|
||||
clearTimeout(this.timer);
|
||||
const s = this.slug.trim();
|
||||
if (s === '') { this.status = ''; return; }
|
||||
if (!/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/.test(s)) { this.status = 'invalid'; return; }
|
||||
this.status = 'checking';
|
||||
this.timer = setTimeout(() => {
|
||||
fetch(this.checkUrl + '?code=' + encodeURIComponent(s))
|
||||
.then(r => r.json())
|
||||
.then(d => { this.status = d.available ? 'available' : 'taken'; })
|
||||
.catch(() => { this.status = ''; });
|
||||
}, 400);
|
||||
},
|
||||
}">
|
||||
<label class="block text-xs font-semibold text-slate-600">Custom link <span class="font-normal text-slate-400">(optional)</span></label>
|
||||
<div class="mt-1.5 flex items-stretch rounded-xl border focus-within:border-indigo-400 focus-within:ring-1 focus-within:ring-indigo-400/30"
|
||||
:class="status === 'available' ? 'border-green-400' : status === 'taken' || status === 'invalid' ? 'border-red-400' : 'border-slate-200'">
|
||||
<span class="flex items-center rounded-l-xl border-r border-slate-200 bg-slate-50 px-3 text-xs text-slate-400 whitespace-nowrap select-none"
|
||||
:class="status === 'available' ? 'border-green-400' : status === 'taken' || status === 'invalid' ? 'border-red-400' : 'border-slate-200'">
|
||||
{{ \App\Models\QrCode::publicBaseUrl() }}/q/
|
||||
</span>
|
||||
<input type="text" name="custom_short_code"
|
||||
x-model="slug"
|
||||
@input="check()"
|
||||
maxlength="20"
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||
class="min-w-0 flex-1 rounded-r-xl bg-white px-3 py-2.5 text-sm placeholder:text-slate-400 focus:outline-none"
|
||||
placeholder="my-brand">
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-1.5 text-[11px]">
|
||||
<template x-if="status === 'checking'"><span class="text-slate-400">Checking…</span></template>
|
||||
<template x-if="status === 'available'">
|
||||
<span class="flex items-center gap-1 text-green-600">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
|
||||
Available
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="status === 'taken'"><span class="text-red-500">Already taken</span></template>
|
||||
<template x-if="status === 'invalid'"><span class="text-red-500">Invalid format</span></template>
|
||||
<template x-if="status === ''"><span class="text-slate-400">Leave blank to auto-generate</span></template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
@include('qr-codes.partials.type-fields-create')
|
||||
</div>
|
||||
|
||||
@unless($isProgramme)
|
||||
<p class="rounded-xl bg-slate-50 px-3.5 py-2.5 text-xs leading-5 text-slate-400">
|
||||
You will design the QR code in the next step. Details can be updated anytime without reprinting.
|
||||
</p>
|
||||
@else
|
||||
<p class="rounded-xl bg-slate-50 px-3.5 py-2.5 text-xs leading-5 text-slate-400">
|
||||
Attendees scan your Ladill event link — update details anytime without reprinting the QR.
|
||||
</p>
|
||||
@endunless
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Step 2: QR customization (events) or inline (programmes) --}}
|
||||
<div x-show="!wizard || step === 2">
|
||||
@include('qr-codes.partials.customization-fields', [
|
||||
'style' => $defaultStyle,
|
||||
'moduleStyles' => $moduleStyles,
|
||||
'cornerOuterStyles' => $cornerOuterStyles,
|
||||
'cornerInnerStyles' => $cornerInnerStyles,
|
||||
'frameStyles' => $frameStyles,
|
||||
])
|
||||
</div>
|
||||
|
||||
{{-- Step 3: Review & create (events only) --}}
|
||||
<div x-show="wizard && step === 3" x-cloak class="space-y-5">
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<div class="border-b border-slate-100 bg-slate-50/70 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Review your event</h2>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Confirm details before publishing. GHS {{ number_format($pricePerQr, 2) }} will be charged from your QR balance.</p>
|
||||
</div>
|
||||
<div class="space-y-3 p-5 text-sm">
|
||||
<div class="flex justify-between gap-4 border-b border-slate-100 pb-3">
|
||||
<span class="text-slate-500">Event</span>
|
||||
<span class="font-semibold text-slate-900 text-right" x-text="reviewEventName()"></span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 border-b border-slate-100 pb-3">
|
||||
<span class="text-slate-500">Admin label</span>
|
||||
<span class="font-semibold text-slate-900 text-right" x-text="reviewLabel()"></span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span class="text-slate-500">QR balance after</span>
|
||||
<span class="font-semibold text-slate-900">GHS <span x-text="Math.max(0, balance - price).toFixed(2)"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden">
|
||||
@include('qr-codes.partials.qr-preview-card', ['showDownloads' => false])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Desktop navigation --}}
|
||||
<div class="hidden gap-3 lg:flex">
|
||||
<button type="button" x-show="wizard && step > 1" x-cloak @click="prevStep()"
|
||||
class="rounded-xl border border-slate-200 px-5 py-3 text-sm font-semibold text-slate-600 hover:bg-slate-50 transition">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" x-show="wizard && step < 3" x-cloak @click="nextStep()"
|
||||
class="btn-primary flex-1">
|
||||
Continue
|
||||
</button>
|
||||
<button type="button" x-show="!wizard || step === 3" @click="wizardSubmit($event)"
|
||||
class="btn-primary flex-1">
|
||||
{{ $isProgramme ? 'Create programme' : 'Create event' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Mobile sticky action bar --}}
|
||||
<div x-show="!showPreviewModal"
|
||||
class="mobile-action-bar lg:hidden">
|
||||
<div class="mobile-action-bar__toolbar">
|
||||
<button type="button" x-show="wizard && step > 1" x-cloak @click="prevStep()"
|
||||
class="rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-600">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" x-show="wizard && (step === 2 || step === 3)" @click="showPreviewModal = true"
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-xl border border-slate-200 bg-slate-50 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-100">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
|
||||
</svg>
|
||||
Preview
|
||||
</button>
|
||||
<button type="button" x-show="wizard && step < 3" x-cloak @click="nextStep()"
|
||||
class="btn-primary flex-1">
|
||||
Continue
|
||||
</button>
|
||||
<button type="button" x-show="!wizard || step === 3" @click="wizardSubmit($event)"
|
||||
class="btn-primary flex-1">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
<div class="mobile-action-bar__inset-fill" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
{{-- Preview modal --}}
|
||||
<div x-show="showPreviewModal" x-cloak
|
||||
x-transition:enter="transition-opacity duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition-opacity duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@keydown.escape.window="showPreviewModal = false"
|
||||
class="fixed inset-0 z-[60] flex flex-col bg-black/60 backdrop-blur-sm lg:hidden"
|
||||
style="padding-bottom: env(safe-area-inset-bottom, 0px)">
|
||||
<div class="flex items-center justify-between px-5 py-4">
|
||||
<span class="text-sm font-semibold text-white">Preview</span>
|
||||
<button type="button" @click="showPreviewModal = false"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-white/20 text-white hover:bg-white/30 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center justify-center p-8">
|
||||
<div class="w-full max-w-xs overflow-hidden rounded-2xl bg-white shadow-2xl"
|
||||
:style="frameStyle === 'thin'
|
||||
? 'box-shadow: 0 0 0 2px ' + frameColor + ', 0 25px 50px -12px rgb(0 0 0 / 0.25)'
|
||||
: 'box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25), 0 0 0 1px rgb(0 0 0 / 0.06)'">
|
||||
<div :class="{
|
||||
'p-[8px]': frameStyle === 'thin',
|
||||
'p-[14px]': frameStyle === 'scan_me' || frameStyle === 'tap_to_scan',
|
||||
'p-4': frameStyle === 'none',
|
||||
}">
|
||||
<div x-ref="qrPreviewModal"
|
||||
class="aspect-square w-full [&>svg]:block [&>svg]:h-full [&>svg]:w-full"></div>
|
||||
<div x-show="frameStyle === 'scan_me'" x-cloak
|
||||
:style="'border-top: 1px solid ' + frameColor + '40; color: ' + frameColor"
|
||||
class="pt-2.5 pb-0.5 text-center">
|
||||
<span x-text="(frameText && frameText.trim() ? frameText : 'SCAN ME').toUpperCase()"
|
||||
class="text-[11px] font-bold tracking-[0.18em]"></span>
|
||||
</div>
|
||||
<div x-show="frameStyle === 'tap_to_scan'" x-cloak class="mt-2.5 flex justify-center pb-0.5">
|
||||
<span x-text="(frameText && frameText.trim() ? frameText : 'TAP TO SCAN').toUpperCase()"
|
||||
:style="'background-color: ' + frameColor + '; color: ' + frameTextColor"
|
||||
class="rounded-full px-5 py-1.5 text-[10px] font-bold tracking-widest"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,86 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Events</x-slot>
|
||||
|
||||
<div class="space-y-6"
|
||||
x-data="balanceGate({
|
||||
balance: @js((float) $wallet->spendableBalance()),
|
||||
price: @js((float) $pricePerQr),
|
||||
topupUrl: @js($topupUrl),
|
||||
href: @js(route('events.create')),
|
||||
})">
|
||||
@foreach(['success', 'error'] as $flash)
|
||||
@if(session($flash))
|
||||
<div class="rounded-lg border px-4 py-3 {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
|
||||
<p class="text-sm">{{ session($flash) }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
<div class="relative overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-violet-50/80 via-white to-indigo-50/60"></div>
|
||||
<div class="relative px-6 py-8 sm:px-10">
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="max-w-xl">
|
||||
<div class="inline-flex items-center gap-1.5 rounded-full bg-violet-100 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||
Tickets · Contributions · Free registration
|
||||
</div>
|
||||
<h1 class="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Your events</h1>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-600">
|
||||
Create ticketed events, manage attendees, print badges, and attach programme outlines.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<button type="button"
|
||||
@click="goOrTopup($event)"
|
||||
class="btn-primary">
|
||||
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Create event
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scrollbar-hide flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 sm:grid sm:grid-cols-3 sm:overflow-visible sm:pb-0 lg:gap-4" style="-ms-overflow-style:none;scrollbar-width:none;">
|
||||
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
|
||||
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">GHS {{ number_format($wallet->spendableBalance(), 2) }}</p>
|
||||
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Account balance</p>
|
||||
</div>
|
||||
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
|
||||
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">{{ $qrCodes->count() }}</p>
|
||||
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Events</p>
|
||||
</div>
|
||||
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
|
||||
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">{{ number_format($totalRegistrations) }}</p>
|
||||
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Registrations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div class="border-b border-slate-100 px-6 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Your events</h2>
|
||||
</div>
|
||||
@if($qrCodes->isEmpty())
|
||||
<p class="px-6 py-10 text-center text-sm text-slate-500">No events yet. Create your first event to get started.</p>
|
||||
@else
|
||||
<div class="divide-y divide-slate-100">
|
||||
@foreach($qrCodes as $code)
|
||||
<a href="{{ route('events.show', $code) }}" class="flex items-center gap-4 px-6 py-4 transition hover:bg-slate-50">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg {{ $code->is_active ? 'bg-violet-100' : 'bg-slate-100 opacity-50' }}">
|
||||
<img src="{{ asset('images/ladill-icons/events.svg') }}?v={{ @filemtime(public_path('images/ladill-icons/events.svg')) ?: '1' }}" alt="" class="h-5 w-5 object-contain" width="20" height="20">
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-slate-900">{{ $code->label }}</p>
|
||||
<p class="truncate text-xs text-slate-500">{{ $code->typeLabel() }} · {{ $code->publicUrl() }}</p>
|
||||
</div>
|
||||
<div class="text-right text-xs text-slate-500">
|
||||
<p class="font-semibold text-slate-900">{{ number_format($code->registrations_count) }} registered</p>
|
||||
<p>{{ $code->is_active ? 'Active' : 'Paused' }}</p>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,530 @@
|
||||
@php
|
||||
$style = \App\Support\Qr\QrStyleDefaults::merge($style ?? null);
|
||||
$moduleStyles = $moduleStyles ?? \App\Support\Qr\QrModuleStyleCatalog::visible();
|
||||
$cornerOuterStyles = $cornerOuterStyles ?? \App\Support\Qr\QrCornerStyleCatalog::outerStyles();
|
||||
$cornerInnerStyles = $cornerInnerStyles ?? \App\Support\Qr\QrCornerStyleCatalog::innerStyles();
|
||||
$frameStyles = $frameStyles ?? \App\Support\Qr\QrFrameStyleCatalog::visible();
|
||||
$qrBits = [1,0,1,0,1, 0,1,1,0,0, 1,1,0,1,1, 0,0,1,0,1, 1,0,1,1,0];
|
||||
@endphp
|
||||
|
||||
@include('qr-codes.partials.qr-customizer-script')
|
||||
|
||||
{{-- ── Quick style presets ──────────────────────────────────────────── --}}
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<div class="border-b border-slate-100 bg-slate-50/70 px-5 py-3.5">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Quick styles</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-px bg-slate-100/80">
|
||||
@foreach([
|
||||
[
|
||||
'key' => 'classic',
|
||||
'label' => 'Classic',
|
||||
'fg' => '#000000',
|
||||
'bg' => '#ffffff',
|
||||
'outer' => 'square',
|
||||
'inner' => 'square',
|
||||
'dot' => false,
|
||||
],
|
||||
[
|
||||
'key' => 'rounded',
|
||||
'label' => 'Rounded',
|
||||
'fg' => '#111827',
|
||||
'bg' => '#ffffff',
|
||||
'outer' => 'rounded',
|
||||
'inner' => 'square',
|
||||
'dot' => false,
|
||||
],
|
||||
[
|
||||
'key' => 'branded',
|
||||
'label' => 'Branded',
|
||||
'fg' => '#1d4ed8',
|
||||
'bg' => '#ffffff',
|
||||
'outer' => 'rounded',
|
||||
'inner' => 'square',
|
||||
'dot' => false,
|
||||
],
|
||||
[
|
||||
'key' => 'dots',
|
||||
'label' => 'Dots',
|
||||
'fg' => '#0f172a',
|
||||
'bg' => '#ffffff',
|
||||
'outer' => 'square',
|
||||
'inner' => 'dot',
|
||||
'dot' => true,
|
||||
],
|
||||
] as $preset)
|
||||
@php
|
||||
$outerRadius = match($preset['outer']) { 'rounded' => '3px', 'circle' => '50%', default => '1px' };
|
||||
$innerRadius = $preset['inner'] === 'dot' ? '50%' : '1px';
|
||||
$dotRadius = $preset['dot'] ? '50%' : '1px';
|
||||
@endphp
|
||||
<button type="button"
|
||||
@click="setStylePreset('{{ $preset['key'] }}')"
|
||||
class="group flex flex-col items-center gap-2.5 bg-white px-3 py-4 text-center transition hover:bg-slate-50/80 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400">
|
||||
|
||||
{{-- Mini QR illustration --}}
|
||||
<span class="relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
||||
style="background-color: {{ $preset['bg'] }}">
|
||||
|
||||
{{-- Top-left finder --}}
|
||||
<span class="absolute left-[4px] top-[4px] flex h-[14px] w-[14px] items-center justify-center"
|
||||
style="border: 2.5px solid {{ $preset['fg'] }}; border-radius: {{ $outerRadius }}; background: transparent">
|
||||
<span class="block h-[5px] w-[5px]"
|
||||
style="background: {{ $preset['fg'] }}; border-radius: {{ $innerRadius }}"></span>
|
||||
</span>
|
||||
|
||||
{{-- Top-right finder --}}
|
||||
<span class="absolute right-[4px] top-[4px] flex h-[14px] w-[14px] items-center justify-center"
|
||||
style="border: 2.5px solid {{ $preset['fg'] }}; border-radius: {{ $outerRadius }}; background: transparent">
|
||||
<span class="block h-[5px] w-[5px]"
|
||||
style="background: {{ $preset['fg'] }}; border-radius: {{ $innerRadius }}"></span>
|
||||
</span>
|
||||
|
||||
{{-- Bottom-left finder --}}
|
||||
<span class="absolute bottom-[4px] left-[4px] flex h-[14px] w-[14px] items-center justify-center"
|
||||
style="border: 2.5px solid {{ $preset['fg'] }}; border-radius: {{ $outerRadius }}; background: transparent">
|
||||
<span class="block h-[5px] w-[5px]"
|
||||
style="background: {{ $preset['fg'] }}; border-radius: {{ $innerRadius }}"></span>
|
||||
</span>
|
||||
|
||||
{{-- Data dots (5×3 grid, avoiding finder corners) --}}
|
||||
<span class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="grid grid-cols-3 gap-[2px] translate-x-[5px]">
|
||||
@foreach([1,0,1,0,1,0,1,1,0] as $bit)
|
||||
<span class="h-[3px] w-[3px]"
|
||||
style="{{ $bit ? 'background:' . $preset['fg'] . ';border-radius:' . $dotRadius : '' }}"></span>
|
||||
@endforeach
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="text-[11px] font-semibold text-slate-600 group-hover:text-slate-900">{{ $preset['label'] }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
|
||||
|
||||
{{-- Pill tab nav --}}
|
||||
<div class="border-b border-slate-100 bg-slate-50/70 px-4 py-3">
|
||||
<div class="flex gap-1 rounded-xl bg-slate-200/50 p-1">
|
||||
@foreach([
|
||||
['id' => 'body', 'label' => 'Body'],
|
||||
['id' => 'colors', 'label' => 'Colors'],
|
||||
['id' => 'eyes', 'label' => 'Eyes'],
|
||||
['id' => 'logo', 'label' => 'Logo'],
|
||||
['id' => 'frame', 'label' => 'Frame'],
|
||||
] as $tab)
|
||||
<button type="button"
|
||||
@click="openSection = '{{ $tab['id'] }}'"
|
||||
:class="openSection === '{{ $tab['id'] }}'
|
||||
? 'bg-white shadow-sm text-slate-900'
|
||||
: 'text-slate-500 hover:text-slate-700'"
|
||||
class="flex-1 rounded-lg py-1.5 text-[11px] font-semibold transition-all duration-150">
|
||||
{{ $tab['label'] }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
|
||||
{{-- ── Body ─────────────────────────────────────────────────────── --}}
|
||||
<div x-show="openSection === 'body'" x-cloak
|
||||
x-transition:enter="transition-opacity duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100">
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Dot shape</p>
|
||||
<div class="grid grid-cols-2 gap-2.5">
|
||||
@foreach($moduleStyles as $key => $meta)
|
||||
<label class="group cursor-pointer">
|
||||
<input type="radio" name="style[module_style]" value="{{ $key }}" x-model="moduleStyle" class="sr-only">
|
||||
<span class="flex items-center gap-3 rounded-xl border-2 bg-white px-3.5 py-3 transition-all"
|
||||
:class="moduleStyle === '{{ $key }}'
|
||||
? 'border-indigo-500 bg-indigo-50/60'
|
||||
: 'border-slate-200 group-hover:border-slate-300'">
|
||||
<span class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
|
||||
@if ($key === 'square')
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-slate-800" fill="currentColor">
|
||||
<path d="M0,0V11H11V0H0ZM7,7h-3v-3h3v3ZM13,0V11h11V0H13Zm7,7h-3v-3h3v3ZM0,13v11H11V13H0Zm7,7h-3v-3h3v3Zm10-3h-4v-4h4v4Zm3,3h-3v-3h3v3Zm-3,4h-4v-4h4v4Zm7-7h-4v-4h4v4Z"/>
|
||||
</svg>
|
||||
@elseif ($key === 'dots')
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-slate-800" fill="currentColor">
|
||||
<path d="M7,0h-3C1.794,0,0,1.794,0,4v3c0,2.206,1.794,4,4,4h3c2.206,0,4-1.794,4-4V4C11,1.794,9.206,0,7,0Zm2,7c0,1.103-.897,2-2,2h-3c-1.103,0-2-.897-2-2V4c0-1.103,.897-2,2-2h3c1.103,0,2,.897,2,2v3Zm-2-2v1c0,.552-.448,1-1,1h-1c-.552,0-1-.448-1-1v-1c0-.552,.448-1,1-1h1c.552,0,1,.448,1,1Zm10,6h3c2.206,0,4-1.794,4-4V4C24,1.794,22.206,0,20,0h-3C14.794,0,13,1.794,13,4v3c0,2.206,1.794,4,4,4Zm-2-7c0-1.103,.897-2,2-2h3c1.103,0,2,.897,2,2v3c0,1.103-.897,2-2,2h-3c-1.103,0-2-.897-2-2V4Zm2,2v-1c0-.552,.448-1,1-1h1c.552,0,1,.448,1,1v1c0,.552-.448,1-1,1h-1c-.552,0-1-.448-1-1ZM7,13h-3c-2.206,0-4,1.794-4,4v3c0,2.206,1.794,4,4,4h3c2.206,0,4-1.794,4-4v-3c0-2.206-1.794-4-4-4Zm2,7c0,1.103-.897,2-2,2h-3c-1.103,0-2-.897-2-2v-3c0-1.103,.897-2,2-2h3c1.103,0,2,.897,2,2v3Zm-2-2v1c0,.552-.448,1-1,1h-1c-.552,0-1-.448-1-1v-1c0-.552,.448-1,1-1h1c.552,0,1,.448,1,1Zm10-3.5v1c0,.828-.672,1.5-1.5,1.5h-1c-.828,0-1.5-.672-1.5-1.5v-1c0-.828,.672-1.5,1.5-1.5h1c.828,0,1.5,.672,1.5,1.5Zm3,4h0c0,.828-.672,1.5-1.5,1.5h0c-.828,0-1.5-.672-1.5-1.5h0c0-.828,.672-1.5,1.5-1.5h0c.828,0,1.5,.672,1.5,1.5Zm-3,3v1c0,.828-.672,1.5-1.5,1.5h-1c-.828,0-1.5-.672-1.5-1.5v-1c0-.828,.672-1.5,1.5-1.5h1c.828,0,1.5,.672,1.5,1.5Zm7-7v1c0,.828-.672,1.5-1.5,1.5h-1c-.828,0-1.5-.672-1.5-1.5v-1c0-.828,.672-1.5,1.5-1.5h1c.828,0,1.5,.672,1.5,1.5Z"/>
|
||||
</svg>
|
||||
@else
|
||||
<span class="grid grid-cols-4 gap-[2px] p-1.5">
|
||||
@foreach($qrBits as $bit)
|
||||
<span class="block aspect-square {{ $bit ? ('bg-slate-800 ' . ($meta['circular'] ? 'rounded-full' : '')) : 'bg-transparent' }}"></span>
|
||||
@endforeach
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
<span class="text-xs font-semibold text-slate-700">{{ $meta['label'] }}</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Colors ───────────────────────────────────────────────────── --}}
|
||||
<div x-show="openSection === 'colors'" x-cloak
|
||||
x-transition:enter="transition-opacity duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
class="space-y-5">
|
||||
<div>
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Quick presets</p>
|
||||
<div class="flex flex-wrap gap-2.5">
|
||||
@foreach([
|
||||
['fg' => '#000000', 'bg' => '#ffffff', 'label' => 'Classic'],
|
||||
['fg' => '#1d4ed8', 'bg' => '#ffffff', 'label' => 'Blue'],
|
||||
['fg' => '#047857', 'bg' => '#ffffff', 'label' => 'Green'],
|
||||
['fg' => '#7c3aed', 'bg' => '#ffffff', 'label' => 'Purple'],
|
||||
['fg' => '#c2410c', 'bg' => '#fff7ed', 'label' => 'Orange'],
|
||||
['fg' => '#be185d', 'bg' => '#fdf4ff', 'label' => 'Pink'],
|
||||
['fg' => '#0f172a', 'bg' => '#ecfdf5', 'label' => 'Dark teal'],
|
||||
] as $preset)
|
||||
<button type="button"
|
||||
@click="fg = '{{ $preset['fg'] }}'; bg = '{{ $preset['bg'] }}'; schedulePreview()"
|
||||
title="{{ $preset['label'] }}"
|
||||
class="h-8 w-8 rounded-full border-2 border-white shadow ring-1 ring-slate-300/60 transition hover:scale-110 hover:ring-indigo-400"
|
||||
style="background-color: {{ $preset['fg'] }}"></button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-semibold text-slate-600">Foreground</label>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50/80 p-2">
|
||||
<input type="color" name="style[foreground]" x-model="fg" @input="schedulePreview()"
|
||||
class="h-9 w-10 shrink-0 cursor-pointer rounded-lg border-0 bg-transparent p-0.5">
|
||||
<input type="text" x-model="fg" @change="schedulePreview()"
|
||||
class="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 font-mono text-sm focus:border-indigo-400 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-semibold text-slate-600">Background</label>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50/80 p-2">
|
||||
<input type="color" name="style[background]" x-model="bg" @input="schedulePreview()"
|
||||
class="h-9 w-10 shrink-0 cursor-pointer rounded-lg border-0 bg-transparent p-0.5">
|
||||
<input type="text" x-model="bg" @change="schedulePreview()"
|
||||
class="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 font-mono text-sm focus:border-indigo-400 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Gradient</p>
|
||||
<input type="hidden" name="style[gradient_type]" :value="gradientType">
|
||||
<div class="flex gap-1 rounded-xl bg-slate-200/50 p-1">
|
||||
@foreach([['none', 'None'], ['linear', 'Linear'], ['radial', 'Radial']] as [$val, $lbl])
|
||||
<button type="button"
|
||||
@click="gradientType = '{{ $val }}'"
|
||||
:class="gradientType === '{{ $val }}' ? 'bg-white shadow-sm text-slate-900' : 'text-slate-500 hover:text-slate-700'"
|
||||
class="flex-1 rounded-lg py-1.5 text-[11px] font-semibold transition-all duration-150">
|
||||
{{ $lbl }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
<div x-show="gradientType !== 'none'" x-cloak class="mt-3 space-y-3">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-semibold text-slate-600">Start color</label>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50/80 p-2">
|
||||
<input type="color" name="style[gradient_color1]" x-model="gradientColor1" @input="schedulePreview()"
|
||||
class="h-9 w-10 shrink-0 cursor-pointer rounded-lg border-0 bg-transparent p-0.5">
|
||||
<input type="text" x-model="gradientColor1" @change="schedulePreview()"
|
||||
class="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 font-mono text-sm focus:border-indigo-400 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-semibold text-slate-600">End color</label>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50/80 p-2">
|
||||
<input type="color" name="style[gradient_color2]" x-model="gradientColor2" @input="schedulePreview()"
|
||||
class="h-9 w-10 shrink-0 cursor-pointer rounded-lg border-0 bg-transparent p-0.5">
|
||||
<input type="text" x-model="gradientColor2" @change="schedulePreview()"
|
||||
class="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 font-mono text-sm focus:border-indigo-400 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="gradientType === 'linear'" class="space-y-1">
|
||||
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
|
||||
<span>Rotation</span>
|
||||
<span x-text="gradientRotation + '°'" class="font-normal text-slate-400"></span>
|
||||
</label>
|
||||
<input type="range" name="style[gradient_rotation]" min="0" max="360" x-model.number="gradientRotation"
|
||||
class="w-full accent-indigo-600">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Eyes ─────────────────────────────────────────────────────── --}}
|
||||
<div x-show="openSection === 'eyes'" x-cloak
|
||||
x-transition:enter="transition-opacity duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
class="space-y-5">
|
||||
<div>
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Outer frame</p>
|
||||
<div class="grid grid-cols-3 gap-2.5">
|
||||
@foreach([
|
||||
['value' => 'square', 'label' => 'Square', 'shape' => 'h-7 w-7 border-[3px] border-slate-800'],
|
||||
['value' => 'rounded', 'label' => 'Rounded', 'shape' => 'h-7 w-7 rounded-[5px] border-[3px] border-slate-800'],
|
||||
['value' => 'circle', 'label' => 'Circle', 'shape' => 'h-7 w-7 rounded-full border-[3px] border-slate-800'],
|
||||
] as $opt)
|
||||
<label class="group cursor-pointer">
|
||||
<input type="radio" name="style[finder_outer]" value="{{ $opt['value'] }}" x-model="finderOuter" class="sr-only">
|
||||
<span class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all"
|
||||
:class="finderOuter === '{{ $opt['value'] }}'
|
||||
? 'border-indigo-500 bg-indigo-50/60'
|
||||
: 'border-slate-200 group-hover:border-slate-300'">
|
||||
<span class="flex h-10 w-10 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
|
||||
<span class="{{ $opt['shape'] }}"></span>
|
||||
</span>
|
||||
<span class="text-[11px] font-semibold text-slate-600">{{ $opt['label'] }}</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Inner dot</p>
|
||||
<div class="grid grid-cols-3 gap-2.5">
|
||||
@foreach([
|
||||
['value' => 'square', 'label' => 'Square', 'shape' => 'h-5 w-5 rounded-[2px] bg-slate-800'],
|
||||
['value' => 'rounded', 'label' => 'Rounded', 'shape' => 'h-5 w-5 rounded-[4px] bg-slate-800'],
|
||||
['value' => 'dot', 'label' => 'Dot', 'shape' => 'h-5 w-5 rounded-full bg-slate-800'],
|
||||
] as $opt)
|
||||
<label class="group cursor-pointer">
|
||||
<input type="radio" name="style[finder_inner]" value="{{ $opt['value'] }}" x-model="finderInner" class="sr-only">
|
||||
<span class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all"
|
||||
:class="finderInner === '{{ $opt['value'] }}'
|
||||
? 'border-indigo-500 bg-indigo-50/60'
|
||||
: 'border-slate-200 group-hover:border-slate-300'">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
|
||||
<span class="{{ $opt['shape'] }}"></span>
|
||||
</span>
|
||||
<span class="text-[11px] font-semibold text-slate-600">{{ $opt['label'] }}</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Logo ─────────────────────────────────────────────────────── --}}
|
||||
<div x-show="openSection === 'logo'" x-cloak
|
||||
x-transition:enter="transition-opacity duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
class="space-y-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-700">Center logo</p>
|
||||
<p class="mt-0.5 text-xs leading-5 text-slate-400">Placed in the center. Error correction is raised automatically when a logo is present.</p>
|
||||
</div>
|
||||
<label class="flex cursor-pointer flex-col items-center gap-3 rounded-xl border-2 border-dashed border-slate-200 bg-slate-50/60 py-8 text-center transition hover:border-indigo-400 hover:bg-indigo-50/20">
|
||||
<span class="flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-slate-200/60">
|
||||
<svg class="h-6 w-6 text-slate-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-700">Click to upload</p>
|
||||
<p class="text-xs text-slate-400">PNG, JPG, GIF, WebP · max 2 MB</p>
|
||||
</div>
|
||||
<input type="file" name="logo" accept="image/png,image/jpeg,image/gif,image/webp" x-ref="logoInput" @change="onLogoChange()" class="sr-only">
|
||||
</label>
|
||||
|
||||
{{-- Logo options: shown when a logo is present (uploaded or existing) --}}
|
||||
<div x-show="hasExistingLogo || _rawLogoFile" x-cloak class="space-y-4 border-t border-slate-100 pt-4">
|
||||
|
||||
{{-- Size --}}
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
|
||||
<span>Logo size</span>
|
||||
<span x-text="Math.round(logoSize * 100) + '%'" class="font-normal text-slate-400"></span>
|
||||
</label>
|
||||
<input type="range" min="10" max="40" step="1"
|
||||
:value="Math.round(logoSize * 100)"
|
||||
@input="logoSize = parseFloat($event.target.value) / 100"
|
||||
class="mt-2 w-full accent-indigo-600">
|
||||
<input type="hidden" name="style[logo_size]" :value="logoSize">
|
||||
</div>
|
||||
|
||||
{{-- Padding --}}
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
|
||||
<span>Padding</span>
|
||||
<span x-text="logoMargin" class="font-normal text-slate-400"></span>
|
||||
</label>
|
||||
<input type="range" name="style[logo_margin]" min="0" max="15" x-model.number="logoMargin"
|
||||
class="mt-2 w-full accent-indigo-600">
|
||||
</div>
|
||||
|
||||
{{-- White background toggle --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-slate-600">White background</span>
|
||||
<button type="button" role="switch" :aria-checked="logoWhiteBg"
|
||||
@click="logoWhiteBg = !logoWhiteBg"
|
||||
:class="logoWhiteBg ? 'bg-indigo-500' : 'bg-slate-200'"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus:outline-none">
|
||||
<span :class="logoWhiteBg ? 'translate-x-5' : 'translate-x-1'"
|
||||
class="inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform"></span>
|
||||
</button>
|
||||
<input type="hidden" name="style[logo_white_bg]" :value="logoWhiteBg ? '1' : '0'">
|
||||
</div>
|
||||
|
||||
{{-- Shape --}}
|
||||
<div>
|
||||
<p class="mb-2 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Shape</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{{-- Original: landscape rectangle — logo keeps its natural aspect ratio --}}
|
||||
<button type="button"
|
||||
@click="logoShape = 'none'"
|
||||
:class="logoShape === 'none' ? 'border-indigo-500 bg-indigo-50/60' : 'border-slate-200 hover:border-slate-300'"
|
||||
class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
|
||||
<span class="h-4 w-7 rounded-[2px] bg-slate-800"></span>
|
||||
</span>
|
||||
<span class="text-[11px] font-semibold text-slate-600">Original</span>
|
||||
</button>
|
||||
{{-- Rounded: square crop with rounded corners --}}
|
||||
<button type="button"
|
||||
@click="logoShape = 'rounded'"
|
||||
:class="logoShape === 'rounded' ? 'border-indigo-500 bg-indigo-50/60' : 'border-slate-200 hover:border-slate-300'"
|
||||
class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
|
||||
<span class="h-7 w-7 rounded-xl bg-slate-800"></span>
|
||||
</span>
|
||||
<span class="text-[11px] font-semibold text-slate-600">Rounded</span>
|
||||
</button>
|
||||
{{-- Circle: square crop clipped to circle --}}
|
||||
<button type="button"
|
||||
@click="logoShape = 'circle'"
|
||||
:class="logoShape === 'circle' ? 'border-indigo-500 bg-indigo-50/60' : 'border-slate-200 hover:border-slate-300'"
|
||||
class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
|
||||
<span class="h-7 w-7 rounded-full bg-slate-800"></span>
|
||||
</span>
|
||||
<span class="text-[11px] font-semibold text-slate-600">Circle</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" name="style[logo_shape]" :value="logoShape">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@if(!empty($showRemoveLogo))
|
||||
<label class="flex items-center gap-2.5 text-sm text-slate-600">
|
||||
<input type="checkbox" name="remove_logo" value="1" x-model="removeLogo" @change="schedulePreview()"
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
Remove current logo
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- ── Frame ────────────────────────────────────────────────────── --}}
|
||||
<div x-show="openSection === 'frame'" x-cloak
|
||||
x-transition:enter="transition-opacity duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
class="space-y-5">
|
||||
<div>
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Frame style</p>
|
||||
<div class="grid grid-cols-2 gap-2.5">
|
||||
@foreach($frameStyles as $key => $meta)
|
||||
<label class="group cursor-pointer">
|
||||
<input type="radio" name="style[frame_style]" value="{{ $key }}" x-model="frameStyle" class="sr-only">
|
||||
<span class="flex items-center gap-3 rounded-xl border-2 bg-white p-3 transition-all"
|
||||
:class="frameStyle === '{{ $key }}'
|
||||
? 'border-indigo-500 bg-indigo-50/60'
|
||||
: 'border-slate-200 group-hover:border-slate-300'">
|
||||
<span class="flex h-10 w-9 shrink-0 items-end justify-center overflow-hidden rounded-lg border border-slate-200 bg-white p-1 shadow-sm">
|
||||
@if($key === 'none')
|
||||
<span class="h-8 w-8 rounded-sm bg-slate-200 opacity-40"></span>
|
||||
@elseif($meta['mode'] === 'border')
|
||||
<span class="h-8 w-8 rounded-sm border-2 border-slate-500 bg-slate-100"></span>
|
||||
@elseif($meta['mode'] === 'label')
|
||||
<span class="flex h-9 w-8 flex-col items-stretch gap-0.5">
|
||||
<span class="flex-1 rounded-sm border border-slate-300 bg-slate-100"></span>
|
||||
<span class="rounded-sm bg-slate-700 py-0.5 text-center text-[5px] font-bold leading-none text-white">SCAN</span>
|
||||
</span>
|
||||
@else
|
||||
<span class="flex h-9 w-8 flex-col items-stretch gap-0.5">
|
||||
<span class="flex-1 rounded-sm border border-slate-300 bg-slate-100"></span>
|
||||
<span class="rounded-full bg-slate-700 py-0.5 text-center text-[5px] font-bold leading-none text-white">TAP</span>
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-xs font-semibold text-slate-800">{{ $meta['label'] }}</span>
|
||||
<span class="block text-[10px] leading-4 text-slate-400">{{ $meta['description'] }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Frame colour --}}
|
||||
<div x-show="frameStyle !== 'none'" x-cloak class="space-y-1">
|
||||
<label class="block text-xs font-semibold text-slate-600">Frame colour</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="color" name="style[frame_color]" x-model="frameColor" @input="schedulePreview()"
|
||||
class="h-9 w-9 shrink-0 cursor-pointer rounded-lg border border-slate-200 bg-white p-0.5">
|
||||
<input type="text" x-model="frameColor" @change="schedulePreview()" maxlength="7"
|
||||
class="flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Frame text -- only for frames that show a CTA label --}}
|
||||
<div x-show="frameStyle === 'scan_me' || frameStyle === 'tap_to_scan'" x-cloak class="space-y-1">
|
||||
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
|
||||
<span>Label text</span>
|
||||
<span class="font-normal text-slate-400" x-text="frameStyle === 'tap_to_scan' ? 'Default: TAP TO SCAN' : 'Default: SCAN ME'"></span>
|
||||
</label>
|
||||
<input type="text" name="style[frame_text]" x-model="frameText"
|
||||
maxlength="100"
|
||||
:placeholder="frameStyle === 'tap_to_scan' ? 'TAP TO SCAN' : 'SCAN ME'"
|
||||
class="w-full rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 border-t border-slate-100 pt-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-600">Error correction</label>
|
||||
<select name="style[error_correction]" x-model="ecc"
|
||||
class="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none">
|
||||
<option value="L">Low — 7% recovery</option>
|
||||
<option value="M">Medium — 15% recovery</option>
|
||||
<option value="Q">Quartile — 25% recovery</option>
|
||||
<option value="H">High — 30% recovery</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
|
||||
<span>Output scale</span>
|
||||
<span x-text="scale + '×'" class="font-normal text-slate-400"></span>
|
||||
</label>
|
||||
<input type="range" name="style[scale]" min="4" max="16" x-model.number="scale"
|
||||
class="mt-2 w-full accent-indigo-600">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
|
||||
<span>Quiet zone</span>
|
||||
<span x-text="margin + ' modules'" class="font-normal text-slate-400"></span>
|
||||
</label>
|
||||
<input type="range" name="style[margin]" min="0" max="10" x-model.number="margin"
|
||||
class="mt-2 w-full accent-indigo-600">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,669 @@
|
||||
@once('qr-customizer-alpine')
|
||||
<script>
|
||||
(function () {
|
||||
const registerQrCustomizer = () => {
|
||||
Alpine.data('qrCustomizer', (config) => ({
|
||||
previewUrl: config.previewUrl,
|
||||
shortCode: config.shortCode || 'preview',
|
||||
qrData: config.qrData || 'https://ladill.com',
|
||||
existingLogoPath: config.existingLogoPath || null,
|
||||
hasExistingLogo: Boolean(config.hasExistingLogo),
|
||||
removeLogo: false,
|
||||
previewLoading: false,
|
||||
previewTimer: null,
|
||||
showPreviewModal: false,
|
||||
openSection: config.openSection || 'body',
|
||||
balance: config.balance ?? null,
|
||||
price: config.price ?? null,
|
||||
topupModalId: config.topupModalId ?? null,
|
||||
topupUrl: config.topupUrl ?? null,
|
||||
canonicalSyncUrl: config.canonicalSyncUrl ?? null,
|
||||
csrf: config.csrf ?? null,
|
||||
_canonicalSynced: false,
|
||||
type: config.type ?? 'url',
|
||||
wizard: Boolean(config.wizard),
|
||||
step: config.wizard ? 1 : 0,
|
||||
fg: config.style.foreground,
|
||||
bg: config.style.background,
|
||||
ecc: config.style.error_correction,
|
||||
margin: Number(config.style.margin),
|
||||
moduleStyle: config.style.module_style,
|
||||
finderOuter: config.style.finder_outer,
|
||||
finderInner: config.style.finder_inner,
|
||||
frameStyle: config.style.frame_style,
|
||||
frameText: config.style.frame_text || '',
|
||||
frameColor: config.style.frame_color || '#000000',
|
||||
scale: Number(config.style.scale),
|
||||
gradientType: config.style.gradient_type || 'none',
|
||||
gradientColor1: config.style.gradient_color1 || '#000000',
|
||||
gradientColor2: config.style.gradient_color2 || '#7c3aed',
|
||||
gradientRotation: Number(config.style.gradient_rotation ?? 45),
|
||||
logoSize: Number(config.style.logo_size ?? 0.3),
|
||||
logoMargin: Number(config.style.logo_margin ?? 5),
|
||||
logoWhiteBg: Boolean(config.style.logo_white_bg),
|
||||
logoShape: config.style.logo_shape || 'none',
|
||||
_rawLogoFile: null,
|
||||
_processedLogoUrl: null,
|
||||
init() {
|
||||
[
|
||||
'fg', 'bg', 'ecc', 'margin', 'moduleStyle',
|
||||
'finderOuter', 'finderInner', 'frameStyle', 'frameText', 'frameColor', 'scale',
|
||||
'gradientType', 'gradientColor1', 'gradientColor2', 'gradientRotation',
|
||||
'logoSize', 'logoMargin',
|
||||
].forEach((field) => {
|
||||
this.$watch(field, () => this.schedulePreview());
|
||||
});
|
||||
|
||||
// These require canvas re-processing before preview
|
||||
['logoWhiteBg', 'logoShape'].forEach((field) => {
|
||||
this.$watch(field, () => this._reprocessLogo());
|
||||
});
|
||||
|
||||
this.schedulePreview();
|
||||
|
||||
if (config.existingLogoUrl) {
|
||||
this._loadExistingLogo(config.existingLogoUrl);
|
||||
}
|
||||
|
||||
this.$watch('showPreviewModal', (val) => {
|
||||
if (val) this.schedulePreview();
|
||||
});
|
||||
},
|
||||
toggleSection(id) {
|
||||
this.openSection = this.openSection === id ? null : id;
|
||||
},
|
||||
schedulePreview() {
|
||||
clearTimeout(this.previewTimer);
|
||||
this.previewTimer = setTimeout(() => this.refreshPreview(), 200);
|
||||
},
|
||||
async onLogoChange() {
|
||||
const input = this.$refs.logoInput;
|
||||
this._rawLogoFile = input?.files?.[0] || null;
|
||||
if (this._rawLogoFile) {
|
||||
await this._reprocessLogo();
|
||||
} else {
|
||||
this._processedLogoUrl = null;
|
||||
this.schedulePreview();
|
||||
}
|
||||
},
|
||||
async _reprocessLogo() {
|
||||
if (!this._rawLogoFile) return;
|
||||
const result = await this._processLogoImage(this._rawLogoFile);
|
||||
if (result) {
|
||||
this._processedLogoUrl = result;
|
||||
this.schedulePreview();
|
||||
}
|
||||
},
|
||||
// Load existing logo from data URI (edit view) and apply canvas processing
|
||||
_loadExistingLogo(dataUri) {
|
||||
fetch(dataUri)
|
||||
.then(r => r.blob())
|
||||
.then(blob => {
|
||||
this._rawLogoFile = new File([blob], 'logo', { type: blob.type || 'image/png' });
|
||||
return this._processLogoImage(this._rawLogoFile);
|
||||
})
|
||||
.then(result => {
|
||||
if (result) {
|
||||
this._processedLogoUrl = result;
|
||||
this.schedulePreview();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this._processedLogoUrl = dataUri;
|
||||
this.schedulePreview();
|
||||
});
|
||||
},
|
||||
// Apply shape clipping and/or white background to logo via canvas.
|
||||
// For circle/rounded shapes a center-square crop is used so the shape looks balanced.
|
||||
// For no-shape logos the natural aspect ratio is preserved.
|
||||
_processLogoImage(file) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const raw = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
const needsSquareCrop = this.logoShape !== 'none';
|
||||
const sw = img.naturalWidth;
|
||||
const sh = img.naturalHeight;
|
||||
|
||||
let cw, ch, sx, sy, sSize;
|
||||
if (needsSquareCrop) {
|
||||
// Crop to center square for circle/rounded shapes
|
||||
sSize = Math.min(sw, sh);
|
||||
sx = (sw - sSize) / 2;
|
||||
sy = (sh - sSize) / 2;
|
||||
cw = ch = 300;
|
||||
} else {
|
||||
// Preserve natural aspect ratio
|
||||
const MAX = 600;
|
||||
const scale = Math.min(1, MAX / Math.max(sw, sh));
|
||||
cw = Math.round(sw * scale);
|
||||
ch = Math.round(sh * scale);
|
||||
sx = sy = 0;
|
||||
sSize = null;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (needsSquareCrop) {
|
||||
ctx.beginPath();
|
||||
if (this.logoShape === 'circle') {
|
||||
ctx.arc(cw / 2, ch / 2, cw / 2, 0, Math.PI * 2);
|
||||
} else {
|
||||
// Rounded — 20% corner radius
|
||||
const r = cw * 0.2;
|
||||
ctx.moveTo(r, 0);
|
||||
ctx.lineTo(cw - r, 0);
|
||||
ctx.arcTo(cw, 0, cw, r, r);
|
||||
ctx.lineTo(cw, ch - r);
|
||||
ctx.arcTo(cw, ch, cw - r, ch, r);
|
||||
ctx.lineTo(r, ch);
|
||||
ctx.arcTo(0, ch, 0, ch - r, r);
|
||||
ctx.lineTo(0, r);
|
||||
ctx.arcTo(0, 0, r, 0, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
ctx.clip();
|
||||
}
|
||||
|
||||
if (this.logoWhiteBg) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, cw, ch);
|
||||
}
|
||||
|
||||
if (sSize !== null) {
|
||||
ctx.drawImage(img, sx, sy, sSize, sSize, 0, 0, cw, ch);
|
||||
} else {
|
||||
ctx.drawImage(img, 0, 0, cw, ch);
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(raw);
|
||||
resolve(canvas.toDataURL('image/png'));
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(raw);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = raw;
|
||||
});
|
||||
},
|
||||
setStylePreset(preset) {
|
||||
const presets = {
|
||||
classic: {
|
||||
fg: '#000000', bg: '#ffffff', moduleStyle: 'square',
|
||||
finderOuter: 'square', finderInner: 'square',
|
||||
frameStyle: 'none', ecc: 'M', margin: 4,
|
||||
gradientType: 'none',
|
||||
},
|
||||
rounded: {
|
||||
fg: '#111827', bg: '#ffffff', moduleStyle: 'square',
|
||||
finderOuter: 'rounded', finderInner: 'square',
|
||||
frameStyle: 'scan_me', ecc: 'Q', margin: 4,
|
||||
gradientType: 'none',
|
||||
},
|
||||
branded: {
|
||||
fg: '#1d4ed8', bg: '#ffffff', moduleStyle: 'square',
|
||||
finderOuter: 'rounded', finderInner: 'square',
|
||||
frameStyle: 'tap_to_scan', ecc: 'Q', margin: 4,
|
||||
gradientType: 'none',
|
||||
},
|
||||
dots: {
|
||||
fg: '#0f172a', bg: '#ffffff', moduleStyle: 'dots',
|
||||
finderOuter: 'square', finderInner: 'dot',
|
||||
frameStyle: 'scan_me', ecc: 'H', margin: 5,
|
||||
gradientType: 'none',
|
||||
},
|
||||
};
|
||||
Object.assign(this, presets[preset] || presets.classic);
|
||||
this.schedulePreview();
|
||||
},
|
||||
setEyePreset(preset) {
|
||||
const presets = {
|
||||
square: { finderOuter: 'square', finderInner: 'square' },
|
||||
rounded: { finderOuter: 'rounded', finderInner: 'square' },
|
||||
target: { finderOuter: 'circle', finderInner: 'dot' },
|
||||
};
|
||||
Object.assign(this, presets[preset] || presets.square);
|
||||
this.schedulePreview();
|
||||
},
|
||||
redirectTopup() {
|
||||
if (this.topupUrl) {
|
||||
window.location.href = this.topupUrl;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.topupModalId) {
|
||||
window.dispatchEvent(new CustomEvent('open-modal', {
|
||||
detail: this.topupModalId,
|
||||
bubbles: true,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
checkBalance(event) {
|
||||
if (this.balance === null || this.price === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.balance <= 0 || this.balance < this.price) {
|
||||
event?.preventDefault();
|
||||
this.redirectTopup();
|
||||
}
|
||||
},
|
||||
nextStep(event) {
|
||||
if (!this.wizard) {
|
||||
return;
|
||||
}
|
||||
if (this.step === 1 && !this.validateWizardStep1()) {
|
||||
event?.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (this.step < 3) {
|
||||
this.step++;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
},
|
||||
prevStep() {
|
||||
if (this.wizard && this.step > 1) {
|
||||
this.step--;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
},
|
||||
validateWizardStep1() {
|
||||
const form = this.$refs.createForm;
|
||||
const label = form?.querySelector('[name=label]');
|
||||
if (!label?.value?.trim()) {
|
||||
label?.focus();
|
||||
label?.reportValidity?.();
|
||||
return false;
|
||||
}
|
||||
return label.checkValidity?.() !== false;
|
||||
},
|
||||
wizardSubmit(event) {
|
||||
if (this.wizard && this.step < 3) {
|
||||
event?.preventDefault();
|
||||
this.nextStep(event);
|
||||
return;
|
||||
}
|
||||
this.submitOrTopup(event);
|
||||
},
|
||||
reviewLabel() {
|
||||
return this.$refs.createForm?.querySelector('[name=label]')?.value?.trim() || '';
|
||||
},
|
||||
reviewEventName() {
|
||||
const eventBlock = this.$refs.createForm?.querySelector('[data-event-name]');
|
||||
return eventBlock?.value?.trim() || this.reviewLabel();
|
||||
},
|
||||
submitOrTopup(event) {
|
||||
if (this.balance <= 0 || this.balance < this.price) {
|
||||
event?.preventDefault();
|
||||
this.redirectTopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event?.type === 'submit') {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = this.$el.tagName === 'FORM' ? this.$el : this.$refs.createForm;
|
||||
form?.requestSubmit();
|
||||
},
|
||||
_buildGradient() {
|
||||
if (this.gradientType === 'none') return null;
|
||||
return {
|
||||
type: this.gradientType,
|
||||
rotation: this.gradientType === 'linear' ? (Math.PI * this.gradientRotation / 180) : 0,
|
||||
colorStops: [
|
||||
{ offset: 0, color: this.gradientColor1 },
|
||||
{ offset: 1, color: this.gradientColor2 },
|
||||
],
|
||||
};
|
||||
},
|
||||
_buildQrOptions(width, height) {
|
||||
const dotsTypeMap = { square: 'square', dots: 'dots' };
|
||||
const cornersSquareMap = { square: 'square', rounded: 'extra-rounded', circle: 'dot' };
|
||||
// 'rounded' maps to 'square'; SVG post-processing applies the actual corner radius
|
||||
const cornersDotMap = { square: 'square', rounded: 'square', dot: 'dot' };
|
||||
|
||||
const gradient = this._buildGradient();
|
||||
|
||||
const options = {
|
||||
width,
|
||||
height,
|
||||
type: 'svg',
|
||||
data: this.qrData,
|
||||
margin: Math.round(Number(this.margin) * 4),
|
||||
qrOptions: { errorCorrectionLevel: this.ecc || 'M' },
|
||||
dotsOptions: {
|
||||
type: dotsTypeMap[this.moduleStyle] || 'square',
|
||||
...(gradient ? { gradient } : { color: this.fg }),
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: cornersSquareMap[this.finderOuter] || 'square',
|
||||
...(gradient ? { gradient } : { color: this.fg }),
|
||||
},
|
||||
cornersDotOptions: {
|
||||
type: cornersDotMap[this.finderInner] || 'square',
|
||||
...(gradient ? { gradient } : { color: this.fg }),
|
||||
},
|
||||
backgroundOptions: { color: this.bg },
|
||||
};
|
||||
|
||||
if (this._processedLogoUrl && !this.removeLogo) {
|
||||
options.image = this._processedLogoUrl;
|
||||
options.imageOptions = {
|
||||
crossOrigin: 'anonymous',
|
||||
margin: this.logoMargin,
|
||||
imageSize: this.logoSize,
|
||||
hideBackgroundDots: true,
|
||||
};
|
||||
}
|
||||
|
||||
return options;
|
||||
},
|
||||
// Post-process SVG to give corner inner dots rounded corners.
|
||||
// qr-code-styling renders square cornersDot as <rect> elements inside <clipPath> in <defs>.
|
||||
// Adding rx/ry directly to those rects rounds the clip mask → the visible dot appears rounded.
|
||||
_applyRoundedInnerDot(container) {
|
||||
const svg = container.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
const defs = svg.querySelector('defs');
|
||||
if (!defs) { this._applyRoundedInnerDotFallback(container); return; }
|
||||
|
||||
// Collect all square <rect> elements (width === height) inside <clipPath> elements,
|
||||
// grouped by their size value.
|
||||
const groups = new Map();
|
||||
defs.querySelectorAll('clipPath rect').forEach(rect => {
|
||||
const w = rect.getAttribute('width');
|
||||
const h = rect.getAttribute('height');
|
||||
if (w && h && w === h) {
|
||||
if (!groups.has(w)) groups.set(w, []);
|
||||
groups.get(w).push(rect);
|
||||
}
|
||||
});
|
||||
|
||||
// Find the group of exactly 3 with the smallest size — that is the inner finder dot
|
||||
// (one rect per finder eye; outer ring uses <path>, individual modules form much larger groups).
|
||||
let targetRects = null, minSize = Infinity;
|
||||
for (const [sizeStr, rects] of groups) {
|
||||
if (rects.length !== 3) continue;
|
||||
const size = parseFloat(sizeStr);
|
||||
if (size < minSize) { minSize = size; targetRects = rects; }
|
||||
}
|
||||
|
||||
if (!targetRects) { this._applyRoundedInnerDotFallback(container); return; }
|
||||
|
||||
const r = (minSize * 0.28).toFixed(3);
|
||||
for (const rect of targetRects) {
|
||||
rect.setAttribute('rx', r);
|
||||
rect.setAttribute('ry', r);
|
||||
}
|
||||
},
|
||||
// Fallback: use qrcode-generator to compute finder inner dot pixel positions, then
|
||||
// overdraw rounded SVG <rect> elements at those positions.
|
||||
_applyRoundedInnerDotFallback(container) {
|
||||
const svg = container.querySelector('svg');
|
||||
if (!svg || typeof window.qrcode === 'undefined') return;
|
||||
try {
|
||||
const qr = window.qrcode(0, this.ecc || 'M');
|
||||
qr.addData(this.qrData);
|
||||
qr.make();
|
||||
const n = qr.getModuleCount();
|
||||
const svgW = svg.viewBox?.baseVal?.width || parseFloat(svg.getAttribute('width') || '500');
|
||||
const marginPx = Math.round(Number(this.margin) * 4);
|
||||
const modulePx = (svgW - 2 * marginPx) / n;
|
||||
const dotSize = modulePx * 3;
|
||||
const r = dotSize * 0.28;
|
||||
const fill = this.gradientType !== 'none' ? this.gradientColor1 : this.fg;
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
for (const [row, col] of [[2, 2], [2, n - 5], [n - 5, 2]]) {
|
||||
const x = marginPx + col * modulePx;
|
||||
const y = marginPx + row * modulePx;
|
||||
// Cover the square dot with the background color
|
||||
const eraser = document.createElementNS(NS, 'rect');
|
||||
eraser.setAttribute('x', x); eraser.setAttribute('y', y);
|
||||
eraser.setAttribute('width', dotSize); eraser.setAttribute('height', dotSize);
|
||||
eraser.setAttribute('fill', this.bg);
|
||||
svg.appendChild(eraser);
|
||||
// Draw rounded corner dot
|
||||
const dot = document.createElementNS(NS, 'rect');
|
||||
dot.setAttribute('x', x); dot.setAttribute('y', y);
|
||||
dot.setAttribute('width', dotSize); dot.setAttribute('height', dotSize);
|
||||
dot.setAttribute('rx', r); dot.setAttribute('ry', r);
|
||||
dot.setAttribute('fill', fill);
|
||||
svg.appendChild(dot);
|
||||
}
|
||||
} catch (_) { /* silent */ }
|
||||
},
|
||||
refreshPreview() {
|
||||
if (typeof window.QRCodeStyling === 'undefined') return;
|
||||
|
||||
this.previewLoading = true;
|
||||
|
||||
try {
|
||||
const container = this.$refs.qrPreview;
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
new window.QRCodeStyling(this._buildQrOptions(500, 500)).append(container);
|
||||
if (this.finderInner === 'rounded') this._applyRoundedInnerDot(container);
|
||||
}
|
||||
|
||||
const modalContainer = this.$refs.qrPreviewModal;
|
||||
if (modalContainer && this.showPreviewModal) {
|
||||
modalContainer.innerHTML = '';
|
||||
new window.QRCodeStyling(this._buildQrOptions(320, 320)).append(modalContainer);
|
||||
if (this.finderInner === 'rounded') this._applyRoundedInnerDot(modalContainer);
|
||||
}
|
||||
} finally {
|
||||
this.previewLoading = false;
|
||||
this._maybeSyncCanonical();
|
||||
}
|
||||
},
|
||||
|
||||
// Persist the exact rendered QR as the canonical SVG+PNG (single source of
|
||||
// truth) so downloads match the preview. Only on pages with a real /q code
|
||||
// (canonicalSyncUrl set — i.e. the show page), once per load.
|
||||
_maybeSyncCanonical() {
|
||||
if (!this.canonicalSyncUrl || this._canonicalSynced) return;
|
||||
if (typeof window.QRCodeStyling === 'undefined') return;
|
||||
const svgEl = this.$refs.qrPreview?.querySelector('svg');
|
||||
if (!svgEl) return;
|
||||
this._canonicalSynced = true;
|
||||
// Defer so finder-dot post-processing + DOM settle before serializing.
|
||||
setTimeout(() => this.syncCanonical(), 300);
|
||||
},
|
||||
async syncCanonical() {
|
||||
try {
|
||||
const svgEl = this.$refs.qrPreview?.querySelector('svg');
|
||||
if (!svgEl) return;
|
||||
const inner = new XMLSerializer().serializeToString(svgEl);
|
||||
const finalSvg = this._buildFramedSvg(inner);
|
||||
const png = await this._svgToPng(finalSvg);
|
||||
if (!png) return;
|
||||
const fd = new FormData();
|
||||
fd.append('svg', finalSvg);
|
||||
fd.append('png', png);
|
||||
await fetch(this.canonicalSyncUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': this.csrf || '', 'Accept': 'application/json' },
|
||||
body: fd,
|
||||
});
|
||||
} catch (_) { /* non-fatal: server fallback render remains */ }
|
||||
},
|
||||
_xmlEscape(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
},
|
||||
// Composite the CSS preview frame (thin border / SCAN ME label / TAP TO
|
||||
// SCAN pill) into a single vector SVG, with the QR nested as vector — so
|
||||
// downloads include the frame exactly like the preview. No frame => QR as-is.
|
||||
_buildFramedSvg(qrSvg) {
|
||||
const fs = this.frameStyle;
|
||||
if (!fs || fs === 'none') return qrSvg;
|
||||
|
||||
const color = this.frameColor || '#000000';
|
||||
const Q = 1000, pad = 72, radius = 56;
|
||||
const W = Q + pad * 2;
|
||||
|
||||
// Nest the QR vector: ensure a viewBox, drop intrinsic width/height, place it.
|
||||
const wm = qrSvg.match(/<svg\b[^>]*?\swidth\s*=\s*"([\d.]+)/i);
|
||||
const hm = qrSvg.match(/<svg\b[^>]*?\sheight\s*=\s*"([\d.]+)/i);
|
||||
const ow = wm ? wm[1] : '500';
|
||||
const oh = hm ? hm[1] : '500';
|
||||
let qr = qrSvg;
|
||||
if (!/viewBox\s*=/i.test(qr)) {
|
||||
qr = qr.replace(/<svg\b/i, `<svg viewBox="0 0 ${ow} ${oh}"`);
|
||||
}
|
||||
qr = qr
|
||||
.replace(/(<svg\b[^>]*?)\swidth\s*=\s*"[^"]*"/i, '$1')
|
||||
.replace(/(<svg\b[^>]*?)\sheight\s*=\s*"[^"]*"/i, '$1')
|
||||
.replace(/<svg\b/i, `<svg x="${pad}" y="${pad}" width="${Q}" height="${Q}"`);
|
||||
|
||||
let labelH = 0, labelMarkup = '', border = '';
|
||||
if (fs === 'scan_me') {
|
||||
labelH = 124;
|
||||
const txt = ((this.frameText && this.frameText.trim()) ? this.frameText : 'SCAN ME').toUpperCase();
|
||||
labelMarkup =
|
||||
`<line x1="${pad}" y1="${Q + pad + 26}" x2="${W - pad}" y2="${Q + pad + 26}" stroke="${color}" stroke-opacity="0.25" stroke-width="2"/>` +
|
||||
`<text x="${W / 2}" y="${Q + pad + 86}" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="44" font-weight="700" letter-spacing="7" fill="${color}">${this._xmlEscape(txt)}</text>`;
|
||||
} else if (fs === 'tap_to_scan') {
|
||||
labelH = 142;
|
||||
const txt = ((this.frameText && this.frameText.trim()) ? this.frameText : 'TAP TO SCAN').toUpperCase();
|
||||
const pillH = 78;
|
||||
const pillW = Math.min(W - pad * 2, 160 + txt.length * 24);
|
||||
const pillX = (W - pillW) / 2, pillY = Q + pad + 30;
|
||||
labelMarkup =
|
||||
`<rect x="${pillX}" y="${pillY}" width="${pillW}" height="${pillH}" rx="${pillH / 2}" fill="${color}"/>` +
|
||||
`<text x="${W / 2}" y="${pillY + pillH / 2 + 13}" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="36" font-weight="700" letter-spacing="5" fill="${this.frameTextColor}">${this._xmlEscape(txt)}</text>`;
|
||||
}
|
||||
const H = Q + pad * 2 + labelH;
|
||||
if (fs === 'thin') {
|
||||
border = `<rect x="11" y="11" width="${W - 22}" height="${H - 22}" rx="${radius - 6}" fill="none" stroke="${color}" stroke-width="18"/>`;
|
||||
}
|
||||
|
||||
// No XML prolog here — the server's sanitizeSvg prepends it (a literal
|
||||
// XML prolog in a Blade file would be read as a PHP open tag).
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">` +
|
||||
`<rect x="0" y="0" width="${W}" height="${H}" rx="${radius}" fill="#ffffff"/>` +
|
||||
border + qr + labelMarkup +
|
||||
'</svg>';
|
||||
},
|
||||
_svgToPng(svgString) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
try {
|
||||
const w = img.naturalWidth || 1000;
|
||||
const h = img.naturalHeight || 1000;
|
||||
const f = Math.max(1, 1000 / Math.max(w, h)); // upscale small to ~1000 for crisp print
|
||||
const cw = Math.round(w * f), ch = Math.round(h * f);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = cw; c.height = ch;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.fillStyle = (this.frameStyle && this.frameStyle !== 'none') ? '#ffffff' : (this.bg || '#ffffff');
|
||||
ctx.fillRect(0, 0, cw, ch);
|
||||
ctx.drawImage(img, 0, 0, cw, ch);
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(c.toDataURL('image/png'));
|
||||
} catch (_) { URL.revokeObjectURL(url); resolve(null); }
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
|
||||
img.src = url;
|
||||
} catch (_) { resolve(null); }
|
||||
});
|
||||
},
|
||||
|
||||
// Returns black or white depending on frameColor luminance, for readable CTA text
|
||||
get frameTextColor() {
|
||||
return this._hexToLuminance(this.frameColor) > 0.35 ? '#000000' : '#ffffff';
|
||||
},
|
||||
|
||||
// ── Scanability validation ────────────────────────────────────────
|
||||
get scanability() {
|
||||
let score = 100;
|
||||
const warnings = [];
|
||||
|
||||
// Contrast check — use gradient start colour when gradient is active
|
||||
const effectiveFg = this.gradientType !== 'none' ? this.gradientColor1 : this.fg;
|
||||
const ratio = this._contrastRatio(effectiveFg, this.bg);
|
||||
|
||||
if (ratio < 2.0) {
|
||||
score -= 50;
|
||||
warnings.push('Very low contrast — code will likely fail to scan.');
|
||||
} else if (ratio < 3.0) {
|
||||
score -= 30;
|
||||
warnings.push('Low contrast between foreground and background.');
|
||||
} else if (ratio < 4.5) {
|
||||
score -= 10;
|
||||
warnings.push('Contrast is acceptable but could be improved.');
|
||||
}
|
||||
|
||||
// Logo size checks
|
||||
const hasLogo = Boolean(this._processedLogoUrl) && !this.removeLogo;
|
||||
if (hasLogo) {
|
||||
if (this.logoSize > 0.40) {
|
||||
score -= 25;
|
||||
warnings.push('Logo is too large and may block scanning.');
|
||||
} else if (this.logoSize > 0.30) {
|
||||
score -= 10;
|
||||
warnings.push('Large logo — use High (H) error correction.');
|
||||
}
|
||||
|
||||
if (this.logoSize > 0.25 && this.ecc === 'L') {
|
||||
score -= 15;
|
||||
warnings.push('Logo requires at least Medium (M) error correction.');
|
||||
}
|
||||
}
|
||||
|
||||
// Extreme customisation warning
|
||||
const hasGradient = this.gradientType !== 'none';
|
||||
const complexEyes = this.finderOuter === 'circle' || this.finderInner === 'dot';
|
||||
if (hasLogo && hasGradient && complexEyes && this.ecc === 'L') {
|
||||
score -= 10;
|
||||
warnings.push('Many effects combined — consider higher error correction.');
|
||||
}
|
||||
|
||||
score = Math.max(0, score);
|
||||
|
||||
let label, level;
|
||||
if (score >= 80) { label = 'Excellent'; level = 'excellent'; }
|
||||
else if (score >= 60) { label = 'Good'; level = 'good'; }
|
||||
else { label = 'Warning'; level = 'warning'; }
|
||||
|
||||
return { score, label, level, warnings };
|
||||
},
|
||||
_hexToLuminance(hex) {
|
||||
hex = (hex || '#000000').replace('#', '');
|
||||
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
|
||||
if (hex.length !== 6) return 0;
|
||||
const r = parseInt(hex.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(hex.slice(4, 6), 16) / 255;
|
||||
const lin = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
},
|
||||
_contrastRatio(hex1, hex2) {
|
||||
const l1 = this._hexToLuminance(hex1);
|
||||
const l2 = this._hexToLuminance(hex2);
|
||||
const light = Math.max(l1, l2);
|
||||
const dark = Math.min(l1, l2);
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
if (window.Alpine) {
|
||||
registerQrCustomizer();
|
||||
} else {
|
||||
document.addEventListener('alpine:init', registerQrCustomizer);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@endonce
|
||||
@@ -0,0 +1,188 @@
|
||||
@php
|
||||
$showDownloads = $showDownloads ?? true;
|
||||
@endphp
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
|
||||
|
||||
{{-- QR display area with gradient background --}}
|
||||
<div class="relative flex items-center justify-center bg-gradient-to-br from-indigo-50/60 via-white to-violet-50/40 px-8 py-10">
|
||||
|
||||
{{-- Spinner overlay --}}
|
||||
<div x-show="previewLoading" x-cloak
|
||||
class="absolute inset-0 z-10 flex items-center justify-center bg-white/60 backdrop-blur-[2px]">
|
||||
<svg class="h-8 w-8 animate-spin text-indigo-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"/>
|
||||
<path class="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{{-- Floating QR card --}}
|
||||
<div class="w-full max-w-xs transition-all duration-300"
|
||||
:class="previewLoading ? 'opacity-40 scale-[0.97]' : 'opacity-100 scale-100'">
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-2xl"
|
||||
:style="frameStyle === 'thin'
|
||||
? 'box-shadow: 0 0 0 2px ' + frameColor + ', 0 25px 50px -12px rgb(0 0 0 / 0.25)'
|
||||
: 'box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25), 0 0 0 1px rgb(0 0 0 / 0.06)'">
|
||||
|
||||
{{-- Frame wrapper: adds padding + CTA for label/pill frames --}}
|
||||
<div :class="{
|
||||
'p-[8px]': frameStyle === 'thin',
|
||||
'p-[14px]': frameStyle === 'scan_me' || frameStyle === 'tap_to_scan',
|
||||
'p-4': frameStyle === 'none',
|
||||
}">
|
||||
{{-- QR code area — SVG renderer only, no static fallback --}}
|
||||
<div x-ref="qrPreview"
|
||||
class="aspect-square w-full [&>svg]:block [&>svg]:h-full [&>svg]:w-full"></div>
|
||||
|
||||
{{-- Scan me label --}}
|
||||
<div x-show="frameStyle === 'scan_me'" x-cloak
|
||||
:style="'border-top: 1px solid ' + frameColor + '40; color: ' + frameColor"
|
||||
class="pt-2.5 pb-0.5 text-center">
|
||||
<span x-text="(frameText && frameText.trim() ? frameText : 'SCAN ME').toUpperCase()"
|
||||
class="text-[11px] font-bold tracking-[0.18em]"></span>
|
||||
</div>
|
||||
|
||||
{{-- Tap to scan pill --}}
|
||||
<div x-show="frameStyle === 'tap_to_scan'" x-cloak
|
||||
class="mt-2.5 flex justify-center pb-0.5">
|
||||
<span x-text="(frameText && frameText.trim() ? frameText : 'TAP TO SCAN').toUpperCase()"
|
||||
:style="'background-color: ' + frameColor + '; color: ' + frameTextColor"
|
||||
class="rounded-full px-5 py-1.5 text-[10px] font-bold tracking-widest"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Scanability score --}}
|
||||
<div class="border-t border-slate-100 px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Scan quality</span>
|
||||
<span class="text-xs font-bold tabular-nums"
|
||||
:class="{
|
||||
'text-emerald-600': scanability.level === 'excellent',
|
||||
'text-amber-500': scanability.level === 'good',
|
||||
'text-red-500': scanability.level === 'warning',
|
||||
}"
|
||||
x-text="scanability.score + '% — ' + scanability.label"></span>
|
||||
</div>
|
||||
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full transition-all duration-300"
|
||||
:style="'width: ' + scanability.score + '%'"
|
||||
:class="{
|
||||
'bg-emerald-500': scanability.level === 'excellent',
|
||||
'bg-amber-400': scanability.level === 'good',
|
||||
'bg-red-500': scanability.level === 'warning',
|
||||
}"></div>
|
||||
</div>
|
||||
<template x-if="scanability.warnings.length > 0">
|
||||
<ul class="mt-2.5 space-y-1.5">
|
||||
<template x-for="w in scanability.warnings" :key="w">
|
||||
<li class="flex items-start gap-1.5">
|
||||
<svg class="mt-px h-3.5 w-3.5 shrink-0 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-[11px] leading-4 text-amber-700" x-text="w"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Download + Share buttons --}}
|
||||
@if($showDownloads && isset($qrCode))
|
||||
@php
|
||||
$shareUrl = $qrCode->publicUrl();
|
||||
$shareEnc = urlencode($shareUrl);
|
||||
$shareText = urlencode($qrCode->label . ' — scan my QR code');
|
||||
@endphp
|
||||
<div class="border-t border-slate-100 bg-slate-50/50 px-6 py-5">
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Download & Share</p>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
|
||||
<a href="{{ route('events.download', [$qrCode, 'png']) }}"
|
||||
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-indigo-300 hover:bg-indigo-50">
|
||||
<svg class="h-4 w-4 text-slate-400 transition group-hover:text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12M12 16.5V3"/>
|
||||
</svg>
|
||||
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-indigo-700">PNG</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('events.download', [$qrCode, 'svg']) }}"
|
||||
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-violet-300 hover:bg-violet-50">
|
||||
<svg class="h-4 w-4 text-slate-400 transition group-hover:text-violet-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"/>
|
||||
</svg>
|
||||
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-violet-700">SVG</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('events.download', [$qrCode, 'pdf']) }}"
|
||||
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-rose-300 hover:bg-rose-50">
|
||||
<svg class="h-4 w-4 text-slate-400 transition group-hover:text-rose-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-rose-700">PDF</span>
|
||||
</a>
|
||||
|
||||
{{-- Share button with popover --}}
|
||||
<div class="relative" x-data="{ open: false, copied: false }">
|
||||
<button type="button"
|
||||
@click="open = !open"
|
||||
:class="open ? 'border-sky-300 bg-sky-50' : 'border-slate-200 bg-white hover:border-sky-300 hover:bg-sky-50'"
|
||||
class="group flex w-full flex-col items-center gap-1.5 rounded-xl border py-3.5 text-center transition">
|
||||
<svg class="h-4 w-4 transition" :class="open ? 'text-sky-500' : 'text-slate-400 group-hover:text-sky-500'" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-semibold transition" :class="open ? 'text-sky-700' : 'text-slate-600 group-hover:text-sky-700'">Share</span>
|
||||
</button>
|
||||
|
||||
<div x-show="open" x-cloak
|
||||
@click.outside="open = false"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute bottom-full right-0 z-50 mb-2 w-52 origin-bottom-right rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl">
|
||||
|
||||
<a href="https://wa.me/?text={{ $shareText }}%20{{ $shareEnc }}" target="_blank" rel="noopener"
|
||||
@click="open = false"
|
||||
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
|
||||
<svg class="h-4 w-4 shrink-0 text-green-500" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
|
||||
WhatsApp
|
||||
</a>
|
||||
|
||||
<a href="https://twitter.com/intent/tweet?url={{ $shareEnc }}&text={{ $shareText }}" target="_blank" rel="noopener"
|
||||
@click="open = false"
|
||||
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
|
||||
<svg class="h-4 w-4 shrink-0 text-slate-800" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.748l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
X (Twitter)
|
||||
</a>
|
||||
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u={{ $shareEnc }}" target="_blank" rel="noopener"
|
||||
@click="open = false"
|
||||
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
|
||||
<svg class="h-4 w-4 shrink-0 text-blue-600" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
Facebook
|
||||
</a>
|
||||
|
||||
<div class="my-1 border-t border-slate-100"></div>
|
||||
|
||||
<button type="button"
|
||||
@click="navigator.clipboard.writeText('{{ $shareUrl }}').then(() => { copied = true; setTimeout(() => { copied = false; open = false }, 1500) })"
|
||||
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition hover:bg-slate-50"
|
||||
:class="copied ? 'text-emerald-600' : 'text-slate-700'">
|
||||
<svg x-show="!copied" class="h-4 w-4 shrink-0 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"/></svg>
|
||||
<svg x-show="copied" x-cloak class="h-4 w-4 shrink-0 text-emerald-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
|
||||
<span x-text="copied ? 'Copied!' : 'Copy link'">Copy link</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">{{ $qrCode->label }}</x-slot>
|
||||
|
||||
@php
|
||||
$qrStyle = $qrCode->style();
|
||||
@endphp
|
||||
|
||||
<div class="mx-auto max-w-6xl space-y-6"
|
||||
x-data="qrCustomizer({
|
||||
previewUrl: @js(route('events.style-preview')),
|
||||
csrf: @js(csrf_token()),
|
||||
canonicalSyncUrl: @js(route('events.canonical-image', $qrCode)),
|
||||
shortCode: @js($qrCode->short_code),
|
||||
qrData: @js($qrCode->encodedPayload()),
|
||||
initialPreview: @js($previewDataUri),
|
||||
existingLogoPath: @js($qrStyle['logo_path'] ?? null),
|
||||
existingLogoUrl: @js($logoDataUri ?? null),
|
||||
hasExistingLogo: @js(! empty($qrStyle['logo_path'])),
|
||||
style: @js(\App\Support\Qr\QrStyleDefaults::merge($qrStyle)),
|
||||
})">
|
||||
|
||||
@foreach(['success', 'error'] as $flash)
|
||||
@if(session($flash))
|
||||
<div class="rounded-xl border px-4 py-3 text-sm {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
|
||||
{{ session($flash) }}
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Mobile page header --}}
|
||||
<div class="flex items-center gap-3 py-1 lg:hidden">
|
||||
<a href="{{ route('events.index') }}"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white shadow-sm text-slate-500 hover:text-slate-900 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</a>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-slate-900">{{ $qrCode->label }}</span>
|
||||
</div>
|
||||
|
||||
{{-- Desktop page header --}}
|
||||
<div class="hidden lg:block">
|
||||
<a href="{{ route('events.index') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||||
QR Codes
|
||||
</a>
|
||||
<h1 class="mt-1.5 text-2xl font-bold text-slate-900">{{ $qrCode->label }}</h1>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {{ $qrCode->is_active ? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200' : 'bg-slate-100 text-slate-500' }}">
|
||||
{{ $qrCode->is_active ? 'Active' : 'Paused' }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-400">{{ $qrCode->typeLabel() }}</span>
|
||||
<code class="rounded-lg bg-slate-100 px-2 py-0.5 text-xs text-slate-600">{{ $qrCode->publicUrl() }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Main 2-column layout --}}
|
||||
<form x-ref="updateForm" action="{{ route('events.update', $qrCode) }}" method="POST" enctype="multipart/form-data"
|
||||
class="grid grid-cols-1 gap-8 lg:grid-cols-[380px_1fr]">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
|
||||
{{-- Left: Sticky preview + downloads (desktop only) --}}
|
||||
<div class="hidden lg:block lg:sticky lg:top-6 lg:self-start">
|
||||
@include('qr-codes.partials.qr-preview-card', [
|
||||
'qrCode' => $qrCode,
|
||||
'previewDataUri' => $previewDataUri,
|
||||
'showDownloads' => true,
|
||||
])
|
||||
</div>
|
||||
|
||||
{{-- Right: Controls --}}
|
||||
<div class="space-y-5 pb-4 lg:pb-0">
|
||||
|
||||
{{-- Content first --}}
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<div class="border-b border-slate-100 bg-slate-50/70 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Content</h2>
|
||||
<p class="mt-0.5 text-xs text-slate-400">
|
||||
@if($qrCode->type === \App\Models\QrCode::TYPE_WIFI)
|
||||
Auto-join is locked to the network set when this code was created. Editing updates your saved info but won't change the printed code.
|
||||
@else
|
||||
Edits are free. The printed QR stays the same.
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4 p-5">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-600">Label</label>
|
||||
<input type="text" name="label" value="{{ $qrCode->label }}"
|
||||
class="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
</div>
|
||||
@include('qr-codes.partials.type-fields-edit')
|
||||
<label class="flex items-center gap-2.5 text-sm text-slate-700">
|
||||
<input type="checkbox" name="is_active" value="1" @checked($qrCode->is_active)
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
QR is active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Customizer --}}
|
||||
@include('qr-codes.partials.customization-fields', [
|
||||
'style' => $qrStyle,
|
||||
'moduleStyles' => $moduleStyles,
|
||||
'cornerOuterStyles' => $cornerOuterStyles,
|
||||
'cornerInnerStyles' => $cornerInnerStyles,
|
||||
'frameStyles' => $frameStyles,
|
||||
'showRemoveLogo' => ! empty($qrStyle['logo_path']),
|
||||
])
|
||||
|
||||
{{-- Desktop save button --}}
|
||||
<button type="submit"
|
||||
class="hidden lg:block w-full rounded-xl bg-slate-900 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-700 focus:ring-offset-2">
|
||||
Save changes
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Mobile sticky action bar --}}
|
||||
<div x-show="!showPreviewModal"
|
||||
class="mobile-action-bar lg:hidden">
|
||||
<div class="mobile-action-bar__toolbar">
|
||||
<button type="button"
|
||||
@click="showPreviewModal = true"
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-xl border border-slate-200 bg-slate-50 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-100">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
|
||||
</svg>
|
||||
Preview
|
||||
</button>
|
||||
<button type="button"
|
||||
@click="$refs.updateForm?.requestSubmit()"
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-xl bg-slate-900 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-slate-700">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<div class="mobile-action-bar__inset-fill" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
{{-- Preview modal --}}
|
||||
<div x-show="showPreviewModal" x-cloak
|
||||
x-transition:enter="transition-opacity duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition-opacity duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@keydown.escape.window="showPreviewModal = false"
|
||||
class="fixed inset-0 z-[60] flex flex-col bg-black/60 backdrop-blur-sm lg:hidden"
|
||||
style="padding-bottom: env(safe-area-inset-bottom, 0px)">
|
||||
<div class="flex items-center justify-between px-5 py-4">
|
||||
<span class="text-sm font-semibold text-white">Preview</span>
|
||||
<button type="button" @click="showPreviewModal = false"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-white/20 text-white hover:bg-white/30 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center justify-center p-8">
|
||||
<div class="w-full max-w-xs overflow-hidden rounded-2xl bg-white shadow-2xl"
|
||||
:style="frameStyle === 'thin'
|
||||
? 'box-shadow: 0 0 0 2px ' + frameColor + ', 0 25px 50px -12px rgb(0 0 0 / 0.25)'
|
||||
: 'box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25), 0 0 0 1px rgb(0 0 0 / 0.06)'">
|
||||
<div :class="{
|
||||
'p-[8px]': frameStyle === 'thin',
|
||||
'p-[14px]': frameStyle === 'scan_me' || frameStyle === 'tap_to_scan',
|
||||
'p-4': frameStyle === 'none',
|
||||
}">
|
||||
<div x-ref="qrPreviewModal"
|
||||
class="aspect-square w-full [&>svg]:block [&>svg]:h-full [&>svg]:w-full"></div>
|
||||
<div x-show="frameStyle === 'scan_me'" x-cloak
|
||||
:style="'border-top: 1px solid ' + frameColor + '40; color: ' + frameColor"
|
||||
class="pt-2.5 pb-0.5 text-center">
|
||||
<span x-text="(frameText && frameText.trim() ? frameText : 'SCAN ME').toUpperCase()"
|
||||
class="text-[11px] font-bold tracking-[0.18em]"></span>
|
||||
</div>
|
||||
<div x-show="frameStyle === 'tap_to_scan'" x-cloak class="mt-2.5 flex justify-center pb-0.5">
|
||||
<span x-text="(frameText && frameText.trim() ? frameText : 'TAP TO SCAN').toUpperCase()"
|
||||
:style="'background-color: ' + frameColor + '; color: ' + frameTextColor"
|
||||
class="rounded-full px-5 py-1.5 text-[10px] font-bold tracking-widest"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{-- Downloads pinned to bottom of modal --}}
|
||||
@php
|
||||
$mobileShareUrl = $qrCode->publicUrl();
|
||||
$mobileShareText = urlencode($qrCode->label . ' — scan my QR code');
|
||||
$mobileShareEnc = urlencode($mobileShareUrl);
|
||||
@endphp
|
||||
<div class="grid grid-cols-4 gap-2 px-6 pb-6">
|
||||
<a href="{{ route('events.download', [$qrCode, 'png']) }}"
|
||||
class="flex flex-col items-center gap-1 rounded-xl border border-white/20 bg-white/10 py-3 text-center text-white transition hover:bg-white/20">
|
||||
<span class="text-xs font-semibold">PNG</span>
|
||||
</a>
|
||||
<a href="{{ route('events.download', [$qrCode, 'svg']) }}"
|
||||
class="flex flex-col items-center gap-1 rounded-xl border border-white/20 bg-white/10 py-3 text-center text-white transition hover:bg-white/20">
|
||||
<span class="text-xs font-semibold">SVG</span>
|
||||
</a>
|
||||
<a href="{{ route('events.download', [$qrCode, 'pdf']) }}"
|
||||
class="flex flex-col items-center gap-1 rounded-xl border border-white/20 bg-white/10 py-3 text-center text-white transition hover:bg-white/20">
|
||||
<span class="text-xs font-semibold">PDF</span>
|
||||
</a>
|
||||
<button type="button"
|
||||
@click="if(navigator.share){navigator.share({title:@js($qrCode->label),url:@js($mobileShareUrl)}).catch(()=>{})}else{window.open('https://wa.me/?text={{ $mobileShareText }}%20{{ $mobileShareEnc }}','_blank')}"
|
||||
class="flex flex-col items-center gap-1 rounded-xl border border-sky-400/40 bg-sky-500/20 py-3 text-center text-sky-200 transition hover:bg-sky-500/30">
|
||||
<span class="text-xs font-semibold">Share</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Orders (Book / Menu / Shop) --}}
|
||||
@if(isset($orders) && !is_null($orders))
|
||||
<div class="mt-10 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-slate-900">Orders</h2>
|
||||
<span class="text-xs text-slate-400">{{ $orders->count() }} paid</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ $orders->count() }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Total orders</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
|
||||
<p class="text-2xl font-bold text-slate-900">GHS {{ number_format((float) $orders->sum('amount_ghs'), 2) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Revenue earned</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($orders->isEmpty())
|
||||
<div class="rounded-2xl border border-dashed border-slate-200 bg-white p-8 text-center">
|
||||
<p class="text-sm text-slate-400">No paid orders yet.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-100 bg-slate-50/70">
|
||||
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500">Customer</th>
|
||||
<th class="hidden px-5 py-3 text-left text-xs font-semibold text-slate-500 sm:table-cell">Items</th>
|
||||
<th class="px-5 py-3 text-right text-xs font-semibold text-slate-500">Amount</th>
|
||||
<th class="px-5 py-3 text-right text-xs font-semibold text-slate-500">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
@foreach($orders as $order)
|
||||
<tr class="transition-colors hover:bg-slate-50/50">
|
||||
<td class="px-5 py-3.5">
|
||||
<p class="text-sm font-medium text-slate-900">{{ $order->customer_name ?: '—' }}</p>
|
||||
<p class="text-xs text-slate-400">{{ $order->customer_email ?: '' }}</p>
|
||||
</td>
|
||||
<td class="hidden px-5 py-3.5 text-sm text-slate-600 sm:table-cell">
|
||||
{{ collect($order->items)->map(fn($i) => ($i['qty'] > 1 ? $i['qty'].'× ' : '').$i['name'])->join(', ') }}
|
||||
</td>
|
||||
<td class="px-5 py-3.5 text-right text-sm font-semibold text-slate-900 whitespace-nowrap">
|
||||
GHS {{ number_format((float) $order->amount_ghs, 2) }}
|
||||
</td>
|
||||
<td class="px-5 py-3.5 text-right text-xs text-slate-400 whitespace-nowrap">
|
||||
{{ $order->paid_at?->format('M j, Y') }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Scan analytics --}}
|
||||
<div class="mt-10 space-y-5">
|
||||
<h2 class="text-base font-semibold text-slate-900">Analytics</h2>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['total_scans']) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Total scans</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['unique_scans']) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Unique scans</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['scans_7d']) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Last 7 days</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['scans_30d']) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Last 30 days</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-6 shadow-sm">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Scans — last 30 days</h3>
|
||||
@php $maxDaily = max(1, $dailyScans->max('total')); @endphp
|
||||
<div class="mt-5 flex h-28 items-end gap-px">
|
||||
@foreach($dailyScans as $day)
|
||||
<div class="group relative flex flex-1 flex-col items-center justify-end"
|
||||
title="{{ $day->date }}: {{ $day->total }}">
|
||||
<div class="w-full rounded-t-sm bg-indigo-400/70 transition-colors group-hover:bg-indigo-600"
|
||||
style="height: {{ max(2, ($day->total / $maxDaily) * 100) }}%"></div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Devices</h3>
|
||||
<ul class="mt-4 space-y-2.5">
|
||||
@forelse($devices as $row)
|
||||
<li class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-600">{{ ucfirst($row['label']) }}</span>
|
||||
<span class="font-semibold text-slate-900">{{ $row['total'] }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-sm text-slate-400">No scans yet</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Browsers</h3>
|
||||
<ul class="mt-4 space-y-2.5">
|
||||
@forelse($browsers as $row)
|
||||
<li class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-600">{{ $row['label'] }}</span>
|
||||
<span class="font-semibold text-slate-900">{{ $row['total'] }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-sm text-slate-400">No scans yet</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,13 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Billing</x-slot>
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Billing</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">QR code creation is billed from your Ladill wallet (GHS {{ number_format(config('qr.price_per_qr_ghs', 5), 2) }} per code).</p>
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Current balance</p>
|
||||
<p class="mt-1 text-3xl font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p>
|
||||
<a href="{{ $topupUrl }}" class="btn-primary mt-4">Top up wallet</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,87 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Developers</x-slot>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Developers</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">API tokens to manage your QR codes programmatically.</p>
|
||||
|
||||
@if($newToken)
|
||||
<div class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-5">
|
||||
<p class="text-sm font-semibold text-emerald-900">Your new token — copy it now</p>
|
||||
<p class="mt-1 text-xs text-emerald-700">This is the only time it will be shown.</p>
|
||||
<div class="mt-3 flex items-center gap-2" x-data="{ copied: false }">
|
||||
<code class="flex-1 truncate rounded-lg bg-white px-3 py-2 font-mono text-xs text-slate-800 ring-1 ring-emerald-200">{{ $newToken }}</code>
|
||||
<button @click="navigator.clipboard.writeText('{{ $newToken }}'); copied = true; setTimeout(() => copied = false, 1500)"
|
||||
class="rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700">
|
||||
<span x-show="!copied">Copy</span><span x-show="copied" x-cloak>Copied ✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Create a token</h2>
|
||||
<form method="POST" action="{{ route('account.developers.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
@csrf
|
||||
<div class="flex-1">
|
||||
<label class="block text-[11px] font-medium text-slate-500">Token name</label>
|
||||
<input type="text" name="name" required placeholder="e.g. CI server"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@error('name')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<button class="btn-primary">Generate token</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Your tokens</h2></div>
|
||||
@forelse($tokens as $token)
|
||||
<div class="flex items-center justify-between px-5 py-3.5 {{ ! $loop->last ? 'border-b border-slate-50' : '' }}">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $token->name }}</p>
|
||||
<p class="text-xs text-slate-400">
|
||||
Created {{ $token->created_at->diffForHumans() }} ·
|
||||
{{ $token->last_used_at ? 'last used '.$token->last_used_at->diffForHumans() : 'never used' }}
|
||||
</p>
|
||||
</div>
|
||||
<x-confirm-dialog
|
||||
:name="'revoke-token-'.$token->id"
|
||||
title="Revoke API token?"
|
||||
:message="'Revoke '.$token->name.'? Apps using this token will stop working.'"
|
||||
:action="route('account.developers.destroy', $token->id)"
|
||||
method="DELETE"
|
||||
confirm-label="Revoke"
|
||||
>
|
||||
<x-slot:trigger>
|
||||
<button type="button" class="text-xs font-medium text-rose-600 hover:underline">Revoke</button>
|
||||
</x-slot:trigger>
|
||||
</x-confirm-dialog>
|
||||
</div>
|
||||
@empty
|
||||
<p class="px-5 py-8 text-center text-sm text-slate-400">No tokens yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-900 p-5 text-slate-200">
|
||||
<h2 class="text-sm font-semibold text-white">Quick start</h2>
|
||||
<p class="mt-1 text-xs text-slate-400">Authenticate with a Bearer token. Base URL:</p>
|
||||
<code class="mt-2 block rounded-lg bg-black/40 px-3 py-2 font-mono text-[11px] text-emerald-300">{{ $apiBase }}</code>
|
||||
<pre class="mt-3 overflow-x-auto rounded-lg bg-black/40 px-3 py-3 font-mono text-[11px] leading-relaxed text-slate-300"><code>curl {{ $apiBase }}/qr-codes \
|
||||
-H "Authorization: Bearer <your-token>" \
|
||||
-H "Accept: application/json"</code></pre>
|
||||
<p class="mt-3 text-[11px] text-slate-400">Endpoints:</p>
|
||||
<ul class="mt-1.5 space-y-1 text-[11px] text-slate-400">
|
||||
<li><span class="font-mono text-slate-300">GET /me</span> — token user and acting account</li>
|
||||
<li><span class="font-mono text-slate-300">GET /qr-codes</span> — list your codes</li>
|
||||
<li><span class="font-mono text-slate-300">GET /qr-codes/{id}</span> — code details</li>
|
||||
<li><span class="font-mono text-slate-300">GET /qr-codes/{id}/analytics</span> — scan stats</li>
|
||||
<li><span class="font-mono text-slate-300">POST /qr-codes</span> — create (url, link_list, wifi, business, app)</li>
|
||||
<li><span class="font-mono text-slate-300">PATCH /qr-codes/{id}</span> — update label, content, pause/resume</li>
|
||||
</ul>
|
||||
<pre class="mt-3 overflow-x-auto rounded-lg bg-black/40 px-3 py-3 font-mono text-[11px] leading-relaxed text-slate-300"><code>curl -X POST {{ $apiBase }}/qr-codes \
|
||||
-H "Authorization: Bearer <your-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"label":"Homepage","type":"url","destination_url":"https://example.com"}'</code></pre>
|
||||
<p class="mt-3 text-[11px] text-slate-500">Team access: pass <span class="font-mono text-slate-300">X-Ladill-Account: <owner-user-id></span>. PDF codes need the web app. Regenerate tokens to get write access if yours only has read.</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,260 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Settings</x-slot>
|
||||
|
||||
@php
|
||||
$ed = $eventDefaults;
|
||||
$badgeFieldLabels = old('event_defaults.badge_fields', $ed['badge_fields'] ?? ['Company', 'Role']);
|
||||
@endphp
|
||||
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Settings</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Notifications, defaults for new events, and QR preferences.</p>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('account.settings.update') }}" class="mt-6 space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Notifications</h2>
|
||||
<p class="mt-1 text-xs text-slate-500">Choose what we email you about your events and account.</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="notify_email" class="block text-[11px] font-medium text-slate-500">Notifications email</label>
|
||||
<input type="email" id="notify_email" name="notify_email"
|
||||
value="{{ old('notify_email', $settings->notify_email ?? $account->email) }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@error('notify_email') <p class="mt-1 text-xs text-rose-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<label class="mt-4 flex items-center justify-between gap-4">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">New registrations</span>
|
||||
<span class="block text-xs text-slate-400">Email when someone registers or buys a ticket.</span>
|
||||
</span>
|
||||
<input type="checkbox" name="notify_registrations" value="1"
|
||||
@checked(old('notify_registrations', $settings->notify_registrations ?? true))
|
||||
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</label>
|
||||
|
||||
<label class="mt-4 flex items-center justify-between gap-4">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">Payouts & settlements</span>
|
||||
<span class="block text-xs text-slate-400">Email when ticket revenue is ready to withdraw.</span>
|
||||
</span>
|
||||
<input type="checkbox" name="notify_payouts" value="1"
|
||||
@checked(old('notify_payouts', $settings->notify_payouts ?? true))
|
||||
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</label>
|
||||
|
||||
<label class="mt-4 flex items-center justify-between gap-4">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">Low QR balance</span>
|
||||
<span class="block text-xs text-slate-400">Email when your wallet is too low to publish a new event QR.</span>
|
||||
</span>
|
||||
<input type="checkbox" name="low_balance_alerts" value="1"
|
||||
@checked(old('low_balance_alerts', $settings->low_balance_alerts ?? true))
|
||||
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</label>
|
||||
|
||||
<label class="mt-4 flex items-center justify-between gap-4">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">Ladill Events updates</span>
|
||||
<span class="block text-xs text-slate-400">Occasional emails about new features and improvements.</span>
|
||||
</span>
|
||||
<input type="checkbox" name="product_updates" value="1"
|
||||
@checked(old('product_updates', $settings->product_updates ?? true))
|
||||
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">New event defaults</h2>
|
||||
<p class="mt-1 text-xs text-slate-500">Pre-fill the create flow so every new event starts with your usual setup.</p>
|
||||
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="default_currency" class="block text-[11px] font-medium text-slate-500">Currency</label>
|
||||
<select id="default_currency" name="event_defaults[currency]"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@foreach (['GHS' => 'GHS (Ghanaian Cedi)', 'USD' => 'USD (US Dollar)', 'NGN' => 'NGN (Nigerian Naira)', 'KES' => 'KES (Kenyan Shilling)'] as $code => $label)
|
||||
<option value="{{ $code }}" @selected(old('event_defaults.currency', $ed['currency']) === $code)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="default_mode" class="block text-[11px] font-medium text-slate-500">Collection type</label>
|
||||
<select id="default_mode" name="event_defaults[mode]"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
<option value="ticketing" @selected(old('event_defaults.mode', $ed['mode']) === 'ticketing')>Ticketed event</option>
|
||||
<option value="contributions" @selected(old('event_defaults.mode', $ed['mode']) === 'contributions')>Contributions / gifts</option>
|
||||
<option value="free" @selected(old('event_defaults.mode', $ed['mode']) === 'free')>Free registration</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="default_badge_size" class="block text-[11px] font-medium text-slate-500">Badge size</label>
|
||||
<select id="default_badge_size" name="event_defaults[badge_size]"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@foreach (['4x3' => '4 × 3 in (landscape)', '4x6' => '4 × 6 in (lanyard)', 'cr80' => 'CR80 card'] as $size => $label)
|
||||
<option value="{{ $size }}" @selected(old('event_defaults.badge_size', $ed['badge_size']) === $size)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="default_brand_color" class="block text-[11px] font-medium text-slate-500">Brand color</label>
|
||||
<input type="color" id="default_brand_color" name="event_defaults[brand_color]"
|
||||
value="{{ old('event_defaults.brand_color', $ed['brand_color']) }}"
|
||||
class="mt-1 h-10 w-full cursor-pointer rounded-lg border border-slate-200 bg-white p-1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="default_organizer" class="block text-[11px] font-medium text-slate-500">Default organizer name</label>
|
||||
<input type="text" id="default_organizer" name="event_defaults[organizer]" maxlength="120"
|
||||
value="{{ old('event_defaults.organizer', $ed['organizer']) }}"
|
||||
placeholder="e.g. CAPBuSS Events"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
</div>
|
||||
|
||||
<div class="mt-4" x-data="{ fields: @js(array_values($badgeFieldLabels)) }">
|
||||
<label class="block text-[11px] font-medium text-slate-500">Default badge fields</label>
|
||||
<p class="mt-0.5 text-[11px] text-slate-400">Extra attendee fields shown on badges (e.g. Company, Role).</p>
|
||||
<div class="mt-2 space-y-2">
|
||||
<template x-for="(field, i) in fields" :key="i">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" :name="'event_defaults[badge_fields]['+i+']'" x-model="fields[i]" maxlength="40"
|
||||
class="flex-1 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
<button type="button" x-show="fields.length > 1" @click="fields.splice(i, 1)"
|
||||
class="rounded-lg border border-slate-200 px-2 py-1 text-xs text-slate-500 hover:bg-slate-50">Remove</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button type="button" @click="fields.push('')"
|
||||
class="mt-2 text-xs font-semibold text-indigo-600 hover:text-indigo-800">+ Add field</button>
|
||||
</div>
|
||||
|
||||
<label class="mt-4 flex items-center justify-between gap-4 rounded-xl border border-slate-100 bg-slate-50/80 px-4 py-3">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">Registration open by default</span>
|
||||
<span class="block text-xs text-slate-400">New events accept sign-ups unless you turn this off.</span>
|
||||
</span>
|
||||
<input type="checkbox" name="event_defaults[registration_open]" value="1"
|
||||
@checked(old('event_defaults.registration_open', $ed['registration_open']))
|
||||
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details class="group rounded-2xl border border-slate-200 bg-white">
|
||||
<summary class="cursor-pointer list-none p-5 marker:content-none">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-slate-900">QR code defaults</h2>
|
||||
<p class="mt-1 text-xs text-slate-500">Style applied when you design event QR codes.</p>
|
||||
</div>
|
||||
<svg class="h-5 w-5 shrink-0 text-slate-400 transition group-open:rotate-180" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div class="space-y-4 border-t border-slate-100 px-5 pb-5 pt-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="foreground" class="block text-[11px] font-medium text-slate-500">Foreground</label>
|
||||
<input type="color" id="foreground" name="default_style[foreground]"
|
||||
value="{{ old('default_style.foreground', $style['foreground']) }}"
|
||||
class="mt-1 h-10 w-full cursor-pointer rounded-lg border border-slate-200 bg-white p-1">
|
||||
</div>
|
||||
<div>
|
||||
<label for="background" class="block text-[11px] font-medium text-slate-500">Background</label>
|
||||
<input type="color" id="background" name="default_style[background]"
|
||||
value="{{ old('default_style.background', $style['background']) }}"
|
||||
class="mt-1 h-10 w-full cursor-pointer rounded-lg border border-slate-200 bg-white p-1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="module_style" class="block text-[11px] font-medium text-slate-500">Module style</label>
|
||||
<select id="module_style" name="default_style[module_style]"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@foreach ($moduleStyles as $key => $meta)
|
||||
<option value="{{ $key }}" @selected(old('default_style.module_style', $style['module_style']) === $key)>{{ $meta['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="error_correction" class="block text-[11px] font-medium text-slate-500">Error correction</label>
|
||||
<select id="error_correction" name="default_style[error_correction]"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@foreach (['L' => 'Low', 'M' => 'Medium', 'Q' => 'Quartile', 'H' => 'High'] as $key => $label)
|
||||
<option value="{{ $key }}" @selected(old('default_style.error_correction', $style['error_correction']) === $key)>{{ $label }} ({{ $key }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="finder_outer" class="block text-[11px] font-medium text-slate-500">Corner outer</label>
|
||||
<select id="finder_outer" name="default_style[finder_outer]"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@foreach ($cornerOuterStyles as $key => $meta)
|
||||
<option value="{{ $key }}" @selected(old('default_style.finder_outer', $style['finder_outer']) === $key)>{{ $meta['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="finder_inner" class="block text-[11px] font-medium text-slate-500">Corner inner</label>
|
||||
<select id="finder_inner" name="default_style[finder_inner]"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@foreach ($cornerInnerStyles as $key => $meta)
|
||||
<option value="{{ $key }}" @selected(old('default_style.finder_inner', $style['finder_inner']) === $key)>{{ $meta['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="frame_style" class="block text-[11px] font-medium text-slate-500">Frame</label>
|
||||
<select id="frame_style" name="default_style[frame_style]"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@foreach ($frameStyles as $key => $meta)
|
||||
<option value="{{ $key }}" @selected(old('default_style.frame_style', $style['frame_style']) === $key)>{{ $meta['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="frame_color" class="block text-[11px] font-medium text-slate-500">Frame color</label>
|
||||
<input type="color" id="frame_color" name="default_style[frame_color]"
|
||||
value="{{ old('default_style.frame_color', $style['frame_color']) }}"
|
||||
class="mt-1 h-10 w-full cursor-pointer rounded-lg border border-slate-200 bg-white p-1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="frame_text" class="block text-[11px] font-medium text-slate-500">Frame label</label>
|
||||
<input type="text" id="frame_text" name="default_style[frame_text]" maxlength="100"
|
||||
value="{{ old('default_style.frame_text', $style['frame_text']) }}"
|
||||
placeholder="SCAN ME"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<button type="submit" class="btn-primary">
|
||||
Save settings
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-xs text-slate-400">
|
||||
Profile, password, and security are managed on
|
||||
<a href="{{ ladill_account_url('account-settings') }}" class="font-medium text-indigo-600 hover:text-indigo-800">account.ladill.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user