Deploy Ladill QR Plus / deploy (push) Successful in 56s
Removes missing topup route/modal and sends low-balance users to the platform wallet. Co-authored-by: Cursor <cursoragent@cursor.com>
429 lines
16 KiB
PHP
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>
|