74 Commits
Author SHA1 Message Date
isaacclad c1cbb35bf0 Sync app launcher from registry
Deploy Ladill Give / deploy (push) Successful in 46s
2026-07-24 19:31:43 +00:00
isaacclad c86e353c98 Let hero stats cards auto-expand to fit long values.
Deploy Ladill Give / deploy (push) Successful in 43s
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:13 +00:00
isaacclad 5d642900c5 Require Ladill Pay only for Give donations.
Deploy Ladill Give / deploy (push) Successful in 58s
Remove custom/owner payment gateway option and always collect donations through Ladill Pay with platform fees.
2026-07-24 14:15:43 +00:00
isaacclad 9890776265 Stop Paystack pre-modal flash on customer checkout.
Deploy Ladill Give / deploy (push) Successful in 1m23s
Use the shared Inline sheet, hold busy until payment UI opens, and avoid locking page scroll during the handoff.
2026-07-24 10:14:40 +00:00
isaacclad 9bda4a5095 Simplify Paystack checkout handoff
Deploy Ladill Give / deploy (push) Successful in 47s
2026-07-24 08:25:29 +00:00
isaacclad 78c6d3072c Simplify pay wait copy and scale Paystack to fill the loading area.
Deploy Ladill Give / deploy (push) Successful in 1m38s
Use “Please wait…” while loading, and scale the contained Paystack iframe so
checkout fills the sheet body without a floating second card.
2026-07-23 22:56:46 +00:00
isaacclad d21397f039 Render Paystack flush in the sheet loading area without a second stage.
Deploy Ladill Give / deploy (push) Has been cancelled
Drop host shadows and keep the iframe transparent so checkout fills the
Ladill sheet body instead of floating as a second card.
2026-07-23 22:44:14 +00:00
isaacclad f09d30c09b Force Paystack checkout into the sheet mount on first insert.
Deploy Ladill Give / deploy (push) Successful in 1m23s
Redirect first iframe append into the Ladill sheet body and keep absolute
fill styles applied so checkout is not a second full-screen modal.
2026-07-23 22:36:35 +00:00
isaacclad a8a9d79af0 Unify payment sheet to one POS design across all apps.
Deploy Ladill Give / deploy (push) Successful in 1m0s
Loading uses the standard white bottomsheet/modal; once Paystack Inline is
ready the host shell goes transparent so only Paystack's card shows on the
backdrop — no oversized second white frame on Merchant/Give/etc.
2026-07-21 23:16:06 +00:00
isaacclad 81f93ff04b Tighten payment sheet UI to match POS single-surface checkout.
Deploy Ladill Give / deploy (push) Successful in 54s
When Paystack Inline is ready: widen desktop panel for channel sidebar, collapse
to a minimal header, hide footer trust line, and re-pin the checkout iframe to
the sheet body so it does not read as a second modal on top of Ladill chrome.
2026-07-21 23:02:49 +00:00
isaacclad c1b6a418ab Fix proxied checkout CSRF for Merchant, Give, Invoice and Events.
Deploy Ladill Give / deploy (push) Successful in 44s
Public order/pay POSTs hit ladl.link first; satellite CSRF tokens cannot bind
to Link (or platform/satellite) sessions, which returned 419 and showed
"Could not start checkout." Except public checkout paths and return
access_code from owner-gateway Paystack init. Sync contained Paystack sheet.
2026-07-21 22:54:01 +00:00
isaaccladandCursor e5d95167dd Add optional owner gateway routing to Give.
Deploy Ladill Give / deploy (push) Successful in 1m0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 19:47:13 +00:00
isaacclad 4ab2fcb3ad Restore Paystack Inline in-page sheet/modal; stop separate-browser checkout.
Deploy Ladill Give / deploy (push) Successful in 1m11s
Pass access_code through pay responses and launch PaystackPop from the shared sheet instead of window.open.
2026-07-21 19:12:24 +00:00
isaacclad 0046e96a7b Keep Paystack sheet/modal visible; open Paystack from Continue.
Deploy Ladill Give / deploy (push) Successful in 33s
Stop auto-popups that steal focus, use one responsive panel, and skip ladl.link redirects for JSON pay requests.
2026-07-21 18:46:26 +00:00
isaacclad 72ecec38d4 Open Paystack from the Pay click gesture, not after fetch.
Deploy Ladill Give / deploy (push) Successful in 1m1s
Paystack blocks iframes, so checkout uses a popup beside the sheet/modal chrome. Opening about:blank on the user tap keeps that window from being blocked after the async initiate response.
2026-07-21 18:13:27 +00:00
isaacclad 24eeb5dfba Fix Paystack sheet: stop iframe embeds that Paystack blocks.
Deploy Ladill Give / deploy (push) Successful in 55s
Paystack checkout.paystack.com returns X-Frame-Options: SAMEORIGIN, so the shared sheet opened empty. Keep mobile sheet/desktop modal chrome, launch checkout in a named window with a Continue CTA fallback, and escape payment-return via window.opener or top.
2026-07-21 17:34:29 +00:00
isaacclad a73a15988b Present Paystack checkout in a responsive mobile sheet and desktop modal.
Deploy Ladill Give / deploy (push) Successful in 44s
Standardize customer-facing Ladill Pay checkout shells across products, open them on all viewports, and escape iframe callbacks back to the product flow.
2026-07-21 16:50:07 +00:00
isaaccladandCursor 959f3d3e14 Fix church giving pages and restore Give in launcher.
Deploy Ladill Give / deploy (push) Successful in 54s
Allow church QR types to resolve to the public giving landing page again after the Mini-only landing gate regression.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 13:37:52 +00:00
isaacclad 7faf3fb30b Fix deploy tmp paths: drop unset gitea.run_attempt.
Deploy Ladill Give / deploy (push) Successful in 1m27s
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:56:37 +00:00
isaacclad 712bfc0a9e Sync launcher after Mini/Merchant/Give pause.
Deploy Ladill Give / deploy (push) Successful in 48s
2026-07-15 01:27:20 +00:00
isaacclad 626485771a Remove desktop header search from app topbars.
Deploy Ladill Give / deploy (push) Successful in 1m32s
Drop the lg+ topbar search field; mobile search entry points are unchanged.
2026-07-09 06:57:33 +00:00
isaaccladandCursor 9923606021 Update Give favicon.
Deploy Ladill Give / deploy (push) Successful in 1m54s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 00:00:33 +00:00
isaaccladandCursor 05c084f18e Update Give logo and Give/POS launcher icons from monolith.
Deploy Ladill Give / deploy (push) Successful in 44s
Redeploy refreshed branding assets for the Give app.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 22:48:10 +00:00
isaacclad c898abf96a Show boot splash on app open/reload for logged-in users.
Deploy Ladill Give / deploy (push) Successful in 1m45s
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:34:23 +00:00
isaacclad 61de1cd7af Sync app launcher from registry
Deploy Ladill Give / deploy (push) Successful in 35s
2026-07-07 14:42:10 +00:00
isaacclad 75840dfebd Prefer system npm in deploy CI when portable node-gyp is broken.
Deploy Ladill Give / deploy (push) Successful in 29s
Falls back to downloading Node only when host npm is unavailable, fixing npm ci failures on ladill-deploy-runner.
2026-07-07 00:46:53 +00:00
isaacclad 51cfe0ed5d Sync app launcher from registry
Deploy Ladill Give / deploy (push) Failing after 3s
2026-07-07 00:21:07 +00:00
isaaccladandCursor 4145c5f7d5 Add Merchant-style hero to Giving Pages index.
Deploy Ladill Give / deploy (push) Successful in 1m5s
Surface wallet balance, page count, and active stats in a gradient hero with balance-gated create CTA.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 20:39:40 +00:00
isaaccladandCursor e5e380bee9 Propagate shared copy-button component and clipboard helper.
Deploy Ladill Give / deploy (push) Successful in 44s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 23:03:57 +00:00
isaacclad 8058362bf4 Use mobile icon buttons on Give and QR dashboards.
Deploy Ladill Give / deploy (push) Successful in 38s
Add header create action and sync mobile notification badge partial.
2026-07-03 22:41:00 +00:00
isaacclad fe106bb45f Sync app launcher from registry
Deploy Ladill Give / deploy (push) Successful in 1m19s
2026-07-01 17:04:10 +00:00
isaaccladandCursor 010d927c4b Throttle platform SSO session checks to cut per-request auth latency.
Deploy Ladill Give / deploy (push) Successful in 2m34s
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:50:34 +00:00
isaacclad 152e537f69 Add Queue to app launcher config and icon.
Deploy Ladill Give / deploy (push) Successful in 32s
Propagated from ladill monorepo launcher sync.
2026-06-29 22:12:15 +00:00
isaacclad 5c970ed443 Sync app launcher from registry
Deploy Ladill Give / deploy (push) Successful in 43s
2026-06-29 17:52:33 +00:00
isaaccladandCursor c5bcef133d Cap launcher dropdown at 15 visible apps (5 rows).
Deploy Ladill Give / deploy (push) Successful in 37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 17:44:14 +00:00
isaaccladandCursor ea381129d7 Hide launcher down arrow when scrolled to the end.
Deploy Ladill Give / deploy (push) Successful in 1m3s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 14:47:57 +00:00
isaaccladandCursor facc60b245 Revert launcher scroll rail and fade; keep down arrow hint.
Deploy Ladill Give / deploy (push) Successful in 1m11s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 14:00:57 +00:00
isaaccladandCursor 998fe93bce Make launcher scroll affordance visible on open.
Deploy Ladill Give / deploy (push) Successful in 37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 13:52:05 +00:00
isaacclad 9a908f1f2e Sync app launcher from registry
Deploy Ladill Give / deploy (push) Successful in 1m23s
2026-06-29 13:27:32 +00:00
isaacclad bd2c5cb3c4 Always show launcher scroll indicator and cap at 18 apps.
Deploy Ladill Give / deploy (push) Successful in 1m6s
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:03:37 +00:00
isaaccladandCursor 1a8bc435f7 Use + icon buttons on mobile dashboards.
Deploy Ladill Give / deploy (push) Successful in 21s
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:22:40 +00:00
isaaccladandCursor f23b49e695 Place wallet widget after Billing link in mobile profile sheet.
Deploy Ladill Give / deploy (push) Successful in 1m17s
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:06:50 +00:00
isaaccladandCursor a8571606ad Sync mobile profile wallet card and 24h session idle timeout.
Deploy Ladill Give / deploy (push) Successful in 1m0s
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:19 +00:00
isaaccladandCursor b7efdbf282 Hide header avatar on mobile and sync shared mobile nav partials.
Deploy Ladill Give / deploy (push) Successful in 48s
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:32 +00:00
isaacclad 6af2ddc664 Sync app launcher from registry — add Frontdesk.
Deploy Ladill Give / deploy (push) Successful in 36s
2026-06-28 01:45:53 +00:00
isaaccladandCursor c5d7a89fd8 Sync app launcher from registry
Deploy Ladill Give / deploy (push) Successful in 25s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 19:03:57 +00:00
isaaccladandCursor 0fc4c7e728 Sync app launcher from registry
Deploy Ladill Give / deploy (push) Successful in 56s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 18:28:03 +00:00
isaaccladandCursor f7c4028e24 Redirect legacy /q/* URLs to canonical ladl.link addresses.
Deploy Ladill Give / deploy (push) Successful in 29s
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 0d478ea51d Wire LadillLink through QrCode model and remaining QR surfaces.
Deploy Ladill Give / deploy (push) Successful in 30s
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 c4e1ac19c6 Use ladl.link URLs for all front-facing QR and short-link surfaces.
Deploy Ladill Give / deploy (push) Successful in 47s
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:27 +00:00
isaacclad c989c1460f Sync app launcher from registry
Deploy Ladill Give / deploy (push) Successful in 47s
2026-06-27 15:14:54 +00:00
isaaccladandCursor bd16fb307b Make guest giving checkouts email-optional like Ladill Mini.
Deploy Ladill Give / deploy (push) Successful in 48s
Donors without Ladill accounts no longer need to enter email at checkout; phone remains required for contact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 14:18:45 +00:00
fff6c9f23a Add Accounting to the app launcher
Deploy Ladill Give / deploy (push) Successful in 50s
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:48:27 +00:00
isaaccladandClaude Opus 4.8 2dac18b1f1 Add branded boot splash (Ladill Mail style) on app pages
Deploy Ladill Give / deploy (push) Successful in 30s
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:11:25 +00:00
isaaccladandClaude Opus 4.8 7099a71b18 Update POS launcher icon (app launcher + marketing)
Deploy Ladill Give / deploy (push) Successful in 23s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:45:01 +00:00
isaaccladandCursor 2a448b670b Sync app launcher config and add POS icon.
Deploy Ladill Give / deploy (push) Successful in 48s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 22:53:12 +00:00
isaaccladandCursor 559ba75d47 Standardize desktop topbar widgets to match CRM layout.
Deploy Ladill Give / deploy (push) Successful in 34s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:15:22 +00:00
isaaccladandClaude Opus 4.8 d6b4a9c853 Add wallet balance widget to the profile menu (after Billing)
Deploy Ladill Give / deploy (push) Successful in 58s
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:10 +00:00
isaaccladandClaude Opus 4.8 ec48ab6b33 Add CRM to the Ladill app launcher
Deploy Ladill Give / deploy (push) Successful in 26s
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 33894810c1 Add Invoice to the app launcher
Deploy Ladill Give / deploy (push) Successful in 27s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:19:11 +00:00
isaaccladandCursor 2177432db5 Bind app sessions to the platform auth session.
Deploy Ladill Give / deploy (push) Successful in 28s
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 302f57cc15 Add Ladill SMS to shared app launcher.
Deploy Ladill Give / deploy (push) Successful in 32s
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 6611737f50 Lower Give platform fee from 5% to 3.5%.
Deploy Ladill Give / deploy (push) Successful in 25s
Update checkout constant, payout copy, and Afia prompts to match the new Ladill Pay rate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 19:51:35 +00:00
isaaccladandCursor 4d29b94e0d Hide the current page link in the unified profile dropdown.
Deploy Ladill Give / deploy (push) Successful in 1m6s
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:34 +00:00
isaaccladandCursor 578d2ddc52 Fix corrupted profileMenuItems in layouts after menu propagation.
Deploy Ladill Give / deploy (push) Successful in 50s
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:34 +00:00
isaaccladandCursor bf8a653829 Unify logged-in profile dropdown with platform account menu.
Deploy Ladill Give / deploy (push) Successful in 34s
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:33:49 +00:00
isaaccladandCursor 20a1d374ad Remove centralized account nav from app sidebar; redirect legacy URLs.
Deploy Ladill Give / deploy (push) Successful in 41s
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:20 +00:00
isaaccladandCursor 33bd52cee5 Restore silent SSO on app entry for users switching between apps.
Deploy Ladill Give / deploy (push) Successful in 28s
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:06 +00:00
isaaccladandCursor 6ee34a6037 Fix guest entry: correct marketing URL and stop auto silent SSO.
Deploy Ladill Give / deploy (push) Successful in 28s
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:08:52 +00:00
isaaccladandCursor 79f7038274 Send SSO guests to ladill.com/products/give instead of subdomain landings.
Deploy Ladill Give / deploy (push) Successful in 28s
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:06 +00:00
isaaccladandCursor 79b1c043c0 Lower donation platform fee from 9% to 5%.
Deploy Ladill Give / deploy (push) Successful in 32s
Update settlement logic and in-app copy to match the new Ladill Pay donations tier.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 19:32:23 +00:00
isaaccladandClaude Opus 4.8 5e7b56f400 Afia: render markdown bold in chat instead of showing raw asterisks
Deploy Ladill Give / deploy (push) Successful in 42s
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:40:54 +00:00
isaaccladandClaude Opus 4.8 c1b168b93e Afia: fix context() calling undefined QrTypeCatalog::eventTypes()
Deploy Ladill Give / deploy (push) Successful in 40s
Use givingTypes() (this app's catalog) so the chat context loads instead of
500ing — the eventTypes() call was a QR Plus leftover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:45:17 +00:00
isaaccladandClaude Opus 4.8 7df7d79d25 Afia: rebrand assistant from QR Plus to Ladill Give
Deploy Ladill Give / deploy (push) Successful in 38s
The Give app shipped the QR Plus Afia prompt (self-identified as 'Ladill QR
Plus' and described QR-code features). Rewrite it for Ladill Give — giving
pages, donations, payouts, 9% fee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:34:08 +00:00
94 changed files with 2930 additions and 1111 deletions
+10 -2
View File
@@ -17,6 +17,11 @@ DB_DATABASE=ladill_give
DB_USERNAME=ladill_give
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).
PLATFORM_DB_HOST=127.0.0.1
PLATFORM_DB_PORT=3306
@@ -24,8 +29,9 @@ PLATFORM_DB_DATABASE=ladilldb
PLATFORM_DB_USERNAME=ladill_give
PLATFORM_DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_DRIVER=redis
# Idle web session length in minutes (1440 = 24 hours of inactivity).
SESSION_LIFETIME=1440
SESSION_DOMAIN=.ladill.com
LADILL_SSO_CLIENT_ID=
@@ -46,4 +52,6 @@ AFIA_PROVIDER=openai
AFIA_MODEL=gpt-4o-mini
AFIA_API_KEY=
LINK_PUBLIC_DOMAIN=ladl.link
VITE_APP_NAME="${APP_NAME}"
+17 -15
View File
@@ -19,8 +19,9 @@ jobs:
env:
NODE_ROOT: /tmp/ladill-node-22-r1
NODE_VERSION: "22.14.0"
RELEASE_ARCHIVE: /tmp/ladill-give-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-give-${{ gitea.run_id }}-${{ gitea.run_attempt }}
# act_runner v0.2.x does not expose gitea.run_attempt — use run_id only.
RELEASE_ARCHIVE: /tmp/ladill-give-release-${{ gitea.run_id }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-give-${{ gitea.run_id }}
LADILL_APP_ROOT: /var/www/ladill-give
steps:
- name: Checkout
@@ -38,11 +39,17 @@ jobs:
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
"$REPO_URL" "$WORKSPACE"
- name: Setup Node.js
- name: Build frontend assets
shell: bash {0}
run: |
set -Eeuo pipefail
if [ ! -x "$NODE_ROOT/bin/node" ]; then
cd "$WORKSPACE"
if command -v npm >/dev/null 2>&1 && npm -v >/dev/null 2>&1; then
NPM_BIN="$(command -v npm)"
elif [ -x "$NODE_ROOT/bin/npm" ]; then
export PATH="$NODE_ROOT/bin:$PATH"
NPM_BIN="$NODE_ROOT/bin/npm"
else
rm -rf "$NODE_ROOT"
mkdir -p "$NODE_ROOT"
case "$(uname -m)" in
@@ -52,17 +59,12 @@ jobs:
esac
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar -xJ --strip-components=1 -C "$NODE_ROOT"
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"
npm ci --no-audit --no-fund
npm run build
NPM_BIN="$NODE_ROOT/bin/npm"
fi
"$NPM_BIN" ci --no-audit --no-fund
"$NPM_BIN" run build
- name: Build release archive
shell: bash {0}
@@ -78,7 +80,7 @@ jobs:
- name: Deploy release
shell: bash {0}
env:
LADILL_RELEASE_ARCHIVE: /tmp/ladill-give-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
LADILL_RELEASE_ARCHIVE: /tmp/ladill-give-release-${{ gitea.run_id }}.tgz
run: |
set -Eeuo pipefail
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
+3 -3
View File
@@ -2,7 +2,7 @@
Standalone app for **online giving pages** at `give.ladill.com` — branded donation
pages with collection categories (Offering, Tithe, Building Fund…). Donations
settle into the one platform UserWallet (9% fee), then withdraw via
settle into the one platform UserWallet (3.5% fee), then withdraw via
`account.ladill.com`.
Public scans stay at `ladill.com/q/<code>` (blueprint decision — printed codes
@@ -24,7 +24,7 @@ main app DB user.
|---|---|
| `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | `passport:client` on platform |
| `BILLING_API_KEY_GIVE` | platform `.env` + this app |
| `PAY_API_KEY_GIVE` | platform `.env` + this app — Ladill Pay checkout (9% donations tier) |
| `PAY_API_KEY_GIVE` | platform `.env` + this app — Ladill Pay checkout (3.5% donations tier) |
| `IDENTITY_API_KEY_GIVE` | platform `.env` + this app |
| `PLATFORM_DB_*` | read `ladilldb.platform_settings` (Paystack keys from admin → Billing) |
@@ -84,7 +84,7 @@ php artisan config:cache route:cache view:cache
- SSO login at `give.ladill.com`
- Create a giving page, download QR PNG
- Scan `/q/<code>`, give test amount, confirm wallet credit (net of 9%)
- Scan `/q/<code>`, give test amount, confirm wallet credit (net of 3.5%)
- Withdraw from `account.ladill.com/wallet`
## Product IA (blueprint)
@@ -88,10 +88,7 @@ class SsoLoginController extends Controller
if ($request->filled('error')) {
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
&& ! $request->boolean('interactive')) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
return redirect()->away((string) config('ladill.marketing_url'));
}
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
@@ -141,6 +138,18 @@ class SsoLoginController extends Controller
return redirect()->away($this->defaultSignedOutUrl());
}
/** Platform session ended — clear this app and offer silent sign-in again. */
public function platformSignedOut(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => (string) $request->query('redirect', ''),
]);
}
public function logoutBridge(Request $request): View
{
$return = $this->safeReturnUrl((string) $request->query('return', ''));
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Give;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrWallet;
use App\Services\Qr\QrCodeManagerService;
use App\Services\Qr\QrImageGeneratorService;
use App\Services\Qr\QrPdfExporter;
@@ -36,9 +37,15 @@ class GivingPageController extends Controller
return [$qr->id => $this->imageGenerator->previewDataUri($qr)];
});
$wallet = $this->manager->walletFor($account);
return view('give.giving-pages.index', [
'qrCodes' => $qrCodes,
'previewDataUris' => $previewDataUris,
'wallet' => $wallet,
'activeCount' => $qrCodes->where('is_active', true)->count(),
'pricePerQr' => QrWallet::pricePerQr(),
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]);
}
@@ -1,31 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProductLandingController extends Controller
{
public function show(Request $request): View|RedirectResponse
{
$landing = config('product_landing', []);
$variant = (string) ($landing['landing_variant'] ?? 'default');
$dashboardRoute = (string) ($landing['dashboard_route'] ?? 'login');
if ($variant === 'webmail') {
if ($request->session()->has('mb.email')) {
return redirect()->route($dashboardRoute);
}
return view('product.landing');
}
if (auth()->check()) {
return redirect()->route($dashboardRoute);
}
return view('product.landing');
}
}
@@ -3,10 +3,11 @@
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\GiveDonation;
use App\Models\QrCode;
use App\Services\Give\GiveDonationService;
use App\Support\LadillLink;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;
@@ -29,7 +30,7 @@ class DonationController extends Controller
$validated = $request->validate([
'customer_name' => ['required', 'string', 'max:120'],
'customer_email' => ['required', 'email', 'max:200'],
'customer_email' => ['nullable', 'email', 'max:200'],
'customer_phone' => ['required', 'string', 'max:30'],
'items' => ['required', 'array', 'min:1'],
'items.*.name' => ['required', 'string', 'max:200'],
@@ -45,25 +46,47 @@ class DonationController extends Controller
return response()->json([
'checkout_url' => $result['checkout_url'],
'access_code' => $result['access_code'] ?? null,
'public_key' => $result['public_key'] ?? null,
'callback_url' => $result['callback_url'] ?? null,
'callback_url' => $result['callback_url'],
]);
}
public function callback(Request $request, string $shortCode): RedirectResponse|View
public function callback(Request $request, string $shortCode): View
{
$reference = trim((string) $request->query('reference', ''));
if ($reference === '') {
return redirect('/q/'.$shortCode)->with('error', 'Missing payment reference.');
return view('public.payment-return', [
'redirect' => LadillLink::url($shortCode),
]);
}
try {
$donation = $this->donations->complete($reference);
} catch (RuntimeException $e) {
return redirect('/q/'.$shortCode)->with('order_error', $e->getMessage());
} catch (\Throwable) {
return redirect('/q/'.$shortCode)->with('order_error', 'Payment could not be verified. Contact the organisation with reference: '.$reference);
return view('public.payment-return', [
'redirect' => LadillLink::url($shortCode),
]);
}
return view('public.payment-return', [
'redirect' => route('qr.public.donation.confirmed', [
'shortCode' => $shortCode,
'reference' => $donation->payment_reference,
], absolute: true),
]);
}
public function confirmed(string $shortCode, string $reference): View
{
$donation = GiveDonation::query()
->with('qrCode')
->where('payment_reference', $reference)
->where('status', GiveDonation::STATUS_PAID)
->whereHas('qrCode', fn ($q) => $q->where('short_code', $shortCode))
->firstOrFail();
return view('public.qr.donation-confirmed', [
'donation' => $donation,
'qrCode' => $donation->qrCode,
@@ -36,7 +36,7 @@ class QrScanController extends Controller
}
if ($qrCode->isDocumentType()) {
return redirect()->route('qr.public.view', $shortCode);
return redirect()->away($qrCode->publicPath('view'));
}
if ($qrCode->isBookType()) {
@@ -62,7 +62,7 @@ class QrScanController extends Controller
return view('public.qr.document-viewer', [
'qrCode' => $qrCode,
'fileUrl' => route('qr.public.file', $shortCode),
'fileUrl' => $qrCode->publicPath('file'),
'allowDownload' => (bool) ($qrCode->content()['allow_download'] ?? true),
]);
}
+34 -1
View File
@@ -3,15 +3,23 @@
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\PaymentGatewaySetting;
use App\Models\QrSetting;
use App\Services\Billing\BillingClient;
use App\Services\Billing\PlanEntitlementService;
use App\Services\Payments\MerchantGatewayService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class AccountController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function __construct(
private BillingClient $billing,
private PlanEntitlementService $entitlements,
private MerchantGatewayService $gateway,
) {}
private function topupUrl(): string
{
@@ -65,6 +73,8 @@ class AccountController extends Controller
return view('give.settings', [
'account' => $account,
'settings' => $settings,
'gateway' => $this->gateway->settingFor($account),
'canUsePaymentGateway' => $this->entitlements->canUseCustomPaymentGateway($account),
]);
}
@@ -77,6 +87,10 @@ class AccountController extends Controller
'product_updates' => ['nullable', 'boolean'],
'notify_registrations' => ['nullable', 'boolean'],
'notify_payouts' => ['nullable', 'boolean'],
'gateway_provider' => ['nullable', Rule::in(['', PaymentGatewaySetting::PROVIDER_PAYSTACK])],
'gateway_public_key' => ['nullable', 'string', 'max:2000'],
'gateway_secret_key' => ['nullable', 'string', 'max:2000'],
'gateway_is_active' => ['nullable', 'boolean'],
]);
QrSetting::updateOrCreate(
@@ -89,6 +103,25 @@ class AccountController extends Controller
],
);
if ($this->entitlements->canUseCustomPaymentGateway($account)) {
$provider = trim((string) ($data['gateway_provider'] ?? ''));
$setting = PaymentGatewaySetting::query()->where('owner_ref', $account->public_id)->first();
if ($provider !== '' || $setting) {
$setting ??= new PaymentGatewaySetting(['owner_ref' => $account->public_id]);
if ($provider !== '') {
$setting->provider = $provider;
}
if (filled($data['gateway_public_key'] ?? null)) {
$setting->public_key = $data['gateway_public_key'];
}
if (filled($data['gateway_secret_key'] ?? null)) {
$setting->secret_key = $data['gateway_secret_key'];
}
$setting->is_active = $provider !== '' && $request->boolean('gateway_is_active');
$setting->save();
}
}
return redirect()->route('account.settings')->with('success', 'Settings saved.');
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ class AfiaController extends Controller
return ['signed_in' => 'no'];
}
$types = QrTypeCatalog::eventTypes();
$types = QrTypeCatalog::givingTypes();
$codes = $account->qrCodes()->whereIn('type', $types);
$ctx = [
+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) {
Storage::disk('qr')->delete($tempLogoPath);
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Services\Billing\BillingClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/**
* Lightweight wallet balance for the avatar dropdown widget.
*/
class WalletBalanceController extends Controller
{
public function balance(Request $request, BillingClient $billing): JsonResponse
{
$publicId = (string) $request->user()->public_id;
$minor = Cache::remember("wallet_balance:{$publicId}", now()->addSeconds(30), function () use ($billing, $publicId) {
try {
return $billing->balanceMinor($publicId);
} catch (\Throwable) {
return null;
}
});
if ($minor === null) {
return response()->json(['available' => false]);
}
$currency = (string) config('billing.currency', 'GHS');
return response()->json([
'available' => true,
'balance_minor' => $minor,
'currency' => $currency,
'formatted' => $currency.' '.number_format($minor / 100, 2),
]);
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
/**
* App sessions are subordinate to the platform (auth.ladill.com) session.
* When the platform session ends, clear this app's local session too.
*
* Re-checks auth.ladill.com at most once per TTL not on every request.
* Client-side sso-keepalive still pings periodically between checks.
*/
class EnsurePlatformSession
{
private const VERIFY_TTL_SECONDS = 90;
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
return $next($request);
}
$authDomain = trim((string) config('app.auth_domain', ''));
if ($authDomain === '') {
return $next($request);
}
$verifiedAt = (int) $request->session()->get('platform_session_verified_at', 0);
if ($verifiedAt > 0 && (time() - $verifiedAt) < self::VERIFY_TTL_SECONDS) {
return $next($request);
}
$cookieHeader = (string) $request->headers->get('Cookie', '');
if ($cookieHeader === '') {
return $this->clearAppSession($request);
}
try {
$response = Http::withHeaders(['Cookie' => $cookieHeader])
->timeout(3)
->connectTimeout(2)
->get('https://'.$authDomain.'/sso/ping');
} catch (\Throwable) {
return $next($request);
}
if ($response->status() === 401) {
return $this->clearAppSession($request);
}
if ($response->successful()) {
$request->session()->put('platform_session_verified_at', time());
}
return $next($request);
}
private function clearAppSession(Request $request): Response
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => $request->fullUrl(),
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
/**
* Injects a branded boot splash (Ladill Mail style) into authenticated HTML
* pages. The splash shows once per browser session while the app loads, then
* fades out masking the client-side boot so the app feels instant.
*/
class InjectBootSplash
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $request->isMethod('GET') || ! Auth::check()) {
return $response;
}
if (! str_contains((string) $response->headers->get('Content-Type', ''), 'text/html')) {
return $response;
}
$content = $response->getContent();
if (! is_string($content)
|| stripos($content, '<body') === false
|| str_contains($content, 'id="ladill-boot"')) {
return $response;
}
$splash = View::make('partials.boot-splash')->render();
$content = preg_replace_callback('/<body\b[^>]*>/i', fn ($m) => $m[0].$splash, $content, 1);
if (is_string($content)) {
$response->setContent($content);
}
return $response;
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use App\Support\LadillLink;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RedirectLegacyQrToLadillLink
{
public function handle(Request $request, Closure $next): Response
{
if (! preg_match('#^q/[a-z0-9]#i', ltrim($request->path(), '/'))) {
return $next($request);
}
if (LadillLink::isInternalRequest($request)) {
return $next($request);
}
// Same-origin AJAX from product-hosted pages must not 307 to ladl.link
// (cross-origin fetch has no CORS → checkout sheet never opens).
if ($request->ajax() || $request->expectsJson() || $request->wantsJson()) {
return $next($request);
}
return LadillLink::legacyRedirect($request);
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ class GiveDonation extends Model
public const STATUS_FAILED = 'failed';
/** Platform fee on donations (Ladill Give tier). */
public const PLATFORM_FEE_RATE = 0.09;
public const PLATFORM_FEE_RATE = 0.035;
protected $fillable = [
'pay_order_id',
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaymentGatewaySetting extends Model
{
public const PROVIDER_PAYSTACK = 'paystack';
protected $fillable = ['owner_ref', 'provider', 'public_key', 'secret_key', 'is_active', 'metadata'];
protected function casts(): array
{
return [
'public_key' => 'encrypted',
'secret_key' => 'encrypted',
'is_active' => 'boolean',
'metadata' => 'array',
];
}
public function isConfigured(): bool
{
return $this->is_active
&& $this->provider === self::PROVIDER_PAYSTACK
&& str_starts_with(trim((string) $this->public_key), 'pk_')
&& str_starts_with(trim((string) $this->secret_key), 'sk_');
}
}
+11 -13
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Support\LadillLink;
use App\Support\Qr\QrStyleDefaults;
use App\Support\Qr\QrTypeCatalog;
use App\Support\Qr\QrWifiPayload;
@@ -81,23 +82,19 @@ class QrCode extends Model
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
{
$appUrl = (string) config('app.url');
$scheme = parse_url($appUrl, PHP_URL_SCHEME) ?: 'https';
$host = (string) config('app.platform_domain')
?: (parse_url($appUrl, PHP_URL_HOST) ?: 'ladill.com');
return $scheme . '://' . $host;
return LadillLink::baseUrl();
}
/**
@@ -192,6 +189,7 @@ class QrCode extends Model
public function usesLandingPage(): bool
{
return in_array($this->type, [
self::TYPE_CHURCH,
self::TYPE_PAYMENT,
], true);
}
+14 -23
View File
@@ -88,36 +88,27 @@ class AfiaService
{
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
return match ((string) config('afia.product', 'qr')) {
'qr' => $this->qrSystemPrompt($ctx),
default => $this->qrSystemPrompt($ctx),
};
return $this->giveSystemPrompt($ctx);
}
private function qrSystemPrompt(string $ctx): string
private function giveSystemPrompt(string $ctx): string
{
return <<<PROMPT
You are Afia, the assistant inside Ladill QR Plus (qrplus.ladill.com).
Help users create and manage dynamic QR codes, understand scans and billing, and print codes that work reliably.
Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
You are Afia, the assistant inside Ladill Give (give.ladill.com).
Help users set up giving pages, collect donations, and get paid out. Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
What Ladill QR Plus does and where things live:
- Overview (Dashboard): recent codes, active code count, scans in the last 30 days, wallet balance.
- My Codes: list, create, and edit QR codes. Types: Link (URL), PDF, List of Links, Business profile, WiFi, App download.
- Creating a code: pick a type, set content, customize style (colors, logo, frame), then download PNG/SVG/PDF.
- Dynamic codes: destination can change after printing the printed QR always points to ladill.com/q/{code}.
- Business QR: name, tagline, contact, hours, logo, cover, social links shown as a mobile landing page.
- WiFi QR: guests scan to join; network name and password are encoded in the code.
- PDF QR: hosts a PDF with optional download button on the viewer.
- Billing: each new code debits the Ladill wallet (account portal Wallet). Top up at account.ladill.com/wallet.
- Team: invite teammates to manage codes together (sidebar Team).
- Developers: API tokens for programmatic code creation (sidebar Developers).
What Ladill Give does and where things live:
- Overview (Dashboard): recent donations, active giving pages, totals raised, and wallet balance.
- Giving pages: create and manage donation pages set a title, description, suggested amounts, and cover image, then publish and share the page link or QR.
- Donations: incoming donations from your pages view donors, amounts, and messages.
- Payouts: donated funds land in your Ladill wallet; request a withdrawal to your bank or mobile money from Payouts.
- Fees: Ladill takes a 3.5% platform fee on donations; the remainder is yours.
- Account & Wallet: check balance or top up at account.ladill.com/wallet.
Rules:
- Only answer questions about Ladill QR Plus code types, styling, downloads, scans, wallet billing, team, and API.
- If asked about domains, hosting, email, or payments/commerce QR (shop, events, donations), briefly say those live in other Ladill apps and suggest the app launcher.
- Never invent prices, short codes, or wallet balances use the user context below or tell them where to check.
- For print tips: recommend high contrast, adequate quiet zone, test-scan before mass printing.
- Only answer questions about Ladill Give giving pages, donations, payouts, fees, and sharing.
- If asked about domains, hosting, email, plain QR codes, storefronts, or events, briefly say those live in other Ladill apps and suggest the app launcher.
- Never invent donation amounts, payout amounts, or wallet balances use the user context below or tell them where to check.
Current user context:
{$ctx}
+9
View File
@@ -45,6 +45,15 @@ class BillingClient
return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]);
}
/** @return array<string,mixed> */
public function suiteEntitlement(string $publicId): array
{
return (array) ($this->get('/suite-entitlements', [
'user' => $publicId,
'app' => (string) config('billing.service', 'give'),
])['data'] ?? []);
}
/**
* Debit the wallet. Returns true on success, false on insufficient balance
* (HTTP 402). Idempotent by $reference.
@@ -0,0 +1,29 @@
<?php
namespace App\Services\Billing;
use App\Models\User;
use Throwable;
class PlanEntitlementService
{
public const CUSTOM_PAYMENT_GATEWAY = 'custom_payment_gateway';
public function __construct(private readonly BillingClient $billing) {}
public function has(User $owner, string $feature): bool
{
try {
return in_array($feature, (array) ($this->billing->suiteEntitlement((string) $owner->public_id)['features'] ?? []), true);
} catch (Throwable) {
return false;
}
}
public function canUseCustomPaymentGateway(User $owner): bool
{
// Custom / owner payment gateways are retired. All checkouts use Ladill Pay
// with platform fees (see config/pay.php fee_tiers on the platform).
return false;
}
}
+52 -3
View File
@@ -8,6 +8,8 @@ use App\Services\Billing\BillingClient;
use App\Services\Billing\PaystackService;
use App\Services\Billing\SmsService;
use App\Services\Pay\PayClient;
use App\Services\Payments\MerchantGatewayService;
use App\Support\LadillLink;
use Illuminate\Support\Str;
use RuntimeException;
@@ -18,6 +20,7 @@ class GiveDonationService
private PaystackService $paystack,
private BillingClient $billing,
private SmsService $sms,
private MerchantGatewayService $gateway,
) {}
/**
@@ -46,7 +49,7 @@ class GiveDonationService
$amountMinor = (int) round($amountGhs * 100);
$reference = 'GIVD-'.strtoupper(Str::random(16));
$orgName = $qrCode->content()['name'] ?? $qrCode->label ?? 'Giving page';
$callbackUrl = route('qr.public.order.callback', ['shortCode' => $qrCode->short_code]);
$callbackUrl = LadillLink::path($qrCode->short_code, 'order/callback');
$donation = GiveDonation::create([
'qr_code_id' => $qrCode->id,
@@ -56,12 +59,27 @@ class GiveDonationService
'amount_minor' => $amountMinor,
'currency' => $qrCode->content()['currency'] ?? 'GHS',
'payer_name' => trim((string) ($data['customer_name'] ?? '')),
'payer_email' => trim((string) ($data['customer_email'] ?? '')),
'payer_email' => ($email = trim((string) ($data['customer_email'] ?? ''))) !== '' ? $email : null,
'payer_phone' => trim((string) ($data['customer_phone'] ?? '')),
'status' => GiveDonation::STATUS_PENDING,
'payment_reference' => null,
]);
$usesOwnerGateway = $this->gateway->shouldUseOwnerGateway($qrCode->user);
$payOrder = null;
if ($usesOwnerGateway) {
try {
$payOrder = $this->gateway->initialize(
$qrCode->user, $amountMinor, (string) ($qrCode->content()['currency'] ?? 'GHS'),
(string) config('pay.mini_checkout_email', 'pay@ladill.com'),
$callbackUrl, 'GGP-'.strtoupper(Str::random(16)),
['give_donation_id' => $donation->id, 'give_reference' => $reference, 'qr_code_id' => $qrCode->id],
);
} catch (\Throwable) {
$usesOwnerGateway = false;
}
}
if (! $usesOwnerGateway) {
$payOrder = $this->pay->createCheckout([
'merchant' => $qrCode->user->public_id,
'fee_tier' => 'donations',
@@ -85,9 +103,10 @@ class GiveDonationService
'collection_type' => $collectionType,
],
]);
}
$donation->update([
'pay_order_id' => $payOrder['id'] ?? null,
'pay_order_id' => $usesOwnerGateway ? null : ($payOrder['id'] ?? null),
'payment_reference' => $payOrder['reference'],
'platform_fee_minor' => $payOrder['platform_fee_minor'] ?? null,
'merchant_amount_minor' => $payOrder['merchant_amount_minor'] ?? null,
@@ -101,6 +120,8 @@ class GiveDonationService
return [
'donation' => $donation->fresh(),
'checkout_url' => $checkoutUrl,
'access_code' => isset($payOrder['access_code']) ? (string) $payOrder['access_code'] : null,
'public_key' => isset($payOrder['public_key']) ? (string) $payOrder['public_key'] : null,
'callback_url' => $callbackUrl,
];
}
@@ -110,6 +131,9 @@ class GiveDonationService
if (str_starts_with($paymentReference, 'LP-')) {
return $this->completeLadillPay($paymentReference);
}
if (str_starts_with($paymentReference, 'GGP-')) {
return $this->completeOwnerGateway($paymentReference);
}
return $this->completeLegacy($paymentReference);
}
@@ -179,6 +203,31 @@ class GiveDonationService
return $donation;
}
private function completeOwnerGateway(string $reference): GiveDonation
{
$donation = GiveDonation::where('payment_reference', $reference)
->where('status', GiveDonation::STATUS_PENDING)
->with('merchant')
->firstOrFail();
$result = $this->gateway->verify($donation->merchant, $reference);
if (! $result['paid']) {
$donation->update(['status' => GiveDonation::STATUS_FAILED]);
throw new RuntimeException('Payment was not successful.');
}
$paidMinor = (int) ($result['amount_minor'] ?: $donation->amount_minor);
$donation->update([
'status' => GiveDonation::STATUS_PAID,
'amount_minor' => $paidMinor,
'platform_fee_minor' => 0,
'merchant_amount_minor' => $paidMinor,
'paid_at' => now(),
'metadata' => array_merge((array) $donation->metadata, ['merchant_gateway' => $result]),
]);
$this->notifyDonor($donation->fresh(['qrCode', 'merchant']));
return $donation;
}
private function notifyDonor(GiveDonation $donation): void
{
if (! $donation->payer_phone) {
@@ -0,0 +1,88 @@
<?php
namespace App\Services\Payments;
use App\Models\PaymentGatewaySetting;
use App\Models\User;
use App\Services\Billing\PlanEntitlementService;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class MerchantGatewayService
{
public function __construct(private readonly PlanEntitlementService $entitlements) {}
public function settingFor(User|string $owner): ?PaymentGatewaySetting
{
$ownerRef = $owner instanceof User ? (string) $owner->public_id : (string) $owner;
return PaymentGatewaySetting::query()->where('owner_ref', $ownerRef)->first();
}
public function shouldUseOwnerGateway(User $owner): bool
{
return $this->entitlements->canUseCustomPaymentGateway($owner)
&& (bool) $this->settingFor($owner)?->isConfigured();
}
/** @return array{checkout_url:string,reference:string,provider:string} */
public function initialize(User $owner, int $amountMinor, string $currency, string $email, string $callbackUrl, string $reference, array $metadata = []): array
{
if (! $this->shouldUseOwnerGateway($owner)) {
throw new RuntimeException('Owner gateway is not available.');
}
$setting = $this->settingFor($owner);
$response = Http::withToken((string) $setting->secret_key)->acceptJson()->timeout(20)
->post('https://api.paystack.co/transaction/initialize', [
'email' => $email !== '' ? $email : 'pay@ladill.com',
'amount' => $amountMinor,
'currency' => strtoupper($currency ?: 'GHS'),
'reference' => $reference,
'callback_url' => $callbackUrl,
'metadata' => $metadata,
]);
if (! $response->successful() || ! ($response->json('status') ?? false) || ! $response->json('data.authorization_url')) {
throw new RuntimeException($response->json('message') ?: 'Paystack could not start checkout.');
}
$accessCode = (string) ($response->json('data.access_code') ?? '');
$checkoutUrl = (string) $response->json('data.authorization_url');
if ($accessCode === '' && $checkoutUrl !== '') {
$path = ltrim((string) (parse_url($checkoutUrl, PHP_URL_PATH) ?: ''), '/');
$maybe = explode('/', $path)[0] ?? '';
if (preg_match('/^[A-Za-z0-9_-]{6,}$/', $maybe) === 1) {
$accessCode = $maybe;
}
}
return [
'checkout_url' => $checkoutUrl,
'access_code' => $accessCode !== '' ? $accessCode : null,
'reference' => (string) ($response->json('data.reference') ?: $reference),
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
];
}
/** @return array<string,mixed> */
public function verify(User|string $owner, string $reference): array
{
$setting = $this->settingFor($owner);
if (! $setting?->isConfigured()) {
throw new RuntimeException('The owner gateway used for this payment is no longer configured.');
}
$response = Http::withToken((string) $setting->secret_key)->acceptJson()->timeout(20)
->get('https://api.paystack.co/transaction/verify/'.rawurlencode($reference));
$data = (array) ($response->json('data') ?? []);
return [
'paid' => ($response->json('status') ?? false) && strtolower((string) ($data['status'] ?? '')) === 'success',
'amount_minor' => (int) ($data['amount'] ?? 0),
'reference' => (string) ($data['reference'] ?? $reference),
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
'raw' => $data,
];
}
}
+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);
}
}
+11
View File
@@ -13,12 +13,23 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// Giving-page donation POSTs are proxied ladl.link → platform → Give without
// a browser session cookie that matches Give CSRF tokens.
$middleware->validateCsrfTokens(except: [
'q/*/order',
]);
$middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [
'redirect' => $request->fullUrl(),
]));
$middleware->web(append: [
\App\Http\Middleware\InjectBootSplash::class,
\App\Http\Middleware\SetActingAccount::class,
]);
$middleware->alias([
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
'redirect.legacy.qr' => \App\Http\Middleware\RedirectLegacyQrToLadillLink::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
+2
View File
@@ -4,5 +4,7 @@ return [
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
'api_key' => env('BILLING_API_KEY_GIVE'),
'service' => 'give',
'wallet_balance_route' => 'wallet.balance',
'currency' => 'GHS',
'platform_settings_connection' => env('BILLING_PLATFORM_SETTINGS_CONNECTION', 'platform'),
];
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'product_slug' => 'give',
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/give'),
];
+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
| launcher in a new app, copy these three things verbatim into that repo:
| - config/ladill_launcher.php (this file)
| - resources/views/partials/launcher.blade.php
| - public/images/launcher-icons/* (the icon set)
| DO NOT EDIT BY HAND. This file is generated from the app registry in the
| monolith (config/ladill_apps.php). To change the launcher, edit that
| registry and run: php artisan ladill:launcher:sync --propagate
|
| When a service becomes FULLY extracted: add one row below AND drop its icon in
| public/images/launcher-icons/ in EVERY repo. That's the only edit needed.
| Do NOT list a service here until it is fully extracted to its own subdomain.
|
| URLs are absolute (built from the platform root) so this file is byte-identical
| in every app; the blade omits whichever row matches the app's APP_URL host.
| Icons are served from /images/launcher-icons/<icon>.
| URLs are absolute (built from the platform root) so this file is
| byte-identical in every repo; the blade omits whichever row matches the
| app's own host. Icons are served from /images/launcher-icons/<icon>.
*/
$root = config('app.platform_domain', 'ladill.com');
return [
'apps' => [
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'],
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
['name' => 'Woo Manager', 'url' => 'https://woo.'.$root.'/sso/connect?redirect='.urlencode('https://woo.'.$root.'/dashboard'), 'icon' => 'woomanager.svg'],
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'],
['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'],
['name' => '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' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'],
['name' => 'Meet', 'url' => 'https://meet.'.$root.'/sso/connect?redirect='.urlencode('https://meet.'.$root.'/dashboard'), 'icon' => 'meet.svg'],
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
['name' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'],
['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'],
['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.svg'],
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
],
];
+5
View File
@@ -0,0 +1,5 @@
<?php
return [
'public_domain' => env('LINK_PUBLIC_DOMAIN', 'ladl.link'),
];
-43
View File
@@ -1,43 +0,0 @@
<?php
return [
'slug' => 'give',
'name' => 'Give',
'logo' => 'images/logo/ladillgive-logo.svg',
'icon' => 'give.svg',
'tagline' => 'Collect donations and member giving',
'headline' => 'Online giving made simple',
'accent' => 'donations, tithes, and campaigns',
'description' => 'Give helps churches, NGOs, and communities collect donations online with branded giving pages and transparent payout tracking.',
'benefits' =>
[
0 =>
[
'title' => 'Branded giving pages',
'text' => 'Share a link or QR code so supporters can give in seconds.',
,]
1 =>
[
'title' => 'Recurring & one-time',
'text' => 'Support memberships, tithes, campaigns, and special offerings.',
,]
2 =>
[
'title' => 'Secure payouts',
'text' => 'Track every gift and reconcile payouts from your Ladill wallet.',
,]
,]
'use_cases' =>
[
0 => 'Churches & ministries',
1 => 'NGOs & charities',
2 => 'Community groups',
,]
'gradient_from' => '#007e7d',
'gradient_to' => '#f6398a',
'dashboard_route' => 'give.dashboard',
'platform_url' => 'https://localhost',
'marketing_url' => 'https://localhost/products/give',
'register_url' => 'https://auth.localhost/register',
'landing_variant' => 'default',
];
+1 -1
View File
@@ -32,7 +32,7 @@ return [
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'lifetime' => (int) env('SESSION_LIFETIME', 1440),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_gateway_settings', function (Blueprint $table) {
$table->id();
$table->string('owner_ref', 64)->unique();
$table->string('provider', 32);
$table->text('public_key')->nullable();
$table->text('secret_key')->nullable();
$table->boolean('is_active')->default(false);
$table->json('metadata')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('payment_gateway_settings');
}
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 264 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: #009e6b;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #db5443;
}
.cls-3 {
fill: #00a3d7;
}
</style>
</defs>
<path class="cls-3" d="M1.7,170.6c0,9,.1,18.2,0,27.2-.1,8.8,4.3,15,13.8,19.3,17.3,7.5,34.4,15.2,51.7,22.8,69,30.6,138.2,61.1,207.1,92,16.9,7.6,37.1-.3,36.9-17.8v-54c0-17.9-4.5-15.2-14.4-19.4-47-20.7-94-41.6-141-62.4-39-17.3-78.2-34.2-117-51.9C19.9,117.7,0,128.1,1.3,144.4c.6,8.8.1,17.6.1,26.3h.3Z"/>
<path class="cls-1" d="M194.6,25.4c9,0,18.2.1,27.2,0,8.8-.1,15,4.3,19.3,13.8,7.5,17.3,15.2,34.4,22.8,51.7,30.6,69.1,60.9,138.3,91.9,207.2,7.6,16.9-.3,37.1-17.8,36.9h-54c-17.9,0-15.2-4.5-19.4-14.4-20.7-47-41.6-94-62.4-141-17.3-39-34.2-78.2-51.9-117-8.7-18.8,1.8-38.7,18.1-37.4,8.8.6,17.6.1,26.3.1h0Z"/>
<path class="cls-2" d="M194.9,25.3c-9,0-18.2.1-27.2,0-8.8-.1-15,4.3-19.3,13.8-7.5,17.3-15.2,34.4-22.8,51.7-30.6,69.1-60.9,138.3-91.9,207.2-7.6,16.9.3,37.1,17.8,36.9h54c17.9,0,15.2-4.5,19.4-14.4,20.7-47,41.6-94,62.4-141,17.3-39,34.2-78.2,51.9-117,8.7-18.8-1.8-38.7-18.1-37.4-8.8.6-17.6.1-26.3.1h0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

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

After

Width:  |  Height:  |  Size: 1.1 KiB

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

After

Width:  |  Height:  |  Size: 1.7 KiB

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

After

Width:  |  Height:  |  Size: 1.3 KiB

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

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+5 -5
View File
@@ -31,10 +31,10 @@
}
</style>
</defs>
<rect class="cls-4" x="0" y="11.7" width="104.6" height="334.3" rx="52.3" ry="52.3"/>
<rect class="cls-1" x="251.7" y="126.2" width="105.8" height="219.8" rx="52.9" ry="52.9"/>
<path class="cls-6" d="M304.6,126.2H51.7C22.7,126.2,0,150.1,0,178.6s23.3,52.3,52.3,52.3h252.9c29.1,0,52.3-23.3,52.3-52.3s-23.3-52.3-52.3-52.3h-.6Z"/>
<path class="cls-4" d="M52.3,11.7h0c28.9,0,52.3,23.4,52.3,52.3v229.7c0,28.9-23.4,52.3-52.3,52.3h0c-28.9,0-52.3-23.4-52.3-52.3V64C0,35.1,23.4,11.7,52.3,11.7Z"/>
<path class="cls-1" d="M304.6,126.2h0c29.2,0,52.9,23.7,52.9,52.9v114c0,29.2-23.7,52.9-52.9,52.9h0c-29.2,0-52.9-23.7-52.9-52.9v-114c0-29.2,23.7-52.9,52.9-52.9Z"/>
<path class="cls-6" d="M304.6,126.2H51.7c-29,0-51.7,23.9-51.7,52.4s23.3,52.3,52.3,52.3h252.9c29.1,0,52.3-23.3,52.3-52.3s-23.3-52.3-52.3-52.3h-.6,0Z"/>
<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-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-2" d="M251.7,178.6v52.3h52.9c26.7,0,48.3-19.8,51.7-45.3v-7c0-29.1-23.3-52.3-52.3-52.3s-52.3,23.3-52.3,52.3h0Z"/>
<path class="cls-5" d="M0,172.2v14c3.5,25.6,25,45.3,51.7,45.3h52.9v-104.6h-52.9c-26.7,0-48.2,19.7-51.7,45.3Z"/>
</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

+22 -16
View File
@@ -3,36 +3,42 @@
<defs>
<style>
.cls-1 {
fill: #f6398a;
fill: #007e7d;
}
.cls-1, .cls-2, .cls-3 {
.cls-1, .cls-2, .cls-3, .cls-4 {
stroke-width: 0px;
}
.cls-2 {
fill: #000;
fill: #7b5c84;
}
.cls-3 {
fill: #007e7d;
fill: #000;
}
.cls-4 {
fill: #f6398a;
}
</style>
</defs>
<path class="cls-1" d="M83.7,29.9C86.9,15.6,76.7,0,62.8,0s-13.3,3.6-17.3,9.3l30,35.8c3.5-4.6,7-9.6,8.2-15.3Z"/>
<path class="cls-4" d="M42.3,15c-2.3-2.6-4.5-5.3-6.8-7.9-1.6-1.9-3.1-4-5.4-5.2C27.6.7,24.5,0,21.6,0,7.7,0-2.5,15.6.5,29.9c1.5,7.2,6.7,13,10.7,18.6,5.2,7.2,15.4,17.4,22.6,24.3,4.7,4.5,11.9,4.5,16.6,0,6.7-6.5,15.9-15.7,21.3-22.6-10.3-12.3-26.5-31.7-29.4-35.2Z"/>
<g>
<path class="cls-2" d="M138.4,53.9v8.6h-34.8V12.2h8.6v41.8h26.2Z"/>
<path class="cls-2" d="M142.1,44.5c0-2.7.5-5.3,1.5-7.7s2.4-4.4,4.2-6.2,3.9-3.1,6.2-4.2c2.4-1,4.9-1.5,7.7-1.5s5.3.5,7.7,1.5,4.4,2.4,6.2,4.2c1.8,1.8,3.1,3.8,4.2,6.2,1,2.4,1.5,4.9,1.5,7.7v18.1h-8.4v-2c-3.3,2.4-7.1,3.5-11.2,3.5s-5.7-.5-8.1-1.5-4.5-2.4-6.2-4.2-3-3.9-4-6.2c-.9-2.4-1.4-4.9-1.4-7.7h.1ZM150.5,44.5c0,1.5.3,3,.9,4.4s1.4,2.6,2.4,3.6,2.2,1.8,3.6,2.4c1.4.6,2.8.9,4.4.9s3-.3,4.4-.8c1.4-.6,2.6-1.3,3.6-2.3s1.8-2.2,2.4-3.6c.6-1.4.9-2.9.9-4.5s-.3-3-.9-4.4c-.6-1.4-1.4-2.6-2.4-3.6s-2.2-1.8-3.6-2.4c-1.4-.6-2.8-.9-4.4-.9s-3,.3-4.4.9-2.6,1.4-3.6,2.4-1.8,2.2-2.4,3.6c-.6,1.4-.9,2.8-.9,4.4h0Z"/>
<path class="cls-2" d="M225.2,44.5c0,2.8-.5,5.4-1.5,7.7-1,2.4-2.4,4.4-4.2,6.2s-3.9,3.1-6.2,4.1-4.9,1.5-7.7,1.5-5.3-.5-7.7-1.5-4.4-2.4-6.2-4.2-3.1-3.8-4.2-6.2c-1-2.4-1.5-4.9-1.5-7.7s.5-5.3,1.5-7.7,2.4-4.4,4.2-6.2,3.8-3.1,6.2-4.2c2.4-1,4.9-1.5,7.7-1.5s2.1.1,3.2.4c1.1.2,2.1.6,3.1.9,1,.4,1.9.8,2.8,1.3.9.5,1.6,1,2.2,1.6V12.2h8.4v32.3h-.1ZM216.8,44.5c0-1.5-.3-3-.9-4.4-.6-1.4-1.4-2.6-2.4-3.6s-2.2-1.8-3.6-2.4c-1.4-.6-2.8-.9-4.4-.9s-3,.3-4.4.9c-1.4.6-2.6,1.4-3.6,2.4s-1.8,2.2-2.4,3.6c-.6,1.4-.9,2.8-.9,4.4s.3,3,.9,4.4c.6,1.4,1.4,2.6,2.4,3.6s2.2,1.8,3.6,2.4c1.4.6,2.8.9,4.4.9s3-.3,4.4-.9c1.4-.6,2.6-1.4,3.6-2.4s1.8-2.2,2.4-3.6c.6-1.4.9-2.8.9-4.4Z"/>
<path class="cls-2" d="M229.7,17.4c0-1.5.5-2.8,1.5-3.8s2.2-1.5,3.7-1.5,2.8.5,3.8,1.5,1.5,2.3,1.5,3.8-.5,2.7-1.5,3.7-2.3,1.5-3.8,1.5-2.7-.5-3.7-1.5-1.5-2.2-1.5-3.7ZM230.7,26.6h8.4v36h-8.4V26.6Z"/>
<path class="cls-2" d="M244.8,12.2h8.4v50.4h-8.4V12.2Z"/>
<path class="cls-2" d="M258.9,12.2h8.4v50.4h-8.4V12.2Z"/>
<path class="cls-3" d="M138.4,53.9v8.6h-34.8V12.2h8.6v41.8h26.2Z"/>
<path class="cls-3" d="M142.1,44.5c0-2.7.5-5.3,1.5-7.7s2.4-4.4,4.2-6.2,3.9-3.1,6.2-4.2c2.4-1,4.9-1.5,7.7-1.5s5.3.5,7.7,1.5,4.4,2.4,6.2,4.2c1.8,1.8,3.1,3.8,4.2,6.2,1,2.4,1.5,4.9,1.5,7.7v18.1h-8.4v-2c-3.3,2.4-7.1,3.5-11.2,3.5s-5.7-.5-8.1-1.5-4.5-2.4-6.2-4.2-3-3.9-4-6.2c-.9-2.4-1.4-4.9-1.4-7.7,0,0,.1,0,.1,0ZM150.5,44.5c0,1.5.3,3,.9,4.4s1.4,2.6,2.4,3.6,2.2,1.8,3.6,2.4c1.4.6,2.8.9,4.4.9s3-.3,4.4-.8c1.4-.6,2.6-1.3,3.6-2.3s1.8-2.2,2.4-3.6c.6-1.4.9-2.9.9-4.5s-.3-3-.9-4.4c-.6-1.4-1.4-2.6-2.4-3.6s-2.2-1.8-3.6-2.4c-1.4-.6-2.8-.9-4.4-.9s-3,.3-4.4.9-2.6,1.4-3.6,2.4-1.8,2.2-2.4,3.6c-.6,1.4-.9,2.8-.9,4.4h0Z"/>
<path class="cls-3" d="M225.2,44.5c0,2.8-.5,5.4-1.5,7.7-1,2.4-2.4,4.4-4.2,6.2s-3.9,3.1-6.2,4.1-4.9,1.5-7.7,1.5-5.3-.5-7.7-1.5-4.4-2.4-6.2-4.2-3.1-3.8-4.2-6.2c-1-2.4-1.5-4.9-1.5-7.7s.5-5.3,1.5-7.7,2.4-4.4,4.2-6.2,3.8-3.1,6.2-4.2c2.4-1,4.9-1.5,7.7-1.5s2.1.1,3.2.4c1.1.2,2.1.6,3.1.9,1,.4,1.9.8,2.8,1.3.9.5,1.6,1,2.2,1.6V12.2h8.4v32.3h-.1ZM216.8,44.5c0-1.5-.3-3-.9-4.4-.6-1.4-1.4-2.6-2.4-3.6s-2.2-1.8-3.6-2.4c-1.4-.6-2.8-.9-4.4-.9s-3,.3-4.4.9c-1.4.6-2.6,1.4-3.6,2.4s-1.8,2.2-2.4,3.6c-.6,1.4-.9,2.8-.9,4.4s.3,3,.9,4.4c.6,1.4,1.4,2.6,2.4,3.6s2.2,1.8,3.6,2.4c1.4.6,2.8.9,4.4.9s3-.3,4.4-.9c1.4-.6,2.6-1.4,3.6-2.4s1.8-2.2,2.4-3.6c.6-1.4.9-2.8.9-4.4Z"/>
<path class="cls-3" d="M229.7,17.4c0-1.5.5-2.8,1.5-3.8s2.2-1.5,3.7-1.5,2.8.5,3.8,1.5,1.5,2.3,1.5,3.8-.5,2.7-1.5,3.7-2.3,1.5-3.8,1.5-2.7-.5-3.7-1.5-1.5-2.2-1.5-3.7ZM230.7,26.6h8.4v36h-8.4V26.6Z"/>
<path class="cls-3" d="M244.8,12.2h8.4v50.4h-8.4V12.2Z"/>
<path class="cls-3" d="M258.9,12.2h8.4v50.4h-8.4V12.2Z"/>
</g>
<g>
<path class="cls-2" d="M339.2,38.7c0,7.9-1.4,13.7-4.2,17.2-2.9,3.6-6,6-9.5,7.2-3.5,1.3-7.1,1.9-10.8,1.9s-7.2-.7-10.4-2.1c-3.3-1.4-6.1-3.3-8.6-5.8-2.4-2.4-4.4-5.3-5.8-8.6-1.4-3.3-2.1-6.7-2.1-10.4s.7-7.2,2.1-10.4c1.4-3.3,3.3-6.1,5.8-8.6,2.4-2.4,5.3-4.4,8.6-5.8,3.3-1.4,6.7-2.1,10.4-2.1s7.2.7,10.4,2.1c3.3,1.4,6.1,3.3,8.6,5.8,1.9,1.9,3.4,4,4.7,6.3h-6c-.7-1-1.4-1.9-2.3-2.7-2-2-4.3-3.6-6.9-4.8-2.6-1.2-5.4-1.7-8.4-1.7s-5.8.6-8.5,1.7-5,2.7-7,4.7-3.6,4.3-4.7,7c-1.2,2.6-1.7,5.5-1.7,8.4s.6,5.8,1.7,8.5c1.2,2.7,2.7,5,4.7,7s4.3,3.6,7,4.7c2.7,1.2,5.5,1.7,8.5,1.7s5.9-.5,8.6-1.4c2.8-.9,5.2-2.7,7.2-5.4s3-5.8,2.9-9.4h-16.8v-5h22.7-.2Z"/>
<path class="cls-2" d="M344,15.9c0-.9.3-1.6.9-2.2.6-.6,1.3-.9,2.2-.9s1.6.3,2.2.9c.6.6.9,1.3.9,2.2s-.3,1.6-.9,2.2-1.3.9-2.2.9-1.6-.3-2.2-.9-.9-1.3-.9-2.2ZM344.4,27.3h5v36h-5V27.3Z"/>
<path class="cls-2" d="M352.9,27.3h5.5l12.9,29.7,13-29.7h5.5l-15.7,36h-5.5l-15.7-36h0Z"/>
<path class="cls-2" d="M393.1,45.2c0,2,.4,3.9,1.2,5.7.8,1.8,1.8,3.4,3.2,4.7,1.3,1.3,2.9,2.4,4.7,3.2,1.8.8,3.7,1.2,5.7,1.2s3.9-.4,5.7-1.2c1.8-.8,3.4-1.8,4.7-3.2,1-1,1.8-2.1,2.4-3.3h5.5l-.2.6c-1,2.4-2.4,4.5-4.2,6.3-1.8,1.8-3.9,3.2-6.3,4.2s-5,1.5-7.7,1.5-5.3-.5-7.7-1.5c-2.4-1-4.5-2.4-6.3-4.2s-3.2-3.9-4.2-6.3c-1-2.4-1.5-5-1.5-7.7s.5-5.3,1.5-7.7,2.4-4.5,4.2-6.3c1.8-1.8,3.9-3.2,6.3-4.2s5-1.5,7.7-1.5,5.3.5,7.7,1.5c2.4,1,4.5,2.4,6.3,4.2,1.8,1.8,3.2,3.9,4.2,6.3.9,2,1.4,4,1.5,6.2h-34.4v1.5h0ZM418.3,34.8c-1.3-1.3-2.9-2.4-4.7-3.2-1.8-.8-3.7-1.2-5.7-1.2s-3.9.4-5.7,1.2c-1.8.8-3.4,1.8-4.7,3.2-1.2,1.2-2.1,2.4-2.8,3.9h26.4c-.7-1.4-1.6-2.7-2.7-3.9h0Z"/>
<path class="cls-3" d="M339.2,38.7c0,7.9-1.4,13.7-4.2,17.2-2.9,3.6-6,6-9.5,7.2-3.5,1.3-7.1,1.9-10.8,1.9s-7.2-.7-10.4-2.1c-3.3-1.4-6.1-3.3-8.6-5.8-2.4-2.4-4.4-5.3-5.8-8.6-1.4-3.3-2.1-6.7-2.1-10.4s.7-7.2,2.1-10.4c1.4-3.3,3.3-6.1,5.8-8.6,2.4-2.4,5.3-4.4,8.6-5.8,3.3-1.4,6.7-2.1,10.4-2.1s7.2.7,10.4,2.1c3.3,1.4,6.1,3.3,8.6,5.8,1.9,1.9,3.4,4,4.7,6.3h-6c-.7-1-1.4-1.9-2.3-2.7-2-2-4.3-3.6-6.9-4.8-2.6-1.2-5.4-1.7-8.4-1.7s-5.8.6-8.5,1.7-5,2.7-7,4.7-3.6,4.3-4.7,7c-1.2,2.6-1.7,5.5-1.7,8.4s.6,5.8,1.7,8.5c1.2,2.7,2.7,5,4.7,7s4.3,3.6,7,4.7c2.7,1.2,5.5,1.7,8.5,1.7s5.9-.5,8.6-1.4c2.8-.9,5.2-2.7,7.2-5.4s3-5.8,2.9-9.4h-16.8v-5h22.7-.2Z"/>
<path class="cls-3" d="M344,15.9c0-.9.3-1.6.9-2.2.6-.6,1.3-.9,2.2-.9s1.6.3,2.2.9c.6.6.9,1.3.9,2.2s-.3,1.6-.9,2.2-1.3.9-2.2.9-1.6-.3-2.2-.9-.9-1.3-.9-2.2ZM344.4,27.3h5v36h-5V27.3Z"/>
<path class="cls-3" d="M352.9,27.3h5.5l12.9,29.7,13-29.7h5.5l-15.7,36h-5.5l-15.7-36h0Z"/>
<path class="cls-3" d="M393.1,45.2c0,2,.4,3.9,1.2,5.7.8,1.8,1.8,3.4,3.2,4.7,1.3,1.3,2.9,2.4,4.7,3.2,1.8.8,3.7,1.2,5.7,1.2s3.9-.4,5.7-1.2c1.8-.8,3.4-1.8,4.7-3.2,1-1,1.8-2.1,2.4-3.3h5.5l-.2.6c-1,2.4-2.4,4.5-4.2,6.3-1.8,1.8-3.9,3.2-6.3,4.2s-5,1.5-7.7,1.5-5.3-.5-7.7-1.5c-2.4-1-4.5-2.4-6.3-4.2s-3.2-3.9-4.2-6.3c-1-2.4-1.5-5-1.5-7.7s.5-5.3,1.5-7.7,2.4-4.5,4.2-6.3c1.8-1.8,3.9-3.2,6.3-4.2s5-1.5,7.7-1.5,5.3.5,7.7,1.5c2.4,1,4.5,2.4,6.3,4.2,1.8,1.8,3.2,3.9,4.2,6.3.9,2,1.4,4,1.5,6.2h-34.4v1.5h0ZM418.3,34.8c-1.3-1.3-2.9-2.4-4.7-3.2-1.8-.8-3.7-1.2-5.7-1.2s-3.9.4-5.7,1.2c-1.8.8-3.4,1.8-4.7,3.2-1.2,1.2-2.1,2.4-2.8,3.9h26.4c-.7-1.4-1.6-2.7-2.7-3.9h0Z"/>
</g>
<path class="cls-1" d="M62.8,0c-7.1,0-13.3,3.6-17.3,9.3l13.7,16c1.1,1.3,1,3.2-.3,4.3-.6.5-1.3.7-2,.7s-1.7-.4-2.3-1.1c-4.1-4.8-8.2-9.5-12.3-14.3-2.3-2.6-4.5-5.3-6.8-7.9-1.6-1.9-3.1-4-5.4-5.2C27.6.6,24.5,0,21.6,0,7.7,0-2.5,15.5.5,29.8c1.5,7.2,6.7,13,10.7,18.6,5.2,7.2,15.4,17.4,22.6,24.3,4.7,4.5,11.9,4.5,16.6,0,7.3-7,17.4-17.2,22.6-24.3,4.1-5.6,9.2-11.5,10.7-18.6C86.9,15.5,76.7,0,62.8,0Z"/>
<path class="cls-3" d="M62.8,0c-7.1,0-13.3,3.6-17.3,9.3l13.7,16c1.1,1.3,1,3.2-.3,4.3-.6.5-1.3.7-2,.7s-1.7-.4-2.3-1.1l17.6,20.3c.3-.4.6-.8.8-1.1,4.1-5.6,9.2-11.5,10.7-18.6C86.9,15.5,76.7,0,62.8,0Z"/>
<path class="cls-2" d="M54.9,29.4c0,0-.2-.2-.3-.2-4.1-4.8-8.2-9.5-12.3-14.3,0,0-.1-.1-.2-.2,0,.1-.2.2-.3.3-4.1,4.8-8.2,9.5-12.3,14.3,0,0-.2.2-.3.2l-17.3,20c5.4,7.1,14.9,16.6,21.7,23.2,4.7,4.5,11.9,4.5,16.6,0,6.9-6.7,16.4-16.2,21.8-23.2l-17.3-20Z"/>
<path class="cls-1" d="M152.1-38.1h0s0,0,0,0c0,0,0,0,0,0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

+65 -2
View File
@@ -101,7 +101,10 @@ html.qr-mobile-page body {
}
.mobile-stats-card {
width: calc(66.666667% - 0.5rem);
width: max-content;
min-width: 9rem;
max-width: none;
box-sizing: border-box;
}
@layer components {
@@ -161,6 +164,66 @@ html.qr-mobile-page body {
@media (min-width: 640px) {
.mobile-stats-card {
width: auto;
width: max-content;
min-width: 9rem;
max-width: none;
flex: 1 1 auto;
}
}
/* Ladill app launcher — cap at 5 rows (15 apps), always-visible scroll indicator */
.ladill-launcher-apps-wrap {
position: relative;
}
.ladill-launcher-apps {
max-height: calc(5 * 5.25rem + 4 * 0.25rem);
overflow-y: scroll;
overscroll-behavior: contain;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgb(148 163 184) rgb(241 245 249);
margin-right: -0.25rem;
padding-right: 0.25rem;
}
.ladill-launcher-apps::-webkit-scrollbar {
-webkit-appearance: none;
width: 8px;
}
.ladill-launcher-apps::-webkit-scrollbar-track {
background: rgb(241 245 249);
border-radius: 9999px;
}
.ladill-launcher-apps::-webkit-scrollbar-thumb {
background-color: rgb(148 163 184);
border-radius: 9999px;
min-height: 2.5rem;
border: 2px solid rgb(241 245 249);
background-clip: padding-box;
}
.ladill-launcher-apps::-webkit-scrollbar-thumb:hover {
background-color: rgb(100 116 139);
}
.ladill-launcher-scroll-more {
position: absolute;
left: 50%;
bottom: 0.125rem;
z-index: 1;
display: flex;
height: 1.25rem;
width: 1.25rem;
-webkit-transform: translateX(-50%);
transform: translateX(-50%);
align-items: center;
justify-content: center;
border-radius: 9999px;
background: rgb(255 255 255);
color: rgb(100 116 139);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.08);
pointer-events: none;
}
+32 -7
View File
@@ -1,11 +1,14 @@
import './bootstrap';
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
registerLadillModalHelpers();
registerLadillSearchShortcut();
import Alpine from 'alpinejs';
import { registerLadillClipboard } from './ladill-clipboard';
import collapse from '@alpinejs/collapse';
import QRCodeStyling from 'qr-code-styling';
window.QRCodeStyling = QRCodeStyling;
@@ -13,6 +16,7 @@ import qrcode from 'qrcode-generator';
window.qrcode = qrcode;
Alpine.plugin(collapse);
registerLadillClipboard(Alpine);
Alpine.data('notificationDropdown', (config = {}) => ({
open: false,
@@ -159,6 +163,9 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
errorMsg: config.errorMsg ?? '',
showSheet: false,
checkoutUrl: '',
accessCode: '',
publicKey: '',
returnUrl: '',
paymentSheetStyle: '',
sheetBleedStyle: '',
@@ -211,7 +218,7 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
}
this.errorMsg = '';
this.loading = true;
this.loading = true; window.LadillPayCheckout?.prepare?.();
try {
const res = await fetch(config.payUrl, {
@@ -235,15 +242,13 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
this.loading = false;
return;
}
if (window.innerWidth < 768) {
this.checkoutUrl = data.checkout_url;
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
this.showSheet = true;
this.loading = false;
} else {
window.location.href = data.checkout_url;
}
} catch (e) {
this.errorMsg = 'Network error. Please try again.';
window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
this.loading = false;
}
},
@@ -300,6 +305,26 @@ Alpine.data('topbarSearch', (config = {}) => ({
},
}));
// Wallet balance peek for the avatar dropdown.
Alpine.data('walletWidget', (config = {}) => ({
display: '…',
async load() {
if (! config.url) {
this.display = 'View wallet';
return;
}
try {
const res = await fetch(config.url, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } });
const data = await res.json();
this.display = data.available ? data.formatted : 'View wallet';
} catch (e) {
this.display = 'View wallet';
}
},
}));
window.Alpine = Alpine;
registerLadillConfirmStore(Alpine);
+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.type === 'search')) {
input.select();
}
return true;
}
export function registerLadillSearchShortcut() {
if (window.__ladillSearchShortcutRegistered) {
return;
}
window.__ladillSearchShortcutRegistered = true;
document.addEventListener('keydown', (event) => {
if (event.key !== '/' && event.code !== 'Slash') {
return;
}
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
if (isEditableTarget(document.activeElement)) {
return;
}
event.preventDefault();
focusSearchInput();
});
}
@@ -1,17 +1,33 @@
@props([
'href' => null,
'type' => 'button',
'label' => null,
])
@php
$tag = $href ? 'a' : 'button';
$ariaLabel = $label ?? trim(preg_replace('/\s+/', ' ', strip_tags((string) $slot)));
@endphp
<{{ $tag }}
@if ($href) href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
{{ $attributes->class(['btn-primary']) }}
aria-label="{{ $ariaLabel }}"
title="{{ $ariaLabel }}"
{{ $attributes->class(['btn-fab h-10 w-10 lg:hidden']) }}
>
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
</{{ $tag }}>
<{{ $tag }}
@if ($href) href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
{{ $attributes->class(['btn-primary hidden lg:inline-flex']) }}
>
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
{{ $slot }}
</{{ $tag }}>
@@ -0,0 +1,29 @@
@props([
'text' => '',
'label' => 'Copy',
'copiedLabel' => 'Copied!',
'icon' => false,
'copiedClass' => '',
])
<button
type="button"
x-data="copyButton(@js($text))"
@click="copy()"
:title="copied ? @js($copiedLabel) : @js($icon ? 'Copy' : $label)"
:class="copied ? @js($copiedClass) : ''"
{{ $attributes->class($icon ? 'inline-flex shrink-0 items-center justify-center' : '') }}
>
@if ($icon)
<svg x-show="!copied" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg x-show="copied" x-cloak class="h-4 w-4 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
@elseif ($slot->isNotEmpty())
{{ $slot }}
@else
<span x-text="copied ? @js($copiedLabel) : @js($label)"></span>
@endif
</button>
@@ -36,10 +36,10 @@
loading: false,
errorMsg: '',
async submitTopup(event) {
if (this.method !== 'paystack' || window.innerWidth >= 768) return;
if (this.method !== 'paystack') return;
event.preventDefault();
this.loading = true;
this.loading = true; window.LadillPayCheckout?.prepare?.();
this.errorMsg = '';
try {
@@ -56,10 +56,12 @@
}
this.checkoutUrl = data.checkout_url;
this.showSheet = true;
this.loading = false;
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened)
} catch (error) {
this.errorMsg = 'Network error. Please try again.';
window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
this.loading = false;
}
},
+10 -3
View File
@@ -2,9 +2,16 @@
<x-slot name="title">Overview</x-slot>
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold text-slate-900">Overview</h1>
<p class="mt-1 text-sm text-slate-500">Total raised today, active giving pages, and recent donations.</p>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<h1 class="truncate text-lg font-semibold text-slate-900 lg:text-xl">Overview</h1>
<p class="mt-1 hidden text-sm text-slate-500 sm:block">Total raised today, active giving pages, and recent donations.</p>
</div>
@include('partials.mobile-header-btn', [
'href' => route('give.giving-pages.create'),
'label' => 'New giving page',
'variant' => 'indigo',
])
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-2xl border border-slate-200 bg-white p-5">
@@ -1,30 +1,85 @@
<x-user-layout>
<x-slot name="title">Giving Pages</x-slot>
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-xl font-semibold text-slate-900">Giving pages</h1>
<p class="mt-1 text-sm text-slate-500">Branded pages with a printable QR for online giving.</p>
<div class="space-y-6"
x-data="balanceGate({
balance: @js((float) $wallet->spendableBalance()),
price: @js((float) $pricePerQr),
topupUrl: @js($topupUrl),
href: @js(route('give.giving-pages.create')),
})">
@foreach(['success', 'error'] as $flash)
@if(session($flash))
<div class="rounded-lg border px-4 py-3 {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
<p class="text-sm">{{ session($flash) }}</p>
</div>
<x-btn.create :href="route('give.giving-pages.create')">New giving page</x-btn.create>
@endif
@endforeach
<div class="relative overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div class="absolute inset-0 bg-gradient-to-br from-violet-50/80 via-white to-indigo-50/60"></div>
<div class="relative px-6 py-8 sm:px-10">
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div class="max-w-xl">
<div class="inline-flex items-center gap-1.5 rounded-full bg-violet-100 px-3 py-1 text-xs font-semibold text-violet-700">
Tithes · Offerings · Campaigns
</div>
<h1 class="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Your giving pages</h1>
<p class="mt-2 text-sm leading-6 text-slate-600">
Branded donation pages with a printable QR. Share a Ladill Link so supporters can give online in seconds.
</p>
<div class="mt-6">
<button type="button"
@click="goOrTopup($event)"
class="btn-primary">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
New giving page
</button>
</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($wallet->spendableBalance(), 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">{{ $qrCodes->count() }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Giving pages</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="rounded-2xl border border-slate-200 bg-white overflow-hidden">
<div class="border-b border-slate-100 px-6 py-4">
<h2 class="text-sm font-semibold text-slate-900">Your giving pages</h2>
</div>
@if($qrCodes->isEmpty())
<div class="rounded-2xl border border-dashed border-slate-200 bg-white px-6 py-12 text-center">
<p class="text-sm text-slate-500">No giving pages yet.</p>
<a href="{{ route('give.giving-pages.create') }}" class="mt-3 inline-block text-sm font-semibold text-indigo-600 hover:text-indigo-800">Create your first giving page</a>
</div>
<p class="px-6 py-10 text-center text-sm text-slate-500">No giving pages yet. Create your first giving page to get started.</p>
@else
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div class="grid gap-4 p-6 sm:grid-cols-2 xl:grid-cols-3">
@foreach($qrCodes as $qr)
<a href="{{ route('give.giving-pages.show', $qr) }}" class="rounded-2xl border border-slate-200 bg-white p-5 transition hover:border-indigo-200 hover:shadow-sm">
<div class="flex items-start gap-4">
@if(!empty($previewDataUris[$qr->id]))
<img src="{{ $previewDataUris[$qr->id] }}" alt="" class="h-16 w-16 rounded-lg border border-slate-100 bg-white p-1">
<img src="{{ $previewDataUris[$qr->id] }}" alt="" class="h-16 w-16 shrink-0 rounded-lg border border-slate-100 bg-white p-1">
@else
<div class="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg {{ $qr->is_active ? 'bg-violet-100' : 'bg-slate-100 opacity-50' }}">
<svg class="h-8 w-8 text-violet-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
</svg>
</div>
@endif
<div class="min-w-0 flex-1">
<p class="truncate font-semibold text-slate-900">{{ $qr->label }}</p>
<p class="mt-0.5 truncate text-sm text-slate-500">{{ $qr->content()['name'] ?? 'Organisation' }}</p>
<p class="mt-2 text-xs text-slate-400">{{ $qr->is_active ? 'Active' : 'Inactive' }} · /q/{{ $qr->short_code }}</p>
<p class="mt-2 truncate text-xs text-slate-400">{{ $qr->is_active ? 'Active' : 'Inactive' }} · {{ $qr->publicUrl() }}</p>
</div>
</div>
</a>
@@ -32,4 +87,5 @@
</div>
@endif
</div>
</div>
</x-user-layout>
+1 -1
View File
@@ -4,7 +4,7 @@
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold text-slate-900">Payouts</h1>
<p class="mt-1 text-sm text-slate-500">Donations settle into your Ladill wallet (9% fee), then withdraw to bank or MoMo.</p>
<p class="mt-1 text-sm text-slate-500">Donations settle into your Ladill wallet (3.5% fee), then withdraw to bank or MoMo.</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-white p-5">
+8
View File
@@ -56,6 +56,14 @@
</label>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 space-y-4">
<div>
<h2 class="text-sm font-semibold text-slate-900">Donor payment engine</h2>
<p class="mt-1 text-xs text-slate-500">All donations use Ladill Pay zero-config, no owner API keys.</p>
</div>
<p class="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">Ladill Pay is the only payment option. Standard platform fees apply on donations and settle to your Ladill wallet.</p>
</div>
<button type="submit" class="btn-primary">
Save settings
</button>
+2 -6
View File
@@ -10,7 +10,7 @@
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="h-full font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }">
<body class="h-full font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }" data-ladill-search-url="{{ route('give.search') }}">
<div class="min-h-screen max-lg:min-h-dvh">
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
<aside x-cloak class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0"
@@ -45,11 +45,7 @@
'profileActive' => false,
'profileName' => $navUser?->name ?? '',
'profileSubtitle' => $navUser?->email ?? '',
'profileMenuItems' => [
['type' => 'link', 'label' => 'Profile', 'href' => $navAcct.'/profile'],
['type' => 'link', 'label' => 'Account Settings', 'href' => $navAcct.'/account-settings'],
['type' => 'logout', 'label' => 'Logout', 'action' => route('logout')],
],
'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
'avatarUrl' => $navUser?->avatar_url,
'initials' => $navInitials !== '' ? $navInitials : 'U',
])
+1 -6
View File
@@ -73,12 +73,7 @@
'profileActive' => request()->routeIs('account.settings'),
'profileName' => $navUser?->name ?? '',
'profileSubtitle' => $navUser?->email ?? '',
'profileMenuItems' => [
['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('give.dashboard')],
['type' => 'logout', 'label' => 'Logout', 'action' => route('logout')],
],
'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
'avatarUrl' => $navUser?->avatarUrl(),
'initials' => $navInitials !== '' ? $navInitials : 'U',
])
@@ -8,7 +8,7 @@
@if ($handler === 'openAfia')
@click="openAfia()"
@else
@click="$dispatch('afia-open')"
@click="window.dispatchEvent(new CustomEvent('afia-open'))"
@endif
@class([
'inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-400 text-sm font-semibold text-white shadow-sm transition hover:opacity-95',
+2 -2
View File
@@ -1,5 +1,5 @@
@php
$afiaGreeting = "Hi, I'm Afia 👋 Ask me about giving pages — creating one, accepting donations, payouts, or the 9% platform fee…";
$afiaGreeting = "Hi, I'm Afia 👋 Ask me about giving pages — creating one, accepting donations, payouts, or the 3.5% platform fee…";
$afiaSuggestions = [
'How do I create a giving page?',
'How do supporters give online?',
@@ -67,7 +67,7 @@
<div class="flex" :class="m.role === 'user' ? 'justify-end' : 'justify-start'">
<div class="max-w-[85%] whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed"
:class="m.role === 'user' ? 'bg-indigo-600 text-white rounded-br-md' : 'bg-slate-100 text-slate-700 rounded-bl-md'"
x-text="m.text"></div>
x-html="m.text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\*\*([^*]+?)\*\*/g,'<strong>$1</strong>')"></div>
</div>
</template>
<div x-show="loading" class="flex justify-start">
@@ -0,0 +1,52 @@
@php
// Branded boot splash (Ladill Mail style). Self-icon from this app's subdomain.
$sub = strtolower((string) ((explode('.', (string) (parse_url((string) config('app.url'), PHP_URL_HOST) ?: '')))[0] ?? ''));
$icon = $sub !== '' && is_file(public_path("images/launcher-icons/{$sub}.svg")) ? "images/launcher-icons/{$sub}.svg" : null;
$label = (string) config('app.name', 'Ladill');
@endphp
<div id="ladill-boot" role="status" aria-live="polite">
<div class="lb-wrap">
@if ($icon)
<img class="lb-logo" src="{{ asset($icon) }}?v={{ @filemtime(public_path($icon)) ?: '1' }}" alt="">
@endif
<div class="lb-bar"><span></span></div>
<div class="lb-title">Loading {{ $label }}</div>
</div>
</div>
<style>
#ladill-boot{position:fixed;inset:0;z-index:99999;background:#f8fafc;display:flex;align-items:center;justify-content:center;transition:opacity .35s ease;font-family:'Figtree',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
#ladill-boot .lb-wrap{display:flex;flex-direction:column;align-items:center;gap:24px;width:260px}
#ladill-boot .lb-logo{width:64px;height:64px;animation:lb-pulse 1.6s ease-in-out infinite}
#ladill-boot .lb-bar{width:100%;height:4px;background:#e2e8f0;border-radius:999px;overflow:hidden}
#ladill-boot .lb-bar>span{display:block;height:100%;width:8%;border-radius:999px;background:linear-gradient(90deg,#4f46e5,#7c3aed);animation:lb-fill 1.9s cubic-bezier(.4,0,.2,1) forwards}
#ladill-boot .lb-title{font-size:14px;color:#64748b;font-weight:500}
@keyframes lb-fill{0%{width:8%}55%{width:68%}100%{width:93%}}
@keyframes lb-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(.93);opacity:.82}}
</style>
<script>
(function(){
var el=document.getElementById('ladill-boot');
if(!el)return;
function remove(){if(el&&el.parentNode)el.parentNode.removeChild(el);}
// Show when the user opens or reloads the app; suppress only on internal
// link navigations and back/forward so we don't flash on every click.
try{
var navType='navigate';
var nav=performance.getEntriesByType&&performance.getEntriesByType('navigation');
if(nav&&nav.length){navType=nav[0].type;}
else if(performance.navigation){navType=performance.navigation.type===1?'reload':(performance.navigation.type===2?'back_forward':'navigate');}
var internal=false;
if(navType!=='reload'&&document.referrer){
try{internal=new URL(document.referrer).origin===location.origin;}catch(e){}
}
if(internal||navType==='back_forward'){remove();return;}
}catch(e){}
var start=Date.now(),MIN=550;
function done(){
var wait=Math.max(0,MIN-(Date.now()-start));
setTimeout(function(){if(!el)return;el.style.opacity='0';setTimeout(remove,400);},wait);
}
if(document.readyState==='complete')done();else window.addEventListener('load',done);
setTimeout(done,8000); // safety net
})();
</script>
+34 -3
View File
@@ -10,10 +10,27 @@
config('ladill_launcher.apps', []),
fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost
));
$launcherOverflows = count($launcherApps) > 15;
@endphp
@if (! empty($launcherApps))
<div class="relative" x-data="{ appsOpen: false }" @click.outside="appsOpen = false" @keydown.escape.window="appsOpen = false">
<button type="button" @click="appsOpen = !appsOpen"
<div class="relative" x-data="{
appsOpen: false,
showScrollMore: {{ $launcherOverflows ? 'true' : 'false' }},
checkScrollEnd() {
const el = this.$refs.appsScroller;
if (! el) return;
this.showScrollMore = el.scrollTop + el.clientHeight < el.scrollHeight - 4;
},
toggleApps() {
this.appsOpen = ! this.appsOpen;
if (this.appsOpen) {
this.$nextTick(() => this.checkScrollEnd());
} else {
this.showScrollMore = {{ $launcherOverflows ? 'true' : 'false' }};
}
},
}" @click.outside="appsOpen = false" @keydown.escape.window="appsOpen = false">
<button type="button" @click="toggleApps()"
@class([
'inline-flex items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:bg-slate-50',
'p-2' => ! $sidebar,
@@ -37,10 +54,15 @@
'bottom-full left-0 mb-2' => $sidebar,
])>
<p class="px-1 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">All apps</p>
<div @class(['ladill-launcher-apps-wrap' => $launcherOverflows])>
<div
class="ladill-launcher-apps"
@if ($launcherOverflows) x-ref="appsScroller" @scroll="checkScrollEnd()" @endif
>
<div class="grid grid-cols-3 gap-1">
@foreach ($launcherApps as $app)
<a href="{{ $app['url'] }}"
class="flex 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">
<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>
@@ -49,5 +71,14 @@
@endforeach
</div>
</div>
@if ($launcherOverflows)
<div class="ladill-launcher-scroll-more" x-show="showScrollMore" x-cloak aria-hidden="true">
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/>
</svg>
</div>
@endif
</div>
</div>
</div>
@endif
@@ -1,5 +1,9 @@
@php
$gridCols = !empty($centerCompose) ? 'grid-cols-5' : 'grid-cols-4';
$showSearch = isset($searchUrl) && $searchUrl !== null && $searchUrl !== '#';
$gridCols = match (true) {
! empty($centerCompose) => $showSearch ? 'grid-cols-5' : 'grid-cols-4',
default => $showSearch ? 'grid-cols-4' : 'grid-cols-3',
};
$avatarUrl = $avatarUrl ?? null;
$initials = $initials ?? 'U';
$notificationsUrl = $notificationsUrl ?? '#';
@@ -11,7 +15,6 @@
if ($profileMenuItems === [] && $profileUrl !== '#') {
$profileMenuItems = [['type' => 'link', 'label' => 'Profile', 'href' => $profileUrl]];
}
$searchLabel = $searchLabel ?? 'Search';
$navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600';
@endphp
<div x-data="{ profileOpen: false }" class="lg:hidden">
@@ -24,11 +27,13 @@
<span class="text-[10px] font-medium">Home</span>
</a>
@if ($showSearch)
<a href="{{ $searchUrl }}"
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($searchActive ?? false) }}">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
<span class="text-[10px] font-medium">{{ $searchLabel }}</span>
<span class="text-[10px] font-medium">Search</span>
</a>
@endif
@if (!empty($centerCompose))
<div class="flex items-center justify-center">
@@ -52,12 +57,14 @@
},
}"
@endif
class="relative flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($notificationsActive ?? false) }}">
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($notificationsActive ?? false) }}">
<span class="relative inline-flex shrink-0">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
@if ($unreadUrl)
<span x-show="unread > 0" x-cloak x-text="unread > 9 ? '9+' : unread"
class="absolute right-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
</span>
<span class="text-[10px] font-medium">Notifications</span>
</a>
@@ -83,6 +90,7 @@
class="absolute inset-0 bg-slate-900/40 backdrop-blur-[1px]"></div>
<div x-show="profileOpen"
x-effect="profileOpen && window.dispatchEvent(new CustomEvent('wallet-balance-refresh'))"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-y-full"
x-transition:enter-end="translate-y-0"
@@ -114,25 +122,11 @@
</div>
@endif
<div class="space-y-1 p-2 pb-4">
@foreach ($profileMenuItems as $item)
@if (($item['type'] ?? 'link') === 'link')
<a href="{{ $item['href'] }}"
@click="profileOpen = false"
class="block rounded-xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-100">
{{ $item['label'] }}
</a>
@elseif (($item['type'] ?? '') === 'logout')
<form method="POST" action="{{ $item['action'] }}" class="border-t border-slate-100 pt-2">
@csrf
<button type="submit"
class="w-full rounded-xl px-4 py-3 text-left text-sm font-medium text-rose-600 transition hover:bg-rose-50">
{{ $item['label'] }}
</button>
</form>
@endif
@endforeach
</div>
@include('partials.user-profile-menu', [
'items' => $profileMenuItems,
'variant' => 'sheet',
'onNavigate' => 'profileOpen = false',
])
</div>
</div>
</div>
@@ -0,0 +1,53 @@
@php
$variant = $variant ?? 'primary';
$type = $type ?? 'submit';
$tag = ! empty($href) ? 'a' : 'button';
$ariaLabel = $ariaLabel ?? $label ?? '';
$desktopLabel = $desktopLabel ?? $label ?? '';
$showDesktopIcon = $showDesktopIcon ?? ($variant === 'primary');
$mobileClass = match ($variant) {
'primary' => 'btn-fab h-10 w-10 lg:hidden',
'outline' => 'inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 lg:hidden',
'dark' => 'inline-flex h-10 w-10 items-center justify-center rounded-full bg-gray-900 text-white shadow-sm transition hover:bg-gray-800 lg:hidden',
'indigo' => 'inline-flex h-10 w-10 items-center justify-center rounded-full bg-indigo-600 text-white shadow-sm transition hover:bg-indigo-700 lg:hidden',
default => 'btn-fab h-10 w-10 lg:hidden',
};
$desktopClass = match ($variant) {
'primary' => 'btn-primary hidden items-center lg:inline-flex',
'outline' => 'hidden items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 lg:inline-flex',
'dark' => 'hidden items-center gap-1.5 rounded-lg bg-gray-900 px-3.5 py-2 text-sm font-medium text-white transition hover:bg-gray-800 lg:inline-flex',
'indigo' => 'hidden items-center justify-center rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-700 lg:inline-flex',
default => 'btn-primary hidden items-center lg:inline-flex',
};
$extraClass = $class ?? '';
@endphp
<{{ $tag }}
@if ($tag === 'a') href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
aria-label="{{ $ariaLabel }}"
title="{{ $ariaLabel }}"
@if (! empty($attributes)) {!! $attributes !!} @endif
class="{{ trim($mobileClass.' '.$extraClass) }}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
</{{ $tag }}>
<{{ $tag }}
@if ($tag === 'a') href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
@if (! empty($attributes)) {!! $attributes !!} @endif
class="{{ trim($desktopClass.' '.$extraClass) }}"
>
@if ($showDesktopIcon)
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
@endif
{{ $desktopLabel }}
</{{ $tag }}>
@@ -0,0 +1,33 @@
@php
$icon = $icon ?? 'arrow';
$desktopClass = $desktopClass ?? 'text-xs font-medium text-indigo-600 hover:text-indigo-700';
$label = $label ?? '';
$extraAttributes = $attributes ?? '';
@endphp
<a href="{{ $href }}"
aria-label="{{ $label }}"
title="{{ $label }}"
@if ($extraAttributes !== '') {!! $extraAttributes !!} @endif
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-indigo-50 text-indigo-600 ring-1 ring-indigo-100 transition hover:bg-indigo-100 lg:h-auto lg:w-auto lg:rounded-none lg:bg-transparent lg:ring-0 lg:hover:bg-transparent">
<span class="lg:hidden">
@if ($icon === 'plus')
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
@elseif ($icon === 'shield')
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"/>
</svg>
@elseif ($icon === 'calendar')
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"/>
</svg>
@else
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/>
</svg>
@endif
</span>
<span class="hidden lg:inline {{ $desktopClass }}">{{ $label }}</span>
</a>
+624 -33
View File
@@ -1,44 +1,635 @@
{{--
Paystack mobile bottom-sheet iframe.
Requires Alpine.js ancestor with: showSheet (bool), checkoutUrl (string).
On mobile (< md) the sheet slides up; on desktop nothing renders.
--}}
<template x-teleport="body">
<div x-show="showSheet"
x-cloak
class="fixed inset-0 z-[9999] flex flex-col justify-end md:hidden"
role="dialog"
aria-modal="true">
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"
x-show="showSheet"
x-transition:enter="transition-opacity duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showSheet = false">
data-ladill-pay-backdrop
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()">
</div>
<div class="relative flex flex-col overflow-hidden rounded-t-2xl bg-white"
style="height:90dvh; padding-bottom: env(safe-area-inset-bottom, 0px)"
x-show="showSheet"
x-transition:enter="transition-transform duration-300 ease-out"
x-transition:enter-start="translate-y-full"
x-transition:enter-end="translate-y-0"
x-transition:leave="transition-transform duration-200 ease-in"
x-transition:leave-start="translate-y-0"
x-transition:leave-end="translate-y-full">
<div 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>
@include('partials.mini-paystack-frame-header')
<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>
<iframe :src="showSheet ? checkoutUrl : ''"
class="min-h-0 flex-1 w-full border-0"
allow="payment"
title="Paystack checkout"></iframe>
<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>
@include('partials.mini-paystack-frame-footer')
<div x-show="$store.ladillPayCheckout && $store.ladillPayCheckout.error"
class="flex min-h-[40dvh] flex-col items-center justify-center gap-4 px-6 py-10 text-center md:min-h-[20rem] md:px-8 md:py-12"
data-ladill-pay-error>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-50 text-red-500" aria-hidden="true">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/>
</svg>
</div>
<div class="max-w-xs space-y-1 md:max-w-sm">
<p class="text-sm font-semibold text-slate-800" data-ladill-pay-status-title>Checkout unavailable</p>
<p class="text-xs leading-snug text-slate-500" x-text="$store.ladillPayCheckout.error"></p>
</div>
<button type="button"
data-ladill-pay-retry
@click="window.LadillPayCheckout?.ensureStore()?.sync(true, (typeof checkoutUrl==='undefined'||checkoutUrl===null)?'':String(checkoutUrl), (typeof accessCode==='undefined'||accessCode===null)?'':String(accessCode), (typeof returnUrl==='undefined'||returnUrl===null)?'':String(returnUrl))"
class="inline-flex w-full max-w-xs items-center justify-center rounded-xl bg-indigo-600 px-4 py-3 text-sm font-semibold text-white hover:bg-indigo-700">
Try again
</button>
<button type="button"
@click="showSheet = false; window.LadillPayCheckout?.cancel?.()"
class="text-xs font-medium text-slate-500 hover:text-slate-700">
Cancel
</button>
</div>
</div>
@if (! empty($sheetFooter))
<p class="shrink-0 border-t border-slate-100 bg-slate-50 px-4 py-2 text-center text-[11px] text-slate-500 md:px-5"
data-ladill-pay-footer>
{{ $sheetFooter }}
</p>
@endif
</div>
</div>
</div>
</template>
@@ -14,7 +14,7 @@
<div class="px-4 pb-3">
<div class="relative">
<svg class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
<input x-ref="input"
<input data-ladill-search-input x-ref="input"
type="text"
x-model="query"
@focus="onFocus()"
@@ -34,7 +34,7 @@
<h1 class="text-2xl font-semibold text-slate-900">{{ $heading }}</h1>
<div class="relative mt-3">
<svg class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
<input x-ref="input"
<input data-ladill-search-input x-ref="input"
type="text"
x-model="query"
@focus="onFocus()"
@@ -23,8 +23,6 @@
<span>{{ $item['name'] }}</span>
</a>
@endforeach
<p class="px-3 pb-1 pt-5 text-[10px] font-bold uppercase tracking-widest text-slate-400">Account</p>
@include('partials.sidebar-support')
</nav>
<div class="shrink-0 border-t border-slate-100 px-3 py-3">
@@ -1,11 +1,19 @@
@if (auth()->check())
@php $authPing = 'https://'.config('app.auth_domain').'/sso/ping'; @endphp
{{-- Same-site pings keep the shared auth.ladill.com session warm while using this app. --}}
<iframe src="{{ $authPing }}" hidden width="0" height="0" title=""></iframe>
@php
$authPing = 'https://'.config('app.auth_domain').'/sso/ping';
$platformSignedOutUrl = route('sso.platform-signed-out', ['redirect' => url()->current()]);
@endphp
<script>
(function () {
const pingUrl = @js($authPing);
const 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();
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 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('give.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
@include('partials.afia-button', ['compact' => true])
@endauth
@includeIf('partials.topbar-account-switcher')
@includeIf('partials.topbar-widgets-prepend')
@include('partials.topbar-desktop-widgets', ['user' => $user ?? $u ?? auth()->user(), 'showUser' => true])
@includeIf('partials.topbar-widgets-append')
@else
<a href="{{ route('login') }}" class="btn-primary">Sign in</a>
@include('partials.launcher')
@endauth
</div>
</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
<header class="flex items-center justify-between h-16 border-b border-slate-200 bg-white px-6"
x-data="afiaChat({
chatUrl: {{ \Illuminate\Support\Js::from(route('user.ai.chat')) }},
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
currentPage: {{ \Illuminate\Support\Js::from(request()->path()) }},
})"
@keydown.escape.window="handleEscape()">
>
<div class="flex items-center gap-3 flex-1 min-w-0">
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden shrink-0 text-slate-500 hover:text-slate-700">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
{{-- Search Bar --}}
<div class="relative hidden w-full max-w-md lg:block" x-data="topbarSearch()" @click.outside="open = false" @keydown.escape.window="open = false">
<div class="relative">
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
<input type="text"
x-model="query"
@focus="onFocus()"
@input.debounce.200ms="search()"
@keydown.arrow-down.prevent="moveDown()"
@keydown.arrow-up.prevent="moveUp()"
@keydown.enter.prevent="go()"
placeholder="Search domains, hosting, email, SMTP…"
class="w-full rounded-xl border border-slate-200 bg-slate-50 py-2 pl-9 pr-10 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
<kbd class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 rounded-md border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-400 sm:inline-block">/</kbd>
</div>
{{-- Results dropdown --}}
<div x-show="open && (results.length > 0 || (query.length >= 2 && !loading))"
x-cloak
x-transition.opacity.duration.150ms
class="absolute left-0 top-full z-30 mt-1.5 w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg">
<template x-if="loading">
<div class="px-4 py-3 text-center text-xs text-slate-400">Searching…</div>
</template>
<template x-if="!loading && results.length === 0 && query.length >= 2">
<div class="px-4 py-3 text-center text-xs text-slate-500">No results for "<span x-text="query" class="font-medium"></span>"</div>
</template>
<template x-if="!loading && results.length > 0">
<ul class="max-h-72 overflow-y-auto py-1">
<template x-for="(item, idx) in results" :key="item.url">
<li>
<a :href="item.url"
:class="idx === active ? 'bg-indigo-50 text-indigo-700' : 'text-slate-700'"
@mouseenter="active = idx"
class="flex items-center gap-3 px-4 py-2.5 text-sm transition hover:bg-indigo-50">
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
:class="{
'bg-blue-100 text-blue-600': item.type === 'domain',
'bg-teal-100 text-teal-600': item.type === 'hosting',
'bg-sky-100 text-sky-600': item.type === 'email',
'bg-violet-100 text-violet-600': item.type === 'smtp',
'bg-amber-100 text-amber-600': item.type === 'ticket',
'bg-orange-100 text-orange-600': item.type === 'order',
'bg-slate-100 text-slate-600': !['domain','hosting','email','smtp','ticket','order'].includes(item.type),
}">
<template x-if="item.type === 'domain'">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"/></svg>
</template>
<template x-if="item.type !== 'domain'">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
</template>
</span>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium leading-tight" x-text="item.title"></span>
<span class="block truncate text-xs text-slate-400" x-text="item.subtitle"></span>
</span>
</a>
</li>
</template>
</ul>
</template>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="relative hidden lg:block"
x-data="notificationDropdown({
unreadUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.unread')) }},
markReadUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.mark-read', ['id' => '__ID__'])) }},
markAllReadUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.mark-all-read')) }},
indexUrl: {{ \Illuminate\Support\Js::from(route('user.notifications.index')) }},
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
})"
@click.outside="open = false">
<button type="button"
@click="toggle()"
class="relative inline-flex items-center justify-center rounded-full border border-slate-200 p-2 text-slate-600 transition hover:bg-slate-50"
aria-label="Notifications">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
</svg>
<span x-show="unreadCount > 0"
x-cloak
x-text="unreadCount > 9 ? '9+' : unreadCount"
class="absolute -right-1 -top-1 inline-flex min-w-5 items-center justify-center rounded-full bg-rose-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"></span>
</button>
{{-- Notification dropdown --}}
<div x-show="open"
x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 z-50 mt-2 w-80 origin-top-right rounded-xl border border-slate-200 bg-white shadow-lg">
<div class="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<h3 class="text-sm font-semibold text-slate-900">Notifications</h3>
<button type="button"
x-show="unreadCount > 0"
@click="markAllRead()"
class="text-xs font-medium text-indigo-600 hover:text-indigo-700">
Mark all read
</button>
</div>
<div class="max-h-80 overflow-y-auto">
<template x-if="loading">
<div class="px-4 py-6 text-center">
<svg class="mx-auto h-5 w-5 animate-spin text-slate-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
</template>
<template x-if="!loading && notifications.length === 0">
<div class="px-4 py-8 text-center">
<svg class="mx-auto h-10 w-10 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
</svg>
<p class="mt-2 text-sm text-slate-500">No notifications yet</p>
</div>
</template>
<template x-if="!loading && notifications.length > 0">
<div class="divide-y divide-slate-100">
<template x-for="notification in notifications" :key="notification.id">
<a :href="notification.url || '#'"
@click="markRead(notification.id)"
class="flex items-start gap-3 px-4 py-3 transition hover:bg-slate-50">
<span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
:class="getIconBg(notification.icon)">
<template x-if="notification.icon === 'website'">
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15A2.25 2.25 0 0 0 2.25 6.75v10.5A2.25 2.25 0 0 0 4.5 19.5Z"/></svg>
</template>
<template x-if="notification.icon === 'domain'">
<span class="inline-flex" :class="getIconColor(notification.icon)">
{!! \App\Support\DomainGlobeIcon::svg('h-4 w-4') !!}
</span>
</template>
<template x-if="notification.icon === 'hosting'">
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/></svg>
</template>
<template x-if="notification.icon === 'email'">
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
</template>
<template x-if="notification.icon === 'billing'">
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z"/></svg>
</template>
<template x-if="notification.icon === 'lead'">
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0"/></svg>
</template>
<template x-if="notification.icon === 'success'">
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
</template>
<template x-if="!['website','domain','hosting','email','billing','lead','success'].includes(notification.icon)">
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
</template>
</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-slate-900 truncate" x-text="notification.title"></p>
<p class="text-xs text-slate-500 line-clamp-2" x-text="notification.message"></p>
<p class="mt-1 text-[11px] text-slate-400" x-text="notification.created_at"></p>
</div>
</a>
</template>
</div>
</template>
</div>
<div class="border-t border-slate-100 px-4 py-2.5">
<a :href="indexUrl" class="block text-center text-xs font-medium text-slate-600 hover:text-slate-900">
View all notifications
</a>
</div>
</div>
</div>
<div class="hidden h-8 w-px bg-slate-200 lg:block"></div>
<a href="{{ $cartRoute }}"
class="hidden items-center gap-2 rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50 lg:inline-flex">
<span>Cart</span>
@if ($cartCount > 0)
<span class="inline-flex min-w-5 items-center justify-center rounded-full bg-slate-900 px-1.5 py-0.5 text-[11px] font-semibold text-white">{{ $cartCount }}</span>
@endif
</a>
<div class="relative hidden lg:block">
<button type="button"
@click="profileOpen = !profileOpen"
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-2 py-1.5 text-slate-700 hover:bg-slate-50">
@if ($user?->avatarUrl())
<img src="{{ $user->avatarUrl() }}" alt="Avatar" class="h-8 w-8 rounded-full object-cover ring-1 ring-slate-200">
@auth
@includeIf('partials.topbar-account-switcher')
@includeIf('partials.topbar-widgets-prepend')
@include('partials.topbar-desktop-widgets', ['user' => $user ?? $u ?? auth()->user(), 'showUser' => true])
@includeIf('partials.topbar-widgets-append')
@else
<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">
{{ $initials !== '' ? $initials : 'U' }}
</span>
@endif
<svg class="h-4 w-4 text-slate-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7" />
</svg>
</button>
<div x-show="profileOpen" x-cloak x-transition @click.outside="profileOpen = false"
class="absolute right-0 z-20 mt-2 w-48 rounded-xl border border-slate-200 bg-white p-1 shadow-lg">
<a href="{{ route('profile.edit') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Profile</a>
<a href="{{ route('account.settings') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Account Settings</a>
<a href="{{ route('give.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) --}}
<a href="{{ route('login') }}" class="btn-primary">Sign in</a>
@include('partials.launcher')
@endauth
</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>
@@ -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
-101
View File
@@ -1,101 +0,0 @@
@php
$landing = (array) config('product_landing');
$logo = (string) ($landing['logo'] ?? '');
$logoPath = $logo !== '' ? public_path($logo) : null;
$platformUrl = (string) ($landing['platform_url'] ?? 'https://ladill.com');
$marketingUrl = (string) ($landing['marketing_url'] ?? $platformUrl);
$signInUrl = route('sso.connect', [
'redirect' => route($landing['dashboard_route'] ?? 'login'),
'interactive' => 1,
]);
$variant = (string) ($landing['landing_variant'] ?? 'default');
@endphp
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{{ $landing['name'] ?? config('app.name') }} Ladill</title>
<meta name="description" content="{{ $landing['description'] ?? '' }}">
@include('partials.favicon')
<link rel="canonical" href="{{ $marketingUrl }}">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
@vite(['resources/css/app.css'])
</head>
<body class="min-h-screen bg-slate-950 font-sans text-white antialiased">
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(28,117,188,.35),transparent_50%),radial-gradient(circle_at_bottom_right,rgba(217,236,103,.12),transparent_45%)]"></div>
<header class="relative z-10 border-b border-white/10">
<div class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
@if ($logo !== '')
<img src="{{ asset($logo) }}?v={{ $logoPath && is_file($logoPath) ? filemtime($logoPath) : '1' }}"
alt="{{ $landing['name'] ?? config('app.name') }}"
class="h-7 w-auto">
@else
<span class="text-sm font-semibold">{{ $landing['name'] ?? config('app.name') }}</span>
@endif
<div class="flex items-center gap-2">
<a href="{{ $platformUrl }}" class="hidden rounded-lg px-3 py-2 text-sm font-medium text-slate-300 transition hover:text-white sm:inline-flex">ladill.com</a>
<a href="{{ $signInUrl }}" class="rounded-lg border border-white/20 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/10">Sign in</a>
</div>
</div>
</header>
<main class="relative z-10 mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<div class="grid items-center gap-12 lg:grid-cols-2">
<div>
<p class="text-sm font-semibold uppercase tracking-wider text-[#d9ec67]">{{ $landing['tagline'] ?? '' }}</p>
<h1 class="mt-4 text-4xl font-bold tracking-tight sm:text-5xl">{{ $landing['headline'] ?? ($landing['name'] ?? '') }}</h1>
<p class="mt-5 text-base leading-relaxed text-slate-300 sm:text-lg">{{ $landing['description'] ?? '' }}</p>
<div class="mt-8 flex flex-wrap gap-3">
<a href="{{ $signInUrl }}" class="inline-flex items-center justify-center rounded-xl bg-[#d9ec67] px-6 py-3.5 text-sm font-bold text-slate-950 transition hover:bg-[#e5f278]">
{{ $variant === 'webmail' ? 'Sign in to webmail' : 'Get started' }}
</a>
@if ($variant === 'webmail')
<a href="{{ route('webmail.login.manual') }}" class="inline-flex items-center justify-center rounded-xl border border-white/20 px-6 py-3.5 text-sm font-semibold text-white transition hover:bg-white/10">
Manual login
</a>
@else
<a href="{{ $landing['register_url'] ?? $platformUrl.'/register' }}" class="inline-flex items-center justify-center rounded-xl border border-white/20 px-6 py-3.5 text-sm font-semibold text-white transition hover:bg-white/10">
Create account
</a>
@endif
</div>
<p class="mt-5 text-sm text-slate-400">
<a href="{{ $marketingUrl }}" class="font-medium text-slate-200 underline decoration-white/20 underline-offset-2 hover:text-white">Learn more on ladill.com</a>
</p>
</div>
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur sm:p-8">
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400">Why {{ $landing['name'] ?? 'Ladill' }}</p>
<div class="mt-5 space-y-4">
@foreach (($landing['benefits'] ?? []) as $benefit)
<div class="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
<h2 class="text-sm font-semibold text-white">{{ $benefit['title'] }}</h2>
<p class="mt-1.5 text-sm leading-relaxed text-slate-400">{{ $benefit['text'] }}</p>
</div>
@endforeach
</div>
@if (! empty($landing['use_cases']))
<div class="mt-6 border-t border-white/10 pt-6">
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400">Ideal for</p>
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($landing['use_cases'] as $useCase)
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-slate-200">{{ $useCase }}</span>
@endforeach
</div>
</div>
@endif
</div>
</div>
</main>
<footer class="relative z-10 border-t border-white/10 py-6 text-center text-xs text-slate-500">
Part of the <a href="{{ $platformUrl }}" class="font-medium text-slate-300 hover:text-white">Ladill</a> platform one account for every app.
</footer>
</body>
</html>
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Payment</title>
<style>
body { font-family: system-ui, sans-serif; background: #f8fafc; color: #0f172a; display: grid; place-items: center; min-height: 100vh; margin: 0; }
p { font-size: 0.95rem; color: #475569; }
</style>
</head>
<body>
<p>Confirming your payment…</p>
<script>
(function () {
var url = @json($redirect);
try {
if (window.opener && !window.opener.closed) {
window.opener.location.replace(url);
window.close();
return;
}
} catch (e) {}
try {
if (window.top && window.top !== window.self) {
window.top.location.replace(url);
return;
}
} catch (e) {}
window.location.replace(url);
})();
</script>
</body>
</html>
@@ -111,7 +111,7 @@
<div id="toolbar">
<span class="tb-label">{{ $qrCode->label }}</span>
@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">
<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>
@@ -44,7 +44,9 @@
</div>
@endif
@if($registration->attendee_email)
<p class="mt-5 text-xs text-slate-400">A receipt was sent to {{ $registration->attendee_email }}.</p>
@endif
@else
<p class="text-sm text-slate-500">Hi <span class="font-semibold text-slate-800">{{ $registration->attendee_name }}</span>, your spot is confirmed.</p>
@@ -64,7 +66,11 @@
@endforeach
</div>
@if($registration->attendee_email)
<p class="mt-5 text-xs text-slate-400">Show this badge code at check-in. A confirmation was sent to {{ $registration->attendee_email }}.</p>
@else
<p class="mt-5 text-xs text-slate-400">Show this badge code at check-in.</p>
@endif
@endif
</div>
</div>
+48 -43
View File
@@ -3,8 +3,8 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@include('partials.favicon')
<title>{{ $qrCode->label }}</title>
@include('partials.favicon')
<meta name="description" content="{{ $qrCode->typeLabel() }} — {{ $qrCode->label }}">
@vite(['resources/css/app.css'])
</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"/>
</svg>
</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">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
@@ -32,7 +32,7 @@
</svg>
View document
</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">
<svg class="h-5 w-5 text-slate-500" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"/>
@@ -62,7 +62,7 @@
{{-- ===== VCARD ===== --}}
@elseif($type === \App\Models\QrCode::TYPE_VCARD)
@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'] ?? [];
$social = [];
foreach ($rawSocial as $platform => $url) {
@@ -159,8 +159,8 @@
{{-- Save contact button --}}
<div class="px-6 pb-6 pt-2">
<a href="{{ route('qr.public.vcard', $qrCode->short_code) }}"
class="flex w-full items-center justify-center gap-2 rounded-2xl bg-indigo-600 py-3.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700">
<a href="{{ $qrCode->publicPath('vcard.vcf') }}"
class="btn-primary w-full">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/></svg>
Save Contact
</a>
@@ -178,8 +178,8 @@
$bizColor = $content['brand_color'] ?? '#1e3a5f';
$bizHasLogo = !empty($content['logo_path']);
$bizHasCover = !empty($content['cover_path']);
$bizLogoUrl = $bizHasLogo ? route('qr.public.business.logo', $qrCode->short_code) : null;
$bizCoverUrl = $bizHasCover ? route('qr.public.business.cover', $qrCode->short_code) : null;
$bizLogoUrl = $bizHasLogo ? $qrCode->publicPath('business-logo') : null;
$bizCoverUrl = $bizHasCover ? $qrCode->publicPath('business-cover') : null;
$bizInitials = strtoupper(substr($bizName, 0, 1));
$bizRawSocial = $content['social'] ?? [];
$bizSocial = [];
@@ -313,10 +313,10 @@
$churchColor = $content['brand_color'] ?? '#1a3a5c';
$churchHasLogo = !empty($content['logo_path']);
$churchHasCover = !empty($content['cover_path']);
$churchLogoUrl = $churchHasLogo ? route('qr.public.church.logo', $qrCode->short_code) : null;
$churchCoverUrl = $churchHasCover ? route('qr.public.church.cover', $qrCode->short_code) : null;
$churchLogoUrl = $churchHasLogo ? $qrCode->publicPath('church-logo') : null;
$churchCoverUrl = $churchHasCover ? $qrCode->publicPath('church-cover') : null;
$churchInitials = strtoupper(substr($churchName, 0, 2));
$churchAcceptsPay = true;
$churchAcceptsPay = !empty($content['accepts_payment']);
$churchCurrency = $content['currency'] ?? 'GHS';
$churchTypes = $content['collection_types'] ?? ['offering', 'tithe', 'donation', 'harvest'];
// Normalise legacy lowercase slugs → display strings
@@ -325,7 +325,7 @@
$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();
@endphp
<div x-data="{
@@ -338,12 +338,15 @@
errorMsg: '',
showSheet: false,
checkoutUrl: '',
accessCode: '',
publicKey: '',
returnUrl: '',
async give() {
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.email.trim() || !this.email.includes('@')) { this.errorMsg = 'Enter a valid email.'; return; }
if (!this.phone.trim()) { this.errorMsg = 'Enter your phone number.'; return; }
this.errorMsg = ''; this.loading = true;
this.errorMsg = ''; this.loading = true; window.LadillPayCheckout?.prepare?.();
const labels = @js($churchTypeLabels);
try {
const res = await fetch(@js($churchOrderRoute), {
@@ -357,16 +360,14 @@
}),
});
const data = await res.json();
if (data.error) { this.errorMsg = data.error; this.loading = false; return; }
if (window.innerWidth < 768) {
if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; }
this.checkoutUrl = data.checkout_url;
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
this.showSheet = true;
this.loading = false;
} else {
window.location.href = data.checkout_url;
}
} catch(e) {
this.errorMsg = 'Network error. Please try again.';
window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
this.loading = false;
}
}
@@ -631,8 +632,8 @@
$evColor = $content['brand_color'] ?? '#4f46e5';
$evHasLogo = !empty($content['logo_path']);
$evHasCover = !empty($content['cover_path']);
$evLogoUrl = $evHasLogo ? route('qr.public.event.logo', $qrCode->short_code) : null;
$evCoverUrl = $evHasCover ? route('qr.public.event.cover', $qrCode->short_code) : null;
$evLogoUrl = $evHasLogo ? $qrCode->publicPath('event-logo') : null;
$evCoverUrl = $evHasCover ? $qrCode->publicPath('event-cover') : null;
$evName = $content['name'] ?? $qrCode->label;
$evInitials = strtoupper(mb_substr($evName, 0, 2));
$evTiers = $content['tiers'] ?? [];
@@ -642,7 +643,7 @@
$evMode = in_array($content['mode'] ?? 'ticketing', ['contributions', 'free'], true) ? ($content['mode'] ?? 'ticketing') : 'ticketing';
$evCategories = $content['contribution_categories'] ?? ['Contribution'];
$evHeading = $evMode === 'contributions' ? 'Make a contribution' : 'Register';
$evRegRoute = route('qr.public.event.register', $qrCode->short_code);
$evRegRoute = $qrCode->publicPath('register');
$evCsrf = csrf_token();
@endphp
<div x-data="{
@@ -661,7 +662,7 @@
if (this.mode === 'contributions' && !(parseFloat(this.amount) > 0)) { this.errorMsg = 'Enter a contribution amount.'; return; }
if (!this.name.trim()) { this.errorMsg = 'Enter your full name.'; return; }
if (!this.email.trim() || !this.email.includes('@')) { this.errorMsg = 'Enter a valid email.'; return; }
this.errorMsg = ''; this.loading = true;
this.errorMsg = ''; this.loading = true; window.LadillPayCheckout?.prepare?.();
try {
const res = await fetch(@js($evRegRoute), {
method: 'POST',
@@ -669,11 +670,14 @@
body: JSON.stringify({ tier: this.tier, amount: this.mode === 'contributions' ? this.amount : null, attendee_name: this.name, attendee_email: this.email, attendee_phone: this.phone, badge_fields: this.fields }),
});
const data = await res.json();
if (data.error) { this.errorMsg = data.error; this.loading = false; return; }
if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; 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; }
else { window.location.href = data.checkout_url; }
} catch(e) { this.errorMsg = 'Network error. Please try again.'; this.loading = false; }
this.checkoutUrl = data.checkout_url;
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
this.showSheet = true;
} catch(e) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.'; this.loading = false; }
}
}" class="min-h-screen bg-slate-50 pb-12">
@@ -887,7 +891,7 @@
@php
$itinColor = $content['brand_color'] ?? '#b45309';
$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;
$itinDays = $content['days'] ?? [];
$multiDay = count($itinDays) > 1;
@@ -1029,7 +1033,7 @@
<h1 class="text-2xl font-bold text-slate-900">{{ $qrCode->label }}</h1>
<div class="mt-6 space-y-4">
@foreach($content['images'] ?? [] as $index => $image)
<img src="{{ route('qr.public.image', [$qrCode->short_code, $index]) }}"
<img src="{{ $qrCode->publicPath('images/'.$index) }}"
alt="{{ $image['title'] ?? 'Image ' . ($index + 1) }}"
class="w-full rounded-2xl border border-slate-200 shadow-sm"
loading="lazy">
@@ -1039,7 +1043,7 @@
{{-- ===== 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="w-full max-w-sm text-center">
@if($appIconUrl)
@@ -1132,14 +1136,14 @@
$sections = $content['sections'] ?? [];
$title = $content['title'] ?? $qrCode->label;
$acceptsPayment = (bool) ($content['accepts_payment'] ?? false);
$orderRoute = route('qr.public.order', $qrCode->short_code);
$orderRoute = $qrCode->publicPath('order');
$csrfToken = csrf_token();
$paystackPublicKey = $acceptsPayment ? app(\App\Services\Billing\PaystackService::class)->publicKey() : '';
$brandColor = $content['brand_color'] ?? ($isShop ? '#7c3aed' : '#f97316');
$hasLogo = !empty($content['logo_path']);
$hasCover = !empty($content['cover_path']);
$logoUrl = $hasLogo ? route('qr.public.menu.logo', $qrCode->short_code) : null;
$coverUrl = $hasCover ? route('qr.public.menu.cover', $qrCode->short_code) : null;
$logoUrl = $hasLogo ? $qrCode->publicPath('menu-logo') : null;
$coverUrl = $hasCover ? $qrCode->publicPath('menu-cover') : null;
$shippingType = $content['shipping_type'] ?? 'none';
$shippingFee = (float) ($content['shipping_fee'] ?? 0);
$freeShippingAbove = (float) ($content['free_shipping_above'] ?? 0);
@@ -1157,6 +1161,9 @@
errorMsg: '',
showSheet: false,
checkoutUrl: '',
accessCode: '',
publicKey: '',
returnUrl: '',
shippingType: @js($shippingType),
shippingFee: @js($shippingFee),
freeAbove: @js($freeShippingAbove),
@@ -1189,7 +1196,7 @@
if (!this.email.trim() || !this.email.includes('@')) { this.errorMsg = 'Enter a valid email address.'; return }
if (!this.phone.trim()) { this.errorMsg = 'Enter your phone number.'; return }
this.errorMsg = '';
this.loading = true;
this.loading = true; window.LadillPayCheckout?.prepare?.();
const payload = {
customer_name: this.name,
customer_email: this.email,
@@ -1204,16 +1211,14 @@
body: JSON.stringify(payload),
});
const data = await res.json();
if (data.error) { this.errorMsg = data.error; this.loading = false; return; }
if (window.innerWidth < 768) {
if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; }
this.checkoutUrl = data.checkout_url;
this.accessCode = data.access_code || '';
this.publicKey = data.public_key || '';
this.returnUrl = data.callback_url || '';
this.showSheet = true;
this.loading = false;
} else {
window.location.href = data.checkout_url;
}
} catch(e) {
this.errorMsg = 'Network error. Please try again.';
window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
this.loading = false;
}
}
@@ -1291,7 +1296,7 @@
<div class="flex gap-3 overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-100">
{{-- Item image --}}
@if($hasImage)
<img src="{{ route('qr.public.item-image', [$qrCode->short_code, $si, $ii]) }}"
<img src="{{ $qrCode->publicPath('item-image/'.$si.'/'.$ii) }}"
alt="{{ $item['name'] }}"
class="h-[100px] w-[100px] flex-shrink-0 object-cover" loading="lazy">
@endif
+1 -1
View File
@@ -3,7 +3,7 @@
$evColor = $c['brand_color'] ?? '#4f46e5';
$evName = $c['name'] ?? $qrCode->label;
$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';
// Physical badge dimensions (inches)
[$bw, $bh] = match ($size) {
+2 -2
View File
@@ -106,7 +106,7 @@
status: '',
timer: null,
checkUrl: @js(route('events.check-slug')),
baseUrl: @js(\App\Models\QrCode::publicBaseUrl() . '/q'),
baseUrl: @js(\App\Support\LadillLink::baseUrl()),
check() {
clearTimeout(this.timer);
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'">
<span class="flex items-center rounded-l-xl border-r border-slate-200 bg-slate-50 px-3 text-xs text-slate-400 whitespace-nowrap select-none"
:class="status === 'available' ? 'border-green-400' : status === 'taken' || status === 'invalid' ? 'border-red-400' : 'border-slate-200'">
{{ \App\Models\QrCode::publicBaseUrl() }}/q/
{{ \App\Support\LadillLink::baseUrl() }}/
</span>
<input type="text" name="custom_short_code"
x-model="slug"
+4 -4
View File
@@ -38,16 +38,16 @@
</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="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="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($wallet->spendableBalance(), 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 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="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Events</p>
</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="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Registrations</p>
</div>
+16 -4
View File
@@ -2,9 +2,16 @@
<x-slot name="title">Overview</x-slot>
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold text-slate-900">QR Plus</h1>
<p class="mt-1 text-sm text-slate-500">Dynamic utility QR codes links, WiFi, business pages & more.</p>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<h1 class="truncate text-lg font-semibold text-slate-900 lg:text-xl">QR Plus</h1>
<p class="mt-1 hidden text-sm text-slate-500 sm:block">Dynamic utility QR codes links, WiFi, business pages & more.</p>
</div>
@include('partials.mobile-header-btn', [
'href' => route('events.create'),
'label' => 'Create code',
'variant' => 'indigo',
])
</div>
<div class="grid gap-4 sm:grid-cols-3">
<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="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<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>
@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>
+10 -3
View File
@@ -1,7 +1,7 @@
<?php
use App\Http\Controllers\ProductLandingController;
use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\WalletBalanceController;
use App\Http\Controllers\Give\DonationsController;
use App\Http\Controllers\Give\GivingPageController;
use App\Http\Controllers\Give\OverviewController;
@@ -15,7 +15,9 @@ use App\Http\Controllers\Qr\TeamController;
use App\Http\Controllers\SearchController;
use Illuminate\Support\Facades\Route;
Route::get('/', [ProductLandingController::class, 'show'])->name('give.landing');
Route::get('/', fn () => auth()->check()
? redirect()->route('give.dashboard')
: redirect()->route('sso.connect'))->name('give.root');
Route::get('/login', [SsoLoginController::class, 'connect'])->name('login');
Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect');
@@ -23,15 +25,20 @@ Route::get('/sso/callback', [SsoLoginController::class, 'callback'])->name('sso.
Route::post('/logout', [SsoLoginController::class, 'logout'])->name('logout');
Route::get('/sso/logout-bridge', [SsoLoginController::class, 'logoutBridge'])->name('sso.logout-bridge');
Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannelLogout'])->name('sso.logout-frontchannel');
Route::get('/sso/platform-signed-out', [SsoLoginController::class, 'platformSignedOut'])->name('sso.platform-signed-out');
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('give.dashboard') : view('give.signed-out'))->name('give.signed-out');
Route::middleware('redirect.legacy.qr')->group(function () {
Route::get('/q/{shortCode}', [QrScanController::class, 'resolve'])->name('qr.public.resolve');
Route::get('/q/{shortCode}/church-logo', [QrScanController::class, 'churchLogo'])->name('qr.public.church.logo');
Route::get('/q/{shortCode}/church-cover', [QrScanController::class, 'churchCover'])->name('qr.public.church.cover');
Route::post('/q/{shortCode}/order', [DonationController::class, 'store'])->name('qr.public.order');
Route::get('/q/{shortCode}/order/callback', [DonationController::class, 'callback'])->name('qr.public.order.callback');
Route::get('/q/{shortCode}/donation/confirmed/{reference}', [DonationController::class, 'confirmed'])->name('qr.public.donation.confirmed');
});
Route::middleware(['auth'])->group(function () {
Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance');
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread');
Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read');
@@ -0,0 +1,33 @@
<?php
namespace Tests\Feature;
use App\Models\PaymentGatewaySetting;
use App\Models\User;
use App\Services\Billing\PlanEntitlementService;
use App\Services\Payments\MerchantGatewayService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class PaymentGatewayPolicyTest extends TestCase
{
use RefreshDatabase;
public function test_owner_gateway_is_never_used_even_with_saved_keys(): void
{
config(['billing.api_url' => 'https://ladill.com/api/billing', 'billing.api_key' => 'give-test']);
$owner = User::factory()->create(['public_id' => 'usr_give_policy']);
PaymentGatewaySetting::create([
'owner_ref' => $owner->public_id, 'provider' => 'paystack',
'public_key' => 'pk_test_saved', 'secret_key' => 'sk_test_saved', 'is_active' => true,
]);
Http::fake(['*/suite-entitlements*' => Http::response(['data' => [
'effective_plan' => 'enterprise', 'features' => ['custom_payment_gateway'],
]])]);
$this->assertFalse(app(PlanEntitlementService::class)->canUseCustomPaymentGateway($owner));
$this->assertFalse(app(MerchantGatewayService::class)->shouldUseOwnerGateway($owner));
}
}
@@ -0,0 +1,85 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
class ResponsivePaystackSheetTest extends TestCase
{
public function test_checkout_sheet_partial_has_mobile_sheet_and_desktop_modal(): void
{
$html = view('partials.paystack-sheet', [
'audience' => 'buyer',
])->render();
$this->assertStringContainsString('role="dialog"', $html);
$this->assertStringContainsString('aria-modal="true"', $html);
$this->assertStringContainsString('data-ladill-pay-sheet', $html);
$this->assertStringContainsString('data-ladill-pay-panel', $html);
$this->assertStringContainsString('data-ladill-pay-backdrop', $html);
$this->assertStringContainsString('data-ladill-pay-handle', $html);
$this->assertStringContainsString('md:items-center', $html);
$this->assertStringContainsString('rounded-t-2xl', $html);
$this->assertStringContainsString('md:rounded-2xl', $html);
$this->assertStringContainsString('md:max-w-lg', $html);
$this->assertStringContainsString('data-paystack-live', $html);
$this->assertStringContainsString('data-ladill-pay-close-live', $html);
$this->assertStringContainsString('data-ladill-pay-header-minimal', $html);
$this->assertStringContainsString('Complete your payment', $html);
$this->assertStringContainsString('safe-area-inset-bottom', $html);
$this->assertStringContainsString('LadillPayCheckout', $html);
$this->assertStringContainsString('resumeTransaction', $html);
$this->assertStringContainsString("\$watch('showSheet'", $html);
$this->assertStringContainsString('js.paystack.co/v2/inline.js', $html);
$this->assertStringContainsString('items-end justify-center md:items-center', $html);
$this->assertStringContainsString('rounded-t-2xl bg-white shadow-2xl md:max-w-lg md:rounded-2xl', $html);
$this->assertStringContainsString('ensurePaystackIframePermissions', $html);
$this->assertStringContainsString('installIframeAllowHook', $html);
$this->assertStringContainsString('fullscreen *', $html);
$this->assertStringNotContainsString("attributeFilter: ['allow', 'src', 'id', 'name', 'style', 'class']", $html);
$this->assertStringContainsString('childList only', $html);
$this->assertStringContainsString('Only write attributes when they actually change', $html);
$this->assertStringContainsString('Do not watch iframes or preload Paystack on every page', $html);
$this->assertStringContainsString('data-ladill-pay-mount', $html);
$this->assertStringContainsString('data-ladill-pay-body', $html);
$this->assertStringNotContainsString('styleCheckoutPinnedToRect', $html);
$this->assertStringNotContainsString('waitForPinTarget', $html);
$this->assertStringNotContainsString('placeCheckoutInMount', $html);
$this->assertStringNotContainsString('installAppendHook', $html);
$this->assertStringNotContainsString('__ladillContainPaystack', $html);
$this->assertStringNotContainsString('Node.prototype.appendChild', $html);
$this->assertStringNotContainsString('data-ladill-pay-inline-status', $html);
$this->assertStringNotContainsString('Please wait', $html);
$this->assertStringContainsString('Payment UI did not open', $html);
$this->assertStringContainsString('ladill-pay-opened', $html);
$this->assertStringContainsString('Only lock page scroll when OUR shell is visible', $html);
$this->assertStringContainsString('releaseBusy', $html);
$this->assertStringContainsString('markOpened', $html);
$this->assertStringNotContainsString('Continue to Paystack', $html);
$this->assertStringNotContainsString('window.open(', $html);
$this->assertStringNotContainsString('hidden w-full max-w-lg', $html);
$this->assertStringNotContainsString('Paystack checkout', $html);
$this->assertStringNotContainsString('bg-transparent shadow-none', $html);
$this->assertStringNotContainsString('not in a separate browser', $html);
}
public function test_payment_return_escapes_popup_and_iframe_to_opener_or_top(): void
{
$view = view()->exists('partials.payment-return')
? 'partials.payment-return'
: 'public.payment-return';
$html = view($view, [
'redirect' => 'https://example.test/paid',
])->render();
$this->assertStringContainsString('Confirming your payment', $html);
$this->assertStringContainsString('window.opener', $html);
$this->assertStringContainsString('window.top.location.replace', $html);
$this->assertTrue(
str_contains($html, 'https://example.test/paid')
|| str_contains($html, 'https:\/\/example.test\/paid')
);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Tests\Unit\Models;
use App\Models\QrCode;
use Tests\TestCase;
class QrCodeLandingTest extends TestCase
{
public function test_church_giving_pages_use_landing_page(): void
{
$qr = new QrCode(['type' => QrCode::TYPE_CHURCH]);
$this->assertTrue($qr->usesLandingPage());
}
}