62 Commits
Author SHA1 Message Date
isaacclad a578e89a99 Sync app launcher from registry
Deploy Ladill Transfer / deploy (push) Waiting to run
2026-07-24 19:32:45 +00:00
isaacclad 219087e9c7 Let hero stats cards auto-expand to fit long values.
Deploy Ladill Transfer / deploy (push) Successful in 31s
Replace fixed mobile widths and equal grid columns with content-sized cards so figures like GHS balances and storage sizes are never clipped.
2026-07-24 14:53:57 +00:00
isaacclad 91f8dd1b92 Stop Paystack pre-modal flash on customer checkout.
Deploy Ladill Transfer / deploy (push) Successful in 1m41s
Use the shared Inline sheet, hold busy until payment UI opens, and avoid locking page scroll during the handoff.
2026-07-24 10:14:42 +00:00
isaacclad ae458c9a6e Sync launcher after restoring Merchant/Mini/Give and adding One.
Deploy Ladill Transfer / deploy (push) Successful in 52s
2026-07-21 14:24:13 +00:00
isaacclad 0c163d5628 Fix deploy tmp paths: drop unset gitea.run_attempt.
Deploy Ladill Transfer / deploy (push) Successful in 2m17s
act_runner v0.2.x does not expose gitea.run_attempt, so release archive and
workspace paths rendered with a trailing dash. Use gitea.run_id only, matching
the Meet/CRM deploy workflows.
2026-07-17 12:54:45 +00:00
isaacclad df2b4cf4bc Remove Merchant, Mini, and Give from the app launcher.
Deploy Ladill Transfer / deploy (push) Successful in 3m14s
Registry marks them soon pending BoG licensing; propagate the clean launcher so every app matches.
2026-07-15 13:29:29 +00:00
isaacclad 270d77b901 Remove desktop header search from app topbars.
Deploy Ladill Transfer / deploy (push) Successful in 1m13s
Drop the lg+ topbar search field; mobile search entry points are unchanged.
2026-07-09 06:57:33 +00:00
isaacclad 4c3cc6de6a Sync app launcher from registry
Deploy Ladill Transfer / deploy (push) Successful in 52s
2026-07-07 23:14:23 +00:00
isaacclad 1d6f1df615 Show boot splash on app open/reload for logged-in users.
Deploy Ladill Transfer / deploy (push) Successful in 1m22s
Replace the once-per-session sessionStorage guard (which suppressed the loading
screen after its first appearance) with entry-aware logic: show on app entry
and reloads, suppress on internal link navigations and back/forward.
2026-07-07 20:36:17 +00:00
isaacclad 28a3d0449c Sync app launcher from registry
Deploy Ladill Transfer / deploy (push) Successful in 46s
2026-07-07 14:43:32 +00:00
isaacclad 309533ddb0 Prefer system npm in deploy CI when portable node-gyp is broken.
Deploy Ladill Transfer / deploy (push) Successful in 49s
Falls back to downloading Node only when host npm is unavailable, fixing npm ci failures on ladill-deploy-runner.
2026-07-07 00:46:54 +00:00
isaacclad a9bd509985 Sync app launcher from registry
Deploy Ladill Transfer / deploy (push) Failing after 2s
2026-07-07 00:22:25 +00:00
isaaccladandCursor 453cb9da24 Hide redundant My files breadcrumb on the Files root page.
Deploy Ladill Transfer / deploy (push) Successful in 1m5s
Only show folder breadcrumbs when navigating inside a folder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 20:55:02 +00:00
isaaccladandCursor 49ff64eda3 Add hero sections to Transfers and Files index pages.
Deploy Ladill Transfer / deploy (push) Successful in 31s
Surface wallet balance and key stats at a glance, matching the layout used across other Ladill apps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 20:47:15 +00:00
isaaccladandCursor aa2db7186a Store transfer files on Contabo Object Storage like Ladill Meet.
Deploy Ladill Transfer / deploy (push) Successful in 50s
Add transfer-files S3 disk, config-driven uploads, and an artisan command to migrate existing local blobs to object storage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 20:01:49 +00:00
isaaccladandCursor ae84295c12 Propagate shared copy-button component and clipboard helper.
Deploy Ladill Transfer / deploy (push) Successful in 1m34s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 23:04:46 +00:00
isaacclad 84afc4bcf7 Sync mobile notification badge and dashboard icon-link partials.
Deploy Ladill Transfer / deploy (push) Successful in 45s
Align mobile unread badge with desktop and add plus icon support for panel actions.
2026-07-03 22:41:01 +00:00
isaacclad c13bf4357c Sync app launcher from registry
Deploy Ladill Transfer / deploy (push) Successful in 1m44s
2026-07-01 17:05:58 +00:00
isaaccladandCursor f13243c4b6 Throttle platform SSO session checks to cut per-request auth latency.
Deploy Ladill Transfer / deploy (push) Successful in 1m5s
Verify auth.ladill.com at most once every 90s; recommend Redis for session/cache in .env.example.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 16:51:29 +00:00
isaacclad 5e9fd3861c Add Queue to app launcher config and icon.
Deploy Ladill Transfer / deploy (push) Successful in 32s
Propagated from ladill monorepo launcher sync.
2026-06-29 22:12:15 +00:00
isaaccladandCursor 1ea5a9af03 Serve transfer landing inline for internal ladl.link proxy requests.
Deploy Ladill Transfer / deploy (push) Successful in 38s
Avoid redirecting satellite proxies back to ladl.link, which caused recipient short links to time out.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 20:55:50 +00:00
isaacclad 43ab9c7405 Sync app launcher from registry
Deploy Ladill Transfer / deploy (push) Successful in 1m8s
2026-06-29 17:53:42 +00:00
isaaccladandCursor eeeca0cfa9 Cap launcher dropdown at 15 visible apps (5 rows).
Deploy Ladill Transfer / deploy (push) Successful in 1m25s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 17:44:31 +00:00
isaaccladandCursor 64b021c582 Hide launcher down arrow when scrolled to the end.
Deploy Ladill Transfer / deploy (push) Successful in 33s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 14:48:17 +00:00
isaaccladandCursor 20dcc8cccc Revert launcher scroll rail and fade; keep down arrow hint.
Deploy Ladill Transfer / deploy (push) Successful in 43s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 14:01:42 +00:00
isaaccladandCursor 5177360395 Make launcher scroll affordance visible on open.
Deploy Ladill Transfer / deploy (push) Successful in 27s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 13:52:26 +00:00
isaacclad d4795cff3c Sync app launcher from registry
Deploy Ladill Transfer / deploy (push) Successful in 52s
2026-06-29 13:30:17 +00:00
isaacclad 16162e939e Always show launcher scroll indicator and cap at 18 apps.
Deploy Ladill Transfer / deploy (push) Successful in 58s
Apply ladill-launcher-apps scroll styles unconditionally with a persistent
scrollbar track; propagate launcher blade and CSS across all Ladill apps.
2026-06-29 13:04:01 +00:00
isaaccladandCursor 1b70ef4873 Use + icon buttons on mobile dashboards.
Deploy Ladill Transfer / deploy (push) Successful in 37s
Sync mobile-header-btn partials, mobile x-btn.create, and update dashboard header actions for consistent mobile FABs.

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 12:04:33 +00:00
isaacclad e9b0a72c1f Sync app launcher from registry — add Frontdesk.
Deploy Ladill Transfer / deploy (push) Successful in 44s
2026-06-28 01:46:49 +00:00
isaaccladandCursor 076b0f7803 Sync app launcher from registry
Deploy Ladill Transfer / deploy (push) Successful in 51s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 19:03:58 +00:00
isaaccladandCursor 575316a8fa Sync app launcher from registry
Deploy Ladill Transfer / deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 18:28:04 +00:00
isaaccladandCursor 1564eee380 Redirect legacy /q/* URLs to canonical ladl.link addresses.
Deploy Ladill Transfer / deploy (push) Successful in 33s
External /q requests 301/307 to ladl.link; internal X-Ladill-Internal requests still serve content for platform proxying.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 17:19:04 +00:00
isaaccladandCursor a171276bc2 Wire LadillLink through QrCode model and remaining QR surfaces.
Deploy Ladill Transfer / deploy (push) Successful in 31s
Public URLs, encoded payloads, slug preview, payment callbacks, and asset paths now use ladl.link instead of ladill.com/q/*.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 16:16:25 +00:00
isaaccladandCursor 7a1bc86b9e Use ladl.link URLs for all front-facing QR and short-link surfaces.
Deploy Ladill Transfer / deploy (push) Successful in 1m43s
Public landing pages, asset paths, payment callbacks, and redirects now go through QrCode::publicPath() / LadillLink instead of ladill.com/q/* routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 15:46:33 +00:00
isaacclad 0505da7b75 Sync app launcher from registry
Deploy Ladill Transfer / deploy (push) Successful in 1m0s
2026-06-27 15:16:40 +00:00
fda38ec91f Add Accounting to the app launcher
Ladill Accounting is now live at accounting.ladill.com; add its row + icon to
the shared app launcher (byte-identical across all Ladill apps).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:49:42 +00:00
isaaccladandClaude Opus 4.8 a0a706093b Transfer: two-step add-funds modal on insufficient balance
Deploy Ladill Transfer / deploy (push) Successful in 48s
Creating a transfer that the wallet can't cover used to flash a bare error.
Detect the insufficient-balance case and open an in-app two-step modal (billing
explainer -> amount -> Paystack) on the create page instead, with an 'Add funds'
button to top up on demand.

- BillingClient::topup -> POST /api/billing/topup (Transfer already holds its key)
- WalletTopupController + route POST /wallet/topup (user.wallet.topup)
- store() flashes open_topup_modal on TransferWalletErrors::isInsufficientBalance
- service-topup-modal made two-step (uses <x-modal>, plain POST -> checkout)

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:16:01 +00:00
isaaccladandClaude Opus 4.8 0712b7d6c6 Update POS launcher icon (app launcher + marketing)
Deploy Ladill Transfer / deploy (push) Successful in 28s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:47:23 +00:00
isaaccladandCursor 0a16b6578c Sync app launcher config and add POS icon.
Deploy Ladill Transfer / deploy (push) Successful in 30s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 22:53:30 +00:00
isaaccladandCursor 2673901995 Standardize desktop topbar widgets to match CRM layout.
Deploy Ladill Transfer / deploy (push) Successful in 27s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:15:44 +00:00
isaaccladandClaude Opus 4.8 004a578218 Add wallet balance widget to the profile menu (after Billing)
Deploy Ladill Transfer / deploy (push) Successful in 30s
Wallet balance peek rendered as a menu row directly under Billing, backed by
a /wallet/balance endpoint (BillingClient) and guarded by Route::has. Also
opens Afia via a direct window event for reliability.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:41:28 +00:00
isaaccladandClaude Opus 4.8 2ef007ee46 Add Invoice to the app launcher
Deploy Ladill Transfer / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:19:12 +00:00
isaaccladandClaude Opus 4.8 4a4024799c Fix dropped auth.service middleware alias breaking service APIs
Deploy Ladill Transfer / deploy (push) Successful in 32s
Middleware::alias() replaces the alias map rather than merging, so the
second alias() call (platform.session, added in d491130) silently
removed the auth.service alias. Every service-to-service route
(auth.service:transfer) then failed with "Target class [auth.service]
does not exist", returning 500 to webmail/mobile for Transfer file
listing and uploads. Merge both aliases into a single alias() call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:11:11 +00:00
isaaccladandCursor d491130b14 Bind app sessions to the platform auth session.
Deploy Ladill Transfer / deploy (push) Successful in 29s
Clear local sessions when auth.ladill.com signs out and ping the platform session from keepalive.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 08:26:30 +00:00
isaaccladandCursor ccffa1433d Route SMS voice uploads through Transfer with wallet billing.
Deploy Ladill Transfer / deploy (push) Successful in 28s
Add a service API for the Ladill SMS platform to store voice recordings in Transfer and charge monthly storage from the customer wallet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 02:23:18 +00:00
isaaccladandCursor eb9023340f Add Ladill SMS to shared app launcher.
Deploy Ladill Transfer / deploy (push) Successful in 44s
Include sms.ladill.com in the cross-app launcher and ship the SMS icon asset.

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 13:09:17 +00:00
isaaccladandCursor 75fdd0255d Replace native confirm dialogs with Ladill modal for file deletes.
Deploy Ladill Transfer / deploy (push) Successful in 36s
Use the existing ladillConfirm store on the Files page and x-confirm-dialog on transfer delete so confirmations match the rest of Transfer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 23:01:23 +00:00
isaaccladandCursor ea58a13fce Add service API to list mailbox files for Ladill Mail.
Deploy Ladill Transfer / deploy (push) Successful in 1m8s
Expose GET /api/v1/transfers/files so webmail can sync the mobile Files tab with Transfer storage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 00:39:37 +00:00
isaaccladandCursor 26cc0c9a64 Send SSO guests to ladill.com/products/transfer instead of subdomain landings.
Deploy Ladill Transfer / deploy (push) Successful in 57s
Remove interactive OAuth fallback on silent SSO failure and add ladill.marketing_url config so app subdomains open the dashboard when signed in locally.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:41:28 +00:00
92 changed files with 3528 additions and 966 deletions
+22 -2
View File
@@ -17,6 +17,11 @@ DB_DATABASE=ladill_transfer
DB_USERNAME=ladill_transfer DB_USERNAME=ladill_transfer
DB_PASSWORD= DB_PASSWORD=
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# Read platform admin settings (Paystack keys live in ladilldb.platform_settings). # Read platform admin settings (Paystack keys live in ladilldb.platform_settings).
PLATFORM_DB_HOST=127.0.0.1 PLATFORM_DB_HOST=127.0.0.1
PLATFORM_DB_PORT=3306 PLATFORM_DB_PORT=3306
@@ -24,8 +29,9 @@ PLATFORM_DB_DATABASE=ladilldb
PLATFORM_DB_USERNAME=ladill_transfer PLATFORM_DB_USERNAME=ladill_transfer
PLATFORM_DB_PASSWORD= PLATFORM_DB_PASSWORD=
SESSION_DRIVER=database SESSION_DRIVER=redis
SESSION_LIFETIME=120 # Idle web session length in minutes (1440 = 24 hours of inactivity).
SESSION_LIFETIME=1440
SESSION_DOMAIN=.ladill.com SESSION_DOMAIN=.ladill.com
LADILL_SSO_CLIENT_ID= LADILL_SSO_CLIENT_ID=
@@ -48,6 +54,9 @@ TRANSFER_MAIL_RETENTION_DAYS=30
# Ladill Mail — large attachment uploads (service-to-service). # Ladill Mail — large attachment uploads (service-to-service).
TRANSFER_API_KEY_WEBMAIL= TRANSFER_API_KEY_WEBMAIL=
# Ladill SMS — voice message uploads (must match SMS_TRANSFER_API_KEY on platform).
TRANSFER_API_KEY_SMS=
TRANSFER_SMS_VOICE_MAX_UPLOAD_KB=5120
AFIA_ENABLED=true AFIA_ENABLED=true
AFIA_PRODUCT=transfer AFIA_PRODUCT=transfer
@@ -55,4 +64,15 @@ AFIA_PROVIDER=openai
AFIA_MODEL=gpt-4o-mini AFIA_MODEL=gpt-4o-mini
AFIA_API_KEY= AFIA_API_KEY=
LINK_PUBLIC_DOMAIN=ladl.link
# Contabo Object Storage (S3-compatible) — same bucket/account as Ladill Meet (`ladill`).
TRANSFER_FILES_DISK=transfer-files
TRANSFER_S3_BUCKET=ladill
TRANSFER_S3_REGION=default
TRANSFER_S3_ENDPOINT=https://eu2.contabostorage.com
TRANSFER_S3_USE_PATH_STYLE=true
TRANSFER_S3_ACCESS_KEY=
TRANSFER_S3_SECRET=
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
+17 -15
View File
@@ -19,8 +19,9 @@ jobs:
env: env:
NODE_ROOT: /tmp/ladill-node-22-r1 NODE_ROOT: /tmp/ladill-node-22-r1
NODE_VERSION: "22.14.0" NODE_VERSION: "22.14.0"
RELEASE_ARCHIVE: /tmp/ladill-transfer-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz # act_runner v0.2.x does not expose gitea.run_attempt — use run_id only.
WORKSPACE: /tmp/${{ gitea.repository_owner }}-transfer-${{ gitea.run_id }}-${{ gitea.run_attempt }} RELEASE_ARCHIVE: /tmp/ladill-transfer-release-${{ gitea.run_id }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-transfer-${{ gitea.run_id }}
LADILL_APP_ROOT: /var/www/ladill-transfer LADILL_APP_ROOT: /var/www/ladill-transfer
steps: steps:
- name: Checkout - name: Checkout
@@ -38,11 +39,17 @@ jobs:
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \ clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
"$REPO_URL" "$WORKSPACE" "$REPO_URL" "$WORKSPACE"
- name: Setup Node.js - name: Build frontend assets
shell: bash {0} shell: bash {0}
run: | run: |
set -Eeuo pipefail set -Eeuo pipefail
if [ ! -x "$NODE_ROOT/bin/node" ]; then cd "$WORKSPACE"
if command -v npm >/dev/null 2>&1 && npm -v >/dev/null 2>&1; then
NPM_BIN="$(command -v npm)"
elif [ -x "$NODE_ROOT/bin/npm" ]; then
export PATH="$NODE_ROOT/bin:$PATH"
NPM_BIN="$NODE_ROOT/bin/npm"
else
rm -rf "$NODE_ROOT" rm -rf "$NODE_ROOT"
mkdir -p "$NODE_ROOT" mkdir -p "$NODE_ROOT"
case "$(uname -m)" in case "$(uname -m)" in
@@ -52,17 +59,12 @@ jobs:
esac esac
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \ curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar -xJ --strip-components=1 -C "$NODE_ROOT" | tar -xJ --strip-components=1 -C "$NODE_ROOT"
fi
"$NODE_ROOT/bin/node" -v
- name: Build frontend assets
shell: bash {0}
run: |
set -Eeuo pipefail
cd "$WORKSPACE"
export PATH="$NODE_ROOT/bin:$PATH" export PATH="$NODE_ROOT/bin:$PATH"
npm ci --no-audit --no-fund NPM_BIN="$NODE_ROOT/bin/npm"
npm run build fi
"$NPM_BIN" ci --no-audit --no-fund
"$NPM_BIN" run build
- name: Build release archive - name: Build release archive
shell: bash {0} shell: bash {0}
@@ -78,7 +80,7 @@ jobs:
- name: Deploy release - name: Deploy release
shell: bash {0} shell: bash {0}
env: env:
LADILL_RELEASE_ARCHIVE: /tmp/ladill-transfer-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz LADILL_RELEASE_ARCHIVE: /tmp/ladill-transfer-release-${{ gitea.run_id }}.tgz
run: | run: |
set -Eeuo pipefail set -Eeuo pipefail
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}" : "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
@@ -0,0 +1,131 @@
<?php
namespace App\Console\Commands;
use App\Models\TransferFile;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class MigrateTransferStorageCommand extends Command
{
protected $signature = 'transfer:migrate-storage
{--from=qr : Source disk for legacy transfer blobs}
{--to= : Target disk (defaults to transfer.files_disk config)}
{--dry-run : List files without copying or updating records}
{--delete-source : Remove local blobs after a successful upload}';
protected $description = 'Copy transfer file blobs from local disk to Contabo object storage and update transfer_files.disk';
public function handle(): int
{
$fromDisk = (string) $this->option('from');
$toDisk = (string) ($this->option('to') ?: config('transfer.files_disk', 'transfer-files'));
$dryRun = (bool) $this->option('dry-run');
$deleteSource = (bool) $this->option('delete-source');
if ($fromDisk === $toDisk) {
$this->error('Source and target disks must differ.');
return self::FAILURE;
}
if (! config("filesystems.disks.{$fromDisk}") || ! config("filesystems.disks.{$toDisk}")) {
$this->error('Unknown filesystem disk configured for migration.');
return self::FAILURE;
}
$files = TransferFile::query()
->where('disk', $fromDisk)
->orderBy('id')
->get();
if ($files->isEmpty()) {
$this->info("No transfer files on disk [{$fromDisk}].");
return self::SUCCESS;
}
$this->info(sprintf(
'%s migrating %d file(s) from [%s] to [%s].',
$dryRun ? 'Would migrate' : 'Migrating',
$files->count(),
$fromDisk,
$toDisk,
));
$migrated = 0;
$skipped = 0;
$failed = 0;
foreach ($files as $file) {
$label = "#{$file->id} {$file->path}";
if (! Storage::disk($fromDisk)->exists($file->path)) {
$this->warn("Missing source blob: {$label}");
$failed++;
continue;
}
if (Storage::disk($toDisk)->exists($file->path)) {
if (! $dryRun) {
$file->update(['disk' => $toDisk]);
if ($deleteSource) {
Storage::disk($fromDisk)->delete($file->path);
}
}
$this->line("Already on target, updated record: {$label}");
$skipped++;
continue;
}
if ($dryRun) {
$this->line("Would migrate: {$label}");
$migrated++;
continue;
}
try {
$stream = Storage::disk($fromDisk)->readStream($file->path);
if ($stream === false) {
throw new \RuntimeException('Could not open source stream.');
}
$written = Storage::disk($toDisk)->writeStream($file->path, $stream);
if (is_resource($stream)) {
fclose($stream);
}
if ($written === false) {
throw new \RuntimeException('Upload to target disk failed.');
}
if (! Storage::disk($toDisk)->exists($file->path)) {
throw new \RuntimeException('Uploaded blob not found on target disk.');
}
$file->update(['disk' => $toDisk]);
if ($deleteSource) {
Storage::disk($fromDisk)->delete($file->path);
}
$this->line("Migrated: {$label}");
$migrated++;
} catch (\Throwable $e) {
$this->error("Failed {$label}: {$e->getMessage()}");
$failed++;
}
}
$this->newLine();
$this->info("Done. migrated={$migrated} skipped={$skipped} failed={$failed}");
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Identity\PublicIdUserResolver;
use App\Services\Transfer\TransferService;
use App\Support\DirectTransferFileUrl;
use App\Support\TransferWalletErrors;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use RuntimeException;
/** Voice SMS audio uploads from the Ladill SMS platform (storage billed via Transfer). */
class SmsVoiceTransferController extends Controller
{
public function __construct(
private PublicIdUserResolver $users,
private TransferService $transfers,
) {}
public function store(Request $request): JsonResponse
{
if ($request->attributes->get('service_caller') !== 'sms') {
return response()->json(['message' => 'Forbidden.'], 403);
}
$maxKb = max(256, (int) config('transfer.sms_voice_max_upload_kb', 5120));
$data = $request->validate([
'user' => ['required', 'string', 'max:64'],
'title' => ['nullable', 'string', 'max:120'],
'voice' => ['required', 'file', 'mimes:mp3,wav', 'max:'.$maxKb],
]);
try {
$user = $this->users->resolve((string) $data['user']);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
$voice = $request->file('voice');
$filename = $voice?->getClientOriginalName() ?: 'voice.mp3';
$title = trim((string) ($data['title'] ?? ''));
if ($title === '') {
$title = Str::limit('SMS voice: '.$filename, 120, '');
}
try {
$transfer = $this->transfers->create($user, [
'title' => $title,
], [$voice]);
} catch (RuntimeException $e) {
$payload = ['message' => $e->getMessage()];
if (TransferWalletErrors::isInsufficientBalance($e)) {
$payload['error_code'] = TransferWalletErrors::CODE_INSUFFICIENT_WALLET;
}
return response()->json($payload, 422);
}
$transfer->load(['files', 'qrCode']);
$file = $transfer->files->first();
if ($file === null) {
return response()->json(['message' => 'Voice upload failed.'], 422);
}
return response()->json([
'data' => [
'direct_file_url' => DirectTransferFileUrl::for($transfer, $file),
'public_url' => $transfer->qrCode?->publicUrl(),
'transfer_id' => $transfer->id,
'file_id' => $file->id,
'paid_until' => $transfer->paid_until?->toIso8601String(),
'storage_gb' => $transfer->storageGb(),
'monthly_cost_ghs' => $transfer->monthlyCostGhs(),
],
], 201);
}
}
@@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Transfer;
use App\Models\TransferFile;
use App\Services\Identity\MailboxUserResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
/** Service-to-service file listing for Ladill Mail (synced with Transfer Files). */
class TransferFilesController extends Controller
{
public function __construct(private MailboxUserResolver $users) {}
public function index(Request $request): JsonResponse
{
if ($request->attributes->get('service_caller') !== 'webmail') {
return response()->json(['message' => 'Forbidden.'], 403);
}
$data = $request->validate([
'mailbox' => ['required', 'email', 'max:255'],
'q' => ['nullable', 'string', 'max:120'],
'page' => ['nullable', 'integer', 'min:1'],
'sort' => ['nullable', 'in:newest,oldest,name,size'],
]);
try {
$user = $this->users->resolve((string) $data['mailbox']);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
$search = trim((string) ($data['q'] ?? ''));
$sort = (string) ($data['sort'] ?? 'newest');
$filesQuery = TransferFile::query()
->whereHas('transfer', fn ($q) => $q
->where('user_id', $user->id)
->where('status', '!=', Transfer::STATUS_DELETED)
->where(function ($query) {
$query->where('is_root_storage', true)
->orWhere(function ($nested) {
$nested->where('is_folder', false)->where('is_root_storage', false);
});
}))
->when($search !== '', fn ($q) => $q->where('original_name', 'like', '%'.$search.'%'))
->with(['transfer.qrCode'])
->when($sort === 'oldest', fn ($q) => $q->oldest())
->when($sort === 'name', fn ($q) => $q->orderBy('original_name'))
->when($sort === 'size', fn ($q) => $q->orderByDesc('size_bytes'))
->when($sort === 'newest', fn ($q) => $q->latest());
$paginator = $filesQuery->paginate(30, ['*'], 'page', (int) ($data['page'] ?? 1));
$items = collect($paginator->items())->map(function (TransferFile $file) {
$transfer = $file->transfer;
$title = (string) ($transfer?->title ?? '');
return [
'id' => $file->id,
'name' => $file->original_name,
'size_bytes' => (int) $file->size_bytes,
'mime_type' => $file->mime_type,
'source' => $this->sourceFor($transfer),
'public_url' => $transfer?->qrCode?->publicUrl(),
'transfer_id' => $transfer?->id,
'transfer_title' => $title !== '' ? $title : null,
'created_at' => $file->created_at?->toIso8601String(),
];
})->values();
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
private function sourceFor(?Transfer $transfer): string
{
if ($transfer === null) {
return 'transfer';
}
if ($transfer->is_root_storage) {
return 'transfer';
}
$title = strtolower(trim($transfer->title));
if (str_starts_with($title, 'email:') || str_starts_with($title, 'email attachment:')) {
return 'mail';
}
return 'transfer';
}
}
@@ -88,10 +88,7 @@ class SsoLoginController extends Controller
if ($request->filled('error')) { if ($request->filled('error')) {
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true) if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
&& ! $request->boolean('interactive')) { && ! $request->boolean('interactive')) {
return redirect()->route('sso.connect', [ return redirect()->away((string) config('ladill.marketing_url'));
'redirect' => $intended,
'interactive' => 1,
]);
} }
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup); return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
@@ -141,6 +138,18 @@ class SsoLoginController extends Controller
return redirect()->away($this->defaultSignedOutUrl()); return redirect()->away($this->defaultSignedOutUrl());
} }
/** Platform session ended — clear this app and offer silent sign-in again. */
public function platformSignedOut(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => (string) $request->query('redirect', ''),
]);
}
public function logoutBridge(Request $request): View public function logoutBridge(Request $request): View
{ {
$return = $this->safeReturnUrl((string) $request->query('return', '')); $return = $this->safeReturnUrl((string) $request->query('return', ''));
@@ -3,8 +3,10 @@
namespace App\Http\Controllers\Public; namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Public\TransferPublicController;
use App\Models\QrCode; use App\Models\QrCode;
use App\Services\Qr\QrScanRecorder; use App\Services\Qr\QrScanRecorder;
use App\Support\LadillLink;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -36,7 +38,7 @@ class QrScanController extends Controller
} }
if ($qrCode->isDocumentType()) { if ($qrCode->isDocumentType()) {
return redirect()->route('qr.public.view', $shortCode); return redirect()->away($qrCode->publicPath('view'));
} }
if ($qrCode->isBookType()) { if ($qrCode->isBookType()) {
@@ -44,7 +46,11 @@ class QrScanController extends Controller
} }
if ($qrCode->isTransferType()) { if ($qrCode->isTransferType()) {
return redirect()->route('qr.public.transfer', $shortCode); if (LadillLink::isInternalRequest($request)) {
return app(TransferPublicController::class)->landing($request, $shortCode);
}
return redirect()->away($qrCode->publicPath('transfer'));
} }
if ($qrCode->isPaymentType()) { if ($qrCode->isPaymentType()) {
@@ -70,7 +76,7 @@ class QrScanController extends Controller
return view('public.qr.document-viewer', [ return view('public.qr.document-viewer', [
'qrCode' => $qrCode, 'qrCode' => $qrCode,
'fileUrl' => route('qr.public.file', $shortCode), 'fileUrl' => $qrCode->publicPath('file'),
'allowDownload' => (bool) ($qrCode->content()['allow_download'] ?? true), 'allowDownload' => (bool) ($qrCode->content()['allow_download'] ?? true),
]); ]);
} }
@@ -60,7 +60,7 @@ class TransferPublicController extends Controller
$request->session()->put("transfer_unlocked.{$transfer->id}", true); $request->session()->put("transfer_unlocked.{$transfer->id}", true);
return redirect()->route('qr.public.transfer', $shortCode); return redirect()->away($qrCode->publicPath('transfer'));
} }
public function download(Request $request, string $shortCode, TransferFile $file): StreamedResponse public function download(Request $request, string $shortCode, TransferFile $file): StreamedResponse
+1 -1
View File
@@ -313,7 +313,7 @@ class QrCodeController extends Controller
} }
} }
$png = $this->imageGenerator->renderPng(QrCode::publicBaseUrl() . '/q/' . $shortCode, $style); $png = $this->imageGenerator->renderPng(\App\Support\LadillLink::url($shortCode), $style);
if ($tempLogoPath) { if ($tempLogoPath) {
Storage::disk('qr')->delete($tempLogoPath); Storage::disk('qr')->delete($tempLogoPath);
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\Transfer; use App\Models\Transfer;
use App\Models\TransferFile; use App\Models\TransferFile;
use App\Models\User; use App\Models\User;
use App\Services\Billing\BillingClient;
use App\Services\Transfer\FileStorageService; use App\Services\Transfer\FileStorageService;
use App\Services\Transfer\TransferRecipientMailService; use App\Services\Transfer\TransferRecipientMailService;
use App\Services\Transfer\TransferService; use App\Services\Transfer\TransferService;
@@ -24,6 +25,7 @@ class FilesController extends Controller
public function __construct( public function __construct(
private TransferService $transfers, private TransferService $transfers,
private FileStorageService $storage, private FileStorageService $storage,
private BillingClient $billing,
) {} ) {}
public function index(Request $request): View public function index(Request $request): View
@@ -88,6 +90,19 @@ class FilesController extends Controller
->where('status', '!=', Transfer::STATUS_DELETED) ->where('status', '!=', Transfer::STATUS_DELETED)
->sum('storage_bytes'); ->sum('storage_bytes');
$fileCount = TransferFile::query()
->whereHas('transfer', fn ($q) => $q
->where('user_id', $account->id)
->where('status', '!=', Transfer::STATUS_DELETED))
->count();
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (\Throwable) {
// Balance is informational on this page.
}
return view('transfer.files.index', [ return view('transfer.files.index', [
'files' => $files, 'files' => $files,
'folders' => $folders, 'folders' => $folders,
@@ -99,6 +114,8 @@ class FilesController extends Controller
'storageBytes' => (int) $storageBytes, 'storageBytes' => (int) $storageBytes,
'storageGb' => round($storageBytes / (1024 * 1024 * 1024), 2), 'storageGb' => round($storageBytes / (1024 * 1024 * 1024), 2),
'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30), 'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30),
'fileCount' => $fileCount,
'balanceMinor' => $balanceMinor,
]); ]);
} }
@@ -4,10 +4,12 @@ namespace App\Http\Controllers\Transfer;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Transfer; use App\Models\Transfer;
use App\Services\Billing\BillingClient;
use App\Services\Qr\QrImageGeneratorService; use App\Services\Qr\QrImageGeneratorService;
use App\Services\Qr\QrPdfExporter; use App\Services\Qr\QrPdfExporter;
use App\Services\Transfer\TransferService; use App\Services\Transfer\TransferService;
use App\Services\Upload\ChunkedUploadService; use App\Services\Upload\ChunkedUploadService;
use App\Support\TransferWalletErrors;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -23,6 +25,7 @@ class TransferController extends Controller
private ChunkedUploadService $chunkedUploads, private ChunkedUploadService $chunkedUploads,
private QrImageGeneratorService $imageGenerator, private QrImageGeneratorService $imageGenerator,
private QrPdfExporter $pdfExporter, private QrPdfExporter $pdfExporter,
private BillingClient $billing,
) {} ) {}
public function index(Request $request): View public function index(Request $request): View
@@ -56,15 +59,51 @@ class TransferController extends Controller
->paginate(20) ->paginate(20)
->withQueryString(); ->withQueryString();
return view('transfer.transfers.index', compact('transfers', 'search', 'status', 'sort')); $transferCount = Transfer::query()
->where('user_id', $account->id)
->shareTransfers()
->where('status', '!=', Transfer::STATUS_DELETED)
->count();
$activeCount = Transfer::query()
->where('user_id', $account->id)
->shareTransfers()
->accessible()
->count();
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (\Throwable) {
// Balance is informational on this page.
} }
public function create(): View return view('transfer.transfers.index', compact(
'transfers',
'search',
'status',
'sort',
'transferCount',
'activeCount',
'balanceMinor',
));
}
public function create(BillingClient $billing): View
{ {
$account = ladill_account();
$balanceCedis = 0.0;
try {
$balanceCedis = $billing->balanceMinor((string) $account->public_id) / 100;
} catch (\Throwable) {
// Balance is informational on this page; ignore lookup failures.
}
return view('transfer.transfers.create', [ return view('transfer.transfers.create', [
'maxFiles' => (int) config('transfer.max_files_per_transfer', 20), 'maxFiles' => (int) config('transfer.max_files_per_transfer', 20),
'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30), 'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30),
'gracePeriodDays' => (int) config('transfer.grace_period_days', 15), 'gracePeriodDays' => (int) config('transfer.grace_period_days', 15),
'balanceCedis' => $balanceCedis,
]); ]);
} }
@@ -108,7 +147,12 @@ class TransferController extends Controller
try { try {
$transfer = $this->transfers->create($account, $data, $files); $transfer = $this->transfers->create($account, $data, $files);
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage()); $redirect = back()->withInput()->with('error', $e->getMessage());
if (TransferWalletErrors::isInsufficientBalance($e)) {
$redirect->with('open_topup_modal', 'transfer');
}
return $redirect;
} finally { } finally {
foreach ($uploadIds as $uploadId) { foreach ($uploadIds as $uploadId) {
$this->chunkedUploads->cleanup($uploadId); $this->chunkedUploads->cleanup($uploadId);
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Services\Billing\BillingClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/**
* Lightweight wallet balance for the avatar dropdown widget.
*/
class WalletBalanceController extends Controller
{
public function balance(Request $request, BillingClient $billing): JsonResponse
{
$publicId = (string) $request->user()->public_id;
$minor = Cache::remember("wallet_balance:{$publicId}", now()->addSeconds(30), function () use ($billing, $publicId) {
try {
return $billing->balanceMinor($publicId);
} catch (\Throwable) {
return null;
}
});
if ($minor === null) {
return response()->json(['available' => false]);
}
$currency = (string) config('billing.currency', 'GHS');
return response()->json([
'available' => true,
'balance_minor' => $minor,
'currency' => $currency,
'formatted' => $currency.' '.number_format($minor / 100, 2),
]);
}
}
@@ -0,0 +1,50 @@
<?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 billing API to start a Paystack
* checkout against the one Ladill wallet, then hands the user to checkout. Backs
* the two-step "Add funds" modal so users never leave for the wallet page when a
* transfer can't be afforded.
*/
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'],
]);
$account = ladill_account();
if ($account === null) {
return $request->expectsJson()
? response()->json(['error' => 'Unauthenticated.'], 401)
: redirect()->route('sso.connect');
}
$returnUrl = $validated['return_url'] ?? (string) (url()->previous() ?: route('transfer.transfers.create'));
try {
$checkoutUrl = $billing->topup((string) $account->public_id, (float) $validated['amount'], $returnUrl);
} catch (\Throwable) {
$checkoutUrl = '';
}
if ($checkoutUrl === '') {
return $request->expectsJson()
? response()->json(['error' => 'Unable to start payment. Please try again.'], 422)
: back()->with('error', 'Unable to start payment. Please try again.');
}
return $request->expectsJson()
? response()->json(['checkout_url' => $checkoutUrl])
: redirect()->away($checkoutUrl);
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
/**
* App sessions are subordinate to the platform (auth.ladill.com) session.
* When the platform session ends, clear this app's local session too.
*
* Re-checks auth.ladill.com at most once per TTL not on every request.
* Client-side sso-keepalive still pings periodically between checks.
*/
class EnsurePlatformSession
{
private const VERIFY_TTL_SECONDS = 90;
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
return $next($request);
}
$authDomain = trim((string) config('app.auth_domain', ''));
if ($authDomain === '') {
return $next($request);
}
$verifiedAt = (int) $request->session()->get('platform_session_verified_at', 0);
if ($verifiedAt > 0 && (time() - $verifiedAt) < self::VERIFY_TTL_SECONDS) {
return $next($request);
}
$cookieHeader = (string) $request->headers->get('Cookie', '');
if ($cookieHeader === '') {
return $this->clearAppSession($request);
}
try {
$response = Http::withHeaders(['Cookie' => $cookieHeader])
->timeout(3)
->connectTimeout(2)
->get('https://'.$authDomain.'/sso/ping');
} catch (\Throwable) {
return $next($request);
}
if ($response->status() === 401) {
return $this->clearAppSession($request);
}
if ($response->successful()) {
$request->session()->put('platform_session_verified_at', time());
}
return $next($request);
}
private function clearAppSession(Request $request): Response
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => $request->fullUrl(),
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
/**
* Injects a branded boot splash (Ladill Mail style) into authenticated HTML
* pages. The splash shows once per browser session while the app loads, then
* fades out masking the client-side boot so the app feels instant.
*/
class InjectBootSplash
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $request->isMethod('GET') || ! Auth::check()) {
return $response;
}
if (! str_contains((string) $response->headers->get('Content-Type', ''), 'text/html')) {
return $response;
}
$content = $response->getContent();
if (! is_string($content)
|| stripos($content, '<body') === false
|| str_contains($content, 'id="ladill-boot"')) {
return $response;
}
$splash = View::make('partials.boot-splash')->render();
$content = preg_replace_callback('/<body\b[^>]*>/i', fn ($m) => $m[0].$splash, $content, 1);
if (is_string($content)) {
$response->setContent($content);
}
return $response;
}
}
@@ -0,0 +1,24 @@
<?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);
}
return LadillLink::legacyRedirect($request);
}
}
+10 -13
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Support\LadillLink;
use App\Support\Qr\QrStyleDefaults; use App\Support\Qr\QrStyleDefaults;
use App\Support\Qr\QrTypeCatalog; use App\Support\Qr\QrTypeCatalog;
use App\Support\Qr\QrWifiPayload; use App\Support\Qr\QrWifiPayload;
@@ -83,23 +84,19 @@ class QrCode extends Model
public function publicUrl(): string public function publicUrl(): string
{ {
return self::publicBaseUrl() . '/q/' . $this->short_code; return LadillLink::url($this->short_code);
}
public function publicPath(string $suffix = ''): string
{
return $suffix === ''
? $this->publicUrl()
: LadillLink::path($this->short_code, $suffix);
} }
/**
* 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 public static function publicBaseUrl(): string
{ {
$appUrl = (string) config('app.url'); return LadillLink::baseUrl();
$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;
} }
/** /**
+16
View File
@@ -35,6 +35,22 @@ class BillingClient
return (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0); return (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 = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/topup', [
'user' => $publicId,
'amount' => $amount,
'return_url' => $returnUrl,
]);
$res->throw();
return (string) ($res->json()['checkout_url'] ?? '');
}
public function canAfford(string $publicId, int $amountMinor): bool public function canAfford(string $publicId, int $amountMinor): bool
{ {
return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false); return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false);
@@ -0,0 +1,57 @@
<?php
namespace App\Services\Identity;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/** Resolves a Ladill Transfer user mirror from a platform account public_id. */
class PublicIdUserResolver
{
public function resolve(string $publicId): User
{
$publicId = trim($publicId);
if ($publicId === '') {
throw new RuntimeException('User public_id is required.');
}
$existing = User::query()->where('public_id', $publicId)->first();
if ($existing) {
return $existing;
}
$base = rtrim((string) config('identity.api_url'), '/');
$key = (string) config('identity.api_key');
if ($base === '' || $key === '') {
throw new RuntimeException('Identity API is not configured.');
}
$res = Http::withToken($key)
->acceptJson()
->timeout(10)
->get("{$base}/identity/profile", ['user' => $publicId]);
if ($res->failed()) {
throw new RuntimeException('Could not resolve the Ladill account.');
}
$data = $res->json('data');
if (! is_array($data)) {
throw new RuntimeException('Could not resolve the Ladill account.');
}
$email = trim((string) ($data['email'] ?? ''));
if ($email === '') {
$email = 'sms+'.$publicId.'@accounts.ladill.local';
}
return User::updateOrCreate(
['public_id' => $publicId],
[
'name' => trim((string) ($data['name'] ?? '')) ?: 'Ladill user',
'email' => $email,
],
);
}
}
+8 -2
View File
@@ -144,19 +144,25 @@ class TransferService
$uuid = Str::uuid()->toString(); $uuid = Str::uuid()->toString();
$ext = $file->getClientOriginalExtension() ?: 'bin'; $ext = $file->getClientOriginalExtension() ?: 'bin';
$path = $user->id.'/transfers/'.$transfer->id.'/'.$uuid.'.'.$ext; $path = $user->id.'/transfers/'.$transfer->id.'/'.$uuid.'.'.$ext;
$disk = $this->filesDisk();
$file->storeAs('', $path, 'qr'); $file->storeAs('', $path, $disk);
return TransferFile::create([ return TransferFile::create([
'transfer_id' => $transfer->id, 'transfer_id' => $transfer->id,
'original_name' => $displayName, 'original_name' => $displayName,
'disk' => 'qr', 'disk' => $disk,
'path' => $path, 'path' => $path,
'mime_type' => $file->getMimeType() ?: 'application/octet-stream', 'mime_type' => $file->getMimeType() ?: 'application/octet-stream',
'size_bytes' => (int) $file->getSize(), 'size_bytes' => (int) $file->getSize(),
]); ]);
} }
private function filesDisk(): string
{
return (string) config('transfer.files_disk', 'qr');
}
/** /**
* @param array<string, mixed> $data * @param array<string, mixed> $data
*/ */
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Support;
use App\Models\Transfer;
use App\Models\TransferFile;
use RuntimeException;
class DirectTransferFileUrl
{
public static function for(Transfer $transfer, TransferFile $file): string
{
$shortCode = $transfer->qrCode?->short_code;
if ($shortCode === null || $shortCode === '') {
throw new RuntimeException('Transfer file URL is unavailable.');
}
return LadillLink::path($shortCode, 'transfer/files/'.$file->id);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?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);
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
namespace App\Support;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Route;
class UserProfileMenu
{
/**
* Standard signed-in profile links for Ladill product apps.
* Not used by Ladill Mail (mail.ladill.com) that app keeps a mailbox-specific menu.
*
* @return list<array<string, mixed>>
*/
public static function items(?Authenticatable $user = null): array
{
$user ??= auth()->user();
if (! $user) {
return [];
}
$items = [];
foreach ([
['label' => 'Home', 'path' => '', 'host' => 'home'],
['label' => 'Profile', 'path' => 'profile'],
['label' => 'Account Settings', 'path' => 'account-settings'],
['label' => 'Dashboard', 'path' => 'dashboard'],
['label' => 'Billing', 'path' => 'billing'],
] as $link) {
$href = self::platformUrl($link['host'] ?? 'account', $link['path']);
if (self::isCurrentMenuLink($link, $href)) {
continue;
}
$items[] = [
'type' => 'link',
'label' => $link['label'],
'href' => $href,
];
}
if (Route::has((string) config('billing.wallet_balance_route', 'wallet.balance'))) {
$items[] = ['type' => 'wallet'];
}
if (self::userIsAdmin($user)) {
$adminHref = self::platformUrl('account', 'admin');
if (! self::isCurrentMenuLink(['path' => 'admin', 'host' => 'account'], $adminHref)) {
$items[] = [
'type' => 'link',
'label' => 'Admin',
'href' => $adminHref,
];
}
}
if (Route::has('logout')) {
$items[] = [
'type' => 'logout',
'label' => 'Logout',
'action' => route('logout'),
];
}
return $items;
}
private static function platformUrl(string $host, string $path = ''): string
{
if ($host === 'home') {
if (function_exists('ladill_home_url')) {
return ladill_home_url($path);
}
$domain = config('app.home_domain');
if (! $domain) {
$platform = (string) config('app.platform_domain', parse_url((string) config('app.url', ''), PHP_URL_HOST) ?: '');
$domain = $platform !== '' ? 'home.'.$platform : (string) config('app.account_domain');
}
return self::absoluteUrl((string) $domain, $path);
}
if (function_exists('ladill_account_url')) {
return ladill_account_url($path);
}
return self::absoluteUrl((string) config('app.account_domain'), $path);
}
/**
* Hide a menu link when the signed-in user is already on that destination.
*
* @param array{label?: string, path?: string, host?: string} $link
*/
private static function isCurrentMenuLink(array $link, string $href): bool
{
if (app()->runningInConsole() && ! app()->runningUnitTests()) {
return false;
}
$request = request();
if (! $request) {
return false;
}
$linkHost = strtolower((string) parse_url($href, PHP_URL_HOST));
$currentHost = strtolower($request->getHost());
if ($linkHost === '' || $linkHost !== $currentHost) {
return false;
}
$currentPath = trim($request->getPathInfo(), '/');
$targetPath = trim((string) parse_url($href, PHP_URL_PATH), '/');
if (($link['host'] ?? 'account') === 'home') {
return $currentPath === '';
}
if (($link['path'] ?? '') === 'dashboard') {
return in_array($currentPath, ['dashboard', 'account'], true)
|| ($targetPath !== '' && self::pathMatchesSection($currentPath, $targetPath));
}
return self::pathMatchesSection($currentPath, $targetPath);
}
private static function pathMatchesSection(string $currentPath, string $targetPath): bool
{
if ($targetPath === '') {
return $currentPath === '';
}
return $currentPath === $targetPath
|| str_starts_with($currentPath, $targetPath.'/');
}
private static function absoluteUrl(string $domain, string $path = ''): string
{
$base = 'https://'.trim($domain, '/');
if ($path === '') {
return $base;
}
return $base.'/'.ltrim($path, '/');
}
private static function userIsAdmin(Authenticatable $user): bool
{
if (method_exists($user, 'isAdmin')) {
return $user->isAdmin();
}
return (bool) ($user->is_admin ?? false);
}
}
+6
View File
@@ -16,10 +16,16 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [ $middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [
'redirect' => $request->fullUrl(), 'redirect' => $request->fullUrl(),
])); ]));
// NOTE: Middleware::alias() REPLACES the alias map (it is not additive), so every alias must be
// registered in a single call. Splitting these into two alias() calls drops 'auth.service' and
// breaks all service-to-service routes (auth.service:transfer) with "Target class does not exist".
$middleware->alias([ $middleware->alias([
'auth.service' => \App\Http\Middleware\AuthenticateService::class, 'auth.service' => \App\Http\Middleware\AuthenticateService::class,
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
'redirect.legacy.qr' => \App\Http\Middleware\RedirectLegacyQrToLadillLink::class,
]); ]);
$middleware->web(append: [ $middleware->web(append: [
\App\Http\Middleware\InjectBootSplash::class,
\App\Http\Middleware\SetActingAccount::class, \App\Http\Middleware\SetActingAccount::class,
]); ]);
}) })
+1
View File
@@ -14,6 +14,7 @@
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/sanctum": "^4.3", "laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"league/flysystem-aws-s3-v3": "^3.25",
"phpseclib/phpseclib": "^3.0" "phpseclib/phpseclib": "^3.0"
}, },
"require-dev": { "require-dev": {
Generated
+344 -1
View File
@@ -4,8 +4,159 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "529339feb28ae432869697327a78cb16", "content-hash": "b2d8ed250f623f28d0a370f6145673fb",
"packages": [ "packages": [
{
"name": "aws/aws-crt-php",
"version": "v1.2.7",
"source": {
"type": "git",
"url": "https://github.com/awslabs/aws-crt-php.git",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
"shasum": ""
},
"require": {
"php": ">=5.5"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
"yoast/phpunit-polyfills": "^1.0"
},
"suggest": {
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
},
"type": "library",
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "AWS SDK Common Runtime Team",
"email": "aws-sdk-common-runtime@amazon.com"
}
],
"description": "AWS Common Runtime for PHP",
"homepage": "https://github.com/awslabs/aws-crt-php",
"keywords": [
"amazon",
"aws",
"crt",
"sdk"
],
"support": {
"issues": "https://github.com/awslabs/aws-crt-php/issues",
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
},
"time": "2024-10-18T22:15:13+00:00"
},
{
"name": "aws/aws-sdk-php",
"version": "3.387.2",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "dac6a19ffc71e6d100f06098e7df58f2e27103f9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/dac6a19ffc71e6d100f06098e7df58f2e27103f9",
"reference": "dac6a19ffc71e6d100f06098e7df58f2e27103f9",
"shasum": ""
},
"require": {
"aws/aws-crt-php": "^1.2.3",
"ext-json": "*",
"ext-pcre": "*",
"ext-simplexml": "*",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/promises": "^2.0",
"guzzlehttp/psr7": "^2.4.5",
"mtdowling/jmespath.php": "^2.9.1",
"php": ">=8.1",
"psr/http-message": "^1.0 || ^2.0",
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
},
"require-dev": {
"andrewsville/php-token-reflection": "^1.4",
"aws/aws-php-sns-message-validator": "~1.0",
"behat/behat": "~3.0",
"composer/composer": "^2.7.8",
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
"doctrine/cache": "~1.4",
"ext-dom": "*",
"ext-openssl": "*",
"ext-sockets": "*",
"phpunit/phpunit": "^10.0",
"psr/cache": "^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^3.0",
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
"yoast/phpunit-polyfills": "^2.0"
},
"suggest": {
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
"doctrine/cache": "To use the DoctrineCacheAdapter",
"ext-curl": "To send requests using cURL",
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
"ext-pcntl": "To use client-side monitoring",
"ext-sockets": "To use client-side monitoring"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Aws\\": "src/"
},
"exclude-from-classmap": [
"src/data/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Amazon Web Services",
"homepage": "https://aws.amazon.com"
}
],
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
"homepage": "https://aws.amazon.com/sdk-for-php",
"keywords": [
"amazon",
"aws",
"cloud",
"dynamodb",
"ec2",
"glacier",
"s3",
"sdk"
],
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.387.2"
},
"time": "2026-07-02T18:06:18+00:00"
},
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.14.8", "version": "0.14.8",
@@ -1963,6 +2114,61 @@
}, },
"time": "2026-05-14T10:28:08+00:00" "time": "2026-05-14T10:28:08+00:00"
}, },
{
"name": "league/flysystem-aws-s3-v3",
"version": "3.35.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
"reference": "3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94",
"reference": "3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94",
"shasum": ""
},
"require": {
"aws/aws-sdk-php": "^3.371.5",
"league/flysystem": "^3.10.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
},
"conflict": {
"guzzlehttp/guzzle": "<7.0",
"guzzlehttp/ringphp": "<1.1.1"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\AwsS3V3\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "AWS S3 filesystem adapter for Flysystem.",
"keywords": [
"Flysystem",
"aws",
"file",
"files",
"filesystem",
"s3",
"storage"
],
"support": {
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.1"
},
"time": "2026-06-25T06:51:08+00:00"
},
{ {
"name": "league/flysystem-local", "name": "league/flysystem-local",
"version": "3.31.0", "version": "3.31.0",
@@ -2353,6 +2559,72 @@
], ],
"time": "2026-01-02T08:56:05+00:00" "time": "2026-01-02T08:56:05+00:00"
}, },
{
"name": "mtdowling/jmespath.php",
"version": "2.9.1",
"source": {
"type": "git",
"url": "https://github.com/jmespath/jmespath.php.git",
"reference": "9c208ba27ae7d90853c288b3795d6702eb251d34"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/9c208ba27ae7d90853c288b3795d6702eb251d34",
"reference": "9c208ba27ae7d90853c288b3795d6702eb251d34",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"symfony/polyfill-mbstring": "^1.17"
},
"require-dev": {
"composer/xdebug-handler": "^3.0.3",
"phpunit/phpunit": "^8.5.52"
},
"bin": [
"bin/jp.php"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.9-dev"
}
},
"autoload": {
"files": [
"src/JmesPath.php"
],
"psr-4": {
"JmesPath\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Declaratively specify how to extract elements from a JSON document",
"keywords": [
"json",
"jsonpath"
],
"support": {
"issues": "https://github.com/jmespath/jmespath.php/issues",
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.1"
},
"time": "2026-06-11T10:43:56+00:00"
},
{ {
"name": "nesbot/carbon", "name": "nesbot/carbon",
"version": "3.11.4", "version": "3.11.4",
@@ -4317,6 +4589,77 @@
], ],
"time": "2026-01-05T13:30:16+00:00" "time": "2026-01-05T13:30:16+00:00"
}, },
{
"name": "symfony/filesystem",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
"reference": "99aec13b82b4967ec5088222c4a3ecca955949c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2",
"reference": "99aec13b82b4967ec5088222c4a3ecca955949c2",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.8"
},
"require-dev": {
"symfony/process": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Filesystem\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/filesystem/tree/v8.1.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-29T05:06:50+00:00"
},
{ {
"name": "symfony/finder", "name": "symfony/finder",
"version": "v7.4.8", "version": "v7.4.8",
+2
View File
@@ -4,5 +4,7 @@ return [
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'), 'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
'api_key' => env('BILLING_API_KEY_TRANSFER'), 'api_key' => env('BILLING_API_KEY_TRANSFER'),
'service' => 'transfer', 'service' => 'transfer',
'wallet_balance_route' => 'wallet.balance',
'currency' => 'GHS',
'platform_settings_connection' => env('BILLING_PLATFORM_SETTINGS_CONNECTION', 'platform'), 'platform_settings_connection' => env('BILLING_PLATFORM_SETTINGS_CONNECTION', 'platform'),
]; ];
+17
View File
@@ -67,6 +67,23 @@ return [
'report' => false, 'report' => false,
], ],
// Contabo Object Storage (S3-compatible) for transfer file blobs — same account as Ladill Meet.
'transfer-files' => [
'driver' => 's3',
'key' => env('TRANSFER_S3_ACCESS_KEY', env('MEET_EGRESS_S3_ACCESS_KEY', env('AWS_ACCESS_KEY_ID'))),
'secret' => env('TRANSFER_S3_SECRET', env('MEET_EGRESS_S3_SECRET', env('AWS_SECRET_ACCESS_KEY'))),
'region' => env('TRANSFER_S3_REGION', env('MEET_EGRESS_S3_REGION', env('AWS_DEFAULT_REGION', 'default'))),
'bucket' => env('TRANSFER_S3_BUCKET', env('MEET_EGRESS_S3_BUCKET', env('AWS_BUCKET'))),
'url' => env('TRANSFER_S3_URL', env('MEET_EGRESS_S3_URL', env('AWS_URL'))),
'endpoint' => env('TRANSFER_S3_ENDPOINT', env('MEET_EGRESS_S3_ENDPOINT', env('AWS_ENDPOINT'))),
'use_path_style_endpoint' => filter_var(
env('TRANSFER_S3_USE_PATH_STYLE', env('MEET_EGRESS_S3_USE_PATH_STYLE', env('AWS_USE_PATH_STYLE_ENDPOINT', true))),
FILTER_VALIDATE_BOOL,
),
'throw' => false,
'report' => false,
],
], ],
/* /*
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'product_slug' => 'transfer',
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/transfer'),
];
+26 -20
View File
@@ -2,39 +2,45 @@
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Ladill App Launcher SHARED, IDENTICAL across every app/service repo | Ladill App Launcher GENERATED, IDENTICAL across every app/service repo
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Lists ONLY fully-extracted apps (each on its own subdomain). To replicate the | DO NOT EDIT BY HAND. This file is generated from the app registry in the
| launcher in a new app, copy these three things verbatim into that repo: | monolith (config/ladill_apps.php). To change the launcher, edit that
| - config/ladill_launcher.php (this file) | registry and run: php artisan ladill:launcher:sync --propagate
| - resources/views/partials/launcher.blade.php
| - public/images/launcher-icons/* (the icon set)
| |
| When a service becomes FULLY extracted: add one row below AND drop its icon in | URLs are absolute (built from the platform root) so this file is
| public/images/launcher-icons/ in EVERY repo. That's the only edit needed. | byte-identical in every repo; the blade omits whichever row matches the
| Do NOT list a service here until it is fully extracted to its own subdomain. | app's own host. Icons are served from /images/launcher-icons/<icon>.
|
| URLs are absolute (built from the platform root) so this file is byte-identical
| in every app; the blade omits whichever row matches the app's APP_URL host.
| Icons are served from /images/launcher-icons/<icon>.
*/ */
$root = config('app.platform_domain', 'ladill.com'); $root = config('app.platform_domain', 'ladill.com');
return [ return [
'apps' => [ 'apps' => [
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'],
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
['name' => 'Woo Manager', 'url' => 'https://woo.'.$root.'/sso/connect?redirect='.urlencode('https://woo.'.$root.'/dashboard'), 'icon' => 'woomanager.svg'],
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'],
['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'],
['name' => 'Stock', 'url' => 'https://stock.'.$root.'/sso/connect?redirect='.urlencode('https://stock.'.$root.'/dashboard'), 'icon' => 'stock.svg'],
['name' => 'CRM', 'url' => 'https://crm.'.$root.'/sso/connect?redirect='.urlencode('https://crm.'.$root.'/dashboard'), 'icon' => 'crm.svg'],
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
['name' => 'Link', 'url' => 'https://link.'.$root.'/sso/connect?redirect='.urlencode('https://link.'.$root.'/dashboard'), 'icon' => 'link.svg'],
['name' => 'Frontdesk', 'url' => 'https://frontdesk.'.$root.'/sso/connect?redirect='.urlencode('https://frontdesk.'.$root.'/dashboard'), 'icon' => 'frontdesk.svg'],
['name' => 'Care', 'url' => 'https://care.'.$root.'/sso/connect?redirect='.urlencode('https://care.'.$root.'/dashboard'), 'icon' => 'care.svg'],
['name' => 'Queue', 'url' => 'https://queue.'.$root.'/sso/connect?redirect='.urlencode('https://queue.'.$root.'/dashboard'), 'icon' => 'queue.svg'],
['name' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.svg'],
['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'], ['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'],
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'], ['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'],
['name' => 'Meet', 'url' => 'https://meet.'.$root.'/sso/connect?redirect='.urlencode('https://meet.'.$root.'/dashboard'), 'icon' => 'meet.svg'],
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
['name' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'], ['name' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'],
['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'], ['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'],
['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.svg'], ['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.svg'],
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
], ],
]; ];
+5
View File
@@ -0,0 +1,5 @@
<?php
return [
'public_domain' => env('LINK_PUBLIC_DOMAIN', 'ladl.link'),
];
+1 -1
View File
@@ -32,7 +32,7 @@ return [
| |
*/ */
'lifetime' => (int) env('SESSION_LIFETIME', 120), 'lifetime' => (int) env('SESSION_LIFETIME', 1440),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
+7
View File
@@ -6,6 +6,7 @@ return [
*/ */
'service_api_keys' => array_filter([ 'service_api_keys' => array_filter([
'webmail' => env('TRANSFER_API_KEY_WEBMAIL'), 'webmail' => env('TRANSFER_API_KEY_WEBMAIL'),
'sms' => env('TRANSFER_API_KEY_SMS'),
]), ]),
// GHS per GB per month of retention (see qr-suite-decomposition.md §7.3). // GHS per GB per month of retention (see qr-suite-decomposition.md §7.3).
@@ -35,4 +36,10 @@ return [
// Legacy env keys (billing is monthly until cancelled by non-payment + grace). // Legacy env keys (billing is monthly until cancelled by non-payment + grace).
'default_retention_days' => (int) env('TRANSFER_DEFAULT_RETENTION_DAYS', 30), 'default_retention_days' => (int) env('TRANSFER_DEFAULT_RETENTION_DAYS', 30),
'mail_retention_days' => (int) env('TRANSFER_MAIL_RETENTION_DAYS', 30), 'mail_retention_days' => (int) env('TRANSFER_MAIL_RETENTION_DAYS', 30),
// Voice SMS uploads from sms.ladill.com (via platform API).
'sms_voice_max_upload_kb' => (int) env('TRANSFER_SMS_VOICE_MAX_UPLOAD_KB', 5120),
// Filesystem disk for transfer blobs (Contabo S3 in production; local `qr` for dev/tests).
'files_disk' => env('TRANSFER_FILES_DISK', 'transfer-files'),
]; ];
+12
View File
@@ -58,6 +58,18 @@ TRANSFER_MAIL_RETENTION_DAYS=30
# Ladill Mail — large attachment uploads (must match WEBMAIL_TRANSFER_API_KEY). # Ladill Mail — large attachment uploads (must match WEBMAIL_TRANSFER_API_KEY).
TRANSFER_API_KEY_WEBMAIL= TRANSFER_API_KEY_WEBMAIL=
# Ladill SMS — voice message uploads (must match SMS_TRANSFER_API_KEY on platform).
TRANSFER_API_KEY_SMS=
TRANSFER_SMS_VOICE_MAX_UPLOAD_KB=5120
AFIA_ENABLED=true AFIA_ENABLED=true
AFIA_PRODUCT=transfer AFIA_PRODUCT=transfer
# Contabo Object Storage (S3-compatible) — same bucket/account as Ladill Meet.
TRANSFER_FILES_DISK=transfer-files
TRANSFER_S3_BUCKET=ladill
TRANSFER_S3_REGION=default
TRANSFER_S3_ENDPOINT=https://eu2.contabostorage.com
TRANSFER_S3_USE_PATH_STYLE=true
TRANSFER_S3_ACCESS_KEY=
TRANSFER_S3_SECRET=
+1
View File
@@ -33,5 +33,6 @@
<env name="PULSE_ENABLED" value="false"/> <env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/> <env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/> <env name="NIGHTWATCH_ENABLED" value="false"/>
<env name="TRANSFER_FILES_DISK" value="qr"/>
</php> </php>
</phpunit> </phpunit>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #009e6b;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #db5443;
}
.cls-3 {
fill: #00a3d7;
}
</style>
</defs>
<path class="cls-3" d="M1.7,170.6c0,9,.1,18.2,0,27.2-.1,8.8,4.3,15,13.8,19.3,17.3,7.5,34.4,15.2,51.7,22.8,69,30.6,138.2,61.1,207.1,92,16.9,7.6,37.1-.3,36.9-17.8v-54c0-17.9-4.5-15.2-14.4-19.4-47-20.7-94-41.6-141-62.4-39-17.3-78.2-34.2-117-51.9C19.9,117.7,0,128.1,1.3,144.4c.6,8.8.1,17.6.1,26.3h.3Z"/>
<path class="cls-1" d="M194.6,25.4c9,0,18.2.1,27.2,0,8.8-.1,15,4.3,19.3,13.8,7.5,17.3,15.2,34.4,22.8,51.7,30.6,69.1,60.9,138.3,91.9,207.2,7.6,16.9-.3,37.1-17.8,36.9h-54c-17.9,0-15.2-4.5-19.4-14.4-20.7-47-41.6-94-62.4-141-17.3-39-34.2-78.2-51.9-117-8.7-18.8,1.8-38.7,18.1-37.4,8.8.6,17.6.1,26.3.1h0Z"/>
<path class="cls-2" d="M194.9,25.3c-9,0-18.2.1-27.2,0-8.8-.1-15,4.3-19.3,13.8-7.5,17.3-15.2,34.4-22.8,51.7-30.6,69.1-60.9,138.3-91.9,207.2-7.6,16.9.3,37.1,17.8,36.9h54c17.9,0,15.2-4.5,19.4-14.4,20.7-47,41.6-94,62.4-141,17.3-39,34.2-78.2,51.9-117,8.7-18.8-1.8-38.7-18.1-37.4-8.8.6-17.6.1-26.3.1h0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

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

After

Width:  |  Height:  |  Size: 1.1 KiB

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

After

Width:  |  Height:  |  Size: 1.7 KiB

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

After

Width:  |  Height:  |  Size: 1.3 KiB

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

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+5 -5
View File
@@ -31,10 +31,10 @@
} }
</style> </style>
</defs> </defs>
<rect class="cls-4" x="0" y="11.7" width="104.6" height="334.3" rx="52.3" ry="52.3"/> <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"/>
<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-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.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"/> <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"/>
<circle class="cls-3" cx="304.1" cy="64" r="52.3"/> <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.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.3h0Z"/>
<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"/> <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"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

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

