64 Commits
Author SHA1 Message Date
isaacclad e1ba05edae Let hero stats cards auto-expand to fit long values.
Deploy Ladill Servers / deploy (push) Successful in 23s
Replace fixed mobile widths and equal grid columns with content-sized cards so figures like GHS balances and storage sizes are never clipped.
2026-07-24 14:53:49 +00:00
isaacclad e5e125ec23 Stop Paystack pre-modal flash on customer checkout.
Deploy Ladill Servers / deploy (push) Successful in 44s
Use the shared Inline sheet, hold busy until payment UI opens, and avoid locking page scroll during the handoff.
2026-07-24 10:14:41 +00:00
isaacclad 8d2d4bd2f0 Simplify pay wait copy and scale Paystack to fill the loading area.
Deploy Ladill Servers / deploy (push) Successful in 1m29s
Use “Please wait…” while loading, and scale the contained Paystack iframe so
checkout fills the sheet body without a floating second card.
2026-07-23 22:57:31 +00:00
isaacclad 8b1830e83a Render Paystack flush in the sheet loading area without a second stage.
Deploy Ladill Servers / deploy (push) Has been cancelled
Drop host shadows and keep the iframe transparent so checkout fills the
Ladill sheet body instead of floating as a second card.
2026-07-23 22:44:47 +00:00
isaacclad 799a52f556 Force Paystack checkout into the sheet mount on first insert.
Deploy Ladill Servers / deploy (push) Has been cancelled
Redirect first iframe append into the Ladill sheet body and keep absolute
fill styles applied so checkout is not a second full-screen modal.
2026-07-23 22:36:56 +00:00
isaacclad ca8931cf0f Open Paystack from the Pay click gesture, not after fetch.
Deploy Ladill Servers / deploy (push) Successful in 59s
Paystack blocks iframes, so checkout uses a popup beside the sheet/modal chrome. Opening about:blank on the user tap keeps that window from being blocked after the async initiate response.
2026-07-21 18:13:50 +00:00
isaacclad b4b5717906 Fix Paystack sheet: stop iframe embeds that Paystack blocks.
Deploy Ladill Servers / deploy (push) Successful in 26s
Paystack checkout.paystack.com returns X-Frame-Options: SAMEORIGIN, so the shared sheet opened empty. Keep mobile sheet/desktop modal chrome, launch checkout in a named window with a Continue CTA fallback, and escape payment-return via window.opener or top.
2026-07-21 17:35:04 +00:00
isaacclad 4531ab8066 Present Paystack checkout in a responsive mobile sheet and desktop modal.
Deploy Ladill Servers / deploy (push) Successful in 42s
Standardize customer-facing Ladill Pay checkout shells across products, open them on all viewports, and escape iframe callbacks back to the product flow.
2026-07-21 16:50:07 +00:00
isaacclad dd5031aa0b Sync launcher after restoring Merchant/Mini/Give and adding One.
Deploy Ladill Servers / deploy (push) Successful in 47s
2026-07-21 14:24:28 +00:00
isaacclad 0b3dbc4832 Fix deploy tmp paths: drop unset gitea.run_attempt.
Deploy Ladill Servers / deploy (push) Successful in 1m2s
act_runner v0.2.x does not expose gitea.run_attempt, so release archive and
workspace paths rendered with a trailing dash. Use gitea.run_id only, matching
the Meet/CRM deploy workflows.
2026-07-17 12:55:06 +00:00
isaacclad bd8d12aad4 Remove Merchant, Mini, and Give from the app launcher.
Deploy Ladill Servers / deploy (push) Successful in 1m55s
Registry marks them soon pending BoG licensing; propagate the clean launcher so every app matches.
2026-07-15 13:28:59 +00:00
isaacclad c4025f9954 Remove desktop header search from app topbars.
Deploy Ladill Servers / deploy (push) Successful in 57s
Drop the lg+ topbar search field; mobile search entry points are unchanged.
2026-07-09 06:57:33 +00:00
isaacclad 749a9990f9 Sync app launcher from registry
Deploy Ladill Servers / deploy (push) Successful in 1m12s
2026-07-07 23:14:09 +00:00
isaacclad 5a08da1051 Show boot splash on app open/reload for logged-in users.
Deploy Ladill Servers / deploy (push) Successful in 1m23s
Replace the once-per-session sessionStorage guard (which suppressed the loading
screen after its first appearance) with entry-aware logic: show on app entry
and reloads, suppress on internal link navigations and back/forward.
2026-07-07 20:35:54 +00:00
isaacclad 723da7b2f0 Sync app launcher from registry
Deploy Ladill Servers / deploy (push) Successful in 1m14s
2026-07-07 14:43:19 +00:00
isaacclad 5913cd8e8b Prefer system npm in deploy CI when portable node-gyp is broken.
Deploy Ladill Servers / deploy (push) Successful in 33s
Falls back to downloading Node only when host npm is unavailable, fixing npm ci failures on ladill-deploy-runner.
2026-07-07 00:46:54 +00:00
isaacclad 72dfd41b78 Sync app launcher from registry
Deploy Ladill Servers / deploy (push) Failing after 3s
2026-07-07 00:22:14 +00:00
isaaccladandCursor b6defa5e47 Add page heroes to VPS and dedicated server pages.
Deploy Ladill Servers / deploy (push) Successful in 1m49s
Surface server counts, status breakdown, and available plans in heroes consistent with other Ladill apps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 14:19:41 +00:00
isaaccladandCursor 55dff218e2 Propagate shared copy-button component and clipboard helper.
Deploy Ladill Servers / deploy (push) Successful in 49s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 23:04:31 +00:00
isaacclad e4bf942eec Sync mobile notification badge and dashboard icon-link partials.
Deploy Ladill Servers / deploy (push) Successful in 1m4s
Align mobile unread badge with desktop and add plus icon support for panel actions.
2026-07-03 22:41:02 +00:00
isaacclad 4b9320f4db Sync app launcher from registry
Deploy Ladill Servers / deploy (push) Successful in 42s
2026-07-01 17:05:40 +00:00
isaaccladandCursor 510f0a1af9 Throttle platform SSO session checks to cut per-request auth latency.
Deploy Ladill Servers / deploy (push) Successful in 1m4s
Verify auth.ladill.com at most once every 90s; recommend Redis for session/cache in .env.example.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 16:51:22 +00:00
isaacclad 6d08d97699 Add Queue to app launcher config and icon.
Deploy Ladill Servers / deploy (push) Successful in 48s
Propagated from ladill monorepo launcher sync.
2026-06-29 22:12:15 +00:00
isaacclad 20cdf7d8f1 Sync app launcher from registry
Deploy Ladill Servers / deploy (push) Successful in 28s
2026-06-29 17:53:26 +00:00
isaaccladandCursor 5685990a71 Cap launcher dropdown at 15 visible apps (5 rows).
Deploy Ladill Servers / deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 17:44:58 +00:00
isaaccladandCursor 0defeaf207 Hide launcher down arrow when scrolled to the end.
Deploy Ladill Servers / deploy (push) Successful in 1m25s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 14:48:47 +00:00
isaaccladandCursor b835bb95ca Revert launcher scroll rail and fade; keep down arrow hint.
Deploy Ladill Servers / deploy (push) Successful in 45s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 14:02:27 +00:00
isaaccladandCursor 0e6b0013a3 Make launcher scroll affordance visible on open.
Deploy Ladill Servers / deploy (push) Successful in 31s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 13:52:55 +00:00
isaacclad d7e6c29335 Sync app launcher from registry
Deploy Ladill Servers / deploy (push) Successful in 26s
2026-06-29 13:29:55 +00:00
isaacclad a1e00ed91e Always show launcher scroll indicator and cap at 18 apps.
Deploy Ladill Servers / deploy (push) Successful in 24s
Apply ladill-launcher-apps scroll styles unconditionally with a persistent
scrollbar track; propagate launcher blade and CSS across all Ladill apps.
2026-06-29 13:04:48 +00:00
isaaccladandCursor 0c917bff5c Use + icon buttons on mobile dashboards.
Deploy Ladill Servers / deploy (push) Successful in 44s
Sync mobile-header-btn partials, mobile x-btn.create, and update dashboard header actions for consistent mobile FABs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 16:21:47 +00:00
isaaccladandCursor 2a37f5dde5 Place wallet widget after Billing link in mobile profile sheet.
Deploy Ladill Servers / deploy (push) Successful in 1m21s
Remove duplicate Billing header above Home; wallet card follows Billing
in menu order to match the desktop avatar dropdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 14:07:47 +00:00
isaaccladandCursor 76545d8b10 Sync mobile profile wallet card and 24h session idle timeout.
Deploy Ladill Servers / deploy (push) Successful in 30s
Show wallet balance prominently in the mobile profile bottom sheet with refresh
on open; align session lifetime with the platform default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 12:46:20 +00:00
isaaccladandCursor 5d5ecc3ee0 Hide header avatar on mobile and sync shared mobile nav partials.
Deploy Ladill Servers / deploy (push) Successful in 52s
Profile moves to the bottom nav sheet on small screens; optional search tab
support is synced from the Ladill platform template.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 12:04:33 +00:00
isaacclad 5fd04dc90a Sync app launcher from registry — add Frontdesk.
Deploy Ladill Servers / deploy (push) Successful in 34s
2026-06-28 01:46:39 +00:00
isaaccladandCursor f13aa7f884 Sync app launcher from registry
Deploy Ladill Servers / deploy (push) Successful in 24s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 19:03:58 +00:00
isaaccladandCursor c1ee58fb03 Sync app launcher from registry
Deploy Ladill Servers / deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 18:28:04 +00:00
isaacclad affeb8c22b Sync app launcher from registry
Deploy Ladill Servers / deploy (push) Successful in 29s
2026-06-27 15:16:28 +00:00
isaaccladandClaude Opus 4.8 65d222aa63 Surface connected domains in Ladill Domains' My Domains
Deploy Ladill Servers / deploy (push) Successful in 1m21s
Push the customer's connected domain to the central connected-domains registry
so it appears under My Domains (with Transfer in if not registered with Ladill).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:17:32 +00:00
isaaccladandClaude Opus 4.8 0a9b0168c7 Register issued certs with Ladill Domains central SSL registry
Deploy Ladill Servers / deploy (push) Successful in 57s
After provisioning a site cert on its node, report it to Domains' central SSL
registry (best-effort, non-blocking) so Domains tracks every platform cert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:47:39 +00:00
e1a2ceff0a Add Accounting to the app launcher
Deploy Ladill Servers / deploy (push) Successful in 1m12s
Ladill Accounting is now live at accounting.ladill.com; add its row + icon to
the shared app launcher (byte-identical across all Ladill apps).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:49:22 +00:00
isaaccladandClaude Opus 4.8 952ef4f1a4 Add branded boot splash (Ladill Mail style) on app pages
Deploy Ladill Servers / deploy (push) Successful in 56s
InjectBootSplash middleware shows a self-branded loading splash once per session
while the app boots, then fades out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:15:24 +00:00
isaaccladandClaude Opus 4.8 14deb710d8 Update POS launcher icon (app launcher + marketing)
Deploy Ladill Servers / deploy (push) Successful in 24s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:46:59 +00:00
isaaccladandCursor 8bc717a2a8 Sync app launcher config and add POS icon.
Deploy Ladill Servers / deploy (push) Successful in 20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 22:53:57 +00:00
isaaccladandCursor 317cab4138 Deliver hosting resource limit alerts in-app only.
Deploy Ladill Servers / deploy (push) Successful in 30s
Stop emailing users when their servers hit usage limits; keep database notifications for the in-app bell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:28:59 +00:00
isaaccladandCursor b0cc71e541 Standardize desktop topbar widgets to match CRM layout.
Deploy Ladill Servers / deploy (push) Successful in 22s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:16:44 +00:00
isaaccladandClaude Opus 4.8 6ba9d7ba2d Add wallet balance widget to the profile menu (after Billing)
Deploy Ladill Servers / deploy (push) Successful in 22s
Wallet balance peek rendered as a menu row directly under Billing, backed by
a /wallet/balance endpoint (BillingClient) and guarded by Route::has. Also
opens Afia via a direct window event for reliability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:25:41 +00:00
isaaccladandClaude Opus 4.8 dac9fd37fc Add CRM to the Ladill app launcher
Deploy Ladill Servers / deploy (push) Successful in 25s
crm.ladill.com is now live in the suite; add its launcher row + icon
(byte-identical shared launcher config).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:41:28 +00:00
isaaccladandClaude Opus 4.8 c7a5a87a2e Add Invoice to the app launcher
Deploy Ladill Servers / deploy (push) Successful in 24s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:19:12 +00:00
isaaccladandCursor fa38d1e06c Bind app sessions to the platform auth session.
Deploy Ladill Servers / deploy (push) Successful in 26s
Clear local sessions when auth.ladill.com signs out and ping the platform session from keepalive.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 08:26:30 +00:00
isaaccladandCursor 2094eafd98 Add Ladill SMS to shared app launcher.
Deploy Ladill Servers / deploy (push) Successful in 31s
Include sms.ladill.com in the cross-app launcher and ship the SMS icon asset.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 23:12:36 +00:00
isaaccladandCursor 191a7c8b99 Fix domain purchase modal on user-layout order flows.
Deploy Ladill Servers / deploy (push) Successful in 50s
Include the purchase modal in user-layout and stack it above the order dialog so Get a new domain opens correctly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 19:29:02 +00:00
isaaccladandCursor f2c287adef Remove duplicate close button from domain purchase modal.
Deploy Ladill Servers / deploy (push) Successful in 25s
Rely on the shared modal component's close control instead of rendering a second one in the embed header.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 19:17:29 +00:00
isaaccladandCursor 2115747588 Merge platform registry into owned-domain picker lookups.
Deploy Ladill Servers / deploy (push) Successful in 28s
Fetch owned domains from both domains.ladill.com and the platform registry so legacy customer domains populate the Ladill picker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 18:17:03 +00:00
isaaccladandCursor 942d996f12 Wire Ladill domain picker and purchase modal into servers flows.
Deploy Ladill Servers / deploy (push) Successful in 27s
Use the Domains API for owned domains across panel, account, and order forms, with an embedded iframe purchase modal instead of redirecting to domains.ladill.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 18:01:23 +00:00
isaaccladandCursor 4a297d50c9 Hide the current page link in the unified profile dropdown.
Deploy Ladill Servers / deploy (push) Successful in 41s
Skip Home, account, and admin menu items when the user is already on that destination.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 10:16:59 +00:00
isaaccladandCursor c48cb46e28 Fix corrupted profileMenuItems in layouts after menu propagation.
Deploy Ladill Servers / deploy (push) Has been cancelled
Remove orphaned array entries that caused Blade parse errors and 500 responses on authenticated pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 10:05:10 +00:00
isaaccladandCursor a30a0f659f Unify logged-in profile dropdown with platform account menu.
Deploy Ladill Servers / deploy (push) Successful in 46s
Wire UserProfileMenu and shared Blade partial into topbar, mobile nav, and layouts so menu items match the account portal (Ladill Mail excluded).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 09:34:14 +00:00
isaaccladandCursor 893af15541 Remove centralized account nav from app sidebar; redirect legacy URLs.
Deploy Ladill Servers / deploy (push) Successful in 44s
Wallet, Billing, Team, Developers, and Support live on account.ladill.com.
Siloed sidebars keep product nav and app Settings only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 00:43:08 +00:00
isaaccladandCursor 87651fb831 Restore silent SSO on app entry for users switching between apps.
Deploy Ladill Servers / deploy (push) Successful in 24s
Guests without a local session try prompt=none SSO first; marketing pages
are only for visitors with no platform session (localhost fix retained).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 13:47:55 +00:00
isaaccladandCursor 09f63d2ca2 Fix guest entry: correct marketing URL and stop auto silent SSO.
Deploy Ladill Servers / deploy (push) Successful in 41s
Correct localhost marketing_url default and stop redirecting guests through
/sso/connect on visit; SSO starts only via explicit sign-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 13:09:47 +00:00
isaaccladandCursor 0188f715a8 Send SSO guests to ladill.com/products/servers instead of subdomain landings.
Deploy Ladill Servers / deploy (push) Successful in 29s
Remove interactive OAuth fallback on silent SSO failure and add ladill.marketing_url config so app subdomains open the dashboard when signed in locally.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 22:22:46 +00:00
isaaccladandClaude Opus 4.8 2e564c2067 Afia: render markdown bold in chat instead of showing raw asterisks
Deploy Ladill Servers / deploy (push) Successful in 57s
Replies came back with **bold** markup that displayed literally to users.
Render the bubble as HTML (escaped first for safety) and convert **text** to
<strong>; newlines already handled by whitespace-pre-line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:37:02 +00:00
isaaccladandClaude Opus 4.8 9cccc9e441 Afia: fix prompt to Ladill Servers (VPS/dedicated), not shared hosting
Deploy Ladill Servers / deploy (push) Successful in 56s
The Servers app's Afia used the shared-hosting prompt, so it refused VPS/
dedicated questions and gave wrong answers. Rewrite as Ladill Servers — VPS
and dedicated plans, ordering, SSH access, server panel, billing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:34:34 +00:00
74 changed files with 2823 additions and 616 deletions
+13 -3
View File
@@ -28,8 +28,14 @@ DB_DATABASE=ladill_servers
DB_USERNAME=ladill_servers
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
SESSION_DRIVER=redis
# Idle web session length in minutes (1440 = 24 hours of inactivity).
SESSION_LIFETIME=1440
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=.ladill.com
@@ -37,7 +43,7 @@ SESSION_DOMAIN=.ladill.com
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
CACHE_STORE=redis
LADILL_SSO_CLIENT_ID=
LADILL_SSO_CLIENT_SECRET=
@@ -48,6 +54,10 @@ BILLING_API_KEY_SERVERS=
IDENTITY_API_URL=https://ladill.com/api
IDENTITY_API_KEY_SERVERS=
DOMAINS_API_URL=https://domains.ladill.com/api
DOMAINS_API_KEY_SERVERS=
DOMAIN_REGISTRY_API_URL=https://ladill.com/api
DOMAIN_API_URL=https://ladill.com/api/domains
DOMAIN_API_KEY_SERVERS=
+16 -14
View File
@@ -19,8 +19,9 @@ jobs:
env:
NODE_ROOT: /tmp/ladill-node-22-r1
NODE_VERSION: "22.14.0"
RELEASE_ARCHIVE: /tmp/ladill-servers-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-servers-${{ gitea.run_id }}-${{ gitea.run_attempt }}
# act_runner v0.2.x does not expose gitea.run_attempt — use run_id only.
RELEASE_ARCHIVE: /tmp/ladill-servers-release-${{ gitea.run_id }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-servers-${{ gitea.run_id }}
LADILL_APP_ROOT: /var/www/ladill-servers
steps:
- name: Checkout
@@ -38,11 +39,17 @@ jobs:
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
"$REPO_URL" "$WORKSPACE"
- name: Setup Node.js
- name: Build frontend assets
shell: bash {0}
run: |
set -Eeuo pipefail
if [ ! -x "$NODE_ROOT/bin/node" ]; then
cd "$WORKSPACE"
if command -v npm >/dev/null 2>&1 && npm -v >/dev/null 2>&1; then
NPM_BIN="$(command -v npm)"
elif [ -x "$NODE_ROOT/bin/npm" ]; then
export PATH="$NODE_ROOT/bin:$PATH"
NPM_BIN="$NODE_ROOT/bin/npm"
else
rm -rf "$NODE_ROOT"
mkdir -p "$NODE_ROOT"
case "$(uname -m)" in
@@ -52,17 +59,12 @@ jobs:
esac
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar -xJ --strip-components=1 -C "$NODE_ROOT"
export PATH="$NODE_ROOT/bin:$PATH"
NPM_BIN="$NODE_ROOT/bin/npm"
fi
"$NODE_ROOT/bin/node" -v
"$NPM_BIN" ci --no-audit --no-fund
"$NPM_BIN" run build
- name: Build frontend assets
shell: bash {0}
run: |
set -Eeuo pipefail
cd "$WORKSPACE"
export PATH="$NODE_ROOT/bin:$PATH"
npm ci --no-audit --no-fund
npm run build
- name: Build release archive
shell: bash {0}
@@ -78,7 +80,7 @@ jobs:
- name: Deploy release
shell: bash {0}
env:
LADILL_RELEASE_ARCHIVE: /tmp/ladill-servers-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
LADILL_RELEASE_ARCHIVE: /tmp/ladill-servers-release-${{ gitea.run_id }}.tgz
run: |
set -Eeuo pipefail
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
@@ -77,10 +77,7 @@ class SsoLoginController extends Controller
if ($request->filled('error')) {
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
&& ! $request->boolean('interactive')) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
return redirect()->away((string) config('ladill.marketing_url'));
}
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
@@ -140,6 +137,18 @@ class SsoLoginController extends Controller
return redirect()->away($this->defaultSignedOutUrl());
}
/** Platform session ended — clear this app and offer silent sign-in again. */
public function platformSignedOut(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => (string) $request->query('redirect', ''),
]);
}
public function logoutBridge(Request $request): View
{
$return = $this->safeReturnUrl((string) $request->query('return', ''));
@@ -1116,6 +1116,18 @@ class HostingPanelController extends Controller
$onboardingMode = $isOwnedDomain
? Domain::MODE_NS_AUTO
: ($validated['onboarding_mode'] ?? Domain::MODE_NS_AUTO);
if ($isOwnedDomain) {
$owned = $this->availableHostingDomainsForUser($request->user()->id, $account);
if (! $owned->contains($domainHost)) {
if ($request->wantsJson()) {
return response()->json(['message' => 'That domain is not in your Ladill account.'], 422);
}
return back()->with('error', 'That domain is not in your Ladill account.');
}
}
$serverIp = $account->node?->ip_address ?? '161.97.138.149';
$docRoot = ! empty($validated['document_root'])
? "/home/{$account->username}/" . ltrim($validated['document_root'], '/')
@@ -1297,35 +1309,13 @@ class HostingPanelController extends Controller
->filter()
->all();
$domainRegistryHosts = Domain::where('user_id', $userId)->pluck('host');
$user = \App\Models\User::query()->find($userId);
$owned = $user
? app(\App\Services\Domains\LadillDomainsClient::class)->ownedForUser((string) $user->public_id)
: [];
$registeredDomains = RcServiceOrder::where('user_id', $userId)
->whereIn('order_type', [RcServiceOrder::TYPE_DOMAIN_REGISTRATION, RcServiceOrder::TYPE_DOMAIN_TRANSFER])
->where('status', RcServiceOrder::STATUS_ACTIVE)
->whereNotNull('domain_name')
->pluck('domain_name');
$customerHostingDomains = CustomerHostingOrder::where('user_id', $userId)
->whereNotIn('status', [
CustomerHostingOrder::STATUS_PENDING_PAYMENT,
CustomerHostingOrder::STATUS_CANCELLED,
CustomerHostingOrder::STATUS_FAILED,
])
->whereNotNull('domain_name')
->pluck('domain_name');
$hostingAccountDomains = HostingAccount::where('user_id', $userId)
->whereNotNull('primary_domain')
->pluck('primary_domain');
return $domainRegistryHosts
->merge($registeredDomains)
->merge($customerHostingDomains)
->merge($hostingAccountDomains)
->map(fn ($d) => strtolower(trim((string) $d)))
->filter(fn ($d) => (bool) preg_match('/^(?=.{1,253}$)(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/', $d))
return collect($owned)
->reject(fn ($d) => in_array($d, $linkedDomains, true))
->unique()
->sort()
->values();
}
@@ -172,6 +172,14 @@ class HostingProductController extends Controller
CustomerHostingOrder::CYCLE_YEARLY => 'Yearly (12 months)',
];
$pendingStatuses = ['pending_payment', 'pending_approval', 'approved', 'provisioning'];
$heroStats = [
'total' => $orders->count() + $rcServiceOrders->count(),
'active' => $orders->where('status', 'active')->count() + $rcServiceOrders->where('status', 'active')->count(),
'pending' => $orders->whereIn('status', $pendingStatuses)->count() + $rcServiceOrders->whereIn('status', $pendingStatuses)->count(),
'plans' => $products->count(),
];
return view('hosting.server-type', [
'title' => $title,
'type' => $type,
@@ -182,6 +190,7 @@ class HostingProductController extends Controller
'serverOrderPayloads' => $serverOrderPayloads,
'billingCycles' => $billingCycles,
'userDomains' => $userDomains,
'heroStats' => $heroStats,
]);
}
@@ -632,17 +641,9 @@ class HostingProductController extends Controller
return $product->catalogSummary();
}
private function getUserDomains($user): \Illuminate\Support\Collection
private function getUserDomains($user): array
{
return \App\Models\Domain::query()
->where('user_id', $user->id)
->whereNull('hosting_account_id')
->whereDoesntHave('hostedSites')
->where(function ($query) {
$query->where('source', 'purchased')
->orWhereNotNull('email_domain_id');
})
->orderBy('host')
->get(['id', 'host', 'status', 'onboarding_state']);
return app(\App\Services\Domains\LadillDomainsClient::class)
->ownedForUser((string) $user->public_id);
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Services\Domains\LadillDomainsClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class OwnedDomainsController extends Controller
{
public function __invoke(Request $request, LadillDomainsClient $domains): JsonResponse
{
$exclude = collect($request->query('exclude', []))
->map(fn ($d) => strtolower(trim((string) $d)))
->filter()
->all();
$owned = collect($domains->ownedForUser((string) ladill_account()->public_id))
->reject(fn ($d) => in_array($d, $exclude, true))
->sort()
->values()
->all();
return response()->json(['data' => $owned]);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Services\Billing\BillingClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/**
* Lightweight wallet balance for the avatar dropdown widget.
*/
class WalletBalanceController extends Controller
{
public function balance(Request $request, BillingClient $billing): JsonResponse
{
$publicId = (string) $request->user()->public_id;
$minor = Cache::remember("wallet_balance:{$publicId}", now()->addSeconds(30), function () use ($billing, $publicId) {
try {
return $billing->balanceMinor($publicId);
} catch (\Throwable) {
return null;
}
});
if ($minor === null) {
return response()->json(['available' => false]);
}
$currency = (string) config('billing.currency', 'GHS');
return response()->json([
'available' => true,
'balance_minor' => $minor,
'currency' => $currency,
'formatted' => $currency.' '.number_format($minor / 100, 2),
]);
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
/**
* App sessions are subordinate to the platform (auth.ladill.com) session.
* When the platform session ends, clear this app's local session too.
*
* Re-checks auth.ladill.com at most once per TTL not on every request.
* Client-side sso-keepalive still pings periodically between checks.
*/
class EnsurePlatformSession
{
private const VERIFY_TTL_SECONDS = 90;
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
return $next($request);
}
$authDomain = trim((string) config('app.auth_domain', ''));
if ($authDomain === '') {
return $next($request);
}
$verifiedAt = (int) $request->session()->get('platform_session_verified_at', 0);
if ($verifiedAt > 0 && (time() - $verifiedAt) < self::VERIFY_TTL_SECONDS) {
return $next($request);
}
$cookieHeader = (string) $request->headers->get('Cookie', '');
if ($cookieHeader === '') {
return $this->clearAppSession($request);
}
try {
$response = Http::withHeaders(['Cookie' => $cookieHeader])
->timeout(3)
->connectTimeout(2)
->get('https://'.$authDomain.'/sso/ping');
} catch (\Throwable) {
return $next($request);
}
if ($response->status() === 401) {
return $this->clearAppSession($request);
}
if ($response->successful()) {
$request->session()->put('platform_session_verified_at', time());
}
return $next($request);
}
private function clearAppSession(Request $request): Response
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => $request->fullUrl(),
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
/**
* Injects a branded boot splash (Ladill Mail style) into authenticated HTML
* pages. The splash shows once per browser session while the app loads, then
* fades out masking the client-side boot so the app feels instant.
*/
class InjectBootSplash
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $request->isMethod('GET') || ! Auth::check()) {
return $response;
}
if (! str_contains((string) $response->headers->get('Content-Type', ''), 'text/html')) {
return $response;
}
$content = $response->getContent();
if (! is_string($content)
|| stripos($content, '<body') === false
|| str_contains($content, 'id="ladill-boot"')) {
return $response;
}
$splash = View::make('partials.boot-splash')->render();
$content = preg_replace_callback('/<body\b[^>]*>/i', fn ($m) => $m[0].$splash, $content, 1);
if (is_string($content)) {
$response->setContent($content);
}
return $response;
}
}
+13
View File
@@ -56,6 +56,16 @@ class ProvisionHostingSslJob implements ShouldQueue
$site->update(['ssl_status' => 'provisioning']);
// Surface this site's domain in Ladill Domains' "My Domains" (best-effort,
// independent of SSL outcome).
if ($account->user?->public_id) {
app(\App\Services\Ssl\CentralSslRegistry::class)->registerConnectedDomain(
$site->domain,
(string) $account->user->public_id,
'Server: '.$site->domain,
);
}
try {
$provider->requestLetsEncryptCertificate($site);
@@ -70,6 +80,9 @@ class ProvisionHostingSslJob implements ShouldQueue
'site_id' => $this->siteId,
'domain' => $site->domain,
]);
// Record the cert in Ladill Domains' central SSL registry (best-effort).
app(\App\Services\Ssl\CentralSslRegistry::class)->register($site->domain, $site->ssl_expires_at);
} catch (\Throwable $exception) {
$message = $exception->getMessage();
@@ -21,7 +21,7 @@ class HostingResourceWarningNotification extends Notification implements ShouldQ
public function via($notifiable): array
{
return ['mail', 'database'];
return ['database'];
}
public function toMail($notifiable): MailMessage
+16 -19
View File
@@ -90,31 +90,28 @@ class AfiaService
{
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
return match ((string) config('afia.product', 'hosting')) {
'email' => $this->emailSystemPrompt($ctx),
default => $this->hostingSystemPrompt($ctx),
};
return $this->serversSystemPrompt($ctx);
}
private function hostingSystemPrompt(string $ctx): string
private function serversSystemPrompt(string $ctx): string
{
return <<<PROMPT
You are Afia, the assistant inside Ladill Hosting Ladill's shared web hosting product (hosting.ladill.com).
Help the user buy hosting, manage accounts, link domains, use the control panel, and understand billing. Be concise,
friendly, and actionable: give short step-by-step answers and point to the relevant section by name.
You are Afia, the assistant inside Ladill Servers Ladill's VPS and dedicated server product (servers.ladill.com).
Help the user choose and order VPS or dedicated servers, access them over SSH, use the server panel, and understand
billing. Be concise, friendly, and actionable: give short step-by-step answers and point to the relevant section by name.
What Ladill Hosting does and where things live:
- Dashboard: overview of all hosting accounts, pending orders, and linked domains.
- Product pages: Single Domain, Multi Domain, and WordPress hosting order new plans here.
- Account overview: per-account summary (plan, expiry, linked domains, email usage) with a Control Panel button.
- Control Panel: file manager, domains, databases, SSL, PHP settings, cron, terminal, and one-click apps.
- Domains: link a Ladill domain or an external domain to a hosting account; DNS may need updating for external domains.
- Billing: hosting renewals and upgrades are paid from the Ladill wallet (account portal Wallet).
- Team: invite teammates to co-manage hosting (sidebar Team).
What Ladill Servers does and where things live:
- Dashboard: overview of your servers, pending orders, and status.
- VPS: browse and order virtual private servers pick a plan (CPU, RAM, storage), region, and OS, then deploy.
- Dedicated: browse and order dedicated (bare-metal) servers for heavier or isolated workloads.
- Accessing a server: connect over SSH using the credentials/key shown on the server's page.
- Server panel: manage a server power (start/stop/reboot), reinstall OS, console, and networking.
- Billing: server orders and renewals are paid from the Ladill wallet (account portal Wallet).
- Account & Team: manage billing, balance, and teammates from the account area.
Rules: Only answer questions about Ladill Hosting plans, accounts, domains on hosting, the control panel,
SSL, file manager, renewals, and wallet billing. If asked about Ladill Email, domains registration, or VPS/servers,
briefly redirect to the right Ladill app. Never invent passwords, DNS values, or prices tell the user where to find them.
Rules: Only answer questions about Ladill Servers VPS and dedicated plans, ordering, SSH access, the server panel,
reinstalls, networking, and wallet billing. If asked about shared web hosting, domain registration, or email,
briefly redirect to the right Ladill app. Never invent passwords, IP addresses, SSH keys, or prices tell the user where to find them.
Current user context:
{$ctx}
@@ -0,0 +1,82 @@
<?php
namespace App\Services\Domains;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/** Read-only client for domains purchased on Ladill (storefront + platform registry). */
class LadillDomainsClient
{
/** @return list<string> Active domain names owned by the account (lowercase). */
public function ownedForUser(string $publicId): array
{
return collect()
->merge($this->fetchFromStorefront($publicId))
->merge($this->fetchFromPlatform($publicId))
->map(fn ($d) => strtolower(trim((string) $d)))
->filter()
->unique()
->sort()
->values()
->all();
}
/** @return list<string> */
private function fetchFromStorefront(string $publicId): array
{
$key = (string) (config('domains.api_key') ?? '');
if ($key === '') {
return [];
}
try {
$res = Http::withToken($key)
->acceptJson()
->timeout(10)
->get(rtrim((string) config('domains.api_url'), '/').'/owned-domains', [
'user' => $publicId,
]);
$res->throw();
return (array) $res->json('data', []);
} catch (\Throwable $e) {
Log::warning('LadillDomainsClient: storefront owned domains unavailable', [
'user' => $publicId,
'error' => $e->getMessage(),
]);
return [];
}
}
/** @return list<string> */
private function fetchFromPlatform(string $publicId): array
{
$key = (string) (config('domains.platform_api_key') ?? '');
if ($key === '') {
return [];
}
try {
$res = Http::withToken($key)
->acceptJson()
->timeout(10)
->get(rtrim((string) config('domains.platform_api_url'), '/').'/owned-domains', [
'user' => $publicId,
]);
$res->throw();
return (array) $res->json('data', []);
} catch (\Throwable $e) {
Log::warning('LadillDomainsClient: platform owned domains unavailable', [
'user' => $publicId,
'error' => $e->getMessage(),
]);
return [];
}
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Services\Ssl;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Reports certificates this app issued (per hosting node) to Ladill Domains'
* central SSL registry, so Domains is the single source of truth for every
* platform certificate. Best-effort and non-blocking issuance stays local.
*/
class CentralSslRegistry
{
public function register(string $host, ?\DateTimeInterface $expiresAt = null): void
{
$url = rtrim((string) config('domains.api_url'), '/');
$key = (string) config('domains.api_key');
if ($url === '' || $key === '' || $host === '') {
return;
}
try {
Http::withToken($key)->acceptJson()->timeout(10)
->post($url.'/ssl/register', array_filter([
'host' => $host,
'status' => 'active',
'expires_at' => $expiresAt?->format(DATE_ATOM),
]));
} catch (\Throwable $e) {
Log::info('CentralSslRegistry: register skipped', ['host' => $host, 'error' => $e->getMessage()]);
}
}
/** Surface a hosted-site domain in Ladill Domains' "My Domains" (best-effort). */
public function registerConnectedDomain(string $host, string $ownerPublicId, ?string $label = null, ?string $linkUrl = null): void
{
$url = rtrim((string) config('domains.api_url'), '/');
$key = (string) config('domains.api_key');
if ($url === '' || $key === '' || $host === '' || $ownerPublicId === '') {
return;
}
try {
Http::withToken($key)->acceptJson()->timeout(10)
->post($url.'/connected-domains', array_filter([
'host' => $host,
'owner_public_id' => $ownerPublicId,
'label' => $label,
'link_url' => $linkUrl,
]));
} catch (\Throwable $e) {
Log::info('CentralSslRegistry: connected register skipped', ['host' => $host, 'error' => $e->getMessage()]);
}
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
namespace App\Support;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Route;
class UserProfileMenu
{
/**
* Standard signed-in profile links for Ladill product apps.
* Not used by Ladill Mail (mail.ladill.com) that app keeps a mailbox-specific menu.
*
* @return list<array<string, mixed>>
*/
public static function items(?Authenticatable $user = null): array
{
$user ??= auth()->user();
if (! $user) {
return [];
}
$items = [];
foreach ([
['label' => 'Home', 'path' => '', 'host' => 'home'],
['label' => 'Profile', 'path' => 'profile'],
['label' => 'Account Settings', 'path' => 'account-settings'],
['label' => 'Dashboard', 'path' => 'dashboard'],
['label' => 'Billing', 'path' => 'billing'],
] as $link) {
$href = self::platformUrl($link['host'] ?? 'account', $link['path']);
if (self::isCurrentMenuLink($link, $href)) {
continue;
}
$items[] = [
'type' => 'link',
'label' => $link['label'],
'href' => $href,
];
}
if (Route::has((string) config('billing.wallet_balance_route', 'wallet.balance'))) {
$items[] = ['type' => 'wallet'];
}
if (self::userIsAdmin($user)) {
$adminHref = self::platformUrl('account', 'admin');
if (! self::isCurrentMenuLink(['path' => 'admin', 'host' => 'account'], $adminHref)) {
$items[] = [
'type' => 'link',
'label' => 'Admin',
'href' => $adminHref,
];
}
}
if (Route::has('logout')) {
$items[] = [
'type' => 'logout',
'label' => 'Logout',
'action' => route('logout'),
];
}
return $items;
}
private static function platformUrl(string $host, string $path = ''): string
{
if ($host === 'home') {
if (function_exists('ladill_home_url')) {
return ladill_home_url($path);
}
$domain = config('app.home_domain');
if (! $domain) {
$platform = (string) config('app.platform_domain', parse_url((string) config('app.url', ''), PHP_URL_HOST) ?: '');
$domain = $platform !== '' ? 'home.'.$platform : (string) config('app.account_domain');
}
return self::absoluteUrl((string) $domain, $path);
}
if (function_exists('ladill_account_url')) {
return ladill_account_url($path);
}
return self::absoluteUrl((string) config('app.account_domain'), $path);
}
/**
* Hide a menu link when the signed-in user is already on that destination.
*
* @param array{label?: string, path?: string, host?: string} $link
*/
private static function isCurrentMenuLink(array $link, string $href): bool
{
if (app()->runningInConsole() && ! app()->runningUnitTests()) {
return false;
}
$request = request();
if (! $request) {
return false;
}
$linkHost = strtolower((string) parse_url($href, PHP_URL_HOST));
$currentHost = strtolower($request->getHost());
if ($linkHost === '' || $linkHost !== $currentHost) {
return false;
}
$currentPath = trim($request->getPathInfo(), '/');
$targetPath = trim((string) parse_url($href, PHP_URL_PATH), '/');
if (($link['host'] ?? 'account') === 'home') {
return $currentPath === '';
}
if (($link['path'] ?? '') === 'dashboard') {
return in_array($currentPath, ['dashboard', 'account'], true)
|| ($targetPath !== '' && self::pathMatchesSection($currentPath, $targetPath));
}
return self::pathMatchesSection($currentPath, $targetPath);
}
private static function pathMatchesSection(string $currentPath, string $targetPath): bool
{
if ($targetPath === '') {
return $currentPath === '';
}
return $currentPath === $targetPath
|| str_starts_with($currentPath, $targetPath.'/');
}
private static function absoluteUrl(string $domain, string $path = ''): string
{
$base = 'https://'.trim($domain, '/');
if ($path === '') {
return $base;
}
return $base.'/'.ltrim($path, '/');
}
private static function userIsAdmin(Authenticatable $user): bool
{
if (method_exists($user, 'isAdmin')) {
return $user->isAdmin();
}
return (bool) ($user->is_admin ?? false);
}
}
+11
View File
@@ -23,6 +23,17 @@ if (! function_exists('ladill_domains_url')) {
}
}
if (! function_exists('ladill_domains_embed_url')) {
function ladill_domains_embed_url(string $path = '/embed/find'): string
{
$url = ladill_domains_url($path);
$origin = rtrim((string) config('app.url'), '/');
$separator = str_contains($url, '?') ? '&' : '?';
return $url.$separator.'parent_origin='.urlencode($origin);
}
}
if (! function_exists('ladill_account_url')) {
function ladill_account_url(string $path = ''): string
{
+4
View File
@@ -17,8 +17,12 @@ return Application::configure(basePath: dirname(__DIR__))
'redirect' => $request->fullUrl(),
]));
$middleware->web(append: [
\App\Http\Middleware\InjectBootSplash::class,
\App\Http\Middleware\SetActingAccount::class,
]);
$middleware->alias([
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
]);
// External registrar webhook posts have no CSRF token.
$middleware->validateCsrfTokens(except: [
+2
View File
@@ -11,4 +11,6 @@ return [
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
'api_key' => env('BILLING_API_KEY_SERVERS'),
'service' => 'servers',
'wallet_balance_route' => 'wallet.balance',
'currency' => 'GHS',
];
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
'api_url' => env('DOMAINS_API_URL', 'https://domains.ladill.com/api'),
'api_key' => env('DOMAINS_API_KEY_SERVERS'),
'platform_api_url' => env('DOMAIN_REGISTRY_API_URL', 'https://ladill.com/api'),
'platform_api_key' => env('DOMAIN_API_KEY_SERVERS'),
];
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'product_slug' => 'servers',
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/servers'),
];
+25 -20
View File
@@ -2,39 +2,44 @@
/*
|--------------------------------------------------------------------------
| Ladill App Launcher SHARED, IDENTICAL across every app/service repo
| Ladill App Launcher GENERATED, IDENTICAL across every app/service repo
|--------------------------------------------------------------------------
|
| Lists ONLY fully-extracted apps (each on its own subdomain). To replicate the
| launcher in a new app, copy these three things verbatim into that repo:
| - config/ladill_launcher.php (this file)
| - resources/views/partials/launcher.blade.php
| - public/images/launcher-icons/* (the icon set)
| DO NOT EDIT BY HAND. This file is generated from the app registry in the
| monolith (config/ladill_apps.php). To change the launcher, edit that
| registry and run: php artisan ladill:launcher:sync --propagate
|
| When a service becomes FULLY extracted: add one row below AND drop its icon in
| public/images/launcher-icons/ in EVERY repo. That's the only edit needed.
| Do NOT list a service here until it is fully extracted to its own subdomain.
|
| URLs are absolute (built from the platform root) so this file is byte-identical
| in every app; the blade omits whichever row matches the app's APP_URL host.
| Icons are served from /images/launcher-icons/<icon>.
| URLs are absolute (built from the platform root) so this file is
| byte-identical in every repo; the blade omits whichever row matches the
| app's own host. Icons are served from /images/launcher-icons/<icon>.
*/
$root = config('app.platform_domain', 'ladill.com');
return [
'apps' => [
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'],
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
['name' => 'Woo Manager', 'url' => 'https://woo.'.$root.'/sso/connect?redirect='.urlencode('https://woo.'.$root.'/dashboard'), 'icon' => 'woomanager.svg'],
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'],
['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'],
['name' => 'CRM', 'url' => 'https://crm.'.$root.'/sso/connect?redirect='.urlencode('https://crm.'.$root.'/dashboard'), 'icon' => 'crm.svg'],
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
['name' => 'Link', 'url' => 'https://link.'.$root.'/sso/connect?redirect='.urlencode('https://link.'.$root.'/dashboard'), 'icon' => 'link.svg'],
['name' => 'Frontdesk', 'url' => 'https://frontdesk.'.$root.'/sso/connect?redirect='.urlencode('https://frontdesk.'.$root.'/dashboard'), 'icon' => 'frontdesk.svg'],
['name' => 'Care', 'url' => 'https://care.'.$root.'/sso/connect?redirect='.urlencode('https://care.'.$root.'/dashboard'), 'icon' => 'care.svg'],
['name' => 'Queue', 'url' => 'https://queue.'.$root.'/sso/connect?redirect='.urlencode('https://queue.'.$root.'/dashboard'), 'icon' => 'queue.svg'],
['name' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.svg'],
['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'],
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'],
['name' => 'Meet', 'url' => 'https://meet.'.$root.'/sso/connect?redirect='.urlencode('https://meet.'.$root.'/dashboard'), 'icon' => 'meet.svg'],
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
['name' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'],
['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'],
['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.svg'],
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
],
];
+1 -1
View File
@@ -32,7 +32,7 @@ return [
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'lifetime' => (int) env('SESSION_LIFETIME', 1440),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #009e6b;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #db5443;
}
.cls-3 {
fill: #00a3d7;
}
</style>
</defs>
<path class="cls-3" d="M1.7,170.6c0,9,.1,18.2,0,27.2-.1,8.8,4.3,15,13.8,19.3,17.3,7.5,34.4,15.2,51.7,22.8,69,30.6,138.2,61.1,207.1,92,16.9,7.6,37.1-.3,36.9-17.8v-54c0-17.9-4.5-15.2-14.4-19.4-47-20.7-94-41.6-141-62.4-39-17.3-78.2-34.2-117-51.9C19.9,117.7,0,128.1,1.3,144.4c.6,8.8.1,17.6.1,26.3h.3Z"/>
<path class="cls-1" d="M194.6,25.4c9,0,18.2.1,27.2,0,8.8-.1,15,4.3,19.3,13.8,7.5,17.3,15.2,34.4,22.8,51.7,30.6,69.1,60.9,138.3,91.9,207.2,7.6,16.9-.3,37.1-17.8,36.9h-54c-17.9,0-15.2-4.5-19.4-14.4-20.7-47-41.6-94-62.4-141-17.3-39-34.2-78.2-51.9-117-8.7-18.8,1.8-38.7,18.1-37.4,8.8.6,17.6.1,26.3.1h0Z"/>
<path class="cls-2" d="M194.9,25.3c-9,0-18.2.1-27.2,0-8.8-.1-15,4.3-19.3,13.8-7.5,17.3-15.2,34.4-22.8,51.7-30.6,69.1-60.9,138.3-91.9,207.2-7.6,16.9.3,37.1,17.8,36.9h54c17.9,0,15.2-4.5,19.4-14.4,20.7-47,41.6-94,62.4-141,17.3-39,34.2-78.2,51.9-117,8.7-18.8-1.8-38.7-18.1-37.4-8.8.6-17.6.1-26.3.1h0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #5cc45b;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #96c;
}
.cls-3 {
fill: #7b9594;
}
</style>
</defs>
<path class="cls-1" d="M347.3,272.3c-50.4,84.4-159.2,112.7-244.6,63.8C17.4,286.6-12.4,177.8,35.6,92.4l311.7,179.8Z"/>
<path class="cls-2" d="M347.3,87.6C297,3.2,188.1-25.1,102.7,24.3,17.4,73.2-12.4,182.1,35.6,267.5L347.3,87.6Z"/>
<path class="cls-3" d="M35.6,92.4c-6,10.6-10.7,21.6-14.3,32.7,0,.3-.2.6-.3.9-.3.9-.6,1.8-.8,2.7-.3,1-.6,1.9-.8,2.9,0,.3-.1.5-.2.8-3.4,12.3-5.4,24.9-6.1,37.7,0,0,0,0,0,.1,0,1.3-.1,2.5-.2,3.8,0,.3,0,.7,0,1,0,1,0,1.9,0,2.9,0,.7,0,1.3,0,2,0,.7,0,1.3,0,2,0,1,0,1.9,0,2.9,0,.3,0,.7,0,1,0,1.2,0,2.5.2,3.7,0,0,0,.1,0,.2.6,9.9,1.9,19.8,4.1,29.4,0,.2,0,.3.1.5.2,1.1.5,2.2.8,3.3.2.6.3,1.3.5,1.9.2.6.3,1.2.5,1.8,3.8,14,9.3,27.7,16.7,40.9l151.7-87.5L35.6,92.4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #d7d135;
}
.cls-1, .cls-2, .cls-3, .cls-4, .cls-5 {
stroke-width: 0px;
}
.cls-2 {
fill: #8c67d5;
}
.cls-3 {
fill: #3d85a4;
}
.cls-4 {
fill: #6576bd;
}
.cls-5 {
fill: #b29c85;
}
</style>
</defs>
<path class="cls-2" d="M168.1,0C74,6.3-.4,84.6.2,179.8h0c0,95.1,74.4,173.4,167.9,179.8V0Z"/>
<path class="cls-3" d="M.2,192.1c6.3,94.1,84.6,168.5,179.8,167.9h0c95.1,0,173.4-74.4,179.8-167.9H.2Z"/>
<path class="cls-1" d="M.2,167.9C6.5,73.8,84.8-.6,180,0h0C275.1,0,353.4,74.4,359.7,167.9H.2Z"/>
<path class="cls-5" d="M168.1,167.9V.4c-19.4,1.2-37.9,5.4-55.1,12.3-.2,0-.4.2-.7.3-2.1.8-4.1,1.7-6.2,2.7-.7.3-1.4.6-2.1.9-1.6.8-3.2,1.5-4.8,2.3-1,.5-2.1,1-3.1,1.6-1.3.7-2.6,1.4-3.8,2.1-1.2.7-2.5,1.4-3.7,2.1-1.1.6-2.1,1.3-3.2,1.9-1.4.8-2.7,1.7-4,2.5-.9.6-1.9,1.3-2.8,1.9-1.4.9-2.7,1.9-4.1,2.8-.9.7-1.8,1.3-2.7,2-1.3,1-2.6,2-3.9,3-.9.7-1.8,1.5-2.7,2.2-1.2,1-2.4,2-3.6,3.1-.9.8-1.9,1.7-2.8,2.5-1.1,1-2.2,2-3.3,3-1,.9-1.9,1.9-2.9,2.9-1,1-1.9,1.9-2.9,2.9-1,1.1-2,2.2-3,3.3-.8.9-1.7,1.8-2.5,2.8-1,1.2-2.1,2.4-3.1,3.6-.7.9-1.5,1.8-2.2,2.7-1,1.3-2,2.6-3,3.9-.7.9-1.3,1.8-2,2.7-1,1.3-1.9,2.7-2.8,4.1-.6.9-1.3,1.9-1.9,2.8-.9,1.3-1.7,2.7-2.5,4-.7,1.1-1.3,2.1-1.9,3.2-.7,1.2-1.4,2.5-2.1,3.7-.7,1.3-1.4,2.5-2.1,3.8-.5,1-1.1,2-1.6,3.1-.8,1.6-1.6,3.2-2.3,4.8-.3.7-.6,1.4-.9,2.1-.9,2-1.8,4.1-2.7,6.2,0,.2-.2.4-.3.7-6.9,17.2-11.1,35.8-12.3,55.1h167.6Z"/>
<path class="cls-4" d="M168.1,359.5v-167.5H.7c6.1,89.5,78,161.4,167.5,167.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #ff79b7;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #7b50c4;
}
.cls-3 {
fill: #0094c8;
}
</style>
</defs>
<path class="cls-2" d="M14.1,74.6c3.7,8.2,7.5,16.9,11.1,25.1s9.9,12.2,20.5,12c19.1-.4,37.6,0,56.9-.1,76.1-.1,152.2-.2,228.2.2,18.9.1,34.1-15.1,26.8-31.6l-22.1-49.8c-7.3-16.5-10.3-12-21.1-12.2-51.9.2-103.5.1-155.4.3-43.1-.2-85.9.4-129.1-.1-20.7-.2-34.9,17.3-27.2,31.7,4.4,7.9,7.1,16.1,10.8,24.3h0l.5.2Z"/>
<path class="cls-1" d="M63.1,188.9c3.7,8.2,7.5,16.9,11.1,25.1,3.7,8.2,9.9,12.2,20.5,12,19.1-.4,4.8-.1,24.1-.2,76.1-.1,86.6-.4,162.6,0,18.9.1,34.1-15.1,26.8-31.6l-22.1-49.8c-7.3-16.5-10.3-12-21.1-12.2-51.9.2-103.5.1-155.4.3-43.1-.2,12.5.6-30.7,0-20.7-.2-34.9,17.3-27.2,31.7,4.4,7.9,7.1,16.1,10.8,24.3h0l.5.2Z"/>
<path class="cls-3" d="M116.1,305.2c3.7,8.2,7.5,16.9,11.1,25.1,3.7,8.2,9.9,12.2,20.5,12,19.1-.4,4.8-.1,24.1-.2,76.1-.1,16.9-.5,92.9-.1,18.9.1,34.1-15.1,26.8-31.6l-22.1-49.8c-7.3-16.5-10.3-12-21.1-12.2-51.9.2-33.7.3-85.7.4-43.1-.2,12.5.6-30.7,0-20.7-.2-34.9,17.3-27.2,31.7,4.4,7.9,7.1,16.1,10.8,24.3h0l.5.2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+10 -5
View File
@@ -3,18 +3,23 @@
<defs>
<style>
.cls-1 {
fill: #f6398a;
fill: #007e7d;
}
.cls-1, .cls-2 {
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #007e7d;
fill: #7b5c84;
}
.cls-3 {
fill: #f6398a;
}
</style>
</defs>
<path class="cls-1" d="M267.9,17.5c-30.1-.2-56.9,15.4-73.6,39.6l58.6,68.2c4.7,5.5,4.1,13.7-1.4,18.4-2.5,2.1-5.5,3.1-8.5,3.1s-7.3-1.5-9.9-4.5c-17.5-20.3-34.9-40.6-52.4-61-9.6-11.2-19.3-22.4-28.9-33.6-7-8.1-13.3-17.2-23.1-22.1-11.2-5.6-24.3-8.1-36.7-8.1C32.8,17.9-10.8,83.4,2.3,144.7c6.5,30.6,28.4,55.4,45.8,79.4,22.2,30.7,65.6,74.1,96.5,103.8,20.2,19.4,50.6,19.4,70.7,0,30.9-29.7,74.3-73.2,96.5-103.8,17.4-24,39.3-48.8,45.8-79.4,13-61.3-30.6-126.9-89.8-127.2Z"/>
<path class="cls-2" d="M267.9,17.5c-30.1-.2-56.9,15.4-73.6,39.6l58.6,68.2c4.7,5.5,4.1,13.7-1.4,18.4-2.5,2.1-5.5,3.1-8.5,3.1s-7.3-1.5-9.9-4.5l75.1,86.7c1.3-1.6,2.5-3.3,3.6-4.8,17.4-24,39.3-48.8,45.8-79.4,13-61.3-30.6-126.9-89.8-127.2Z"/>
<path class="cls-1" d="M357,144.9c13.6-60.9-29.8-126.9-89-126.9s-56.6,15.3-73.7,39.6l127.6,152.5c14.8-19.8,30-40.8,35.1-65.2Z"/>
<path class="cls-3" d="M180.7,81.5c-9.8-11.1-19.2-22.6-29-33.6-6.8-8.1-13.2-17-23-22.1-10.6-5.1-23.9-7.7-36.2-7.7-59.1-.1-102.6,65.9-89.8,126.8,6.4,30.7,28.5,55.4,45.6,79.2,22.1,30.7,65.6,74.1,96.3,103.5,20,19.2,50.7,19.2,70.7,0,28.7-27.5,67.6-66.7,90.8-96.3-44-52.6-112.7-135.1-125.3-149.8h-.1Z"/>
<path class="cls-2" d="M234.3,143.1c-.4-.3-.8-.6-1.1-1-17.5-20.4-34.9-40.5-52.4-60.9-.2-.3-.4-.5-.7-.8-.4.4-.7.9-1.1,1.3-17.5,20.4-34.9,40.5-52.4,60.9-.3.4-.8.7-1.1,1l-73.5,85.1c23,30.3,63.5,70.8,92.6,98.6,20,19.2,50.7,19.2,70.7,0,29.6-28.3,69.8-69,92.8-98.9l-73.7-85.3h0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #c1c93d;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #3dc3a4;
}
.cls-3 {
fill: #2a4372;
}
</style>
</defs>
<circle class="cls-3" cx="259.2" cy="76.3" r="76.3"/>
<path class="cls-1" d="M168.8,360v-189.2c-79.5,0-144.3,64.7-144.3,144.3v45h144.3Z"/>
<path class="cls-2" d="M187.1,170.8v189.2c79.5,0,144.3-64.7,144.3-144.3v-45h-144.3Z"/>
</svg>

After

Width:  |  Height:  |  Size: 638 B

+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #a8ca38;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #678e9c;
}
.cls-3 {
fill: #2551ff;
}
</style>
</defs>
<rect class="cls-1" x="32" y="36.1" width="175.5" height="290.1" rx="87.7" ry="87.7" transform="translate(-74.5 84.1) rotate(-30)"/>
<rect class="cls-3" x="152.6" y="36.1" width="175.5" height="290.1" rx="87.7" ry="87.7" transform="translate(-58.4 144.4) rotate(-30)"/>
<path class="cls-2" d="M167.1,87.6c-4.4-7.5-9.7-14.2-15.7-19.9-28.9,27.3-36.4,71.7-15.7,107.6l57.3,99.2c4.4,7.5,9.7,14.2,15.7,19.9,28.9-27.3,36.4-71.7,15.7-107.6l-57.3-99.2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 859 B

+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #db54d9;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #c0d343;
}
.cls-3 {
fill: #009e46;
}
</style>
</defs>
<path class="cls-1" d="M126.3,60.6c-6.9,0-14,0-20.9,0-6.8,0-11.5,3.3-14.9,10.6-5.8,13.3-11.7,26.5-17.6,39.8C49.4,164.2,26.1,217.5,2.2,270.5c-5.9,13,.2,28.6,13.7,28.4h41.6c13.8,0,11.7-3.5,14.9-11.1,15.9-36.2,32-72.4,48-108.6,13.3-30,26.3-60.2,40-90.1,6.7-14.5-1.4-29.8-13.9-28.8-6.8.5-13.5,0-20.2,0h0Z"/>
<path class="cls-2" d="M238.2,60.5c-6.9,0-14,0-20.9,0-6.8,0-11.5,3.3-14.9,10.6-5.8,13.3-11.7,26.5-17.6,39.8-23.6,53.2-46.9,106.5-70.8,159.5-5.9,13,.2,28.6,13.7,28.4h41.6c13.8,0,11.7-3.5,14.9-11.1,15.9-36.2,32-72.4,48-108.6,13.3-30,26.3-60.2,40-90.1,6.7-14.5-1.4-29.8-13.9-28.8-6.8.5-13.5,0-20.2,0h0Z"/>
<circle class="cls-3" cx="300.2" cy="239.1" r="59.8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #f48725;
}
.cls-1, .cls-2 {
stroke-width: 0px;
}
.cls-2 {
fill: #1797c8;
}
</style>
</defs>
<path class="cls-2" d="M155.3,1.1C72.9,1.1,6,68,6,150.4s66.9,149.3,149.3,149.3h8.9V1.1h-8.9Z"/>
<path class="cls-1" d="M205.7,61.8h-8.9v298.2h8.9c82.4,0,149.3-66.9,149.3-149.3S288.2,61.4,205.7,61.8Z"/>
</svg>

