Files
ladill-qr-plus/resources/views/layouts/user.blade.php
T
isaaccladandCursor bcd1cf5d28
Deploy Ladill QR Plus / deploy (push) Successful in 56s
Fix QR codes views to use user.qr-codes route names.
Removes missing topup route/modal and sends low-balance users to the platform wallet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 06:02:33 +00:00

429 lines
16 KiB
PHP

@php
$mobileFullScreenPage = request()->routeIs('account.settings')
|| request()->routeIs('user.qr-codes.create')
|| request()->routeIs('user.qr-codes.show');
$qrMobilePage = request()->routeIs('user.qr-codes.create') || request()->routeIs('user.qr-codes.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',
'p-0 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('user.qr-codes.create') || request()->routeIs('user.qr-codes.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('qr.dashboard'),
'homeActive' => request()->routeIs('qr.dashboard'),
'searchUrl' => route('user.qr-codes.index'),
'searchActive' => request()->routeIs('user.qr-codes.*'),
'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' => 'Account Settings', 'href' => route('account.settings')],
['type' => 'link', 'label' => 'Overview', 'href' => route('qr.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('topbarSearch', (config = {}) => ({
query: config.initialQuery || '',
results: Array.isArray(config.initialResults) ? config.initialResults : [],
open: !!config.openOnInit,
loading: false,
active: 0,
_abort: null,
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(`/search?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;
}
}
}));
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('afiaChat', (config = {}) => ({
profileOpen: false,
afiaOpen: false,
afiaChatUrl: config.chatUrl || '/ai/chat',
afiaCsrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '',
afiaCurrentPage: config.currentPage ?? null,
afiaLoading: false,
afiaError: '',
afiaInput: '',
afiaSuggestions: [
'How do I register a domain?',
'Help me set up email',
'What hosting plan should I choose?',
],
afiaMessages: [
{
role: 'assistant',
text: "Hi, I'm Afia! I can help with domains, hosting, servers, business email, SMTP services, and billing.",
},
],
openAfia() {
this.profileOpen = false;
this.afiaOpen = true;
document.body.classList.add('overflow-hidden');
this.$nextTick(() => {
this.$refs.afiaInput?.focus();
});
},
closeAfia() {
this.afiaOpen = false;
document.body.classList.remove('overflow-hidden');
},
handleEscape() {
if (this.profileOpen) {
this.profileOpen = false;
return;
}
if (this.afiaOpen) {
this.closeAfia();
}
},
useSuggestion(suggestion) {
if (this.afiaLoading) return;
this.afiaInput = suggestion;
this.sendMessage();
},
async sendMessage() {
const text = this.afiaInput.trim();
if (!text || this.afiaLoading) return;
this.afiaError = '';
this.afiaMessages.push({ role: 'user', text });
this.afiaInput = '';
this.afiaLoading = true;
this.$nextTick(() => {
const el = this.$refs.afiaScroll;
if (el) el.scrollTop = el.scrollHeight;
});
try {
const response = await fetch(this.afiaChatUrl, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': this.afiaCsrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
message: text,
current_page: this.afiaCurrentPage,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.message || 'Afia is unavailable right now.');
}
const reply = (payload?.data?.reply || '').toString().trim();
this.afiaMessages.push({
role: 'assistant',
text: reply !== '' ? reply : 'I could not parse a reply from the AI provider.',
});
} catch (error) {
this.afiaError = error?.message || 'Afia could not process your request.';
this.afiaMessages.push({
role: 'assistant',
text: 'I could not complete that request right now. Please try again.',
});
} finally {
this.afiaLoading = false;
this.$nextTick(() => {
const el = this.$refs.afiaScroll;
if (el) el.scrollTop = el.scrollHeight;
});
}
},
}));
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();
},
}));
});
// Global "/" shortcut to focus search
document.addEventListener('keydown', (e) => {
if (e.key === '/' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName) && !document.activeElement.isContentEditable) {
e.preventDefault();
const input = document.querySelector('[x-data*="topbarSearch"] input');
if (input) input.focus();
}
});
</script>
</body>
</html>