After

Width:  |  Height:  |  Size: 638 B

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

After

Width:  |  Height:  |  Size: 859 B

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

After

Width:  |  Height:  |  Size: 1.0 KiB

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

After

Width:  |  Height:  |  Size: 557 B

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

After

Width:  |  Height:  |  Size: 839 B

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

After

Width:  |  Height:  |  Size: 734 B

+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #00997d;
}
.cls-1, .cls-2, .cls-3, .cls-4 {
stroke-width: 0px;
}
.cls-2 {
fill: #ffa34d;
}
.cls-3 {
fill: #0776be;
}
.cls-4 {
fill: #5051df;
}
</style>
</defs>
<path class="cls-4" d="M340.5,213.3l-7.7-7.7c-10.4-7.1-23-11-36.2-11h-157.5l110.3,110.3c25.2,25.2,66.4,25.2,91.6,0,25.2-25.2,25.2-66.4,0-91.6h-.5Z"/>
<path class="cls-1" d="M18.9,305l4.9,4.9c11,8.8,24.7,13.7,40.1,13.7h156.9l-110.8-110.3c-25.2-24.7-65.9-24.7-91.1,0-25.2,24.7-25.2,66.4,0,91.6Z"/>
<path class="cls-3" d="M340.5,54.5l-7.7-7.7c-10.4-7.1-23-11-36.2-11h-157.5l110.3,110.3c25.2,25.2,66.4,25.2,91.6,0,25.2-25.2,25.2-66.4,0-91.6h-.5Z"/>
<path class="cls-2" d="M18.9,146.2l4.9,4.9c11,8.8,24.7,13.7,40.1,13.7h156.9L110,54.6c-25.2-24.7-65.9-24.7-91.1,0-25.2,24.7-25.2,66.4,0,91.6Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

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