After

Width:  |  Height:  |  Size: 557 B

+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #33669a;
}
.cls-1, .cls-2, .cls-3, .cls-4 {
stroke-width: 0px;
}
.cls-2 {
fill: #ffd05a;
}
.cls-3 {
fill: #f16667;
}
.cls-4 {
fill: #319966;
}
</style>
</defs>
<path class="cls-4" d="M265.6,180v1.3h95.9C361.4,81.2,280.2,0,180.1,0v94.5c47.2,0,85.5,38.3,85.5,85.5Z"/>
<path class="cls-3" d="M94.6,180v-1.3H-1.2c0,100.1,81.2,181.3,181.3,181.3v-94.5c-47.2,0-85.5-38.3-85.5-85.5Z"/>
<path class="cls-1" d="M180.1,0C80,0-1.2,81.2-1.2,181.3h181.3V0Z"/>
<path class="cls-2" d="M180.1,360c100.1,0,181.3-81.2,181.3-181.3h-181.3v181.3h0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 839 B

+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #5896fa;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #734dc6;
}
.cls-3 {
fill: #c150a4;
}
</style>
</defs>
<path class="cls-3" d="M123,.4h224.4c0,60.8-49.5,110.3-110.3,110.3H12.6C12.6,49.9,62.1.4,123,.4Z"/>
<path class="cls-2" d="M347.4,249.3H123c-60.8,0-110.3,49.5-110.3,110.3h224.4c60.8,0,110.3-49.5,110.3-110.3h0Z"/>
<path class="cls-1" d="M237,124.8H12.6c0,60.8,49.5,110.3,110.3,110.3h224.4c0-60.8-49.5-110.3-110.3-110.3Z"/>
</svg>

