Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff03cbd906 | ||
|
|
e1ba05edae | ||
|
|
e5e125ec23 | ||
|
|
8d2d4bd2f0 | ||
|
|
8b1830e83a | ||
|
|
799a52f556 | ||
|
|
ca8931cf0f | ||
|
|
b4b5717906 | ||
|
|
4531ab8066 | ||
|
|
dd5031aa0b | ||
|
|
0b3dbc4832 | ||
|
|
bd8d12aad4 | ||
|
|
c4025f9954 | ||
|
|
749a9990f9 | ||
|
|
5a08da1051 | ||
|
|
723da7b2f0 | ||
|
|
5913cd8e8b | ||
|
|
72dfd41b78 | ||
|
|
b6defa5e47 | ||
|
|
55dff218e2 | ||
|
|
e4bf942eec | ||
|
|
4b9320f4db | ||
|
|
510f0a1af9 | ||
|
|
6d08d97699 | ||
|
|
20cdf7d8f1 | ||
|
|
5685990a71 | ||
|
|
0defeaf207 | ||
|
|
b835bb95ca | ||
|
|
0e6b0013a3 | ||
|
|
d7e6c29335 | ||
|
|
a1e00ed91e | ||
|
|
0c917bff5c | ||
|
|
2a37f5dde5 | ||
|
|
76545d8b10 | ||
|
|
5d5ecc3ee0 | ||
|
|
5fd04dc90a | ||
|
|
f13aa7f884 | ||
|
|
c1ee58fb03 | ||
|
|
affeb8c22b | ||
|
|
65d222aa63 | ||
|
|
0a9b0168c7 | ||
|
|
e1a2ceff0a | ||
|
|
952ef4f1a4 | ||
|
|
14deb710d8 | ||
|
|
8bc717a2a8 | ||
|
|
317cab4138 | ||
|
|
b0cc71e541 | ||
|
|
6ba9d7ba2d | ||
|
|
dac9fd37fc | ||
|
|
c7a5a87a2e | ||
|
|
fa38d1e06c | ||
|
|
2094eafd98 | ||
|
|
191a7c8b99 | ||
|
|
f2c287adef | ||
|
|
2115747588 | ||
|
|
942d996f12 | ||
|
|
4a297d50c9 | ||
|
|
c48cb46e28 | ||
|
|
a30a0f659f | ||
|
|
893af15541 | ||
|
|
87651fb831 | ||
|
|
09f63d2ca2 | ||
|
|
0188f715a8 | ||
|
|
2e564c2067 | ||
|
|
9cccc9e441 |
@@ -28,8 +28,14 @@ DB_DATABASE=ladill_servers
|
|||||||
DB_USERNAME=ladill_servers
|
DB_USERNAME=ladill_servers
|
||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
REDIS_CLIENT=phpredis
|
||||||
SESSION_LIFETIME=120
|
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_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=.ladill.com
|
SESSION_DOMAIN=.ladill.com
|
||||||
@@ -37,7 +43,7 @@ SESSION_DOMAIN=.ladill.com
|
|||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
CACHE_STORE=database
|
CACHE_STORE=redis
|
||||||
|
|
||||||
LADILL_SSO_CLIENT_ID=
|
LADILL_SSO_CLIENT_ID=
|
||||||
LADILL_SSO_CLIENT_SECRET=
|
LADILL_SSO_CLIENT_SECRET=
|
||||||
@@ -48,6 +54,10 @@ BILLING_API_KEY_SERVERS=
|
|||||||
IDENTITY_API_URL=https://ladill.com/api
|
IDENTITY_API_URL=https://ladill.com/api
|
||||||
IDENTITY_API_KEY_SERVERS=
|
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_URL=https://ladill.com/api/domains
|
||||||
DOMAIN_API_KEY_SERVERS=
|
DOMAIN_API_KEY_SERVERS=
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_ROOT: /tmp/ladill-node-22-r1
|
NODE_ROOT: /tmp/ladill-node-22-r1
|
||||||
NODE_VERSION: "22.14.0"
|
NODE_VERSION: "22.14.0"
|
||||||
RELEASE_ARCHIVE: /tmp/ladill-servers-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
# act_runner v0.2.x does not expose gitea.run_attempt — use run_id only.
|
||||||
WORKSPACE: /tmp/${{ gitea.repository_owner }}-servers-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
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
|
LADILL_APP_ROOT: /var/www/ladill-servers
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -38,11 +39,17 @@ jobs:
|
|||||||
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
|
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
|
||||||
"$REPO_URL" "$WORKSPACE"
|
"$REPO_URL" "$WORKSPACE"
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Build frontend assets
|
||||||
shell: bash {0}
|
shell: bash {0}
|
||||||
run: |
|
run: |
|
||||||
set -Eeuo pipefail
|
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"
|
rm -rf "$NODE_ROOT"
|
||||||
mkdir -p "$NODE_ROOT"
|
mkdir -p "$NODE_ROOT"
|
||||||
case "$(uname -m)" in
|
case "$(uname -m)" in
|
||||||
@@ -52,17 +59,12 @@ jobs:
|
|||||||
esac
|
esac
|
||||||
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
|
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"
|
| tar -xJ --strip-components=1 -C "$NODE_ROOT"
|
||||||
fi
|
|
||||||
"$NODE_ROOT/bin/node" -v
|
|
||||||
|
|
||||||
- name: Build frontend assets
|
|
||||||
shell: bash {0}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
cd "$WORKSPACE"
|
|
||||||
export PATH="$NODE_ROOT/bin:$PATH"
|
export PATH="$NODE_ROOT/bin:$PATH"
|
||||||
npm ci --no-audit --no-fund
|
NPM_BIN="$NODE_ROOT/bin/npm"
|
||||||
npm run build
|
fi
|
||||||
|
"$NPM_BIN" ci --no-audit --no-fund
|
||||||
|
"$NPM_BIN" run build
|
||||||
|
|
||||||
|
|
||||||
- name: Build release archive
|
- name: Build release archive
|
||||||
shell: bash {0}
|
shell: bash {0}
|
||||||
@@ -78,7 +80,7 @@ jobs:
|
|||||||
- name: Deploy release
|
- name: Deploy release
|
||||||
shell: bash {0}
|
shell: bash {0}
|
||||||
env:
|
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: |
|
run: |
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
|
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
|
||||||
|
|||||||
@@ -77,10 +77,7 @@ class SsoLoginController extends Controller
|
|||||||
if ($request->filled('error')) {
|
if ($request->filled('error')) {
|
||||||
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
|
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
|
||||||
&& ! $request->boolean('interactive')) {
|
&& ! $request->boolean('interactive')) {
|
||||||
return redirect()->route('sso.connect', [
|
return redirect()->away((string) config('ladill.marketing_url'));
|
||||||
'redirect' => $intended,
|
|
||||||
'interactive' => 1,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
|
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());
|
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
|
public function logoutBridge(Request $request): View
|
||||||
{
|
{
|
||||||
$return = $this->safeReturnUrl((string) $request->query('return', ''));
|
$return = $this->safeReturnUrl((string) $request->query('return', ''));
|
||||||
|
|||||||
@@ -1116,6 +1116,18 @@ class HostingPanelController extends Controller
|
|||||||
$onboardingMode = $isOwnedDomain
|
$onboardingMode = $isOwnedDomain
|
||||||
? Domain::MODE_NS_AUTO
|
? Domain::MODE_NS_AUTO
|
||||||
: ($validated['onboarding_mode'] ?? 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';
|
$serverIp = $account->node?->ip_address ?? '161.97.138.149';
|
||||||
$docRoot = ! empty($validated['document_root'])
|
$docRoot = ! empty($validated['document_root'])
|
||||||
? "/home/{$account->username}/" . ltrim($validated['document_root'], '/')
|
? "/home/{$account->username}/" . ltrim($validated['document_root'], '/')
|
||||||
@@ -1297,35 +1309,13 @@ class HostingPanelController extends Controller
|
|||||||
->filter()
|
->filter()
|
||||||
->all();
|
->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)
|
return collect($owned)
|
||||||
->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))
|
|
||||||
->reject(fn ($d) => in_array($d, $linkedDomains, true))
|
->reject(fn ($d) => in_array($d, $linkedDomains, true))
|
||||||
->unique()
|
|
||||||
->sort()
|
->sort()
|
||||||
->values();
|
->values();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,14 @@ class HostingProductController extends Controller
|
|||||||
CustomerHostingOrder::CYCLE_YEARLY => 'Yearly (12 months)',
|
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', [
|
return view('hosting.server-type', [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
@@ -182,6 +190,7 @@ class HostingProductController extends Controller
|
|||||||
'serverOrderPayloads' => $serverOrderPayloads,
|
'serverOrderPayloads' => $serverOrderPayloads,
|
||||||
'billingCycles' => $billingCycles,
|
'billingCycles' => $billingCycles,
|
||||||
'userDomains' => $userDomains,
|
'userDomains' => $userDomains,
|
||||||
|
'heroStats' => $heroStats,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,17 +641,9 @@ class HostingProductController extends Controller
|
|||||||
return $product->catalogSummary();
|
return $product->catalogSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getUserDomains($user): \Illuminate\Support\Collection
|
private function getUserDomains($user): array
|
||||||
{
|
{
|
||||||
return \App\Models\Domain::query()
|
return app(\App\Services\Domains\LadillDomainsClient::class)
|
||||||
->where('user_id', $user->id)
|
->ownedForUser((string) $user->public_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']);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,16 @@ class ProvisionHostingSslJob implements ShouldQueue
|
|||||||
|
|
||||||
$site->update(['ssl_status' => 'provisioning']);
|
$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 {
|
try {
|
||||||
$provider->requestLetsEncryptCertificate($site);
|
$provider->requestLetsEncryptCertificate($site);
|
||||||
|
|
||||||
@@ -70,6 +80,9 @@ class ProvisionHostingSslJob implements ShouldQueue
|
|||||||
'site_id' => $this->siteId,
|
'site_id' => $this->siteId,
|
||||||
'domain' => $site->domain,
|
'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) {
|
} catch (\Throwable $exception) {
|
||||||
$message = $exception->getMessage();
|
$message = $exception->getMessage();
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class HostingResourceWarningNotification extends Notification implements ShouldQ
|
|||||||
|
|
||||||
public function via($notifiable): array
|
public function via($notifiable): array
|
||||||
{
|
{
|
||||||
return ['mail', 'database'];
|
return ['database'];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toMail($notifiable): MailMessage
|
public function toMail($notifiable): MailMessage
|
||||||
|
|||||||
@@ -90,31 +90,28 @@ class AfiaService
|
|||||||
{
|
{
|
||||||
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
|
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
|
||||||
|
|
||||||
return match ((string) config('afia.product', 'hosting')) {
|
return $this->serversSystemPrompt($ctx);
|
||||||
'email' => $this->emailSystemPrompt($ctx),
|
|
||||||
default => $this->hostingSystemPrompt($ctx),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function hostingSystemPrompt(string $ctx): string
|
private function serversSystemPrompt(string $ctx): string
|
||||||
{
|
{
|
||||||
return <<<PROMPT
|
return <<<PROMPT
|
||||||
You are Afia, the assistant inside Ladill Hosting — Ladill's shared web hosting product (hosting.ladill.com).
|
You are Afia, the assistant inside Ladill Servers — Ladill's VPS and dedicated server product (servers.ladill.com).
|
||||||
Help the user buy hosting, manage accounts, link domains, use the control panel, and understand billing. Be concise,
|
Help the user choose and order VPS or dedicated servers, access them over SSH, use the server panel, and understand
|
||||||
friendly, and actionable: give short step-by-step answers and point to the relevant section by name.
|
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:
|
What Ladill Servers does and where things live:
|
||||||
- Dashboard: overview of all hosting accounts, pending orders, and linked domains.
|
- Dashboard: overview of your servers, pending orders, and status.
|
||||||
- Product pages: Single Domain, Multi Domain, and WordPress hosting — order new plans here.
|
- VPS: browse and order virtual private servers — pick a plan (CPU, RAM, storage), region, and OS, then deploy.
|
||||||
- Account overview: per-account summary (plan, expiry, linked domains, email usage) with a Control Panel button.
|
- Dedicated: browse and order dedicated (bare-metal) servers for heavier or isolated workloads.
|
||||||
- Control Panel: file manager, domains, databases, SSL, PHP settings, cron, terminal, and one-click apps.
|
- Accessing a server: connect over SSH using the credentials/key shown on the server's page.
|
||||||
- Domains: link a Ladill domain or an external domain to a hosting account; DNS may need updating for external domains.
|
- Server panel: manage a server — power (start/stop/reboot), reinstall OS, console, and networking.
|
||||||
- Billing: hosting renewals and upgrades are paid from the Ladill wallet (account portal → Wallet).
|
- Billing: server orders and renewals are paid from the Ladill wallet (account portal → Wallet).
|
||||||
- Team: invite teammates to co-manage hosting (sidebar → Team).
|
- 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,
|
Rules: Only answer questions about Ladill Servers — VPS and dedicated plans, ordering, SSH access, the server panel,
|
||||||
SSL, file manager, renewals, and wallet billing. If asked about Ladill Email, domains registration, or VPS/servers,
|
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, DNS values, or prices — tell the user where to find them.
|
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:
|
Current user context:
|
||||||
{$ctx}
|
{$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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')) {
|
if (! function_exists('ladill_account_url')) {
|
||||||
function ladill_account_url(string $path = ''): string
|
function ladill_account_url(string $path = ''): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'redirect' => $request->fullUrl(),
|
'redirect' => $request->fullUrl(),
|
||||||
]));
|
]));
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
|
\App\Http\Middleware\InjectBootSplash::class,
|
||||||
\App\Http\Middleware\SetActingAccount::class,
|
\App\Http\Middleware\SetActingAccount::class,
|
||||||
]);
|
]);
|
||||||
|
$middleware->alias([
|
||||||
|
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
|
||||||
|
]);
|
||||||
|
|
||||||
// External registrar webhook posts have no CSRF token.
|
// External registrar webhook posts have no CSRF token.
|
||||||
$middleware->validateCsrfTokens(except: [
|
$middleware->validateCsrfTokens(except: [
|
||||||
|
|||||||
@@ -11,4 +11,6 @@ return [
|
|||||||
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
|
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
|
||||||
'api_key' => env('BILLING_API_KEY_SERVERS'),
|
'api_key' => env('BILLING_API_KEY_SERVERS'),
|
||||||
'service' => 'servers',
|
'service' => 'servers',
|
||||||
|
'wallet_balance_route' => 'wallet.balance',
|
||||||
|
'currency' => 'GHS',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
];
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'product_slug' => 'servers',
|
||||||
|
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/servers'),
|
||||||
|
];
|
||||||
@@ -2,39 +2,45 @@
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| 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
|
| DO NOT EDIT BY HAND. This file is generated from the app registry in the
|
||||||
| launcher in a new app, copy these three things verbatim into that repo:
|
| monolith (config/ladill_apps.php). To change the launcher, edit that
|
||||||
| - config/ladill_launcher.php (this file)
|
| registry and run: php artisan ladill:launcher:sync --propagate
|
||||||
| - resources/views/partials/launcher.blade.php
|
|
||||||
| - public/images/launcher-icons/* (the icon set)
|
|
||||||
|
|
|
|
||||||
| When a service becomes FULLY extracted: add one row below AND drop its icon in
|
| URLs are absolute (built from the platform root) so this file is
|
||||||
| public/images/launcher-icons/ — in EVERY repo. That's the only edit needed.
|
| byte-identical in every repo; the blade omits whichever row matches the
|
||||||
| Do NOT list a service here until it is fully extracted to its own subdomain.
|
| app's own 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 app; the blade omits whichever row matches the app's APP_URL host.
|
|
||||||
| Icons are served from /images/launcher-icons/<icon>.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$root = config('app.platform_domain', 'ladill.com');
|
$root = config('app.platform_domain', 'ladill.com');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'apps' => [
|
'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' => 'Stock', 'url' => 'https://stock.'.$root.'/sso/connect?redirect='.urlencode('https://stock.'.$root.'/dashboard'), 'icon' => 'stock.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' => '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' => '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' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'],
|
||||||
['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'],
|
['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'],
|
||||||
['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.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'],
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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),
|
'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 |
@@ -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 |
@@ -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 |
@@ -3,18 +3,23 @@
|
|||||||
<defs>
|
<defs>
|
||||||
<style>
|
<style>
|
||||||
.cls-1 {
|
.cls-1 {
|
||||||
fill: #f6398a;
|
fill: #007e7d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cls-1, .cls-2 {
|
.cls-1, .cls-2, .cls-3 {
|
||||||
stroke-width: 0px;
|
stroke-width: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cls-2 {
|
.cls-2 {
|
||||||
fill: #007e7d;
|
fill: #7b5c84;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #f6398a;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</defs>
|
</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-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-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-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>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 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: #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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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: #00997d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2, .cls-3, .cls-4 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #ffa34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #0776be;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #5051df;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-4" d="M340.5,213.3l-7.7-7.7c-10.4-7.1-23-11-36.2-11h-157.5l110.3,110.3c25.2,25.2,66.4,25.2,91.6,0,25.2-25.2,25.2-66.4,0-91.6h-.5Z"/>
|
||||||
|
<path class="cls-1" d="M18.9,305l4.9,4.9c11,8.8,24.7,13.7,40.1,13.7h156.9l-110.8-110.3c-25.2-24.7-65.9-24.7-91.1,0-25.2,24.7-25.2,66.4,0,91.6Z"/>
|
||||||
|
<path class="cls-3" d="M340.5,54.5l-7.7-7.7c-10.4-7.1-23-11-36.2-11h-157.5l110.3,110.3c25.2,25.2,66.4,25.2,91.6,0,25.2-25.2,25.2-66.4,0-91.6h-.5Z"/>
|
||||||
|
<path class="cls-2" d="M18.9,146.2l4.9,4.9c11,8.8,24.7,13.7,40.1,13.7h156.9L110,54.6c-25.2-24.7-65.9-24.7-91.1,0-25.2,24.7-25.2,66.4,0,91.6Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 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 |
@@ -68,3 +68,76 @@
|
|||||||
filter: brightness(0.92);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import './bootstrap';
|
import './bootstrap';
|
||||||
|
|
||||||
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
|
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
|
||||||
|
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
|
||||||
|
import { registerLadillDomainPurchase } from './ladill-domain-purchase';
|
||||||
|
|
||||||
registerLadillModalHelpers();
|
registerLadillModalHelpers();
|
||||||
|
registerLadillSearchShortcut();
|
||||||
|
registerLadillDomainPurchase();
|
||||||
|
|
||||||
|
|
||||||
import Alpine from 'alpinejs';
|
import Alpine from 'alpinejs';
|
||||||
|
import { registerLadillClipboard } from './ladill-clipboard';
|
||||||
import collapse from '@alpinejs/collapse';
|
import collapse from '@alpinejs/collapse';
|
||||||
|
|
||||||
Alpine.plugin(collapse);
|
Alpine.plugin(collapse);
|
||||||
|
registerLadillClipboard(Alpine);
|
||||||
|
|
||||||
Alpine.data('notificationDropdown', (config = {}) => ({
|
Alpine.data('notificationDropdown', (config = {}) => ({
|
||||||
open: false,
|
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;
|
window.Alpine = Alpine;
|
||||||
registerLadillConfirmStore(Alpine);
|
registerLadillConfirmStore(Alpine);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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([
|
@props([
|
||||||
'href' => null,
|
'href' => null,
|
||||||
'type' => 'button',
|
'type' => 'button',
|
||||||
|
'label' => null,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$tag = $href ? 'a' : 'button';
|
$tag = $href ? 'a' : 'button';
|
||||||
|
$ariaLabel = $label ?? trim(preg_replace('/\s+/', ' ', strip_tags((string) $slot)));
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<{{ $tag }}
|
<{{ $tag }}
|
||||||
@if ($href) href="{{ $href }}" @endif
|
@if ($href) href="{{ $href }}" @endif
|
||||||
@if ($tag === 'button') type="{{ $type }}" @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 }}
|
{{ $slot }}
|
||||||
</{{ $tag }}>
|
</{{ $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
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
@props([
|
@props([
|
||||||
'name',
|
'name',
|
||||||
'show' => false,
|
'show' => false,
|
||||||
'maxWidth' => '2xl'
|
'maxWidth' => '2xl',
|
||||||
|
'zIndex' => 'z-50',
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
@@ -52,7 +53,7 @@ $maxWidth = [
|
|||||||
x-transition:leave="transition-opacity ease-in duration-150"
|
x-transition:leave="transition-opacity ease-in duration-150"
|
||||||
x-transition:leave-start="opacity-100"
|
x-transition:leave-start="opacity-100"
|
||||||
x-transition:leave-end="opacity-0"
|
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);"
|
style="background: rgba(0,0,0,0.45); backdrop-filter: blur(3px);"
|
||||||
>
|
>
|
||||||
{{-- Backdrop click to close --}}
|
{{-- 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
|
@auth
|
||||||
@include('partials.afia')
|
@include('partials.afia')
|
||||||
@endauth
|
@endauth
|
||||||
|
@include('partials.sso-keepalive')
|
||||||
|
@include('partials.confirm-prompt')
|
||||||
|
@include('components.domain-purchase-modal')
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -36,9 +36,9 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@php
|
@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', ''));
|
$oldDomain = trim((string) old('domain', ''));
|
||||||
$initialDomainSource = $ownedDomains->isEmpty()
|
$initialDomainSource = empty($ownedDomainHosts)
|
||||||
? 'external'
|
? 'external'
|
||||||
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
|
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
|
||||||
$selectedOwned = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
$selectedOwned = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
||||||
@@ -54,10 +54,6 @@
|
|||||||
showSheet: false,
|
showSheet: false,
|
||||||
checkoutUrl: '',
|
checkoutUrl: '',
|
||||||
renewLoading: false,
|
renewLoading: false,
|
||||||
domainSource: @js($initialDomainSource),
|
|
||||||
selectedOwnedDomain: @js($selectedOwned),
|
|
||||||
externalDomain: @js($externalFallback),
|
|
||||||
onboardingMode: '{{ old('onboarding_mode', 'ns_auto') }}',
|
|
||||||
async submitRenewal() {
|
async submitRenewal() {
|
||||||
this.renewLoading = true;
|
this.renewLoading = true;
|
||||||
try {
|
try {
|
||||||
@@ -69,13 +65,8 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok || data.error) { alert(data.error || 'Unable to start payment.'); this.renewLoading = false; return; }
|
if (!res.ok || data.error) { alert(data.error || 'Unable to start payment.'); this.renewLoading = false; return; }
|
||||||
this.renewModal = false;
|
this.renewModal = false;
|
||||||
if (window.innerWidth < 768) {
|
|
||||||
this.checkoutUrl = data.checkout_url;
|
this.checkoutUrl = data.checkout_url;
|
||||||
this.showSheet = true;
|
this.showSheet = true; // renewLoading cleared when payment UI opens (ladill-pay-opened)
|
||||||
this.renewLoading = false;
|
|
||||||
} else {
|
|
||||||
window.location.href = data.checkout_url;
|
|
||||||
}
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Network error. Please try again.');
|
alert('Network error. Please try again.');
|
||||||
this.renewLoading = false;
|
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"
|
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>
|
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
|
@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">
|
<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>
|
<h3 class="text-base font-semibold text-slate-900">Connect a Domain</h3>
|
||||||
@@ -483,83 +481,15 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 space-y-5">
|
<div class="p-6 space-y-5">
|
||||||
{{-- Domain Source Toggle --}}
|
<x-domain-source-fields
|
||||||
<div>
|
:owned-domains="$ownedDomainHosts"
|
||||||
<div class="mb-2 flex items-center justify-between">
|
owned-url="{{ route('servers.domains.owned') }}"
|
||||||
<label class="text-sm font-medium text-slate-700">Domain</label>
|
show-connection-method
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3 pt-1">
|
<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 @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"
|
<button type="submit" class="btn-primary">Add Domain</button>
|
||||||
class="btn-primary">
|
|
||||||
Add Domain
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$ownedDomainHosts = $ownedDomains->map(fn ($d) => (string) $d)->all();
|
$ownedDomainHosts = collect($ownedDomains)->map(fn ($d) => (string) $d)->all();
|
||||||
$oldDomain = trim((string) old('domain', ''));
|
$oldDomain = trim((string) old('domain', ''));
|
||||||
$initialDomainSource = $ownedDomains->isEmpty()
|
$initialDomainSource = empty($ownedDomainHosts)
|
||||||
? 'external'
|
? 'external'
|
||||||
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
|
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
|
||||||
$selectedOwnedDomain = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
$selectedOwnedDomain = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
||||||
@@ -28,113 +28,21 @@
|
|||||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
<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>
|
<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"
|
<form action="{{ route('hosting.panel.domains.add', $account) }}" method="POST" class="space-y-5"
|
||||||
x-data="{
|
x-data="ladillDomainSourceForm({
|
||||||
domainSource: @js($initialDomainSource),
|
domainSource: @js($initialDomainSource),
|
||||||
selectedOwnedDomain: @js($selectedOwnedDomain),
|
selectedOwnedDomain: @js($selectedOwnedDomain),
|
||||||
externalDomain: @js($externalFallbackDomain),
|
externalDomain: @js($externalFallbackDomain),
|
||||||
onboardingMode: '{{ old('onboarding_mode', 'ns_auto') }}'
|
ownedDomains: @js($ownedDomainHosts),
|
||||||
}">
|
ownedUrl: @js(route('servers.domains.owned', ['exclude' => $account->sites->pluck('domain')->filter()->all()])),
|
||||||
|
onboardingMode: @js(old('onboarding_mode', 'ns_auto')),
|
||||||
|
})">
|
||||||
@csrf
|
@csrf
|
||||||
<input type="hidden" name="is_owned_domain" :value="domainSource === 'ladill' ? '1' : '0'">
|
<x-domain-source-fields
|
||||||
|
:owned-domains="$ownedDomainHosts"
|
||||||
{{-- Domain Source Toggle --}}
|
owned-url="{{ route('servers.domains.owned') }}"
|
||||||
<div>
|
show-connection-method
|
||||||
<div class="mb-2 flex items-center justify-between">
|
show-document-root
|
||||||
<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>
|
|
||||||
|
|
||||||
<button type="submit" class="btn-primary">
|
<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>
|
<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>
|
<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">
|
<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
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</x-slot:trigger>
|
</x-slot:trigger>
|
||||||
@@ -209,21 +116,14 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@php
|
@php $serverIp = $account->node?->ip_address ?? '161.97.138.149'; @endphp
|
||||||
$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">
|
<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>
|
<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>
|
<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="grid gap-4 md:grid-cols-2">
|
||||||
<div class="rounded-lg border border-emerald-200 bg-emerald-50/50 p-4">
|
<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 mb-2">Nameservers</h4>
|
||||||
<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>
|
|
||||||
<div class="space-y-1.5">
|
<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">
|
<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>
|
<span class="text-sm font-mono text-slate-900">ns1.ladill.com</span>
|
||||||
@@ -235,27 +135,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-slate-200 bg-slate-50/50 p-4">
|
<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>
|
<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="space-y-1 rounded bg-white border border-slate-200 p-3 text-xs font-mono">
|
||||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
<div>A @ {{ $serverIp }}</div>
|
||||||
<span class="font-medium text-slate-500">Type</span>
|
<div>A www {{ $serverIp }}</div>
|
||||||
<span class="font-medium text-slate-500">Name</span>
|
|
||||||
<span class="font-medium text-slate-500">Value</span>
|
|
||||||
</div>
|
</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>
|
|
||||||
<p class="mt-2 text-xs text-slate-500">DNS changes can take up to 24–48 hours.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,50 +35,23 @@
|
|||||||
|
|
||||||
@if ($nativeForm['requires_domain'])
|
@if ($nativeForm['requires_domain'])
|
||||||
@php
|
@php
|
||||||
$newDomainUrl = ladill_domains_url('/find');
|
$ownedDomainHosts = $userDomains;
|
||||||
|
$selectedOwned = in_array($oldDomainName, $ownedDomainHosts, true) ? $oldDomainName : '';
|
||||||
|
$externalFallback = $initialDomainSource === 'external' ? $oldDomainName : '';
|
||||||
@endphp
|
@endphp
|
||||||
<div>
|
<div x-data="ladillDomainSourceForm({
|
||||||
<div class="mb-2 flex items-center justify-between">
|
domainSource: @js($initialDomainSource),
|
||||||
<label class="text-sm font-medium text-gray-700">Domain</label>
|
selectedOwnedDomain: @js($selectedOwned),
|
||||||
<div class="inline-flex items-center rounded-md bg-gray-100 p-0.5 text-[11px] font-medium">
|
externalDomain: @js($externalFallback),
|
||||||
<button type="button" @click="domainSource = 'ladill'"
|
ownedDomains: @js($ownedDomainHosts),
|
||||||
:class="domainSource === 'ladill' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
|
ownedUrl: @js(route('servers.domains.owned')),
|
||||||
class="rounded px-2 py-1 transition">Ladill</button>
|
})">
|
||||||
<button type="button" @click="domainSource = 'external'"
|
<x-domain-source-fields
|
||||||
:class="domainSource === 'external' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
|
field-name="domain_name"
|
||||||
class="rounded px-2 py-1 transition">External</button>
|
:owned-domains="$ownedDomainHosts"
|
||||||
</div>
|
owned-url="{{ route('servers.domains.owned') }}"
|
||||||
</div>
|
variant="gray"
|
||||||
|
/>
|
||||||
<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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|||||||
@@ -85,20 +85,24 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-6" x-data="{ showOrderModal: @js($shouldOpenOrderModal) }">
|
<div class="space-y-6" x-data="{ showOrderModal: @js($shouldOpenOrderModal) }">
|
||||||
{{-- Page Header --}}
|
<x-servers.page-hero
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
:badge="$type === 'vps' ? 'SSD · Root access · Flexible billing' : 'Bare metal · RAID · Full root'"
|
||||||
<div>
|
:title="$type === 'vps' ? 'VPS' : 'Dedicated servers'"
|
||||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">{{ $title }}</h1>
|
:description="$type === 'vps'
|
||||||
<p class="mt-1 text-sm text-gray-500">Manage your {{ strtolower($title) }} orders.</p>
|
? 'Deploy scalable virtual private servers — pick a plan, region, and image.'
|
||||||
</div>
|
: '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 !== [])
|
@if ($serverOrderPayloads !== [])
|
||||||
<button type="button" @click="showOrderModal = true"
|
<x-slot name="actions">
|
||||||
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">
|
<x-btn.create type="button" @click="showOrderModal = true">{{ $orderButtonLabel }}</x-btn.create>
|
||||||
<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>
|
</x-slot>
|
||||||
{{ $orderButtonLabel }}
|
|
||||||
</button>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</x-servers.page-hero>
|
||||||
|
|
||||||
@if ($hasAnyProducts)
|
@if ($hasAnyProducts)
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
$orderFormErrorFields = ['domain_name', 'plan_id', 'months'];
|
$orderFormErrorFields = ['domain_name', 'plan_id', 'months'];
|
||||||
$hasOrderFormErrors = collect($orderFormErrorFields)->contains(fn ($field) => $errors->has($field));
|
$hasOrderFormErrors = collect($orderFormErrorFields)->contains(fn ($field) => $errors->has($field));
|
||||||
$shouldOpenOrderModal = (bool) session('open_product_order_modal', false) || $hasOrderFormErrors;
|
$shouldOpenOrderModal = (bool) session('open_product_order_modal', false) || $hasOrderFormErrors;
|
||||||
$knownDomainHosts = $userDomains->pluck('host')->all();
|
$knownDomainHosts = $userDomains;
|
||||||
$oldDomainName = trim((string) old('domain_name', ''));
|
$oldDomainName = trim((string) old('domain_name', ''));
|
||||||
$initialDomainSource = $oldDomainName !== '' && ! in_array($oldDomainName, $knownDomainHosts, true) ? 'external' : 'ladill';
|
$initialDomainSource = $oldDomainName !== '' && ! in_array($oldDomainName, $knownDomainHosts, true) ? 'external' : 'ladill';
|
||||||
$initialExternalDomain = $initialDomainSource === 'external' ? $oldDomainName : '';
|
$initialExternalDomain = $initialDomainSource === 'external' ? $oldDomainName : '';
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
|| $rcServiceOrders->isNotEmpty();
|
|| $rcServiceOrders->isNotEmpty();
|
||||||
@endphp
|
@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 --}}
|
{{-- Page Header --}}
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -46,11 +46,7 @@
|
|||||||
'profileActive' => false,
|
'profileActive' => false,
|
||||||
'profileName' => $navUser?->name ?? '',
|
'profileName' => $navUser?->name ?? '',
|
||||||
'profileSubtitle' => $navUser?->email ?? '',
|
'profileSubtitle' => $navUser?->email ?? '',
|
||||||
'profileMenuItems' => [
|
'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
|
||||||
['type' => 'link', 'label' => 'Profile', 'href' => $navAcct.'/profile'],
|
|
||||||
['type' => 'link', 'label' => 'Account Settings', 'href' => $navAcct.'/account-settings'],
|
|
||||||
['type' => 'logout', 'label' => 'Logout', 'action' => route('logout')],
|
|
||||||
],
|
|
||||||
'avatarUrl' => $navUser?->avatar_url,
|
'avatarUrl' => $navUser?->avatar_url,
|
||||||
'initials' => $navInitials !== '' ? $navInitials : 'U',
|
'initials' => $navInitials !== '' ? $navInitials : 'U',
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
<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'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
</head>
|
</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 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>
|
<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"
|
<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,
|
'profileActive' => false,
|
||||||
'profileName' => $navUser?->name ?? '',
|
'profileName' => $navUser?->name ?? '',
|
||||||
'profileSubtitle' => $navUser?->email ?? '',
|
'profileSubtitle' => $navUser?->email ?? '',
|
||||||
'profileMenuItems' => [
|
'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
|
||||||
['type' => 'link', 'label' => 'Profile', 'href' => $navAcct.'/profile'],
|
|
||||||
['type' => 'link', 'label' => 'Account Settings', 'href' => $navAcct.'/account-settings'],
|
|
||||||
['type' => 'logout', 'label' => 'Logout', 'action' => route('logout')],
|
|
||||||
],
|
|
||||||
'avatarUrl' => $navUser?->avatar_url,
|
'avatarUrl' => $navUser?->avatar_url,
|
||||||
'initials' => $navInitials !== '' ? $navInitials : 'U',
|
'initials' => $navInitials !== '' ? $navInitials : 'U',
|
||||||
])
|
])
|
||||||
@@ -57,5 +53,6 @@
|
|||||||
@endauth
|
@endauth
|
||||||
@include('partials.sso-keepalive')
|
@include('partials.sso-keepalive')
|
||||||
@include('partials.confirm-prompt')
|
@include('partials.confirm-prompt')
|
||||||
|
@include('components.domain-purchase-modal')
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
@if ($handler === 'openAfia')
|
@if ($handler === 'openAfia')
|
||||||
@click="openAfia()"
|
@click="openAfia()"
|
||||||
@else
|
@else
|
||||||
@click="$dispatch('afia-open')"
|
@click="window.dispatchEvent(new CustomEvent('afia-open'))"
|
||||||
@endif
|
@endif
|
||||||
@class([
|
@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',
|
'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',
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
<div class="flex" :class="m.role === 'user' ? 'justify-end' : 'justify-start'">
|
<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"
|
<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'"
|
: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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\*\*([^*]+?)\*\*/g,'<strong>$1</strong>')"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div x-show="loading" class="flex justify-start">
|
<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>
|
||||||
@@ -10,10 +10,27 @@
|
|||||||
config('ladill_launcher.apps', []),
|
config('ladill_launcher.apps', []),
|
||||||
fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost
|
fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost
|
||||||
));
|
));
|
||||||
|
$launcherOverflows = count($launcherApps) > 15;
|
||||||
@endphp
|
@endphp
|
||||||
@if (! empty($launcherApps))
|
@if (! empty($launcherApps))
|
||||||
<div class="relative" x-data="{ appsOpen: false }" @click.outside="appsOpen = false" @keydown.escape.window="appsOpen = false">
|
<div class="relative" x-data="{
|
||||||
<button type="button" @click="appsOpen = !appsOpen"
|
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([
|
@class([
|
||||||
'inline-flex items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:bg-slate-50',
|
'inline-flex items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:bg-slate-50',
|
||||||
'p-2' => ! $sidebar,
|
'p-2' => ! $sidebar,
|
||||||
@@ -37,10 +54,15 @@
|
|||||||
'bottom-full left-0 mb-2' => $sidebar,
|
'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>
|
<p class="px-1 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">All apps</p>
|
||||||
|
<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">
|
<div class="grid grid-cols-3 gap-1">
|
||||||
@foreach ($launcherApps as $app)
|
@foreach ($launcherApps as $app)
|
||||||
<a href="{{ $app['url'] }}"
|
<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">
|
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">
|
<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">
|
<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>
|
||||||
@@ -49,5 +71,14 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
@php
|
@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;
|
$avatarUrl = $avatarUrl ?? null;
|
||||||
$initials = $initials ?? 'U';
|
$initials = $initials ?? 'U';
|
||||||
$notificationsUrl = $notificationsUrl ?? '#';
|
$notificationsUrl = $notificationsUrl ?? '#';
|
||||||
@@ -23,11 +27,13 @@
|
|||||||
<span class="text-[10px] font-medium">Home</span>
|
<span class="text-[10px] font-medium">Home</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
@if ($showSearch)
|
||||||
<a href="{{ $searchUrl }}"
|
<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) }}">
|
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>
|
<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>
|
<span class="text-[10px] font-medium">Search</span>
|
||||||
</a>
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if (!empty($centerCompose))
|
@if (!empty($centerCompose))
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
@@ -51,12 +57,14 @@
|
|||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
@endif
|
@endif
|
||||||
class="relative flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($notificationsActive ?? false) }}">
|
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>
|
<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)
|
@if ($unreadUrl)
|
||||||
<span x-show="unread > 0" x-cloak x-text="unread > 9 ? '9+' : unread"
|
<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>
|
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
|
@endif
|
||||||
|
</span>
|
||||||
<span class="text-[10px] font-medium">Notifications</span>
|
<span class="text-[10px] font-medium">Notifications</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -82,6 +90,7 @@
|
|||||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-[1px]"></div>
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-[1px]"></div>
|
||||||
|
|
||||||
<div x-show="profileOpen"
|
<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="transition ease-out duration-300"
|
||||||
x-transition:enter-start="translate-y-full"
|
x-transition:enter-start="translate-y-full"
|
||||||
x-transition:enter-end="translate-y-0"
|
x-transition:enter-end="translate-y-0"
|
||||||
@@ -113,25 +122,11 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="space-y-1 p-2 pb-4">
|
@include('partials.user-profile-menu', [
|
||||||
@foreach ($profileMenuItems as $item)
|
'items' => $profileMenuItems,
|
||||||
@if (($item['type'] ?? 'link') === 'link')
|
'variant' => 'sheet',
|
||||||
<a href="{{ $item['href'] }}"
|
'onNavigate' => 'profileOpen = false',
|
||||||
@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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
@@ -1,54 +1,635 @@
|
|||||||
{{--
|
{{--
|
||||||
Paystack mobile bottom-sheet iframe.
|
Payment checkout shell.
|
||||||
Requires Alpine.js ancestor with: showSheet (bool), checkoutUrl (string).
|
|
||||||
On mobile (< md) the sheet slides up; on desktop nothing renders.
|
|
||||||
--}}
|
|
||||||
<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">
|
|
||||||
|
|
||||||
{{-- Backdrop --}}
|
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">
|
||||||
|
{{-- 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 }}">
|
||||||
|
|
||||||
|
{{--
|
||||||
|
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"
|
<div class="absolute inset-0 bg-black/60"
|
||||||
x-show="showSheet"
|
data-ladill-pay-backdrop
|
||||||
x-transition:enter="transition-opacity duration-300"
|
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()">
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{{-- Sheet --}}
|
<div data-ladill-pay-panel
|
||||||
<div class="relative flex flex-col bg-white rounded-t-2xl overflow-hidden"
|
data-paystack-live="0"
|
||||||
style="height:90dvh"
|
class="relative flex w-full flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl md:max-w-lg md:rounded-2xl"
|
||||||
x-show="showSheet"
|
style="height: min(90dvh, 720px); max-height: 90dvh; padding-bottom: env(safe-area-inset-bottom, 0px)"
|
||||||
x-transition:enter="transition-transform duration-300 ease-out"
|
x-transition:enter="transition duration-300 ease-out md:duration-200"
|
||||||
x-transition:enter-start="translate-y-full"
|
x-transition:enter-start="translate-y-full opacity-0 md:translate-y-0 md:scale-95"
|
||||||
x-transition:enter-end="translate-y-0"
|
x-transition:enter-end="translate-y-0 opacity-100 md:scale-100"
|
||||||
x-transition:leave="transition-transform duration-200 ease-in"
|
x-transition:leave="transition duration-200 ease-in md:duration-150"
|
||||||
x-transition:leave-start="translate-y-0"
|
x-transition:leave-start="translate-y-0 opacity-100 md:scale-100"
|
||||||
x-transition:leave-end="translate-y-full">
|
x-transition:leave-end="translate-y-full opacity-0 md:translate-y-0 md:scale-95"
|
||||||
|
@click.stop>
|
||||||
|
|
||||||
<div class="flex shrink-0 items-center justify-between border-b border-slate-100 px-4 py-3">
|
<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"
|
||||||
<div class="absolute left-1/2 top-2 h-1 w-10 -translate-x-1/2 rounded-full bg-slate-200"></div>
|
style="padding-top: max(1.25rem, env(safe-area-inset-top, 0px))"
|
||||||
<p class="text-sm font-semibold text-slate-800">Complete Payment</p>
|
data-ladill-pay-header>
|
||||||
<button @click="showSheet = false"
|
<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>
|
||||||
class="rounded-full p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
|
<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">
|
aria-label="Close">
|
||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<iframe :src="showSheet ? checkoutUrl : ''"
|
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||||
class="flex-1 w-full border-0"
|
<iframe x-show="$store.ladillPayCheckout && $store.ladillPayCheckout.frameable"
|
||||||
allow="payment"
|
:src="showSheet && $store.ladillPayCheckout && $store.ladillPayCheckout.frameable ? checkoutUrl : ''"
|
||||||
title="Paystack checkout"></iframe>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="px-4 pb-3">
|
<div class="px-4 pb-3">
|
||||||
<div class="relative">
|
<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>
|
<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"
|
type="text"
|
||||||
x-model="query"
|
x-model="query"
|
||||||
@focus="onFocus()"
|
@focus="onFocus()"
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<h1 class="text-2xl font-semibold text-slate-900">{{ $heading }}</h1>
|
<h1 class="text-2xl font-semibold text-slate-900">{{ $heading }}</h1>
|
||||||
<div class="relative mt-3">
|
<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>
|
<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"
|
type="text"
|
||||||
x-model="query"
|
x-model="query"
|
||||||
@focus="onFocus()"
|
@focus="onFocus()"
|
||||||
|
|||||||
@@ -21,24 +21,6 @@
|
|||||||
<span>{{ $item['name'] }}</span>
|
<span>{{ $item['name'] }}</span>
|
||||||
</a>
|
</a>
|
||||||
@endforeach
|
@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>
|
</nav>
|
||||||
|
|
||||||
<div class="shrink-0 border-t border-slate-100 px-3 py-3">
|
<div class="shrink-0 border-t border-slate-100 px-3 py-3">
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
@if (auth()->check())
|
@if (auth()->check())
|
||||||
{{-- Same-site iframe keeps the shared auth.ladill.com session warm while using this app. --}}
|
@php
|
||||||
<iframe src="https://{{ config('app.auth_domain') }}/sso/ping" hidden width="0" height="0" title=""></iframe>
|
$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
|
@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>
|
||||||
@@ -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>
|
<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>
|
</button>
|
||||||
@include('partials.mobile-topbar-title')
|
@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>
|
||||||
<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>
|
<div class="flex items-center gap-3">
|
||||||
<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">
|
|
||||||
@auth
|
@auth
|
||||||
@include('partials.notification-dropdown')
|
@includeIf('partials.topbar-account-switcher')
|
||||||
|
@includeIf('partials.topbar-widgets-prepend')
|
||||||
<span class="hidden h-7 w-px bg-slate-200 lg:block"></span>
|
@include('partials.topbar-desktop-widgets', ['user' => $user ?? $u ?? auth()->user(), 'showUser' => true])
|
||||||
|
@includeIf('partials.topbar-widgets-append')
|
||||||
{{-- 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])
|
|
||||||
@else
|
@else
|
||||||
<a href="{{ route('login') }}" class="btn-primary">Sign in</a>
|
<a href="{{ route('login') }}" class="btn-primary">Sign in</a>
|
||||||
@endauth
|
|
||||||
|
|
||||||
@include('partials.launcher')
|
@include('partials.launcher')
|
||||||
|
@endauth
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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>
|
||||||
@@ -2,18 +2,23 @@
|
|||||||
@section('title', 'Overview — Ladill Servers')
|
@section('title', 'Overview — Ladill Servers')
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="mx-auto max-w-5xl">
|
<div class="mx-auto max-w-5xl">
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div class="min-w-0 flex-1">
|
||||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Overview</h1>
|
<h1 class="truncate text-lg font-semibold tracking-tight text-slate-900 lg:text-xl">Overview</h1>
|
||||||
<p class="mt-0.5 text-sm text-slate-500">Your VPS and dedicated servers at a glance.</p>
|
<p class="mt-0.5 hidden text-sm text-slate-500 sm:block">Your VPS and dedicated servers at a glance.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
<a href="{{ route('servers.vps', ['order' => 1]) }}" class="inline-flex items-center justify-center btn-primary">
|
@include('partials.mobile-header-btn', [
|
||||||
Order VPS
|
'href' => route('servers.vps', ['order' => 1]),
|
||||||
</a>
|
'label' => 'Order VPS',
|
||||||
<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">
|
'variant' => 'primary',
|
||||||
Order dedicated
|
])
|
||||||
</a>
|
@include('partials.mobile-header-btn', [
|
||||||
|
'href' => route('servers.dedicated', ['order' => 1]),
|
||||||
|
'label' => 'Order dedicated',
|
||||||
|
'variant' => 'outline',
|
||||||
|
'showDesktopIcon' => false,
|
||||||
|
])
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Auth\SsoLoginController;
|
use App\Http\Controllers\Auth\SsoLoginController;
|
||||||
|
use App\Http\Controllers\WalletBalanceController;
|
||||||
use App\Http\Controllers\Hosting\AccountController;
|
use App\Http\Controllers\Hosting\AccountController;
|
||||||
use App\Http\Controllers\Hosting\AfiaController;
|
use App\Http\Controllers\Hosting\AfiaController;
|
||||||
use App\Http\Controllers\Hosting\DeveloperController;
|
use App\Http\Controllers\Hosting\DeveloperController;
|
||||||
@@ -12,27 +13,31 @@ use App\Http\Controllers\Hosting\TeamController;
|
|||||||
use App\Http\Controllers\NotificationController;
|
use App\Http\Controllers\NotificationController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
// Ladill Servers — VPS & dedicated servers (servers.ladill.com).
|
|
||||||
|
|
||||||
Route::get('/', fn () => auth()->check()
|
Route::get('/', fn () => auth()->check()
|
||||||
? redirect()->route('servers.dashboard')
|
? redirect()->route('servers.dashboard')
|
||||||
: redirect()->route('sso.connect'))->name('servers.root');
|
: 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('/login', [SsoLoginController::class, 'connect'])->name('login');
|
||||||
Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect');
|
Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect');
|
||||||
Route::get('/sso/callback', [SsoLoginController::class, 'callback'])->name('sso.callback');
|
Route::get('/sso/callback', [SsoLoginController::class, 'callback'])->name('sso.callback');
|
||||||
Route::post('/logout', [SsoLoginController::class, 'logout'])->name('logout');
|
Route::post('/logout', [SsoLoginController::class, 'logout'])->name('logout');
|
||||||
Route::get('/sso/logout-bridge', [SsoLoginController::class, 'logoutBridge'])->name('sso.logout-bridge');
|
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/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::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', [NotificationController::class, 'index'])->name('notifications.index');
|
||||||
Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread');
|
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/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read');
|
||||||
Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-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('/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('/dashboard', [OverviewController::class, 'index'])->name('servers.dashboard');
|
||||||
|
|
||||||
Route::get('/vps', [HostingProductController::class, 'vps'])->name('servers.vps');
|
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::post('/tasks/log', [ServerPanelController::class, 'queueLog'])->name('hosting.server-panel.tasks.log');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/account/wallet', [AccountController::class, 'wallet'])->name('account.wallet');
|
// Wallet, Billing, Team and Developers now live on the central hub
|
||||||
Route::get('/account/billing', [AccountController::class, 'billing'])->name('account.billing');
|
// (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::get('/account/settings', [AccountController::class, 'settings'])->name('account.settings');
|
||||||
Route::put('/account/settings', [AccountController::class, 'updateSettings'])->name('account.settings.update');
|
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::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');
|
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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||