Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1addcddfc5 |
@@ -17,22 +17,14 @@ DB_DATABASE=ladill_qr_plus
|
||||
DB_USERNAME=ladill_qr_plus
|
||||
DB_PASSWORD=
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
# Idle web session length in minutes (1440 = 24 hours of inactivity).
|
||||
SESSION_LIFETIME=1440
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_DOMAIN=.ladill.com
|
||||
|
||||
LADILL_SSO_CLIENT_ID=
|
||||
LADILL_SSO_CLIENT_SECRET=
|
||||
|
||||
BILLING_API_URL=https://ladill.com/api/billing
|
||||
# Same-server: set to 127.0.0.1 to skip public hairpin latency on billing calls
|
||||
BILLING_API_FORCE_IP=127.0.0.1
|
||||
BILLING_API_KEY_QR=
|
||||
|
||||
IDENTITY_API_URL=https://ladill.com/api
|
||||
@@ -44,6 +36,4 @@ AFIA_PROVIDER=openai
|
||||
AFIA_MODEL=gpt-4o-mini
|
||||
AFIA_API_KEY=
|
||||
|
||||
LINK_PUBLIC_DOMAIN=ladl.link
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
@@ -19,9 +19,8 @@ jobs:
|
||||
env:
|
||||
NODE_ROOT: /tmp/ladill-node-22-r1
|
||||
NODE_VERSION: "22.14.0"
|
||||
# act_runner v0.2.x does not expose gitea.run_attempt — use run_id only.
|
||||
RELEASE_ARCHIVE: /tmp/ladill-qr-plus-release-${{ gitea.run_id }}.tgz
|
||||
WORKSPACE: /tmp/${{ gitea.repository_owner }}-qr-plus-${{ gitea.run_id }}
|
||||
RELEASE_ARCHIVE: /tmp/ladill-qr-plus-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||
WORKSPACE: /tmp/${{ gitea.repository_owner }}-qr-plus-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
LADILL_APP_ROOT: /var/www/ladill-qr-plus
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -39,17 +38,11 @@ jobs:
|
||||
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
|
||||
"$REPO_URL" "$WORKSPACE"
|
||||
|
||||
- name: Build frontend assets
|
||||
- name: Setup Node.js
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
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
|
||||
if [ ! -x "$NODE_ROOT/bin/node" ]; then
|
||||
rm -rf "$NODE_ROOT"
|
||||
mkdir -p "$NODE_ROOT"
|
||||
case "$(uname -m)" in
|
||||
@@ -59,12 +52,17 @@ jobs:
|
||||
esac
|
||||
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
|
||||
| tar -xJ --strip-components=1 -C "$NODE_ROOT"
|
||||
export PATH="$NODE_ROOT/bin:$PATH"
|
||||
NPM_BIN="$NODE_ROOT/bin/npm"
|
||||
fi
|
||||
"$NPM_BIN" ci --no-audit --no-fund
|
||||
"$NPM_BIN" run build
|
||||
"$NODE_ROOT/bin/node" -v
|
||||
|
||||
- name: Build frontend assets
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
cd "$WORKSPACE"
|
||||
export PATH="$NODE_ROOT/bin:$PATH"
|
||||
npm ci --no-audit --no-fund
|
||||
npm run build
|
||||
|
||||
- name: Build release archive
|
||||
shell: bash {0}
|
||||
@@ -80,7 +78,7 @@ jobs:
|
||||
- name: Deploy release
|
||||
shell: bash {0}
|
||||
env:
|
||||
LADILL_RELEASE_ARCHIVE: /tmp/ladill-qr-plus-release-${{ gitea.run_id }}.tgz
|
||||
LADILL_RELEASE_ARCHIVE: /tmp/ladill-qr-plus-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
|
||||
|
||||
@@ -88,7 +88,10 @@ class SsoLoginController extends Controller
|
||||
if ($request->filled('error')) {
|
||||
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
|
||||
&& ! $request->boolean('interactive')) {
|
||||
return redirect()->away((string) config('ladill.marketing_url'));
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => $intended,
|
||||
'interactive' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
|
||||
@@ -138,18 +141,6 @@ class SsoLoginController extends Controller
|
||||
return redirect()->away($this->defaultSignedOutUrl());
|
||||
}
|
||||
|
||||
/** Platform session ended — clear this app and offer silent sign-in again. */
|
||||
public function platformSignedOut(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => (string) $request->query('redirect', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logoutBridge(Request $request): View
|
||||
{
|
||||
$return = $this->safeReturnUrl((string) $request->query('return', ''));
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProductLandingController extends Controller
|
||||
{
|
||||
public function show(Request $request): View|RedirectResponse
|
||||
{
|
||||
$landing = config('product_landing', []);
|
||||
$variant = (string) ($landing['landing_variant'] ?? 'default');
|
||||
$dashboardRoute = (string) ($landing['dashboard_route'] ?? 'login');
|
||||
|
||||
if ($variant === 'webmail') {
|
||||
if ($request->session()->has('mb.email')) {
|
||||
return redirect()->route($dashboardRoute);
|
||||
}
|
||||
|
||||
return view('product.landing');
|
||||
}
|
||||
|
||||
if (auth()->check()) {
|
||||
return redirect()->route($dashboardRoute);
|
||||
}
|
||||
|
||||
return view('product.landing');
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ class QrScanController extends Controller
|
||||
}
|
||||
|
||||
if ($qrCode->isDocumentType()) {
|
||||
return redirect()->away($qrCode->publicPath('view'));
|
||||
return redirect()->route('qr.public.view', $shortCode);
|
||||
}
|
||||
|
||||
if ($qrCode->isBookType()) {
|
||||
@@ -62,7 +62,7 @@ class QrScanController extends Controller
|
||||
|
||||
return view('public.qr.document-viewer', [
|
||||
'qrCode' => $qrCode,
|
||||
'fileUrl' => $qrCode->publicPath('file'),
|
||||
'fileUrl' => route('qr.public.file', $shortCode),
|
||||
'allowDownload' => (bool) ($qrCode->content()['allow_download'] ?? true),
|
||||
]);
|
||||
}
|
||||
@@ -329,51 +329,6 @@ class QrScanController extends Controller
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
public function portfolioAvatar(string $shortCode): StreamedResponse
|
||||
{
|
||||
return $this->serveContentImage($shortCode, QrCode::TYPE_PORTFOLIO, 'avatar_path');
|
||||
}
|
||||
|
||||
public function portfolioCover(string $shortCode): StreamedResponse
|
||||
{
|
||||
return $this->serveContentImage($shortCode, QrCode::TYPE_PORTFOLIO, 'cover_path');
|
||||
}
|
||||
|
||||
public function portfolioProjectImage(string $shortCode, int $index): StreamedResponse
|
||||
{
|
||||
return $this->serveListImage($shortCode, QrCode::TYPE_PORTFOLIO, 'projects', $index, 'image_path');
|
||||
}
|
||||
|
||||
public function bookshopAvatar(string $shortCode): StreamedResponse
|
||||
{
|
||||
return $this->serveContentImage($shortCode, QrCode::TYPE_BOOKSHOP, 'avatar_path');
|
||||
}
|
||||
|
||||
public function bookshopCover(string $shortCode): StreamedResponse
|
||||
{
|
||||
return $this->serveContentImage($shortCode, QrCode::TYPE_BOOKSHOP, 'cover_path');
|
||||
}
|
||||
|
||||
public function bookshopBookCover(string $shortCode, int $index): StreamedResponse
|
||||
{
|
||||
return $this->serveListImage($shortCode, QrCode::TYPE_BOOKSHOP, 'books', $index, 'cover_path');
|
||||
}
|
||||
|
||||
private function serveListImage(string $shortCode, string $type, string $listKey, int $index, string $pathKey): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($qrCode->type === $type, 404);
|
||||
|
||||
$path = $qrCode->content()[$listKey][$index][$pathKey] ?? null;
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
private function escapeVcard(string $value): string
|
||||
{
|
||||
return str_replace(["\n", "\r", ',', ';'], [' ', ' ', '\\,', '\\;'], $value);
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Qr\QrAnalyticsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AnalyticsController extends Controller
|
||||
{
|
||||
public function __construct(private QrAnalyticsService $analytics) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
return view('qr.analytics.index', [
|
||||
'summary' => $this->analytics->summaryForAccount($account),
|
||||
'dailyScans' => $this->analytics->dailyScansForAccount($account, 30),
|
||||
'devices' => $this->analytics->breakdownForAccount($account, 'device_type'),
|
||||
'browsers' => $this->analytics->breakdownForAccount($account, 'browser'),
|
||||
'topCodes' => $this->analytics->topCodesForAccount($account),
|
||||
'recentScans' => $this->analytics->recentScansForAccount($account),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Campaign;
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CampaignController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$campaigns = Campaign::query()
|
||||
->where('user_id', $account->id)
|
||||
->withCount('qrCodes')
|
||||
->withSum('qrCodes', 'scans_total')
|
||||
->latest()
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
|
||||
$base = Campaign::query()->where('user_id', $account->id);
|
||||
|
||||
return view('qr.campaigns.index', [
|
||||
'campaigns' => $campaigns,
|
||||
'campaignCount' => (clone $base)->count(),
|
||||
'activeCount' => (clone $base)->where('status', Campaign::STATUS_ACTIVE)->count(),
|
||||
'codesInCampaigns' => QrCode::query()
|
||||
->where('user_id', $account->id)
|
||||
->whereNotNull('campaign_id')
|
||||
->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('qr.campaigns.create', [
|
||||
'campaign' => new Campaign(['status' => Campaign::STATUS_ACTIVE]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
$data = $this->validated($request);
|
||||
|
||||
$campaign = Campaign::create([
|
||||
...$data,
|
||||
'user_id' => $account->id,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('qr.campaigns.show', $campaign)
|
||||
->with('success', 'Campaign created.');
|
||||
}
|
||||
|
||||
public function show(Campaign $campaign): View
|
||||
{
|
||||
$this->authorizeCampaign($campaign);
|
||||
|
||||
$codes = $campaign->qrCodes()->latest()->get();
|
||||
$availableCodes = QrCode::query()
|
||||
->where('user_id', $campaign->user_id)
|
||||
->where(function ($q) use ($campaign) {
|
||||
$q->whereNull('campaign_id')->orWhere('campaign_id', $campaign->id);
|
||||
})
|
||||
->orderBy('label')
|
||||
->get();
|
||||
|
||||
return view('qr.campaigns.show', [
|
||||
'campaign' => $campaign,
|
||||
'codes' => $codes,
|
||||
'availableCodes' => $availableCodes,
|
||||
'totalScans' => (int) $codes->sum('scans_total'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Campaign $campaign): RedirectResponse
|
||||
{
|
||||
$this->authorizeCampaign($campaign);
|
||||
$campaign->update($this->validated($request));
|
||||
|
||||
return back()->with('success', 'Campaign updated.');
|
||||
}
|
||||
|
||||
public function destroy(Campaign $campaign): RedirectResponse
|
||||
{
|
||||
$this->authorizeCampaign($campaign);
|
||||
|
||||
QrCode::query()
|
||||
->where('campaign_id', $campaign->id)
|
||||
->update(['campaign_id' => null]);
|
||||
|
||||
$campaign->delete();
|
||||
|
||||
return redirect()
|
||||
->route('qr.campaigns.index')
|
||||
->with('success', 'Campaign deleted. QR codes were unassigned, not removed.');
|
||||
}
|
||||
|
||||
public function attach(Request $request, Campaign $campaign): RedirectResponse
|
||||
{
|
||||
$this->authorizeCampaign($campaign);
|
||||
|
||||
$validated = $request->validate([
|
||||
'qr_code_ids' => ['required', 'array', 'min:1'],
|
||||
'qr_code_ids.*' => ['integer'],
|
||||
]);
|
||||
|
||||
QrCode::query()
|
||||
->where('user_id', $campaign->user_id)
|
||||
->whereIn('id', $validated['qr_code_ids'])
|
||||
->update(['campaign_id' => $campaign->id]);
|
||||
|
||||
return back()->with('success', 'QR codes added to campaign.');
|
||||
}
|
||||
|
||||
public function detach(Campaign $campaign, QrCode $qrCode): RedirectResponse
|
||||
{
|
||||
$this->authorizeCampaign($campaign);
|
||||
abort_unless((int) $qrCode->user_id === (int) $campaign->user_id, 403);
|
||||
abort_unless((int) $qrCode->campaign_id === (int) $campaign->id, 404);
|
||||
|
||||
$qrCode->update(['campaign_id' => null]);
|
||||
|
||||
return back()->with('success', 'QR code removed from campaign.');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function validated(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'status' => ['required', Rule::in(array_keys(Campaign::STATUSES))],
|
||||
'starts_at' => ['nullable', 'date'],
|
||||
'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'],
|
||||
]);
|
||||
|
||||
$data['description'] = $data['description'] ?? null;
|
||||
$data['starts_at'] = $data['starts_at'] ?? null;
|
||||
$data['ends_at'] = $data['ends_at'] ?? null;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function authorizeCampaign(Campaign $campaign): void
|
||||
{
|
||||
abort_unless((int) $campaign->user_id === (int) ladill_account()->id, 403);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrWallet;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Qr\QrCodeManagerService;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
@@ -57,7 +56,6 @@ class OverviewController extends Controller
|
||||
'activeCount' => $activeCount,
|
||||
'scans30d' => (int) $scans30d,
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'pricePerQr' => QrWallet::pricePerQr(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,12 +73,6 @@ class QrCodeController extends Controller
|
||||
$wallet = $this->manager->walletFor($account);
|
||||
$qrSettings = $account->getOrCreateQrSetting();
|
||||
|
||||
$prefill = \App\Support\CrmPrefillCodec::decode($request->query('prefill'));
|
||||
$defaultType = $qrSettings->resolvedDefaultType();
|
||||
if (($prefill['kind'] ?? null) === 'qr_business' && ! empty($prefill['type'])) {
|
||||
$defaultType = (string) $prefill['type'];
|
||||
}
|
||||
|
||||
return view('qr-codes.create', [
|
||||
'wallet' => $wallet,
|
||||
'types' => QrTypeCatalog::all(),
|
||||
@@ -90,9 +84,8 @@ class QrCodeController extends Controller
|
||||
'minTopup' => QrWallet::minTopupGhs(),
|
||||
'ladillWalletBalance' => $this->platformBilling->balanceMinor($account->public_id) / 100,
|
||||
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
||||
'defaultType' => $defaultType,
|
||||
'defaultType' => $qrSettings->resolvedDefaultType(),
|
||||
'accountDefaultStyle' => $qrSettings->resolvedDefaultStyle(),
|
||||
'prefill' => ($prefill['kind'] ?? null) === 'qr_business' ? $prefill : null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -286,20 +279,6 @@ class QrCodeController extends Controller
|
||||
return back()->with('success', 'QR code updated.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, QrCode $qrCode): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $qrCode);
|
||||
|
||||
// Remove the code and its scan history. Deletion is gated behind a
|
||||
// type-to-confirm dialog in the UI — this is intentionally permanent.
|
||||
$qrCode->scanEvents()->delete();
|
||||
$qrCode->delete();
|
||||
|
||||
return redirect()
|
||||
->route('user.qr-codes.index')
|
||||
->with('success', 'QR code deleted.');
|
||||
}
|
||||
|
||||
public function stylePreview(Request $request): Response
|
||||
{
|
||||
$validated = $request->validate(array_merge($this->styleRules(), [
|
||||
@@ -324,7 +303,7 @@ class QrCodeController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$png = $this->imageGenerator->renderPng(\App\Support\LadillLink::url($shortCode), $style);
|
||||
$png = $this->imageGenerator->renderPng(QrCode::publicBaseUrl() . '/q/' . $shortCode, $style);
|
||||
|
||||
if ($tempLogoPath) {
|
||||
Storage::disk('qr')->delete($tempLogoPath);
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// BillingClient already short-caches balances; avoid a second long cache layer.
|
||||
try {
|
||||
$minor = $billing->balanceMinor($publicId);
|
||||
} catch (\Throwable) {
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* In-app wallet top-up. Posts to the central identity API to start a Paystack
|
||||
* checkout against the one Ladill wallet, then hands the user to checkout. This
|
||||
* backs the two-step "Add funds" modal so users never leave for the wallet page
|
||||
* when they hit an insufficient-balance action.
|
||||
*/
|
||||
class WalletTopupController extends Controller
|
||||
{
|
||||
public function store(Request $request, BillingClient $billing): JsonResponse|RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'amount' => ['required', 'numeric', 'min:5', 'max:5000'],
|
||||
'return_url' => ['nullable', 'url', 'max:2048'],
|
||||
]);
|
||||
|
||||
$returnUrl = $validated['return_url'] ?? (string) (url()->previous() ?: url('/'));
|
||||
|
||||
try {
|
||||
$checkoutUrl = $billing->topup(
|
||||
(string) $request->user()->public_id,
|
||||
(float) $validated['amount'],
|
||||
$returnUrl,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$checkoutUrl = '';
|
||||
}
|
||||
|
||||
if ($checkoutUrl === '') {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['error' => 'Unable to start payment. Please try again.'], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Unable to start payment. Please try again.');
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['checkout_url' => $checkoutUrl]);
|
||||
}
|
||||
|
||||
return redirect()->away($checkoutUrl);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\LadillLink;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RedirectLegacyQrToLadillLink
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! preg_match('#^q/[a-z0-9]#i', ltrim($request->path(), '/'))) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (LadillLink::isInternalRequest($request)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Same-origin AJAX from product-hosted pages must not 307 to ladl.link
|
||||
// (cross-origin fetch has no CORS → checkout sheet never opens).
|
||||
if ($request->ajax() || $request->expectsJson() || $request->wantsJson()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return LadillLink::legacyRedirect($request);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Campaign extends Model
|
||||
{
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_DRAFT => 'Draft',
|
||||
self::STATUS_ACTIVE => 'Active',
|
||||
self::STATUS_ARCHIVED => 'Archived',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'description',
|
||||
'status',
|
||||
'starts_at',
|
||||
'ends_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function qrCodes(): HasMany
|
||||
{
|
||||
return $this->hasMany(QrCode::class);
|
||||
}
|
||||
|
||||
public function statusLabel(): string
|
||||
{
|
||||
return self::STATUSES[$this->status] ?? ucfirst((string) $this->status);
|
||||
}
|
||||
|
||||
public function isLive(): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->starts_at && $this->starts_at->isFuture()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->ends_at && $this->ends_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function totalScans(): int
|
||||
{
|
||||
return (int) $this->qrCodes()->sum('scans_total');
|
||||
}
|
||||
|
||||
public function codesCount(): int
|
||||
{
|
||||
return (int) $this->qrCodes()->count();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\LadillLink;
|
||||
use App\Support\Qr\QrStyleDefaults;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use App\Support\Qr\QrWifiPayload;
|
||||
@@ -23,8 +22,6 @@ class QrCode extends Model
|
||||
public const TYPE_SHOP = 'shop';
|
||||
public const TYPE_APP = 'app';
|
||||
public const TYPE_BOOK = 'book';
|
||||
public const TYPE_PORTFOLIO = 'portfolio';
|
||||
public const TYPE_BOOKSHOP = 'bookshop';
|
||||
public const TYPE_WIFI = 'wifi';
|
||||
public const TYPE_COUPON = 'coupon';
|
||||
public const TYPE_EVENT = 'event';
|
||||
@@ -32,7 +29,6 @@ class QrCode extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'campaign_id',
|
||||
'short_code',
|
||||
'type',
|
||||
'label',
|
||||
@@ -62,11 +58,6 @@ class QrCode extends Model
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function campaign(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Campaign::class);
|
||||
}
|
||||
|
||||
public function document(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QrDocument::class, 'qr_document_id');
|
||||
@@ -94,19 +85,23 @@ class QrCode extends Model
|
||||
|
||||
public function publicUrl(): string
|
||||
{
|
||||
return LadillLink::url($this->short_code);
|
||||
}
|
||||
|
||||
public function publicPath(string $suffix = ''): string
|
||||
{
|
||||
return $suffix === ''
|
||||
? $this->publicUrl()
|
||||
: LadillLink::path($this->short_code, $suffix);
|
||||
return self::publicBaseUrl() . '/q/' . $this->short_code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL for public QR links. Always the short platform domain
|
||||
* (ladill.com) — never the signed-in account/product host — so printed
|
||||
* codes and shared links stay short and host-independent of where the QR
|
||||
* was created.
|
||||
*/
|
||||
public static function publicBaseUrl(): string
|
||||
{
|
||||
return LadillLink::baseUrl();
|
||||
$appUrl = (string) config('app.url');
|
||||
$scheme = parse_url($appUrl, PHP_URL_SCHEME) ?: 'https';
|
||||
$host = (string) config('app.platform_domain')
|
||||
?: (parse_url($appUrl, PHP_URL_HOST) ?: 'ladill.com');
|
||||
|
||||
return $scheme . '://' . $host;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,8 +197,6 @@ class QrCode extends Model
|
||||
self::TYPE_SHOP,
|
||||
self::TYPE_APP,
|
||||
self::TYPE_BOOK,
|
||||
self::TYPE_PORTFOLIO,
|
||||
self::TYPE_BOOKSHOP,
|
||||
self::TYPE_WIFI,
|
||||
self::TYPE_EVENT,
|
||||
self::TYPE_ITINERARY,
|
||||
|
||||
@@ -58,11 +58,6 @@ class User extends Authenticatable
|
||||
return $this->hasMany(QrCode::class);
|
||||
}
|
||||
|
||||
public function campaigns(): HasMany
|
||||
{
|
||||
return $this->hasMany(Campaign::class);
|
||||
}
|
||||
|
||||
public function qrSetting(): HasOne
|
||||
{
|
||||
return $this->hasOne(QrSetting::class);
|
||||
|
||||
@@ -103,10 +103,9 @@ class AfiaService
|
||||
|
||||
What Ladill QR Plus does and where things live:
|
||||
- Overview (Dashboard): recent codes, active code count, scans in the last 30 days, wallet balance.
|
||||
- My Codes: list, create, and edit QR codes. Types: Link (URL), PDF, List of Links, Business profile, Portfolio (freelancer work showcase), Bookshop (author books with buy links), WiFi, App download.
|
||||
- My Codes: list, create, and edit QR codes. Types: Link (URL), PDF, List of Links, Business profile, WiFi, App download.
|
||||
- Creating a code: pick a type, set content, customize style (colors, logo, frame), then download PNG/SVG/PDF.
|
||||
- Dynamic codes: destination can change after printing — the printed QR always points to ladill.com/q/{code}.
|
||||
- Campaigns: group QR codes for a promo or launch (sidebar → Campaigns), attach/detach codes, and see combined scan totals.
|
||||
- Business QR: name, tagline, contact, hours, logo, cover, social links — shown as a mobile landing page.
|
||||
- WiFi QR: guests scan to join; network name and password are encoded in the code.
|
||||
- PDF QR: hosts a PDF with optional download button on the viewer.
|
||||
@@ -115,7 +114,7 @@ class AfiaService
|
||||
- Developers: API tokens for programmatic code creation (sidebar → Developers).
|
||||
|
||||
Rules:
|
||||
- Only answer questions about Ladill QR Plus — code types, styling, downloads, campaigns, scans, wallet billing, team, and API.
|
||||
- Only answer questions about Ladill QR Plus — code types, styling, downloads, scans, wallet billing, team, and API.
|
||||
- If asked about domains, hosting, email, or payments/commerce QR (shop, events, donations), briefly say those live in other Ladill apps and suggest the app launcher.
|
||||
- Never invent prices, short codes, or wallet balances — use the user context below or tell them where to check.
|
||||
- For print tips: recommend high contrast, adequate quiet zone, test-scan before mass printing.
|
||||
|
||||
@@ -2,50 +2,19 @@
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Client for the platform Billing HTTP API — the one UserWallet lives on the
|
||||
* platform; QR Plus only consumes it. Amounts are integer minor units; the user is
|
||||
* platform; Bird only consumes it. Amounts are integer minor units; the user is
|
||||
* identified by public_id. Authenticates with the per-consumer config('billing.service') key.
|
||||
*
|
||||
* When BILLING_API_FORCE_IP is set (e.g. 127.0.0.1), requests resolve the billing
|
||||
* host to that IP so same-server apps avoid a public hairpin (~400ms TLS each call).
|
||||
* Contract validated in the monolith (smtp-extraction-runbook §4-A).
|
||||
*/
|
||||
class BillingClient
|
||||
{
|
||||
private const BALANCE_CACHE_TTL_SECONDS = 8;
|
||||
|
||||
private function configuredBase(): string
|
||||
{
|
||||
return rtrim((string) config('billing.api_url'), '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Public billing URL rewritten to a same-host IP when force_ip is set.
|
||||
* Example: https://ladill.com/api/billing + 127.0.0.1
|
||||
* → https://127.0.0.1/api/billing (Host: ladill.com)
|
||||
*/
|
||||
private function base(): string
|
||||
{
|
||||
$base = $this->configuredBase();
|
||||
$forceIp = trim((string) config('billing.force_ip', ''));
|
||||
if ($forceIp === '') {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$parts = parse_url($base);
|
||||
if (! is_array($parts) || empty($parts['host'])) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$scheme = $parts['scheme'] ?? 'https';
|
||||
$path = $parts['path'] ?? '';
|
||||
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
|
||||
|
||||
return $scheme.'://'.$forceIp.$port.$path;
|
||||
return rtrim((string) config('billing.api_url'), '/');
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
@@ -53,37 +22,9 @@ class BillingClient
|
||||
return (string) (config('billing.api_key') ?? '');
|
||||
}
|
||||
|
||||
private function client(): PendingRequest
|
||||
{
|
||||
$pending = Http::withToken($this->token())
|
||||
->acceptJson()
|
||||
->timeout((int) config('billing.timeout', 10))
|
||||
->connectTimeout((int) config('billing.connect_timeout', 2));
|
||||
|
||||
$forceIp = trim((string) config('billing.force_ip', ''));
|
||||
if ($forceIp === '') {
|
||||
return $pending;
|
||||
}
|
||||
|
||||
$host = parse_url($this->configuredBase(), PHP_URL_HOST);
|
||||
if (! is_string($host) || $host === '') {
|
||||
return $pending;
|
||||
}
|
||||
|
||||
$pending = $pending->withHeaders(['Host' => $host]);
|
||||
|
||||
// Loopback TLS cert is issued for the public host, not the IP.
|
||||
if (in_array($forceIp, ['127.0.0.1', '::1', 'localhost'], true)
|
||||
|| filter_var($forceIp, FILTER_VALIDATE_IP)) {
|
||||
$pending = $pending->withOptions(['verify' => false]);
|
||||
}
|
||||
|
||||
return $pending;
|
||||
}
|
||||
|
||||
private function get(string $path, array $query): array
|
||||
{
|
||||
$res = $this->client()->get($this->base().$path, $query);
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().$path, $query);
|
||||
$res->throw();
|
||||
|
||||
return (array) $res->json();
|
||||
@@ -91,33 +32,12 @@ class BillingClient
|
||||
|
||||
public function balanceMinor(string $publicId): int
|
||||
{
|
||||
return (int) Cache::remember(
|
||||
$this->balanceCacheKey($publicId),
|
||||
self::BALANCE_CACHE_TTL_SECONDS,
|
||||
fn () => (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Paystack checkout to top up the central wallet; returns the
|
||||
* checkout URL. Backs the in-app two-step "Add funds" modal.
|
||||
*/
|
||||
public function topup(string $publicId, float $amount, string $returnUrl): string
|
||||
{
|
||||
$res = $this->client()->timeout(15)->post($this->base().'/topup', [
|
||||
'user' => $publicId,
|
||||
'amount' => $amount,
|
||||
'return_url' => $returnUrl,
|
||||
]);
|
||||
$res->throw();
|
||||
|
||||
return (string) ($res->json()['checkout_url'] ?? '');
|
||||
return (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0);
|
||||
}
|
||||
|
||||
public function canAfford(string $publicId, int $amountMinor): bool
|
||||
{
|
||||
// Prefer a single balance lookup (cached) over a dedicated can-afford round-trip.
|
||||
return $this->balanceMinor($publicId) >= $amountMinor;
|
||||
return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false);
|
||||
}
|
||||
|
||||
public function serviceLedger(string $publicId, string $service): array
|
||||
@@ -126,15 +46,12 @@ class BillingClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Debit the wallet. Returns the API payload on success (includes
|
||||
* balance_after_minor), or null on insufficient balance (HTTP 402).
|
||||
* Idempotent by $reference.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
* Debit the wallet. Returns true on success, false on insufficient balance
|
||||
* (HTTP 402). Idempotent by $reference.
|
||||
*/
|
||||
public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): ?array
|
||||
public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): bool
|
||||
{
|
||||
$res = $this->client()->post($this->base().'/debit', array_filter([
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
|
||||
'user' => $publicId,
|
||||
'amount_minor' => $amountMinor,
|
||||
'service' => $service,
|
||||
@@ -145,40 +62,16 @@ class BillingClient
|
||||
], static fn ($v) => $v !== null));
|
||||
|
||||
if ($res->status() === 402) {
|
||||
$this->forgetBalance($publicId);
|
||||
if (is_numeric($res->json('balance_minor'))) {
|
||||
Cache::put(
|
||||
$this->balanceCacheKey($publicId),
|
||||
(int) $res->json('balance_minor'),
|
||||
self::BALANCE_CACHE_TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
|
||||
$res->throw();
|
||||
$payload = (array) $res->json();
|
||||
|
||||
if (isset($payload['balance_after_minor'])) {
|
||||
Cache::put(
|
||||
$this->balanceCacheKey($publicId),
|
||||
(int) $payload['balance_after_minor'],
|
||||
self::BALANCE_CACHE_TTL_SECONDS,
|
||||
);
|
||||
} else {
|
||||
$this->forgetBalance($publicId);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function credit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): array
|
||||
{
|
||||
$res = $this->client()->post($this->base().'/credit', array_filter([
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/credit', array_filter([
|
||||
'user' => $publicId,
|
||||
'amount_minor' => $amountMinor,
|
||||
'service' => $service,
|
||||
@@ -189,27 +82,6 @@ class BillingClient
|
||||
], static fn ($v) => $v !== null));
|
||||
$res->throw();
|
||||
|
||||
$payload = (array) $res->json();
|
||||
if (isset($payload['balance_after_minor'])) {
|
||||
Cache::put(
|
||||
$this->balanceCacheKey($publicId),
|
||||
(int) $payload['balance_after_minor'],
|
||||
self::BALANCE_CACHE_TTL_SECONDS,
|
||||
);
|
||||
} else {
|
||||
$this->forgetBalance($publicId);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function forgetBalance(string $publicId): void
|
||||
{
|
||||
Cache::forget($this->balanceCacheKey($publicId));
|
||||
}
|
||||
|
||||
private function balanceCacheKey(string $publicId): string
|
||||
{
|
||||
return 'billing:balance_minor:'.$publicId;
|
||||
return (array) $res->json();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,119 +4,14 @@ namespace App\Services\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrScanEvent;
|
||||
use App\Models\User;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use App\Models\QrWallet;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class QrAnalyticsService
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function summaryForAccount(User $account): array
|
||||
{
|
||||
$codeIds = $this->codeIdsFor($account);
|
||||
|
||||
if ($codeIds === []) {
|
||||
return $this->emptySummary();
|
||||
}
|
||||
|
||||
$codes = QrCode::query()->whereIn('id', $codeIds)->get(['id', 'scans_total', 'unique_scans_total', 'last_scanned_at']);
|
||||
$now = now();
|
||||
$events = QrScanEvent::query()->whereIn('qr_code_id', $codeIds);
|
||||
|
||||
return [
|
||||
'total_scans' => (int) $codes->sum('scans_total'),
|
||||
'unique_scans' => (int) $codes->sum('unique_scans_total'),
|
||||
'scans_7d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(7))->count(),
|
||||
'scans_30d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(30))->count(),
|
||||
'last_scanned_at' => $codes->max('last_scanned_at'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, object{date: string, total: int}>
|
||||
*/
|
||||
public function dailyScansForAccount(User $account, int $days = 30): Collection
|
||||
{
|
||||
$codeIds = $this->codeIdsFor($account);
|
||||
$start = now()->subDays($days - 1)->startOfDay();
|
||||
|
||||
if ($codeIds === []) {
|
||||
return $this->emptyDailySeries($start, $days);
|
||||
}
|
||||
|
||||
$rows = QrScanEvent::query()
|
||||
->selectRaw('DATE(scanned_at) as scan_date, COUNT(*) as total')
|
||||
->whereIn('qr_code_id', $codeIds)
|
||||
->where('scanned_at', '>=', $start)
|
||||
->groupBy('scan_date')
|
||||
->orderBy('scan_date')
|
||||
->get()
|
||||
->keyBy('scan_date');
|
||||
|
||||
return $this->buildDailySeries($start, $days, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, total: int}>
|
||||
*/
|
||||
public function breakdownForAccount(User $account, string $column, int $limit = 5): array
|
||||
{
|
||||
$codeIds = $this->codeIdsFor($account);
|
||||
|
||||
if ($codeIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return QrScanEvent::query()
|
||||
->select($column, DB::raw('COUNT(*) as total'))
|
||||
->whereIn('qr_code_id', $codeIds)
|
||||
->whereNotNull($column)
|
||||
->groupBy($column)
|
||||
->orderByDesc('total')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'label' => (string) $row->{$column},
|
||||
'total' => (int) $row->total,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, QrScanEvent>
|
||||
*/
|
||||
public function recentScansForAccount(User $account, int $limit = 20)
|
||||
{
|
||||
$codeIds = $this->codeIdsFor($account);
|
||||
|
||||
if ($codeIds === []) {
|
||||
return QrScanEvent::query()->whereRaw('0 = 1')->get();
|
||||
}
|
||||
|
||||
return QrScanEvent::query()
|
||||
->whereIn('qr_code_id', $codeIds)
|
||||
->with('qrCode:id,label,short_code')
|
||||
->latest('scanned_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, QrCode>
|
||||
*/
|
||||
public function topCodesForAccount(User $account, int $limit = 10)
|
||||
{
|
||||
return $account->qrCodes()
|
||||
->whereIn('type', QrTypeCatalog::plusTypes())
|
||||
->orderByDesc('scans_total')
|
||||
->limit($limit)
|
||||
->get(['id', 'label', 'short_code', 'type', 'scans_total', 'last_scanned_at']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -150,7 +45,16 @@ class QrAnalyticsService
|
||||
->get()
|
||||
->keyBy('scan_date');
|
||||
|
||||
return $this->buildDailySeries($start, $days, $rows);
|
||||
$series = collect();
|
||||
for ($i = 0; $i < $days; $i++) {
|
||||
$date = $start->copy()->addDays($i)->toDateString();
|
||||
$series->push((object) [
|
||||
'date' => $date,
|
||||
'total' => (int) ($rows->get($date)?->total ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
return $series;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,49 +88,4 @@ class QrAnalyticsService
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function codeIdsFor(User $account): array
|
||||
{
|
||||
return $account->qrCodes()
|
||||
->whereIn('type', QrTypeCatalog::plusTypes())
|
||||
->pluck('id')
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function emptySummary(): array
|
||||
{
|
||||
return [
|
||||
'total_scans' => 0,
|
||||
'unique_scans' => 0,
|
||||
'scans_7d' => 0,
|
||||
'scans_30d' => 0,
|
||||
'last_scanned_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return Collection<int, object{date: string, total: int}> */
|
||||
private function emptyDailySeries(Carbon $start, int $days): Collection
|
||||
{
|
||||
return $this->buildDailySeries($start, $days, collect());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, object{scan_date?: string, total?: int}> $rows
|
||||
* @return Collection<int, object{date: string, total: int}>
|
||||
*/
|
||||
private function buildDailySeries(Carbon $start, int $days, Collection $rows): Collection
|
||||
{
|
||||
$series = collect();
|
||||
for ($i = 0; $i < $days; $i++) {
|
||||
$date = $start->copy()->addDays($i)->toDateString();
|
||||
$series->push((object) [
|
||||
'date' => $date,
|
||||
'total' => (int) ($rows->get($date)?->total ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
return $series;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ class QrCodeManagerService
|
||||
{
|
||||
$wallet = $this->walletFor($user);
|
||||
|
||||
// Fast path: one cached balance read. Debit is still authoritative (402 if
|
||||
// funds are short). Avoids a second can-afford HTTP round-trip.
|
||||
if (! $wallet->canCreateQr()) {
|
||||
throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.');
|
||||
}
|
||||
@@ -46,8 +44,7 @@ class QrCodeManagerService
|
||||
$validated = $this->payloadValidator->validateForCreate($type, $data);
|
||||
$style = QrStyleDefaults::merge($data['style'] ?? null);
|
||||
|
||||
// Persist the code first (short DB transaction — no remote billing/image work).
|
||||
$qrCode = DB::transaction(function () use ($user, $data, $type, $validated, $style) {
|
||||
return DB::transaction(function () use ($user, $wallet, $data, $type, $validated, $style) {
|
||||
$documentId = null;
|
||||
$content = $validated['content'];
|
||||
|
||||
@@ -106,38 +103,6 @@ class QrCodeManagerService
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_PORTFOLIO) {
|
||||
if (($data['portfolio_avatar'] ?? null) instanceof UploadedFile) {
|
||||
$content['avatar_path'] = $this->storeMenuBrandImage($user, $data['portfolio_avatar'], 'portfolio-avatars');
|
||||
}
|
||||
if (($data['portfolio_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['portfolio_cover'], 'portfolio-covers');
|
||||
}
|
||||
$content['projects'] = $this->injectListImages(
|
||||
$user,
|
||||
$content['projects'] ?? [],
|
||||
$data['project_images'] ?? [],
|
||||
'portfolio-projects',
|
||||
'image_path',
|
||||
);
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_BOOKSHOP) {
|
||||
if (($data['bookshop_avatar'] ?? null) instanceof UploadedFile) {
|
||||
$content['avatar_path'] = $this->storeMenuBrandImage($user, $data['bookshop_avatar'], 'bookshop-avatars');
|
||||
}
|
||||
if (($data['bookshop_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['bookshop_cover'], 'bookshop-covers');
|
||||
}
|
||||
$content['books'] = $this->injectListImages(
|
||||
$user,
|
||||
$content['books'] ?? [],
|
||||
$data['book_covers'] ?? [],
|
||||
'bookshop-books',
|
||||
'cover_path',
|
||||
);
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_CHURCH) {
|
||||
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['church_logo'], 'church-logos');
|
||||
@@ -178,7 +143,7 @@ class QrCodeManagerService
|
||||
$payload['wifi_encoded'] = \App\Support\Qr\QrWifiPayload::encode($content);
|
||||
}
|
||||
|
||||
return QrCode::create([
|
||||
$qrCode = QrCode::create([
|
||||
'user_id' => $user->id,
|
||||
'short_code' => $shortCode,
|
||||
'type' => $type,
|
||||
@@ -189,20 +154,12 @@ class QrCodeManagerService
|
||||
'is_active' => true,
|
||||
'destination_updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
try {
|
||||
$this->billing->debitForQrCreation($wallet, $qrCode);
|
||||
} catch (RuntimeException $e) {
|
||||
// Roll back the unpaid code so a failed debit cannot leave a free QR.
|
||||
$qrCode->delete();
|
||||
throw $e;
|
||||
}
|
||||
$this->imageGenerator->generateAndStore($qrCode);
|
||||
|
||||
// Image render is local CPU work — keep it outside the DB transaction.
|
||||
$this->imageGenerator->generateAndStore($qrCode);
|
||||
|
||||
return $qrCode->fresh(['document']);
|
||||
return $qrCode->fresh(['document']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,38 +245,6 @@ class QrCodeManagerService
|
||||
}
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_PORTFOLIO) {
|
||||
if (($data['portfolio_avatar'] ?? null) instanceof UploadedFile) {
|
||||
$content['avatar_path'] = $this->storeMenuBrandImage($qrCode->user, $data['portfolio_avatar'], 'portfolio-avatars');
|
||||
}
|
||||
if (($data['portfolio_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['portfolio_cover'], 'portfolio-covers');
|
||||
}
|
||||
$content['projects'] = $this->injectListImages(
|
||||
$qrCode->user,
|
||||
$content['projects'] ?? [],
|
||||
$data['project_images'] ?? [],
|
||||
'portfolio-projects',
|
||||
'image_path',
|
||||
);
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_BOOKSHOP) {
|
||||
if (($data['bookshop_avatar'] ?? null) instanceof UploadedFile) {
|
||||
$content['avatar_path'] = $this->storeMenuBrandImage($qrCode->user, $data['bookshop_avatar'], 'bookshop-avatars');
|
||||
}
|
||||
if (($data['bookshop_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['bookshop_cover'], 'bookshop-covers');
|
||||
}
|
||||
$content['books'] = $this->injectListImages(
|
||||
$qrCode->user,
|
||||
$content['books'] ?? [],
|
||||
$data['book_covers'] ?? [],
|
||||
'bookshop-books',
|
||||
'cover_path',
|
||||
);
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_CHURCH) {
|
||||
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_logo'], 'church-logos');
|
||||
@@ -342,10 +267,9 @@ class QrCodeManagerService
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['itinerary_cover'], 'itinerary-covers');
|
||||
}
|
||||
|
||||
$previousStyle = $style;
|
||||
|
||||
if (isset($data['style']) && is_array($data['style'])) {
|
||||
$style = QrStyleDefaults::merge(array_merge($style, $data['style']));
|
||||
$regenerate = true;
|
||||
}
|
||||
|
||||
if (($data['logo'] ?? null) instanceof UploadedFile) {
|
||||
@@ -353,16 +277,11 @@ class QrCodeManagerService
|
||||
$regenerate = true;
|
||||
}
|
||||
|
||||
if (($data['remove_logo'] ?? false) && ($style['logo_path'] ?? null)) {
|
||||
if (($data['remove_logo'] ?? false) && $style['logo_path']) {
|
||||
$style['logo_path'] = null;
|
||||
$regenerate = true;
|
||||
}
|
||||
|
||||
// Only re-render the PNG/SVG when style actually changed (forms always post style[]).
|
||||
if (! $regenerate && $this->styleChanged($previousStyle, $style)) {
|
||||
$regenerate = true;
|
||||
}
|
||||
|
||||
$qrCode->destination_url = $destinationUrl;
|
||||
// Preserve frozen keys (e.g. wifi_encoded) so the printed WiFi code stays put.
|
||||
$payload = (array) ($qrCode->payload ?? []);
|
||||
@@ -378,20 +297,6 @@ class QrCodeManagerService
|
||||
return $qrCode->fresh(['document']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $before
|
||||
* @param array<string, mixed> $after
|
||||
*/
|
||||
private function styleChanged(array $before, array $after): bool
|
||||
{
|
||||
$before = QrStyleDefaults::merge($before);
|
||||
$after = QrStyleDefaults::merge($after);
|
||||
ksort($before);
|
||||
ksort($after);
|
||||
|
||||
return $before !== $after;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject uploaded item images into the sections array.
|
||||
* Form field: item_images[sectionIndex][itemIndex] (UploadedFile)
|
||||
@@ -449,8 +354,6 @@ class QrCodeManagerService
|
||||
QrCode::TYPE_SHOP => ['shop_title', 'sections', 'currency', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
|
||||
QrCode::TYPE_APP => ['app_name', 'ios_url', 'android_url', 'web_url', 'app_icon'],
|
||||
QrCode::TYPE_BOOK => ['book_title', 'author', 'description', 'price_ghs'],
|
||||
QrCode::TYPE_PORTFOLIO => ['name', 'role', 'bio', 'email', 'phone', 'website', 'brand_color', 'projects', 'social'],
|
||||
QrCode::TYPE_BOOKSHOP => ['author_name', 'tagline', 'bio', 'email', 'website', 'brand_color', 'books', 'social'],
|
||||
QrCode::TYPE_WIFI => ['ssid', 'password', 'encryption', 'hidden'],
|
||||
default => [],
|
||||
};
|
||||
@@ -577,36 +480,6 @@ class QrCodeManagerService
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject uploaded list images (project images / book covers) into a flat list.
|
||||
*
|
||||
* @param array<int, mixed> $items
|
||||
* @param array<int, mixed> $uploads
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function injectListImages(User $user, array $items, array $uploads, string $subdir, string $pathKey): array
|
||||
{
|
||||
if ($uploads === []) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
foreach ($uploads as $index => $file) {
|
||||
if (! ($file instanceof UploadedFile) || ! isset($items[$index])) {
|
||||
continue;
|
||||
}
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! str_starts_with($mime, 'image/')) {
|
||||
continue;
|
||||
}
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$path = $user->id.'/'.$subdir.'/'.Str::uuid()->toString().'.'.$ext;
|
||||
$file->storeAs('', $path, 'qr');
|
||||
$items[$index][$pathKey] = $path;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function storeBookCover(User $user, UploadedFile $file): string
|
||||
{
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
|
||||
@@ -34,8 +34,6 @@ class QrPayloadValidator
|
||||
QrCode::TYPE_SHOP => $this->validateShop($input),
|
||||
QrCode::TYPE_APP => $this->validateApp($input),
|
||||
QrCode::TYPE_BOOK => $this->validateBook($input),
|
||||
QrCode::TYPE_PORTFOLIO => $this->validatePortfolio($input),
|
||||
QrCode::TYPE_BOOKSHOP => $this->validateBookshop($input),
|
||||
QrCode::TYPE_WIFI => $this->validateWifi($input),
|
||||
default => throw new RuntimeException('Unsupported QR type.'),
|
||||
};
|
||||
@@ -511,143 +509,6 @@ class QrPayloadValidator
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validatePortfolio(array $input): array
|
||||
{
|
||||
$name = trim((string) ($input['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('Enter your name.');
|
||||
}
|
||||
|
||||
$projects = [];
|
||||
foreach ((array) ($input['projects'] ?? []) as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$title = trim((string) ($row['title'] ?? ''));
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
$url = trim((string) ($row['url'] ?? ''));
|
||||
if ($url !== '' && ! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
throw new RuntimeException("Enter a valid project URL for “{$title}”.");
|
||||
}
|
||||
$tags = array_values(array_filter(array_map(
|
||||
fn ($t) => trim((string) $t),
|
||||
preg_split('/[,|]/', (string) ($row['tags'] ?? '')) ?: []
|
||||
)));
|
||||
$projects[] = [
|
||||
'title' => $title,
|
||||
'description' => trim((string) ($row['description'] ?? '')),
|
||||
'url' => $url,
|
||||
'tags' => $tags,
|
||||
'image_path' => $row['image_path'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($projects === []) {
|
||||
throw new RuntimeException('Add at least one project to your portfolio.');
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'name' => $name,
|
||||
'role' => trim((string) ($input['role'] ?? '')),
|
||||
'bio' => trim((string) ($input['bio'] ?? '')),
|
||||
'email' => trim((string) ($input['email'] ?? '')),
|
||||
'phone' => trim((string) ($input['phone'] ?? '')),
|
||||
'website' => trim((string) ($input['website'] ?? '')),
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#4f46e5',
|
||||
'avatar_path' => $input['avatar_path'] ?? null,
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
'social' => $this->normalizeSocialInput($input),
|
||||
'projects' => $projects,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateBookshop(array $input): array
|
||||
{
|
||||
$author = trim((string) ($input['author_name'] ?? ''));
|
||||
if ($author === '') {
|
||||
throw new RuntimeException('Enter the author name.');
|
||||
}
|
||||
|
||||
$books = [];
|
||||
foreach ((array) ($input['books'] ?? []) as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$title = trim((string) ($row['title'] ?? ''));
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
$buyLinks = [];
|
||||
foreach ((array) ($row['buy_links'] ?? []) as $link) {
|
||||
if (! is_array($link)) {
|
||||
continue;
|
||||
}
|
||||
$label = trim((string) ($link['label'] ?? ''));
|
||||
$url = trim((string) ($link['url'] ?? ''));
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
if (! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
throw new RuntimeException("Enter a valid purchase URL for “{$title}”.");
|
||||
}
|
||||
$buyLinks[] = [
|
||||
'label' => $label !== '' ? $label : 'Buy',
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
if ($buyLinks === []) {
|
||||
throw new RuntimeException("Add at least one purchase link for “{$title}”.");
|
||||
}
|
||||
$books[] = [
|
||||
'title' => $title,
|
||||
'description' => trim((string) ($row['description'] ?? '')),
|
||||
'cover_path' => $row['cover_path'] ?? null,
|
||||
'buy_links' => $buyLinks,
|
||||
];
|
||||
}
|
||||
|
||||
if ($books === []) {
|
||||
throw new RuntimeException('Add at least one book to the bookshop.');
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'author_name' => $author,
|
||||
'tagline' => trim((string) ($input['tagline'] ?? '')),
|
||||
'bio' => trim((string) ($input['bio'] ?? '')),
|
||||
'email' => trim((string) ($input['email'] ?? '')),
|
||||
'website' => trim((string) ($input['website'] ?? '')),
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#0f766e',
|
||||
'avatar_path' => $input['avatar_path'] ?? null,
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
'social' => $this->normalizeSocialInput($input),
|
||||
'books' => $books,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input @return array<string, string> */
|
||||
private function normalizeSocialInput(array $input): array
|
||||
{
|
||||
$social = [];
|
||||
foreach (['linkedin', 'twitter', 'instagram', 'facebook', 'tiktok', 'youtube', 'whatsapp', 'snapchat'] as $platform) {
|
||||
$raw = trim((string) (($input['social'] ?? [])[$platform] ?? ''));
|
||||
if ($raw !== '') {
|
||||
$social[$platform] = static::normalizeSocialUrl($platform, $raw);
|
||||
}
|
||||
}
|
||||
|
||||
return $social;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateWifi(array $input): array
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\QrTransaction;
|
||||
use App\Models\QrWallet;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -38,37 +39,36 @@ class QrWalletBillingService
|
||||
$user = $wallet->user;
|
||||
$reference = 'QR-DEBIT-'.strtoupper(Str::random(12));
|
||||
|
||||
$result = $this->billing->debit(
|
||||
$user->public_id,
|
||||
$priceMinor,
|
||||
'qr',
|
||||
'qr_create',
|
||||
$reference,
|
||||
$qrCode->id,
|
||||
sprintf('Created QR code: %s', $qrCode->label),
|
||||
);
|
||||
return DB::transaction(function () use ($wallet, $user, $qrCode, $price, $priceMinor, $reference) {
|
||||
$ok = $this->billing->debit(
|
||||
$user->public_id,
|
||||
$priceMinor,
|
||||
'qr',
|
||||
'qr_create',
|
||||
$reference,
|
||||
$qrCode->id,
|
||||
sprintf('Created QR code: %s', $qrCode->label),
|
||||
);
|
||||
|
||||
if ($result === null) {
|
||||
throw new RuntimeException('Insufficient wallet balance.');
|
||||
}
|
||||
if (! $ok) {
|
||||
throw new RuntimeException('Insufficient wallet balance.');
|
||||
}
|
||||
|
||||
$wallet->increment('qr_codes_total');
|
||||
$wallet->increment('qr_codes_total');
|
||||
$balanceAfter = $this->billing->balanceMinor($user->public_id) / 100;
|
||||
|
||||
$balanceAfter = isset($result['balance_after_minor'])
|
||||
? ((int) $result['balance_after_minor']) / 100
|
||||
: $this->billing->balanceMinor($user->public_id) / 100;
|
||||
|
||||
return QrTransaction::create([
|
||||
'user_id' => $wallet->user_id,
|
||||
'qr_wallet_id' => $wallet->id,
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'type' => QrTransaction::TYPE_DEBIT,
|
||||
'amount_ghs' => round($price, 4),
|
||||
'balance_after_ghs' => $balanceAfter,
|
||||
'reference' => $reference,
|
||||
'status' => 'completed',
|
||||
'description' => sprintf('Created QR code: %s', $qrCode->label),
|
||||
'metadata' => ['qr_code_id' => $qrCode->id],
|
||||
]);
|
||||
return QrTransaction::create([
|
||||
'user_id' => $wallet->user_id,
|
||||
'qr_wallet_id' => $wallet->id,
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'type' => QrTransaction::TYPE_DEBIT,
|
||||
'amount_ghs' => round($price, 4),
|
||||
'balance_after_ghs' => $balanceAfter,
|
||||
'reference' => $reference,
|
||||
'status' => 'completed',
|
||||
'description' => sprintf('Created QR code: %s', $qrCode->label),
|
||||
'metadata' => ['qr_code_id' => $qrCode->id],
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class CrmPrefillCodec
|
||||
{
|
||||
/** @return array<string, mixed>|null */
|
||||
public static function decode(?string $token): ?array
|
||||
{
|
||||
if (! is_string($token) || $token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$padded = $token.str_repeat('=', (4 - strlen($token) % 4) % 4);
|
||||
$json = base64_decode(strtr($padded, '-_', '+/'), true);
|
||||
|
||||
if ($json === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Canonical short-link URLs for the Ladill platform (ladl.link).
|
||||
*/
|
||||
final class LadillLink
|
||||
{
|
||||
public const INTERNAL_HEADER = 'X-Ladill-Internal';
|
||||
|
||||
public static function baseUrl(): string
|
||||
{
|
||||
$domain = (string) config('link.public_domain', 'ladl.link');
|
||||
|
||||
return 'https://'.$domain;
|
||||
}
|
||||
|
||||
public static function url(string $slug): string
|
||||
{
|
||||
return rtrim(self::baseUrl(), '/').'/'.ltrim($slug, '/');
|
||||
}
|
||||
|
||||
public static function path(string $slug, string $suffix): string
|
||||
{
|
||||
return self::url($slug).'/'.ltrim($suffix, '/');
|
||||
}
|
||||
|
||||
public static function isInternalRequest(Request $request): bool
|
||||
{
|
||||
return $request->header(self::INTERNAL_HEADER) === '1';
|
||||
}
|
||||
|
||||
public static function legacyRedirect(Request $request): RedirectResponse
|
||||
{
|
||||
$shortCode = $request->route('shortCode');
|
||||
if (! is_string($shortCode) || $shortCode === '') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$path = ltrim($request->path(), '/');
|
||||
$prefix = 'q/'.$shortCode;
|
||||
$suffix = '';
|
||||
if ($path !== $prefix && str_starts_with($path, $prefix.'/')) {
|
||||
$suffix = substr($path, strlen($prefix) + 1);
|
||||
}
|
||||
|
||||
$target = $suffix === '' ? self::url($shortCode) : self::path($shortCode, $suffix);
|
||||
if ($qs = $request->getQueryString()) {
|
||||
$target .= '?'.$qs;
|
||||
}
|
||||
|
||||
$status = in_array($request->method(), ['GET', 'HEAD'], true) ? 301 : 307;
|
||||
|
||||
return redirect()->away($target, $status);
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,6 @@ class QrTypeCatalog
|
||||
QrCode::TYPE_DOCUMENT,
|
||||
QrCode::TYPE_LINK_LIST,
|
||||
QrCode::TYPE_BUSINESS,
|
||||
QrCode::TYPE_PORTFOLIO,
|
||||
QrCode::TYPE_BOOKSHOP,
|
||||
QrCode::TYPE_WIFI,
|
||||
QrCode::TYPE_APP,
|
||||
];
|
||||
@@ -52,18 +50,6 @@ class QrTypeCatalog
|
||||
'category' => 'contact',
|
||||
'icon' => 'business-profile.svg',
|
||||
],
|
||||
QrCode::TYPE_PORTFOLIO => [
|
||||
'label' => 'Portfolio',
|
||||
'description' => 'Showcase work for freelancers',
|
||||
'category' => 'contact',
|
||||
'icon' => 'portfolio.svg',
|
||||
],
|
||||
QrCode::TYPE_BOOKSHOP => [
|
||||
'label' => 'Bookshop',
|
||||
'description' => 'Author books with buy links',
|
||||
'category' => 'business',
|
||||
'icon' => 'bookshop.svg',
|
||||
],
|
||||
QrCode::TYPE_WIFI => [
|
||||
'label' => 'WiFi',
|
||||
'description' => 'Network name and password',
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'redirect' => $request->fullUrl(),
|
||||
]));
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\InjectBootSplash::class,
|
||||
\App\Http\Middleware\SetActingAccount::class,
|
||||
]);
|
||||
$middleware->alias([
|
||||
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
|
||||
'redirect.legacy.qr' => \App\Http\Middleware\RedirectLegacyQrToLadillLink::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
@@ -3,12 +3,5 @@
|
||||
return [
|
||||
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
|
||||
'api_key' => env('BILLING_API_KEY_QR'),
|
||||
// Same-server hairpin: force billing DNS to this IP (e.g. 127.0.0.1) to skip
|
||||
// public network/TLS lag. Leave empty to use normal DNS.
|
||||
'force_ip' => env('BILLING_API_FORCE_IP', ''),
|
||||
'timeout' => (int) env('BILLING_API_TIMEOUT', 10),
|
||||
'connect_timeout' => (int) env('BILLING_API_CONNECT_TIMEOUT', 2),
|
||||
'service' => 'qr',
|
||||
'wallet_balance_route' => 'wallet.balance',
|
||||
'currency' => 'GHS',
|
||||
];
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'product_slug' => 'qrplus',
|
||||
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/qrplus'),
|
||||
];
|
||||
@@ -2,45 +2,39 @@
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ladill App Launcher — GENERATED, IDENTICAL across every app/service repo
|
||||
| Ladill App Launcher — SHARED, IDENTICAL across every app/service repo
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| DO NOT EDIT BY HAND. This file is generated from the app registry in the
|
||||
| monolith (config/ladill_apps.php). To change the launcher, edit that
|
||||
| registry and run: php artisan ladill:launcher:sync --propagate
|
||||
| Lists ONLY fully-extracted apps (each on its own subdomain). To replicate the
|
||||
| launcher in a new app, copy these three things verbatim into that repo:
|
||||
| - config/ladill_launcher.php (this file)
|
||||
| - resources/views/partials/launcher.blade.php
|
||||
| - public/images/launcher-icons/* (the icon set)
|
||||
|
|
||||
| URLs are absolute (built from the platform root) so this file is
|
||||
| byte-identical in every repo; the blade omits whichever row matches the
|
||||
| app's own host. Icons are served from /images/launcher-icons/<icon>.
|
||||
| When a service becomes FULLY extracted: add one row below AND drop its icon in
|
||||
| public/images/launcher-icons/ — in EVERY repo. That's the only edit needed.
|
||||
| Do NOT list a service here until it is fully extracted to its own subdomain.
|
||||
|
|
||||
| URLs are absolute (built from the platform root) so this file is byte-identical
|
||||
| in every app; the blade omits whichever row matches the app's APP_URL host.
|
||||
| Icons are served from /images/launcher-icons/<icon>.
|
||||
*/
|
||||
|
||||
$root = config('app.platform_domain', 'ladill.com');
|
||||
|
||||
return [
|
||||
'apps' => [
|
||||
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
|
||||
['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'],
|
||||
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
|
||||
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
|
||||
['name' => 'Woo Manager', 'url' => 'https://woo.'.$root.'/sso/connect?redirect='.urlencode('https://woo.'.$root.'/dashboard'), 'icon' => 'woomanager.svg'],
|
||||
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
|
||||
['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'],
|
||||
['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'],
|
||||
['name' => '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' => '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' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'],
|
||||
['name' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'],
|
||||
['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'],
|
||||
['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.svg'],
|
||||
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
|
||||
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
|
||||
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
|
||||
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
|
||||
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
|
||||
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'public_domain' => env('LINK_PUBLIC_DOMAIN', 'ladl.link'),
|
||||
];
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'slug' => 'qrplus',
|
||||
'name' => 'QR Plus',
|
||||
'logo' => 'images/logo/ladillqrplus-logo.svg',
|
||||
'icon' => 'qrplus.svg',
|
||||
'tagline' => 'Dynamic QR codes for anything',
|
||||
'headline' => 'QR codes that do more than link',
|
||||
'accent' => 'menus, catalogs, and smart redirects',
|
||||
'description' => 'QR Plus creates dynamic QR codes for menus, product catalogs, contact cards, files, and more — update content anytime without reprinting.',
|
||||
'benefits' =>
|
||||
[
|
||||
0 =>
|
||||
[
|
||||
'title' => 'Dynamic & editable',
|
||||
'text' => 'Change the destination or content without generating a new code.',
|
||||
,]
|
||||
1 =>
|
||||
[
|
||||
'title' => 'Rich content types',
|
||||
'text' => 'Menus, business pages, Wi‑Fi, vCards, files, and payment flows.',
|
||||
,]
|
||||
2 =>
|
||||
[
|
||||
'title' => 'Scan analytics',
|
||||
'text' => 'See when and where your codes are being used.',
|
||||
,]
|
||||
,]
|
||||
'use_cases' =>
|
||||
[
|
||||
0 => 'Restaurant menus',
|
||||
1 => 'Retail product tags',
|
||||
2 => 'Events & signage',
|
||||
,]
|
||||
'gradient_from' => '#08245f',
|
||||
'gradient_to' => '#1c75bc',
|
||||
'dashboard_route' => 'qr.dashboard',
|
||||
'platform_url' => 'https://localhost',
|
||||
'marketing_url' => 'https://localhost/products/qrplus',
|
||||
'register_url' => 'https://auth.localhost/register',
|
||||
'landing_variant' => 'default',
|
||||
];
|
||||
@@ -32,7 +32,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 1440),
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('campaigns', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('status', 20)->default('active')->index();
|
||||
$table->timestamp('starts_at')->nullable();
|
||||
$table->timestamp('ends_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'status']);
|
||||
});
|
||||
|
||||
Schema::table('qr_codes', function (Blueprint $table) {
|
||||
$table->foreignId('campaign_id')->nullable()->after('user_id')->constrained('campaigns')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('qr_codes', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('campaign_id');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('campaigns');
|
||||
}
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,25 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,35 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,25 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -3,23 +3,18 @@
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #007e7d;
|
||||
fill: #f6398a;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #7b5c84;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #f6398a;
|
||||
fill: #007e7d;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M357,144.9c13.6-60.9-29.8-126.9-89-126.9s-56.6,15.3-73.7,39.6l127.6,152.5c14.8-19.8,30-40.8,35.1-65.2Z"/>
|
||||
<path class="cls-3" d="M180.7,81.5c-9.8-11.1-19.2-22.6-29-33.6-6.8-8.1-13.2-17-23-22.1-10.6-5.1-23.9-7.7-36.2-7.7-59.1-.1-102.6,65.9-89.8,126.8,6.4,30.7,28.5,55.4,45.6,79.2,22.1,30.7,65.6,74.1,96.3,103.5,20,19.2,50.7,19.2,70.7,0,28.7-27.5,67.6-66.7,90.8-96.3-44-52.6-112.7-135.1-125.3-149.8h-.1Z"/>
|
||||
<path class="cls-2" d="M234.3,143.1c-.4-.3-.8-.6-1.1-1-17.5-20.4-34.9-40.5-52.4-60.9-.2-.3-.4-.5-.7-.8-.4.4-.7.9-1.1,1.3-17.5,20.4-34.9,40.5-52.4,60.9-.3.4-.8.7-1.1,1l-73.5,85.1c23,30.3,63.5,70.8,92.6,98.6,20,19.2,50.7,19.2,70.7,0,29.6-28.3,69.8-69,92.8-98.9l-73.7-85.3h0Z"/>
|
||||
<path class="cls-1" d="M267.9,17.5c-30.1-.2-56.9,15.4-73.6,39.6l58.6,68.2c4.7,5.5,4.1,13.7-1.4,18.4-2.5,2.1-5.5,3.1-8.5,3.1s-7.3-1.5-9.9-4.5c-17.5-20.3-34.9-40.6-52.4-61-9.6-11.2-19.3-22.4-28.9-33.6-7-8.1-13.3-17.2-23.1-22.1-11.2-5.6-24.3-8.1-36.7-8.1C32.8,17.9-10.8,83.4,2.3,144.7c6.5,30.6,28.4,55.4,45.8,79.4,22.2,30.7,65.6,74.1,96.5,103.8,20.2,19.4,50.6,19.4,70.7,0,30.9-29.7,74.3-73.2,96.5-103.8,17.4-24,39.3-48.8,45.8-79.4,13-61.3-30.6-126.9-89.8-127.2Z"/>
|
||||
<path class="cls-2" d="M267.9,17.5c-30.1-.2-56.9,15.4-73.6,39.6l58.6,68.2c4.7,5.5,4.1,13.7-1.4,18.4-2.5,2.1-5.5,3.1-8.5,3.1s-7.3-1.5-9.9-4.5l75.1,86.7c1.3-1.6,2.5-3.3,3.6-4.8,17.4-24,39.3-48.8,45.8-79.4,13-61.3-30.6-126.9-89.8-127.2Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -31,10 +31,10 @@
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-4" d="M52.3,11.7h0c28.9,0,52.3,23.4,52.3,52.3v229.7c0,28.9-23.4,52.3-52.3,52.3h0c-28.9,0-52.3-23.4-52.3-52.3V64C0,35.1,23.4,11.7,52.3,11.7Z"/>
|
||||
<path class="cls-1" d="M304.6,126.2h0c29.2,0,52.9,23.7,52.9,52.9v114c0,29.2-23.7,52.9-52.9,52.9h0c-29.2,0-52.9-23.7-52.9-52.9v-114c0-29.2,23.7-52.9,52.9-52.9Z"/>
|
||||
<path class="cls-6" d="M304.6,126.2H51.7c-29,0-51.7,23.9-51.7,52.4s23.3,52.3,52.3,52.3h252.9c29.1,0,52.3-23.3,52.3-52.3s-23.3-52.3-52.3-52.3h-.6,0Z"/>
|
||||
<rect class="cls-4" x="0" y="11.7" width="104.6" height="334.3" rx="52.3" ry="52.3"/>
|
||||
<rect class="cls-1" x="251.7" y="126.2" width="105.8" height="219.8" rx="52.9" ry="52.9"/>
|
||||
<path class="cls-6" d="M304.6,126.2H51.7C22.7,126.2,0,150.1,0,178.6s23.3,52.3,52.3,52.3h252.9c29.1,0,52.3-23.3,52.3-52.3s-23.3-52.3-52.3-52.3h-.6Z"/>
|
||||
<circle class="cls-3" cx="304.1" cy="64" r="52.3"/>
|
||||
<path class="cls-2" d="M251.7,178.6v52.3h52.9c26.7,0,48.3-19.8,51.7-45.3v-7c0-29.1-23.3-52.3-52.3-52.3s-52.3,23.3-52.3,52.3h0Z"/>
|
||||
<path class="cls-5" d="M0,172.2v14c3.5,25.6,25,45.3,51.7,45.3h52.9v-104.6h-52.9c-26.7,0-48.2,19.7-51.7,45.3Z"/>
|
||||
<path class="cls-2" d="M251.7,178.6v52.3h52.9c26.7,0,48.3-19.8,51.7-45.3v-7c0-29.1-23.3-52.3-52.3-52.3s-52.3,23.3-52.3,52.3Z"/>
|
||||
<path class="cls-5" d="M0,172.2v14c3.5,25.6,25,45.3,51.7,45.3h52.9v-104.6h-52.9C25,126.8,3.5,146.6,0,172.2Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -1,25 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 638 B |
@@ -1,25 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 859 B |
@@ -1,25 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,20 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 557 B |
@@ -1,30 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 839 B |
@@ -1,25 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 734 B |
@@ -1,30 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,31 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#0f766e" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><path d="M8 7h8M8 11h6"/></svg>
|
||||
|
Before Width: | Height: | Size: 304 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#4f46e5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M3 12h18"/></svg>
|
||||
|
Before Width: | Height: | Size: 281 B |
@@ -68,18 +68,12 @@ html.qr-mobile-page body {
|
||||
}
|
||||
|
||||
.mobile-stats-card {
|
||||
width: max-content;
|
||||
min-width: 9rem;
|
||||
max-width: none;
|
||||
box-sizing: border-box;
|
||||
width: calc(66.666667% - 0.5rem);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.mobile-stats-card {
|
||||
width: max-content;
|
||||
min-width: 9rem;
|
||||
max-width: none;
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,60 +130,3 @@ html.qr-mobile-page body {
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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,11 @@
|
||||
import './bootstrap';
|
||||
|
||||
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
|
||||
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
|
||||
|
||||
registerLadillModalHelpers();
|
||||
registerLadillSearchShortcut();
|
||||
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
import { registerLadillClipboard } from './ladill-clipboard';
|
||||
import collapse from '@alpinejs/collapse';
|
||||
import QRCodeStyling from 'qr-code-styling';
|
||||
window.QRCodeStyling = QRCodeStyling;
|
||||
@@ -16,7 +13,6 @@ import qrcode from 'qrcode-generator';
|
||||
window.qrcode = qrcode;
|
||||
|
||||
Alpine.plugin(collapse);
|
||||
registerLadillClipboard(Alpine);
|
||||
|
||||
Alpine.data('notificationDropdown', (config = {}) => ({
|
||||
open: false,
|
||||
@@ -199,26 +195,6 @@ Alpine.data('topbarSearch', (config = {}) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
// Wallet balance peek for the avatar dropdown.
|
||||
Alpine.data('walletWidget', (config = {}) => ({
|
||||
display: '…',
|
||||
async load() {
|
||||
if (! config.url) {
|
||||
this.display = 'View wallet';
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(config.url, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
const data = await res.json();
|
||||
this.display = data.available ? data.formatted : 'View wallet';
|
||||
} catch (e) {
|
||||
this.display = 'View wallet';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
window.Alpine = Alpine;
|
||||
registerLadillConfirmStore(Alpine);
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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);
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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,33 +1,17 @@
|
||||
@props([
|
||||
'href' => null,
|
||||
'type' => 'button',
|
||||
'label' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$tag = $href ? 'a' : 'button';
|
||||
$ariaLabel = $label ?? trim(preg_replace('/\s+/', ' ', strip_tags((string) $slot)));
|
||||
@endphp
|
||||
|
||||
<{{ $tag }}
|
||||
@if ($href) href="{{ $href }}" @endif
|
||||
@if ($tag === 'button') type="{{ $type }}" @endif
|
||||
aria-label="{{ $ariaLabel }}"
|
||||
title="{{ $ariaLabel }}"
|
||||
{{ $attributes->class(['btn-fab h-10 w-10 lg:hidden']) }}
|
||||
{{ $attributes->class(['btn-primary']) }}
|
||||
>
|
||||
<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>
|
||||
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
{{ $slot }}
|
||||
</{{ $tag }}>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
@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>
|
||||
@@ -1,15 +1,13 @@
|
||||
@props([
|
||||
'id',
|
||||
'title' => 'Add funds',
|
||||
'title' => 'Add credits',
|
||||
'description' => '',
|
||||
'topupAction',
|
||||
'minTopup' => 5,
|
||||
'suggestedAmount' => 10,
|
||||
'ladillWalletBalance' => 0,
|
||||
'serviceBalance' => null,
|
||||
'serviceBalanceLabel' => 'Wallet balance',
|
||||
'pricePerAction' => null,
|
||||
'actionNoun' => 'action',
|
||||
'serviceBalanceLabel' => 'Current balance',
|
||||
'openOnLoad' => false,
|
||||
'returnUrl' => null,
|
||||
])
|
||||
@@ -19,68 +17,18 @@
|
||||
// wallet into service credits" is a meaningless round-trip — top up the one
|
||||
// wallet directly (Paystack). Transfer option removed.
|
||||
$canPayFromWallet = false;
|
||||
|
||||
// Balance shown in the explainer — prefer the service balance, fall back to
|
||||
// the central wallet figure.
|
||||
$shownBalance = $serviceBalance !== null ? (float) $serviceBalance : (float) $ladillWalletBalance;
|
||||
@endphp
|
||||
|
||||
<x-modal :name="$id" :show="$openOnLoad" maxWidth="md">
|
||||
<div x-data="{ step: 1 }"
|
||||
x-on:open-modal.window="String($event.detail) === @js($id) ? step = 1 : null"
|
||||
class="flex min-h-full flex-col sm:min-h-0">
|
||||
|
||||
{{-- Header with step indicator --}}
|
||||
<div class="flex min-h-full flex-col sm:min-h-0">
|
||||
<div class="border-b border-slate-100 px-5 py-4 pr-12 sm:px-6 sm:pr-14">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="h-1.5 w-6 rounded-full transition-colors" :class="step >= 1 ? 'bg-indigo-600' : 'bg-slate-200'"></span>
|
||||
<span class="h-1.5 w-6 rounded-full transition-colors" :class="step >= 2 ? 'bg-indigo-600' : 'bg-slate-200'"></span>
|
||||
<span class="ml-2 text-xs font-medium text-slate-400" x-text="'Step ' + step + ' of 2'"></span>
|
||||
</div>
|
||||
<h2 class="mt-2 text-lg font-semibold text-slate-900"
|
||||
x-text="step === 1 ? 'How billing works' : @js($title)">How billing works</h2>
|
||||
<p x-show="step === 2 && @js((bool) $description)" x-cloak class="mt-1 text-sm text-slate-500">{{ $description }}</p>
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ $title }}</h2>
|
||||
@if($description)
|
||||
<p class="mt-1 text-sm text-slate-500">{{ $description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- STEP 1 — billing explainer --}}
|
||||
<div x-show="step === 1" class="flex flex-1 flex-col p-5 sm:p-6">
|
||||
<p class="text-sm leading-6 text-slate-600">
|
||||
Ladill is <span class="font-medium text-slate-800">pay-as-you-go</span>. One wallet funds
|
||||
every Ladill app, and you're only charged when you perform an action.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 space-y-2.5 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
@if($pricePerAction !== null)
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-500">Each {{ $actionNoun }} costs</span>
|
||||
<span class="font-semibold text-slate-900">GHS {{ number_format((float) $pricePerAction, 2) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-500">{{ $serviceBalanceLabel }}</span>
|
||||
<span class="font-semibold text-slate-900">GHS {{ number_format($shownBalance, 2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-xs leading-5 text-slate-500">
|
||||
Top up any amount — unused funds stay in your wallet for next time. You can withdraw
|
||||
them whenever you like.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-2 sm:mt-auto sm:flex-row sm:justify-end">
|
||||
<button type="button" @click="$dispatch('close-modal', '{{ $id }}')"
|
||||
data-close-modal="{{ $id }}"
|
||||
class="hidden rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 sm:inline-flex">
|
||||
Not now
|
||||
</button>
|
||||
<button type="button" @click="step = 2" class="btn-primary w-full sm:w-auto">
|
||||
Add funds
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- STEP 2 — amount + payment --}}
|
||||
<form x-show="step === 2" x-cloak action="{{ $topupAction }}" method="POST" class="flex flex-1 flex-col p-5 sm:p-6"
|
||||
<form action="{{ $topupAction }}" method="POST" class="flex flex-1 flex-col p-5 sm:p-6"
|
||||
x-data="{
|
||||
method: 'paystack',
|
||||
showSheet: false,
|
||||
@@ -88,10 +36,10 @@
|
||||
loading: false,
|
||||
errorMsg: '',
|
||||
async submitTopup(event) {
|
||||
if (this.method !== 'paystack') return;
|
||||
if (this.method !== 'paystack' || window.innerWidth >= 768) return;
|
||||
|
||||
event.preventDefault();
|
||||
this.loading = true; window.LadillPayCheckout?.prepare?.();
|
||||
this.loading = true;
|
||||
this.errorMsg = '';
|
||||
|
||||
try {
|
||||
@@ -108,9 +56,10 @@
|
||||
}
|
||||
|
||||
this.checkoutUrl = data.checkout_url;
|
||||
this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened)
|
||||
this.showSheet = true;
|
||||
this.loading = false;
|
||||
} catch (error) {
|
||||
window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
|
||||
this.errorMsg = 'Network error. Please try again.';
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
@@ -121,6 +70,13 @@
|
||||
<input type="hidden" name="return_url" value="{{ $returnUrl }}">
|
||||
@endif
|
||||
|
||||
@if($serviceBalance !== null)
|
||||
<div class="mb-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-center">
|
||||
<p class="text-xl font-bold text-slate-900">GHS {{ number_format((float) $serviceBalance, 2) }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $serviceBalanceLabel }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div x-show="errorMsg" x-cloak class="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700" x-text="errorMsg"></div>
|
||||
|
||||
<div>
|
||||
@@ -132,18 +88,33 @@
|
||||
<p class="mt-1.5 text-xs text-slate-500">Minimum GHS {{ number_format($minTopup, 2) }}</p>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="payment_method" value="paystack">
|
||||
<input type="hidden" name="payment_method" value="paystack" @if($canPayFromWallet) x-model="method" @endif>
|
||||
|
||||
<p class="mt-4 text-xs text-slate-500">Pay securely with Paystack.</p>
|
||||
@if($canPayFromWallet)
|
||||
<div class="mt-4">
|
||||
<p class="mb-2 text-xs font-medium text-slate-600">Payment method</p>
|
||||
<div class="flex rounded-xl border border-slate-200 bg-white p-0.5 text-sm">
|
||||
<button type="button" @click="method = 'paystack'"
|
||||
:class="method === 'paystack' ? 'bg-indigo-600 text-white' : 'text-slate-600'"
|
||||
class="flex-1 rounded-lg py-2 font-medium transition">Paystack</button>
|
||||
<button type="button" @click="method = 'wallet'"
|
||||
:class="method === 'wallet' ? 'bg-indigo-600 text-white' : 'text-slate-600'"
|
||||
class="flex-1 rounded-lg py-2 font-medium transition">Wallet (GHS {{ number_format($ladillWalletBalance, 2) }})</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<p class="mt-4 text-xs text-slate-500">Pay with Paystack.</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 flex flex-col gap-2 sm:mt-auto sm:flex-row sm:justify-end">
|
||||
<button type="button" @click="step = 1"
|
||||
<button type="button" @click="$dispatch('close-modal', '{{ $id }}')"
|
||||
data-close-modal="{{ $id }}"
|
||||
class="hidden rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 sm:inline-flex">
|
||||
Back
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="loading"
|
||||
class="btn-primary w-full sm:w-auto">
|
||||
<span x-text="loading ? 'Processing…' : 'Continue to payment'">Continue to payment</span>
|
||||
<span x-text="loading ? 'Processing…' : 'Add credits'">Add credits</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -45,7 +45,11 @@
|
||||
'profileActive' => false,
|
||||
'profileName' => $navUser?->name ?? '',
|
||||
'profileSubtitle' => $navUser?->email ?? '',
|
||||
'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
|
||||
'profileMenuItems' => [
|
||||
['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,
|
||||
'initials' => $navInitials !== '' ? $navInitials : 'U',
|
||||
])
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }" data-ladill-search-url="{{ route('qr.search') }}">
|
||||
<body class="font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }">
|
||||
<div class="min-h-screen max-lg:min-h-dvh">
|
||||
{{-- Mobile sidebar overlay --}}
|
||||
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
|
||||
@@ -74,7 +74,12 @@
|
||||
'profileActive' => request()->routeIs('account.settings'),
|
||||
'profileName' => $navUser?->name ?? '',
|
||||
'profileSubtitle' => $navUser?->email ?? '',
|
||||
'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
|
||||
'profileMenuItems' => [
|
||||
['type' => 'link', 'label' => 'QR Settings', 'href' => route('account.settings')],
|
||||
['type' => 'link', 'label' => 'Account Settings', 'href' => ladill_account_url('account-settings')],
|
||||
['type' => 'link', 'label' => 'Overview', 'href' => route('qr.dashboard')],
|
||||
['type' => 'logout', 'label' => 'Logout', 'action' => route('logout')],
|
||||
],
|
||||
'avatarUrl' => $navUser?->avatarUrl(),
|
||||
'initials' => $navInitials !== '' ? $navInitials : 'U',
|
||||
])
|
||||
@@ -207,19 +212,19 @@ document.addEventListener('alpine:init', () => {
|
||||
return this.needsTopupFor(this.requiresPayment);
|
||||
},
|
||||
openTopup() {
|
||||
// Prefer the in-app two-step add-funds modal; only fall back to the
|
||||
// central wallet page when no modal is wired on this view.
|
||||
if (this.modalId) {
|
||||
window.dispatchEvent(new CustomEvent('open-modal', {
|
||||
detail: this.modalId,
|
||||
bubbles: true,
|
||||
}));
|
||||
if (this.topupUrl) {
|
||||
window.location.href = this.topupUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.topupUrl) {
|
||||
window.location.href = this.topupUrl;
|
||||
if (! this.modalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('open-modal', {
|
||||
detail: this.modalId,
|
||||
bubbles: true,
|
||||
}));
|
||||
},
|
||||
goOrTopup(event, href = null, requiresPayment = null) {
|
||||
const paymentRequired = requiresPayment === null
|
||||
@@ -254,6 +259,14 @@ document.addEventListener('alpine:init', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
// Global "/" shortcut to focus search
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === '/' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName) && !document.activeElement.isContentEditable) {
|
||||
e.preventDefault();
|
||||
const input = document.querySelector('[x-data*="topbarSearch"] input');
|
||||
if (input) input.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@include('partials.afia')
|
||||
@include('partials.sso-keepalive')
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
@if ($handler === 'openAfia')
|
||||
@click="openAfia()"
|
||||
@else
|
||||
@click="window.dispatchEvent(new CustomEvent('afia-open'))"
|
||||
@click="$dispatch('afia-open')"
|
||||
@endif
|
||||
@class([
|
||||
'inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-400 text-sm font-semibold text-white shadow-sm transition hover:opacity-95',
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="flex" :class="m.role === 'user' ? 'justify-end' : 'justify-start'">
|
||||
<div class="max-w-[85%] whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed"
|
||||
:class="m.role === 'user' ? 'bg-indigo-600 text-white rounded-br-md' : 'bg-slate-100 text-slate-700 rounded-bl-md'"
|
||||
x-html="m.text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\*\*([^*]+?)\*\*/g,'<strong>$1</strong>')"></div>
|
||||
x-text="m.text"></div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="loading" class="flex justify-start">
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
@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,27 +10,10 @@
|
||||
config('ladill_launcher.apps', []),
|
||||
fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost
|
||||
));
|
||||
$launcherOverflows = count($launcherApps) > 15;
|
||||
@endphp
|
||||
@if (! empty($launcherApps))
|
||||
<div class="relative" x-data="{
|
||||
appsOpen: false,
|
||||
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()"
|
||||
<div class="relative" x-data="{ appsOpen: false }" @click.outside="appsOpen = false" @keydown.escape.window="appsOpen = false">
|
||||
<button type="button" @click="appsOpen = !appsOpen"
|
||||
@class([
|
||||
'inline-flex items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:bg-slate-50',
|
||||
'p-2' => ! $sidebar,
|
||||
@@ -54,30 +37,16 @@
|
||||
'bottom-full left-0 mb-2' => $sidebar,
|
||||
])>
|
||||
<p class="px-1 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">All apps</p>
|
||||
<div @class(['ladill-launcher-apps-wrap' => $launcherOverflows])>
|
||||
<div
|
||||
class="ladill-launcher-apps"
|
||||
@if ($launcherOverflows) x-ref="appsScroller" @scroll="checkScrollEnd()" @endif
|
||||
>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
@foreach ($launcherApps as $app)
|
||||
<a href="{{ $app['url'] }}"
|
||||
class="flex min-h-[5.25rem] flex-col items-center justify-center gap-1.5 rounded-xl px-2 py-3 text-center transition hover:bg-indigo-50">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-100">
|
||||
<img src="{{ asset('images/launcher-icons/'.$app['icon']) }}?v={{ @filemtime(public_path('images/launcher-icons/'.$app['icon'])) ?: '1' }}" alt="" class="h-5 w-5 object-contain" width="20" height="20">
|
||||
</span>
|
||||
<span class="text-[11px] font-medium leading-tight text-slate-700">{{ $app['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@if ($launcherOverflows)
|
||||
<div class="ladill-launcher-scroll-more" x-show="showScrollMore" x-cloak aria-hidden="true">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
@endif
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
@foreach ($launcherApps as $app)
|
||||
<a href="{{ $app['url'] }}"
|
||||
class="flex flex-col items-center gap-1.5 rounded-xl px-2 py-3 text-center transition hover:bg-indigo-50">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-100">
|
||||
<img src="{{ asset('images/launcher-icons/'.$app['icon']) }}?v={{ @filemtime(public_path('images/launcher-icons/'.$app['icon'])) ?: '1' }}" alt="" class="h-5 w-5 object-contain" width="20" height="20">
|
||||
</span>
|
||||
<span class="text-[11px] font-medium leading-tight text-slate-700">{{ $app['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
@php
|
||||
$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',
|
||||
};
|
||||
$gridCols = !empty($centerCompose) ? 'grid-cols-5' : 'grid-cols-4';
|
||||
$avatarUrl = $avatarUrl ?? null;
|
||||
$initials = $initials ?? 'U';
|
||||
$notificationsUrl = $notificationsUrl ?? '#';
|
||||
@@ -27,13 +23,11 @@
|
||||
<span class="text-[10px] font-medium">Home</span>
|
||||
</a>
|
||||
|
||||
@if ($showSearch)
|
||||
<a href="{{ $searchUrl }}"
|
||||
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($searchActive ?? false) }}">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||
<span class="text-[10px] font-medium">Search</span>
|
||||
</a>
|
||||
@endif
|
||||
<a href="{{ $searchUrl }}"
|
||||
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($searchActive ?? false) }}">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||
<span class="text-[10px] font-medium">Search</span>
|
||||
</a>
|
||||
|
||||
@if (!empty($centerCompose))
|
||||
<div class="flex items-center justify-center">
|
||||
@@ -57,14 +51,12 @@
|
||||
},
|
||||
}"
|
||||
@endif
|
||||
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($notificationsActive ?? false) }}">
|
||||
<span class="relative inline-flex shrink-0">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
|
||||
@if ($unreadUrl)
|
||||
<span x-show="unread > 0" x-cloak x-text="unread > 9 ? '9+' : unread"
|
||||
class="absolute -right-1 -top-1 inline-flex min-w-5 items-center justify-center rounded-full bg-rose-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"></span>
|
||||
@endif
|
||||
</span>
|
||||
class="relative flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($notificationsActive ?? false) }}">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
|
||||
@if ($unreadUrl)
|
||||
<span x-show="unread > 0" x-cloak x-text="unread > 9 ? '9+' : unread"
|
||||
class="absolute right-3 top-1.5 inline-flex min-w-4 items-center justify-center rounded-full bg-rose-500 px-1 py-0.5 text-[9px] font-semibold text-white"></span>
|
||||
@endif
|
||||
<span class="text-[10px] font-medium">Notifications</span>
|
||||
</a>
|
||||
|
||||
@@ -90,7 +82,6 @@
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-[1px]"></div>
|
||||
|
||||
<div x-show="profileOpen"
|
||||
x-effect="profileOpen && window.dispatchEvent(new CustomEvent('wallet-balance-refresh'))"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="translate-y-full"
|
||||
x-transition:enter-end="translate-y-0"
|
||||
@@ -122,11 +113,25 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@include('partials.user-profile-menu', [
|
||||
'items' => $profileMenuItems,
|
||||
'variant' => 'sheet',
|
||||
'onNavigate' => 'profileOpen = false',
|
||||
])
|
||||
<div class="space-y-1 p-2 pb-4">
|
||||
@foreach ($profileMenuItems as $item)
|
||||
@if (($item['type'] ?? 'link') === 'link')
|
||||
<a href="{{ $item['href'] }}"
|
||||
@click="profileOpen = false"
|
||||
class="block rounded-xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-100">
|
||||
{{ $item['label'] }}
|
||||
</a>
|
||||
@elseif (($item['type'] ?? '') === 'logout')
|
||||
<form method="POST" action="{{ $item['action'] }}" class="border-t border-slate-100 pt-2">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="w-full rounded-xl px-4 py-3 text-left text-sm font-medium text-rose-600 transition hover:bg-rose-50">
|
||||
{{ $item['label'] }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
@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 }}>
|
||||
@@ -1,33 +0,0 @@
|
||||
@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,635 +1,54 @@
|
||||
{{--
|
||||
Payment checkout shell.
|
||||
|
||||
Audiences:
|
||||
- buyer (default): public ticket buyers, donors, shoppers — self-serve checkout
|
||||
- operator: signed-in staff flows such as wallet top-up
|
||||
|
||||
Optional overrides: $sheetTitle, $sheetSubtitle, $sheetFooter, $sheetAria, $iframeTitle
|
||||
Requires Alpine ancestor with:
|
||||
- showSheet (bool)
|
||||
- checkoutUrl (string) — authorization / waiting URL
|
||||
- accessCode (string, optional) — Paystack initialize access_code for Inline
|
||||
- publicKey (string, optional)
|
||||
- returnUrl (string, optional) — where to go after Inline onSuccess (?reference=)
|
||||
|
||||
checkout.paystack.com cannot be iframed (X-Frame-Options: SAMEORIGIN).
|
||||
|
||||
Paystack Inline owns its secure payment popup. We do not place a Ladill
|
||||
loading sheet in front of it: that created a needless first screen before
|
||||
the checkout appeared. The Ladill bottomsheet/modal is reserved for
|
||||
same-origin waiting pages (such as MoMo) and recoverable errors.
|
||||
Paystack mobile bottom-sheet iframe.
|
||||
Requires Alpine.js ancestor with: showSheet (bool), checkoutUrl (string).
|
||||
On mobile (< md) the sheet slides up; on desktop nothing renders.
|
||||
--}}
|
||||
@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 }}">
|
||||
<div x-show="showSheet"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-[9999] flex flex-col justify-end md:hidden"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
|
||||
{{--
|
||||
Ladill bottomsheet (mobile) / modal (desktop) — ONLY for:
|
||||
- same-origin waiting pages (MoMo etc.)
|
||||
- recoverable errors
|
||||
Paystack Inline opens its own secure payment popup; this shell stays
|
||||
hidden during launch so nothing flashes in front of it.
|
||||
--}}
|
||||
<div x-show="$store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error)"
|
||||
x-cloak
|
||||
class="pointer-events-auto absolute inset-0 flex items-end justify-center md:items-center md:p-6"
|
||||
data-ladill-pay-shell>
|
||||
<div class="absolute inset-0 bg-black/60"
|
||||
data-ladill-pay-backdrop
|
||||
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()">
|
||||
{{-- Backdrop --}}
|
||||
<div class="absolute inset-0 bg-black/60"
|
||||
x-show="showSheet"
|
||||
x-transition:enter="transition-opacity duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition-opacity duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@click="showSheet = false">
|
||||
</div>
|
||||
|
||||
{{-- Sheet --}}
|
||||
<div class="relative flex flex-col bg-white rounded-t-2xl overflow-hidden"
|
||||
style="height:90dvh"
|
||||
x-show="showSheet"
|
||||
x-transition:enter="transition-transform duration-300 ease-out"
|
||||
x-transition:enter-start="translate-y-full"
|
||||
x-transition:enter-end="translate-y-0"
|
||||
x-transition:leave="transition-transform duration-200 ease-in"
|
||||
x-transition:leave-start="translate-y-0"
|
||||
x-transition:leave-end="translate-y-full">
|
||||
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<div class="absolute left-1/2 top-2 h-1 w-10 -translate-x-1/2 rounded-full bg-slate-200"></div>
|
||||
<p class="text-sm font-semibold text-slate-800">Complete Payment</p>
|
||||
<button @click="showSheet = false"
|
||||
class="rounded-full p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
|
||||
aria-label="Close">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div data-ladill-pay-panel
|
||||
data-paystack-live="0"
|
||||
class="relative flex w-full flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl md:max-w-lg md:rounded-2xl"
|
||||
style="height: min(90dvh, 720px); max-height: 90dvh; padding-bottom: env(safe-area-inset-bottom, 0px)"
|
||||
x-transition:enter="transition duration-300 ease-out md:duration-200"
|
||||
x-transition:enter-start="translate-y-full opacity-0 md:translate-y-0 md:scale-95"
|
||||
x-transition:enter-end="translate-y-0 opacity-100 md:scale-100"
|
||||
x-transition:leave="transition duration-200 ease-in md:duration-150"
|
||||
x-transition:leave-start="translate-y-0 opacity-100 md:scale-100"
|
||||
x-transition:leave-end="translate-y-full opacity-0 md:translate-y-0 md:scale-95"
|
||||
@click.stop>
|
||||
|
||||
<div class="relative flex shrink-0 flex-col gap-0.5 border-b border-slate-100 px-4 pb-3 pt-5 md:px-5 md:pt-3"
|
||||
style="padding-top: max(1.25rem, env(safe-area-inset-top, 0px))"
|
||||
data-ladill-pay-header>
|
||||
<div class="absolute left-1/2 top-2 h-1 w-10 -translate-x-1/2 rounded-full bg-slate-200 md:hidden" data-ladill-pay-handle aria-hidden="true"></div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-slate-800">{{ $sheetTitle }}</p>
|
||||
@if (! empty($sheetSubtitle))
|
||||
<p class="mt-0.5 text-xs leading-snug text-slate-500">{{ $sheetSubtitle }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<button type="button"
|
||||
x-ref="checkoutClose"
|
||||
x-init="
|
||||
$watch(
|
||||
() => !!(showSheet && $store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error)),
|
||||
(open) => { if (open) $nextTick(() => $refs.checkoutClose?.focus()); }
|
||||
)
|
||||
"
|
||||
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()"
|
||||
class="shrink-0 rounded-full p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
|
||||
aria-label="Close">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{-- Markers kept for tests / callers that query live close chrome. --}}
|
||||
<div class="sr-only" data-ladill-pay-header-minimal data-ladill-pay-close-live aria-hidden="true"></div>
|
||||
<div class="sr-only" data-ladill-pay-mount data-ladill-pay-body aria-hidden="true"></div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<iframe x-show="$store.ladillPayCheckout && $store.ladillPayCheckout.frameable"
|
||||
:src="showSheet && $store.ladillPayCheckout && $store.ladillPayCheckout.frameable ? checkoutUrl : ''"
|
||||
class="h-full min-h-[60dvh] w-full border-0 md:min-h-0"
|
||||
style="min-height: 28rem"
|
||||
allow="payment *; fullscreen *; clipboard-read *; clipboard-write *"
|
||||
title="{{ $iframeTitle }}"></iframe>
|
||||
|
||||
<div x-show="$store.ladillPayCheckout && $store.ladillPayCheckout.error"
|
||||
class="flex min-h-[40dvh] flex-col items-center justify-center gap-4 px-6 py-10 text-center md:min-h-[20rem] md:px-8 md:py-12"
|
||||
data-ladill-pay-error>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-50 text-red-500" aria-hidden="true">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="max-w-xs space-y-1 md:max-w-sm">
|
||||
<p class="text-sm font-semibold text-slate-800" data-ladill-pay-status-title>Checkout unavailable</p>
|
||||
<p class="text-xs leading-snug text-slate-500" x-text="$store.ladillPayCheckout.error"></p>
|
||||
</div>
|
||||
<button type="button"
|
||||
data-ladill-pay-retry
|
||||
@click="window.LadillPayCheckout?.ensureStore()?.sync(true, (typeof checkoutUrl==='undefined'||checkoutUrl===null)?'':String(checkoutUrl), (typeof accessCode==='undefined'||accessCode===null)?'':String(accessCode), (typeof returnUrl==='undefined'||returnUrl===null)?'':String(returnUrl))"
|
||||
class="inline-flex w-full max-w-xs items-center justify-center rounded-xl bg-indigo-600 px-4 py-3 text-sm font-semibold text-white hover:bg-indigo-700">
|
||||
Try again
|
||||
</button>
|
||||
<button type="button"
|
||||
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()"
|
||||
class="text-xs font-medium text-slate-500 hover:text-slate-700">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (! empty($sheetFooter))
|
||||
<p class="shrink-0 border-t border-slate-100 bg-slate-50 px-4 py-2 text-center text-[11px] text-slate-500 md:px-5"
|
||||
data-ladill-pay-footer>
|
||||
{{ $sheetFooter }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
<iframe :src="showSheet ? checkoutUrl : ''"
|
||||
class="flex-1 w-full border-0"
|
||||
allow="payment"
|
||||
title="Paystack checkout"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="px-4 pb-3">
|
||||
<div class="relative">
|
||||
<svg class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||
<input data-ladill-search-input x-ref="input"
|
||||
<input x-ref="input"
|
||||
type="text"
|
||||
x-model="query"
|
||||
@focus="onFocus()"
|
||||
@@ -34,7 +34,7 @@
|
||||
<h1 class="text-2xl font-semibold text-slate-900">{{ $heading }}</h1>
|
||||
<div class="relative mt-3">
|
||||
<svg class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||
<input data-ladill-search-input x-ref="input"
|
||||
<input x-ref="input"
|
||||
type="text"
|
||||
x-model="query"
|
||||
@focus="onFocus()"
|
||||
|
||||
@@ -10,10 +10,16 @@
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12 11.2 3.05c.44-.44 1.15-.44 1.59 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75H9.75v-6a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v6h4.5a.75.75 0 0 0 .75-.75V9.75" />'],
|
||||
['name' => 'My Codes', 'route' => route('user.qr-codes.index'), 'active' => request()->routeIs('user.qr-codes.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 6.75h.75v.75h-.75v-.75ZM6.75 16.5h.75v.75h-.75v-.75ZM16.5 6.75h.75v.75h-.75v-.75ZM13.5 13.5h.75v.75h-.75v-.75ZM16.5 16.5h.75v.75h-.75v-.75ZM13.5 19.5h.75v.75h-.75v-.75ZM19.5 13.5h.75v.75h-.75v-.75ZM19.5 19.5h.75v.75h-.75v-.75ZM22.5 18.75a2.25 2.25 0 0 0-2.25-2.25h-1.5a2.25 2.25 0 0 0-2.25 2.25v1.5a2.25 2.25 0 0 0 2.25 2.25h1.5a2.25 2.25 0 0 0 2.25-2.25v-1.5Z" />'],
|
||||
['name' => 'Campaigns', 'route' => route('qr.campaigns.index'), 'active' => request()->routeIs('qr.campaigns.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 1 1 0-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 0 1-1.44-4.282m3.102.069a18.03 18.03 0 0 1-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 0 1 8.789 2.553c.56.246 1.198-.004 1.434-.56a11.958 11.958 0 0 0 1.15-4.506c.02-.292.02-.585 0-.877a11.958 11.958 0 0 0-1.15-4.506c-.236-.556-.874-.806-1.434-.56a23.848 23.848 0 0 1-8.789 2.553m0 0a23.948 23.948 0 0 0 0 5.634" />'],
|
||||
['name' => 'Analytics', 'route' => route('qr.analytics.index'), 'active' => request()->routeIs('qr.analytics.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />'],
|
||||
];
|
||||
$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 4.5 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" />'],
|
||||
['name' => 'Developers', 'route' => route('account.developers'), 'active' => request()->routeIs('account.developers*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />'],
|
||||
];
|
||||
@endphp
|
||||
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||
@@ -23,6 +29,14 @@
|
||||
<span>{{ $item['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
<p class="px-3 pb-1 pt-5 text-[10px] font-bold uppercase tracking-widest text-slate-400">Account</p>
|
||||
@foreach($accountNav as $item)
|
||||
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||
<svg class="h-[18px] w-[18px] shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">{!! $item['icon'] !!}</svg>
|
||||
<span>{{ $item['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@include('partials.sidebar-support')
|
||||
</nav>
|
||||
|
||||
<div class="shrink-0 border-t border-slate-100 px-3 py-3">
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
@if (auth()->check())
|
||||
@php
|
||||
$authPing = 'https://'.config('app.auth_domain').'/sso/ping';
|
||||
$platformSignedOutUrl = route('sso.platform-signed-out', ['redirect' => url()->current()]);
|
||||
@endphp
|
||||
@php $authPing = 'https://'.config('app.auth_domain').'/sso/ping'; @endphp
|
||||
{{-- Same-site pings keep the shared auth.ladill.com session warm while using this app. --}}
|
||||
<iframe src="{{ $authPing }}" hidden width="0" height="0" title=""></iframe>
|
||||
<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(() => {});
|
||||
const ping = () => fetch(pingUrl, { credentials: 'include', mode: 'no-cors' }).catch(() => {});
|
||||
ping();
|
||||
setInterval(ping, 5 * 60 * 1000);
|
||||
})();
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{{-- 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
|
||||
@@ -1,45 +0,0 @@
|
||||
{{--
|
||||
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>
|
||||
@@ -19,7 +19,7 @@
|
||||
@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.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 data-ladill-search-input type="text"
|
||||
<input type="text"
|
||||
x-model="query"
|
||||
@focus="onFocus()"
|
||||
@input.debounce.200ms="search()"
|
||||
@@ -61,15 +61,128 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
@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
|
||||
|
||||
<div class="relative hidden lg:block"
|
||||
x-data="notificationDropdown({
|
||||
unreadUrl: {{ \Illuminate\Support\Js::from(route('notifications.unread')) }},
|
||||
markReadUrl: {{ \Illuminate\Support\Js::from(route('notifications.mark-read', ['id' => '__ID__'])) }},
|
||||
markAllReadUrl: {{ \Illuminate\Support\Js::from(route('notifications.mark-all-read')) }},
|
||||
indexUrl: {{ \Illuminate\Support\Js::from(route('notifications.index')) }},
|
||||
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
||||
})"
|
||||
@click.outside="open = false">
|
||||
<button type="button"
|
||||
@click="toggle()"
|
||||
class="relative inline-flex items-center justify-center rounded-full border border-slate-200 p-2 text-slate-600 transition hover:bg-slate-50"
|
||||
aria-label="Notifications">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" 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>
|
||||
<span x-show="unreadCount > 0"
|
||||
x-cloak
|
||||
x-text="unreadCount > 9 ? '9+' : unreadCount"
|
||||
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>
|
||||
</button>
|
||||
|
||||
<div x-show="open"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="absolute right-0 z-50 mt-2 w-80 origin-top-right rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Notifications</h3>
|
||||
<button type="button"
|
||||
x-show="unreadCount > 0"
|
||||
@click="markAllRead()"
|
||||
class="text-xs font-medium text-indigo-600 hover:text-indigo-700">
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
<template x-if="loading">
|
||||
<div class="px-4 py-6 text-center text-xs text-slate-400">Loading…</div>
|
||||
</template>
|
||||
<template x-if="!loading && notifications.length === 0">
|
||||
<div class="px-4 py-8 text-center text-sm text-slate-500">No notifications yet</div>
|
||||
</template>
|
||||
<template x-if="!loading && notifications.length > 0">
|
||||
<div class="divide-y divide-slate-100">
|
||||
<template x-for="notification in notifications" :key="notification.id">
|
||||
<a :href="notification.url || '#'"
|
||||
@click="markRead(notification.id)"
|
||||
class="flex items-start gap-3 px-4 py-3 transition hover:bg-slate-50">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-slate-900" x-text="notification.title"></p>
|
||||
<p class="line-clamp-2 text-xs text-slate-500" x-text="notification.message"></p>
|
||||
<p class="mt-1 text-[11px] text-slate-400" x-text="notification.created_at"></p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-100 px-4 py-2.5">
|
||||
<a :href="indexUrl" class="block text-center text-xs font-medium text-slate-600 hover:text-slate-900">
|
||||
View all notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative hidden lg:block" x-data="{ profileOpen: false }" @click.outside="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 hover:bg-slate-50">
|
||||
@if ($user?->avatarUrl())
|
||||
<img src="{{ $user->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 @click.outside="profileOpen = 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="{{ route('account.settings') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">QR Settings</a>
|
||||
<a href="{{ ladill_account_url('account-settings') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Account Settings</a>
|
||||
<a href="{{ route('qr.dashboard') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Dashboard</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>
|
||||
@auth
|
||||
@includeIf('partials.topbar-account-switcher')
|
||||
@includeIf('partials.topbar-widgets-prepend')
|
||||
@include('partials.topbar-desktop-widgets', ['user' => $user ?? $u ?? auth()->user(), 'showUser' => true])
|
||||
@includeIf('partials.topbar-widgets-append')
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="btn-primary">Sign in</a>
|
||||
@include('partials.launcher')
|
||||
@include('partials.afia-button', ['compact' => true])
|
||||
@endauth
|
||||
|
||||
@include('partials.launcher')
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{{-- Mobile cart icon (after avatar controls). --}}
|
||||
@if (! empty($cartRoute))
|
||||
@include('partials.mobile-header-cart', [
|
||||
'cartUrl' => $cartRoute,
|
||||
'cartCount' => $cartCount ?? 0,
|
||||
])
|
||||
@endif
|
||||
@@ -1,10 +0,0 @@
|
||||
{{-- Desktop cart pill (between launcher and avatar divider). --}}
|
||||
@if (! empty($cartRoute))
|
||||
<a href="{{ $cartRoute }}"
|
||||
class="hidden items-center gap-2 rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50 lg:inline-flex">
|
||||
<span>Cart</span>
|
||||
@if (($cartCount ?? 0) > 0)
|
||||
<span class="inline-flex min-w-5 items-center justify-center rounded-full bg-slate-900 px-1.5 py-0.5 text-[11px] font-semibold text-white">{{ $cartCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
@endif
|
||||
@@ -36,23 +36,491 @@
|
||||
@endphp
|
||||
|
||||
<header class="flex items-center justify-between h-16 border-b border-slate-200 bg-white px-6"
|
||||
>
|
||||
x-data="afiaChat({
|
||||
chatUrl: {{ \Illuminate\Support\Js::from(route('user.ai.chat')) }},
|
||||
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
||||
currentPage: {{ \Illuminate\Support\Js::from(request()->path()) }},
|
||||
})"
|
||||
@keydown.escape.window="handleEscape()">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden shrink-0 text-slate-500 hover:text-slate-700">
|
||||
<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>
|
||||
|
||||
{{-- Search Bar --}}
|
||||
<div class="relative hidden w-full max-w-md lg:block" x-data="topbarSearch()" @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.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 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 domains, hosting, email, SMTP…"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 py-2 pl-9 pr-10 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">
|
||||
<kbd class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 rounded-md border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-400 sm:inline-block">/</kbd>
|
||||
</div>
|
||||
|
||||
{{-- Results dropdown --}}
|
||||
<div x-show="open && (results.length > 0 || (query.length >= 2 && !loading))"
|
||||
x-cloak
|
||||
x-transition.opacity.duration.150ms
|
||||
class="absolute left-0 top-full z-30 mt-1.5 w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
<template x-if="loading">
|
||||
<div class="px-4 py-3 text-center text-xs text-slate-400">Searching…</div>
|
||||
</template>
|
||||
<template x-if="!loading && results.length === 0 && query.length >= 2">
|
||||
<div class="px-4 py-3 text-center text-xs text-slate-500">No results for "<span x-text="query" class="font-medium"></span>"</div>
|
||||
</template>
|
||||
<template x-if="!loading && results.length > 0">
|
||||
<ul class="max-h-72 overflow-y-auto py-1">
|
||||
<template x-for="(item, idx) in results" :key="item.url">
|
||||
<li>
|
||||
<a :href="item.url"
|
||||
:class="idx === active ? 'bg-indigo-50 text-indigo-700' : 'text-slate-700'"
|
||||
@mouseenter="active = idx"
|
||||
class="flex items-center gap-3 px-4 py-2.5 text-sm transition hover:bg-indigo-50">
|
||||
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-600': item.type === 'domain',
|
||||
'bg-teal-100 text-teal-600': item.type === 'hosting',
|
||||
'bg-sky-100 text-sky-600': item.type === 'email',
|
||||
'bg-violet-100 text-violet-600': item.type === 'smtp',
|
||||
'bg-amber-100 text-amber-600': item.type === 'ticket',
|
||||
'bg-orange-100 text-orange-600': item.type === 'order',
|
||||
'bg-slate-100 text-slate-600': !['domain','hosting','email','smtp','ticket','order'].includes(item.type),
|
||||
}">
|
||||
<template x-if="item.type === 'domain'">
|
||||
<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="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"/></svg>
|
||||
</template>
|
||||
<template x-if="item.type !== 'domain'">
|
||||
<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="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>
|
||||
</template>
|
||||
</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-3">
|
||||
@auth
|
||||
@includeIf('partials.topbar-account-switcher')
|
||||
@includeIf('partials.topbar-widgets-prepend')
|
||||
@include('partials.topbar-desktop-widgets', ['user' => $user ?? $u ?? auth()->user(), 'showUser' => true])
|
||||
@includeIf('partials.topbar-widgets-append')
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="btn-primary">Sign in</a>
|
||||
@include('partials.launcher')
|
||||
@endauth
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative hidden lg:block"
|
||||
x-data="notificationDropdown({
|
||||
unreadUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.unread')) }},
|
||||
markReadUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.mark-read', ['id' => '__ID__'])) }},
|
||||
markAllReadUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.mark-all-read')) }},
|
||||
indexUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.index')) }},
|
||||
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
||||
})"
|
||||
@click.outside="open = false">
|
||||
<button type="button"
|
||||
@click="toggle()"
|
||||
class="relative inline-flex items-center justify-center rounded-full border border-slate-200 p-2 text-slate-600 transition hover:bg-slate-50"
|
||||
aria-label="Notifications">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" 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>
|
||||
<span x-show="unreadCount > 0"
|
||||
x-cloak
|
||||
x-text="unreadCount > 9 ? '9+' : unreadCount"
|
||||
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>
|
||||
</button>
|
||||
|
||||
{{-- Notification dropdown --}}
|
||||
<div x-show="open"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 z-50 mt-2 w-80 origin-top-right rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Notifications</h3>
|
||||
<button type="button"
|
||||
x-show="unreadCount > 0"
|
||||
@click="markAllRead()"
|
||||
class="text-xs font-medium text-indigo-600 hover:text-indigo-700">
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
<template x-if="loading">
|
||||
<div class="px-4 py-6 text-center">
|
||||
<svg class="mx-auto h-5 w-5 animate-spin text-slate-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && notifications.length === 0">
|
||||
<div class="px-4 py-8 text-center">
|
||||
<svg class="mx-auto h-10 w-10 text-slate-300" 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>
|
||||
<p class="mt-2 text-sm text-slate-500">No notifications yet</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && notifications.length > 0">
|
||||
<div class="divide-y divide-slate-100">
|
||||
<template x-for="notification in notifications" :key="notification.id">
|
||||
<a :href="notification.url || '#'"
|
||||
@click="markRead(notification.id)"
|
||||
class="flex items-start gap-3 px-4 py-3 transition hover:bg-slate-50">
|
||||
<span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
:class="getIconBg(notification.icon)">
|
||||
<template x-if="notification.icon === 'website'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3M4.5 19.5h15a2.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 6.75v10.5A2.25 2.25 0 0 0 4.5 19.5Z"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'domain'">
|
||||
<span class="inline-flex" :class="getIconColor(notification.icon)">
|
||||
{!! \App\Support\DomainGlobeIcon::svg('h-4 w-4') !!}
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'hosting'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" 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>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'email'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'billing'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><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 4.5 19.5Z"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'lead'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'success'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
|
||||
</template>
|
||||
<template x-if="!['website','domain','hosting','email','billing','lead','success'].includes(notification.icon)">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" 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>
|
||||
</template>
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-slate-900 truncate" x-text="notification.title"></p>
|
||||
<p class="text-xs text-slate-500 line-clamp-2" x-text="notification.message"></p>
|
||||
<p class="mt-1 text-[11px] text-slate-400" x-text="notification.created_at"></p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-100 px-4 py-2.5">
|
||||
<a :href="indexUrl" class="block text-center text-xs font-medium text-slate-600 hover:text-slate-900">
|
||||
View all notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden h-8 w-px bg-slate-200 lg:block"></div>
|
||||
|
||||
<a href="{{ $cartRoute }}"
|
||||
class="hidden items-center gap-2 rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50 lg:inline-flex">
|
||||
<span>Cart</span>
|
||||
@if ($cartCount > 0)
|
||||
<span class="inline-flex min-w-5 items-center justify-center rounded-full bg-slate-900 px-1.5 py-0.5 text-[11px] font-semibold text-white">{{ $cartCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
<div class="relative hidden lg:block">
|
||||
<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 hover:bg-slate-50">
|
||||
@if ($user?->avatarUrl())
|
||||
<img src="{{ $user->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 @click.outside="profileOpen = 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="{{ route('profile.edit') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Profile</a>
|
||||
<a href="{{ route('account.settings') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Account Settings</a>
|
||||
<a href="{{ route('qr.dashboard') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Dashboard</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.mobile-header-cart', [
|
||||
'cartUrl' => $cartRoute,
|
||||
'cartCount' => $cartCount,
|
||||
])
|
||||
|
||||
<button type="button"
|
||||
@click="openAfia()"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-400 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:opacity-95">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2.25c.414 0 .75.336.75.75a5.25 5.25 0 0 0 5.25 5.25.75.75 0 0 1 0 1.5A5.25 5.25 0 0 0 12.75 15a.75.75 0 0 1-1.5 0A5.25 5.25 0 0 0 6 9.75a.75.75 0 0 1 0-1.5A5.25 5.25 0 0 0 11.25 3a.75.75 0 0 1 .75-.75Zm6.75 11.25a.75.75 0 0 1 .75.75A2.25 2.25 0 0 0 21.75 16.5a.75.75 0 0 1 0 1.5A2.25 2.25 0 0 0 19.5 20.25a.75.75 0 0 1-1.5 0A2.25 2.25 0 0 0 15.75 18a.75.75 0 0 1 0-1.5A2.25 2.25 0 0 0 18 14.25a.75.75 0 0 1 .75-.75ZM5.25 14.25a.75.75 0 0 1 .75.75 3 3 0 0 0 3 3 .75.75 0 0 1 0 1.5 3 3 0 0 0-3 3 .75.75 0 0 1-1.5 0 3 3 0 0 0-3-3 .75.75 0 0 1 0-1.5 3 3 0 0 0 3-3 .75.75 0 0 1 .75-.75Z"/>
|
||||
</svg>
|
||||
<span>AI</span>
|
||||
</button>
|
||||
|
||||
{{-- All-apps launcher (shared, config-driven; see resources/views/partials/launcher) --}}
|
||||
@include('partials.launcher')
|
||||
</div>
|
||||
|
||||
{{-- Afia backdrop --}}
|
||||
<div x-show="afiaOpen"
|
||||
style="display: none;"
|
||||
x-transition.opacity.duration.200ms
|
||||
class="fixed inset-0 z-40 bg-black/20 backdrop-blur-[2px]"
|
||||
@click="closeAfia()"></div>
|
||||
|
||||
{{-- Afia slide-over panel --}}
|
||||
<section x-show="afiaOpen"
|
||||
style="display: none;"
|
||||
x-transition:enter="transition transform ease-out duration-250"
|
||||
x-transition:enter-start="translate-x-full"
|
||||
x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transition transform ease-in duration-200"
|
||||
x-transition:leave-start="translate-x-0"
|
||||
x-transition:leave-end="translate-x-full"
|
||||
class="fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col bg-white shadow-2xl sm:rounded-l-2xl">
|
||||
|
||||
{{-- Panel header --}}
|
||||
<div class="flex items-center justify-between px-5 py-3.5">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="relative flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 36 36" class="h-8 w-8" aria-hidden="true">
|
||||
<defs>
|
||||
<radialGradient id="afia-orb-h" cx="40%" cy="35%" r="60%">
|
||||
<stop offset="0%" stop-color="#a5b4fc"/>
|
||||
<stop offset="50%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#3730a3"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="18" cy="18" r="14" fill="url(#afia-orb-h)">
|
||||
<animate attributeName="r" values="14;14.8;14" dur="3s" ease="ease-in-out" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="18" cy="18" r="11" fill="none" stroke="#c7d2fe" stroke-width=".6" opacity=".5">
|
||||
<animate attributeName="r" values="11;12.5;11" dur="4s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".5;.15;.5" dur="4s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="18" cy="18" r="4" fill="white" opacity=".25">
|
||||
<animate attributeName="r" values="4;5.5;4" dur="2.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".25;.1;.25" dur="2.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle r="1.2" fill="#c7d2fe" opacity=".8">
|
||||
<animateMotion dur="3s" repeatCount="indefinite" path="M18,8 A10,10 0 1,1 17.99,8" rotate="auto"/>
|
||||
</circle>
|
||||
<circle r=".8" fill="#e0e7ff" opacity=".6">
|
||||
<animateMotion dur="4.5s" repeatCount="indefinite" path="M18,6 A12,12 0 1,0 18.01,6" rotate="auto"/>
|
||||
</circle>
|
||||
</svg>
|
||||
<span class="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-emerald-400"></span>
|
||||
</span>
|
||||
<div class="leading-tight">
|
||||
<p class="text-sm font-semibold text-slate-900">Afia</p>
|
||||
<p class="text-[11px] text-slate-400">Online</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<a href="{{ route('user.ai.index') }}"
|
||||
class="rounded-lg px-2.5 py-1.5 text-[11px] font-semibold text-slate-500 transition hover:bg-slate-100 hover:text-slate-700">
|
||||
History
|
||||
</a>
|
||||
<button type="button"
|
||||
@click="closeAfia()"
|
||||
class="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
|
||||
aria-label="Close">
|
||||
<svg class="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-slate-100"></div>
|
||||
|
||||
{{-- Chat body --}}
|
||||
<div class="flex-1 overflow-y-auto" x-ref="afiaScroll">
|
||||
{{-- Welcome card (shown only before conversation starts) --}}
|
||||
<div x-show="afiaMessages.length <= 1" class="px-5 py-8">
|
||||
<div class="text-center">
|
||||
<span class="mx-auto flex h-20 w-20 items-center justify-center">
|
||||
<svg viewBox="0 0 80 80" class="h-20 w-20" aria-hidden="true">
|
||||
<defs>
|
||||
<radialGradient id="afia-orb-w" cx="38%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#c7d2fe"/>
|
||||
<stop offset="40%" stop-color="#818cf8"/>
|
||||
<stop offset="100%" stop-color="#3730a3"/>
|
||||
</radialGradient>
|
||||
<filter id="afia-glow-w">
|
||||
<feGaussianBlur stdDeviation="3" result="b"/>
|
||||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="40" cy="40" r="28" fill="url(#afia-orb-w)" filter="url(#afia-glow-w)">
|
||||
<animate attributeName="r" values="28;30;28" dur="3s" ease="ease-in-out" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="40" cy="40" r="22" fill="none" stroke="#e0e7ff" stroke-width=".7" opacity=".4">
|
||||
<animate attributeName="r" values="22;25;22" dur="4s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".4;.1;.4" dur="4s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="40" cy="40" r="34" fill="none" stroke="#c7d2fe" stroke-width=".4" opacity=".25">
|
||||
<animate attributeName="r" values="34;37;34" dur="5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".25;.05;.25" dur="5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="40" cy="40" r="8" fill="white" opacity=".18">
|
||||
<animate attributeName="r" values="8;11;8" dur="2.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".18;.06;.18" dur="2.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle r="2" fill="#c7d2fe" opacity=".9">
|
||||
<animateMotion dur="3s" repeatCount="indefinite" path="M40,14 A26,26 0 1,1 39.99,14" rotate="auto"/>
|
||||
</circle>
|
||||
<circle r="1.5" fill="#e0e7ff" opacity=".7">
|
||||
<animateMotion dur="5s" repeatCount="indefinite" path="M40,10 A30,30 0 1,0 40.01,10" rotate="auto"/>
|
||||
</circle>
|
||||
<circle r="1" fill="#a5b4fc" opacity=".5">
|
||||
<animateMotion dur="7s" repeatCount="indefinite" path="M40,6 A34,34 0 1,1 39.99,6" rotate="auto"/>
|
||||
</circle>
|
||||
</svg>
|
||||
</span>
|
||||
<h3 class="mt-5 text-xl font-semibold text-slate-900">How can I help you?</h3>
|
||||
<p class="mt-1.5 text-sm text-slate-400">I can help with websites, domains, hosting, email, and more.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-2">
|
||||
<template x-for="suggestion in afiaSuggestions" :key="suggestion">
|
||||
<button type="button"
|
||||
@click="useSuggestion(suggestion)"
|
||||
:disabled="afiaLoading"
|
||||
class="group flex w-full items-center gap-3 rounded-xl border border-slate-150 bg-white px-4 py-3 text-left text-[13px] font-medium text-slate-700 shadow-sm transition hover:border-indigo-200 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-500 transition group-hover:bg-indigo-100">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5"/></svg>
|
||||
</span>
|
||||
<span x-text="suggestion"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Messages --}}
|
||||
<div x-show="afiaMessages.length > 1" class="space-y-1 px-4 py-4">
|
||||
<template x-for="(message, idx) in afiaMessages" :key="idx">
|
||||
<div class="flex gap-2.5 py-1.5" :class="message.role === 'user' ? 'flex-row-reverse' : ''">
|
||||
{{-- Avatar --}}
|
||||
<template x-if="message.role === 'assistant'">
|
||||
<span class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" class="h-6 w-6" aria-hidden="true">
|
||||
<defs>
|
||||
<radialGradient id="afia-orb-m" cx="40%" cy="35%" r="60%">
|
||||
<stop offset="0%" stop-color="#a5b4fc"/>
|
||||
<stop offset="50%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#3730a3"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="12" cy="12" r="10" fill="url(#afia-orb-m)">
|
||||
<animate attributeName="r" values="10;10.6;10" dur="3s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="12" cy="12" r="3" fill="white" opacity=".2">
|
||||
<animate attributeName="r" values="3;4;3" dur="2.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".2;.08;.2" dur="2.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle r=".8" fill="#c7d2fe" opacity=".8">
|
||||
<animateMotion dur="3s" repeatCount="indefinite" path="M12,4 A8,8 0 1,1 11.99,4"/>
|
||||
</circle>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
{{-- Bubble --}}
|
||||
<div class="max-w-[82%] rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed"
|
||||
:class="message.role === 'user'
|
||||
? 'bg-slate-900 text-white rounded-br-md'
|
||||
: 'bg-slate-50 text-slate-700 rounded-bl-md'">
|
||||
<p class="whitespace-pre-line" x-text="message.text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Typing indicator --}}
|
||||
<div x-show="afiaLoading" class="flex gap-2.5 py-1.5">
|
||||
<span class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" class="h-6 w-6" aria-hidden="true">
|
||||
<defs>
|
||||
<radialGradient id="afia-orb-t" cx="40%" cy="35%" r="60%">
|
||||
<stop offset="0%" stop-color="#a5b4fc"/>
|
||||
<stop offset="50%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#3730a3"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="12" cy="12" r="10" fill="url(#afia-orb-t)">
|
||||
<animate attributeName="r" values="10;10.6;10" dur="3s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="12" cy="12" r="3" fill="white" opacity=".2">
|
||||
<animate attributeName="r" values="3;4;3" dur="2.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".2;.08;.2" dur="2.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle r=".8" fill="#c7d2fe" opacity=".8">
|
||||
<animateMotion dur="3s" repeatCount="indefinite" path="M12,4 A8,8 0 1,1 11.99,4"/>
|
||||
</circle>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="rounded-2xl rounded-bl-md bg-slate-50 px-4 py-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay: 0ms"></span>
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay: 150ms"></span>
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay: 300ms"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Error banner --}}
|
||||
<div x-show="afiaError" x-cloak class="mx-4 mb-2 rounded-lg bg-rose-50 px-3 py-2 text-xs text-rose-600" x-text="afiaError"></div>
|
||||
|
||||
{{-- Input area --}}
|
||||
<div class="border-t border-slate-100 bg-white px-4 pb-4 pt-3">
|
||||
<form @submit.prevent="sendMessage()" class="flex items-end gap-2">
|
||||
<div class="relative flex-1">
|
||||
<input type="text"
|
||||
x-ref="afiaInput"
|
||||
x-model="afiaInput"
|
||||
:disabled="afiaLoading"
|
||||
placeholder="Message Afia..."
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 pr-10 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100/80">
|
||||
</div>
|
||||
<button type="submit"
|
||||
:disabled="afiaLoading || !afiaInput.trim()"
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-900 text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="Send">
|
||||
<svg x-show="!afiaLoading" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18" />
|
||||
</svg>
|
||||
<svg x-show="afiaLoading" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-2 text-center text-[10px] text-slate-400">Afia can make mistakes. Verify important info.</p>
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
@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>
|
||||
@@ -1,30 +0,0 @@
|
||||
{{-- 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,101 @@
|
||||
@php
|
||||
$landing = (array) config('product_landing');
|
||||
$logo = (string) ($landing['logo'] ?? '');
|
||||
$logoPath = $logo !== '' ? public_path($logo) : null;
|
||||
$platformUrl = (string) ($landing['platform_url'] ?? 'https://ladill.com');
|
||||
$marketingUrl = (string) ($landing['marketing_url'] ?? $platformUrl);
|
||||
$signInUrl = route('sso.connect', [
|
||||
'redirect' => route($landing['dashboard_route'] ?? 'login'),
|
||||
'interactive' => 1,
|
||||
]);
|
||||
$variant = (string) ($landing['landing_variant'] ?? 'default');
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>{{ $landing['name'] ?? config('app.name') }} — Ladill</title>
|
||||
<meta name="description" content="{{ $landing['description'] ?? '' }}">
|
||||
@include('partials.favicon')
|
||||
<link rel="canonical" href="{{ $marketingUrl }}">
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 font-sans text-white antialiased">
|
||||
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(28,117,188,.35),transparent_50%),radial-gradient(circle_at_bottom_right,rgba(217,236,103,.12),transparent_45%)]"></div>
|
||||
|
||||
<header class="relative z-10 border-b border-white/10">
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
|
||||
@if ($logo !== '')
|
||||
<img src="{{ asset($logo) }}?v={{ $logoPath && is_file($logoPath) ? filemtime($logoPath) : '1' }}"
|
||||
alt="{{ $landing['name'] ?? config('app.name') }}"
|
||||
class="h-7 w-auto">
|
||||
@else
|
||||
<span class="text-sm font-semibold">{{ $landing['name'] ?? config('app.name') }}</span>
|
||||
@endif
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ $platformUrl }}" class="hidden rounded-lg px-3 py-2 text-sm font-medium text-slate-300 transition hover:text-white sm:inline-flex">ladill.com</a>
|
||||
<a href="{{ $signInUrl }}" class="rounded-lg border border-white/20 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/10">Sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="relative z-10 mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<div class="grid items-center gap-12 lg:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-sm font-semibold uppercase tracking-wider text-[#d9ec67]">{{ $landing['tagline'] ?? '' }}</p>
|
||||
<h1 class="mt-4 text-4xl font-bold tracking-tight sm:text-5xl">{{ $landing['headline'] ?? ($landing['name'] ?? '') }}</h1>
|
||||
<p class="mt-5 text-base leading-relaxed text-slate-300 sm:text-lg">{{ $landing['description'] ?? '' }}</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-3">
|
||||
<a href="{{ $signInUrl }}" class="inline-flex items-center justify-center rounded-xl bg-[#d9ec67] px-6 py-3.5 text-sm font-bold text-slate-950 transition hover:bg-[#e5f278]">
|
||||
{{ $variant === 'webmail' ? 'Sign in to webmail' : 'Get started' }}
|
||||
</a>
|
||||
@if ($variant === 'webmail')
|
||||
<a href="{{ route('webmail.login.manual') }}" class="inline-flex items-center justify-center rounded-xl border border-white/20 px-6 py-3.5 text-sm font-semibold text-white transition hover:bg-white/10">
|
||||
Manual login
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ $landing['register_url'] ?? $platformUrl.'/register' }}" class="inline-flex items-center justify-center rounded-xl border border-white/20 px-6 py-3.5 text-sm font-semibold text-white transition hover:bg-white/10">
|
||||
Create account
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<p class="mt-5 text-sm text-slate-400">
|
||||
<a href="{{ $marketingUrl }}" class="font-medium text-slate-200 underline decoration-white/20 underline-offset-2 hover:text-white">Learn more on ladill.com</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur sm:p-8">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400">Why {{ $landing['name'] ?? 'Ladill' }}</p>
|
||||
<div class="mt-5 space-y-4">
|
||||
@foreach (($landing['benefits'] ?? []) as $benefit)
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
|
||||
<h2 class="text-sm font-semibold text-white">{{ $benefit['title'] }}</h2>
|
||||
<p class="mt-1.5 text-sm leading-relaxed text-slate-400">{{ $benefit['text'] }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if (! empty($landing['use_cases']))
|
||||
<div class="mt-6 border-t border-white/10 pt-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400">Ideal for</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
@foreach ($landing['use_cases'] as $useCase)
|
||||
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-slate-200">{{ $useCase }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="relative z-10 border-t border-white/10 py-6 text-center text-xs text-slate-500">
|
||||
Part of the <a href="{{ $platformUrl }}" class="font-medium text-slate-300 hover:text-white">Ladill</a> platform — one account for every app.
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,34 +0,0 @@
|
||||
<!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>
|
||||
@@ -110,7 +110,7 @@
|
||||
<div id="toolbar">
|
||||
<span class="tb-label">{{ $qrCode->label }}</span>
|
||||
@if($allowDownload)
|
||||
<a href="{{ $qrCode->publicPath('file') }}" download class="tb-download">
|
||||
<a href="{{ route('qr.public.file', $qrCode->short_code) }}" download class="tb-download">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"/>
|
||||
</svg>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ $qrCode->label }}</title>
|
||||
@include('partials.favicon')
|
||||
<meta name="description" content="{{ $qrCode->typeLabel() }} — {{ $qrCode->label }}">
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
@@ -24,7 +23,7 @@
|
||||
<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.25m2.25 0H5.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>
|
||||
<a href="{{ $qrCode->publicPath('view') }}"
|
||||
<a href="{{ route('qr.public.view', $qrCode->short_code) }}"
|
||||
class="mt-6 flex w-full items-center justify-center gap-2 rounded-2xl bg-red-600 px-6 py-4 text-sm font-semibold text-white shadow-sm transition hover:bg-red-700">
|
||||
<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="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
|
||||
@@ -32,7 +31,7 @@
|
||||
</svg>
|
||||
View document
|
||||
</a>
|
||||
<a href="{{ $qrCode->publicPath('file') }}"
|
||||
<a href="{{ route('qr.public.file', $qrCode->short_code) }}"
|
||||
class="mt-3 flex w-full items-center justify-center gap-2 rounded-2xl border border-slate-200 bg-white px-6 py-4 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50">
|
||||
<svg class="h-5 w-5 text-slate-500" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"/>
|
||||
@@ -62,7 +61,7 @@
|
||||
{{-- ===== VCARD ===== --}}
|
||||
@elseif($type === \App\Models\QrCode::TYPE_VCARD)
|
||||
@php
|
||||
$avatarUrl = !empty($content['avatar_path']) ? $qrCode->publicPath('avatar') : null;
|
||||
$avatarUrl = !empty($content['avatar_path']) ? route('qr.public.vcard.avatar', $qrCode->short_code) : null;
|
||||
$rawSocial = $content['social'] ?? [];
|
||||
$social = [];
|
||||
foreach ($rawSocial as $platform => $url) {
|
||||
@@ -159,7 +158,7 @@
|
||||
|
||||
{{-- Save contact button --}}
|
||||
<div class="px-6 pb-6 pt-2">
|
||||
<a href="{{ $qrCode->publicPath('vcard.vcf') }}"
|
||||
<a href="{{ route('qr.public.vcard', $qrCode->short_code) }}"
|
||||
class="btn-primary w-full">
|
||||
<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="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/></svg>
|
||||
Save Contact
|
||||
@@ -178,8 +177,8 @@
|
||||
$bizColor = $content['brand_color'] ?? '#1e3a5f';
|
||||
$bizHasLogo = !empty($content['logo_path']);
|
||||
$bizHasCover = !empty($content['cover_path']);
|
||||
$bizLogoUrl = $bizHasLogo ? $qrCode->publicPath('business-logo') : null;
|
||||
$bizCoverUrl = $bizHasCover ? $qrCode->publicPath('business-cover') : null;
|
||||
$bizLogoUrl = $bizHasLogo ? route('qr.public.business.logo', $qrCode->short_code) : null;
|
||||
$bizCoverUrl = $bizHasCover ? route('qr.public.business.cover', $qrCode->short_code) : null;
|
||||
$bizInitials = strtoupper(substr($bizName, 0, 1));
|
||||
$bizRawSocial = $content['social'] ?? [];
|
||||
$bizSocial = [];
|
||||
@@ -313,8 +312,8 @@
|
||||
$churchColor = $content['brand_color'] ?? '#1a3a5c';
|
||||
$churchHasLogo = !empty($content['logo_path']);
|
||||
$churchHasCover = !empty($content['cover_path']);
|
||||
$churchLogoUrl = $churchHasLogo ? $qrCode->publicPath('church-logo') : null;
|
||||
$churchCoverUrl = $churchHasCover ? $qrCode->publicPath('church-cover') : null;
|
||||
$churchLogoUrl = $churchHasLogo ? route('qr.public.church.logo', $qrCode->short_code) : null;
|
||||
$churchCoverUrl = $churchHasCover ? route('qr.public.church.cover', $qrCode->short_code) : null;
|
||||
$churchInitials = strtoupper(substr($churchName, 0, 2));
|
||||
$churchAcceptsPay = !empty($content['accepts_payment']);
|
||||
$churchCurrency = $content['currency'] ?? 'GHS';
|
||||
@@ -325,7 +324,7 @@
|
||||
$churchTypes,
|
||||
array_map(fn($t) => $legacyLabels[strtolower(trim($t))] ?? trim($t), $churchTypes)
|
||||
);
|
||||
$churchOrderRoute = $qrCode->publicPath('order');
|
||||
$churchOrderRoute = route('qr.public.order', $qrCode->short_code);
|
||||
$churchCsrf = csrf_token();
|
||||
@endphp
|
||||
<div x-data="{
|
||||
@@ -343,7 +342,7 @@
|
||||
if (!this.name.trim()) { this.errorMsg = 'Enter your name.'; return; }
|
||||
if (!this.email.trim() || !this.email.includes('@')) { this.errorMsg = 'Enter a valid email.'; return; }
|
||||
if (!this.phone.trim()) { this.errorMsg = 'Enter your phone number.'; return; }
|
||||
this.errorMsg = ''; this.loading = true; window.LadillPayCheckout?.prepare?.();
|
||||
this.errorMsg = ''; this.loading = true;
|
||||
const labels = @js($churchTypeLabels);
|
||||
try {
|
||||
const res = await fetch(@js($churchOrderRoute), {
|
||||
@@ -357,11 +356,16 @@
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; }
|
||||
this.checkoutUrl = data.checkout_url;
|
||||
this.showSheet = true;
|
||||
if (data.error) { this.errorMsg = data.error; this.loading = false; return; }
|
||||
if (window.innerWidth < 768) {
|
||||
this.checkoutUrl = data.checkout_url;
|
||||
this.showSheet = true;
|
||||
this.loading = false;
|
||||
} else {
|
||||
window.location.href = data.checkout_url;
|
||||
}
|
||||
} catch(e) {
|
||||
window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
|
||||
this.errorMsg = 'Network error. Please try again.';
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
@@ -626,8 +630,8 @@
|
||||
$evColor = $content['brand_color'] ?? '#4f46e5';
|
||||
$evHasLogo = !empty($content['logo_path']);
|
||||
$evHasCover = !empty($content['cover_path']);
|
||||
$evLogoUrl = $evHasLogo ? $qrCode->publicPath('event-logo') : null;
|
||||
$evCoverUrl = $evHasCover ? $qrCode->publicPath('event-cover') : null;
|
||||
$evLogoUrl = $evHasLogo ? route('qr.public.event.logo', $qrCode->short_code) : null;
|
||||
$evCoverUrl = $evHasCover ? route('qr.public.event.cover', $qrCode->short_code) : null;
|
||||
$evName = $content['name'] ?? $qrCode->label;
|
||||
$evInitials = strtoupper(mb_substr($evName, 0, 2));
|
||||
$evTiers = $content['tiers'] ?? [];
|
||||
@@ -637,7 +641,7 @@
|
||||
$evMode = in_array($content['mode'] ?? 'ticketing', ['contributions', 'free'], true) ? ($content['mode'] ?? 'ticketing') : 'ticketing';
|
||||
$evCategories = $content['contribution_categories'] ?? ['Contribution'];
|
||||
$evHeading = $evMode === 'contributions' ? 'Make a contribution' : 'Register';
|
||||
$evRegRoute = $qrCode->publicPath('register');
|
||||
$evRegRoute = route('qr.public.event.register', $qrCode->short_code);
|
||||
$evCsrf = csrf_token();
|
||||
@endphp
|
||||
<div x-data="{
|
||||
@@ -656,7 +660,7 @@
|
||||
if (this.mode === 'contributions' && !(parseFloat(this.amount) > 0)) { this.errorMsg = 'Enter a contribution amount.'; return; }
|
||||
if (!this.name.trim()) { this.errorMsg = 'Enter your full name.'; return; }
|
||||
if (!this.email.trim() || !this.email.includes('@')) { this.errorMsg = 'Enter a valid email.'; return; }
|
||||
this.errorMsg = ''; this.loading = true; window.LadillPayCheckout?.prepare?.();
|
||||
this.errorMsg = ''; this.loading = true;
|
||||
try {
|
||||
const res = await fetch(@js($evRegRoute), {
|
||||
method: 'POST',
|
||||
@@ -664,11 +668,11 @@
|
||||
body: JSON.stringify({ tier: this.tier, amount: this.mode === 'contributions' ? this.amount : null, attendee_name: this.name, attendee_email: this.email, attendee_phone: this.phone, badge_fields: this.fields }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; }
|
||||
if (data.error) { this.errorMsg = data.error; this.loading = false; return; }
|
||||
if (!data.paid) { window.location.href = data.success_url; return; }
|
||||
this.checkoutUrl = data.checkout_url;
|
||||
this.showSheet = true;
|
||||
} catch(e) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.'; this.loading = false; }
|
||||
if (window.innerWidth < 768) { this.checkoutUrl = data.checkout_url; this.showSheet = true; this.loading = false; }
|
||||
else { window.location.href = data.checkout_url; }
|
||||
} catch(e) { this.errorMsg = 'Network error. Please try again.'; this.loading = false; }
|
||||
}
|
||||
}" class="min-h-screen bg-slate-50 pb-12">
|
||||
|
||||
@@ -882,7 +886,7 @@
|
||||
@php
|
||||
$itinColor = $content['brand_color'] ?? '#b45309';
|
||||
$itinHasCover = !empty($content['cover_path']);
|
||||
$itinCoverUrl = $itinHasCover ? $qrCode->publicPath('itinerary-cover') : null;
|
||||
$itinCoverUrl = $itinHasCover ? route('qr.public.itinerary.cover', $qrCode->short_code) : null;
|
||||
$itinTitle = $content['title'] ?? $qrCode->label;
|
||||
$itinDays = $content['days'] ?? [];
|
||||
$multiDay = count($itinDays) > 1;
|
||||
@@ -1024,7 +1028,7 @@
|
||||
<h1 class="text-2xl font-bold text-slate-900">{{ $qrCode->label }}</h1>
|
||||
<div class="mt-6 space-y-4">
|
||||
@foreach($content['images'] ?? [] as $index => $image)
|
||||
<img src="{{ $qrCode->publicPath('images/'.$index) }}"
|
||||
<img src="{{ route('qr.public.image', [$qrCode->short_code, $index]) }}"
|
||||
alt="{{ $image['title'] ?? 'Image ' . ($index + 1) }}"
|
||||
class="w-full rounded-2xl border border-slate-200 shadow-sm"
|
||||
loading="lazy">
|
||||
@@ -1034,7 +1038,7 @@
|
||||
|
||||
{{-- ===== APP ===== --}}
|
||||
@elseif($type === \App\Models\QrCode::TYPE_APP)
|
||||
@php $appIconUrl = !empty($content['icon_path']) ? $qrCode->publicPath('app-icon') : null; @endphp
|
||||
@php $appIconUrl = !empty($content['icon_path']) ? route('qr.public.app.icon', $qrCode->short_code) : null; @endphp
|
||||
<div class="flex min-h-screen flex-col items-center justify-center px-4 py-12">
|
||||
<div class="w-full max-w-sm text-center">
|
||||
@if($appIconUrl)
|
||||
@@ -1089,217 +1093,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ===== PORTFOLIO ===== --}}
|
||||
@elseif($type === \App\Models\QrCode::TYPE_PORTFOLIO)
|
||||
@php
|
||||
$pName = $content['name'] ?? $qrCode->label;
|
||||
$pRole = $content['role'] ?? '';
|
||||
$pColor = $content['brand_color'] ?? '#4f46e5';
|
||||
$pAvatar = !empty($content['avatar_path']) ? $qrCode->publicPath('portfolio-avatar') : null;
|
||||
$pCover = !empty($content['cover_path']) ? $qrCode->publicPath('portfolio-cover') : null;
|
||||
$pSocial = [];
|
||||
foreach (($content['social'] ?? []) as $platform => $url) {
|
||||
$pSocial[$platform] = \App\Services\Qr\QrPayloadValidator::normalizeSocialUrl($platform, trim((string) $url));
|
||||
}
|
||||
$pInitials = strtoupper(substr($pName, 0, 1));
|
||||
$socialIcons = [
|
||||
'linkedin' => 'linkedin.svg', 'twitter' => 'twitter.svg', 'instagram' => 'instagram.svg',
|
||||
'facebook' => 'facebook.svg', 'youtube' => 'youtube.svg', 'whatsapp' => 'whatsapp.svg',
|
||||
];
|
||||
@endphp
|
||||
<div class="min-h-screen bg-slate-100 pb-12">
|
||||
<div class="relative h-44 overflow-hidden">
|
||||
@if($pCover)
|
||||
<img src="{{ $pCover }}" alt="" class="absolute inset-0 h-full w-full object-cover">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-black/10 to-black/55"></div>
|
||||
@else
|
||||
<div class="absolute inset-0" style="background: linear-gradient(135deg, {{ $pColor }}, {{ $pColor }}aa);"></div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="relative mx-auto -mt-14 max-w-md px-4">
|
||||
<div class="overflow-hidden rounded-3xl bg-white shadow-xl">
|
||||
<div class="px-6 pb-6 pt-4 text-center">
|
||||
<div class="mx-auto -mt-12 mb-3 flex h-24 w-24 items-center justify-center overflow-hidden rounded-full border-4 border-white shadow-lg" style="background: {{ $pColor }};">
|
||||
@if($pAvatar)
|
||||
<img src="{{ $pAvatar }}" alt="{{ $pName }}" class="h-full w-full object-cover">
|
||||
@else
|
||||
<span class="text-3xl font-bold text-white">{{ $pInitials }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-slate-900">{{ $pName }}</h1>
|
||||
@if($pRole)<p class="mt-1 text-sm font-medium" style="color: {{ $pColor }};">{{ $pRole }}</p>@endif
|
||||
@if(!empty($content['bio']))<p class="mt-3 text-sm leading-relaxed text-slate-600">{{ $content['bio'] }}</p>@endif
|
||||
<div class="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
@if(!empty($content['email']))
|
||||
<a href="mailto:{{ $content['email'] }}" class="rounded-full px-3 py-1.5 text-xs font-semibold text-white" style="background: {{ $pColor }};">Email</a>
|
||||
@endif
|
||||
@if(!empty($content['phone']))
|
||||
<a href="tel:{{ $content['phone'] }}" class="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700">Call</a>
|
||||
@endif
|
||||
@if(!empty($content['website']))
|
||||
<a href="{{ $content['website'] }}" target="_blank" rel="noopener" class="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700">Website</a>
|
||||
@endif
|
||||
</div>
|
||||
@if($pSocial !== [])
|
||||
<div class="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
@foreach($pSocial as $platform => $url)
|
||||
@if(isset($socialIcons[$platform]))
|
||||
<a href="{{ $url }}" target="_blank" rel="noopener" class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-100">
|
||||
<img src="/images/qr-icons/{{ $socialIcons[$platform] }}" alt="{{ $platform }}" class="h-4 w-4">
|
||||
</a>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="border-t border-slate-100 px-4 py-5">
|
||||
<h2 class="mb-3 px-2 text-xs font-semibold uppercase tracking-wider text-slate-400">Selected work</h2>
|
||||
<div class="space-y-3">
|
||||
@foreach(($content['projects'] ?? []) as $index => $project)
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-100 bg-slate-50">
|
||||
@if(!empty($project['image_path']))
|
||||
<img src="{{ $qrCode->publicPath('portfolio-project/'.$index) }}" alt="{{ $project['title'] ?? '' }}"
|
||||
class="h-40 w-full object-cover">
|
||||
@endif
|
||||
<div class="p-4">
|
||||
<h3 class="text-sm font-bold text-slate-900">{{ $project['title'] ?? '' }}</h3>
|
||||
@if(!empty($project['description']))
|
||||
<p class="mt-1 text-sm leading-relaxed text-slate-600">{{ $project['description'] }}</p>
|
||||
@endif
|
||||
@if(!empty($project['tags']))
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
@foreach($project['tags'] as $tag)
|
||||
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold" style="background: {{ $pColor }}14; color: {{ $pColor }};">{{ $tag }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@if(!empty($project['url']))
|
||||
<a href="{{ $project['url'] }}" target="_blank" rel="noopener"
|
||||
class="mt-3 inline-flex items-center gap-1 text-xs font-semibold" style="color: {{ $pColor }};">
|
||||
View project
|
||||
<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="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/></svg>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ===== BOOKSHOP ===== --}}
|
||||
@elseif($type === \App\Models\QrCode::TYPE_BOOKSHOP)
|
||||
@php
|
||||
$aName = $content['author_name'] ?? $qrCode->label;
|
||||
$aTagline = $content['tagline'] ?? '';
|
||||
$aColor = $content['brand_color'] ?? '#0f766e';
|
||||
$aAvatar = !empty($content['avatar_path']) ? $qrCode->publicPath('bookshop-avatar') : null;
|
||||
$aCover = !empty($content['cover_path']) ? $qrCode->publicPath('bookshop-cover') : null;
|
||||
$aSocial = [];
|
||||
foreach (($content['social'] ?? []) as $platform => $url) {
|
||||
$aSocial[$platform] = \App\Services\Qr\QrPayloadValidator::normalizeSocialUrl($platform, trim((string) $url));
|
||||
}
|
||||
$aInitials = strtoupper(substr($aName, 0, 1));
|
||||
$socialIcons = [
|
||||
'linkedin' => 'linkedin.svg', 'twitter' => 'twitter.svg', 'instagram' => 'instagram.svg',
|
||||
'facebook' => 'facebook.svg', 'youtube' => 'youtube.svg', 'whatsapp' => 'whatsapp.svg',
|
||||
];
|
||||
@endphp
|
||||
<div class="min-h-screen bg-stone-100 pb-12">
|
||||
{{-- Extra bottom padding leaves room so title/sub sit above the overlapping author card --}}
|
||||
<div class="relative h-52 overflow-hidden sm:h-56">
|
||||
@if($aCover)
|
||||
<img src="{{ $aCover }}" alt="" class="absolute inset-0 h-full w-full object-cover">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-black/20 to-black/65"></div>
|
||||
@else
|
||||
<div class="absolute inset-0" style="background: linear-gradient(145deg, {{ $aColor }}, {{ $aColor }}99);"></div>
|
||||
@endif
|
||||
<div class="absolute inset-x-0 bottom-0 px-5 pb-12 sm:pb-14">
|
||||
<div class="mx-auto max-w-md text-white">
|
||||
<h1 class="text-2xl font-bold">{{ $aName }}</h1>
|
||||
@if($aTagline)
|
||||
<p class="mt-1 truncate text-sm text-white/90">{{ $aTagline }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 mx-auto -mt-8 max-w-md px-4">
|
||||
<div class="rounded-3xl bg-white p-5 shadow-xl">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-2xl border-2 border-white shadow" style="background: {{ $aColor }};">
|
||||
@if($aAvatar)
|
||||
<img src="{{ $aAvatar }}" alt="{{ $aName }}" class="h-full w-full object-cover">
|
||||
@else
|
||||
<span class="text-2xl font-bold text-white">{{ $aInitials }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
@if(!empty($content['bio']))
|
||||
<p class="text-sm leading-relaxed text-slate-600">{{ $content['bio'] }}</p>
|
||||
@endif
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
@if(!empty($content['website']))
|
||||
<a href="{{ $content['website'] }}" target="_blank" rel="noopener" class="rounded-full px-3 py-1 text-xs font-semibold text-white" style="background: {{ $aColor }};">Website</a>
|
||||
@endif
|
||||
@if(!empty($content['email']))
|
||||
<a href="mailto:{{ $content['email'] }}" class="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-slate-700">Email</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if($aSocial !== [])
|
||||
<div class="mt-4 flex flex-wrap gap-2 border-t border-slate-100 pt-4">
|
||||
@foreach($aSocial as $platform => $url)
|
||||
@if(isset($socialIcons[$platform]))
|
||||
<a href="{{ $url }}" target="_blank" rel="noopener" class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-100">
|
||||
<img src="/images/qr-icons/{{ $socialIcons[$platform] }}" alt="{{ $platform }}" class="h-4 w-4">
|
||||
</a>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<h2 class="mb-3 mt-8 text-xs font-semibold uppercase tracking-wider text-slate-500">Books</h2>
|
||||
<div class="space-y-4">
|
||||
@foreach(($content['books'] ?? []) as $index => $book)
|
||||
<div class="overflow-hidden rounded-3xl border border-stone-200 bg-white shadow-sm">
|
||||
<div class="flex gap-4 p-4">
|
||||
<div class="h-36 w-24 shrink-0 overflow-hidden rounded-xl bg-stone-100 shadow-inner">
|
||||
@if(!empty($book['cover_path']))
|
||||
<img src="{{ $qrCode->publicPath('bookshop-book/'.$index) }}" alt="{{ $book['title'] ?? '' }}"
|
||||
class="h-full w-full object-cover">
|
||||
@else
|
||||
<div class="flex h-full w-full items-center justify-center px-2 text-center text-[10px] font-semibold text-stone-400">
|
||||
{{ $book['title'] ?? 'Book' }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-base font-bold text-slate-900">{{ $book['title'] ?? '' }}</h3>
|
||||
@if(!empty($book['description']))
|
||||
<p class="mt-1 line-clamp-3 text-sm leading-relaxed text-slate-600">{{ $book['description'] }}</p>
|
||||
@endif
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
@foreach(($book['buy_links'] ?? []) as $buy)
|
||||
<a href="{{ $buy['url'] }}" target="_blank" rel="noopener"
|
||||
class="inline-flex items-center rounded-full px-3 py-1.5 text-xs font-semibold text-white shadow-sm"
|
||||
style="background: {{ $aColor }};">
|
||||
{{ $buy['label'] ?? 'Buy' }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ===== COUPON ===== --}}
|
||||
@elseif($type === \App\Models\QrCode::TYPE_COUPON)
|
||||
<div class="flex min-h-screen flex-col items-center justify-center px-4 py-12">
|
||||
@@ -1338,14 +1131,14 @@
|
||||
$sections = $content['sections'] ?? [];
|
||||
$title = $content['title'] ?? $qrCode->label;
|
||||
$acceptsPayment = (bool) ($content['accepts_payment'] ?? false);
|
||||
$orderRoute = $qrCode->publicPath('order');
|
||||
$orderRoute = route('qr.public.order', $qrCode->short_code);
|
||||
$csrfToken = csrf_token();
|
||||
$paystackPublicKey = $acceptsPayment ? app(\App\Services\Billing\PaystackService::class)->publicKey() : '';
|
||||
$brandColor = $content['brand_color'] ?? ($isShop ? '#7c3aed' : '#f97316');
|
||||
$hasLogo = !empty($content['logo_path']);
|
||||
$hasCover = !empty($content['cover_path']);
|
||||
$logoUrl = $hasLogo ? $qrCode->publicPath('menu-logo') : null;
|
||||
$coverUrl = $hasCover ? $qrCode->publicPath('menu-cover') : null;
|
||||
$logoUrl = $hasLogo ? route('qr.public.menu.logo', $qrCode->short_code) : null;
|
||||
$coverUrl = $hasCover ? route('qr.public.menu.cover', $qrCode->short_code) : null;
|
||||
$shippingType = $content['shipping_type'] ?? 'none';
|
||||
$shippingFee = (float) ($content['shipping_fee'] ?? 0);
|
||||
$freeShippingAbove = (float) ($content['free_shipping_above'] ?? 0);
|
||||
@@ -1395,7 +1188,7 @@
|
||||
if (!this.email.trim() || !this.email.includes('@')) { this.errorMsg = 'Enter a valid email address.'; return }
|
||||
if (!this.phone.trim()) { this.errorMsg = 'Enter your phone number.'; return }
|
||||
this.errorMsg = '';
|
||||
this.loading = true; window.LadillPayCheckout?.prepare?.();
|
||||
this.loading = true;
|
||||
const payload = {
|
||||
customer_name: this.name,
|
||||
customer_email: this.email,
|
||||
@@ -1410,11 +1203,16 @@
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; }
|
||||
this.checkoutUrl = data.checkout_url;
|
||||
this.showSheet = true;
|
||||
if (data.error) { this.errorMsg = data.error; this.loading = false; return; }
|
||||
if (window.innerWidth < 768) {
|
||||
this.checkoutUrl = data.checkout_url;
|
||||
this.showSheet = true;
|
||||
this.loading = false;
|
||||
} else {
|
||||
window.location.href = data.checkout_url;
|
||||
}
|
||||
} catch(e) {
|
||||
window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
|
||||
this.errorMsg = 'Network error. Please try again.';
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
@@ -1492,7 +1290,7 @@
|
||||
<div class="flex gap-3 overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-100">
|
||||
{{-- Item image --}}
|
||||
@if($hasImage)
|
||||
<img src="{{ $qrCode->publicPath('item-image/'.$si.'/'.$ii) }}"
|
||||
<img src="{{ route('qr.public.item-image', [$qrCode->short_code, $si, $ii]) }}"
|
||||
alt="{{ $item['name'] }}"
|
||||
class="h-[100px] w-[100px] flex-shrink-0 object-cover" loading="lazy">
|
||||
@endif
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
$evColor = $c['brand_color'] ?? '#4f46e5';
|
||||
$evName = $c['name'] ?? $qrCode->label;
|
||||
$hasLogo = !empty($c['logo_path']);
|
||||
$logoUrl = $hasLogo ? $qrCode->publicPath('event-logo') : null;
|
||||
$logoUrl = $hasLogo ? route('qr.public.event.logo', $qrCode->short_code) : null;
|
||||
$size = $c['badge_size'] ?? '4x3';
|
||||
// Physical badge dimensions (inches)
|
||||
[$bw, $bh] = match ($size) {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
$defaultStyle = \App\Support\Qr\QrStyleDefaults::merge(old('style') ?: ($accountDefaultStyle ?? null));
|
||||
@endphp
|
||||
|
||||
@php $prefill = $prefill ?? []; @endphp
|
||||
<div class="mx-auto max-w-6xl space-y-6"
|
||||
x-data="qrCustomizer({
|
||||
previewUrl: @js(route('user.qr-codes.style-preview')),
|
||||
@@ -15,16 +14,9 @@
|
||||
balance: @js((float) $wallet->spendableBalance()),
|
||||
price: @js((float) $pricePerQr),
|
||||
topupUrl: @js($topupUrl),
|
||||
topupModalId: 'qr',
|
||||
type: @js(old('type', $prefill['type'] ?? $defaultType ?? 'url')),
|
||||
type: @js(old('type', $defaultType ?? 'url')),
|
||||
})">
|
||||
|
||||
@if(!empty($prefill))
|
||||
<div class="rounded-xl border border-indigo-200 bg-indigo-50 px-4 py-3 text-sm text-indigo-900">
|
||||
Prefilled from Ladill CRM. Review the business details, then create your QR code.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
|
||||
@endif
|
||||
@@ -73,7 +65,7 @@
|
||||
<div class="space-y-4 p-5">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-600">Label</label>
|
||||
<input type="text" name="label" value="{{ old('label', $prefill['label'] ?? '') }}" required maxlength="120"
|
||||
<input type="text" name="label" value="{{ old('label') }}" required maxlength="120"
|
||||
class="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30"
|
||||
placeholder="e.g. Shop flyer, WiFi guest, Menu">
|
||||
</div>
|
||||
@@ -84,7 +76,7 @@
|
||||
status: '',
|
||||
timer: null,
|
||||
checkUrl: @js(route('user.qr-codes.check-slug')),
|
||||
baseUrl: @js(\App\Support\LadillLink::baseUrl()),
|
||||
baseUrl: @js(\App\Models\QrCode::publicBaseUrl() . '/q'),
|
||||
check() {
|
||||
clearTimeout(this.timer);
|
||||
const s = this.slug.trim();
|
||||
@@ -104,7 +96,7 @@
|
||||
:class="status === 'available' ? 'border-green-400' : status === 'taken' || status === 'invalid' ? 'border-red-400' : 'border-slate-200'">
|
||||
<span class="flex items-center rounded-l-xl border-r border-slate-200 bg-slate-50 px-3 text-xs text-slate-400 whitespace-nowrap select-none"
|
||||
:class="status === 'available' ? 'border-green-400' : status === 'taken' || status === 'invalid' ? 'border-red-400' : 'border-slate-200'">
|
||||
{{ \App\Support\LadillLink::baseUrl() }}/
|
||||
{{ \App\Models\QrCode::publicBaseUrl() }}/q/
|
||||
</span>
|
||||
<input type="text" name="custom_short_code"
|
||||
x-model="slug"
|
||||
@@ -157,7 +149,7 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
@include('qr-codes.partials.type-fields-create', ['prefill' => $prefill ?? []])
|
||||
@include('qr-codes.partials.type-fields-create')
|
||||
</div>
|
||||
|
||||
<p class="rounded-xl bg-slate-50 px-3.5 py-2.5 text-xs leading-5 text-slate-400">
|
||||
@@ -255,17 +247,5 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Two-step add-funds modal (insufficient balance opens this instead of redirecting to the wallet) --}}
|
||||
<x-user.service-topup-modal
|
||||
id="qr"
|
||||
title="Add funds"
|
||||
:topup-action="route('user.wallet.topup')"
|
||||
:price-per-action="(float) $pricePerQr"
|
||||
action-noun="QR code"
|
||||
:service-balance="(float) $wallet->spendableBalance()"
|
||||
service-balance-label="Wallet balance"
|
||||
:return-url="route('user.qr-codes.create')"
|
||||
:open-on-load="session('open_topup_modal') === 'qr'" />
|
||||
|
||||
</div>
|
||||
</x-user-layout>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
balance: @js((float) $wallet->spendableBalance()),
|
||||
price: @js((float) $pricePerQr),
|
||||
topupUrl: @js($topupUrl),
|
||||
modalId: 'qr',
|
||||
href: @js(route('user.qr-codes.create')),
|
||||
})">
|
||||
@foreach(['success', 'error'] as $flash)
|
||||
@@ -39,16 +38,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
<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">
|
||||
<div class="scrollbar-hide flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 sm:grid sm:grid-cols-3 sm:overflow-visible sm:pb-0 lg:gap-4" style="-ms-overflow-style:none;scrollbar-width:none;">
|
||||
<div class="mobile-stats-card 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">GHS {{ number_format($wallet->spendableBalance(), 2) }}</p>
|
||||
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">QR balance</p>
|
||||
</div>
|
||||
<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">
|
||||
<div class="mobile-stats-card 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">{{ $qrCodes->count() }}</p>
|
||||
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">QR codes</p>
|
||||
</div>
|
||||
<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">
|
||||
<div class="mobile-stats-card 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">{{ number_format($wallet->scans_total) }}</p>
|
||||
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Total scans</p>
|
||||
</div>
|
||||
@@ -83,17 +82,5 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Two-step add-funds modal (insufficient balance opens this instead of redirecting to the wallet) --}}
|
||||
<x-user.service-topup-modal
|
||||
id="qr"
|
||||
title="Add funds"
|
||||
:topup-action="route('user.wallet.topup')"
|
||||
:price-per-action="(float) $pricePerQr"
|
||||
action-noun="QR code"
|
||||
:service-balance="(float) $wallet->spendableBalance()"
|
||||
service-balance-label="Wallet balance"
|
||||
:return-url="route('user.qr-codes.index')"
|
||||
:open-on-load="session('open_topup_modal') === 'qr'" />
|
||||
</div>
|
||||
</x-user-layout>
|
||||
|
||||
@@ -227,8 +227,11 @@
|
||||
this.schedulePreview();
|
||||
},
|
||||
redirectTopup() {
|
||||
// Prefer the in-app two-step add-funds modal; only fall back to the
|
||||
// central wallet page when no modal is wired on this view.
|
||||
if (this.topupUrl) {
|
||||
window.location.href = this.topupUrl;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.topupModalId) {
|
||||
window.dispatchEvent(new CustomEvent('open-modal', {
|
||||
detail: this.topupModalId,
|
||||
@@ -237,11 +240,6 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.topupUrl) {
|
||||
window.location.href = this.topupUrl;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
checkBalance(event) {
|
||||
|
||||
@@ -121,19 +121,19 @@
|
||||
{{-- Business --}}
|
||||
<div x-show="type === 'business'" x-cloak class="space-y-4">
|
||||
<div class="grid gap-3">
|
||||
<input type="text" name="name" value="{{ old('name', $prefill['name'] ?? '') }}" placeholder="Business name *"
|
||||
<input type="text" name="name" value="{{ old('name') }}" placeholder="Business name *"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<input type="text" name="tagline" value="{{ old('tagline') }}" placeholder="Tagline or brief description"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input type="text" name="phone" value="{{ old('phone', $prefill['phone'] ?? '') }}" placeholder="Phone"
|
||||
<input type="text" name="phone" value="{{ old('phone') }}" placeholder="Phone"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<input type="email" name="email" value="{{ old('email', $prefill['email'] ?? '') }}" placeholder="Email"
|
||||
<input type="email" name="email" value="{{ old('email') }}" placeholder="Email"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
</div>
|
||||
<input type="url" name="website" value="{{ old('website') }}" placeholder="Website URL"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<input type="text" name="address" value="{{ old('address', $prefill['address'] ?? '') }}" placeholder="Address"
|
||||
<input type="text" name="address" value="{{ old('address') }}" placeholder="Address"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<textarea name="hours" rows="2" placeholder="Opening hours (e.g. Mon–Fri 8am–6pm)"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">{{ old('hours') }}</textarea>
|
||||
@@ -678,193 +678,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{-- Portfolio --}}
|
||||
<div x-show="type === 'portfolio'" x-cloak
|
||||
x-data="{
|
||||
projects: [{ title: '', description: '', url: '', tags: '' }],
|
||||
addProject() { this.projects.push({ title: '', description: '', url: '', tags: '' }) },
|
||||
removeProject(i) { this.projects.splice(i, 1); if (this.projects.length === 0) this.addProject() },
|
||||
avatarPreview: null,
|
||||
coverPreview: null,
|
||||
}"
|
||||
class="space-y-4">
|
||||
<div class="grid gap-3">
|
||||
<input type="text" name="name" value="{{ old('name') }}" placeholder="Your name *"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<input type="text" name="role" value="{{ old('role') }}" placeholder="Title / specialty (e.g. Product designer)"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<textarea name="bio" rows="3" placeholder="Short bio"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">{{ old('bio') }}</textarea>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input type="email" name="email" value="{{ old('email') }}" placeholder="Email"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<input type="text" name="phone" value="{{ old('phone') }}" placeholder="Phone"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
</div>
|
||||
<input type="url" name="website" value="{{ old('website') }}" placeholder="Website / portfolio URL"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Photo</label>
|
||||
<input type="file" name="portfolio_avatar" accept="image/*"
|
||||
@change="avatarPreview = $event.target.files[0] ? URL.createObjectURL($event.target.files[0]) : null"
|
||||
class="mt-1 block w-full text-xs text-slate-600 file:mr-2 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-2 file:py-1 file:text-xs file:font-semibold file:text-indigo-700">
|
||||
<div x-show="avatarPreview" class="mt-2"><img :src="avatarPreview" class="h-14 w-14 rounded-full object-cover shadow" alt=""></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Cover</label>
|
||||
<input type="file" name="portfolio_cover" accept="image/*"
|
||||
@change="coverPreview = $event.target.files[0] ? URL.createObjectURL($event.target.files[0]) : null"
|
||||
class="mt-1 block w-full text-xs text-slate-600 file:mr-2 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-2 file:py-1 file:text-xs file:font-semibold file:text-indigo-700">
|
||||
<x-qr.cover-image-hint />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Brand color</label>
|
||||
<input type="color" name="brand_color" value="{{ old('brand_color', '#4f46e5') }}"
|
||||
class="mt-1 h-10 w-full cursor-pointer rounded-xl border border-slate-200 p-1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-semibold text-slate-600">Social links <span class="font-normal text-slate-400">(optional)</span></p>
|
||||
<div class="grid gap-2">
|
||||
@foreach(['linkedin' => 'LinkedIn', 'twitter' => 'X / Twitter', 'instagram' => 'Instagram', 'facebook' => 'Facebook', 'youtube' => 'YouTube', 'whatsapp' => 'WhatsApp'] as $platform => $label)
|
||||
<input type="text" name="social[{{ $platform }}]" value="{{ old('social.'.$platform) }}" placeholder="{{ $label }}"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold text-slate-600">Projects *</p>
|
||||
<button type="button" @click="addProject()" class="text-xs font-semibold text-indigo-600 hover:text-indigo-800">+ Add project</button>
|
||||
</div>
|
||||
<template x-for="(project, i) in projects" :key="i">
|
||||
<div class="space-y-2 rounded-xl border border-slate-200 bg-slate-50/60 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400" x-text="'Project ' + (i + 1)"></p>
|
||||
<button type="button" @click="removeProject(i)" class="text-xs text-red-600 hover:text-red-800">Remove</button>
|
||||
</div>
|
||||
<input type="text" :name="'projects['+i+'][title]'" x-model="project.title" placeholder="Project title *"
|
||||
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<textarea :name="'projects['+i+'][description]'" x-model="project.description" rows="2" placeholder="What you built / delivered"
|
||||
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30"></textarea>
|
||||
<input type="url" :name="'projects['+i+'][url]'" x-model="project.url" placeholder="Project or case-study URL"
|
||||
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<input type="text" :name="'projects['+i+'][tags]'" x-model="project.tags" placeholder="Tags (comma-separated)"
|
||||
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Project image</label>
|
||||
<input type="file" :name="'project_images['+i+']'" accept="image/*"
|
||||
class="mt-1 block w-full text-xs text-slate-600 file:mr-2 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-2 file:py-1 file:text-xs file:font-semibold file:text-indigo-700">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Bookshop --}}
|
||||
<div x-show="type === 'bookshop'" x-cloak
|
||||
x-data="{
|
||||
books: [{ title: '', description: '', buy_links: [{ label: 'Amazon', url: '' }] }],
|
||||
addBook() { this.books.push({ title: '', description: '', buy_links: [{ label: 'Amazon', url: '' }] }) },
|
||||
removeBook(i) { this.books.splice(i, 1); if (this.books.length === 0) this.addBook() },
|
||||
addLink(b) { this.books[b].buy_links.push({ label: '', url: '' }) },
|
||||
removeLink(b, l) { this.books[b].buy_links.splice(l, 1); if (this.books[b].buy_links.length === 0) this.addLink(b) },
|
||||
avatarPreview: null,
|
||||
coverPreview: null,
|
||||
}"
|
||||
class="space-y-4">
|
||||
<div class="grid gap-3">
|
||||
<input type="text" name="author_name" value="{{ old('author_name') }}" placeholder="Author name *"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<input type="text" name="tagline" value="{{ old('tagline') }}" placeholder="Tagline (e.g. Stories that stay with you)"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<textarea name="bio" rows="3" placeholder="About the author"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">{{ old('bio') }}</textarea>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input type="email" name="email" value="{{ old('email') }}" placeholder="Contact email"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<input type="url" name="website" value="{{ old('website') }}" placeholder="Author website"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Author photo</label>
|
||||
<input type="file" name="bookshop_avatar" accept="image/*"
|
||||
@change="avatarPreview = $event.target.files[0] ? URL.createObjectURL($event.target.files[0]) : null"
|
||||
class="mt-1 block w-full text-xs text-slate-600 file:mr-2 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-2 file:py-1 file:text-xs file:font-semibold file:text-indigo-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Cover banner</label>
|
||||
<input type="file" name="bookshop_cover" accept="image/*"
|
||||
@change="coverPreview = $event.target.files[0] ? URL.createObjectURL($event.target.files[0]) : null"
|
||||
class="mt-1 block w-full text-xs text-slate-600 file:mr-2 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-2 file:py-1 file:text-xs file:font-semibold file:text-indigo-700">
|
||||
<x-qr.cover-image-hint />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Brand color</label>
|
||||
<input type="color" name="brand_color" value="{{ old('brand_color', '#0f766e') }}"
|
||||
class="mt-1 h-10 w-full cursor-pointer rounded-xl border border-slate-200 p-1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-semibold text-slate-600">Social links <span class="font-normal text-slate-400">(optional)</span></p>
|
||||
<div class="grid gap-2">
|
||||
@foreach(['instagram' => 'Instagram', 'twitter' => 'X / Twitter', 'facebook' => 'Facebook', 'youtube' => 'YouTube', 'linkedin' => 'LinkedIn'] as $platform => $label)
|
||||
<input type="text" name="social[{{ $platform }}]" value="{{ old('social.'.$platform) }}" placeholder="{{ $label }}"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold text-slate-600">Books *</p>
|
||||
<button type="button" @click="addBook()" class="text-xs font-semibold text-indigo-600 hover:text-indigo-800">+ Add book</button>
|
||||
</div>
|
||||
<template x-for="(book, b) in books" :key="b">
|
||||
<div class="space-y-2 rounded-xl border border-slate-200 bg-slate-50/60 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400" x-text="'Book ' + (b + 1)"></p>
|
||||
<button type="button" @click="removeBook(b)" class="text-xs text-red-600 hover:text-red-800">Remove</button>
|
||||
</div>
|
||||
<input type="text" :name="'books['+b+'][title]'" x-model="book.title" placeholder="Book title *"
|
||||
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<textarea :name="'books['+b+'][description]'" x-model="book.description" rows="2" placeholder="Short description"
|
||||
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30"></textarea>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Cover image</label>
|
||||
<input type="file" :name="'book_covers['+b+']'" accept="image/*"
|
||||
class="mt-1 block w-full text-xs text-slate-600 file:mr-2 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-2 file:py-1 file:text-xs file:font-semibold file:text-indigo-700">
|
||||
<x-qr.cover-image-hint variant="book" />
|
||||
</div>
|
||||
<div class="space-y-2 rounded-lg border border-dashed border-slate-200 bg-white p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-[11px] font-semibold text-slate-500">Where to buy *</p>
|
||||
<button type="button" @click="addLink(b)" class="text-[11px] font-semibold text-indigo-600">+ Link</button>
|
||||
</div>
|
||||
<template x-for="(link, l) in book.buy_links" :key="l">
|
||||
<div class="grid gap-2 sm:grid-cols-[1fr_2fr_auto]">
|
||||
<input type="text" :name="'books['+b+'][buy_links]['+l+'][label]'" x-model="link.label" placeholder="Amazon, Bookshop.org…"
|
||||
class="rounded-lg border border-slate-200 px-2.5 py-1.5 text-sm">
|
||||
<input type="url" :name="'books['+b+'][buy_links]['+l+'][url]'" x-model="link.url" placeholder="https://…"
|
||||
class="rounded-lg border border-slate-200 px-2.5 py-1.5 text-sm">
|
||||
<button type="button" @click="removeLink(b, l)" class="text-xs text-red-600">Remove</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- App --}}
|
||||
<div x-show="type === 'app'" x-cloak x-data="{ iconPreview: null }" class="grid gap-3">
|
||||
<input type="text" name="app_name" value="{{ old('app_name') }}" placeholder="App name *"
|
||||
|
||||
@@ -731,159 +731,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@elseif($qrCode->type === \App\Models\QrCode::TYPE_PORTFOLIO)
|
||||
@php
|
||||
$projects = collect($c['projects'] ?? [])->map(fn ($p) => [
|
||||
'title' => $p['title'] ?? '',
|
||||
'description' => $p['description'] ?? '',
|
||||
'url' => $p['url'] ?? '',
|
||||
'tags' => implode(', ', $p['tags'] ?? []),
|
||||
'image_path' => $p['image_path'] ?? null,
|
||||
])->values()->all();
|
||||
if ($projects === []) {
|
||||
$projects = [['title' => '', 'description' => '', 'url' => '', 'tags' => '', 'image_path' => null]];
|
||||
}
|
||||
@endphp
|
||||
<div x-data="{
|
||||
projects: @js($projects),
|
||||
addProject() { this.projects.push({ title: '', description: '', url: '', tags: '', image_path: null }) },
|
||||
removeProject(i) { this.projects.splice(i, 1); if (this.projects.length === 0) this.addProject() },
|
||||
}" class="space-y-4">
|
||||
<div class="grid gap-3">
|
||||
<input type="text" name="name" value="{{ $c['name'] ?? '' }}" placeholder="Your name *"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<input type="text" name="role" value="{{ $c['role'] ?? '' }}" placeholder="Title / specialty"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<textarea name="bio" rows="3" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">{{ $c['bio'] ?? '' }}</textarea>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input type="email" name="email" value="{{ $c['email'] ?? '' }}" placeholder="Email" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||
<input type="text" name="phone" value="{{ $c['phone'] ?? '' }}" placeholder="Phone" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||
</div>
|
||||
<input type="url" name="website" value="{{ $c['website'] ?? '' }}" placeholder="Website" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||
<input type="color" name="brand_color" value="{{ $c['brand_color'] ?? '#4f46e5' }}" class="h-10 w-full rounded-xl border border-slate-200 p-1">
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Replace photo</label>
|
||||
<input type="file" name="portfolio_avatar" accept="image/*" class="mt-1 block w-full text-xs">
|
||||
@if(!empty($c['avatar_path']))
|
||||
<img src="{{ route('qr.public.portfolio.avatar', $qrCode->short_code) }}" class="mt-2 h-14 w-14 rounded-full object-cover" alt="">
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Replace cover</label>
|
||||
<input type="file" name="portfolio_cover" accept="image/*" class="mt-1 block w-full text-xs">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
@foreach(['linkedin','twitter','instagram','facebook','youtube','whatsapp'] as $platform)
|
||||
<input type="text" name="social[{{ $platform }}]" value="{{ $c['social'][$platform] ?? '' }}" placeholder="{{ ucfirst($platform) }}"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold text-slate-600">Projects</p>
|
||||
<button type="button" @click="addProject()" class="text-xs font-semibold text-indigo-600">+ Add</button>
|
||||
</div>
|
||||
<template x-for="(project, i) in projects" :key="i">
|
||||
<div class="space-y-2 rounded-xl border border-slate-200 p-3">
|
||||
<input type="hidden" :name="'projects['+i+'][image_path]'" :value="project.image_path || ''">
|
||||
<input type="text" :name="'projects['+i+'][title]'" x-model="project.title" placeholder="Title *" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||
<textarea :name="'projects['+i+'][description]'" x-model="project.description" rows="2" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm"></textarea>
|
||||
<input type="url" :name="'projects['+i+'][url]'" x-model="project.url" placeholder="URL" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||
<input type="text" :name="'projects['+i+'][tags]'" x-model="project.tags" placeholder="Tags" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||
<input type="file" :name="'project_images['+i+']'" accept="image/*" class="block w-full text-xs">
|
||||
<button type="button" @click="removeProject(i)" class="text-xs text-red-600">Remove project</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@elseif($qrCode->type === \App\Models\QrCode::TYPE_BOOKSHOP)
|
||||
@php
|
||||
$books = collect($c['books'] ?? [])->map(function ($b) {
|
||||
$links = collect($b['buy_links'] ?? [])->map(fn ($l) => [
|
||||
'label' => $l['label'] ?? '',
|
||||
'url' => $l['url'] ?? '',
|
||||
])->values()->all();
|
||||
if ($links === []) {
|
||||
$links = [['label' => 'Amazon', 'url' => '']];
|
||||
}
|
||||
return [
|
||||
'title' => $b['title'] ?? '',
|
||||
'description' => $b['description'] ?? '',
|
||||
'cover_path' => $b['cover_path'] ?? null,
|
||||
'buy_links' => $links,
|
||||
];
|
||||
})->values()->all();
|
||||
if ($books === []) {
|
||||
$books = [['title' => '', 'description' => '', 'cover_path' => null, 'buy_links' => [['label' => 'Amazon', 'url' => '']]]];
|
||||
}
|
||||
@endphp
|
||||
<div x-data="{
|
||||
books: @js($books),
|
||||
addBook() { this.books.push({ title: '', description: '', cover_path: null, buy_links: [{ label: 'Amazon', url: '' }] }) },
|
||||
removeBook(i) { this.books.splice(i, 1); if (this.books.length === 0) this.addBook() },
|
||||
addLink(b) { this.books[b].buy_links.push({ label: '', url: '' }) },
|
||||
removeLink(b, l) { this.books[b].buy_links.splice(l, 1); if (this.books[b].buy_links.length === 0) this.addLink(b) },
|
||||
}" class="space-y-4">
|
||||
<div class="grid gap-3">
|
||||
<input type="text" name="author_name" value="{{ $c['author_name'] ?? '' }}" placeholder="Author name *" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||
<input type="text" name="tagline" value="{{ $c['tagline'] ?? '' }}" placeholder="Tagline" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||
<textarea name="bio" rows="3" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">{{ $c['bio'] ?? '' }}</textarea>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input type="email" name="email" value="{{ $c['email'] ?? '' }}" placeholder="Email" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||
<input type="url" name="website" value="{{ $c['website'] ?? '' }}" placeholder="Website" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||
</div>
|
||||
<input type="color" name="brand_color" value="{{ $c['brand_color'] ?? '#0f766e' }}" class="h-10 w-full rounded-xl border border-slate-200 p-1">
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Replace author photo</label>
|
||||
<input type="file" name="bookshop_avatar" accept="image/*" class="mt-1 block w-full text-xs">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Replace cover banner</label>
|
||||
<input type="file" name="bookshop_cover" accept="image/*" class="mt-1 block w-full text-xs">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
@foreach(['instagram','twitter','facebook','youtube','linkedin'] as $platform)
|
||||
<input type="text" name="social[{{ $platform }}]" value="{{ $c['social'][$platform] ?? '' }}" placeholder="{{ ucfirst($platform) }}"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold text-slate-600">Books</p>
|
||||
<button type="button" @click="addBook()" class="text-xs font-semibold text-indigo-600">+ Add book</button>
|
||||
</div>
|
||||
<template x-for="(book, b) in books" :key="b">
|
||||
<div class="space-y-2 rounded-xl border border-slate-200 p-3">
|
||||
<input type="hidden" :name="'books['+b+'][cover_path]'" :value="book.cover_path || ''">
|
||||
<input type="text" :name="'books['+b+'][title]'" x-model="book.title" placeholder="Title *" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||
<textarea :name="'books['+b+'][description]'" x-model="book.description" rows="2" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm"></textarea>
|
||||
<input type="file" :name="'book_covers['+b+']'" accept="image/*" class="block w-full text-xs">
|
||||
<div class="space-y-2 rounded-lg border border-dashed border-slate-200 p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-[11px] font-semibold text-slate-500">Buy links</p>
|
||||
<button type="button" @click="addLink(b)" class="text-[11px] font-semibold text-indigo-600">+ Link</button>
|
||||
</div>
|
||||
<template x-for="(link, l) in book.buy_links" :key="l">
|
||||
<div class="grid gap-2 sm:grid-cols-[1fr_2fr_auto]">
|
||||
<input type="text" :name="'books['+b+'][buy_links]['+l+'][label]'" x-model="link.label" placeholder="Amazon" class="rounded-lg border border-slate-200 px-2.5 py-1.5 text-sm">
|
||||
<input type="url" :name="'books['+b+'][buy_links]['+l+'][url]'" x-model="link.url" placeholder="https://…" class="rounded-lg border border-slate-200 px-2.5 py-1.5 text-sm">
|
||||
<button type="button" @click="removeLink(b, l)" class="text-xs text-red-600">Remove</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button type="button" @click="removeBook(b)" class="text-xs text-red-600">Remove book</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@elseif($qrCode->type === \App\Models\QrCode::TYPE_APP)
|
||||
<div x-data="{ iconPreview: null }" class="grid gap-3">
|
||||
<input type="text" name="app_name" value="{{ $c['name'] ?? '' }}" placeholder="App name"
|
||||
|
||||
@@ -339,52 +339,5 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Danger zone: delete this QR code (type-to-confirm, not a one-click delete) --}}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50/40 p-5 shadow-sm"
|
||||
x-data="{ open: false, confirm: '' }">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-red-800">Delete this QR code</h3>
|
||||
<p class="mt-1 text-sm text-red-700/80">The printed code stops working permanently and its scan history is removed. This can't be undone.</p>
|
||||
</div>
|
||||
<button type="button" @click="open = true; confirm = ''"
|
||||
class="inline-flex shrink-0 items-center justify-center rounded-xl border border-red-300 bg-white px-4 py-2 text-sm font-semibold text-red-700 transition hover:bg-red-100">
|
||||
Delete code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="open" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@keydown.escape.window="open = false">
|
||||
<div class="absolute inset-0 bg-slate-900/40" @click="open = false"></div>
|
||||
<div class="relative w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h3 class="text-base font-bold text-slate-900">Delete “{{ $qrCode->label }}”?</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
This permanently deletes the code and its scan history. Anyone who scans the printed code will get an error.
|
||||
<span class="font-semibold">This cannot be undone.</span>
|
||||
</p>
|
||||
<label class="mt-4 block text-sm font-medium text-slate-700">
|
||||
Type <span class="font-semibold text-slate-900">{{ $qrCode->label }}</span> to confirm
|
||||
</label>
|
||||
<input type="text" x-model="confirm" autocomplete="off" autofocus
|
||||
class="mt-1.5 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-red-400 focus:ring-2 focus:ring-red-100 focus:outline-none"
|
||||
placeholder="{{ $qrCode->label }}">
|
||||
<div class="mt-5 flex items-center justify-end gap-2">
|
||||
<button type="button" @click="open = false"
|
||||
class="rounded-xl px-4 py-2 text-sm font-semibold text-slate-600 hover:bg-slate-100">Cancel</button>
|
||||
<form action="{{ route('user.qr-codes.destroy', $qrCode) }}" method="POST">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit"
|
||||
:disabled="confirm.trim() !== @js($qrCode->label)"
|
||||
class="rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
Delete permanently
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-user-layout>
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Analytics</x-slot>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Analytics</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Scan activity across all your QR Plus codes.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['total_scans']) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Total scans</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['unique_scans']) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Unique scans</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['scans_7d']) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Last 7 days</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['scans_30d']) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Last 30 days</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Scans — last 30 days</h2>
|
||||
@php $maxDaily = max(1, $dailyScans->max('total')); @endphp
|
||||
<div class="mt-5 flex h-28 items-end gap-px">
|
||||
@foreach($dailyScans as $day)
|
||||
<div class="group relative flex flex-1 flex-col items-center justify-end"
|
||||
title="{{ $day->date }}: {{ $day->total }}">
|
||||
<div class="w-full rounded-t-sm bg-indigo-400/70 transition-colors group-hover:bg-indigo-600"
|
||||
style="height: {{ max(2, ($day->total / $maxDaily) * 100) }}%"></div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Devices</h2>
|
||||
<ul class="mt-4 space-y-2.5">
|
||||
@forelse($devices as $row)
|
||||
<li class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-600">{{ ucfirst($row['label']) }}</span>
|
||||
<span class="font-semibold text-slate-900">{{ number_format($row['total']) }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-sm text-slate-400">No scans yet</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Browsers</h2>
|
||||
<ul class="mt-4 space-y-2.5">
|
||||
@forelse($browsers as $row)
|
||||
<li class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-600">{{ $row['label'] }}</span>
|
||||
<span class="font-semibold text-slate-900">{{ number_format($row['total']) }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-sm text-slate-400">No scans yet</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-6 py-4">
|
||||
<h2 class="font-semibold text-slate-900">Top codes</h2>
|
||||
</div>
|
||||
@if($topCodes->isEmpty())
|
||||
<p class="px-6 py-8 text-sm text-slate-500">No scan data yet.</p>
|
||||
@else
|
||||
<div class="divide-y divide-slate-100">
|
||||
@foreach($topCodes as $code)
|
||||
<div class="flex items-center justify-between px-6 py-4">
|
||||
<a href="{{ route('user.qr-codes.show', $code) }}" class="font-medium text-indigo-600 hover:text-indigo-800">{{ $code->label }}</a>
|
||||
<span class="text-sm text-slate-600">{{ number_format($code->scans_total) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-6 py-4">
|
||||
<h2 class="font-semibold text-slate-900">Recent scans</h2>
|
||||
</div>
|
||||
@if($recentScans->isEmpty())
|
||||
<p class="px-6 py-8 text-sm text-slate-500">No scans recorded yet.</p>
|
||||
@else
|
||||
<div class="divide-y divide-slate-100">
|
||||
@foreach($recentScans as $event)
|
||||
<div class="px-6 py-4">
|
||||
<p class="text-sm font-medium text-slate-900">{{ $event->qrCode?->label ?? 'QR code' }}</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
{{ ucfirst($event->device_type ?? 'unknown') }}
|
||||
@if($event->browser)
|
||||
· {{ $event->browser }}
|
||||
@endif
|
||||
· {{ $event->scanned_at->diffForHumans() }}
|
||||
</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -1,42 +0,0 @@
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-slate-700">Name</label>
|
||||
<input type="text" name="name" id="name" required value="{{ old('name', $campaign->name) }}"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
placeholder="e.g. Store opening · April">
|
||||
@error('name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-slate-700">Description <span class="font-normal text-slate-400">(optional)</span></label>
|
||||
<textarea name="description" id="description" rows="3"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
placeholder="What this campaign is for">{{ old('description', $campaign->description) }}</textarea>
|
||||
@error('description')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-slate-700">Status</label>
|
||||
<select name="status" id="status" class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
@foreach (\App\Models\Campaign::STATUSES as $value => $label)
|
||||
<option value="{{ $value }}" @selected(old('status', $campaign->status ?? 'active') === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('status')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="starts_at" class="block text-sm font-medium text-slate-700">Starts <span class="font-normal text-slate-400">(optional)</span></label>
|
||||
<input type="date" name="starts_at" id="starts_at"
|
||||
value="{{ old('starts_at', optional($campaign->starts_at)->format('Y-m-d')) }}"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
@error('starts_at')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="ends_at" class="block text-sm font-medium text-slate-700">Ends <span class="font-normal text-slate-400">(optional)</span></label>
|
||||
<input type="date" name="ends_at" id="ends_at"
|
||||
value="{{ old('ends_at', optional($campaign->ends_at)->format('Y-m-d')) }}"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
@error('ends_at')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,20 +0,0 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">New campaign</x-slot>
|
||||
|
||||
<div class="mx-auto max-w-xl space-y-6">
|
||||
<div>
|
||||
<a href="{{ route('qr.campaigns.index') }}" class="text-sm text-slate-500 hover:text-slate-700">← Campaigns</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-slate-900">New campaign</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Name a launch or promo, then attach QR codes on the next screen.</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('qr.campaigns.store') }}" class="space-y-5 rounded-xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
@include('qr.campaigns._form', ['campaign' => $campaign])
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="{{ route('qr.campaigns.index') }}" class="rounded-xl px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">Cancel</a>
|
||||
<button type="submit" class="btn-primary">Create campaign</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -1,100 +0,0 @@
|
||||
@php
|
||||
$statusColor = [
|
||||
'draft' => 'bg-slate-100 text-slate-700',
|
||||
'active' => 'bg-emerald-50 text-emerald-700',
|
||||
'archived' => 'bg-amber-50 text-amber-800',
|
||||
];
|
||||
@endphp
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Campaigns</x-slot>
|
||||
|
||||
<div class="space-y-6">
|
||||
@foreach (['success', 'error'] as $flash)
|
||||
@if (session($flash))
|
||||
<div class="rounded-lg border px-4 py-3 {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
|
||||
<p class="text-sm">{{ session($flash) }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
<div class="relative overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-violet-50/80 via-white to-indigo-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-violet-100 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||
Group codes · Track performance
|
||||
</div>
|
||||
<h1 class="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Campaigns</h1>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-600">
|
||||
Organise QR codes by launch, promo, or channel and see combined scan totals in one place.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<a href="{{ route('qr.campaigns.create') }}" 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>
|
||||
New campaign
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
<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">{{ number_format($campaignCount) }}</p>
|
||||
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Campaigns</p>
|
||||
</div>
|
||||
<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">{{ number_format($activeCount) }}</p>
|
||||
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Active</p>
|
||||
</div>
|
||||
<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">{{ number_format($codesInCampaigns) }}</p>
|
||||
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Codes assigned</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-6 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">All campaigns</h2>
|
||||
</div>
|
||||
@if ($campaigns->isEmpty())
|
||||
<div class="px-6 py-12 text-center">
|
||||
<p class="text-sm text-slate-500">No campaigns yet. Create one to group QR codes for a promo or launch.</p>
|
||||
</div>
|
||||
@else
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-5 py-3">Campaign</th>
|
||||
<th class="hidden px-5 py-3 sm:table-cell">Codes</th>
|
||||
<th class="hidden px-5 py-3 md:table-cell">Scans</th>
|
||||
<th class="px-5 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@foreach ($campaigns as $campaign)
|
||||
<tr class="cursor-pointer hover:bg-slate-50" onclick="window.location='{{ route('qr.campaigns.show', $campaign) }}'">
|
||||
<td class="px-5 py-3">
|
||||
<p class="font-medium text-slate-900">{{ $campaign->name }}</p>
|
||||
@if ($campaign->description)
|
||||
<p class="mt-0.5 line-clamp-1 text-xs text-slate-500">{{ $campaign->description }}</p>
|
||||
@endif
|
||||
</td>
|
||||
<td class="hidden px-5 py-3 text-slate-600 sm:table-cell">{{ number_format($campaign->qr_codes_count) }}</td>
|
||||
<td class="hidden px-5 py-3 text-slate-600 md:table-cell">{{ number_format((int) $campaign->qr_codes_sum_scans_total) }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {{ $statusColor[$campaign->status] ?? 'bg-slate-100 text-slate-700' }}">
|
||||
{{ $campaign->statusLabel() }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="border-t border-slate-100 px-5 py-3">{{ $campaigns->links() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -1,115 +0,0 @@
|
||||
@php
|
||||
$statusColor = [
|
||||
'draft' => 'bg-slate-100 text-slate-700',
|
||||
'active' => 'bg-emerald-50 text-emerald-700',
|
||||
'archived' => 'bg-amber-50 text-amber-800',
|
||||
];
|
||||
@endphp
|
||||
<x-user-layout>
|
||||
<x-slot name="title">{{ $campaign->name }}</x-slot>
|
||||
|
||||
<div class="mx-auto max-w-5xl space-y-6">
|
||||
@foreach (['success', 'error'] as $flash)
|
||||
@if (session($flash))
|
||||
<div class="rounded-lg border px-4 py-3 {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
|
||||
<p class="text-sm">{{ session($flash) }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<a href="{{ route('qr.campaigns.index') }}" class="text-sm text-slate-500 hover:text-slate-700">← Campaigns</a>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<h1 class="text-2xl font-semibold text-slate-900">{{ $campaign->name }}</h1>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {{ $statusColor[$campaign->status] ?? 'bg-slate-100 text-slate-700' }}">
|
||||
{{ $campaign->statusLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
@if ($campaign->description)
|
||||
<p class="mt-1 text-sm text-slate-600">{{ $campaign->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<form method="POST" action="{{ route('qr.campaigns.destroy', $campaign) }}" onsubmit="return confirm('Delete this campaign? Codes stay; they are only unassigned.')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="text-sm text-red-600 hover:text-red-700">Delete campaign</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($codes->count()) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">QR codes</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($totalScans) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Total scans</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4 col-span-2 sm:col-span-1">
|
||||
<p class="text-sm font-semibold text-slate-900">
|
||||
@if ($campaign->starts_at || $campaign->ends_at)
|
||||
{{ optional($campaign->starts_at)->format('d M Y') ?? '…' }}
|
||||
→
|
||||
{{ optional($campaign->ends_at)->format('d M Y') ?? '…' }}
|
||||
@else
|
||||
No date range
|
||||
@endif
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Schedule</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('qr.campaigns.update', $campaign) }}" class="space-y-5 rounded-xl border border-slate-200 bg-white p-6">
|
||||
@csrf @method('PATCH')
|
||||
<h2 class="text-base font-semibold text-slate-900">Campaign details</h2>
|
||||
@include('qr.campaigns._form', ['campaign' => $campaign])
|
||||
<button type="submit" class="btn-primary">Save changes</button>
|
||||
</form>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-6 py-4">
|
||||
<h2 class="text-base font-semibold text-slate-900">QR codes in this campaign</h2>
|
||||
</div>
|
||||
@if ($codes->isEmpty())
|
||||
<p class="px-6 py-8 text-center text-sm text-slate-500">No codes assigned yet.</p>
|
||||
@else
|
||||
<ul class="divide-y divide-slate-100">
|
||||
@foreach ($codes as $code)
|
||||
<li class="flex items-center justify-between gap-4 px-6 py-3">
|
||||
<a href="{{ route('user.qr-codes.show', $code) }}" class="min-w-0 flex-1 hover:text-indigo-700">
|
||||
<p class="truncate text-sm font-medium text-slate-900">{{ $code->label ?: $code->short_code }}</p>
|
||||
<p class="truncate text-xs text-slate-500">{{ $code->publicUrl() }} · {{ number_format($code->scans_total) }} scans</p>
|
||||
</a>
|
||||
<form method="POST" action="{{ route('qr.campaigns.detach', [$campaign, $code]) }}">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="text-xs font-medium text-red-600 hover:text-red-800">Remove</button>
|
||||
</form>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@php
|
||||
$unassigned = $availableCodes->whereNull('campaign_id');
|
||||
@endphp
|
||||
@if ($unassigned->isNotEmpty())
|
||||
<form method="POST" action="{{ route('qr.campaigns.attach', $campaign) }}" class="space-y-4 rounded-xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<h2 class="text-base font-semibold text-slate-900">Add QR codes</h2>
|
||||
<div class="max-h-56 space-y-2 overflow-y-auto rounded-lg border border-slate-100 p-3">
|
||||
@foreach ($unassigned as $code)
|
||||
<label class="flex items-center gap-3 rounded-lg px-2 py-1.5 text-sm hover:bg-slate-50">
|
||||
<input type="checkbox" name="qr_code_ids[]" value="{{ $code->id }}" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
<span class="font-medium text-slate-900">{{ $code->label ?: $code->short_code }}</span>
|
||||
<span class="text-slate-400"> · {{ $code->short_code }}</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Add selected</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -1,24 +1,10 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Overview</x-slot>
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="space-y-6"
|
||||
x-data="balanceGate({
|
||||
balance: @js((float) ($balanceMinor / 100)),
|
||||
price: @js((float) $pricePerQr),
|
||||
modalId: 'qr',
|
||||
href: @js(route('user.qr-codes.create')),
|
||||
})">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-lg font-semibold text-slate-900 lg:text-xl">QR Plus</h1>
|
||||
<p class="mt-1 hidden text-sm text-slate-500 sm:block">Dynamic utility QR codes — links, WiFi, business pages & more.</p>
|
||||
</div>
|
||||
@include('partials.mobile-header-btn', [
|
||||
'href' => route('user.qr-codes.create'),
|
||||
'label' => 'Create code',
|
||||
'variant' => 'indigo',
|
||||
'attributes' => '@click="goOrTopup($event)"',
|
||||
])
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">QR Plus</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Dynamic utility QR codes — links, WiFi, business pages & more.</p>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
@@ -37,16 +23,10 @@
|
||||
<div class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<h2 class="font-semibold text-slate-900">Recent codes</h2>
|
||||
@include('partials.mobile-icon-link', [
|
||||
'href' => route('user.qr-codes.create'),
|
||||
'label' => 'Create code',
|
||||
'icon' => 'plus',
|
||||
'desktopClass' => 'text-sm font-medium text-indigo-600 hover:text-indigo-800',
|
||||
'attributes' => '@click="goOrTopup($event)"',
|
||||
])
|
||||
<a href="{{ route('user.qr-codes.create') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Create code</a>
|
||||
</div>
|
||||
@if($recentCodes->isEmpty())
|
||||
<p class="px-6 py-8 text-sm text-slate-500">No QR codes yet. <a href="{{ route('user.qr-codes.create') }}" @click="goOrTopup($event)" class="font-medium text-indigo-600">Create your first code</a>.</p>
|
||||
<p class="px-6 py-8 text-sm text-slate-500">No QR codes yet. <a href="{{ route('user.qr-codes.create') }}" class="font-medium text-indigo-600">Create your first code</a>.</p>
|
||||
@else
|
||||
<div class="divide-y divide-slate-100">
|
||||
@foreach($recentCodes as $code)
|
||||
@@ -61,17 +41,5 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Two-step add-funds modal (create links open this when balance is too low) --}}
|
||||
<x-user.service-topup-modal
|
||||
id="qr"
|
||||
title="Add funds"
|
||||
:topup-action="route('user.wallet.topup')"
|
||||
:price-per-action="(float) $pricePerQr"
|
||||
action-noun="QR code"
|
||||
:service-balance="(float) ($balanceMinor / 100)"
|
||||
service-balance-label="Wallet balance"
|
||||
:return-url="route('user.qr-codes.create')"
|
||||
:open-on-load="session('open_topup_modal') === 'qr'" />
|
||||
</div>
|
||||
</x-user-layout>
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ProductLandingController;
|
||||
use App\Http\Controllers\Auth\SsoLoginController;
|
||||
use App\Http\Controllers\WalletBalanceController;
|
||||
use App\Http\Controllers\WalletTopupController;
|
||||
use App\Http\Controllers\NotificationController;
|
||||
use App\Http\Controllers\Public\QrScanController;
|
||||
use App\Http\Controllers\Qr\AccountController;
|
||||
use App\Http\Controllers\Qr\AfiaController;
|
||||
use App\Http\Controllers\Qr\AnalyticsController;
|
||||
use App\Http\Controllers\Qr\DeveloperController;
|
||||
use App\Http\Controllers\Qr\CampaignController;
|
||||
use App\Http\Controllers\Qr\OverviewController;
|
||||
use App\Http\Controllers\Qr\TeamController;
|
||||
use App\Http\Controllers\Qr\QrCodeController;
|
||||
use App\Http\Controllers\SearchController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', fn () => auth()->check()
|
||||
? redirect()->route('qr.dashboard')
|
||||
: redirect()->route('sso.connect'))->name('qr.root');
|
||||
Route::get('/', [ProductLandingController::class, 'show'])->name('qrplus.landing');
|
||||
|
||||
Route::get('/login', [SsoLoginController::class, 'connect'])->name('login');
|
||||
Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect');
|
||||
@@ -26,46 +21,25 @@ Route::get('/sso/callback', [SsoLoginController::class, 'callback'])->name('sso.
|
||||
Route::post('/logout', [SsoLoginController::class, 'logout'])->name('logout');
|
||||
Route::get('/sso/logout-bridge', [SsoLoginController::class, 'logoutBridge'])->name('sso.logout-bridge');
|
||||
Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannelLogout'])->name('sso.logout-frontchannel');
|
||||
Route::get('/sso/platform-signed-out', [SsoLoginController::class, 'platformSignedOut'])->name('sso.platform-signed-out');
|
||||
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('qr.dashboard') : view('qr.signed-out'))->name('qr.signed-out');
|
||||
|
||||
Route::middleware('redirect.legacy.qr')->group(function () {
|
||||
Route::get('/q/{shortCode}', [QrScanController::class, 'resolve'])->name('qr.public.resolve');
|
||||
Route::get('/q/{shortCode}/view', [QrScanController::class, 'view'])->name('qr.public.view');
|
||||
Route::get('/q/{shortCode}/file', [QrScanController::class, 'file'])->name('qr.public.file');
|
||||
Route::get('/q/{shortCode}/business-logo', [QrScanController::class, 'businessLogo'])->name('qr.public.business.logo');
|
||||
Route::get('/q/{shortCode}/business-cover', [QrScanController::class, 'businessCover'])->name('qr.public.business.cover');
|
||||
Route::get('/q/{shortCode}/app-icon', [QrScanController::class, 'appIcon'])->name('qr.public.app.icon');
|
||||
Route::get('/q/{shortCode}/portfolio-avatar', [QrScanController::class, 'portfolioAvatar'])->name('qr.public.portfolio.avatar');
|
||||
Route::get('/q/{shortCode}/portfolio-cover', [QrScanController::class, 'portfolioCover'])->name('qr.public.portfolio.cover');
|
||||
Route::get('/q/{shortCode}/portfolio-project/{index}', [QrScanController::class, 'portfolioProjectImage'])->name('qr.public.portfolio.project')->whereNumber('index');
|
||||
Route::get('/q/{shortCode}/bookshop-avatar', [QrScanController::class, 'bookshopAvatar'])->name('qr.public.bookshop.avatar');
|
||||
Route::get('/q/{shortCode}/bookshop-cover', [QrScanController::class, 'bookshopCover'])->name('qr.public.bookshop.cover');
|
||||
Route::get('/q/{shortCode}/bookshop-book/{index}', [QrScanController::class, 'bookshopBookCover'])->name('qr.public.bookshop.book')->whereNumber('index');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance');
|
||||
Route::post('/wallet/topup', [WalletTopupController::class, 'store'])->name('user.wallet.topup');
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
|
||||
Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread');
|
||||
Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read');
|
||||
Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read');
|
||||
|
||||
Route::get('/dashboard', [OverviewController::class, 'index'])->name('qr.dashboard');
|
||||
Route::get('/analytics', [AnalyticsController::class, 'index'])->name('qr.analytics.index');
|
||||
Route::post('/afia/chat', [AfiaController::class, 'chat'])->name('qr.afia.chat');
|
||||
Route::get('/search', SearchController::class)->name('qr.search');
|
||||
|
||||
Route::get('/campaigns', [CampaignController::class, 'index'])->name('qr.campaigns.index');
|
||||
Route::get('/campaigns/create', [CampaignController::class, 'create'])->name('qr.campaigns.create');
|
||||
Route::post('/campaigns', [CampaignController::class, 'store'])->name('qr.campaigns.store');
|
||||
Route::get('/campaigns/{campaign}', [CampaignController::class, 'show'])->name('qr.campaigns.show');
|
||||
Route::patch('/campaigns/{campaign}', [CampaignController::class, 'update'])->name('qr.campaigns.update');
|
||||
Route::delete('/campaigns/{campaign}', [CampaignController::class, 'destroy'])->name('qr.campaigns.destroy');
|
||||
Route::post('/campaigns/{campaign}/attach', [CampaignController::class, 'attach'])->name('qr.campaigns.attach');
|
||||
Route::delete('/campaigns/{campaign}/codes/{qrCode}', [CampaignController::class, 'detach'])->name('qr.campaigns.detach');
|
||||
|
||||
Route::get('/qr-codes', [QrCodeController::class, 'index'])->name('user.qr-codes.index');
|
||||
Route::get('/qr-codes/create', [QrCodeController::class, 'create'])->name('user.qr-codes.create');
|
||||
Route::post('/qr-codes', [QrCodeController::class, 'store'])->name('user.qr-codes.store');
|
||||
@@ -73,18 +47,22 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
Route::post('/qr-codes/style-preview', [QrCodeController::class, 'stylePreview'])->name('user.qr-codes.style-preview');
|
||||
Route::get('/qr-codes/{qrCode}', [QrCodeController::class, 'show'])->name('user.qr-codes.show');
|
||||
Route::patch('/qr-codes/{qrCode}', [QrCodeController::class, 'update'])->name('user.qr-codes.update');
|
||||
Route::delete('/qr-codes/{qrCode}', [QrCodeController::class, 'destroy'])->name('user.qr-codes.destroy');
|
||||
Route::post('/qr-codes/{qrCode}/canonical-image', [QrCodeController::class, 'storeCanonicalImage'])->name('user.qr-codes.canonical-image');
|
||||
Route::get('/qr-codes/{qrCode}/preview.png', [QrCodeController::class, 'preview'])->name('user.qr-codes.preview');
|
||||
Route::get('/qr-codes/{qrCode}/download/{format}', [QrCodeController::class, 'download'])->name('user.qr-codes.download')->whereIn('format', ['png', 'svg', 'pdf']);
|
||||
|
||||
Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('account.wallet');
|
||||
Route::get('/billing', fn () => redirect()->away(ladill_account_url('/billing')))->name('account.billing');
|
||||
Route::get('/wallet', [AccountController::class, 'wallet'])->name('account.wallet');
|
||||
Route::get('/billing', [AccountController::class, 'billing'])->name('account.billing');
|
||||
Route::get('/settings', [AccountController::class, 'settings'])->name('account.settings');
|
||||
Route::put('/settings', [AccountController::class, 'updateSettings'])->name('account.settings.update');
|
||||
|
||||
Route::get('/team', fn () => redirect()->away(ladill_account_url('/account/team')))->name('account.team');
|
||||
Route::get('/team', [TeamController::class, 'index'])->name('account.team');
|
||||
Route::post('/team', [TeamController::class, 'store'])->name('account.team.store');
|
||||
Route::patch('/team/{member}', [TeamController::class, 'updateRole'])->name('account.team.role');
|
||||
Route::delete('/team/{member}', [TeamController::class, 'destroy'])->name('account.team.destroy');
|
||||
Route::post('/switch-account', [TeamController::class, 'switchAccount'])->name('account.switch');
|
||||
|
||||
Route::get('/developers', fn () => redirect()->away(ladill_account_url('/account/developers')))->name('account.developers');
|
||||
Route::get('/developers', [DeveloperController::class, 'index'])->name('account.developers');
|
||||
Route::post('/developers', [DeveloperController::class, 'store'])->name('account.developers.store');
|
||||
Route::delete('/developers/{token}', [DeveloperController::class, 'destroy'])->whereNumber('token')->name('account.developers.destroy');
|
||||
});
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||