After

Width:  |  Height:  |  Size: 734 B

+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #a277ff;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #b6362f;
}
.cls-3 {
fill: #0091ac;
}
</style>
</defs>
<path class="cls-3" d="M131.4,56.7c-7.2,0-14.5.1-21.7,0-7-.1-12,3.4-15.4,11-6,13.8-12.1,27.4-18.2,41.2C51.7,163.9,27.4,219.1,2.7,274.1c-6.1,13.5.2,29.6,14.2,29.4,14.4,0,28.8,0,43.1,0s12.1-3.6,15.5-11.5c16.5-37.5,33.2-75,49.8-112.5,13.8-31.1,27.3-62.4,41.4-93.3,6.9-15-1.4-30.9-14.4-29.8-7,.5-14,.1-21,.1h0Z"/>
<path class="cls-2" d="M228.6,56.7c7.2,0,14.5.1,21.7,0,7-.1,12,3.4,15.4,11,6,13.8,12.1,27.4,18.2,41.2,24.4,55.1,48.6,110.3,73.3,165.3,6.1,13.5-.2,29.6-14.2,29.4-14.4,0-28.8,0-43.1,0s-12.1-3.6-15.5-11.5c-16.5-37.5-33.2-75-49.8-112.5-13.8-31.1-27.3-62.4-41.4-93.3-6.9-15,1.4-30.9,14.4-29.8,7,.5,14,.1,21,.1h0Z"/>
<path class="cls-1" d="M228.8,56.6c-7.2,0-14.5.1-21.7,0-7-.1-12,3.4-15.4,11-6,13.8-12.1,27.4-18.2,41.2-24.4,55.1-48.6,110.3-73.3,165.3-6.1,13.5.2,29.6,14.2,29.4,14.4,0,28.8,0,43.1,0s12.1-3.6,15.5-11.5c16.5-37.5,33.2-75,49.8-112.5,13.8-31.1,27.3-62.4,41.4-93.3,6.9-15-1.4-30.9-14.4-29.8-7,.5-14,.1-21,.1h0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #33669a;
}
.cls-1, .cls-2, .cls-3, .cls-4 {
stroke-width: 0px;
}
.cls-2 {
fill: #ffd05a;
}
.cls-3 {
fill: #f16667;
}
.cls-4 {
fill: #319966;
}
</style>
</defs>
<rect class="cls-3" x=".2" y="51.2" width="147.8" height="84.5" rx="42.1" ry="42.1"/>
<path class="cls-2" d="M105.6,51.2h0c23.4,0,42.4,19.1,42.4,42.7v172.2c0,23.6-19,42.7-42.4,42.7h0c-23.4,0-42.4-19.1-42.4-42.7V93.9c0-23.6,19-42.7,42.4-42.7Z"/>
<path class="cls-4" d="M232.7,56.9h0c20.3,11.7,27.2,37.7,15.5,57.9l-105.9,172.7c-11.7,20.3-37.7,27.2-57.9,15.5h0c-20.3-11.7-27.2-37.7-15.5-57.9l105.9-172.7c11.7-20.3,37.7-27.2,57.9-15.5h0Z"/>
<path class="cls-1" d="M211.5,51.2h0c23.4,0,42.4,19.1,42.4,42.7v172.2c0,23.6-19,42.7-42.4,42.7h0c-23.4,0-42.4-19.1-42.4-42.7V93.9c0-23.6,19-42.7,42.4-42.7Z"/>
<path class="cls-3" d="M338.6,56.9h0c20.3,11.7,27.2,37.7,15.5,57.9l-105.9,172.7c-11.7,20.3-37.7,27.2-57.9,15.5h0c-20.3-11.7-27.2-37.7-15.5-57.9l105.9-172.7c11.7-20.3,37.7-27.2,57.9-15.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+73
View File
@@ -68,3 +68,76 @@
filter: brightness(0.92);
}
}
.mobile-stats-card {
width: max-content;
min-width: 9rem;
max-width: none;
box-sizing: border-box;
}
@media (min-width: 640px) {
.mobile-stats-card {
width: max-content;
min-width: 9rem;
max-width: none;
flex: 1 1 auto;
}
}
/* Ladill app launcher — cap at 5 rows (15 apps), always-visible scroll indicator */
.ladill-launcher-apps-wrap {
position: relative;
}
.ladill-launcher-apps {
max-height: calc(5 * 5.25rem + 4 * 0.25rem);
overflow-y: scroll;
overscroll-behavior: contain;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgb(148 163 184) rgb(241 245 249);
margin-right: -0.25rem;
padding-right: 0.25rem;
}
.ladill-launcher-apps::-webkit-scrollbar {
-webkit-appearance: none;
width: 8px;
}
.ladill-launcher-apps::-webkit-scrollbar-track {
background: rgb(241 245 249);
border-radius: 9999px;
}
.ladill-launcher-apps::-webkit-scrollbar-thumb {
background-color: rgb(148 163 184);
border-radius: 9999px;
min-height: 2.5rem;
border: 2px solid rgb(241 245 249);
background-clip: padding-box;
}
.ladill-launcher-apps::-webkit-scrollbar-thumb:hover {
background-color: rgb(100 116 139);
}
.ladill-launcher-scroll-more {
position: absolute;
left: 50%;
bottom: 0.125rem;
z-index: 1;
display: flex;
height: 1.25rem;
width: 1.25rem;
-webkit-transform: translateX(-50%);
transform: translateX(-50%);
align-items: center;
justify-content: center;
border-radius: 9999px;
background: rgb(255 255 255);
color: rgb(100 116 139);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.08);
pointer-events: none;
}
+26
View File
@@ -1,14 +1,20 @@
import './bootstrap';
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
import { registerLadillDomainPurchase } from './ladill-domain-purchase';
registerLadillModalHelpers();
registerLadillSearchShortcut();
registerLadillDomainPurchase();
import Alpine from 'alpinejs';
import { registerLadillClipboard } from './ladill-clipboard';
import collapse from '@alpinejs/collapse';
Alpine.plugin(collapse);
registerLadillClipboard(Alpine);
Alpine.data('notificationDropdown', (config = {}) => ({
open: false,
@@ -551,6 +557,26 @@ window.serverOrderForm = (products = {}, billingCycleLabels = {}, initial = {})
},
});
// Wallet balance peek for the avatar dropdown.
Alpine.data('walletWidget', (config = {}) => ({
display: '…',
async load() {
if (! config.url) {
this.display = 'View wallet';
return;
}
try {
const res = await fetch(config.url, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } });
const data = await res.json();
this.display = data.available ? data.formatted : 'View wallet';
} catch (e) {
this.display = 'View wallet';
}
},
}));
window.Alpine = Alpine;
registerLadillConfirmStore(Alpine);
+57
View File
@@ -0,0 +1,57 @@
const COPY_FEEDBACK_MS = 2000;
export async function writeClipboardText(text) {
const value = String(text ?? '');
if (!value) {
return false;
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
} catch {
// fall through to legacy copy
}
try {
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
export function registerLadillClipboard(Alpine) {
Alpine.data('copyButton', (text = '') => ({
copied: false,
text: text ?? '',
resetTimer: null,
async copy() {
const ok = await writeClipboardText(this.text);
if (!ok) {
return;
}
this.copied = true;
if (this.resetTimer) {
clearTimeout(this.resetTimer);
}
this.resetTimer = setTimeout(() => {
this.copied = false;
}, COPY_FEEDBACK_MS);
},
}));
}
+99
View File
@@ -0,0 +1,99 @@
import { closeLadillModal, openLadillModal } from './ladill-modals';
const PURCHASE_MODAL = 'domain-purchase';
function allowedParentOrigin(origin) {
try {
const host = new URL(origin).hostname;
return host === 'ladill.com' || host.endsWith('.ladill.com');
} catch {
return false;
}
}
function loadPurchaseFrame() {
const frame = document.getElementById('ladill-domain-purchase-frame');
if (!frame || frame.dataset.loaded === '1') {
return;
}
const embedUrl = frame.dataset.embedUrl;
if (embedUrl) {
frame.src = embedUrl;
frame.dataset.loaded = '1';
}
}
export function registerLadillDomainPurchase() {
window.ladillDomainSourceForm = (initial = {}) => ({
domainSource: initial.domainSource ?? 'external',
selectedOwnedDomain: initial.selectedOwnedDomain ?? '',
externalDomain: initial.externalDomain ?? '',
ownedDomains: initial.ownedDomains ?? [],
ownedUrl: initial.ownedUrl ?? '',
onboardingMode: initial.onboardingMode ?? 'ns_auto',
openPurchaseModal() {
loadPurchaseFrame();
openLadillModal(PURCHASE_MODAL);
},
async refreshOwnedDomains() {
if (!this.ownedUrl) {
return;
}
try {
const res = await fetch(this.ownedUrl, {
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok) {
return;
}
const payload = await res.json();
this.ownedDomains = Array.isArray(payload.data) ? payload.data : [];
this.domainSource = 'ladill';
} catch {
// Non-fatal.
}
},
});
window.addEventListener('message', (event) => {
if (!allowedParentOrigin(event.origin)) {
return;
}
const data = event.data;
if (!data || data.type !== 'ladill:domains:purchase-complete') {
return;
}
closeLadillModal(PURCHASE_MODAL);
const domains = Array.isArray(data.domains) ? data.domains : (data.domain ? [data.domain] : []);
document.querySelectorAll('[x-data*="ladillDomainSourceForm"]').forEach((el) => {
const component = el._x_dataStack?.[0];
if (!component?.refreshOwnedDomains) {
return;
}
component.refreshOwnedDomains().then(() => {
const newest = domains.map((d) => String(d).toLowerCase()).find(Boolean);
if (newest) {
component.selectedOwnedDomain = newest;
component.domainSource = 'ladill';
}
});
});
});
window.addEventListener('open-modal', (event) => {
if (String(event.detail) === PURCHASE_MODAL) {
loadPurchaseFrame();
}
});
}
+97
View File
@@ -0,0 +1,97 @@
function isEditableTarget(element) {
if (!(element instanceof HTMLElement)) {
return false;
}
const tag = element.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
return true;
}
return element.isContentEditable;
}
function isVisibleInput(input) {
if (!(input instanceof HTMLElement)) {
return false;
}
const style = window.getComputedStyle(input);
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& style.opacity !== '0';
}
function findSearchInput() {
const marked = [...document.querySelectorAll('[data-ladill-search-input]')];
const visibleMarked = marked.find(isVisibleInput);
if (visibleMarked) {
return visibleMarked;
}
if (marked.length > 0) {
return marked[0];
}
return document.querySelector('[x-data*="topbarSearch"] input');
}
function focusSearchInput() {
const input = findSearchInput();
if (!input) {
const fallbackUrl = document.body?.dataset?.ladillSearchUrl;
if (fallbackUrl) {
window.location.href = fallbackUrl;
}
return false;
}
if (!isVisibleInput(input)) {
const fallbackUrl = document.body?.dataset?.ladillSearchUrl;
if (fallbackUrl) {
window.location.href = fallbackUrl;
return true;
}
}
input.focus();
if (input instanceof HTMLInputElement && input.type === 'text') {
input.select();
}
return true;
}
export function registerLadillSearchShortcut() {
if (window.__ladillSearchShortcutRegistered) {
return;
}
window.__ladillSearchShortcutRegistered = true;
document.addEventListener('keydown', (event) => {
if (event.key !== '/' && event.code !== 'Slash') {
return;
}
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
if (isEditableTarget(document.activeElement)) {
return;
}
event.preventDefault();
focusSearchInput();
});
}
@@ -1,17 +1,33 @@
@props([
'href' => null,
'type' => 'button',
'label' => null,
])
@php
$tag = $href ? 'a' : 'button';
$ariaLabel = $label ?? trim(preg_replace('/\s+/', ' ', strip_tags((string) $slot)));
@endphp
<{{ $tag }}
@if ($href) href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
{{ $attributes->class(['btn-primary']) }}
aria-label="{{ $ariaLabel }}"
title="{{ $ariaLabel }}"
{{ $attributes->class(['btn-fab h-10 w-10 lg:hidden']) }}
>
<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>
<svg class="h-5 w-5" 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>
</{{ $tag }}>
<{{ $tag }}
@if ($href) href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
{{ $attributes->class(['btn-primary hidden lg:inline-flex']) }}
>
<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,29 @@
@props([
'text' => '',
'label' => 'Copy',
'copiedLabel' => 'Copied!',
'icon' => false,
'copiedClass' => '',
])
<button
type="button"
x-data="copyButton(@js($text))"
@click="copy()"
:title="copied ? @js($copiedLabel) : @js($icon ? 'Copy' : $label)"
:class="copied ? @js($copiedClass) : ''"
{{ $attributes->class($icon ? 'inline-flex shrink-0 items-center justify-center' : '') }}
>
@if ($icon)
<svg x-show="!copied" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg x-show="copied" x-cloak class="h-4 w-4 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
@elseif ($slot->isNotEmpty())
{{ $slot }}
@else
<span x-text="copied ? @js($copiedLabel) : @js($label)"></span>
@endif
</button>
@@ -0,0 +1,11 @@
<x-modal name="domain-purchase" maxWidth="2xl" zIndex="z-[60]">
<div class="flex flex-col" style="height: min(80vh, 720px);">
<div class="flex shrink-0 items-center border-b border-slate-100 px-5 py-4 pr-14">
<h2 class="text-base font-semibold text-slate-900">Register a domain</h2>
</div>
<iframe id="ladill-domain-purchase-frame" title="Ladill Domains"
src="about:blank"
data-embed-url="{{ ladill_domains_embed_url() }}"
class="min-h-0 w-full flex-1 border-0 bg-white"></iframe>
</div>
</x-modal>
@@ -0,0 +1,140 @@
@props([
'ownedDomains' => [],
'fieldName' => 'domain',
'ownedUrl' => '',
'variant' => 'slate',
'showConnectionMethod' => false,
'showDocumentRoot' => false,
])
@php
$ownedDomainHosts = collect($ownedDomains)->map(fn ($d) => (string) $d)->all();
$oldValue = trim((string) old($fieldName, ''));
$initialDomainSource = empty($ownedDomainHosts)
? 'external'
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
$selectedOwnedDomain = in_array($oldValue, $ownedDomainHosts, true) ? $oldValue : '';
$externalFallbackDomain = ! in_array($oldValue, $ownedDomainHosts, true) ? $oldValue : '';
$isGray = $variant === 'gray';
$inputClass = $isGray
? 'block w-full rounded-lg border-gray-200 bg-white text-sm focus:border-gray-400 focus:ring-0'
: 'block w-full rounded-xl border-slate-200 bg-slate-50 text-sm shadow-sm focus:border-indigo-500 focus:bg-white focus:ring-indigo-500';
$toggleWrapClass = $isGray ? 'bg-gray-100' : 'bg-slate-100';
$purchaseLinkClass = $isGray
? 'font-medium text-gray-700 underline underline-offset-2 hover:text-gray-900'
: 'font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-800';
@endphp
<div>
<div class="mb-2 flex items-center justify-between">
<label class="text-sm font-medium {{ $isGray ? 'text-gray-700' : 'text-slate-700' }}">Domain</label>
<div class="inline-flex items-center rounded-md {{ $toggleWrapClass }} p-0.5 text-[11px] font-medium">
<button type="button" @click="domainSource = 'ladill'"
:class="domainSource === 'ladill' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
class="rounded px-2 py-1 transition">Ladill</button>
<button type="button" @click="domainSource = 'external'"
:class="domainSource === 'external' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
class="rounded px-2 py-1 transition">External</button>
</div>
</div>
<div x-show="domainSource === 'ladill'" x-cloak>
<template x-if="ownedDomains.length">
<div class="space-y-2">
<select name="{{ $fieldName }}" x-model="selectedOwnedDomain" :disabled="domainSource !== 'ladill'" class="{{ $inputClass }}">
<option value="">Choose a domain...</option>
<template x-for="domain in ownedDomains" :key="domain">
<option :value="domain" x-text="domain"></option>
</template>
</select>
<p class="text-xs {{ $isGray ? 'text-gray-500' : 'text-slate-500' }}">
Prefer another?
<button type="button" @click="openPurchaseModal()" class="{{ $purchaseLinkClass }}">Get a new domain</button>
</p>
</div>
</template>
<template x-if="!ownedDomains.length">
<div @class([
'rounded-xl px-4 py-3 text-center text-sm',
'bg-gray-50 text-gray-600' => $isGray,
'bg-slate-50 text-slate-600' => ! $isGray,
])>
No Ladill domains yet.
<button type="button" @click="openPurchaseModal()" class="{{ $purchaseLinkClass }}">Register a domain</button>
</div>
</template>
</div>
<div x-show="domainSource === 'external'" x-cloak>
<input type="text" name="{{ $fieldName }}" x-model="externalDomain" :disabled="domainSource !== 'external'"
placeholder="example.com" class="{{ $inputClass }} @error($fieldName) border-rose-300 @enderror">
<p class="mt-1.5 text-xs {{ $isGray ? 'text-gray-500' : 'text-slate-500' }}">Enter your domain without www or http (e.g., example.com)</p>
</div>
@error($fieldName)
<p class="mt-1.5 text-sm text-rose-600">{{ $message }}</p>
@enderror
</div>
<input type="hidden" name="is_owned_domain" :value="domainSource === 'ladill' ? '1' : '0'">
@if($showConnectionMethod)
<div x-show="domainSource === 'external'" x-cloak class="mt-5">
<label class="mb-3 block text-sm font-medium {{ $isGray ? 'text-gray-700' : 'text-slate-700' }}">Connection method</label>
<div class="space-y-3">
<label @class([
'relative flex cursor-pointer rounded-lg border bg-white p-4 transition hover:bg-slate-50',
'border-slate-200' => ! $isGray,
'border-gray-200 hover:bg-gray-50' => $isGray,
])>
<input type="radio" name="onboarding_mode" value="ns_auto" x-model="onboardingMode" class="sr-only peer">
<div class="flex flex-1 items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600">
<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="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>
</div>
<div class="flex-1">
<p class="text-sm font-semibold text-slate-900">Point nameservers</p>
<p class="mt-0.5 text-xs text-slate-500">Point your domain's nameservers to Ladill. We'll manage DNS and SSL automatically.</p>
</div>
</div>
<div class="absolute right-4 top-4 hidden h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white peer-checked:flex">
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</div>
<div class="pointer-events-none absolute inset-0 rounded-lg border-2 border-transparent peer-checked:border-indigo-600"></div>
</label>
<label @class([
'relative flex cursor-pointer rounded-lg border bg-white p-4 transition hover:bg-slate-50',
'border-slate-200' => ! $isGray,
'border-gray-200 hover:bg-gray-50' => $isGray,
])>
<input type="radio" name="onboarding_mode" value="manual_dns" x-model="onboardingMode" class="sr-only peer">
<div class="flex flex-1 items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
<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="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.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.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>
</div>
<div class="flex-1">
<p class="text-sm font-semibold text-slate-900">Add DNS records</p>
<p class="mt-0.5 text-xs text-slate-500">Keep your current DNS provider and manually add A records pointing to our server.</p>
</div>
</div>
<div class="absolute right-4 top-4 hidden h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white peer-checked:flex">
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</div>
<div class="pointer-events-none absolute inset-0 rounded-lg border-2 border-transparent peer-checked:border-indigo-600"></div>
</label>
</div>
@error('onboarding_mode')
<p class="mt-1.5 text-sm text-rose-600">{{ $message }}</p>
@enderror
</div>
@endif
@if($showDocumentRoot)
<div class="mt-5">
<label class="mb-1 block text-sm font-medium text-slate-700">Document root <span class="font-normal text-slate-400">(optional)</span></label>
<input type="text" name="document_root" placeholder="public_html/example.com" value="{{ old('document_root') }}"
class="w-full rounded-lg border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-slate-500">Leave empty to use default: public_html/domain.com</p>
</div>
@endif
+3 -2
View File
@@ -1,7 +1,8 @@
@props([
'name',
'show' => false,
'maxWidth' => '2xl'
'maxWidth' => '2xl',
'zIndex' => 'z-50',
])
@php
@@ -52,7 +53,7 @@ $maxWidth = [
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"
class="fixed inset-0 {{ $zIndex }} 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 --}}
@@ -0,0 +1,39 @@
@props([
'badge',
'title',
'description' => null,
'stats' => [],
])
<div {{ $attributes->merge(['class' => 'relative overflow-hidden rounded-2xl border border-slate-200 bg-white']) }}>
<div class="absolute inset-0 bg-gradient-to-br from-indigo-50/80 via-white to-violet-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-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700">
{{ $badge }}
</div>
<h1 class="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">{{ $title }}</h1>
@if ($description)
<p class="mt-2 text-sm leading-6 text-slate-600">{{ $description }}</p>
@endif
@isset($actions)
<div class="mt-6 flex flex-wrap items-center gap-2">
{{ $actions }}
</div>
@endisset
</div>
@if (count($stats) > 0)
<div class="scrollbar-hide flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 sm:flex sm:flex-wrap sm:justify-end sm:overflow-visible sm:pb-0 lg:gap-4" style="-ms-overflow-style:none;scrollbar-width:none;">
@foreach ($stats as $stat)
<div class="mobile-stats-card w-max min-w-[9rem] 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">{{ $stat['value'] }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">{{ $stat['label'] }}</p>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
@@ -29,5 +29,8 @@
@auth
@include('partials.afia')
@endauth
@include('partials.sso-keepalive')
@include('partials.confirm-prompt')
@include('components.domain-purchase-modal')
</body>
</html>
+19 -89
View File
@@ -36,9 +36,9 @@
@endphp
@php
$ownedDomainHosts = $ownedDomains->pluck('host')->map(fn ($d) => (string) $d)->all();
$ownedDomainHosts = collect($ownedDomains)->map(fn ($d) => (string) $d)->all();
$oldDomain = trim((string) old('domain', ''));
$initialDomainSource = $ownedDomains->isEmpty()
$initialDomainSource = empty($ownedDomainHosts)
? 'external'
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
$selectedOwned = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
@@ -54,10 +54,6 @@
showSheet: false,
checkoutUrl: '',
renewLoading: false,
domainSource: @js($initialDomainSource),
selectedOwnedDomain: @js($selectedOwned),
externalDomain: @js($externalFallback),
onboardingMode: '{{ old('onboarding_mode', 'ns_auto') }}',
async submitRenewal() {
this.renewLoading = true;
try {
@@ -69,13 +65,8 @@
const data = await res.json();
if (!res.ok || data.error) { alert(data.error || 'Unable to start payment.'); this.renewLoading = false; return; }
this.renewModal = false;
if (window.innerWidth < 768) {
this.checkoutUrl = data.checkout_url;
this.showSheet = true;
this.renewLoading = false;
} else {
window.location.href = data.checkout_url;
}
this.checkoutUrl = data.checkout_url;
this.showSheet = true; // renewLoading cleared when payment UI opens (ladill-pay-opened)
} catch(e) {
alert('Network error. Please try again.');
this.renewLoading = false;
@@ -472,9 +463,16 @@
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="relative min-h-full sm:min-h-0 w-full sm:max-w-lg sm:rounded-2xl sm:border sm:border-slate-200 bg-white shadow-xl" @click.stop>
<form method="POST" action="{{ $addDomainAction }}">
<form method="POST" action="{{ $addDomainAction }}"
x-data="ladillDomainSourceForm({
domainSource: @js($initialDomainSource),
selectedOwnedDomain: @js($selectedOwned),
externalDomain: @js($externalFallback),
ownedDomains: @js($ownedDomainHosts),
ownedUrl: @js(route('servers.domains.owned', ['exclude' => $linkedSites->pluck('domain')->filter()->all()])),
onboardingMode: @js(old('onboarding_mode', 'ns_auto')),
})">
@csrf
<input type="hidden" name="is_owned_domain" :value="domainSource === 'ladill' ? '1' : '0'">
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<h3 class="text-base font-semibold text-slate-900">Connect a Domain</h3>
@@ -483,83 +481,15 @@
</button>
</div>
<div class="p-6 space-y-5">
{{-- Domain Source Toggle --}}
<div>
<div class="mb-2 flex items-center justify-between">
<label class="text-sm font-medium text-slate-700">Domain</label>
<div class="inline-flex items-center rounded-md bg-slate-100 p-0.5 text-[11px] font-medium">
<button type="button" @click="domainSource = 'ladill'"
:class="domainSource === 'ladill' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
class="rounded px-2 py-1 transition">Ladill</button>
<button type="button" @click="domainSource = 'external'"
:class="domainSource === 'external' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
class="rounded px-2 py-1 transition">External</button>
</div>
</div>
{{-- Ladill (Owned) Domains --}}
<div x-show="domainSource === 'ladill'" x-cloak>
@if($ownedDomains->isNotEmpty())
<select name="domain" x-model="selectedOwnedDomain" :disabled="domainSource !== 'ladill'"
class="block w-full rounded-xl border-slate-200 bg-slate-50 text-sm focus:border-indigo-500 focus:bg-white focus:ring-indigo-500">
<option value="">Choose a domain...</option>
@foreach($ownedDomains as $domain)
<option value="{{ $domain->host }}">{{ $domain->host }}</option>
@endforeach
</select>
<p class="mt-1.5 text-xs text-slate-400">
Don't have a domain?
<a href="{{ ladill_domains_url('/find') }}" class="font-medium text-indigo-600 hover:text-indigo-800">Purchase one first</a>
</p>
@else
<div class="rounded-lg bg-slate-50 px-4 py-3 text-center text-sm text-slate-600">
No domains found in your account.
<a href="{{ ladill_domains_url('/find') }}" class="font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-800">Register a domain</a>
</div>
@endif
</div>
{{-- External Domain --}}
<div x-show="domainSource === 'external'" x-cloak>
<input name="domain" type="text" x-model="externalDomain" :disabled="domainSource !== 'external'" placeholder="example.com"
class="w-full rounded-xl border-slate-200 bg-slate-50 px-4 py-2.5 text-sm focus:border-indigo-500 focus:bg-white focus:ring-indigo-500">
<p class="mt-1.5 text-xs text-slate-400">Enter your domain without www or http (e.g., example.com)</p>
</div>
@if ($formErrors->has('domain'))
<p class="mt-2 text-xs text-rose-600">{{ $formErrors->first('domain') }}</p>
@endif
</div>
{{-- Connection Method (External only) --}}
<div x-show="domainSource === 'external'" x-cloak>
<label class="mb-2 block text-sm font-medium text-slate-700">Connection method</label>
<div class="space-y-2">
<label class="flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition"
:class="onboardingMode === 'ns_auto' ? 'border-indigo-300 bg-indigo-50/50' : 'border-slate-200 hover:border-slate-300'">
<input type="radio" x-model="onboardingMode" name="onboarding_mode" value="ns_auto" class="mt-0.5 text-indigo-600 focus:ring-indigo-500">
<div>
<p class="text-sm font-medium text-slate-900">Point Nameservers</p>
<p class="mt-0.5 text-xs text-slate-500">Point your domain's nameservers to Ladill. We'll manage DNS and SSL automatically.</p>
</div>
</label>
<label class="flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition"
:class="onboardingMode === 'manual_dns' ? 'border-indigo-300 bg-indigo-50/50' : 'border-slate-200 hover:border-slate-300'">
<input type="radio" x-model="onboardingMode" name="onboarding_mode" value="manual_dns" class="mt-0.5 text-indigo-600 focus:ring-indigo-500">
<div>
<p class="text-sm font-medium text-slate-900">Add DNS Records</p>
<p class="mt-0.5 text-xs text-slate-500">Keep your current DNS provider and manually add A records pointing to our server.</p>
</div>
</label>
</div>
</div>
<x-domain-source-fields
:owned-domains="$ownedDomainHosts"
owned-url="{{ route('servers.domains.owned') }}"
show-connection-method
/>
<div class="flex items-center justify-end gap-3 pt-1">
<button @click="domainModal = false" type="button" class="rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition">Cancel</button>
<button type="submit"
class="btn-primary">
Add Domain
</button>
<button type="submit" class="btn-primary">Add Domain</button>
</div>
</div>
</form>
+21 -136
View File
@@ -16,9 +16,9 @@
@endif
@php
$ownedDomainHosts = $ownedDomains->map(fn ($d) => (string) $d)->all();
$ownedDomainHosts = collect($ownedDomains)->map(fn ($d) => (string) $d)->all();
$oldDomain = trim((string) old('domain', ''));
$initialDomainSource = $ownedDomains->isEmpty()
$initialDomainSource = empty($ownedDomainHosts)
? 'external'
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
$selectedOwnedDomain = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
@@ -28,113 +28,21 @@
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h3 class="text-base font-semibold text-slate-900 mb-4">Add Domain</h3>
<form action="{{ route('hosting.panel.domains.add', $account) }}" method="POST" class="space-y-5"
x-data="{
domainSource: @js($initialDomainSource),
selectedOwnedDomain: @js($selectedOwnedDomain),
externalDomain: @js($externalFallbackDomain),
onboardingMode: '{{ old('onboarding_mode', 'ns_auto') }}'
}">
x-data="ladillDomainSourceForm({
domainSource: @js($initialDomainSource),
selectedOwnedDomain: @js($selectedOwnedDomain),
externalDomain: @js($externalFallbackDomain),
ownedDomains: @js($ownedDomainHosts),
ownedUrl: @js(route('servers.domains.owned', ['exclude' => $account->sites->pluck('domain')->filter()->all()])),
onboardingMode: @js(old('onboarding_mode', 'ns_auto')),
})">
@csrf
<input type="hidden" name="is_owned_domain" :value="domainSource === 'ladill' ? '1' : '0'">
{{-- Domain Source Toggle --}}
<div>
<div class="mb-2 flex items-center justify-between">
<label class="text-sm font-medium text-slate-700">Domain</label>
<div class="inline-flex items-center rounded-md bg-slate-100 p-0.5 text-[11px] font-medium">
<button type="button" @click="domainSource = 'ladill'"
:class="domainSource === 'ladill' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
class="rounded px-2 py-1 transition">Ladill</button>
<button type="button" @click="domainSource = 'external'"
:class="domainSource === 'external' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
class="rounded px-2 py-1 transition">External</button>
</div>
</div>
{{-- Ladill (Owned) Domains --}}
<div x-show="domainSource === 'ladill'" x-cloak>
@if($ownedDomains->isNotEmpty())
<select name="domain" x-model="selectedOwnedDomain" :disabled="domainSource !== 'ladill'"
class="block w-full rounded-lg border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">Choose a domain...</option>
@foreach($ownedDomains as $domain)
<option value="{{ $domain }}">{{ $domain }}</option>
@endforeach
</select>
<p class="mt-1.5 text-xs text-slate-500">
Don't have a domain?
<a href="{{ ladill_domains_url('/find') }}" class="font-medium text-indigo-600 hover:text-indigo-800">Purchase one first</a>
</p>
@else
<div class="rounded-lg bg-slate-50 px-4 py-3 text-center text-sm text-slate-600">
No domains found in your account.
<a href="{{ ladill_domains_url('/find') }}" class="font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-800">Register a domain</a>
</div>
@endif
</div>
{{-- External Domain --}}
<div x-show="domainSource === 'external'" x-cloak>
<input type="text" name="domain" x-model="externalDomain" :disabled="domainSource !== 'external'" required placeholder="example.com"
class="block w-full rounded-lg border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 @error('domain') border-red-300 @enderror">
<p class="mt-1.5 text-xs text-slate-500">Enter your domain without www or http (e.g., example.com)</p>
</div>
@error('domain')
<p class="mt-1.5 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- Onboarding Mode (External only) --}}
<div x-show="domainSource === 'external'" x-cloak>
<label class="block text-sm font-medium text-slate-700 mb-3">Connection Method</label>
<div class="space-y-3">
<label class="relative flex cursor-pointer rounded-lg border border-slate-200 bg-white p-4 hover:bg-slate-50 transition">
<input type="radio" name="onboarding_mode" value="ns_auto" x-model="onboardingMode" class="sr-only peer">
<div class="flex flex-1 items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600">
<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="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>
</div>
<div class="flex-1">
<p class="text-sm font-semibold text-slate-900">Point Nameservers</p>
<p class="mt-0.5 text-xs text-slate-500">Point your domain's nameservers to Ladill. We'll manage DNS and SSL automatically.</p>
</div>
</div>
<div class="absolute right-4 top-4 hidden h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white peer-checked:flex">
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</div>
<div class="absolute inset-0 rounded-lg border-2 border-transparent peer-checked:border-indigo-600 pointer-events-none"></div>
</label>
<label class="relative flex cursor-pointer rounded-lg border border-slate-200 bg-white p-4 hover:bg-slate-50 transition">
<input type="radio" name="onboarding_mode" value="manual_dns" x-model="onboardingMode" class="sr-only peer">
<div class="flex flex-1 items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
<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="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.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.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>
</div>
<div class="flex-1">
<p class="text-sm font-semibold text-slate-900">Add DNS Records</p>
<p class="mt-0.5 text-xs text-slate-500">Keep your current DNS provider and manually add A records pointing to our server.</p>
</div>
</div>
<div class="absolute right-4 top-4 hidden h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white peer-checked:flex">
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</div>
<div class="absolute inset-0 rounded-lg border-2 border-transparent peer-checked:border-indigo-600 pointer-events-none"></div>
</label>
</div>
@error('onboarding_mode')
<p class="mt-1.5 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- Document Root (optional, both flows) --}}
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Document Root <span class="font-normal text-slate-400">(optional)</span></label>
<input type="text" name="document_root" placeholder="public_html/example.com" value="{{ old('document_root') }}"
class="w-full rounded-lg border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-slate-500">Leave empty to use default: public_html/domain.com</p>
</div>
<x-domain-source-fields
:owned-domains="$ownedDomainHosts"
owned-url="{{ route('servers.domains.owned') }}"
show-connection-method
show-document-root
/>
<button type="submit" class="btn-primary">
<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="M12 4.5v15m7.5-7.5h-15"/></svg>
@@ -188,7 +96,6 @@
>
<x-slot:trigger>
<button type="button" class="inline-flex items-center gap-1.5 rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-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="M14.74 9l-.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 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
Remove
</button>
</x-slot:trigger>
@@ -209,21 +116,14 @@
@endif
</div>
@php
$serverIp = $account->node?->ip_address ?? '161.97.138.149';
@endphp
@php $serverIp = $account->node?->ip_address ?? '161.97.138.149'; @endphp
{{-- DNS Quick Reference --}}
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h3 class="text-base font-semibold text-slate-900 mb-2">DNS Quick Reference</h3>
<p class="text-sm text-slate-600 mb-4">Use one of these methods to connect an external domain:</p>
<div class="grid gap-4 md:grid-cols-2">
<div class="rounded-lg border border-emerald-200 bg-emerald-50/50 p-4">
<div class="flex items-center gap-2 mb-2">
<h4 class="text-sm font-semibold text-slate-900">Nameservers</h4>
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-800">Recommended</span>
</div>
<h4 class="text-sm font-semibold text-slate-900 mb-2">Nameservers</h4>
<div class="space-y-1.5">
<div class="flex items-center justify-between rounded bg-white border border-emerald-200 px-3 py-1.5">
<span class="text-sm font-mono text-slate-900">ns1.ladill.com</span>
@@ -235,27 +135,12 @@
</div>
</div>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50/50 p-4">
<h4 class="text-sm font-semibold text-slate-900 mb-2">A Records</h4>
<div class="space-y-1 rounded bg-white border border-slate-200 p-3">
<div class="grid grid-cols-3 gap-2 text-xs">
<span class="font-medium text-slate-500">Type</span>
<span class="font-medium text-slate-500">Name</span>
<span class="font-medium text-slate-500">Value</span>
</div>
<div class="grid grid-cols-3 gap-2 text-xs">
<span class="font-mono text-slate-900">A</span>
<span class="font-mono text-slate-900">@</span>
<span class="font-mono text-slate-900">{{ $serverIp }}</span>
</div>
<div class="grid grid-cols-3 gap-2 text-xs">
<span class="font-mono text-slate-900">A</span>
<span class="font-mono text-slate-900">www</span>
<span class="font-mono text-slate-900">{{ $serverIp }}</span>
</div>
<div class="space-y-1 rounded bg-white border border-slate-200 p-3 text-xs font-mono">
<div>A @ {{ $serverIp }}</div>
<div>A www {{ $serverIp }}</div>
</div>
<p class="mt-2 text-xs text-slate-500">DNS changes can take up to 2448 hours.</p>
</div>
</div>
</div>
@@ -35,50 +35,23 @@
@if ($nativeForm['requires_domain'])
@php
$newDomainUrl = ladill_domains_url('/find');
$ownedDomainHosts = $userDomains;
$selectedOwned = in_array($oldDomainName, $ownedDomainHosts, true) ? $oldDomainName : '';
$externalFallback = $initialDomainSource === 'external' ? $oldDomainName : '';
@endphp
<div>
<div class="mb-2 flex items-center justify-between">
<label class="text-sm font-medium text-gray-700">Domain</label>
<div class="inline-flex items-center rounded-md bg-gray-100 p-0.5 text-[11px] font-medium">
<button type="button" @click="domainSource = 'ladill'"
:class="domainSource === 'ladill' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
class="rounded px-2 py-1 transition">Ladill</button>
<button type="button" @click="domainSource = 'external'"
:class="domainSource === 'external' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
class="rounded px-2 py-1 transition">External</button>
</div>
</div>
<div x-show="domainSource === 'ladill'" x-cloak>
@if ($userDomains->isNotEmpty())
<div class="space-y-2">
<select name="domain_name" :disabled="domainSource !== 'ladill'"
class="w-full rounded-lg border-gray-200 bg-white px-3 py-2 text-sm focus:border-gray-400 focus:ring-0">
<option value="">Choose a domain</option>
@foreach ($userDomains as $domain)
<option value="{{ $domain->host }}" @selected(old('domain_name') === $domain->host)>{{ $domain->host }}</option>
@endforeach
</select>
<p class="text-xs text-gray-500">
Prefer another?
<a href="{{ $newDomainUrl }}" class="font-medium text-gray-700 underline underline-offset-2 hover:text-gray-900">Get a new domain</a>
</p>
</div>
@else
<div class="rounded-lg bg-gray-50 px-3 py-2.5 text-center text-xs">
<a href="{{ $newDomainUrl }}" class="font-medium text-gray-700 underline underline-offset-2 hover:text-gray-900">Get a new domain</a>
</div>
@endif
</div>
<div x-show="domainSource === 'external'" x-cloak>
<input name="domain_name" type="text" x-model="externalDomain" :disabled="domainSource !== 'external'" value="{{ old('domain_name') }}"
placeholder="example.com"
class="w-full rounded-lg border-gray-200 bg-white px-3 py-2 text-sm placeholder-gray-400 focus:border-gray-400 focus:ring-0">
</div>
@error('domain_name')
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
@enderror
<div x-data="ladillDomainSourceForm({
domainSource: @js($initialDomainSource),
selectedOwnedDomain: @js($selectedOwned),
externalDomain: @js($externalFallback),
ownedDomains: @js($ownedDomainHosts),
ownedUrl: @js(route('servers.domains.owned')),
})">
<x-domain-source-fields
field-name="domain_name"
:owned-domains="$ownedDomainHosts"
owned-url="{{ route('servers.domains.owned') }}"
variant="gray"
/>
</div>
@endif
+16 -12
View File
@@ -85,20 +85,24 @@
@endphp
<div class="space-y-6" x-data="{ showOrderModal: @js($shouldOpenOrderModal) }">
{{-- Page Header --}}
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">{{ $title }}</h1>
<p class="mt-1 text-sm text-gray-500">Manage your {{ strtolower($title) }} orders.</p>
</div>
<x-servers.page-hero
:badge="$type === 'vps' ? 'SSD · Root access · Flexible billing' : 'Bare metal · RAID · Full root'"
:title="$type === 'vps' ? 'VPS' : 'Dedicated servers'"
:description="$type === 'vps'
? 'Deploy scalable virtual private servers — pick a plan, region, and image.'
: 'Maximum performance and isolation for production workloads on dedicated hardware.'"
:stats="[
['value' => number_format($heroStats['total']), 'label' => 'Servers'],
['value' => number_format($heroStats['active']), 'label' => 'Active'],
['value' => number_format($heroStats['pending']), 'label' => 'Pending'],
['value' => number_format($heroStats['plans']), 'label' => 'Plans'],
]">
@if ($serverOrderPayloads !== [])
<button type="button" @click="showOrderModal = true"
class="inline-flex items-center gap-1.5 rounded-lg bg-gray-900 px-3.5 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-gray-800">
<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="M12 4.5v15m7.5-7.5h-15"/></svg>
{{ $orderButtonLabel }}
</button>
<x-slot name="actions">
<x-btn.create type="button" @click="showOrderModal = true">{{ $orderButtonLabel }}</x-btn.create>
</x-slot>
@endif
</div>
</x-servers.page-hero>
@if ($hasAnyProducts)
<div class="space-y-3">
+2 -2
View File
@@ -6,7 +6,7 @@
$orderFormErrorFields = ['domain_name', 'plan_id', 'months'];
$hasOrderFormErrors = collect($orderFormErrorFields)->contains(fn ($field) => $errors->has($field));
$shouldOpenOrderModal = (bool) session('open_product_order_modal', false) || $hasOrderFormErrors;
$knownDomainHosts = $userDomains->pluck('host')->all();
$knownDomainHosts = $userDomains;
$oldDomainName = trim((string) old('domain_name', ''));
$initialDomainSource = $oldDomainName !== '' && ! in_array($oldDomainName, $knownDomainHosts, true) ? 'external' : 'ladill';
$initialExternalDomain = $initialDomainSource === 'external' ? $oldDomainName : '';
@@ -52,7 +52,7 @@
|| $rcServiceOrders->isNotEmpty();
@endphp
<div class="space-y-6" x-data="{ showOrderModal: @js($shouldOpenOrderModal), domainSource: @js($initialDomainSource), externalDomain: @js($initialExternalDomain) }">
<div class="space-y-6" x-data="{ showOrderModal: @js($shouldOpenOrderModal) }">
{{-- Page Header --}}
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
+1 -5
View File
@@ -46,11 +46,7 @@
'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')],
],
'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
'avatarUrl' => $navUser?->avatar_url,
'initials' => $navInitials !== '' ? $navInitials : 'U',
])
+3 -6
View File
@@ -10,7 +10,7 @@
<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 }">
<body class="h-full font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }" data-ladill-search-url="{{ route('servers.search') }}">
<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"
@@ -45,11 +45,7 @@
'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')],
],
'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
'avatarUrl' => $navUser?->avatar_url,
'initials' => $navInitials !== '' ? $navInitials : 'U',
])
@@ -57,5 +53,6 @@
@endauth
@include('partials.sso-keepalive')
@include('partials.confirm-prompt')
@include('components.domain-purchase-modal')
</body>
</html>
@@ -8,7 +8,7 @@
@if ($handler === 'openAfia')
@click="openAfia()"
@else
@click="$dispatch('afia-open')"
@click="window.dispatchEvent(new CustomEvent('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',
+1 -1
View File
@@ -59,7 +59,7 @@
<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>
x-html="m.text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\*\*([^*]+?)\*\*/g,'<strong>$1</strong>')"></div>
</div>
</template>
<div x-show="loading" class="flex justify-start">
@@ -0,0 +1,52 @@
@php
// Branded boot splash (Ladill Mail style). Self-icon from this app's subdomain.
$sub = strtolower((string) ((explode('.', (string) (parse_url((string) config('app.url'), PHP_URL_HOST) ?: '')))[0] ?? ''));
$icon = $sub !== '' && is_file(public_path("images/launcher-icons/{$sub}.svg")) ? "images/launcher-icons/{$sub}.svg" : null;
$label = (string) config('app.name', 'Ladill');
@endphp
<div id="ladill-boot" role="status" aria-live="polite">
<div class="lb-wrap">
@if ($icon)
<img class="lb-logo" src="{{ asset($icon) }}?v={{ @filemtime(public_path($icon)) ?: '1' }}" alt="">
@endif
<div class="lb-bar"><span></span></div>
<div class="lb-title">Loading {{ $label }}</div>
</div>
</div>
<style>
#ladill-boot{position:fixed;inset:0;z-index:99999;background:#f8fafc;display:flex;align-items:center;justify-content:center;transition:opacity .35s ease;font-family:'Figtree',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
#ladill-boot .lb-wrap{display:flex;flex-direction:column;align-items:center;gap:24px;width:260px}
#ladill-boot .lb-logo{width:64px;height:64px;animation:lb-pulse 1.6s ease-in-out infinite}
#ladill-boot .lb-bar{width:100%;height:4px;background:#e2e8f0;border-radius:999px;overflow:hidden}
#ladill-boot .lb-bar>span{display:block;height:100%;width:8%;border-radius:999px;background:linear-gradient(90deg,#4f46e5,#7c3aed);animation:lb-fill 1.9s cubic-bezier(.4,0,.2,1) forwards}
#ladill-boot .lb-title{font-size:14px;color:#64748b;font-weight:500}
@keyframes lb-fill{0%{width:8%}55%{width:68%}100%{width:93%}}
@keyframes lb-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(.93);opacity:.82}}
</style>
<script>
(function(){
var el=document.getElementById('ladill-boot');
if(!el)return;
function remove(){if(el&&el.parentNode)el.parentNode.removeChild(el);}
// Show when the user opens or reloads the app; suppress only on internal
// link navigations and back/forward so we don't flash on every click.
try{
var navType='navigate';
var nav=performance.getEntriesByType&&performance.getEntriesByType('navigation');
if(nav&&nav.length){navType=nav[0].type;}
else if(performance.navigation){navType=performance.navigation.type===1?'reload':(performance.navigation.type===2?'back_forward':'navigate');}
var internal=false;
if(navType!=='reload'&&document.referrer){
try{internal=new URL(document.referrer).origin===location.origin;}catch(e){}
}
if(internal||navType==='back_forward'){remove();return;}
}catch(e){}
var start=Date.now(),MIN=550;
function done(){
var wait=Math.max(0,MIN-(Date.now()-start));
setTimeout(function(){if(!el)return;el.style.opacity='0';setTimeout(remove,400);},wait);
}
if(document.readyState==='complete')done();else window.addEventListener('load',done);
setTimeout(done,8000); // safety net
})();
</script>
+43 -12
View File
@@ -10,10 +10,27 @@
config('ladill_launcher.apps', []),
fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost
));
$launcherOverflows = count($launcherApps) > 15;
@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"
<div class="relative" x-data="{
appsOpen: false,
showScrollMore: {{ $launcherOverflows ? 'true' : 'false' }},
checkScrollEnd() {
const el = this.$refs.appsScroller;
if (! el) return;
this.showScrollMore = el.scrollTop + el.clientHeight < el.scrollHeight - 4;
},
toggleApps() {
this.appsOpen = ! this.appsOpen;
if (this.appsOpen) {
this.$nextTick(() => this.checkScrollEnd());
} else {
this.showScrollMore = {{ $launcherOverflows ? 'true' : 'false' }};
}
},
}" @click.outside="appsOpen = false" @keydown.escape.window="appsOpen = false">
<button type="button" @click="toggleApps()"
@class([
'inline-flex items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:bg-slate-50',
'p-2' => ! $sidebar,
@@ -37,16 +54,30 @@
'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 @class(['ladill-launcher-apps-wrap' => $launcherOverflows])>
<div
class="ladill-launcher-apps"
@if ($launcherOverflows) x-ref="appsScroller" @scroll="checkScrollEnd()" @endif
>
<div class="grid grid-cols-3 gap-1">
@foreach ($launcherApps as $app)
<a href="{{ $app['url'] }}"
class="flex min-h-[5.25rem] flex-col items-center justify-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>
@if ($launcherOverflows)
<div class="ladill-launcher-scroll-more" x-show="showScrollMore" x-cloak aria-hidden="true">
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/>
</svg>
</div>
@endif
</div>
</div>
</div>
@@ -1,5 +1,9 @@
@php
$gridCols = !empty($centerCompose) ? 'grid-cols-5' : 'grid-cols-4';
$showSearch = isset($searchUrl) && $searchUrl !== null && $searchUrl !== '#';
$gridCols = match (true) {
! empty($centerCompose) => $showSearch ? 'grid-cols-5' : 'grid-cols-4',
default => $showSearch ? 'grid-cols-4' : 'grid-cols-3',
};
$avatarUrl = $avatarUrl ?? null;
$initials = $initials ?? 'U';
$notificationsUrl = $notificationsUrl ?? '#';
@@ -23,11 +27,13 @@
<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">Search</span>
</a>
@if ($showSearch)
<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">Search</span>
</a>
@endif
@if (!empty($centerCompose))
<div class="flex items-center justify-center">
@@ -51,12 +57,14 @@
},
}"
@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
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($notificationsActive ?? false) }}">
<span class="relative inline-flex shrink-0">
<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-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>
@endif
</span>
<span class="text-[10px] font-medium">Notifications</span>
</a>
@@ -82,6 +90,7 @@
class="absolute inset-0 bg-slate-900/40 backdrop-blur-[1px]"></div>
<div x-show="profileOpen"
x-effect="profileOpen && window.dispatchEvent(new CustomEvent('wallet-balance-refresh'))"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-y-full"
x-transition:enter-end="translate-y-0"
@@ -113,25 +122,11 @@
</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>
@include('partials.user-profile-menu', [
'items' => $profileMenuItems,
'variant' => 'sheet',
'onNavigate' => 'profileOpen = false',
])
</div>
</div>
</div>
@@ -0,0 +1,53 @@
@php
$variant = $variant ?? 'primary';
$type = $type ?? 'submit';
$tag = ! empty($href) ? 'a' : 'button';
$ariaLabel = $ariaLabel ?? $label ?? '';
$desktopLabel = $desktopLabel ?? $label ?? '';
$showDesktopIcon = $showDesktopIcon ?? ($variant === 'primary');
$mobileClass = match ($variant) {
'primary' => 'btn-fab h-10 w-10 lg:hidden',
'outline' => 'inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 lg:hidden',
'dark' => 'inline-flex h-10 w-10 items-center justify-center rounded-full bg-gray-900 text-white shadow-sm transition hover:bg-gray-800 lg:hidden',
'indigo' => 'inline-flex h-10 w-10 items-center justify-center rounded-full bg-indigo-600 text-white shadow-sm transition hover:bg-indigo-700 lg:hidden',
default => 'btn-fab h-10 w-10 lg:hidden',
};
$desktopClass = match ($variant) {
'primary' => 'btn-primary hidden items-center lg:inline-flex',
'outline' => 'hidden items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 lg:inline-flex',
'dark' => 'hidden items-center gap-1.5 rounded-lg bg-gray-900 px-3.5 py-2 text-sm font-medium text-white transition hover:bg-gray-800 lg:inline-flex',
'indigo' => 'hidden items-center justify-center rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-700 lg:inline-flex',
default => 'btn-primary hidden items-center lg:inline-flex',
};
$extraClass = $class ?? '';
@endphp
<{{ $tag }}
@if ($tag === 'a') href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
aria-label="{{ $ariaLabel }}"
title="{{ $ariaLabel }}"
@if (! empty($attributes)) {!! $attributes !!} @endif
class="{{ trim($mobileClass.' '.$extraClass) }}"
>
<svg class="h-5 w-5" 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>
</{{ $tag }}>
<{{ $tag }}
@if ($tag === 'a') href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
@if (! empty($attributes)) {!! $attributes !!} @endif
class="{{ trim($desktopClass.' '.$extraClass) }}"
>
@if ($showDesktopIcon)
<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>
@endif
{{ $desktopLabel }}
</{{ $tag }}>
@@ -0,0 +1,33 @@
@php
$icon = $icon ?? 'arrow';
$desktopClass = $desktopClass ?? 'text-xs font-medium text-indigo-600 hover:text-indigo-700';
$label = $label ?? '';
$extraAttributes = $attributes ?? '';
@endphp
<a href="{{ $href }}"
aria-label="{{ $label }}"
title="{{ $label }}"
@if ($extraAttributes !== '') {!! $extraAttributes !!} @endif
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-indigo-50 text-indigo-600 ring-1 ring-indigo-100 transition hover:bg-indigo-100 lg:h-auto lg:w-auto lg:rounded-none lg:bg-transparent lg:ring-0 lg:hover:bg-transparent">
<span class="lg:hidden">
@if ($icon === 'plus')
<svg class="h-4 w-4" 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>
@elseif ($icon === 'shield')
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"/>
</svg>
@elseif ($icon === 'calendar')
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"/>
</svg>
@else
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/>
</svg>
@endif
</span>
<span class="hidden lg:inline {{ $desktopClass }}">{{ $label }}</span>
</a>
+626 -45
View File
@@ -1,54 +1,635 @@
{{--
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.
Payment checkout shell.
Audiences:
- buyer (default): public ticket buyers, donors, shoppers self-serve checkout
- operator: signed-in staff flows such as wallet top-up
Optional overrides: $sheetTitle, $sheetSubtitle, $sheetFooter, $sheetAria, $iframeTitle
Requires Alpine ancestor with:
- showSheet (bool)
- checkoutUrl (string) authorization / waiting URL
- accessCode (string, optional) Paystack initialize access_code for Inline
- publicKey (string, optional)
- returnUrl (string, optional) where to go after Inline onSuccess (?reference=)
checkout.paystack.com cannot be iframed (X-Frame-Options: SAMEORIGIN).
Paystack Inline owns its secure payment popup. We do not place a Ladill
loading sheet in front of it: that created a needless first screen before
the checkout appeared. The Ladill bottomsheet/modal is reserved for
same-origin waiting pages (such as MoMo) and recoverable errors.
--}}
@php
$audience = $audience ?? 'buyer';
$defaults = match ($audience) {
'operator' => [
'title' => 'Complete payment',
'subtitle' => null,
'aria' => 'Complete payment',
'iframe' => 'Payment checkout',
'footer' => null,
],
default => [
'title' => 'Complete your payment',
'subtitle' => 'Pay securely. You\'ll get a confirmation when it succeeds.',
'aria' => 'Complete your payment',
'iframe' => 'Secure payment checkout',
'footer' => 'Your payment is processed securely. Do not close this window until finished.',
],
};
$sheetTitle = $sheetTitle ?? $defaults['title'];
$sheetSubtitle = $sheetSubtitle ?? $defaults['subtitle'];
$sheetAria = $sheetAria ?? $defaults['aria'];
$iframeTitle = $iframeTitle ?? $defaults['iframe'];
$sheetFooter = $sheetFooter ?? $defaults['footer'];
@endphp
@once
<script>
(function () {
if (window.__ladillPayCheckoutBootstrapped) return;
window.__ladillPayCheckoutBootstrapped = true;
var INLINE_SRC = 'https://js.paystack.co/v2/inline.js';
var inlineLoading = null;
var activePopup = null;
function isFrameable(url) {
if (!url) return false;
try {
return new URL(url, window.location.href).origin === window.location.origin;
} catch (e) {
return false;
}
}
function isPaystackCheckoutUrl(url) {
if (!url) return false;
try {
var host = new URL(url, window.location.href).hostname.toLowerCase();
return host === 'checkout.paystack.com' || host.endsWith('.paystack.com');
} catch (e) {
return false;
}
}
function accessCodeFromUrl(url) {
if (!url || !isPaystackCheckoutUrl(url)) return '';
try {
var path = new URL(url, window.location.href).pathname.replace(/^\/+/, '');
var code = path.split('/')[0] || '';
return /^[A-Za-z0-9_-]{6,}$/.test(code) ? code : '';
} catch (e) {
return '';
}
}
function resolveAccessCode(accessCode, checkoutUrl) {
var code = (accessCode || '').toString().trim();
if (code) return code;
return accessCodeFromUrl(checkoutUrl);
}
function loadInlineJs() {
if (window.PaystackPop) {
return Promise.resolve(window.PaystackPop);
}
if (inlineLoading) return inlineLoading;
inlineLoading = new Promise(function (resolve, reject) {
var existing = document.querySelector('script[data-ladill-paystack-inline]');
if (existing) {
if (window.PaystackPop) {
resolve(window.PaystackPop);
return;
}
existing.addEventListener('load', function () { resolve(window.PaystackPop); });
existing.addEventListener('error', function () { reject(new Error('Paystack script failed')); });
return;
}
var script = document.createElement('script');
script.src = INLINE_SRC;
script.async = true;
script.dataset.ladillPaystackInline = '1';
script.onload = function () { resolve(window.PaystackPop); };
script.onerror = function () {
inlineLoading = null;
reject(new Error('Paystack script failed'));
};
document.head.appendChild(script);
});
return inlineLoading;
}
function buildReturnUrl(returnUrl, reference) {
if (!returnUrl || !reference) return '';
try {
var u = new URL(returnUrl, window.location.href);
u.searchParams.set('reference', reference);
return u.toString();
} catch (e) {
var join = returnUrl.indexOf('?') >= 0 ? '&' : '?';
return returnUrl + join + 'reference=' + encodeURIComponent(reference);
}
}
function cancelInline() {
try {
if (activePopup && typeof activePopup.cancelTransaction === 'function') {
activePopup.cancelTransaction();
}
} catch (e) {}
activePopup = null;
}
// Fullscreen permission for Paystack's own iframes (do not reparent them).
// Only write attributes when they actually change — writing always re-fires
// MutationObservers and freezes the page (wallet include path).
function applyIframePermissions(iframe) {
if (!iframe || !iframe.setAttribute) return;
try {
var cur = (iframe.getAttribute('allow') || '').trim();
var need = ['payment *', 'fullscreen *', 'clipboard-read *', 'clipboard-write *', 'publickey-credentials-get *'];
var parts = cur ? cur.split(';').map(function (p) { return p.trim(); }).filter(Boolean) : [];
var lower = parts.map(function (p) { return p.toLowerCase(); });
need.forEach(function (perm) {
var base = perm.split(' ')[0].toLowerCase();
if (!lower.some(function (p) { return p === base || p.indexOf(base + ' ') === 0; })) {
parts.push(perm);
}
});
var next = parts.join('; ');
if (cur !== next) {
iframe.setAttribute('allow', next);
}
if (!iframe.hasAttribute('allowfullscreen')) {
iframe.setAttribute('allowfullscreen', '');
}
} catch (e) {}
}
function ensurePaystackIframePermissions(root) {
try {
var scope = root || document;
var nodes = scope.querySelectorAll ? scope.querySelectorAll('iframe') : [];
for (var i = 0; i < nodes.length; i++) {
var iframe = nodes[i];
var id = (iframe.id || '').toLowerCase();
var src = '';
try { src = (iframe.getAttribute('src') || '').toLowerCase(); } catch (e) {}
if (id.indexOf('inline-') === 0 || id.indexOf('embed-') === 0 || src.indexOf('paystack') !== -1) {
applyIframePermissions(iframe);
}
}
} catch (e) {}
}
function installIframeAllowHook() {
if (window.__ladillIframeAllowHook) return;
window.__ladillIframeAllowHook = true;
try {
var orig = Document.prototype.createElement;
Document.prototype.createElement = function (tagName, options) {
var el = options !== undefined ? orig.call(this, tagName, options) : orig.call(this, tagName);
try {
if (String(tagName).toLowerCase() === 'iframe') {
applyIframePermissions(el);
}
} catch (e) {}
return el;
};
} catch (e) {}
}
/**
* Arm iframe permission helpers only while a payment is starting.
* Do not install on every wallet/page load observing style/class + always
* rewriting allow caused an infinite MutationObserver loop (page unresponsive).
*/
function watchPaystackIframes() {
if (window.__ladillPaystackIframeWatch) return;
window.__ladillPaystackIframeWatch = true;
installIframeAllowHook();
ensurePaystackIframePermissions(document);
try {
var obs = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
// childList only — never re-enter on attribute writes.
if (!m.addedNodes || !m.addedNodes.length) continue;
for (var n = 0; n < m.addedNodes.length; n++) {
var node = m.addedNodes[n];
if (!node || node.nodeType !== 1) continue;
if (node.tagName === 'IFRAME') {
applyIframePermissions(node);
} else if (node.querySelectorAll) {
var nested = node.querySelectorAll('iframe');
for (var k = 0; k < nested.length; k++) {
applyIframePermissions(nested[k]);
}
}
}
}
});
obs.observe(document.documentElement || document.body, {
childList: true,
subtree: true,
});
} catch (e) {}
}
function emitPayEvent(name, detail) {
try {
window.dispatchEvent(new CustomEvent(name, { detail: detail || {} }));
} catch (e) {}
}
function createStoreDefinition() {
return {
frameable: false,
inline: false,
ready: false,
launching: false,
error: '',
_launchToken: 0,
_activeCode: '',
_openedEmitted: false,
shellVisible() {
return !!(this.frameable || this.error);
},
reset() {
this.frameable = false;
this.inline = false;
this.ready = false;
this.launching = false;
this.error = '';
this._activeCode = '';
this._openedEmitted = false;
cancelInline();
},
markOpened() {
if (this._openedEmitted) return;
this._openedEmitted = true;
this.launching = false;
emitPayEvent('ladill-pay-opened');
},
markFailed(message) {
this.launching = false;
this._activeCode = '';
this._openedEmitted = false;
this.error = message || 'Could not open secure payment.';
emitPayEvent('ladill-pay-error', { message: this.error });
},
sync(show, url, accessCode, returnUrl) {
url = (url || '').toString();
accessCode = (accessCode || '').toString();
returnUrl = (returnUrl || '').toString();
if (!show) {
this._launchToken += 1;
this.reset();
return;
}
this.ready = !!(url || accessCode);
// Keep a previous recoverable error only until a new attempt supplies payload.
if (url || accessCode) {
this.error = '';
}
if (!url && !accessCode) {
// Waiting for parent to set access_code / checkout_url after fetch.
this.frameable = false;
this.inline = false;
this.launching = true;
this._activeCode = '';
this._openedEmitted = false;
return;
}
if (isFrameable(url)) {
this.frameable = true;
this.inline = false;
this.launching = false;
this._activeCode = '';
this._openedEmitted = false;
cancelInline();
// Same-origin waiting UI is our shell — it is "open" for busy-state release.
emitPayEvent('ladill-pay-opened');
return;
}
var code = resolveAccessCode(accessCode, url);
if (code) {
this.frameable = false;
this.inline = true;
if (this._activeCode === code && (this.launching || activePopup)) {
return;
}
this.launchInline(code, returnUrl);
return;
}
this.frameable = false;
this.inline = false;
this.markFailed(
isPaystackCheckoutUrl(url)
? 'Could not start in-app payment. Please try again.'
: 'Secure checkout could not be opened in-page. Please try again.'
);
},
launchInline(accessCode, returnUrl) {
var store = this;
var token = ++this._launchToken;
this._activeCode = accessCode;
this.launching = true;
this.error = '';
this.inline = true;
this.frameable = false;
this._openedEmitted = false;
watchPaystackIframes();
// Ensure Paystack script is fully ready before resumeTransaction so we
// do not paint an empty popup shell for a beat (the pre-modal flash).
loadInlineJs().then(function (PaystackPop) {
if (token !== store._launchToken) return;
if (!PaystackPop) throw new Error('Paystack unavailable');
cancelInline();
var popup = new PaystackPop();
activePopup = popup;
ensurePaystackIframePermissions(document);
popup.resumeTransaction(accessCode, {
onLoad: function () {
if (token !== store._launchToken) return;
store.markOpened();
ensurePaystackIframePermissions(document);
},
onSuccess: function (transaction) {
if (token !== store._launchToken) return;
store.launching = false;
activePopup = null;
var reference = (transaction && (transaction.reference || transaction.trxref)) || '';
var dest = buildReturnUrl(returnUrl, reference);
if (dest) {
window.location.assign(dest);
return;
}
window.location.reload();
},
onCancel: function () {
if (token !== store._launchToken) return;
store.launching = false;
store._activeCode = '';
store._openedEmitted = false;
activePopup = null;
emitPayEvent('ladill-pay-cancelled');
},
onError: function (err) {
if (token !== store._launchToken) return;
activePopup = null;
store.markFailed((err && err.message) ? String(err.message) : 'Could not open secure payment.');
},
});
// Safety: if onLoad is slow, treat a live checkout iframe as open.
var polls = 0;
var pollId = setInterval(function () {
if (token !== store._launchToken) {
clearInterval(pollId);
return;
}
ensurePaystackIframePermissions(document);
var live = document.querySelector(
'iframe[id^="inline-checkout"], iframe[id^="embed-checkout"]'
);
if (live) {
store.markOpened();
clearInterval(pollId);
}
polls += 1;
if (polls >= 50) {
clearInterval(pollId);
if (store.launching && token === store._launchToken) {
store.markFailed('Payment UI did not open. Please try again.');
}
}
}, 100);
}).catch(function (err) {
if (token !== store._launchToken) return;
store.markFailed((err && err.message) ? String(err.message) : 'Could not load payment script.');
});
},
};
}
function ensureStore() {
if (!window.Alpine || typeof window.Alpine.store !== 'function') return null;
try {
var existing = window.Alpine.store('ladillPayCheckout');
if (existing) return existing;
} catch (e) {}
window.Alpine.store('ladillPayCheckout', createStoreDefinition());
return window.Alpine.store('ladillPayCheckout');
}
document.addEventListener('alpine:init', function () {
ensureStore();
});
if (window.Alpine && window.Alpine.version) {
ensureStore();
}
// Do not watch iframes or preload Paystack on every page that includes this
// partial (e.g. wallet). That froze the tab. prepare()/launchInline arm it.
window.LadillPayCheckout = {
isFrameable: isFrameable,
isPaystackCheckoutUrl: isPaystackCheckoutUrl,
accessCodeFromUrl: accessCodeFromUrl,
resolveAccessCode: resolveAccessCode,
prepare: function () {
// Warm script + hooks only when user is about to pay.
installIframeAllowHook();
loadInlineJs().catch(function () {});
return null;
},
cancel: function () {
cancelInline();
var store = ensureStore();
if (store) store.reset();
},
open: function () { return null; },
ensureStore: ensureStore,
};
})();
</script>
@endonce
<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">
{{-- Inherit showSheet / checkoutUrl / accessCode / returnUrl from parent Alpine scope. --}}
<div
x-init="
window.LadillPayCheckout && window.LadillPayCheckout.ensureStore();
// Drop parent busy flags only when payment UI is actually up (or sheet closed).
// Prevents the form/button from flashing back while Paystack is still booting.
const releaseBusy = () => {
try {
if (typeof loading !== 'undefined' && loading) loading = false;
if (typeof renewLoading !== 'undefined' && renewLoading) renewLoading = false;
} catch (e) {}
};
const onPayOpened = () => releaseBusy();
const onPayCancelled = () => { showSheet = false; releaseBusy(); };
const onPayError = () => releaseBusy();
window.addEventListener('ladill-pay-opened', onPayOpened);
window.addEventListener('ladill-pay-cancelled', onPayCancelled);
window.addEventListener('ladill-pay-error', onPayError);
const runSync = () => {
const store = $store.ladillPayCheckout;
if (!store) return;
store.sync(
!!showSheet,
(typeof checkoutUrl === 'undefined' || checkoutUrl === null) ? '' : String(checkoutUrl),
(typeof accessCode === 'undefined' || accessCode === null) ? '' : String(accessCode),
(typeof returnUrl === 'undefined' || returnUrl === null) ? '' : String(returnUrl)
);
};
$watch('showSheet', (value) => {
runSync();
if (!value) releaseBusy();
});
$watch('checkoutUrl', () => runSync());
$watch('accessCode', () => runSync());
$watch('returnUrl', () => runSync());
$nextTick(() => runSync());
"
x-show="showSheet && $store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error)"
x-cloak
data-ladill-pay-sheet
style="display: none"
x-effect="
// Only lock page scroll when OUR shell is visible — never during pure
// Paystack Inline handoff (that scrollbar jump was the pre-modal flash).
const lock = !!(showSheet && $store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error));
document.documentElement.style.overflow = lock ? 'hidden' : '';
"
@keydown.escape.window="
if (!showSheet) return;
// While Paystack Inline is the active UI, Escape is handled by Paystack.
if ($store.ladillPayCheckout && $store.ladillPayCheckout.inline && !$store.ladillPayCheckout.launching && !$store.ladillPayCheckout.error) return;
showSheet = false;
window.LadillPayCheckout?.cancel?.();
"
@ladill-pay-cancelled.window="showSheet = false"
class="fixed inset-0 z-[9999]"
role="dialog"
aria-modal="true"
:aria-hidden="(!(showSheet && $store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error))).toString()"
aria-label="{{ $sheetAria }}">
{{-- Backdrop --}}
<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>
{{-- Sheet --}}
<div class="relative flex flex-col bg-white rounded-t-2xl overflow-hidden"
style="height:90dvh"
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">
<div class="flex shrink-0 items-center justify-between border-b border-slate-100 px-4 py-3">
<div class="absolute left-1/2 top-2 h-1 w-10 -translate-x-1/2 rounded-full bg-slate-200"></div>
<p class="text-sm font-semibold text-slate-800">Complete Payment</p>
<button @click="showSheet = false"
class="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>
{{--
Ladill bottomsheet (mobile) / modal (desktop) ONLY for:
- same-origin waiting pages (MoMo etc.)
- recoverable errors
Paystack Inline opens its own secure payment popup; this shell stays
hidden during launch so nothing flashes in front of it.
--}}
<div x-show="$store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error)"
x-cloak
class="pointer-events-auto absolute inset-0 flex items-end justify-center md:items-center md:p-6"
data-ladill-pay-shell>
<div class="absolute inset-0 bg-black/60"
data-ladill-pay-backdrop
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()">
</div>
<iframe :src="showSheet ? checkoutUrl : ''"
class="flex-1 w-full border-0"
allow="payment"
title="Paystack checkout"></iframe>
<div data-ladill-pay-panel
data-paystack-live="0"
class="relative flex w-full flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl md:max-w-lg md:rounded-2xl"
style="height: min(90dvh, 720px); max-height: 90dvh; padding-bottom: env(safe-area-inset-bottom, 0px)"
x-transition:enter="transition duration-300 ease-out md:duration-200"
x-transition:enter-start="translate-y-full opacity-0 md:translate-y-0 md:scale-95"
x-transition:enter-end="translate-y-0 opacity-100 md:scale-100"
x-transition:leave="transition duration-200 ease-in md:duration-150"
x-transition:leave-start="translate-y-0 opacity-100 md:scale-100"
x-transition:leave-end="translate-y-full opacity-0 md:translate-y-0 md:scale-95"
@click.stop>
<div class="relative flex shrink-0 flex-col gap-0.5 border-b border-slate-100 px-4 pb-3 pt-5 md:px-5 md:pt-3"
style="padding-top: max(1.25rem, env(safe-area-inset-top, 0px))"
data-ladill-pay-header>
<div class="absolute left-1/2 top-2 h-1 w-10 -translate-x-1/2 rounded-full bg-slate-200 md:hidden" data-ladill-pay-handle aria-hidden="true"></div>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-semibold text-slate-800">{{ $sheetTitle }}</p>
@if (! empty($sheetSubtitle))
<p class="mt-0.5 text-xs leading-snug text-slate-500">{{ $sheetSubtitle }}</p>
@endif
</div>
<button type="button"
x-ref="checkoutClose"
x-init="
$watch(
() => !!(showSheet && $store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error)),
(open) => { if (open) $nextTick(() => $refs.checkoutClose?.focus()); }
)
"
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()"
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" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
{{-- Markers kept for tests / callers that query live close chrome. --}}
<div class="sr-only" data-ladill-pay-header-minimal data-ladill-pay-close-live aria-hidden="true"></div>
<div class="sr-only" data-ladill-pay-mount data-ladill-pay-body aria-hidden="true"></div>
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<iframe x-show="$store.ladillPayCheckout && $store.ladillPayCheckout.frameable"
:src="showSheet && $store.ladillPayCheckout && $store.ladillPayCheckout.frameable ? checkoutUrl : ''"
class="h-full min-h-[60dvh] w-full border-0 md:min-h-0"
style="min-height: 28rem"
allow="payment *; fullscreen *; clipboard-read *; clipboard-write *"
title="{{ $iframeTitle }}"></iframe>
<div x-show="$store.ladillPayCheckout && $store.ladillPayCheckout.error"
class="flex min-h-[40dvh] flex-col items-center justify-center gap-4 px-6 py-10 text-center md:min-h-[20rem] md:px-8 md:py-12"
data-ladill-pay-error>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-50 text-red-500" aria-hidden="true">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor">
<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>
</div>
<div class="max-w-xs space-y-1 md:max-w-sm">
<p class="text-sm font-semibold text-slate-800" data-ladill-pay-status-title>Checkout unavailable</p>
<p class="text-xs leading-snug text-slate-500" x-text="$store.ladillPayCheckout.error"></p>
</div>
<button type="button"
data-ladill-pay-retry
@click="window.LadillPayCheckout?.ensureStore()?.sync(true, (typeof checkoutUrl==='undefined'||checkoutUrl===null)?'':String(checkoutUrl), (typeof accessCode==='undefined'||accessCode===null)?'':String(accessCode), (typeof returnUrl==='undefined'||returnUrl===null)?'':String(returnUrl))"
class="inline-flex w-full max-w-xs items-center justify-center rounded-xl bg-indigo-600 px-4 py-3 text-sm font-semibold text-white hover:bg-indigo-700">
Try again
</button>
<button type="button"
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()"
class="text-xs font-medium text-slate-500 hover:text-slate-700">
Cancel
</button>
</div>
</div>
@if (! empty($sheetFooter))
<p class="shrink-0 border-t border-slate-100 bg-slate-50 px-4 py-2 text-center text-[11px] text-slate-500 md:px-5"
data-ladill-pay-footer>
{{ $sheetFooter }}
</p>
@endif
</div>
</div>
</div>
</template>
@@ -14,7 +14,7 @@
<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"
<input data-ladill-search-input x-ref="input"
type="text"
x-model="query"
@focus="onFocus()"
@@ -34,7 +34,7 @@
<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"
<input data-ladill-search-input x-ref="input"
type="text"
x-model="query"
@focus="onFocus()"
@@ -21,24 +21,6 @@
<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>
@php
$accountNav = [
['name' => 'Wallet', 'route' => route('account.wallet'), 'active' => request()->routeIs('account.wallet'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 12m18 0v6a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 9m18 0V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v3" />'],
['name' => 'Billing', 'route' => route('account.billing'), 'active' => request()->routeIs('account.billing'),
'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 6.75 19.5Z" />'],
['name' => 'Team', 'route' => route('account.team'), 'active' => request()->routeIs('account.team'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />'],
];
@endphp
@foreach($accountNav 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
@include('partials.sidebar-support')
</nav>
<div class="shrink-0 border-t border-slate-100 px-3 py-3">
@@ -1,4 +1,21 @@
@if (auth()->check())
{{-- Same-site iframe keeps the shared auth.ladill.com session warm while using this app. --}}
<iframe src="https://{{ config('app.auth_domain') }}/sso/ping" hidden width="0" height="0" title=""></iframe>
@php
$authPing = 'https://'.config('app.auth_domain').'/sso/ping';
$platformSignedOutUrl = route('sso.platform-signed-out', ['redirect' => url()->current()]);
@endphp
<script>
(function () {
const pingUrl = @js($authPing);
const signedOutUrl = @js($platformSignedOutUrl);
const ping = () => fetch(pingUrl, { credentials: 'include' })
.then((response) => {
if (response.status === 401) {
window.location.href = signedOutUrl;
}
})
.catch(() => {});
ping();
setInterval(ping, 5 * 60 * 1000);
})();
</script>
@endif
@@ -0,0 +1,24 @@
{{-- Account switcher (desktop) only when the user belongs to more than one account. --}}
@if (isset($accessibleAccounts) && $accessibleAccounts->count() > 1)
<div x-data="{ accountSwitcherOpen: false }" @click.outside="accountSwitcherOpen = false" class="relative hidden lg:block">
<button type="button" @click="accountSwitcherOpen = !accountSwitcherOpen"
class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
<span class="max-w-[140px] 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="accountSwitcherOpen" x-cloak x-transition
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
@@ -0,0 +1,45 @@
{{--
Top-right header widgets:
Mobile Afia + launcher only (profile/notifications live in bottom nav).
Desktop Afia notifications launcher divider avatar dropdown.
--}}
@php
$topbarUser = $user ?? auth()->user();
$initials = collect(explode(' ', trim((string) $topbarUser?->name)))
->filter()->take(2)->map(fn ($p) => strtoupper(substr($p, 0, 1)))->implode('');
$avatarUrl = $topbarUser && method_exists($topbarUser, 'avatarUrl')
? $topbarUser->avatarUrl()
: ($topbarUser?->avatar_url ?? null);
$showUserHeader = $showUser ?? true;
@endphp
@includeIf('partials.afia-button', ['compact' => true])
@includeIf('partials.notification-dropdown')
@include('partials.launcher')
@includeIf('partials.topbar-widgets-mid')
<div class="hidden h-8 w-px bg-slate-200 lg:block"></div>
<div class="relative hidden lg:block" x-data="{ profileOpen: false }" @click.outside="profileOpen = false" @keydown.escape.window="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 transition hover:bg-slate-50">
@if ($avatarUrl)
<img src="{{ $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
class="absolute right-0 z-50 mt-2 w-64 rounded-xl border border-slate-200 bg-white shadow-lg">
@include('partials.user-profile-menu', [
'items' => \App\Support\UserProfileMenu::items($topbarUser),
'user' => $topbarUser,
'showUser' => $showUserHeader,
])
</div>
</div>
+7 -78
View File
@@ -5,88 +5,17 @@
<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>
@include('partials.mobile-topbar-title')
{{-- Search hosting accounts, domains, orders --}}
<div class="relative hidden w-full max-w-md lg:block" x-data="topbarSearch({ searchUrl: @js(route('servers.search')) })" @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.2-5.2m0 0A7.5 7.5 0 1 0 5.2 5.2a7.5 7.5 0 0 0 10.6 10.6Z"/></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 servers…"
class="w-full rounded-xl border border-slate-200 bg-slate-50 py-2 pl-9 pr-3 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-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 bg-teal-100 text-teal-600">
<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="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"/></svg>
</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-2.5">
<div class="flex items-center gap-3">
@auth
@include('partials.notification-dropdown')
<span class="hidden h-7 w-px bg-slate-200 lg:block"></span>
{{-- Account switcher (only when the user belongs to more than one account) --}}
@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
{{-- Profile / avatar menu (desktop; mobile uses bottom nav) --}}
<div class="relative hidden lg:block" x-data="{ open: false }">
<button type="button" @click="open = !open" class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 p-1 pr-2 text-slate-700 hover:bg-slate-50">
@if ($u?->avatar_url)
<img src="{{ $u->avatar_url }}" alt="" 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">{{ strtoupper(substr($u->name ?? $u->email, 0, 1)) }}</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="open" x-cloak @click.outside="open = 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="{{ $acct }}/profile" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Profile</a>
<a href="{{ $acct }}/account-settings" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Account Settings</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.afia-button', ['compact' => true])
@includeIf('partials.topbar-account-switcher')
@includeIf('partials.topbar-widgets-prepend')
@include('partials.topbar-desktop-widgets', ['user' => $user ?? $u ?? auth()->user(), 'showUser' => true])
@includeIf('partials.topbar-widgets-append')
@else
<a href="{{ route('login') }}" class="btn-primary">Sign in</a>
@include('partials.launcher')
@endauth
@include('partials.launcher')
</div>
</header>
@@ -0,0 +1,73 @@
@props([
'items' => [],
'user' => null,
'showUser' => false,
'variant' => 'dropdown',
'onNavigate' => null,
])
@php
$linkClass = match ($variant) {
'dark' => 'block rounded-md px-3 py-2 text-sm text-slate-200 hover:bg-slate-800',
'sheet' => 'block rounded-xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-100',
default => 'block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100',
};
$logoutClass = match ($variant) {
'dark' => 'w-full rounded-md px-3 py-2 text-left text-sm text-rose-300 hover:bg-rose-950/40',
'sheet' => 'w-full rounded-xl px-4 py-3 text-left text-sm font-medium text-rose-600 transition hover:bg-rose-50',
default => 'w-full rounded-lg px-3 py-2 text-left text-sm text-rose-600 hover:bg-rose-50',
};
$logoutWrapperClass = match ($variant) {
'sheet' => 'border-t border-slate-100 pt-2',
'dark' => '',
default => '',
};
$dividerClass = match ($variant) {
'dark' => 'my-1 border-t border-slate-800',
default => 'my-1 border-t border-slate-100',
};
$containerClass = match ($variant) {
'dark' => 'mt-2 rounded-lg border border-slate-800 bg-slate-900 p-1',
'sheet' => 'space-y-1 p-2 pb-4',
default => 'p-1',
};
@endphp
<div {{ $attributes->merge(['class' => $containerClass]) }}>
@if ($showUser && $user)
<div class="px-3 py-2">
<p class="truncate text-sm font-semibold text-slate-900">{{ $user->name ?? 'Your account' }}</p>
<p class="truncate text-xs text-slate-400">{{ $user->email }}</p>
</div>
<div class="{{ $dividerClass }}"></div>
@endif
@foreach ($items as $item)
@if (($item['type'] ?? 'link') === 'link')
<a href="{{ $item['href'] }}"
class="{{ $linkClass }}"
@if ($onNavigate) @click="{{ $onNavigate }}" @endif>
{{ $item['label'] }}
</a>
@elseif (($item['type'] ?? '') === 'wallet')
@includeIf('partials.wallet-widget', [
'onNavigate' => $onNavigate,
'class' => $variant === 'sheet' ? 'mx-2' : 'mx-1',
])
@elseif (($item['type'] ?? '') === 'logout')
@if ($variant !== 'sheet')
<div class="{{ $dividerClass }}"></div>
@endif
<form method="POST" action="{{ $item['action'] }}" class="{{ $logoutWrapperClass }}">
@csrf
<button type="submit" class="{{ $logoutClass }}">
{{ $item['label'] }}
</button>
</form>
@endif
@endforeach
</div>
@@ -0,0 +1,30 @@
{{-- Wallet balance peek (links to the account wallet on account.ladill.com). --}}
@php
$onNavigate = $onNavigate ?? null;
$class = trim((string) ($class ?? 'mx-1'));
$balanceRoute = (string) config('billing.wallet_balance_route', 'wallet.balance');
$balanceUrl = \Illuminate\Support\Facades\Route::has($balanceRoute) ? route($balanceRoute) : null;
$walletUrl = \Illuminate\Support\Facades\Route::has('user.wallet.index')
? route('user.wallet.index')
: (function_exists('ladill_account_url') ? ladill_account_url('/wallet') : '#');
@endphp
@if ($balanceUrl)
<a href="{{ $walletUrl }}"
x-data="walletWidget({ url: {{ \Illuminate\Support\Js::from($balanceUrl) }} })" x-init="load()"
@wallet-balance-refresh.window="load()"
@if ($onNavigate) @click="{{ $onNavigate }}" @endif
class="{{ $class }} flex items-center justify-between gap-3 rounded-xl border border-slate-100 bg-gradient-to-r from-indigo-50 to-slate-50 px-3 py-2.5 transition hover:border-indigo-200 hover:from-indigo-100/70">
<span class="flex items-center gap-2.5 min-w-0">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-indigo-100 text-indigo-600">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.6" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 1 3 7.5m18 4.5v3.75A2.25 2.25 0 0 1 18.75 18H5.25A2.25 2.25 0 0 1 3 15.75V7.5m18 4.5h-3.75a1.5 1.5 0 0 0 0 3H21M3 7.5A2.25 2.25 0 0 1 5.25 5.25h11.25A2.25 2.25 0 0 1 18.75 7.5"/>
</svg>
</span>
<span class="min-w-0">
<span class="block text-[11px] font-medium text-slate-500">Wallet balance</span>
<span class="block truncate text-sm font-semibold text-slate-900" x-text="display"></span>
</span>
</span>
<svg 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="m9 5 7 7-7 7"/></svg>
</a>
@endif
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Payment</title>
<style>
body { font-family: system-ui, sans-serif; background: #f8fafc; color: #0f172a; display: grid; place-items: center; min-height: 100vh; margin: 0; }
p { font-size: 0.95rem; color: #475569; }
</style>
</head>
<body>
<p>Confirming your payment…</p>
<script>
(function () {
var url = @json($redirect);
try {
if (window.opener && !window.opener.closed) {
window.opener.location.replace(url);
window.close();
return;
}
} catch (e) {}
try {
if (window.top && window.top !== window.self) {
window.top.location.replace(url);
return;
}
} catch (e) {}
window.location.replace(url);
})();
</script>
</body>
</html>
+16 -11
View File
@@ -2,18 +2,23 @@
@section('title', 'Overview — Ladill Servers')
@section('content')
<div class="mx-auto max-w-5xl">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm: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 VPS and dedicated servers at a glance.</p>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<h1 class="truncate text-lg font-semibold tracking-tight text-slate-900 lg:text-xl">Overview</h1>
<p class="mt-0.5 hidden text-sm text-slate-500 sm:block">Your VPS and dedicated servers at a glance.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ route('servers.vps', ['order' => 1]) }}" class="inline-flex items-center justify-center btn-primary">
Order VPS
</a>
<a href="{{ route('servers.dedicated', ['order' => 1]) }}" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
Order dedicated
</a>
<div class="flex shrink-0 items-center gap-2">
@include('partials.mobile-header-btn', [
'href' => route('servers.vps', ['order' => 1]),
'label' => 'Order VPS',
'variant' => 'primary',
])
@include('partials.mobile-header-btn', [
'href' => route('servers.dedicated', ['order' => 1]),
'label' => 'Order dedicated',
'variant' => 'outline',
'showDesktopIcon' => false,
])
</div>
</div>
+13 -13
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\WalletBalanceController;
use App\Http\Controllers\Hosting\AccountController;
use App\Http\Controllers\Hosting\AfiaController;
use App\Http\Controllers\Hosting\DeveloperController;
@@ -12,27 +13,31 @@ use App\Http\Controllers\Hosting\TeamController;
use App\Http\Controllers\NotificationController;
use Illuminate\Support\Facades\Route;
// Ladill Servers — VPS & dedicated servers (servers.ladill.com).
Route::get('/', fn () => auth()->check()
? redirect()->route('servers.dashboard')
: redirect()->route('sso.connect'))->name('servers.root');
// Ladill Servers — VPS & dedicated servers (servers.ladill.com).
Route::get('/login', [SsoLoginController::class, 'connect'])->name('login');
Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect');
Route::get('/sso/callback', [SsoLoginController::class, 'callback'])->name('sso.callback');
Route::post('/logout', [SsoLoginController::class, 'logout'])->name('logout');
Route::get('/sso/logout-bridge', [SsoLoginController::class, 'logoutBridge'])->name('sso.logout-bridge');
Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannelLogout'])->name('sso.logout-frontchannel');
Route::get('/sso/platform-signed-out', [SsoLoginController::class, 'platformSignedOut'])->name('sso.platform-signed-out');
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('servers.dashboard') : view('servers.signed-out'))->name('servers.signed-out');
Route::middleware(['auth'])->group(function () {
Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance');
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread');
Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read');
Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read');
Route::get('/search', SearchController::class)->name('servers.search');
Route::get('/domains/owned', \App\Http\Controllers\OwnedDomainsController::class)->name('servers.domains.owned');
Route::get('/dashboard', [OverviewController::class, 'index'])->name('servers.dashboard');
Route::get('/vps', [HostingProductController::class, 'vps'])->name('servers.vps');
@@ -65,20 +70,15 @@ Route::middleware(['auth'])->group(function () {
Route::post('/tasks/log', [ServerPanelController::class, 'queueLog'])->name('hosting.server-panel.tasks.log');
});
Route::get('/account/wallet', [AccountController::class, 'wallet'])->name('account.wallet');
Route::get('/account/billing', [AccountController::class, 'billing'])->name('account.billing');
// Wallet, Billing, Team and Developers now live on the central hub
// (account.ladill.com). App-specific Settings + the acting-account switcher
// remain here.
Route::get('/account/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('account.wallet');
Route::get('/account/billing', fn () => redirect()->away(ladill_account_url('/billing')))->name('account.billing');
Route::get('/account/settings', [AccountController::class, 'settings'])->name('account.settings');
Route::put('/account/settings', [AccountController::class, 'updateSettings'])->name('account.settings.update');
Route::get('/account/team', [TeamController::class, 'index'])->name('account.team');
Route::post('/account/team', [TeamController::class, 'store'])->name('account.team.store');
Route::patch('/account/team/{member}/role', [TeamController::class, 'updateRole'])->name('account.team.role');
Route::delete('/account/team/{member}', [TeamController::class, 'destroy'])->name('account.team.destroy');
Route::post('/account/switch', [TeamController::class, 'switchAccount'])->name('account.switch');
Route::get('/account/developers', [DeveloperController::class, 'index'])->name('account.developers');
Route::post('/account/developers', [DeveloperController::class, 'store'])->name('account.developers.store');
Route::delete('/account/developers/{token}', [DeveloperController::class, 'destroy'])->whereNumber('token')->name('account.developers.destroy');
Route::post('/afia/chat', [AfiaController::class, 'chat'])->name('servers.afia.chat');
});
@@ -0,0 +1,81 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
class ResponsivePaystackSheetTest extends TestCase
{
public function test_checkout_sheet_partial_has_mobile_sheet_and_desktop_modal(): void
{
$html = view('partials.paystack-sheet', [
'audience' => 'buyer',
])->render();
$this->assertStringContainsString('role="dialog"', $html);
$this->assertStringContainsString('aria-modal="true"', $html);
$this->assertStringContainsString('data-ladill-pay-sheet', $html);
$this->assertStringContainsString('data-ladill-pay-panel', $html);
$this->assertStringContainsString('data-ladill-pay-backdrop', $html);
$this->assertStringContainsString('data-ladill-pay-handle', $html);
$this->assertStringContainsString('md:items-center', $html);
$this->assertStringContainsString('rounded-t-2xl', $html);
$this->assertStringContainsString('md:rounded-2xl', $html);
$this->assertStringContainsString('md:max-w-lg', $html);
$this->assertStringContainsString('data-paystack-live', $html);
$this->assertStringContainsString('data-ladill-pay-close-live', $html);
$this->assertStringContainsString('data-ladill-pay-header-minimal', $html);
$this->assertStringContainsString('Complete your payment', $html);
$this->assertStringContainsString('safe-area-inset-bottom', $html);
$this->assertStringContainsString('LadillPayCheckout', $html);
$this->assertStringContainsString('resumeTransaction', $html);
$this->assertStringContainsString("\$watch('showSheet'", $html);
$this->assertStringContainsString('js.paystack.co/v2/inline.js', $html);
$this->assertStringContainsString('items-end justify-center md:items-center', $html);
$this->assertStringContainsString('rounded-t-2xl bg-white shadow-2xl md:max-w-lg md:rounded-2xl', $html);
$this->assertStringContainsString('ensurePaystackIframePermissions', $html);
$this->assertStringContainsString('installIframeAllowHook', $html);
$this->assertStringContainsString('fullscreen *', $html);
$this->assertStringNotContainsString("attributeFilter: ['allow', 'src', 'id', 'name', 'style', 'class']", $html);
$this->assertStringContainsString('childList only', $html);
$this->assertStringContainsString('Only write attributes when they actually change', $html);
$this->assertStringContainsString('Do not watch iframes or preload Paystack on every page', $html);
$this->assertStringContainsString('data-ladill-pay-mount', $html);
$this->assertStringContainsString('data-ladill-pay-body', $html);
$this->assertStringNotContainsString('styleCheckoutPinnedToRect', $html);
$this->assertStringNotContainsString('waitForPinTarget', $html);
$this->assertStringNotContainsString('placeCheckoutInMount', $html);
$this->assertStringNotContainsString('installAppendHook', $html);
$this->assertStringNotContainsString('__ladillContainPaystack', $html);
$this->assertStringNotContainsString('Node.prototype.appendChild', $html);
$this->assertStringNotContainsString('data-ladill-pay-inline-status', $html);
$this->assertStringNotContainsString('Please wait', $html);
$this->assertStringContainsString('Payment UI did not open', $html);
$this->assertStringContainsString('ladill-pay-opened', $html);
$this->assertStringContainsString('Only lock page scroll when OUR shell is visible', $html);
$this->assertStringContainsString('releaseBusy', $html);
$this->assertStringContainsString('markOpened', $html);
$this->assertStringNotContainsString('Continue to Paystack', $html);
$this->assertStringNotContainsString('window.open(', $html);
$this->assertStringNotContainsString('hidden w-full max-w-lg', $html);
$this->assertStringNotContainsString('Paystack checkout', $html);
$this->assertStringNotContainsString('bg-transparent shadow-none', $html);
$this->assertStringNotContainsString('not in a separate browser', $html);
}
public function test_payment_return_escapes_popup_and_iframe_to_opener_or_top(): void
{
$html = view('public.payment-return', [
'redirect' => 'https://example.test/paid',
])->render();
$this->assertStringContainsString('Confirming your payment', $html);
$this->assertStringContainsString('window.opener', $html);
$this->assertStringContainsString('window.top.location.replace', $html);
$this->assertTrue(
str_contains($html, 'https://example.test/paid')
|| str_contains($html, 'https:\/\/example.test\/paid')
);
}
}