After

Width:  |  Height:  |  Size: 1.2 KiB

+65 -2
View File
@@ -136,12 +136,18 @@ html.qr-mobile-page body {
} }
.mobile-stats-card { .mobile-stats-card {
width: calc(66.666667% - 0.5rem); width: max-content;
min-width: 9rem;
max-width: none;
box-sizing: border-box;
} }
@media (min-width: 640px) { @media (min-width: 640px) {
.mobile-stats-card { .mobile-stats-card {
width: auto; width: max-content;
min-width: 9rem;
max-width: none;
flex: 1 1 auto;
} }
} }
@@ -198,3 +204,60 @@ html.qr-mobile-page body {
filter: brightness(0.92); 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;
}
+41 -10
View File
@@ -1,12 +1,15 @@
import './bootstrap'; import './bootstrap';
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals'; import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
import { uploadFileChunked } from './chunked-upload'; import { uploadFileChunked } from './chunked-upload';
registerLadillModalHelpers(); registerLadillModalHelpers();
registerLadillSearchShortcut();
import Alpine from 'alpinejs'; import Alpine from 'alpinejs';
import { registerLadillClipboard } from './ladill-clipboard';
import collapse from '@alpinejs/collapse'; import collapse from '@alpinejs/collapse';
import QRCodeStyling from 'qr-code-styling'; import QRCodeStyling from 'qr-code-styling';
window.QRCodeStyling = QRCodeStyling; window.QRCodeStyling = QRCodeStyling;
@@ -14,6 +17,7 @@ import qrcode from 'qrcode-generator';
window.qrcode = qrcode; window.qrcode = qrcode;
Alpine.plugin(collapse); Alpine.plugin(collapse);
registerLadillClipboard(Alpine);
Alpine.data('notificationDropdown', (config = {}) => ({ Alpine.data('notificationDropdown', (config = {}) => ({
open: false, open: false,
@@ -160,6 +164,8 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
errorMsg: config.errorMsg ?? '', errorMsg: config.errorMsg ?? '',
showSheet: false, showSheet: false,
checkoutUrl: '', checkoutUrl: '',
accessCode: '',
returnUrl: '',
paymentSheetStyle: '', paymentSheetStyle: '',
sheetBleedStyle: '', sheetBleedStyle: '',
@@ -236,14 +242,13 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
this.loading = false; this.loading = false;
return; return;
} }
if (window.innerWidth < 768) { this.checkoutUrl = data.checkout_url || data.authorization_url || '';
this.checkoutUrl = data.checkout_url; this.accessCode = data.access_code || '';
this.showSheet = true; this.returnUrl = data.callback_url || this.returnUrl || '';
this.loading = false; window.LadillPayCheckout?.prepare?.();
} else { this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened)
window.location.href = data.checkout_url;
}
} catch (e) { } catch (e) {
window.LadillPayCheckout?.cancel?.();
this.errorMsg = 'Network error. Please try again.'; this.errorMsg = 'Network error. Please try again.';
this.loading = false; this.loading = false;
} }
@@ -686,9 +691,15 @@ Alpine.data('filesManager', (config = {}) => ({
this.showToast('Delete files individually. Folder delete is not supported yet.'); this.showToast('Delete files individually. Folder delete is not supported yet.');
return; return;
} }
if (! confirm(`Delete ${this.selectedFileIds.length} file${this.selectedFileIds.length === 1 ? '' : 's'}? This cannot be undone.`)) { const count = this.selectedFileIds.length;
return; const confirmed = await Alpine.store('ladillConfirm').ask({
} title: count === 1 ? 'Delete file?' : `Delete ${count} files?`,
message: 'This cannot be undone.',
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
variant: 'danger',
});
if (!confirmed) return;
this.submitAction(this.routes.bulkDelete); this.submitAction(this.routes.bulkDelete);
}, },
@@ -948,6 +959,26 @@ Alpine.data('filesManager', (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; window.Alpine = Alpine;
registerLadillConfirmStore(Alpine); registerLadillConfirmStore(Alpine);
+57
View File
@@ -0,0 +1,57 @@
const COPY_FEEDBACK_MS = 2000;
export async function writeClipboardText(text) {
const value = String(text ?? '');
if (!value) {
return false;
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
} catch {
// fall through to legacy copy
}
try {
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
export function registerLadillClipboard(Alpine) {
Alpine.data('copyButton', (text = '') => ({
copied: false,
text: text ?? '',
resetTimer: null,
async copy() {
const ok = await writeClipboardText(this.text);
if (!ok) {
return;
}
this.copied = true;
if (this.resetTimer) {
clearTimeout(this.resetTimer);
}
this.resetTimer = setTimeout(() => {
this.copied = false;
}, COPY_FEEDBACK_MS);
},
}));
}
+97
View File
@@ -0,0 +1,97 @@
function isEditableTarget(element) {
if (!(element instanceof HTMLElement)) {
return false;
}
const tag = element.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
return true;
}
return element.isContentEditable;
}
function isVisibleInput(input) {
if (!(input instanceof HTMLElement)) {
return false;
}
const style = window.getComputedStyle(input);
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& style.opacity !== '0';
}
function findSearchInput() {
const marked = [...document.querySelectorAll('[data-ladill-search-input]')];
const visibleMarked = marked.find(isVisibleInput);
if (visibleMarked) {
return visibleMarked;
}
if (marked.length > 0) {
return marked[0];
}
return document.querySelector('[x-data*="topbarSearch"] input');
}
function focusSearchInput() {
const input = findSearchInput();
if (!input) {
const fallbackUrl = document.body?.dataset?.ladillSearchUrl;
if (fallbackUrl) {
window.location.href = fallbackUrl;
}
return false;
}
if (!isVisibleInput(input)) {
const fallbackUrl = document.body?.dataset?.ladillSearchUrl;
if (fallbackUrl) {
window.location.href = fallbackUrl;
return true;
}
}
input.focus();
if (input instanceof HTMLInputElement && input.type === 'text') {
input.select();
}
return true;
}
export function registerLadillSearchShortcut() {
if (window.__ladillSearchShortcutRegistered) {
return;
}
window.__ladillSearchShortcutRegistered = true;
document.addEventListener('keydown', (event) => {
if (event.key !== '/' && event.code !== 'Slash') {
return;
}
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
if (isEditableTarget(document.activeElement)) {
return;
}
event.preventDefault();
focusSearchInput();
});
}
@@ -1,17 +1,33 @@
@props([ @props([
'href' => null, 'href' => null,
'type' => 'button', 'type' => 'button',
'label' => null,
]) ])
@php @php
$tag = $href ? 'a' : 'button'; $tag = $href ? 'a' : 'button';
$ariaLabel = $label ?? trim(preg_replace('/\s+/', ' ', strip_tags((string) $slot)));
@endphp @endphp
<{{ $tag }} <{{ $tag }}
@if ($href) href="{{ $href }}" @endif @if ($href) href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif @if ($tag === 'button') type="{{ $type }}" @endif
{{ $attributes->class(['btn-primary']) }} aria-label="{{ $ariaLabel }}"
title="{{ $ariaLabel }}"
{{ $attributes->class(['btn-fab h-10 w-10 lg:hidden']) }}
> >
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg> <svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
</{{ $tag }}>
<{{ $tag }}
@if ($href) href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
{{ $attributes->class(['btn-primary hidden lg:inline-flex']) }}
>
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
{{ $slot }} {{ $slot }}
</{{ $tag }}> </{{ $tag }}>
@@ -0,0 +1,29 @@
@props([
'text' => '',
'label' => 'Copy',
'copiedLabel' => 'Copied!',
'icon' => false,
'copiedClass' => '',
])
<button
type="button"
x-data="copyButton(@js($text))"
@click="copy()"
:title="copied ? @js($copiedLabel) : @js($icon ? 'Copy' : $label)"
:class="copied ? @js($copiedClass) : ''"
{{ $attributes->class($icon ? 'inline-flex shrink-0 items-center justify-center' : '') }}
>
@if ($icon)
<svg x-show="!copied" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg x-show="copied" x-cloak class="h-4 w-4 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
@elseif ($slot->isNotEmpty())
{{ $slot }}
@else
<span x-text="copied ? @js($copiedLabel) : @js($label)"></span>
@endif
</button>
@@ -1,84 +1,83 @@
@props([ @props([
'id', 'id',
'title' => 'Add credits', 'title' => 'Add funds',
'description' => '',
'topupAction', 'topupAction',
'minTopup' => 5, 'minTopup' => 5,
'suggestedAmount' => 10, 'suggestedAmount' => 10,
'ladillWalletBalance' => 0, 'pricePerAction' => null,
'actionNoun' => 'action',
'serviceBalance' => null, 'serviceBalance' => null,
'serviceBalanceLabel' => 'Current balance', 'serviceBalanceLabel' => 'Wallet balance',
'description' => '',
'openOnLoad' => false, 'openOnLoad' => false,
'returnUrl' => null, 'returnUrl' => null,
]) ])
@php
// Single-wallet (siloing step 2): there is one Ladill wallet, so "pay from
// wallet into service credits" is a meaningless round-trip — top up the one
// wallet directly (Paystack). Transfer option removed.
$canPayFromWallet = false;
@endphp
<x-modal :name="$id" :show="$openOnLoad" maxWidth="md"> <x-modal :name="$id" :show="$openOnLoad" maxWidth="md">
<div class="flex min-h-full flex-col sm:min-h-0"> <div x-data="{ step: 1 }"
x-on:open-modal.window="String($event.detail) === @js($id) ? step = 1 : null"
class="flex min-h-full flex-col sm:min-h-0">
{{-- Header with step indicator --}}
<div class="border-b border-slate-100 px-5 py-4 pr-12 sm:px-6 sm:pr-14"> <div class="border-b border-slate-100 px-5 py-4 pr-12 sm:px-6 sm:pr-14">
<h2 class="text-lg font-semibold text-slate-900">{{ $title }}</h2> <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>
</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>
@if($description) @if($description)
<p class="mt-1 text-sm text-slate-500">{{ $description }}</p> <div class="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">{{ $description }}</div>
@endif
<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, 4) }}</span>
</div>
@endif
@if($serviceBalance !== null)
<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((float) $serviceBalance, 2) }}</span>
</div>
@endif @endif
</div> </div>
<form action="{{ $topupAction }}" method="POST" class="flex flex-1 flex-col p-5 sm:p-6" <p class="mt-4 text-xs leading-5 text-slate-500">
x-data="{ Top up any amount unused funds stay in your wallet for next time.
method: 'paystack', </p>
showSheet: false,
checkoutUrl: '',
loading: false,
errorMsg: '',
async submitTopup(event) {
if (this.method !== 'paystack' || window.innerWidth >= 768) return;
event.preventDefault(); <div class="mt-6 flex flex-col gap-2 sm:mt-auto sm:flex-row sm:justify-end">
this.loading = true; <button type="button" @click="$dispatch('close-modal', '{{ $id }}')"
this.errorMsg = ''; 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>
try { {{-- STEP 2 amount + payment (plain POST -> Paystack checkout) --}}
const res = await fetch(event.target.action, { <form x-show="step === 2" x-cloak action="{{ $topupAction }}" method="POST" class="flex flex-1 flex-col p-5 sm:p-6">
method: 'POST',
headers: { 'Accept': 'application/json' },
body: new FormData(event.target),
});
const data = await res.json();
if (!res.ok || data.error || !data.checkout_url) {
this.errorMsg = data.error || 'Unable to start payment. Please try again.';
this.loading = false;
return;
}
this.checkoutUrl = data.checkout_url;
this.showSheet = true;
this.loading = false;
} catch (error) {
this.errorMsg = 'Network error. Please try again.';
this.loading = false;
}
},
}"
@submit="submitTopup($event)">
@csrf @csrf
@if($returnUrl) @if($returnUrl)
<input type="hidden" name="return_url" value="{{ $returnUrl }}"> <input type="hidden" name="return_url" value="{{ $returnUrl }}">
@endif @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> <div>
<label class="text-sm font-medium text-slate-700">Amount (GHS)</label> <label class="text-sm font-medium text-slate-700">Amount (GHS)</label>
<input type="number" name="amount" min="{{ $minTopup }}" max="5000" step="0.01" <input type="number" name="amount" min="{{ $minTopup }}" max="5000" step="0.01"
@@ -88,37 +87,17 @@
<p class="mt-1.5 text-xs text-slate-500">Minimum GHS {{ number_format($minTopup, 2) }}</p> <p class="mt-1.5 text-xs text-slate-500">Minimum GHS {{ number_format($minTopup, 2) }}</p>
</div> </div>
<input type="hidden" name="payment_method" value="paystack" @if($canPayFromWallet) x-model="method" @endif> <input type="hidden" name="payment_method" value="paystack">
@if($canPayFromWallet) <p class="mt-4 text-xs text-slate-500">Pay securely with Paystack.</p>
<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"> <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 }}')" <button type="button" @click="step = 1"
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"> 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">
Cancel Back
</button>
<button type="submit" :disabled="loading"
class="btn-primary w-full sm:w-auto">
<span x-text="loading ? 'Processing…' : 'Add credits'">Add credits</span>
</button> </button>
<button type="submit" class="btn-primary w-full sm:w-auto">Continue to payment</button>
</div> </div>
@include('partials.paystack-sheet')
</form> </form>
</div> </div>
</x-modal> </x-modal>
+2 -6
View File
@@ -10,7 +10,7 @@
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" /> <link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
</head> </head>
<body class="h-full font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }"> <body class="h-full font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }" data-ladill-search-url="{{ route('transfer.search') }}">
<div class="min-h-screen max-lg:min-h-dvh"> <div class="min-h-screen max-lg:min-h-dvh">
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div> <div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
<aside x-cloak class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0" <aside x-cloak class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0"
@@ -45,11 +45,7 @@
'profileActive' => false, 'profileActive' => false,
'profileName' => $navUser?->name ?? '', 'profileName' => $navUser?->name ?? '',
'profileSubtitle' => $navUser?->email ?? '', 'profileSubtitle' => $navUser?->email ?? '',
'profileMenuItems' => [ 'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
['type' => 'link', 'label' => 'Profile', 'href' => $navAcct.'/profile'],
['type' => 'link', 'label' => 'Account Settings', 'href' => $navAcct.'/account-settings'],
['type' => 'logout', 'label' => 'Logout', 'action' => route('logout')],
],
'avatarUrl' => $navUser?->avatar_url, 'avatarUrl' => $navUser?->avatar_url,
'initials' => $navInitials !== '' ? $navInitials : 'U', 'initials' => $navInitials !== '' ? $navInitials : 'U',
]) ])
+1 -6
View File
@@ -73,12 +73,7 @@
'profileActive' => request()->routeIs('account.settings'), 'profileActive' => request()->routeIs('account.settings'),
'profileName' => $navUser?->name ?? '', 'profileName' => $navUser?->name ?? '',
'profileSubtitle' => $navUser?->email ?? '', 'profileSubtitle' => $navUser?->email ?? '',
'profileMenuItems' => [ 'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
['type' => 'link', 'label' => 'Settings', 'href' => route('account.settings')],
['type' => 'link', 'label' => 'Account Settings', 'href' => ladill_account_url('account-settings')],
['type' => 'link', 'label' => 'Overview', 'href' => route('transfer.dashboard')],
['type' => 'logout', 'label' => 'Logout', 'action' => route('logout')],
],
'avatarUrl' => $navUser?->avatarUrl(), 'avatarUrl' => $navUser?->avatarUrl(),
'initials' => $navInitials !== '' ? $navInitials : 'U', 'initials' => $navInitials !== '' ? $navInitials : 'U',
]) ])
@@ -8,7 +8,7 @@
@if ($handler === 'openAfia') @if ($handler === 'openAfia')
@click="openAfia()" @click="openAfia()"
@else @else
@click="$dispatch('afia-open')" @click="window.dispatchEvent(new CustomEvent('afia-open'))"
@endif @endif
@class([ @class([
'inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-400 text-sm font-semibold text-white shadow-sm transition hover:opacity-95', 'inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-400 text-sm font-semibold text-white shadow-sm transition hover:opacity-95',
+1 -1
View File
@@ -67,7 +67,7 @@
<div class="flex" :class="m.role === 'user' ? 'justify-end' : 'justify-start'"> <div class="flex" :class="m.role === 'user' ? 'justify-end' : 'justify-start'">
<div class="max-w-[85%] whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed" <div class="max-w-[85%] whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed"
:class="m.role === 'user' ? 'bg-indigo-600 text-white rounded-br-md' : 'bg-slate-100 text-slate-700 rounded-bl-md'" :class="m.role === 'user' ? 'bg-indigo-600 text-white rounded-br-md' : 'bg-slate-100 text-slate-700 rounded-bl-md'"
x-text="m.text"></div> x-html="m.text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\*\*([^*]+?)\*\*/g,'<strong>$1</strong>')"></div>
</div> </div>
</template> </template>
<div x-show="loading" class="flex justify-start"> <div x-show="loading" class="flex justify-start">
@@ -0,0 +1,52 @@
@php
// Branded boot splash (Ladill Mail style). Self-icon from this app's subdomain.
$sub = strtolower((string) ((explode('.', (string) (parse_url((string) config('app.url'), PHP_URL_HOST) ?: '')))[0] ?? ''));
$icon = $sub !== '' && is_file(public_path("images/launcher-icons/{$sub}.svg")) ? "images/launcher-icons/{$sub}.svg" : null;
$label = (string) config('app.name', 'Ladill');
@endphp
<div id="ladill-boot" role="status" aria-live="polite">
<div class="lb-wrap">
@if ($icon)
<img class="lb-logo" src="{{ asset($icon) }}?v={{ @filemtime(public_path($icon)) ?: '1' }}" alt="">
@endif
<div class="lb-bar"><span></span></div>
<div class="lb-title">Loading {{ $label }}</div>
</div>
</div>
<style>
#ladill-boot{position:fixed;inset:0;z-index:99999;background:#f8fafc;display:flex;align-items:center;justify-content:center;transition:opacity .35s ease;font-family:'Figtree',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
#ladill-boot .lb-wrap{display:flex;flex-direction:column;align-items:center;gap:24px;width:260px}
#ladill-boot .lb-logo{width:64px;height:64px;animation:lb-pulse 1.6s ease-in-out infinite}
#ladill-boot .lb-bar{width:100%;height:4px;background:#e2e8f0;border-radius:999px;overflow:hidden}
#ladill-boot .lb-bar>span{display:block;height:100%;width:8%;border-radius:999px;background:linear-gradient(90deg,#4f46e5,#7c3aed);animation:lb-fill 1.9s cubic-bezier(.4,0,.2,1) forwards}
#ladill-boot .lb-title{font-size:14px;color:#64748b;font-weight:500}
@keyframes lb-fill{0%{width:8%}55%{width:68%}100%{width:93%}}
@keyframes lb-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(.93);opacity:.82}}
</style>
<script>
(function(){
var el=document.getElementById('ladill-boot');
if(!el)return;
function remove(){if(el&&el.parentNode)el.parentNode.removeChild(el);}
// Show when the user opens or reloads the app; suppress only on internal
// link navigations and back/forward so we don't flash on every click.
try{
var navType='navigate';
var nav=performance.getEntriesByType&&performance.getEntriesByType('navigation');
if(nav&&nav.length){navType=nav[0].type;}
else if(performance.navigation){navType=performance.navigation.type===1?'reload':(performance.navigation.type===2?'back_forward':'navigate');}
var internal=false;
if(navType!=='reload'&&document.referrer){
try{internal=new URL(document.referrer).origin===location.origin;}catch(e){}
}
if(internal||navType==='back_forward'){remove();return;}
}catch(e){}
var start=Date.now(),MIN=550;
function done(){
var wait=Math.max(0,MIN-(Date.now()-start));
setTimeout(function(){if(!el)return;el.style.opacity='0';setTimeout(remove,400);},wait);
}
if(document.readyState==='complete')done();else window.addEventListener('load',done);
setTimeout(done,8000); // safety net
})();
</script>
+34 -3
View File
@@ -10,10 +10,27 @@
config('ladill_launcher.apps', []), config('ladill_launcher.apps', []),
fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost
)); ));
$launcherOverflows = count($launcherApps) > 15;
@endphp @endphp
@if (! empty($launcherApps)) @if (! empty($launcherApps))
<div class="relative" x-data="{ appsOpen: false }" @click.outside="appsOpen = false" @keydown.escape.window="appsOpen = false"> <div class="relative" x-data="{
<button type="button" @click="appsOpen = !appsOpen" appsOpen: false,
showScrollMore: {{ $launcherOverflows ? 'true' : 'false' }},
checkScrollEnd() {
const el = this.$refs.appsScroller;
if (! el) return;
this.showScrollMore = el.scrollTop + el.clientHeight < el.scrollHeight - 4;
},
toggleApps() {
this.appsOpen = ! this.appsOpen;
if (this.appsOpen) {
this.$nextTick(() => this.checkScrollEnd());
} else {
this.showScrollMore = {{ $launcherOverflows ? 'true' : 'false' }};
}
},
}" @click.outside="appsOpen = false" @keydown.escape.window="appsOpen = false">
<button type="button" @click="toggleApps()"
@class([ @class([
'inline-flex items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:bg-slate-50', 'inline-flex items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:bg-slate-50',
'p-2' => ! $sidebar, 'p-2' => ! $sidebar,
@@ -37,10 +54,15 @@
'bottom-full left-0 mb-2' => $sidebar, 'bottom-full left-0 mb-2' => $sidebar,
])> ])>
<p class="px-1 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">All apps</p> <p class="px-1 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">All apps</p>
<div @class(['ladill-launcher-apps-wrap' => $launcherOverflows])>
<div
class="ladill-launcher-apps"
@if ($launcherOverflows) x-ref="appsScroller" @scroll="checkScrollEnd()" @endif
>
<div class="grid grid-cols-3 gap-1"> <div class="grid grid-cols-3 gap-1">
@foreach ($launcherApps as $app) @foreach ($launcherApps as $app)
<a href="{{ $app['url'] }}" <a href="{{ $app['url'] }}"
class="flex flex-col items-center gap-1.5 rounded-xl px-2 py-3 text-center transition hover:bg-indigo-50"> class="flex min-h-[5.25rem] flex-col items-center justify-center gap-1.5 rounded-xl px-2 py-3 text-center transition hover:bg-indigo-50">
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-100"> <span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-100">
<img src="{{ asset('images/launcher-icons/'.$app['icon']) }}?v={{ @filemtime(public_path('images/launcher-icons/'.$app['icon'])) ?: '1' }}" alt="" class="h-5 w-5 object-contain" width="20" height="20"> <img src="{{ asset('images/launcher-icons/'.$app['icon']) }}?v={{ @filemtime(public_path('images/launcher-icons/'.$app['icon'])) ?: '1' }}" alt="" class="h-5 w-5 object-contain" width="20" height="20">
</span> </span>
@@ -49,5 +71,14 @@
@endforeach @endforeach
</div> </div>
</div> </div>
@if ($launcherOverflows)
<div class="ladill-launcher-scroll-more" x-show="showScrollMore" x-cloak aria-hidden="true">
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/>
</svg>
</div>
@endif
</div>
</div>
</div> </div>
@endif @endif
@@ -1,5 +1,9 @@
@php @php
$gridCols = !empty($centerCompose) ? 'grid-cols-5' : 'grid-cols-4'; $showSearch = isset($searchUrl) && $searchUrl !== null && $searchUrl !== '#';
$gridCols = match (true) {
! empty($centerCompose) => $showSearch ? 'grid-cols-5' : 'grid-cols-4',
default => $showSearch ? 'grid-cols-4' : 'grid-cols-3',
};
$avatarUrl = $avatarUrl ?? null; $avatarUrl = $avatarUrl ?? null;
$initials = $initials ?? 'U'; $initials = $initials ?? 'U';
$notificationsUrl = $notificationsUrl ?? '#'; $notificationsUrl = $notificationsUrl ?? '#';
@@ -11,7 +15,6 @@
if ($profileMenuItems === [] && $profileUrl !== '#') { if ($profileMenuItems === [] && $profileUrl !== '#') {
$profileMenuItems = [['type' => 'link', 'label' => 'Profile', 'href' => $profileUrl]]; $profileMenuItems = [['type' => 'link', 'label' => 'Profile', 'href' => $profileUrl]];
} }
$searchLabel = $searchLabel ?? 'Search';
$navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600'; $navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600';
@endphp @endphp
<div x-data="{ profileOpen: false }" class="lg:hidden"> <div x-data="{ profileOpen: false }" class="lg:hidden">
@@ -24,11 +27,13 @@
<span class="text-[10px] font-medium">Home</span> <span class="text-[10px] font-medium">Home</span>
</a> </a>
@if ($showSearch)
<a href="{{ $searchUrl }}" <a href="{{ $searchUrl }}"
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($searchActive ?? false) }}"> class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($searchActive ?? false) }}">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg> <svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
<span class="text-[10px] font-medium">{{ $searchLabel }}</span> <span class="text-[10px] font-medium">Search</span>
</a> </a>
@endif
@if (!empty($centerCompose)) @if (!empty($centerCompose))
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@@ -52,12 +57,14 @@
}, },
}" }"
@endif @endif
class="relative flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($notificationsActive ?? false) }}"> class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($notificationsActive ?? false) }}">
<span class="relative inline-flex shrink-0">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg> <svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
@if ($unreadUrl) @if ($unreadUrl)
<span x-show="unread > 0" x-cloak x-text="unread > 9 ? '9+' : unread" <span x-show="unread > 0" x-cloak x-text="unread > 9 ? '9+' : unread"
class="absolute right-3 top-1.5 inline-flex min-w-4 items-center justify-center rounded-full bg-rose-500 px-1 py-0.5 text-[9px] font-semibold text-white"></span> class="absolute -right-1 -top-1 inline-flex min-w-5 items-center justify-center rounded-full bg-rose-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"></span>
@endif @endif
</span>
<span class="text-[10px] font-medium">Notifications</span> <span class="text-[10px] font-medium">Notifications</span>
</a> </a>
@@ -83,6 +90,7 @@
class="absolute inset-0 bg-slate-900/40 backdrop-blur-[1px]"></div> class="absolute inset-0 bg-slate-900/40 backdrop-blur-[1px]"></div>
<div x-show="profileOpen" <div x-show="profileOpen"
x-effect="profileOpen && window.dispatchEvent(new CustomEvent('wallet-balance-refresh'))"
x-transition:enter="transition ease-out duration-300" x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-y-full" x-transition:enter-start="translate-y-full"
x-transition:enter-end="translate-y-0" x-transition:enter-end="translate-y-0"
@@ -114,25 +122,11 @@
</div> </div>
@endif @endif
<div class="space-y-1 p-2 pb-4"> @include('partials.user-profile-menu', [
@foreach ($profileMenuItems as $item) 'items' => $profileMenuItems,
@if (($item['type'] ?? 'link') === 'link') 'variant' => 'sheet',
<a href="{{ $item['href'] }}" 'onNavigate' => 'profileOpen = false',
@click="profileOpen = false" ])
class="block rounded-xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-100">
{{ $item['label'] }}
</a>
@elseif (($item['type'] ?? '') === 'logout')
<form method="POST" action="{{ $item['action'] }}" class="border-t border-slate-100 pt-2">
@csrf
<button type="submit"
class="w-full rounded-xl px-4 py-3 text-left text-sm font-medium text-rose-600 transition hover:bg-rose-50">
{{ $item['label'] }}
</button>
</form>
@endif
@endforeach
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,53 @@
@php
$variant = $variant ?? 'primary';
$type = $type ?? 'submit';
$tag = ! empty($href) ? 'a' : 'button';
$ariaLabel = $ariaLabel ?? $label ?? '';
$desktopLabel = $desktopLabel ?? $label ?? '';
$showDesktopIcon = $showDesktopIcon ?? ($variant === 'primary');
$mobileClass = match ($variant) {
'primary' => 'btn-fab h-10 w-10 lg:hidden',
'outline' => 'inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 lg:hidden',
'dark' => 'inline-flex h-10 w-10 items-center justify-center rounded-full bg-gray-900 text-white shadow-sm transition hover:bg-gray-800 lg:hidden',
'indigo' => 'inline-flex h-10 w-10 items-center justify-center rounded-full bg-indigo-600 text-white shadow-sm transition hover:bg-indigo-700 lg:hidden',
default => 'btn-fab h-10 w-10 lg:hidden',
};
$desktopClass = match ($variant) {
'primary' => 'btn-primary hidden items-center lg:inline-flex',
'outline' => 'hidden items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 lg:inline-flex',
'dark' => 'hidden items-center gap-1.5 rounded-lg bg-gray-900 px-3.5 py-2 text-sm font-medium text-white transition hover:bg-gray-800 lg:inline-flex',
'indigo' => 'hidden items-center justify-center rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-700 lg:inline-flex',
default => 'btn-primary hidden items-center lg:inline-flex',
};
$extraClass = $class ?? '';
@endphp
<{{ $tag }}
@if ($tag === 'a') href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
aria-label="{{ $ariaLabel }}"
title="{{ $ariaLabel }}"
@if (! empty($attributes)) {!! $attributes !!} @endif
class="{{ trim($mobileClass.' '.$extraClass) }}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
</{{ $tag }}>
<{{ $tag }}
@if ($tag === 'a') href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
@if (! empty($attributes)) {!! $attributes !!} @endif
class="{{ trim($desktopClass.' '.$extraClass) }}"
>
@if ($showDesktopIcon)
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
@endif
{{ $desktopLabel }}
</{{ $tag }}>
@@ -0,0 +1,33 @@
@php
$icon = $icon ?? 'arrow';
$desktopClass = $desktopClass ?? 'text-xs font-medium text-indigo-600 hover:text-indigo-700';
$label = $label ?? '';
$extraAttributes = $attributes ?? '';
@endphp
<a href="{{ $href }}"
aria-label="{{ $label }}"
title="{{ $label }}"
@if ($extraAttributes !== '') {!! $extraAttributes !!} @endif
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-indigo-50 text-indigo-600 ring-1 ring-indigo-100 transition hover:bg-indigo-100 lg:h-auto lg:w-auto lg:rounded-none lg:bg-transparent lg:ring-0 lg:hover:bg-transparent">
<span class="lg:hidden">
@if ($icon === 'plus')
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
@elseif ($icon === 'shield')
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"/>
</svg>
@elseif ($icon === 'calendar')
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"/>
</svg>
@else
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/>
</svg>
@endif
</span>
<span class="hidden lg:inline {{ $desktopClass }}">{{ $label }}</span>
</a>
@@ -0,0 +1,635 @@
{{--
Payment checkout shell.
Audiences:
- buyer (default): public ticket buyers, donors, shoppers self-serve checkout
- operator: signed-in staff flows such as wallet top-up
Optional overrides: $sheetTitle, $sheetSubtitle, $sheetFooter, $sheetAria, $iframeTitle
Requires Alpine ancestor with:
- showSheet (bool)
- checkoutUrl (string) authorization / waiting URL
- accessCode (string, optional) Paystack initialize access_code for Inline
- publicKey (string, optional)
- returnUrl (string, optional) where to go after Inline onSuccess (?reference=)
checkout.paystack.com cannot be iframed (X-Frame-Options: SAMEORIGIN).
Paystack Inline owns its secure payment popup. We do not place a Ladill
loading sheet in front of it: that created a needless first screen before
the checkout appeared. The Ladill bottomsheet/modal is reserved for
same-origin waiting pages (such as MoMo) and recoverable errors.
--}}
@php
$audience = $audience ?? 'buyer';
$defaults = match ($audience) {
'operator' => [
'title' => 'Complete payment',
'subtitle' => null,
'aria' => 'Complete payment',
'iframe' => 'Payment checkout',
'footer' => null,
],
default => [
'title' => 'Complete your payment',
'subtitle' => 'Pay securely. You\'ll get a confirmation when it succeeds.',
'aria' => 'Complete your payment',
'iframe' => 'Secure payment checkout',
'footer' => 'Your payment is processed securely. Do not close this window until finished.',
],
};
$sheetTitle = $sheetTitle ?? $defaults['title'];
$sheetSubtitle = $sheetSubtitle ?? $defaults['subtitle'];
$sheetAria = $sheetAria ?? $defaults['aria'];
$iframeTitle = $iframeTitle ?? $defaults['iframe'];
$sheetFooter = $sheetFooter ?? $defaults['footer'];
@endphp
@once
<script>
(function () {
if (window.__ladillPayCheckoutBootstrapped) return;
window.__ladillPayCheckoutBootstrapped = true;
var INLINE_SRC = 'https://js.paystack.co/v2/inline.js';
var inlineLoading = null;
var activePopup = null;
function isFrameable(url) {
if (!url) return false;
try {
return new URL(url, window.location.href).origin === window.location.origin;
} catch (e) {
return false;
}
}
function isPaystackCheckoutUrl(url) {
if (!url) return false;
try {
var host = new URL(url, window.location.href).hostname.toLowerCase();
return host === 'checkout.paystack.com' || host.endsWith('.paystack.com');
} catch (e) {
return false;
}
}
function accessCodeFromUrl(url) {
if (!url || !isPaystackCheckoutUrl(url)) return '';
try {
var path = new URL(url, window.location.href).pathname.replace(/^\/+/, '');
var code = path.split('/')[0] || '';
return /^[A-Za-z0-9_-]{6,}$/.test(code) ? code : '';
} catch (e) {
return '';
}
}
function resolveAccessCode(accessCode, checkoutUrl) {
var code = (accessCode || '').toString().trim();
if (code) return code;
return accessCodeFromUrl(checkoutUrl);
}
function loadInlineJs() {
if (window.PaystackPop) {
return Promise.resolve(window.PaystackPop);
}
if (inlineLoading) return inlineLoading;
inlineLoading = new Promise(function (resolve, reject) {
var existing = document.querySelector('script[data-ladill-paystack-inline]');
if (existing) {
if (window.PaystackPop) {
resolve(window.PaystackPop);
return;
}
existing.addEventListener('load', function () { resolve(window.PaystackPop); });
existing.addEventListener('error', function () { reject(new Error('Paystack script failed')); });
return;
}
var script = document.createElement('script');
script.src = INLINE_SRC;
script.async = true;
script.dataset.ladillPaystackInline = '1';
script.onload = function () { resolve(window.PaystackPop); };
script.onerror = function () {
inlineLoading = null;
reject(new Error('Paystack script failed'));
};
document.head.appendChild(script);
});
return inlineLoading;
}
function buildReturnUrl(returnUrl, reference) {
if (!returnUrl || !reference) return '';
try {
var u = new URL(returnUrl, window.location.href);
u.searchParams.set('reference', reference);
return u.toString();
} catch (e) {
var join = returnUrl.indexOf('?') >= 0 ? '&' : '?';
return returnUrl + join + 'reference=' + encodeURIComponent(reference);
}
}
function cancelInline() {
try {
if (activePopup && typeof activePopup.cancelTransaction === 'function') {
activePopup.cancelTransaction();
}
} catch (e) {}
activePopup = null;
}
// Fullscreen permission for Paystack's own iframes (do not reparent them).
// Only write attributes when they actually change — writing always re-fires
// MutationObservers and freezes the page (wallet include path).
function applyIframePermissions(iframe) {
if (!iframe || !iframe.setAttribute) return;
try {
var cur = (iframe.getAttribute('allow') || '').trim();
var need = ['payment *', 'fullscreen *', 'clipboard-read *', 'clipboard-write *', 'publickey-credentials-get *'];
var parts = cur ? cur.split(';').map(function (p) { return p.trim(); }).filter(Boolean) : [];
var lower = parts.map(function (p) { return p.toLowerCase(); });
need.forEach(function (perm) {
var base = perm.split(' ')[0].toLowerCase();
if (!lower.some(function (p) { return p === base || p.indexOf(base + ' ') === 0; })) {
parts.push(perm);
}
});
var next = parts.join('; ');
if (cur !== next) {
iframe.setAttribute('allow', next);
}
if (!iframe.hasAttribute('allowfullscreen')) {
iframe.setAttribute('allowfullscreen', '');
}
} catch (e) {}
}
function ensurePaystackIframePermissions(root) {
try {
var scope = root || document;
var nodes = scope.querySelectorAll ? scope.querySelectorAll('iframe') : [];
for (var i = 0; i < nodes.length; i++) {
var iframe = nodes[i];
var id = (iframe.id || '').toLowerCase();
var src = '';
try { src = (iframe.getAttribute('src') || '').toLowerCase(); } catch (e) {}
if (id.indexOf('inline-') === 0 || id.indexOf('embed-') === 0 || src.indexOf('paystack') !== -1) {
applyIframePermissions(iframe);
}
}
} catch (e) {}
}
function installIframeAllowHook() {
if (window.__ladillIframeAllowHook) return;
window.__ladillIframeAllowHook = true;
try {
var orig = Document.prototype.createElement;
Document.prototype.createElement = function (tagName, options) {
var el = options !== undefined ? orig.call(this, tagName, options) : orig.call(this, tagName);
try {
if (String(tagName).toLowerCase() === 'iframe') {
applyIframePermissions(el);
}
} catch (e) {}
return el;
};
} catch (e) {}
}
/**
* Arm iframe permission helpers only while a payment is starting.
* Do not install on every wallet/page load observing style/class + always
* rewriting allow caused an infinite MutationObserver loop (page unresponsive).
*/
function watchPaystackIframes() {
if (window.__ladillPaystackIframeWatch) return;
window.__ladillPaystackIframeWatch = true;
installIframeAllowHook();
ensurePaystackIframePermissions(document);
try {
var obs = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
// childList only — never re-enter on attribute writes.
if (!m.addedNodes || !m.addedNodes.length) continue;
for (var n = 0; n < m.addedNodes.length; n++) {
var node = m.addedNodes[n];
if (!node || node.nodeType !== 1) continue;
if (node.tagName === 'IFRAME') {
applyIframePermissions(node);
} else if (node.querySelectorAll) {
var nested = node.querySelectorAll('iframe');
for (var k = 0; k < nested.length; k++) {
applyIframePermissions(nested[k]);
}
}
}
}
});
obs.observe(document.documentElement || document.body, {
childList: true,
subtree: true,
});
} catch (e) {}
}
function emitPayEvent(name, detail) {
try {
window.dispatchEvent(new CustomEvent(name, { detail: detail || {} }));
} catch (e) {}
}
function createStoreDefinition() {
return {
frameable: false,
inline: false,
ready: false,
launching: false,
error: '',
_launchToken: 0,
_activeCode: '',
_openedEmitted: false,
shellVisible() {
return !!(this.frameable || this.error);
},
reset() {
this.frameable = false;
this.inline = false;
this.ready = false;
this.launching = false;
this.error = '';
this._activeCode = '';
this._openedEmitted = false;
cancelInline();
},
markOpened() {
if (this._openedEmitted) return;
this._openedEmitted = true;
this.launching = false;
emitPayEvent('ladill-pay-opened');
},
markFailed(message) {
this.launching = false;
this._activeCode = '';
this._openedEmitted = false;
this.error = message || 'Could not open secure payment.';
emitPayEvent('ladill-pay-error', { message: this.error });
},
sync(show, url, accessCode, returnUrl) {
url = (url || '').toString();
accessCode = (accessCode || '').toString();
returnUrl = (returnUrl || '').toString();
if (!show) {
this._launchToken += 1;
this.reset();
return;
}
this.ready = !!(url || accessCode);
// Keep a previous recoverable error only until a new attempt supplies payload.
if (url || accessCode) {
this.error = '';
}
if (!url && !accessCode) {
// Waiting for parent to set access_code / checkout_url after fetch.
this.frameable = false;
this.inline = false;
this.launching = true;
this._activeCode = '';
this._openedEmitted = false;
return;
}
if (isFrameable(url)) {
this.frameable = true;
this.inline = false;
this.launching = false;
this._activeCode = '';
this._openedEmitted = false;
cancelInline();
// Same-origin waiting UI is our shell — it is "open" for busy-state release.
emitPayEvent('ladill-pay-opened');
return;
}
var code = resolveAccessCode(accessCode, url);
if (code) {
this.frameable = false;
this.inline = true;
if (this._activeCode === code && (this.launching || activePopup)) {
return;
}
this.launchInline(code, returnUrl);
return;
}
this.frameable = false;
this.inline = false;
this.markFailed(
isPaystackCheckoutUrl(url)
? 'Could not start in-app payment. Please try again.'
: 'Secure checkout could not be opened in-page. Please try again.'
);
},
launchInline(accessCode, returnUrl) {
var store = this;
var token = ++this._launchToken;
this._activeCode = accessCode;
this.launching = true;
this.error = '';
this.inline = true;
this.frameable = false;
this._openedEmitted = false;
watchPaystackIframes();
// Ensure Paystack script is fully ready before resumeTransaction so we
// do not paint an empty popup shell for a beat (the pre-modal flash).
loadInlineJs().then(function (PaystackPop) {
if (token !== store._launchToken) return;
if (!PaystackPop) throw new Error('Paystack unavailable');
cancelInline();
var popup = new PaystackPop();
activePopup = popup;
ensurePaystackIframePermissions(document);
popup.resumeTransaction(accessCode, {
onLoad: function () {
if (token !== store._launchToken) return;
store.markOpened();
ensurePaystackIframePermissions(document);
},
onSuccess: function (transaction) {
if (token !== store._launchToken) return;
store.launching = false;
activePopup = null;
var reference = (transaction && (transaction.reference || transaction.trxref)) || '';
var dest = buildReturnUrl(returnUrl, reference);
if (dest) {
window.location.assign(dest);
return;
}
window.location.reload();
},
onCancel: function () {
if (token !== store._launchToken) return;
store.launching = false;
store._activeCode = '';
store._openedEmitted = false;
activePopup = null;
emitPayEvent('ladill-pay-cancelled');
},
onError: function (err) {
if (token !== store._launchToken) return;
activePopup = null;
store.markFailed((err && err.message) ? String(err.message) : 'Could not open secure payment.');
},
});
// Safety: if onLoad is slow, treat a live checkout iframe as open.
var polls = 0;
var pollId = setInterval(function () {
if (token !== store._launchToken) {
clearInterval(pollId);
return;
}
ensurePaystackIframePermissions(document);
var live = document.querySelector(
'iframe[id^="inline-checkout"], iframe[id^="embed-checkout"]'
);
if (live) {
store.markOpened();
clearInterval(pollId);
}
polls += 1;
if (polls >= 50) {
clearInterval(pollId);
if (store.launching && token === store._launchToken) {
store.markFailed('Payment UI did not open. Please try again.');
}
}
}, 100);
}).catch(function (err) {
if (token !== store._launchToken) return;
store.markFailed((err && err.message) ? String(err.message) : 'Could not load payment script.');
});
},
};
}
function ensureStore() {
if (!window.Alpine || typeof window.Alpine.store !== 'function') return null;
try {
var existing = window.Alpine.store('ladillPayCheckout');
if (existing) return existing;
} catch (e) {}
window.Alpine.store('ladillPayCheckout', createStoreDefinition());
return window.Alpine.store('ladillPayCheckout');
}
document.addEventListener('alpine:init', function () {
ensureStore();
});
if (window.Alpine && window.Alpine.version) {
ensureStore();
}
// Do not watch iframes or preload Paystack on every page that includes this
// partial (e.g. wallet). That froze the tab. prepare()/launchInline arm it.
window.LadillPayCheckout = {
isFrameable: isFrameable,
isPaystackCheckoutUrl: isPaystackCheckoutUrl,
accessCodeFromUrl: accessCodeFromUrl,
resolveAccessCode: resolveAccessCode,
prepare: function () {
// Warm script + hooks only when user is about to pay.
installIframeAllowHook();
loadInlineJs().catch(function () {});
return null;
},
cancel: function () {
cancelInline();
var store = ensureStore();
if (store) store.reset();
},
open: function () { return null; },
ensureStore: ensureStore,
};
})();
</script>
@endonce
<template x-teleport="body">
{{-- Inherit showSheet / checkoutUrl / accessCode / returnUrl from parent Alpine scope. --}}
<div
x-init="
window.LadillPayCheckout && window.LadillPayCheckout.ensureStore();
// Drop parent busy flags only when payment UI is actually up (or sheet closed).
// Prevents the form/button from flashing back while Paystack is still booting.
const releaseBusy = () => {
try {
if (typeof loading !== 'undefined' && loading) loading = false;
if (typeof renewLoading !== 'undefined' && renewLoading) renewLoading = false;
} catch (e) {}
};
const onPayOpened = () => releaseBusy();
const onPayCancelled = () => { showSheet = false; releaseBusy(); };
const onPayError = () => releaseBusy();
window.addEventListener('ladill-pay-opened', onPayOpened);
window.addEventListener('ladill-pay-cancelled', onPayCancelled);
window.addEventListener('ladill-pay-error', onPayError);
const runSync = () => {
const store = $store.ladillPayCheckout;
if (!store) return;
store.sync(
!!showSheet,
(typeof checkoutUrl === 'undefined' || checkoutUrl === null) ? '' : String(checkoutUrl),
(typeof accessCode === 'undefined' || accessCode === null) ? '' : String(accessCode),
(typeof returnUrl === 'undefined' || returnUrl === null) ? '' : String(returnUrl)
);
};
$watch('showSheet', (value) => {
runSync();
if (!value) releaseBusy();
});
$watch('checkoutUrl', () => runSync());
$watch('accessCode', () => runSync());
$watch('returnUrl', () => runSync());
$nextTick(() => runSync());
"
x-show="showSheet && $store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error)"
x-cloak
data-ladill-pay-sheet
style="display: none"
x-effect="
// Only lock page scroll when OUR shell is visible — never during pure
// Paystack Inline handoff (that scrollbar jump was the pre-modal flash).
const lock = !!(showSheet && $store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error));
document.documentElement.style.overflow = lock ? 'hidden' : '';
"
@keydown.escape.window="
if (!showSheet) return;
// While Paystack Inline is the active UI, Escape is handled by Paystack.
if ($store.ladillPayCheckout && $store.ladillPayCheckout.inline && !$store.ladillPayCheckout.launching && !$store.ladillPayCheckout.error) return;
showSheet = false;
window.LadillPayCheckout?.cancel?.();
"
@ladill-pay-cancelled.window="showSheet = false"
class="fixed inset-0 z-[9999]"
role="dialog"
aria-modal="true"
:aria-hidden="(!(showSheet && $store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error))).toString()"
aria-label="{{ $sheetAria }}">
{{--
Ladill bottomsheet (mobile) / modal (desktop) ONLY for:
- same-origin waiting pages (MoMo etc.)
- recoverable errors
Paystack Inline opens its own secure payment popup; this shell stays
hidden during launch so nothing flashes in front of it.
--}}
<div x-show="$store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error)"
x-cloak
class="pointer-events-auto absolute inset-0 flex items-end justify-center md:items-center md:p-6"
data-ladill-pay-shell>
<div class="absolute inset-0 bg-black/60"
data-ladill-pay-backdrop
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()">
</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>
</div>
</div>
</template>
@@ -14,7 +14,7 @@
<div class="px-4 pb-3"> <div class="px-4 pb-3">
<div class="relative"> <div class="relative">
<svg class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg> <svg class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
<input x-ref="input" <input data-ladill-search-input x-ref="input"
type="text" type="text"
x-model="query" x-model="query"
@focus="onFocus()" @focus="onFocus()"
@@ -34,7 +34,7 @@
<h1 class="text-2xl font-semibold text-slate-900">{{ $heading }}</h1> <h1 class="text-2xl font-semibold text-slate-900">{{ $heading }}</h1>
<div class="relative mt-3"> <div class="relative mt-3">
<svg class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg> <svg class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
<input x-ref="input" <input data-ladill-search-input x-ref="input"
type="text" type="text"
x-model="query" x-model="query"
@focus="onFocus()" @focus="onFocus()"
@@ -15,14 +15,6 @@
['name' => 'Analytics', 'route' => route('transfer.analytics.index'), 'active' => request()->routeIs('transfer.analytics.*'), ['name' => 'Analytics', 'route' => route('transfer.analytics.index'), 'active' => request()->routeIs('transfer.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" />'], '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" />'],
];
@endphp @endphp
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4"> <nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
@foreach($main as $item) @foreach($main as $item)
@@ -31,14 +23,6 @@
<span>{{ $item['name'] }}</span> <span>{{ $item['name'] }}</span>
</a> </a>
@endforeach @endforeach
<p class="px-3 pb-1 pt-5 text-[10px] font-bold uppercase tracking-widest text-slate-400">Account</p>
@foreach($accountNav as $item)
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="h-[18px] w-[18px] shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">{!! $item['icon'] !!}</svg>
<span>{{ $item['name'] }}</span>
</a>
@endforeach
@include('partials.sidebar-support')
</nav> </nav>
<div class="shrink-0 border-t border-slate-100 px-3 py-3"> <div class="shrink-0 border-t border-slate-100 px-3 py-3">
@@ -1,11 +1,19 @@
@if (auth()->check()) @if (auth()->check())
@php $authPing = 'https://'.config('app.auth_domain').'/sso/ping'; @endphp @php
{{-- Same-site pings keep the shared auth.ladill.com session warm while using this app. --}} $authPing = 'https://'.config('app.auth_domain').'/sso/ping';
<iframe src="{{ $authPing }}" hidden width="0" height="0" title=""></iframe> $platformSignedOutUrl = route('sso.platform-signed-out', ['redirect' => url()->current()]);
@endphp
<script> <script>
(function () { (function () {
const pingUrl = @js($authPing); const pingUrl = @js($authPing);
const ping = () => fetch(pingUrl, { credentials: 'include', mode: 'no-cors' }).catch(() => {}); const signedOutUrl = @js($platformSignedOutUrl);
const ping = () => fetch(pingUrl, { credentials: 'include' })
.then((response) => {
if (response.status === 401) {
window.location.href = signedOutUrl;
}
})
.catch(() => {});
ping(); ping();
setInterval(ping, 5 * 60 * 1000); setInterval(ping, 5 * 60 * 1000);
})(); })();
@@ -0,0 +1,24 @@
{{-- Account switcher (desktop) only when the user belongs to more than one account. --}}
@if (isset($accessibleAccounts) && $accessibleAccounts->count() > 1)
<div x-data="{ accountSwitcherOpen: false }" @click.outside="accountSwitcherOpen = false" class="relative hidden lg:block">
<button type="button" @click="accountSwitcherOpen = !accountSwitcherOpen"
class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
<span class="max-w-[140px] truncate">{{ $actingAccount->name ?? $actingAccount->email }}</span>
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7"/></svg>
</button>
<div x-show="accountSwitcherOpen" x-cloak x-transition
class="absolute right-0 z-30 mt-2 w-60 rounded-xl border border-slate-200 bg-white p-1 shadow-lg">
<p class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">Switch account</p>
@foreach ($accessibleAccounts as $acctOption)
<form method="POST" action="{{ route('account.switch') }}">
@csrf
<input type="hidden" name="account" value="{{ $acctOption->id }}">
<button type="submit" class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-50 {{ $acctOption->id === $actingAccount->id ? 'font-semibold text-indigo-700' : 'text-slate-700' }}">
<span class="truncate">{{ $acctOption->id === auth()->id() ? 'My account' : ($acctOption->name ?? $acctOption->email) }}</span>
@if ($acctOption->id === $actingAccount->id)<span class="text-indigo-600"></span>@endif
</button>
</form>
@endforeach
</div>
</div>
@endif
@@ -0,0 +1,45 @@
{{--
Top-right header widgets:
Mobile Afia + launcher only (profile/notifications live in bottom nav).
Desktop Afia notifications launcher divider avatar dropdown.
--}}
@php
$topbarUser = $user ?? auth()->user();
$initials = collect(explode(' ', trim((string) $topbarUser?->name)))
->filter()->take(2)->map(fn ($p) => strtoupper(substr($p, 0, 1)))->implode('');
$avatarUrl = $topbarUser && method_exists($topbarUser, 'avatarUrl')
? $topbarUser->avatarUrl()
: ($topbarUser?->avatar_url ?? null);
$showUserHeader = $showUser ?? true;
@endphp
@includeIf('partials.afia-button', ['compact' => true])
@includeIf('partials.notification-dropdown')
@include('partials.launcher')
@includeIf('partials.topbar-widgets-mid')
<div class="hidden h-8 w-px bg-slate-200 lg:block"></div>
<div class="relative hidden lg:block" x-data="{ profileOpen: false }" @click.outside="profileOpen = false" @keydown.escape.window="profileOpen = false">
<button type="button" @click="profileOpen = !profileOpen"
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-2 py-1.5 text-slate-700 transition hover:bg-slate-50">
@if ($avatarUrl)
<img src="{{ $avatarUrl }}" alt="Avatar" class="h-8 w-8 rounded-full object-cover ring-1 ring-slate-200">
@else
<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{{ $initials !== '' ? $initials : 'U' }}</span>
@endif
<svg class="h-4 w-4 text-slate-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7"/></svg>
</button>
<div x-show="profileOpen" x-cloak x-transition
class="absolute right-0 z-50 mt-2 w-64 rounded-xl border border-slate-200 bg-white shadow-lg">
@include('partials.user-profile-menu', [
'items' => \App\Support\UserProfileMenu::items($topbarUser),
'user' => $topbarUser,
'showUser' => $showUserHeader,
])
</div>
</div>
+7 -120
View File
@@ -16,127 +16,14 @@
</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">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('transfer.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 @auth
@include('partials.afia-button', ['compact' => true]) @includeIf('partials.topbar-account-switcher')
@endauth @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.launcher')
@endauth
</div> </div>
</header> </header>
@@ -0,0 +1,7 @@
{{-- Mobile cart icon (after avatar controls). --}}
@if (! empty($cartRoute))
@include('partials.mobile-header-cart', [
'cartUrl' => $cartRoute,
'cartCount' => $cartCount ?? 0,
])
@endif
@@ -0,0 +1,10 @@
{{-- 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
+8 -476
View File
@@ -36,491 +36,23 @@
@endphp @endphp
<header class="flex items-center justify-between h-16 border-b border-slate-200 bg-white px-6" <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"> <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"> <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> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button> </button>
{{-- 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>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="relative hidden lg:block" @auth
x-data="notificationDropdown({ @includeIf('partials.topbar-account-switcher')
unreadUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.unread')) }}, @includeIf('partials.topbar-widgets-prepend')
markReadUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.mark-read', ['id' => '__ID__'])) }}, @include('partials.topbar-desktop-widgets', ['user' => $user ?? $u ?? auth()->user(), 'showUser' => true])
markAllReadUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.mark-all-read')) }}, @includeIf('partials.topbar-widgets-append')
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 @else
<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white"> <a href="{{ route('login') }}" class="btn-primary">Sign in</a>
{{ $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('transfer.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') @include('partials.launcher')
@endauth
</div> </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> </header>
@@ -0,0 +1,73 @@
@props([
'items' => [],
'user' => null,
'showUser' => false,
'variant' => 'dropdown',
'onNavigate' => null,
])
@php
$linkClass = match ($variant) {
'dark' => 'block rounded-md px-3 py-2 text-sm text-slate-200 hover:bg-slate-800',
'sheet' => 'block rounded-xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-100',
default => 'block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100',
};
$logoutClass = match ($variant) {
'dark' => 'w-full rounded-md px-3 py-2 text-left text-sm text-rose-300 hover:bg-rose-950/40',
'sheet' => 'w-full rounded-xl px-4 py-3 text-left text-sm font-medium text-rose-600 transition hover:bg-rose-50',
default => 'w-full rounded-lg px-3 py-2 text-left text-sm text-rose-600 hover:bg-rose-50',
};
$logoutWrapperClass = match ($variant) {
'sheet' => 'border-t border-slate-100 pt-2',
'dark' => '',
default => '',
};
$dividerClass = match ($variant) {
'dark' => 'my-1 border-t border-slate-800',
default => 'my-1 border-t border-slate-100',
};
$containerClass = match ($variant) {
'dark' => 'mt-2 rounded-lg border border-slate-800 bg-slate-900 p-1',
'sheet' => 'space-y-1 p-2 pb-4',
default => 'p-1',
};
@endphp
<div {{ $attributes->merge(['class' => $containerClass]) }}>
@if ($showUser && $user)
<div class="px-3 py-2">
<p class="truncate text-sm font-semibold text-slate-900">{{ $user->name ?? 'Your account' }}</p>
<p class="truncate text-xs text-slate-400">{{ $user->email }}</p>
</div>
<div class="{{ $dividerClass }}"></div>
@endif
@foreach ($items as $item)
@if (($item['type'] ?? 'link') === 'link')
<a href="{{ $item['href'] }}"
class="{{ $linkClass }}"
@if ($onNavigate) @click="{{ $onNavigate }}" @endif>
{{ $item['label'] }}
</a>
@elseif (($item['type'] ?? '') === 'wallet')
@includeIf('partials.wallet-widget', [
'onNavigate' => $onNavigate,
'class' => $variant === 'sheet' ? 'mx-2' : 'mx-1',
])
@elseif (($item['type'] ?? '') === 'logout')
@if ($variant !== 'sheet')
<div class="{{ $dividerClass }}"></div>
@endif
<form method="POST" action="{{ $item['action'] }}" class="{{ $logoutWrapperClass }}">
@csrf
<button type="submit" class="{{ $logoutClass }}">
{{ $item['label'] }}
</button>
</form>
@endif
@endforeach
</div>
@@ -0,0 +1,30 @@
{{-- Wallet balance peek (links to the account wallet on account.ladill.com). --}}
@php
$onNavigate = $onNavigate ?? null;
$class = trim((string) ($class ?? 'mx-1'));
$balanceRoute = (string) config('billing.wallet_balance_route', 'wallet.balance');
$balanceUrl = \Illuminate\Support\Facades\Route::has($balanceRoute) ? route($balanceRoute) : null;
$walletUrl = \Illuminate\Support\Facades\Route::has('user.wallet.index')
? route('user.wallet.index')
: (function_exists('ladill_account_url') ? ladill_account_url('/wallet') : '#');
@endphp
@if ($balanceUrl)
<a href="{{ $walletUrl }}"
x-data="walletWidget({ url: {{ \Illuminate\Support\Js::from($balanceUrl) }} })" x-init="load()"
@wallet-balance-refresh.window="load()"
@if ($onNavigate) @click="{{ $onNavigate }}" @endif
class="{{ $class }} flex items-center justify-between gap-3 rounded-xl border border-slate-100 bg-gradient-to-r from-indigo-50 to-slate-50 px-3 py-2.5 transition hover:border-indigo-200 hover:from-indigo-100/70">
<span class="flex items-center gap-2.5 min-w-0">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-indigo-100 text-indigo-600">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.6" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 1 3 7.5m18 4.5v3.75A2.25 2.25 0 0 1 18.75 18H5.25A2.25 2.25 0 0 1 3 15.75V7.5m18 4.5h-3.75a1.5 1.5 0 0 0 0 3H21M3 7.5A2.25 2.25 0 0 1 5.25 5.25h11.25A2.25 2.25 0 0 1 18.75 7.5"/>
</svg>
</span>
<span class="min-w-0">
<span class="block text-[11px] font-medium text-slate-500">Wallet balance</span>
<span class="block truncate text-sm font-semibold text-slate-900" x-text="display"></span>
</span>
</span>
<svg class="h-4 w-4 shrink-0 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m9 5 7 7-7 7"/></svg>
</a>
@endif
@@ -111,7 +111,7 @@
<div id="toolbar"> <div id="toolbar">
<span class="tb-label">{{ $qrCode->label }}</span> <span class="tb-label">{{ $qrCode->label }}</span>
@if($allowDownload) @if($allowDownload)
<a href="{{ route('qr.public.file', $qrCode->short_code) }}" download class="tb-download"> <a href="{{ $qrCode->publicPath('file') }}" download class="tb-download">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <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"/> <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> </svg>
+32 -36
View File
@@ -3,8 +3,8 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@include('partials.favicon')
<title>{{ $qrCode->label }}</title> <title>{{ $qrCode->label }}</title>
@include('partials.favicon')
<meta name="description" content="{{ $qrCode->typeLabel() }} — {{ $qrCode->label }}"> <meta name="description" content="{{ $qrCode->typeLabel() }} — {{ $qrCode->label }}">
@vite(['resources/css/app.css']) @vite(['resources/css/app.css'])
</head> </head>
@@ -24,7 +24,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"/> <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> </svg>
</div> </div>
<a href="{{ route('qr.public.view', $qrCode->short_code) }}" <a href="{{ $qrCode->publicPath('view') }}"
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"> 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"> <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"/> <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 +32,7 @@
</svg> </svg>
View document View document
</a> </a>
<a href="{{ route('qr.public.file', $qrCode->short_code) }}" <a href="{{ $qrCode->publicPath('file') }}"
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"> 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"> <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"/> <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 +62,7 @@
{{-- ===== VCARD ===== --}} {{-- ===== VCARD ===== --}}
@elseif($type === \App\Models\QrCode::TYPE_VCARD) @elseif($type === \App\Models\QrCode::TYPE_VCARD)
@php @php
$avatarUrl = !empty($content['avatar_path']) ? route('qr.public.vcard.avatar', $qrCode->short_code) : null; $avatarUrl = !empty($content['avatar_path']) ? $qrCode->publicPath('avatar') : null;
$rawSocial = $content['social'] ?? []; $rawSocial = $content['social'] ?? [];
$social = []; $social = [];
foreach ($rawSocial as $platform => $url) { foreach ($rawSocial as $platform => $url) {
@@ -159,7 +159,7 @@
{{-- Save contact button --}} {{-- Save contact button --}}
<div class="px-6 pb-6 pt-2"> <div class="px-6 pb-6 pt-2">
<a href="{{ route('qr.public.vcard', $qrCode->short_code) }}" <a href="{{ $qrCode->publicPath('vcard.vcf') }}"
class="btn-primary w-full"> 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> <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 Save Contact
@@ -178,8 +178,8 @@
$bizColor = $content['brand_color'] ?? '#1e3a5f'; $bizColor = $content['brand_color'] ?? '#1e3a5f';
$bizHasLogo = !empty($content['logo_path']); $bizHasLogo = !empty($content['logo_path']);
$bizHasCover = !empty($content['cover_path']); $bizHasCover = !empty($content['cover_path']);
$bizLogoUrl = $bizHasLogo ? route('qr.public.business.logo', $qrCode->short_code) : null; $bizLogoUrl = $bizHasLogo ? $qrCode->publicPath('business-logo') : null;
$bizCoverUrl = $bizHasCover ? route('qr.public.business.cover', $qrCode->short_code) : null; $bizCoverUrl = $bizHasCover ? $qrCode->publicPath('business-cover') : null;
$bizInitials = strtoupper(substr($bizName, 0, 1)); $bizInitials = strtoupper(substr($bizName, 0, 1));
$bizRawSocial = $content['social'] ?? []; $bizRawSocial = $content['social'] ?? [];
$bizSocial = []; $bizSocial = [];
@@ -313,8 +313,8 @@
$churchColor = $content['brand_color'] ?? '#1a3a5c'; $churchColor = $content['brand_color'] ?? '#1a3a5c';
$churchHasLogo = !empty($content['logo_path']); $churchHasLogo = !empty($content['logo_path']);
$churchHasCover = !empty($content['cover_path']); $churchHasCover = !empty($content['cover_path']);
$churchLogoUrl = $churchHasLogo ? route('qr.public.church.logo', $qrCode->short_code) : null; $churchLogoUrl = $churchHasLogo ? $qrCode->publicPath('church-logo') : null;
$churchCoverUrl = $churchHasCover ? route('qr.public.church.cover', $qrCode->short_code) : null; $churchCoverUrl = $churchHasCover ? $qrCode->publicPath('church-cover') : null;
$churchInitials = strtoupper(substr($churchName, 0, 2)); $churchInitials = strtoupper(substr($churchName, 0, 2));
$churchAcceptsPay = !empty($content['accepts_payment']); $churchAcceptsPay = !empty($content['accepts_payment']);
$churchCurrency = $content['currency'] ?? 'GHS'; $churchCurrency = $content['currency'] ?? 'GHS';
@@ -325,7 +325,7 @@
$churchTypes, $churchTypes,
array_map(fn($t) => $legacyLabels[strtolower(trim($t))] ?? trim($t), $churchTypes) array_map(fn($t) => $legacyLabels[strtolower(trim($t))] ?? trim($t), $churchTypes)
); );
$churchOrderRoute = route('qr.public.order', $qrCode->short_code); $churchOrderRoute = $qrCode->publicPath('order');
$churchCsrf = csrf_token(); $churchCsrf = csrf_token();
@endphp @endphp
<div x-data="{ <div x-data="{
@@ -338,6 +338,7 @@
errorMsg: '', errorMsg: '',
showSheet: false, showSheet: false,
checkoutUrl: '', checkoutUrl: '',
accessCode: '',
async give() { async give() {
if (!this.amount || parseFloat(this.amount) <= 0) { this.errorMsg = 'Enter a valid amount.'; return; } if (!this.amount || parseFloat(this.amount) <= 0) { this.errorMsg = 'Enter a valid amount.'; return; }
if (!this.name.trim()) { this.errorMsg = 'Enter your name.'; return; } if (!this.name.trim()) { this.errorMsg = 'Enter your name.'; return; }
@@ -358,13 +359,10 @@
}); });
const data = await res.json(); const data = await res.json();
if (data.error) { this.errorMsg = data.error; this.loading = false; return; } if (data.error) { this.errorMsg = data.error; this.loading = false; return; }
if (window.innerWidth < 768) { this.checkoutUrl = data.checkout_url || data.authorization_url || '';
this.checkoutUrl = data.checkout_url; this.accessCode = data.access_code || '';
this.showSheet = true; this.returnUrl = data.callback_url || this.returnUrl || '';
this.loading = false; this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened)
} else {
window.location.href = data.checkout_url;
}
} catch(e) { } catch(e) {
this.errorMsg = 'Network error. Please try again.'; this.errorMsg = 'Network error. Please try again.';
this.loading = false; this.loading = false;
@@ -631,8 +629,8 @@
$evColor = $content['brand_color'] ?? '#4f46e5'; $evColor = $content['brand_color'] ?? '#4f46e5';
$evHasLogo = !empty($content['logo_path']); $evHasLogo = !empty($content['logo_path']);
$evHasCover = !empty($content['cover_path']); $evHasCover = !empty($content['cover_path']);
$evLogoUrl = $evHasLogo ? route('qr.public.event.logo', $qrCode->short_code) : null; $evLogoUrl = $evHasLogo ? $qrCode->publicPath('event-logo') : null;
$evCoverUrl = $evHasCover ? route('qr.public.event.cover', $qrCode->short_code) : null; $evCoverUrl = $evHasCover ? $qrCode->publicPath('event-cover') : null;
$evName = $content['name'] ?? $qrCode->label; $evName = $content['name'] ?? $qrCode->label;
$evInitials = strtoupper(mb_substr($evName, 0, 2)); $evInitials = strtoupper(mb_substr($evName, 0, 2));
$evTiers = $content['tiers'] ?? []; $evTiers = $content['tiers'] ?? [];
@@ -642,7 +640,7 @@
$evMode = in_array($content['mode'] ?? 'ticketing', ['contributions', 'free'], true) ? ($content['mode'] ?? 'ticketing') : 'ticketing'; $evMode = in_array($content['mode'] ?? 'ticketing', ['contributions', 'free'], true) ? ($content['mode'] ?? 'ticketing') : 'ticketing';
$evCategories = $content['contribution_categories'] ?? ['Contribution']; $evCategories = $content['contribution_categories'] ?? ['Contribution'];
$evHeading = $evMode === 'contributions' ? 'Make a contribution' : 'Register'; $evHeading = $evMode === 'contributions' ? 'Make a contribution' : 'Register';
$evRegRoute = route('qr.public.event.register', $qrCode->short_code); $evRegRoute = $qrCode->publicPath('register');
$evCsrf = csrf_token(); $evCsrf = csrf_token();
@endphp @endphp
<div x-data="{ <div x-data="{
@@ -654,6 +652,7 @@
fields: {}, fields: {},
loading: false, errorMsg: '', loading: false, errorMsg: '',
showSheet: false, checkoutUrl: '', showSheet: false, checkoutUrl: '',
accessCode: '',
selectTier(n, p) { this.tier = n; this.tierPrice = p; }, selectTier(n, p) { this.tier = n; this.tierPrice = p; },
selectCategory(n) { this.tier = n; }, selectCategory(n) { this.tier = n; },
async submit() { async submit() {
@@ -671,8 +670,7 @@
const data = await res.json(); const data = await res.json();
if (data.error) { 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; } if (!data.paid) { window.location.href = data.success_url; return; }
if (window.innerWidth < 768) { this.checkoutUrl = data.checkout_url; this.showSheet = true; this.loading = false; } this.checkoutUrl = data.checkout_url || data.authorization_url || ''; this.accessCode = data.access_code || ''; this.returnUrl = data.callback_url || this.returnUrl || ''; this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened)
else { window.location.href = data.checkout_url; }
} catch(e) { this.errorMsg = 'Network error. Please try again.'; this.loading = false; } } catch(e) { this.errorMsg = 'Network error. Please try again.'; this.loading = false; }
} }
}" class="min-h-screen bg-slate-50 pb-12"> }" class="min-h-screen bg-slate-50 pb-12">
@@ -887,7 +885,7 @@
@php @php
$itinColor = $content['brand_color'] ?? '#b45309'; $itinColor = $content['brand_color'] ?? '#b45309';
$itinHasCover = !empty($content['cover_path']); $itinHasCover = !empty($content['cover_path']);
$itinCoverUrl = $itinHasCover ? route('qr.public.itinerary.cover', $qrCode->short_code) : null; $itinCoverUrl = $itinHasCover ? $qrCode->publicPath('itinerary-cover') : null;
$itinTitle = $content['title'] ?? $qrCode->label; $itinTitle = $content['title'] ?? $qrCode->label;
$itinDays = $content['days'] ?? []; $itinDays = $content['days'] ?? [];
$multiDay = count($itinDays) > 1; $multiDay = count($itinDays) > 1;
@@ -1029,7 +1027,7 @@
<h1 class="text-2xl font-bold text-slate-900">{{ $qrCode->label }}</h1> <h1 class="text-2xl font-bold text-slate-900">{{ $qrCode->label }}</h1>
<div class="mt-6 space-y-4"> <div class="mt-6 space-y-4">
@foreach($content['images'] ?? [] as $index => $image) @foreach($content['images'] ?? [] as $index => $image)
<img src="{{ route('qr.public.image', [$qrCode->short_code, $index]) }}" <img src="{{ $qrCode->publicPath('images/'.$index) }}"
alt="{{ $image['title'] ?? 'Image ' . ($index + 1) }}" alt="{{ $image['title'] ?? 'Image ' . ($index + 1) }}"
class="w-full rounded-2xl border border-slate-200 shadow-sm" class="w-full rounded-2xl border border-slate-200 shadow-sm"
loading="lazy"> loading="lazy">
@@ -1039,7 +1037,7 @@
{{-- ===== APP ===== --}} {{-- ===== APP ===== --}}
@elseif($type === \App\Models\QrCode::TYPE_APP) @elseif($type === \App\Models\QrCode::TYPE_APP)
@php $appIconUrl = !empty($content['icon_path']) ? route('qr.public.app.icon', $qrCode->short_code) : null; @endphp @php $appIconUrl = !empty($content['icon_path']) ? $qrCode->publicPath('app-icon') : null; @endphp
<div class="flex min-h-screen flex-col items-center justify-center px-4 py-12"> <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"> <div class="w-full max-w-sm text-center">
@if($appIconUrl) @if($appIconUrl)
@@ -1132,14 +1130,14 @@
$sections = $content['sections'] ?? []; $sections = $content['sections'] ?? [];
$title = $content['title'] ?? $qrCode->label; $title = $content['title'] ?? $qrCode->label;
$acceptsPayment = (bool) ($content['accepts_payment'] ?? false); $acceptsPayment = (bool) ($content['accepts_payment'] ?? false);
$orderRoute = route('qr.public.order', $qrCode->short_code); $orderRoute = $qrCode->publicPath('order');
$csrfToken = csrf_token(); $csrfToken = csrf_token();
$paystackPublicKey = $acceptsPayment ? app(\App\Services\Billing\PaystackService::class)->publicKey() : ''; $paystackPublicKey = $acceptsPayment ? app(\App\Services\Billing\PaystackService::class)->publicKey() : '';
$brandColor = $content['brand_color'] ?? ($isShop ? '#7c3aed' : '#f97316'); $brandColor = $content['brand_color'] ?? ($isShop ? '#7c3aed' : '#f97316');
$hasLogo = !empty($content['logo_path']); $hasLogo = !empty($content['logo_path']);
$hasCover = !empty($content['cover_path']); $hasCover = !empty($content['cover_path']);
$logoUrl = $hasLogo ? route('qr.public.menu.logo', $qrCode->short_code) : null; $logoUrl = $hasLogo ? $qrCode->publicPath('menu-logo') : null;
$coverUrl = $hasCover ? route('qr.public.menu.cover', $qrCode->short_code) : null; $coverUrl = $hasCover ? $qrCode->publicPath('menu-cover') : null;
$shippingType = $content['shipping_type'] ?? 'none'; $shippingType = $content['shipping_type'] ?? 'none';
$shippingFee = (float) ($content['shipping_fee'] ?? 0); $shippingFee = (float) ($content['shipping_fee'] ?? 0);
$freeShippingAbove = (float) ($content['free_shipping_above'] ?? 0); $freeShippingAbove = (float) ($content['free_shipping_above'] ?? 0);
@@ -1157,6 +1155,7 @@
errorMsg: '', errorMsg: '',
showSheet: false, showSheet: false,
checkoutUrl: '', checkoutUrl: '',
accessCode: '',
shippingType: @js($shippingType), shippingType: @js($shippingType),
shippingFee: @js($shippingFee), shippingFee: @js($shippingFee),
freeAbove: @js($freeShippingAbove), freeAbove: @js($freeShippingAbove),
@@ -1205,13 +1204,10 @@
}); });
const data = await res.json(); const data = await res.json();
if (data.error) { this.errorMsg = data.error; this.loading = false; return; } if (data.error) { this.errorMsg = data.error; this.loading = false; return; }
if (window.innerWidth < 768) { this.checkoutUrl = data.checkout_url || data.authorization_url || '';
this.checkoutUrl = data.checkout_url; this.accessCode = data.access_code || '';
this.showSheet = true; this.returnUrl = data.callback_url || this.returnUrl || '';
this.loading = false; this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened)
} else {
window.location.href = data.checkout_url;
}
} catch(e) { } catch(e) {
this.errorMsg = 'Network error. Please try again.'; this.errorMsg = 'Network error. Please try again.';
this.loading = false; this.loading = false;
@@ -1291,7 +1287,7 @@
<div class="flex gap-3 overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-100"> <div class="flex gap-3 overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-100">
{{-- Item image --}} {{-- Item image --}}
@if($hasImage) @if($hasImage)
<img src="{{ route('qr.public.item-image', [$qrCode->short_code, $si, $ii]) }}" <img src="{{ $qrCode->publicPath('item-image/'.$si.'/'.$ii) }}"
alt="{{ $item['name'] }}" alt="{{ $item['name'] }}"
class="h-[100px] w-[100px] flex-shrink-0 object-cover" loading="lazy"> class="h-[100px] w-[100px] flex-shrink-0 object-cover" loading="lazy">
@endif @endif
@@ -48,7 +48,7 @@
<p class="file-name">{{ $file->original_name }}</p> <p class="file-name">{{ $file->original_name }}</p>
<p class="file-size">{{ $file->humanSize() }}</p> <p class="file-size">{{ $file->humanSize() }}</p>
</div> </div>
<a href="{{ route('qr.public.transfer.download', [$qrCode->short_code, $file]) }}" class="download">Download</a> <a href="{{ \App\Support\DirectTransferFileUrl::for($transfer, $file) }}" class="download">Download</a>
</div> </div>
@endforeach @endforeach
</div> </div>
@@ -17,7 +17,7 @@
<p class="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{{ session('error') }}</p> <p class="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{{ session('error') }}</p>
@endif @endif
<form method="post" action="{{ route('qr.public.transfer.unlock', $qrCode->short_code) }}" class="mt-6 space-y-4"> <form method="post" action="{{ $qrCode->publicPath('transfer/unlock') }}" class="mt-6 space-y-4">
@csrf @csrf
<div> <div>
<label for="password" class="block text-sm font-medium text-slate-700">Password</label> <label for="password" class="block text-sm font-medium text-slate-700">Password</label>
+1 -1
View File
@@ -3,7 +3,7 @@
$evColor = $c['brand_color'] ?? '#4f46e5'; $evColor = $c['brand_color'] ?? '#4f46e5';
$evName = $c['name'] ?? $qrCode->label; $evName = $c['name'] ?? $qrCode->label;
$hasLogo = !empty($c['logo_path']); $hasLogo = !empty($c['logo_path']);
$logoUrl = $hasLogo ? route('qr.public.event.logo', $qrCode->short_code) : null; $logoUrl = $hasLogo ? $qrCode->publicPath('event-logo') : null;
$size = $c['badge_size'] ?? '4x3'; $size = $c['badge_size'] ?? '4x3';
// Physical badge dimensions (inches) // Physical badge dimensions (inches)
[$bw, $bh] = match ($size) { [$bw, $bh] = match ($size) {
+2 -2
View File
@@ -106,7 +106,7 @@
status: '', status: '',
timer: null, timer: null,
checkUrl: @js(route('events.check-slug')), checkUrl: @js(route('events.check-slug')),
baseUrl: @js(\App\Models\QrCode::publicBaseUrl() . '/q'), baseUrl: @js(\App\Support\LadillLink::baseUrl()),
check() { check() {
clearTimeout(this.timer); clearTimeout(this.timer);
const s = this.slug.trim(); const s = this.slug.trim();
@@ -126,7 +126,7 @@
:class="status === 'available' ? 'border-green-400' : status === 'taken' || status === 'invalid' ? 'border-red-400' : 'border-slate-200'"> :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" <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'"> :class="status === 'available' ? 'border-green-400' : status === 'taken' || status === 'invalid' ? 'border-red-400' : 'border-slate-200'">
{{ \App\Models\QrCode::publicBaseUrl() }}/q/ {{ \App\Support\LadillLink::baseUrl() }}/
</span> </span>
<input type="text" name="custom_short_code" <input type="text" name="custom_short_code"
x-model="slug" x-model="slug"
+4 -4
View File
@@ -38,16 +38,16 @@
</div> </div>
</div> </div>
<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="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 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 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">GHS {{ number_format($wallet->spendableBalance(), 2) }}</p> <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">Account balance</p> <p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Account balance</p>
</div> </div>
<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"> <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">{{ $qrCodes->count() }}</p> <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">Events</p> <p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Events</p>
</div> </div>
<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"> <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($totalRegistrations) }}</p> <p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">{{ number_format($totalRegistrations) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Registrations</p> <p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Registrations</p>
</div> </div>
+16 -4
View File
@@ -2,9 +2,16 @@
<x-slot name="title">Overview</x-slot> <x-slot name="title">Overview</x-slot>
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
<div class="space-y-6"> <div class="space-y-6">
<div> <div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-semibold text-slate-900">QR Plus</h1> <div class="min-w-0 flex-1">
<p class="mt-1 text-sm text-slate-500">Dynamic utility QR codes links, WiFi, business pages & more.</p> <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('events.create'),
'label' => 'Create code',
'variant' => 'indigo',
])
</div> </div>
<div class="grid gap-4 sm:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-3">
<div class="rounded-2xl border border-slate-200 bg-white p-5"> <div class="rounded-2xl border border-slate-200 bg-white p-5">
@@ -23,7 +30,12 @@
<div class="rounded-2xl border border-slate-200 bg-white"> <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"> <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> <h2 class="font-semibold text-slate-900">Recent codes</h2>
<a href="{{ route('events.create') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Create code</a> @include('partials.mobile-icon-link', [
'href' => route('events.create'),
'label' => 'Create code',
'icon' => 'plus',
'desktopClass' => 'text-sm font-medium text-indigo-600 hover:text-indigo-800',
])
</div> </div>
@if($recentCodes->isEmpty()) @if($recentCodes->isEmpty())
<p class="px-6 py-8 text-sm text-slate-500">No QR codes yet. <a href="{{ route('events.create') }}" 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('events.create') }}" class="font-medium text-indigo-600">Create your first code</a>.</p>
+36 -13
View File
@@ -29,7 +29,7 @@
])->values(); ])->values();
@endphp @endphp
<div class="space-y-4" <div class="space-y-6"
x-data="filesManager(@js([ x-data="filesManager(@js([
'fileIds' => $fileRows->pluck('id')->all(), 'fileIds' => $fileRows->pluck('id')->all(),
'files' => $fileRows->all(), 'files' => $fileRows->all(),
@@ -54,15 +54,20 @@
])->values()->all(), ])->values()->all(),
]))"> ]))">
{{-- Header --}} <div class="relative overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="absolute inset-0 bg-gradient-to-br from-sky-50/80 via-white to-indigo-50/60"></div>
<div> <div class="relative px-6 py-8 sm:px-10">
<h1 class="text-xl font-semibold text-slate-900">Files</h1> <div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<p class="mt-1 text-sm text-slate-500">{{ $fmtBytes($storageBytes) }} stored (~GHS {{ number_format($storageGb * $pricePerGb, 2) }}/month)</p> <div class="max-w-xl">
<div class="inline-flex items-center gap-1.5 rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700">
Cloud storage · QR sharing
</div> </div>
<h1 class="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Your files</h1>
{{-- Create or upload --}} <p class="mt-2 text-sm leading-6 text-slate-600">
<div class="relative" @click.outside="createMenuOpen = false"> Upload, organise, and share files from your Ladill Transfer storage. Billed at GHS {{ number_format($pricePerGb, 2) }}/GB per month.
</p>
<div class="mt-6">
<div class="relative inline-block" @click.outside="createMenuOpen = false">
<button type="button" <button type="button"
@click="createMenuOpen = !createMenuOpen" @click="createMenuOpen = !createMenuOpen"
class="btn-primary"> class="btn-primary">
@@ -73,7 +78,7 @@
<div x-show="createMenuOpen" <div x-show="createMenuOpen"
x-cloak x-cloak
x-transition x-transition
class="absolute right-0 z-30 mt-2 w-56 overflow-hidden rounded-xl border border-slate-200 bg-white py-1 shadow-lg"> class="absolute left-0 z-30 mt-2 w-56 overflow-hidden rounded-xl border border-slate-200 bg-white py-1 shadow-lg sm:left-auto sm:right-0">
<button type="button" @click="openFolderModal()" class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-slate-700 hover:bg-slate-50"> <button type="button" @click="openFolderModal()" class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-slate-700 hover:bg-slate-50">
<img src="{{ $iconBase }}/folder.svg" alt="" class="h-5 w-5"> <img src="{{ $iconBase }}/folder.svg" alt="" class="h-5 w-5">
<span>Folder</span> <span>Folder</span>
@@ -94,15 +99,33 @@
<input type="file" x-ref="folderUploadInput" multiple webkitdirectory class="hidden" @change="handleUploadPick($event)"> <input type="file" x-ref="folderUploadInput" multiple webkitdirectory class="hidden" @change="handleUploadPick($event)">
</div> </div>
</div> </div>
</div>
{{-- Breadcrumbs --}} <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">GHS {{ number_format($balanceMinor / 100, 2) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Account 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">
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">{{ $fmtBytes($storageBytes) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Storage used</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($fileCount) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Files</p>
</div>
</div>
</div>
</div>
</div>
@if($folderTransfer)
<nav class="flex flex-wrap items-center gap-1 text-sm text-slate-500"> <nav class="flex flex-wrap items-center gap-1 text-sm text-slate-500">
<a href="{{ route('transfer.files.index') }}" class="font-medium text-indigo-600 hover:text-indigo-800">My files</a> <a href="{{ route('transfer.files.index') }}" class="font-medium text-indigo-600 hover:text-indigo-800">My files</a>
@if($folderTransfer)
<span>/</span> <span>/</span>
<span class="font-medium text-slate-900">{{ $folderTransfer->title }}</span> <span class="font-medium text-slate-900">{{ $folderTransfer->title }}</span>
@endif
</nav> </nav>
@endif
{{-- Action toolbar + file list --}} {{-- Action toolbar + file list --}}
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white"> <div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
@@ -106,7 +106,13 @@
</div> </div>
<div class="rounded-xl border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-sm text-slate-600"> <div class="rounded-xl border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-sm text-slate-600">
<div class="flex items-start justify-between gap-3">
<p class="font-medium text-slate-900">Monthly storage billing</p> <p class="font-medium text-slate-900">Monthly storage billing</p>
<button type="button" @click="$dispatch('open-modal', 'transfer')"
class="shrink-0 text-xs font-semibold text-indigo-600 hover:underline">
Wallet: GHS {{ number_format($balanceCedis, 2) }} · Add funds
</button>
</div>
<p class="mt-1">GHS {{ number_format($pricePerGb, 2) }} per GB per month, debited from your wallet. Files stay available while payments succeed. If a monthly renewal fails, files are kept for {{ $gracePeriodDays }} days before deletion.</p> <p class="mt-1">GHS {{ number_format($pricePerGb, 2) }} per GB per month, debited from your wallet. Files stay available while payments succeed. If a monthly renewal fails, files are kept for {{ $gracePeriodDays }} days before deletion.</p>
</div> </div>
@@ -126,5 +132,17 @@
</button> </button>
</div> </div>
</form> </form>
{{-- Two-step add-funds modal (opens on insufficient balance instead of a wallet redirect) --}}
<x-user.service-topup-modal
id="transfer"
title="Add funds"
:topup-action="route('user.wallet.topup')"
action-noun="transfer"
:service-balance="(float) $balanceCedis"
service-balance-label="Wallet balance"
description="Ladill Transfer bills monthly for storage — GHS {{ number_format($pricePerGb, 2) }} per GB per month, from your one Ladill wallet. Top up to create or keep your transfers."
:return-url="route('transfer.transfers.create')"
:open-on-load="session('open_topup_modal') === 'transfer'" />
</div> </div>
</x-user-layout> </x-user-layout>
@@ -1,16 +1,45 @@
<x-user-layout> <x-user-layout>
<x-slot name="title">Transfers</x-slot> <x-slot name="title">Transfers</x-slot>
<div class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-4"> <div class="space-y-6">
<div> <div class="relative overflow-hidden rounded-2xl border border-slate-200 bg-white">
<h1 class="text-xl font-semibold text-slate-900">Transfers</h1> <div class="absolute inset-0 bg-gradient-to-br from-sky-50/80 via-white to-indigo-50/60"></div>
<p class="mt-1 text-sm text-slate-500">All your file shares active, grace, and expired.</p> <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-sky-100 px-3 py-1 text-xs font-semibold text-sky-700">
File shares · QR downloads
</div> </div>
<h1 class="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Your transfers</h1>
<p class="mt-2 text-sm leading-6 text-slate-600">
All your file shares active, grace, and expired. Each transfer gets a QR code and download link for recipients.
</p>
<div class="mt-6">
<x-btn.create :href="route('transfer.transfers.create')">New transfer</x-btn.create> <x-btn.create :href="route('transfer.transfers.create')">New transfer</x-btn.create>
</div> </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">GHS {{ number_format($balanceMinor / 100, 2) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Account 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">
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">{{ number_format($transferCount) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Transfers</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>
</div>
</div>
</div>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white"> <div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div class="flex flex-wrap items-center gap-2 border-b border-slate-100 px-4 py-2.5"> <div class="flex flex-wrap items-center gap-2 border-b border-slate-100 px-4 py-3 sm:px-6">
<h2 class="text-sm font-semibold text-slate-900">All transfers</h2>
<form method="get" action="{{ route('transfer.transfers.index') }}" class="ml-auto flex w-full flex-wrap items-center gap-2 sm:w-auto"> <form method="get" action="{{ route('transfer.transfers.index') }}" class="ml-auto flex w-full flex-wrap items-center gap-2 sm:w-auto">
<div class="relative min-w-0 flex-1 sm:min-w-[200px]"> <div class="relative min-w-0 flex-1 sm:min-w-[200px]">
<svg class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg> <svg class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.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>
@@ -47,7 +76,7 @@
<a href="{{ route('transfer.transfers.create') }}" class="mt-3 inline-block text-sm font-semibold text-indigo-600 hover:text-indigo-800">Create your first transfer</a> <a href="{{ route('transfer.transfers.create') }}" class="mt-3 inline-block text-sm font-semibold text-indigo-600 hover:text-indigo-800">Create your first transfer</a>
</div> </div>
@else @else
<div class="flex items-center justify-between border-b border-slate-100 px-4 py-3 text-xs text-slate-500"> <div class="flex items-center justify-between border-b border-slate-100 px-4 py-3 text-xs text-slate-500 sm:px-6">
<span>{{ $transfers->total() }} transfer{{ $transfers->total() === 1 ? '' : 's' }}</span> <span>{{ $transfers->total() }} transfer{{ $transfers->total() === 1 ? '' : 's' }}</span>
<span>Showing {{ $transfers->firstItem() }}{{ $transfers->lastItem() }}</span> <span>Showing {{ $transfers->firstItem() }}{{ $transfers->lastItem() }}</span>
</div> </div>
@@ -24,11 +24,18 @@
· {{ $fmtBytes($transfer->storage_bytes) }} stored · GHS {{ number_format($transfer->monthlyCostGhs(), 2) }}/mo · {{ $fmtBytes($transfer->storage_bytes) }} stored · GHS {{ number_format($transfer->monthlyCostGhs(), 2) }}/mo
</p> </p>
</div> </div>
<form method="post" action="{{ route('transfer.transfers.destroy', $transfer) }}" onsubmit="return confirm('Delete this transfer and its files?')"> <x-confirm-dialog
@csrf :name="'delete-transfer-'.$transfer->id"
@method('DELETE') title="Delete transfer?"
<button type="submit" class="rounded-xl border border-red-200 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50">Delete</button> message="Delete this transfer and its files? This cannot be undone."
</form> :action="route('transfer.transfers.destroy', $transfer)"
method="DELETE"
confirm-label="Delete"
>
<x-slot:trigger>
<button type="button" class="rounded-xl border border-red-200 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50">Delete</button>
</x-slot:trigger>
</x-confirm-dialog>
</div> </div>
@if(session('success')) @if(session('success'))
+3
View File
@@ -3,11 +3,14 @@
use App\Http\Controllers\Api\ChunkedUploadController; use App\Http\Controllers\Api\ChunkedUploadController;
use App\Http\Controllers\Api\MeController; use App\Http\Controllers\Api\MeController;
use App\Http\Controllers\Api\QrCodeController; use App\Http\Controllers\Api\QrCodeController;
use App\Http\Controllers\Api\SmsVoiceTransferController;
use App\Http\Controllers\Api\TransferController; use App\Http\Controllers\Api\TransferController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware('auth.service:transfer')->prefix('v1')->group(function () { Route::middleware('auth.service:transfer')->prefix('v1')->group(function () {
Route::post('/transfers', [TransferController::class, 'store']); Route::post('/transfers', [TransferController::class, 'store']);
Route::post('/transfers/voice', [SmsVoiceTransferController::class, 'store']);
Route::get('/transfers/files', [\App\Http\Controllers\Api\TransferFilesController::class, 'index']);
Route::post('/transfers/upload/init', [ChunkedUploadController::class, 'init']); Route::post('/transfers/upload/init', [ChunkedUploadController::class, 'init']);
Route::post('/transfers/upload/chunk', [ChunkedUploadController::class, 'chunk']); Route::post('/transfers/upload/chunk', [ChunkedUploadController::class, 'chunk']);
Route::post('/transfers/upload/finalize', [ChunkedUploadController::class, 'finalize']); Route::post('/transfers/upload/finalize', [ChunkedUploadController::class, 'finalize']);
+11 -6
View File
@@ -1,6 +1,8 @@
<?php <?php
use App\Http\Controllers\Auth\SsoLoginController; 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\NotificationController;
use App\Http\Controllers\Public\QrScanController; use App\Http\Controllers\Public\QrScanController;
use App\Http\Controllers\Public\TransferPublicController; use App\Http\Controllers\Public\TransferPublicController;
@@ -25,16 +27,21 @@ Route::get('/sso/callback', [SsoLoginController::class, 'callback'])->name('sso.
Route::post('/logout', [SsoLoginController::class, 'logout'])->name('logout'); Route::post('/logout', [SsoLoginController::class, 'logout'])->name('logout');
Route::get('/sso/logout-bridge', [SsoLoginController::class, 'logoutBridge'])->name('sso.logout-bridge'); Route::get('/sso/logout-bridge', [SsoLoginController::class, 'logoutBridge'])->name('sso.logout-bridge');
Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannelLogout'])->name('sso.logout-frontchannel'); Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannelLogout'])->name('sso.logout-frontchannel');
Route::get('/sso/platform-signed-out', [SsoLoginController::class, 'platformSignedOut'])->name('sso.platform-signed-out');
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('transfer.dashboard') : view('transfer.signed-out'))->name('transfer.signed-out'); Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('transfer.dashboard') : view('transfer.signed-out'))->name('transfer.signed-out');
Route::middleware('redirect.legacy.qr')->group(function () {
Route::get('/q/{shortCode}', [QrScanController::class, 'resolve'])->name('qr.public.resolve'); 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}/view', [QrScanController::class, 'view'])->name('qr.public.view');
Route::get('/q/{shortCode}/file', [QrScanController::class, 'file'])->name('qr.public.file'); Route::get('/q/{shortCode}/file', [QrScanController::class, 'file'])->name('qr.public.file');
Route::get('/q/{shortCode}/transfer', [TransferPublicController::class, 'landing'])->name('qr.public.transfer'); Route::get('/q/{shortCode}/transfer', [TransferPublicController::class, 'landing'])->name('qr.public.transfer');
Route::post('/q/{shortCode}/transfer/unlock', [TransferPublicController::class, 'unlock'])->name('qr.public.transfer.unlock'); Route::post('/q/{shortCode}/transfer/unlock', [TransferPublicController::class, 'unlock'])->name('qr.public.transfer.unlock');
Route::get('/q/{shortCode}/transfer/files/{file}', [TransferPublicController::class, 'download'])->name('qr.public.transfer.download'); Route::get('/q/{shortCode}/transfer/files/{file}', [TransferPublicController::class, 'download'])->name('qr.public.transfer.download');
});
Route::middleware(['auth'])->group(function () { 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::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread'); Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread');
Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read'); Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read');
@@ -70,13 +77,11 @@ Route::middleware(['auth'])->group(function () {
Route::delete('/files/{file}', [FilesController::class, 'destroy'])->name('transfer.files.destroy'); Route::delete('/files/{file}', [FilesController::class, 'destroy'])->name('transfer.files.destroy');
Route::get('/analytics', [AnalyticsController::class, 'index'])->name('transfer.analytics.index'); Route::get('/analytics', [AnalyticsController::class, 'index'])->name('transfer.analytics.index');
Route::get('/wallet', [AccountController::class, 'wallet'])->name('account.wallet'); Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('account.wallet');
Route::get('/billing', [AccountController::class, 'billing'])->name('account.billing'); Route::get('/billing', fn () => redirect()->away(ladill_account_url('/billing')))->name('account.billing');
Route::get('/settings', [AccountController::class, 'settings'])->name('account.settings'); Route::get('/settings', [AccountController::class, 'settings'])->name('account.settings');
Route::put('/settings', [AccountController::class, 'updateSettings'])->name('account.settings.update'); Route::put('/settings', [AccountController::class, 'updateSettings'])->name('account.settings.update');
Route::get('/team', [TeamController::class, 'index'])->name('account.team'); Route::get('/team', fn () => redirect()->away(ladill_account_url('/account/team')))->name('account.team');
Route::post('/team', [TeamController::class, 'store'])->name('account.team.store');
Route::delete('/team/{member}', [TeamController::class, 'destroy'])->name('account.team.destroy');
Route::post('/switch-account', [TeamController::class, 'switchAccount'])->name('account.switch'); Route::post('/switch-account', [TeamController::class, 'switchAccount'])->name('account.switch');
}); });
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\Transfer\TransferBillingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\Concerns\FakesTransferBilling;
use Tests\TestCase;
class SmsVoiceTransferTest extends TestCase
{
use FakesTransferBilling;
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('qr');
$this->fakeTransferBillingApi();
config([
'transfer.service_api_keys' => ['sms' => 'test-sms-key'],
'identity.api_url' => 'https://ladill.com/api',
'identity.api_key' => 'test-identity-key',
]);
}
public function test_sms_service_can_upload_voice_recording(): void
{
$publicId = (string) Str::uuid();
Http::fake([
rtrim((string) config('identity.api_url'), '/').'/identity/profile*' => Http::response([
'data' => [
'public_id' => $publicId,
'name' => 'SMS User',
'email' => 'sms@acme.com',
'picture' => null,
],
]),
]);
$voice = UploadedFile::fake()->create('greeting.mp3', 120, 'audio/mpeg');
$this->withToken('test-sms-key')
->post('/api/v1/transfers/voice', [
'user' => $publicId,
'title' => 'SMS voice: greeting.mp3',
'voice' => $voice,
])
->assertCreated()
->assertJsonPath('data.direct_file_url', fn (string $url) => str_contains($url, '/transfer/files/'))
->assertJsonStructure([
'data' => [
'direct_file_url',
'public_url',
'transfer_id',
'file_id',
'paid_until',
'storage_gb',
'monthly_cost_ghs',
],
]);
$this->assertDatabaseHas('users', ['public_id' => $publicId]);
$this->assertDatabaseCount('transfers', 1);
}
public function test_voice_upload_rejects_non_sms_callers(): void
{
config(['transfer.service_api_keys' => ['webmail' => 'test-webmail-key']]);
$this->withToken('test-webmail-key')
->post('/api/v1/transfers/voice', [
'user' => (string) Str::uuid(),
'voice' => UploadedFile::fake()->create('greeting.mp3', 120, 'audio/mpeg'),
])
->assertForbidden();
}
public function test_voice_upload_returns_insufficient_wallet_error_code(): void
{
$publicId = (string) Str::uuid();
User::factory()->create(['public_id' => $publicId]);
$this->mock(TransferBillingService::class, function ($mock): void {
$mock->shouldReceive('chargeInitialPeriod')
->once()
->andThrow(new \RuntimeException(
'Insufficient wallet balance for the first month of storage. Top up your Ladill wallet and try again.'
));
});
$voice = UploadedFile::fake()->create('greeting.mp3', 120, 'audio/mpeg');
$this->withToken('test-sms-key')
->post('/api/v1/transfers/voice', [
'user' => $publicId,
'voice' => $voice,
])
->assertStatus(422)
->assertJsonPath('error_code', 'insufficient_wallet');
$this->assertDatabaseCount('transfers', 0);
}
}