Files
ladill-qr-plus/resources/views/layouts/user.blade.php
T
isaaccladandCursor cd6571f199
Deploy Ladill QR Plus / deploy (push) Failing after 1s
Extract Ladill QR Plus as standalone app at qr.ladill.com.
Utility QR types only (URL, WiFi, link list, business, app download) with
SSO, Billing API integration, public /q resolver, and qr-plus:import for
platform migration. vCard and commerce types stay on the platform.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 21:31:24 +00:00

458 lines
17 KiB
PHP

@php
$mobileFullScreenPage = request()->routeIs('user.search')
|| request()->routeIs('user.products.cart')
|| request()->routeIs('user.domains.cart')
|| request()->routeIs('user.domains.checkout')
|| request()->routeIs('user.products.checkout')
|| request()->routeIs('user.products.order')
|| request()->routeIs('profile.edit')
|| 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');
$mobileCartCount = 0;
$mobileCartRoute = route('user.products.cart');
if (auth()->check()) {
$mobileProductCartCount = \App\Models\RcServiceOrder::query()
->where('user_id', auth()->id())
->whereIn('status', [
\App\Models\RcServiceOrder::STATUS_CART,
\App\Models\RcServiceOrder::STATUS_PENDING_PAYMENT,
])
->count();
$mobileDomainCartCount = \App\Models\DomainOrder::query()
->where('user_id', auth()->id())
->whereIn('status', [
\App\Models\DomainOrder::STATUS_CART,
\App\Models\DomainOrder::STATUS_PENDING_PAYMENT,
])
->count();
$mobileCartCount = $mobileProductCartCount + $mobileDomainCartCount;
if ($mobileProductCartCount === 0 && $mobileDomainCartCount > 0) {
$mobileCartRoute = route('user.domains.cart');
}
}
@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')
</div>
<div @class([
'px-6 pt-4',
'hidden lg:block' => $mobileFullScreenPage,
])>
@include('partials.flash')
@include('partials.mailbox-link-banner')
</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.search'),
'searchActive' => request()->routeIs('user.search'),
'notificationsUrl' => route('user.notifications.index'),
'notificationsActive' => request()->routeIs('user.notifications.*'),
'unreadUrl' => route('user.notifications.unread'),
'profileActive' => request()->routeIs('profile.edit') || request()->routeIs('account.settings'),
'profileName' => $navUser?->name ?? '',
'profileSubtitle' => $navUser?->email ?? '',
'profileMenuItems' => [
['type' => 'link', 'label' => 'Profile', 'href' => route('profile.edit')],
['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,
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.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>