Bootstrap Ladill Link from QR Plus extract template.

This commit is contained in:
isaacclad
2026-06-27 10:54:31 +00:00
commit 04e4f6ab51
243 changed files with 26587 additions and 0 deletions
@@ -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,154 @@
@props([
'id',
'title' => 'Add funds',
'description' => '',
'topupAction',
'minTopup' => 5,
'suggestedAmount' => 10,
'ladillWalletBalance' => 0,
'serviceBalance' => null,
'serviceBalanceLabel' => 'Wallet balance',
'pricePerAction' => null,
'actionNoun' => 'action',
'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;
// Balance shown in the explainer — prefer the service balance, fall back to
// the central wallet figure.
$shownBalance = $serviceBalance !== null ? (float) $serviceBalance : (float) $ladillWalletBalance;
@endphp
<x-modal :name="$id" :show="$openOnLoad" maxWidth="md">
<div x-data="{ step: 1 }"
x-on:open-modal.window="String($event.detail) === @js($id) ? step = 1 : null"
class="flex min-h-full flex-col sm:min-h-0">
{{-- Header with step indicator --}}
<div class="border-b border-slate-100 px-5 py-4 pr-12 sm:px-6 sm:pr-14">
<div class="flex items-center gap-1.5">
<span class="h-1.5 w-6 rounded-full transition-colors" :class="step >= 1 ? 'bg-indigo-600' : 'bg-slate-200'"></span>
<span class="h-1.5 w-6 rounded-full transition-colors" :class="step >= 2 ? 'bg-indigo-600' : 'bg-slate-200'"></span>
<span class="ml-2 text-xs font-medium text-slate-400" x-text="'Step ' + step + ' of 2'"></span>
</div>
<h2 class="mt-2 text-lg font-semibold text-slate-900"
x-text="step === 1 ? 'How billing works' : @js($title)">How billing works</h2>
<p x-show="step === 2 && @js((bool) $description)" x-cloak class="mt-1 text-sm text-slate-500">{{ $description }}</p>
</div>
{{-- STEP 1 billing explainer --}}
<div x-show="step === 1" class="flex flex-1 flex-col p-5 sm:p-6">
<p class="text-sm leading-6 text-slate-600">
Ladill is <span class="font-medium text-slate-800">pay-as-you-go</span>. One wallet funds
every Ladill app, and you're only charged when you perform an action.
</p>
<div class="mt-4 space-y-2.5 rounded-xl border border-slate-200 bg-slate-50 p-4">
@if($pricePerAction !== null)
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">Each {{ $actionNoun }} costs</span>
<span class="font-semibold text-slate-900">GHS {{ number_format((float) $pricePerAction, 2) }}</span>
</div>
@endif
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">{{ $serviceBalanceLabel }}</span>
<span class="font-semibold text-slate-900">GHS {{ number_format($shownBalance, 2) }}</span>
</div>
</div>
<p class="mt-4 text-xs leading-5 text-slate-500">
Top up any amount unused funds stay in your wallet for next time. You can withdraw
them whenever you like.
</p>
<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">
Not now
</button>
<button type="button" @click="step = 2" class="btn-primary w-full sm:w-auto">
Add funds
</button>
</div>
</div>
{{-- STEP 2 amount + payment --}}
<form x-show="step === 2" x-cloak 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
<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">
<p class="mt-4 text-xs text-slate-500">Pay securely with Paystack.</p>
<div class="mt-6 flex flex-col gap-2 sm:mt-auto sm:flex-row sm:justify-end">
<button type="button" @click="step = 1"
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">
Back
</button>
<button type="submit" :disabled="loading"
class="btn-primary w-full sm:w-auto">
<span x-text="loading ? 'Processing…' : 'Continue to payment'">Continue to payment</span>
</button>
</div>
@include('partials.paystack-sheet')
</form>
</div>
</x-modal>