122 Commits
Author SHA1 Message Date
isaacclad a2b1dc7054 Let hero stats cards auto-expand to fit long values.
Deploy Ladill Events / deploy (push) Successful in 36s
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:06 +00:00
isaacclad 25ce2bc9de Retire Events subscriptions and owner payment gateways.
Deploy Ladill Events / deploy (push) Successful in 55s
Make Ladill Events free for all accounts, remove plan upgrade UI, force Ladill Pay only for tickets and contributions, and reinstate standard platform fees.
2026-07-24 14:15:34 +00:00
isaacclad ce5ef67a7c Stop Paystack pre-modal flash on customer checkout.
Deploy Ladill Events / deploy (push) Successful in 1m25s
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 7f65204127 Simplify Paystack checkout handoff
Deploy Ladill Events / deploy (push) Successful in 47s
2026-07-24 08:25:35 +00:00
isaacclad 863cad503c Simplify pay wait copy and scale Paystack to fill the loading area.
Deploy Ladill Events / deploy (push) Successful in 1m39s
Use “Please wait…” while loading, and scale the contained Paystack iframe so
checkout fills the sheet body without a floating second card.
2026-07-23 22:57:17 +00:00
isaacclad 2b5644df7e Render Paystack flush in the sheet loading area without a second stage.
Deploy Ladill Events / 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:36 +00:00
isaacclad 3f4149c9a4 Force Paystack checkout into the sheet mount on first insert.
Deploy Ladill Events / deploy (push) Successful in 1m15s
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:48 +00:00
isaacclad 2fafc5e640 Unify payment sheet to one POS design across all apps.
Deploy Ladill Events / deploy (push) Successful in 1m14s
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:33 +00:00
isaacclad daf40f831b Tighten payment sheet UI to match POS single-surface checkout.
Deploy Ladill Events / deploy (push) Successful in 47s
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:59 +00:00
isaacclad b8b6521058 Fix proxied checkout CSRF for Merchant, Give, Invoice and Events.
Deploy Ladill Events / deploy (push) Successful in 35s
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:28 +00:00
isaaccladandCursor 1bcbdbfdb1 Make Events payment routing plan-aware.
Deploy Ladill Events / deploy (push) Successful in 51s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 19:47:10 +00:00
isaacclad 837c249634 Restore Paystack Inline in-page sheet/modal; stop separate-browser checkout.
Deploy Ladill Events / deploy (push) Successful in 1m6s
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 14c17fffa7 Keep Paystack sheet/modal visible; open Paystack from Continue.
Deploy Ladill Events / deploy (push) Successful in 34s
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 d7ce7459a5 Open Paystack from the Pay click gesture, not after fetch.
Deploy Ladill Events / deploy (push) Successful in 57s
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:33 +00:00
isaacclad 51244c7bad Fix Paystack sheet: stop iframe embeds that Paystack blocks.
Deploy Ladill Events / deploy (push) Successful in 37s
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:35 +00:00
isaacclad 183f0305ce Present Paystack checkout in a responsive mobile sheet and desktop modal.
Deploy Ladill Events / deploy (push) Successful in 1m30s
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 7b20b71ea0 Route event ticket payments through centralized Ladill Pay.
Deploy Ladill Events / deploy (push) Successful in 45s
Replace per-owner gateway checkout with platform Paystack via the Pay API, drop owner key requirements from settings, and add coverage for init and idempotent completion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 13:40:13 +00:00
isaaccladandCursor 29da6f12a7 Sync DemoWorld specialty staff ownerEmailForStaff fix.
Deploy Ladill Events / deploy (push) Successful in 53s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 15:29:19 +00:00
isaaccladandCursor c3be1b9132 Sync DemoWorld: Care receptionist without queue; add Queue operators.
Deploy Ladill Events / deploy (push) Successful in 3m31s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 10:17:32 +00:00
isaaccladandCursor 701cffb143 Seed DemoWorld attendees and speakers for Events demo continuity
Deploy Ladill Events / deploy (push) Successful in 1m18s
Attendee registrations, programme hosts, and event speakers now draw
name/email/phone/company from DemoWorld people instead of generic
placeholders, and staff logins skip the login reseed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:40:15 +00:00
isaacclad 11656eaad1 Fix deploy tmp paths: drop unset gitea.run_attempt.
Deploy Ladill Events / deploy (push) Successful in 1m13s
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:50 +00:00
isaaccladandCursor da5fc51262 Reseed demo tenants after each SSO login.
Deploy Ladill Events / deploy (push) Successful in 2m18s
Demo Free/Pro/Enterprise accounts wipe and reload sample data on
authorization-code SSO callbacks so every sales walkthrough starts clean.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 11:19:04 +00:00
isaaccladandCursor 66e6e117de Add plan-capped Events demo:seed command.
Deploy Ladill Events / deploy (push) Successful in 2m20s
Seed live events, registrations, and Pro subscriptions without billing APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:03:03 +00:00
isaacclad a7231ab16f fix(email): use client/org name as From display, not Ladill product
Deploy Ladill Events / deploy (push) Successful in 45s
Customer-facing outbound mail should appear from the business (organizer,
clinic, company, location), not the Ladill product brand.
2026-07-16 12:01:06 +00:00
isaacclad a5ebd1e414 fix(events): point Messaging settings at suite defaults, not API keys
Deploy Ladill Events / deploy (push) Successful in 48s
Mirror Care: suite platform keys count as ready for attendee email/SMS;
Account Messaging is primary, product Bird/SMS keys are advanced.
SmsService now prefers channel=suite before customer API keys.
2026-07-16 11:36:43 +00:00
isaacclad 3d5ffa593d feat(messaging): suite-channel email/SMS and entitlement sync
Deploy Ladill Events / deploy (push) Successful in 48s
Prefer platform suite messaging (channel=suite) for attendee email, fall back
to Bird integrations; sync suite plan entitlements on subscribe/renew.
2026-07-16 11:24:21 +00:00
isaacclad b73b071cb1 Gate payment gateway behind Pro and Business plans.
Deploy Ladill Events / deploy (push) Successful in 46s
Free accounts can no longer connect or use Paystack/Flutterwave/Hubtel;
settings show an upgrade path and checkout requires a paid subscription.
2026-07-15 21:04:03 +00:00
isaacclad 6a793548ad Raise suite Pro and Business/Enterprise prices tenfold.
Deploy Ladill Events / deploy (push) Successful in 1m3s
Free tiers stay free. Paid defaults and marketing copy use the new GHS amounts (e.g. Pro 79→790, Business 249→2490).
2026-07-15 13:53:48 +00:00
isaacclad 69ede57c03 Frame event checkout sheet for ticket buyers.
Deploy Ladill Events / deploy (push) Successful in 1m41s
Use buyer-facing copy, attendee email for gateway receipts, and keep operator wording only for staff wallet top-up.
2026-07-15 13:23:36 +00:00
isaaccladandCursor ab192e443f Embed event checkouts and send seller email to gateways.
Deploy Ladill Events / deploy (push) Successful in 1m42s
Ticket/contribution payments open in a desktop modal or mobile sheet, initialize with the organizer email, and break out of the iframe after callback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 08:29:13 +00:00
isaaccladandCursor 6b2d97967a Fix Events /pro 500 and align Plans UI with Accounting.
Deploy Ladill Events / deploy (push) Successful in 2m1s
Use the Events user layout, match the Accounting/Invoice upgrade sidebar CTA, and tolerate a missing subscriptions table until migrate runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 08:14:07 +00:00
isaaccladandCursor 4c87c786da Add Events Pro/Business and BYO ticket gateways.
Deploy Ladill Events / deploy (push) Successful in 42s
Cut ticket checkouts off Ladill Pay, settle to merchant gateways at 0% platform fee, and mirror Invoice freemium pricing (GHS 49 / 149).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 01:26:28 +00:00
isaaccladandCursor dda52722ce Add Sent via Ladill Events to customer email footers.
Deploy Ladill Events / deploy (push) Successful in 43s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 20:14:19 +00:00
isaaccladandCursor b5debd79c4 Add account company logo for customer event emails.
Deploy Ladill Events / deploy (push) Successful in 1m39s
Upload logo in account settings and prefer it over Ladill product marks in event notification headers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 20:07:55 +00:00
isaaccladandCursor 407f1364fb Use generic copy on messaging Integrations screens.
Deploy Ladill Events / deploy (push) Successful in 44s
Drop wallet/brand marketing language so credential setup reads as neutral configuration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 18:40:05 +00:00
isaaccladandCursor d14180b335 Replace native browser confirms with Ladill confirm dialogs.
Deploy Ladill Events / deploy (push) Successful in 42s
Integrations disconnect and custom-domain removal use the shared modal UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 17:51:22 +00:00
isaaccladandCursor 6e42cd1cb2 Add per-customer SMS and Bird Integrations for event messaging.
Deploy Ladill Events / deploy (push) Successful in 28s
Attendee and speaker comms use encrypted tenant relay credentials and skip platform-key fallback and Bird wallet double-debit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 17:05:54 +00:00
isaacclad 13b2bd9702 Remove desktop header search from app topbars.
Deploy Ladill Events / deploy (push) Successful in 1m7s
Drop the lg+ topbar search field; mobile search entry points are unchanged.
2026-07-09 06:57:33 +00:00
isaacclad 8fcb4b8f8c Sync app launcher from registry
Deploy Ladill Events / deploy (push) Successful in 55s
2026-07-07 23:12:48 +00:00
isaacclad d6957a1618 Show boot splash on app open/reload for logged-in users.
Deploy Ladill Events / deploy (push) Successful in 36s
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:07 +00:00
isaacclad dbaea55cfd Sync app launcher from registry
Deploy Ladill Events / deploy (push) Successful in 41s
2026-07-07 14:41:51 +00:00
isaacclad ce26d2cf87 Prefer system npm in deploy CI when portable node-gyp is broken.
Deploy Ladill Events / deploy (push) Successful in 37s
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 eb2273242b Sync app launcher from registry
Deploy Ladill Events / deploy (push) Failing after 7s
2026-07-07 00:20:52 +00:00
isaaccladandCursor 6cf069d630 Return programme snapshot from Events link-room API.
Deploy Ladill Events / deploy (push) Successful in 31s
Meet can persist the agenda locally when linking, even if the service-to-service Meet sync fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 22:25:12 +00:00
isaaccladandCursor 422c7e01bf Fix Meet programme sync when linking an existing room.
Deploy Ladill Events / deploy (push) Successful in 37s
PATCH the pre-linked room by UUID so programme_snapshot lands on the room users join instead of creating a duplicate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 22:12:12 +00:00
isaaccladandCursor 129dd04bea Remove event search from Attendees, Badge printing, and Speakers hubs.
Deploy Ladill Events / deploy (push) Successful in 31s
These pages list all events directly without a redundant search filter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 22:06:27 +00:00
isaaccladandCursor f7ee8dc322 List Programmes after Events as a top-level sidebar item.
Deploy Ladill Events / deploy (push) Successful in 46s
Restore flat nav ordering instead of nesting Programmes under Events.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 12:53:42 +00:00
isaaccladandCursor 8ead108a27 Nest Programmes under Events in sidebar and add programmes index hero.
Deploy Ladill Events / deploy (push) Successful in 44s
Match the Events list layout with balance, programme, and session stats so programme management feels consistent with the rest of the app.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 12:49:21 +00:00
isaaccladandCursor 71ab6e9d0e Remove QR Plus customization hint from event and programme show pages.
Deploy Ladill Events / deploy (push) Successful in 59s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 12:42:55 +00:00
isaaccladandCursor dac095ef92 Align programme flow with Events: system-generated QR, no customizer.
Deploy Ladill Events / deploy (push) Successful in 31s
Replace the QR-first create and edit experience with simple programme forms that auto-generate Ladill Link codes like events do.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 12:39:11 +00:00
isaaccladandCursor a00f032b5b Fix Events mail calendar sync when virtual sessions lack ids.
Deploy Ladill Events / deploy (push) Successful in 52s
Generate session ids before upserting and fall back to the main event date so August events reach Mail calendar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 12:27:48 +00:00
isaaccladandCursor d25682bc6c Complete Ladill Pay payouts UI for Events.
Deploy Ladill Events / deploy (push) Successful in 38s
Show per-event payment history with fees and net amounts, and let organizers manage payout accounts and withdrawals without leaving the app.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 12:06:43 +00:00
isaaccladandCursor 0aafb14836 Remove test QR artifacts accidentally committed with calendar sync.
Deploy Ladill Events / deploy (push) Successful in 34s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 11:49:07 +00:00
isaaccladandCursor 9cdfc331b0 Sync Ladill Events to the Mail utility calendar.
Events upsert calendar entries by stable external_ref keys, prefer the
owner's linked mailbox, and Meet skips mail sync for events-sourced rooms
so linked virtual sessions appear only once on the calendar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 11:48:37 +00:00
isaacclad c875a64fe4 Use mobile icon buttons on Events and QR dashboards.
Deploy Ladill Events / deploy (push) Successful in 32s
Add header create actions and panel icon links; sync mobile notification badge partial.
2026-07-03 22:41:00 +00:00
isaaccladandCursor 0cf3ef5de0 Personalize speaker portal greeting with session topic.
Deploy Ladill Events / deploy (push) Successful in 26s
Replace the roster role line with "you are speaking on …" using the speaker's programme assignment or virtual session title.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 17:56:39 +00:00
isaaccladandCursor 157165954b Simplify speaker invite email and restore dark logo header.
Deploy Ladill Events / deploy (push) Successful in 1m49s
Remove the What to expect checklist from speaker invitations and use the dark gradient header so the white Events email logo reads clearly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 17:39:52 +00:00
isaaccladandCursor 5662ac9454 Use PNG Ladill Events logo in all transactional email templates.
Deploy Ladill Events / deploy (push) Successful in 1m7s
Load ladillevents-logo-email.png via asset() on a light header and extend the branded layout to join, registration, and programme emails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 17:30:21 +00:00
isaaccladandCursor 1746874aaa Use noreply@ladill.com for Events mail like other Ladill apps.
Deploy Ladill Events / deploy (push) Successful in 49s
Stop overriding the From display name with the event title; rely on MAIL_FROM_ADDRESS and MAIL_FROM_NAME only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 17:20:45 +00:00
isaaccladandCursor 0c5a1ddb23 Send Events mail from platform SMTP like Invoice, not owner Bird domain.
Deploy Ladill Events / deploy (push) Successful in 31s
Use EventMailer with MAIL_FROM and organiser Reply-To instead of the Bird API, which rewrote the From address onto the account's verified domain (e.g. climp.me). Redesign the speaker invitation to use the shared Events email layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 17:09:05 +00:00
isaaccladandCursor 1db499ba2f Surface platform SMTP errors on speaker invitation failures.
Deploy Ladill Events / deploy (push) Successful in 44s
Return the Bird API error instead of a generic wallet message so domain and balance issues are actionable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 16:23:09 +00:00
isaaccladandCursor 99ffc510f9 Improve speaker invites and redesign roster add flow.
Deploy Ladill Events / deploy (push) Successful in 41s
Surface specific send failures, allow resend, use ladl.link portal URLs, and replace stacked speaker forms with a single add-to-list pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 16:01:47 +00:00
isaaccladandCursor fec24da7bd Add speaker invitations with portal page and programme host picker.
Deploy Ladill Events / deploy (push) Successful in 47s
Speakers require email, get a personal holding page with programme and stage links, auto-invites when programme hosts are saved, and manual send for events without a schedule; also fix wallet copy on event create and anchor attendee comms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 15:48:32 +00:00
isaaccladandCursor 42d997a599 Fix Blade syntax error on event speakers page.
Deploy Ladill Events / deploy (push) Successful in 42s
Split inline @if directives in programme assignment rows so the speakers view compiles and loads without a 500.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 15:18:50 +00:00
isaaccladandCursor 2084cbc852 Cap public landing page logos at half the viewport width on mobile.
Deploy Ladill Events / deploy (push) Successful in 1m26s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 14:58:51 +00:00
isaaccladandCursor 7cce194567 Return Meet attendees to join flow after event registration.
Deploy Ladill Events / deploy (push) Successful in 47s
Add badge verification API, preserve meet_return through registration and payment, and link confirmation back to Meet with badge auto-login.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 14:36:42 +00:00
isaaccladandCursor 9eab538f2e Fix webinar registration redirecting to ladl.link/undefined.
Deploy Ladill Events / deploy (push) Successful in 29s
Exempt public register POST from cross-site CSRF, collect email on the
landing form, and handle API errors instead of navigating to a missing
success_url.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 14:06:37 +00:00
isaaccladandCursor d2ec31f7a5 Fix post-registration 404, add speaker management, and simplify event creation.
Deploy Ladill Events / deploy (push) Successful in 28s
Proxy registration transaction paths on Events, add a join button on the
confirmation page, introduce speaker roster management, and replace the QR
design wizard with merchant-style create/show flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 13:44:53 +00:00
isaaccladandCursor d942ebaa83 Fix event branding images broken by legacy QR redirect.
Deploy Ladill Events / deploy (push) Successful in 25s
Keep logo and cover asset URLs on Events instead of redirecting them to
ladl.link, which does not serve uploaded QR storage files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 08:14:17 +00:00
isaaccladandCursor 32c721b4f2 Add service API for Meet to list and link virtual events.
Deploy Ladill Events / deploy (push) Successful in 1m17s
Meet can attach existing conference or webinar rooms to event virtual sessions so registration stays in Events.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 07:38:56 +00:00
isaaccladandCursor 93272f968a Fix programme attachment dropdown always appearing disabled on events.
Deploy Ladill Events / deploy (push) Successful in 31s
Load itinerary options in the event show controller and link to programme creation when none exist yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 00:10:58 +00:00
isaaccladandCursor e9b51024d9 Add reusable copy-to-clipboard button for Events UI.
Deploy Ladill Events / deploy (push) Successful in 31s
Shared Alpine helper and Blade component with clipboard API fallback for one-click copy feedback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 22:33:09 +00:00
isaaccladandCursor 05a6be7efe Complete Events–Meet integration phases 2–5.
Deploy Ladill Events / deploy (push) Successful in 41s
Programme sync to Meet rooms, wallet-billed mass comms, webhook lifecycle updates, and registration access bridge with join-window gating on public pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 22:31:45 +00:00
isaaccladandCursor 06fedcfc55 Add virtual event sessions with Meet sync and platform-billed messaging.
Deploy Ladill Events / deploy (push) Successful in 41s
Events can define hybrid/virtual sessions synced to Meet rooms; SMS and email use wallet-billed platform APIs instead of Termii and Laravel mail.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 22:08:09 +00:00
isaacclad 5a8a462102 Sync app launcher from registry
Deploy Ladill Events / deploy (push) Successful in 46s
2026-07-01 17:03:51 +00:00
isaaccladandCursor 7d00349847 Throttle platform SSO session checks to cut per-request auth latency.
Deploy Ladill Events / deploy (push) Successful in 1m4s
Verify auth.ladill.com at most once every 90s; recommend Redis for session/cache in .env.example.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 16:50:20 +00:00
isaacclad d5a84c07c0 Add Queue to app launcher config and icon.
Deploy Ladill Events / deploy (push) Successful in 24s
Propagated from ladill monorepo launcher sync.
2026-06-29 22:12:14 +00:00
isaacclad e356757d6c Sync app launcher from registry
Deploy Ladill Events / deploy (push) Successful in 47s
2026-06-29 17:52:20 +00:00
isaaccladandCursor 70316879b2 Cap launcher dropdown at 15 visible apps (5 rows).
Deploy Ladill Events / deploy (push) Successful in 25s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 17:44:25 +00:00
isaaccladandCursor 18bb548c9f Hide launcher down arrow when scrolled to the end.
Deploy Ladill Events / deploy (push) Successful in 37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 14:48:11 +00:00
isaaccladandCursor 7856ec854b Revert launcher scroll rail and fade; keep down arrow hint.
Deploy Ladill Events / deploy (push) Successful in 52s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 14:01:29 +00:00
isaaccladandCursor af9d315c70 Make launcher scroll affordance visible on open.
Deploy Ladill Events / deploy (push) Successful in 1m12s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 13:52:21 +00:00
isaacclad e437aff7bc Sync app launcher from registry
Deploy Ladill Events / deploy (push) Successful in 51s
2026-06-29 13:27:17 +00:00
isaacclad 2cafdb3dc1 Always show launcher scroll indicator and cap at 18 apps.
Deploy Ladill Events / deploy (push) Successful in 1m27s
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:54 +00:00
isaaccladandCursor a015cda2ee Use + icon buttons on mobile dashboards.
Deploy Ladill Events / deploy (push) Successful in 58s
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:56 +00:00
isaaccladandCursor d369de0ce4 Place wallet widget after Billing link in mobile profile sheet.
Deploy Ladill Events / deploy (push) Successful in 1m14s
Remove duplicate Billing header above Home; wallet card follows Billing
in menu order to match the desktop avatar dropdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 14:07:07 +00:00
isaaccladandCursor d9137b47e5 Sync mobile profile wallet card and 24h session idle timeout.
Deploy Ladill Events / deploy (push) Successful in 49s
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 b789e81134 Hide header avatar on mobile and sync shared mobile nav partials.
Deploy Ladill Events / deploy (push) Successful in 41s
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 baf56479cc Sync app launcher from registry — add Frontdesk.
Deploy Ladill Events / deploy (push) Successful in 31s
2026-06-28 01:45:47 +00:00
isaaccladandCursor c6ba7f5604 Sync app launcher from registry
Deploy Ladill Events / deploy (push) Successful in 37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 19:03:57 +00:00
isaaccladandCursor fe6bffaf1d Sync app launcher from registry
Deploy Ladill Events / deploy (push) Successful in 25s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 18:28:03 +00:00
isaaccladandCursor 82259b1fac Redirect legacy /q/* URLs to canonical ladl.link addresses.
Deploy Ladill Events / deploy (push) Successful in 27s
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 cbfb8733ea Wire LadillLink through QrCode model and remaining QR surfaces.
Deploy Ladill Events / deploy (push) Successful in 46s
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 ceaa9ba8dc Use ladl.link URLs for all front-facing QR and short-link surfaces.
Deploy Ladill Events / deploy (push) Successful in 26s
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:17 +00:00
isaacclad 5bc3b3a064 Sync app launcher from registry
Deploy Ladill Events / deploy (push) Successful in 38s
2026-06-27 15:14:49 +00:00
isaaccladandCursor e234889f60 Make guest event registration email-optional like Ladill Mini.
Deploy Ladill Events / deploy (push) Successful in 35s
Attendees without Ladill accounts no longer need email at checkout; phone is required so organizers can reach them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 14:18:45 +00:00
isaaccladandClaude Opus 4.8 c3fa39a053 Surface connected domains in Ladill Domains' My Domains
Deploy Ladill Events / deploy (push) Successful in 55s
Push the customer's connected domain to the central connected-domains registry
so it appears under My Domains (with Transfer in if not registered with Ladill).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:16:57 +00:00
isaaccladandClaude Opus 4.8 75bb72abb3 Fix custom-domain test fixtures (public_id, bypass platform.session)
Deploy Ladill Events / deploy (push) Successful in 39s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:06:27 +00:00
isaaccladandClaude Opus 4.8 8cca4fb6e3 Add opt-in custom domains with automatic SSL
Deploy Ladill Events / deploy (push) Successful in 1m23s
Customers can connect their own domain to a events page (storefront/event):
add a domain, point an A record (apex + www) to the app server, click Verify —
DNS is checked, then Ladill Domains' central SSL service issues + installs the
Let's Encrypt cert and calls back to flip it live. The custom domain then serves
the mapped page (host resolution on /). Feature-gated: only active when a Domains
SSL API key is set, so this deploy is inert until wired.

- custom_domains table + CustomDomain model
- CustomDomainService (DNS verify, request cert), DomainsSslClient, DnsResolver
- settings UI panel, signed SSL callback receiver, host resolution on /
- feature tests (DNS verify/fail, signed callback, ownership)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:59:25 +00:00
5ddf674983 Add Accounting to the app launcher
Deploy Ladill Events / deploy (push) Successful in 1m24s
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:16 +00:00
isaaccladandClaude Opus 4.8 73b9c30562 Add branded boot splash (Ladill Mail style) on app pages
Deploy Ladill Events / 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:00 +00:00
isaaccladandClaude Opus 4.8 ee2af28739 Update POS launcher icon (app launcher + marketing)
Deploy Ladill Events / deploy (push) Successful in 30s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:44:51 +00:00
isaaccladandCursor 1476e4d9e1 Sync app launcher config and add POS icon.
Deploy Ladill Events / deploy (push) Successful in 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 22:53:23 +00:00
isaaccladandCursor 5e37d6103c Standardize desktop topbar widgets to match CRM layout.
Deploy Ladill Events / deploy (push) Successful in 52s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:15:15 +00:00
isaaccladandClaude Opus 4.8 c9bcc58fb0 Add wallet balance widget to the profile menu (after Billing)
Deploy Ladill Events / deploy (push) Successful in 30s
Wallet balance peek rendered as a menu row directly under Billing, backed by
a /wallet/balance endpoint (BillingClient) and guarded by Route::has. Also
opens Afia via a direct window event for reliability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:25:56 +00:00
isaaccladandClaude Opus 4.8 c62d04d333 Add CRM to the Ladill app launcher
Deploy Ladill Events / deploy (push) Successful in 29s
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 5cfc800533 Add Invoice to the app launcher
Deploy Ladill Events / deploy (push) Successful in 23s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:19:11 +00:00
isaaccladandCursor b08c27cf6e Bind app sessions to the platform auth session.
Deploy Ladill Events / deploy (push) Successful in 26s
Clear local sessions when auth.ladill.com signs out and ping the platform session from keepalive.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 08:26:30 +00:00
isaaccladandCursor 88665d3e97 Add Ladill SMS to shared app launcher.
Deploy Ladill Events / deploy (push) Successful in 55s
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 4437dd781d Lower Events platform fees to 3.5% and 5.5%.
Deploy Ladill Events / deploy (push) Successful in 23s
Contributions drop from 5% to 3.5%; tickets drop from 9% to 5.5%.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 19:51:36 +00:00
isaaccladandCursor 60523c6a34 Hide the current page link in the unified profile dropdown.
Deploy Ladill Events / deploy (push) Successful in 48s
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:46 +00:00
isaaccladandCursor 8a4e650347 Fix corrupted profileMenuItems in layouts after menu propagation.
Deploy Ladill Events / deploy (push) Successful in 1m14s
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:50 +00:00
isaaccladandCursor 903b27b5d2 Unify logged-in profile dropdown with platform account menu.
Deploy Ladill Events / deploy (push) Successful in 28s
Wire UserProfileMenu and shared Blade partial into topbar, mobile nav, and layouts so menu items match the account portal (Ladill Mail excluded).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 09:34:00 +00:00
isaaccladandCursor 1fe3f80c09 Remove centralized account nav from app sidebar; redirect legacy URLs.
Deploy Ladill Events / deploy (push) Successful in 1m4s
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:32 +00:00
isaaccladandClaude Opus 4.8 e4df5967c0 Fix package name (ladill-events) + ignore storage/framework build artifacts
Deploy Ladill Events / deploy (push) Successful in 27s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:00:30 +00:00
isaaccladandCursor 632ac64298 Restore silent SSO on app entry for users switching between apps.
Deploy Ladill Events / deploy (push) Successful in 57s
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:17 +00:00
isaaccladandCursor ea612fbc23 Fix guest entry: correct marketing URL and stop auto silent SSO.
Deploy Ladill Events / deploy (push) Successful in 29s
Correct localhost marketing_url default and stop redirecting guests through
/sso/connect on visit; SSO starts only via explicit sign-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 13:09:06 +00:00
isaaccladandCursor 9015039e48 Fix QR code previews not rendering on event and programme pages.
Deploy Ladill Events / deploy (push) Successful in 52s
Remove a duplicate preview ref that targeted a hidden card, enable mobile preview for programmes, and harden customizer init timing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 23:39:47 +00:00
isaaccladandCursor 55ea2d506c Send SSO guests to ladill.com/products/events instead of subdomain landings.
Deploy Ladill Events / deploy (push) Successful in 24s
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:12 +00:00
isaaccladandCursor 77b3f3ccf4 Lower event fees: tickets 15%→9%, contributions 9%→5%.
Deploy Ladill Events / deploy (push) Successful in 32s
Update registration settlement and payouts copy for the new Ladill Pay tiers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 19:32:24 +00:00
isaaccladandClaude Opus 4.8 e0e51d4f9f Afia: render markdown bold in chat instead of showing raw asterisks
Deploy Ladill Events / deploy (push) Successful in 44s
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:49 +00:00
isaaccladandClaude Opus 4.8 86e40bfbd2 Afia: rebrand assistant from QR Plus to Ladill Events
Deploy Ladill Events / deploy (push) Successful in 53s
Events shipped the QR Plus Afia (prompt, greeting, suggestions, and 'QR Plus
assistant' subtitle). Rewrite all for Ladill Events — events, tickets,
attendees, check-in, badges, programmes, payouts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:34:26 +00:00
205 changed files with 13866 additions and 1445 deletions
+48 -2
View File
@@ -17,8 +17,14 @@ DB_DATABASE=ladill_events
DB_USERNAME=ladill_events
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
SESSION_DRIVER=redis
# Idle web session length in minutes (1440 = 24 hours of inactivity).
SESSION_LIFETIME=1440
SESSION_DOMAIN=.ladill.com
LADILL_SSO_CLIENT_ID=
@@ -33,10 +39,50 @@ PAY_API_KEY_EVENTS=
IDENTITY_API_URL=https://ladill.com/api
IDENTITY_API_KEY_EVENTS=
MEET_API_URL=https://meet.ladill.com/api/service/v1
MEET_API_KEY_EVENTS=
MEET_WEBHOOK_SECRET_EVENTS=
# Ladill Mail calendar (sync events to the webmail utility calendar)
LADILL_MAIL_API_URL=https://mail.ladill.com/api/service/v1
MAIL_API_KEY_EVENTS=
# Inbound service API (Meet calls Events)
EVENTS_API_KEY_MEET=
SMS_PLATFORM_API_URL=https://ladill.com/api
SMS_API_KEY_EVENTS=
SMS_CUSTOMER_RELAY_URL=https://ladill.com/api/sms
SMS_DEFAULT_SENDER_ID=Ladill
# Transactional email (platform mailer — same pattern as Invoice)
MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM_ADDRESS=noreply@ladill.com
MAIL_FROM_NAME=Ladill
# Platform suite messaging (zero-config — channel=suite; no Bird API key required)
# Monolith must have MESSAGING_SUITE_PLATFORM_CHANNEL=true and SMTP_API_KEY_EVENTS set.
SMTP_PLATFORM_API_URL=https://ladill.com/api/smtp
SMTP_API_KEY_EVENTS=
SMTP_CUSTOMER_RELAY_URL=https://ladill.com/api/smtp
EVENTS_SMTP_FROM=events@ladill.com
EVENTS_SMTP_FROM_NAME="Ladill Events"
MESSAGING_SETTINGS_URL=https://account.ladill.com/account/settings/messaging?reason=messaging_mailbox_required&from=suite
AFIA_ENABLED=true
AFIA_PRODUCT=events
AFIA_PROVIDER=openai
AFIA_MODEL=gpt-4o-mini
AFIA_API_KEY=
LINK_PUBLIC_DOMAIN=ladl.link
# Suite demo accounts (sales walkthroughs) — reset Events data on each SSO login
DEMO_ACCOUNTS_ENABLED=false
DEMO_RESET_ON_LOGIN=true
VITE_APP_NAME="${APP_NAME}"
+16 -14
View File
@@ -19,8 +19,9 @@ jobs:
env:
NODE_ROOT: /tmp/ladill-node-22-r1
NODE_VERSION: "22.14.0"
RELEASE_ARCHIVE: /tmp/ladill-events-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-events-${{ 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-events-release-${{ gitea.run_id }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-events-${{ gitea.run_id }}
LADILL_APP_ROOT: /var/www/ladill-events
steps:
- name: Checkout
@@ -38,11 +39,17 @@ jobs:
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
"$REPO_URL" "$WORKSPACE"
- name: Setup Node.js
- name: Build frontend assets
shell: bash {0}
run: |
set -Eeuo pipefail
if [ ! -x "$NODE_ROOT/bin/node" ]; then
cd "$WORKSPACE"
if command -v npm >/dev/null 2>&1 && npm -v >/dev/null 2>&1; then
NPM_BIN="$(command -v npm)"
elif [ -x "$NODE_ROOT/bin/npm" ]; then
export PATH="$NODE_ROOT/bin:$PATH"
NPM_BIN="$NODE_ROOT/bin/npm"
else
rm -rf "$NODE_ROOT"
mkdir -p "$NODE_ROOT"
case "$(uname -m)" in
@@ -52,17 +59,12 @@ jobs:
esac
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar -xJ --strip-components=1 -C "$NODE_ROOT"
export PATH="$NODE_ROOT/bin:$PATH"
NPM_BIN="$NODE_ROOT/bin/npm"
fi
"$NODE_ROOT/bin/node" -v
"$NPM_BIN" ci --no-audit --no-fund
"$NPM_BIN" run build
- name: Build frontend assets
shell: bash {0}
run: |
set -Eeuo pipefail
cd "$WORKSPACE"
export PATH="$NODE_ROOT/bin:$PATH"
npm ci --no-audit --no-fund
npm run build
- name: Build release archive
shell: bash {0}
@@ -78,7 +80,7 @@ jobs:
- name: Deploy release
shell: bash {0}
env:
LADILL_RELEASE_ARCHIVE: /tmp/ladill-events-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
LADILL_RELEASE_ARCHIVE: /tmp/ladill-events-release-${{ gitea.run_id }}.tgz
run: |
set -Eeuo pipefail
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
+1
View File
@@ -24,3 +24,4 @@
Homestead.json
Homestead.yaml
Thumbs.db
storage/app/private/qr/
+368
View File
@@ -0,0 +1,368 @@
<?php
namespace App\Console\Commands;
use App\Models\Events\ProSubscription;
use App\Models\PaymentGatewaySetting;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Models\QrTeamMember;
use App\Models\User;
use App\Services\Events\SubscriptionService;
use App\Support\DemoWorld;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Seed plan-aware Events demo data for a suite demo identity.
*/
class DemoSeedCommand extends Command
{
protected $signature = 'demo:seed
{identity : Email or public_id of the local mirrored user}
{--plan=free : free|pro|enterprise}
{--reset : Remove prior demo data for this user before seeding}
{--public-id= : Platform public_id when creating a local mirror}
{--email= : Email when creating a local mirror}
{--name= : Display name when creating a local mirror}';
protected $description = 'Seed Events demo data for a suite demo account';
public function handle(SubscriptionService $subscriptions): int
{
$plan = strtolower((string) $this->option('plan'));
if (! in_array($plan, ['free', 'pro', 'enterprise'], true)) {
$this->error('Plan must be free, pro, or enterprise.');
return self::FAILURE;
}
$user = $this->resolveUser();
if (! $user) {
return self::FAILURE;
}
if ($this->option('reset')) {
$this->resetDemoData($user);
$this->info('Reset demo data for '.$user->email);
}
$this->assignPlan($user, $plan, $subscriptions);
if ($this->hasDemoMarker($user) && ! $this->option('reset')) {
$this->info('Demo data already present; plan updated. Use --reset to reseed.');
$this->printSummary($user, $plan, $subscriptions);
return self::SUCCESS;
}
$this->seedEvents($user, $plan);
$this->printSummary($user, $plan, $subscriptions);
return self::SUCCESS;
}
private function resolveUser(): ?User
{
$identity = trim((string) $this->argument('identity'));
$publicIdOpt = trim((string) ($this->option('public-id') ?: ''));
$emailOpt = trim((string) ($this->option('email') ?: ''));
$nameOpt = trim((string) ($this->option('name') ?: ''));
$user = User::query()
->where('email', $identity)
->orWhere('public_id', $identity)
->first();
if ($user) {
return $user;
}
$publicId = $publicIdOpt !== '' ? $publicIdOpt : (Str::isUuid($identity) ? $identity : '');
$email = $emailOpt !== '' ? $emailOpt : (filter_var($identity, FILTER_VALIDATE_EMAIL) ? $identity : '');
if ($publicId === '' || $email === '') {
$this->error('Local user not found for "'.$identity.'". Pass --public-id and --email to create a mirror.');
return null;
}
return User::query()->updateOrCreate(
['public_id' => $publicId],
[
'email' => $email,
'name' => $nameOpt !== '' ? $nameOpt : Str::before($email, '@'),
'email_verified_at' => now(),
],
);
}
private function assignPlan(User $user, string $plan, SubscriptionService $subscriptions): void
{
if ($plan === 'free') {
ProSubscription::query()->where('user_id', $user->id)->delete();
return;
}
$planKey = $plan === 'enterprise'
? ProSubscription::PLAN_ENTERPRISE
: ProSubscription::PLAN_PRO;
ProSubscription::query()->updateOrCreate(
['user_id' => $user->id],
[
'plan' => $planKey,
'status' => ProSubscription::STATUS_ACTIVE,
'price_minor' => $planKey === ProSubscription::PLAN_ENTERPRISE
? $subscriptions->enterprisePriceMinor()
: $subscriptions->priceMinor(),
'currency' => (string) config('events.pro.currency', 'GHS'),
'auto_renew' => false,
'started_at' => now(),
'current_period_end' => now()->addYear(),
'last_charged_at' => null,
'canceled_at' => null,
'last_reference' => 'demo-seed-'.$planKey,
'last_error' => null,
],
);
}
private function hasDemoMarker(User $user): bool
{
return QrCode::query()
->where('user_id', $user->id)
->where('type', QrCode::TYPE_EVENT)
->where('short_code', 'like', $this->codePrefix($user).'%')
->exists();
}
private function codePrefix(User $user): string
{
return 'dm'.substr(md5((string) $user->public_id), 0, 8);
}
private function resetDemoData(User $user): void
{
DB::transaction(function () use ($user) {
PaymentGatewaySetting::query()->where('owner_ref', $user->public_id)->delete();
QrTeamMember::query()
->where('account_id', $user->id)
->orWhere('user_id', $user->id)
->delete();
$codes = QrCode::query()->where('user_id', $user->id)->pluck('id');
if ($codes->isNotEmpty()) {
QrEventRegistration::query()->whereIn('qr_code_id', $codes)->delete();
QrCode::query()->whereIn('id', $codes)->delete();
}
QrEventRegistration::query()->where('user_id', $user->id)->delete();
ProSubscription::query()->where('user_id', $user->id)->delete();
});
}
private function seedEvents(User $user, string $plan): void
{
$prefix = $this->codePrefix($user);
$paid = $plan !== 'free';
$eventCount = $paid ? ($plan === 'enterprise' ? 5 : 4) : 2;
$regsPerEvent = $paid ? ($plan === 'enterprise' ? 40 : 25) : 35;
$keynoteSpeaker = DemoWorld::personByKey('ama-mensah');
$breakoutSpeaker = DemoWorld::personByKey('kojo-darko');
$programmeId = null;
if ($paid) {
$programme = QrCode::query()->updateOrCreate(
['short_code' => $prefix.'-itn'],
[
'user_id' => $user->id,
'type' => QrCode::TYPE_ITINERARY,
'label' => 'Demo Summit Programme',
'payload' => [
'content' => [
'title' => 'Accra Tech Summit Programme',
'subtitle' => 'Two-day agenda',
'description' => 'Demo programme for sales walkthroughs.',
'event_date' => now()->addDays(14)->toDateString(),
'location' => 'Kempinski Accra',
'brand_color' => '#0F766E',
'days' => [[
'label' => 'Day 1',
'date' => now()->addDays(14)->toDateString(),
'items' => [
[
'ref' => 'day0-item0',
'time' => '09:00',
'title' => 'Opening keynote',
'description' => 'Welcome and product roadmap',
'location' => 'Main hall',
'host' => DemoWorld::fullName($keynoteSpeaker),
'host_speaker_email' => $keynoteSpeaker['email'],
],
[
'ref' => 'day0-item1',
'time' => '11:00',
'title' => 'Breakout: ticketing',
'description' => 'Paid tiers and badges',
'location' => 'Room B',
'host' => DemoWorld::fullName($breakoutSpeaker),
'host_speaker_email' => $breakoutSpeaker['email'],
],
],
]],
],
'style' => [],
],
'is_active' => true,
],
);
$programmeId = $programme->id;
}
for ($i = 1; $i <= $eventCount; $i++) {
$mode = $paid ? 'ticketing' : 'free';
$tiers = $paid
? [
['name' => 'General', 'price' => 150, 'capacity' => 200],
['name' => 'VIP', 'price' => 450, 'capacity' => 40],
]
: [['name' => 'Registration', 'price' => 0, 'capacity' => 100]];
$event = QrCode::query()->updateOrCreate(
['short_code' => $prefix.'-e'.$i],
[
'user_id' => $user->id,
'type' => QrCode::TYPE_EVENT,
'label' => $paid ? 'Demo Paid Event '.$i : 'Demo Free Event '.$i,
'payload' => [
'content' => [
'name' => $paid ? 'Accra Business Forum '.$i : 'Community Meetup '.$i,
'tagline' => $paid ? 'Ticketed demo event' : 'Free RSVP demo event',
'description' => 'Seeded by demo:seed for sales walkthroughs.',
'location' => 'Accra, Ghana',
'starts_at' => now()->addDays(7 + $i)->toIso8601String(),
'ends_at' => now()->addDays(7 + $i)->addHours(6)->toIso8601String(),
'organizer' => DemoWorld::business($plan)['name'],
'currency' => 'GHS',
'mode' => $mode,
'format' => $i === 1 ? 'in_person' : 'hybrid',
'tiers' => $tiers,
'badge_fields' => ['company', 'role'],
'badge_size' => 'standard',
'accepts_payment' => $paid,
'registration_open' => true,
'programme_qr_id' => $programmeId,
'speakers' => $paid ? [
[
'name' => DemoWorld::fullName($keynoteSpeaker),
'email' => $keynoteSpeaker['email'],
'role' => 'Keynote',
],
] : [],
],
'style' => [],
],
'is_active' => true,
],
);
$this->seedRegistrations($user, $event, $regsPerEvent, $paid, $prefix, $i);
}
if ($paid) {
PaymentGatewaySetting::query()->updateOrCreate(
['owner_ref' => $user->public_id],
[
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
'public_key' => 'pk_test_demo_events',
'secret_key' => 'sk_test_demo_events',
'is_active' => true,
'metadata' => ['demo' => true],
],
);
}
}
private function seedRegistrations(
User $user,
QrCode $event,
int $count,
bool $paid,
string $prefix,
int $eventIndex,
): void {
$existing = QrEventRegistration::query()
->where('qr_code_id', $event->id)
->where('reference', 'like', $prefix.'%')
->count();
if ($existing >= $count) {
return;
}
$people = DemoWorld::people();
for ($n = $existing + 1; $n <= $count; $n++) {
$tier = $paid ? ($n % 5 === 0 ? 'VIP' : 'General') : 'Registration';
$amount = $paid ? ($tier === 'VIP' ? 45000 : 15000) : 0;
$ref = sprintf('%s-r%02d-%03d', $prefix, $eventIndex, $n);
$badge = strtoupper(substr($prefix, 2, 4).sprintf('%02d%03d', $eventIndex, $n));
$person = $people[($n - 1) % count($people)];
QrEventRegistration::query()->updateOrCreate(
['reference' => $ref],
[
'qr_code_id' => $event->id,
'user_id' => $user->id,
'badge_code' => substr($badge, 0, 16),
'tier_name' => $tier,
'amount_minor' => $amount,
'currency' => 'GHS',
'attendee_name' => DemoWorld::fullName($person),
'attendee_email' => $person['email'],
'attendee_phone' => $person['phone'],
'badge_fields' => [
'company' => DemoWorld::companyNameForPerson($person),
'role' => $tier === 'VIP' ? 'Executive' : 'Guest',
],
'status' => QrEventRegistration::STATUS_CONFIRMED,
'payment_reference' => $paid ? 'demo-pay-'.$ref : null,
'paid_at' => $paid ? now()->subDays(max(1, 30 - $n)) : null,
'checked_in_at' => $n % 4 === 0 ? now()->subHours($n) : null,
'metadata' => ['demo' => true],
],
);
}
}
private function printSummary(User $user, string $plan, SubscriptionService $subscriptions): void
{
$events = QrCode::query()
->where('user_id', $user->id)
->where('type', QrCode::TYPE_EVENT)
->count();
$programmes = QrCode::query()
->where('user_id', $user->id)
->where('type', QrCode::TYPE_ITINERARY)
->count();
$regs = QrEventRegistration::query()->where('user_id', $user->id)->count();
$this->table(
['Field', 'Value'],
[
['email', $user->email],
['public_id', $user->public_id],
['requested_plan', $plan],
['effective_plan', $subscriptions->planKey($user)],
['live_events', (string) $events],
['programmes', (string) $programmes],
['registrations', (string) $regs],
],
);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Models\Events\ProSubscription;
use App\Services\Events\SubscriptionService;
use Illuminate\Console\Command;
class RenewProSubscriptionsCommand extends Command
{
protected $signature = 'events:pro-renew';
protected $description = 'Charge the Ladill wallet for due Events Pro subscriptions.';
public function handle(SubscriptionService $subscriptions): int
{
$due = ProSubscription::where('status', ProSubscription::STATUS_ACTIVE)
->where('auto_renew', true)
->where('current_period_end', '<=', now())
->get();
$this->info("Renewing {$due->count()} due subscription(s).");
$due->each(fn (ProSubscription $sub) => $subscriptions->renewIfDue($sub));
return self::SUCCESS;
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Meet\MeetLifecycleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MeetWebhookController extends Controller
{
public function __invoke(Request $request, MeetLifecycleService $lifecycle): JsonResponse
{
$secret = (string) config('meet.webhook_secret', '');
if ($secret === '') {
return response()->json(['error' => 'Webhook not configured.'], 503);
}
$signature = (string) $request->header('X-Ladill-Meet-Signature', '');
$body = $request->getContent();
$expected = hash_hmac('sha256', $body, $secret);
if ($signature === '' || ! hash_equals($expected, $signature)) {
return response()->json(['error' => 'Invalid signature.'], 401);
}
/** @var array<string, mixed> $payload */
$payload = $request->json()->all();
$event = (string) ($payload['event'] ?? '');
if ($event === '') {
return response()->json(['error' => 'Missing event.'], 422);
}
$lifecycle->handle($event, $payload);
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\CustomDomain\CustomDomainService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Signed completion callback from Ladill Domains' SSL service. Flips a custom
* domain to live (or failed) once the certificate is issued. Auth is the HMAC
* signature over the raw body (shared SSL_CALLBACK_SECRET), not a session.
*/
class SslCallbackController extends Controller
{
public function __invoke(Request $request, CustomDomainService $service): JsonResponse
{
$secret = (string) config('customdomain.callback_secret');
$body = $request->getContent();
$signature = (string) $request->header('X-Ladill-Signature', '');
if ($secret === '' || ! hash_equals(hash_hmac('sha256', $body, $secret), $signature)) {
return response()->json(['error' => 'Invalid signature.'], 401);
}
$payload = json_decode($body, true);
$data = (array) ($payload['data'] ?? []);
$host = (string) ($data['host'] ?? '');
if ($host === '') {
return response()->json(['error' => 'Missing host.'], 422);
}
$service->applyCallback(
$host,
(string) ($data['status'] ?? 'failed'),
$data['expires_at'] ?? null,
$data['last_error'] ?? null,
);
return response()->json(['status' => 'ok']);
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\User;
use App\Services\Meet\EventMeetRoomLinkService;
use App\Services\Meet\EventRegistrationVerifyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServiceMeetController extends Controller
{
public function __construct(private readonly EventMeetRoomLinkService $links) {}
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'owner_ref' => ['required', 'string', 'max:64'],
]);
$owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail();
return response()->json([
'events' => $this->links->linkableEvents($owner),
]);
}
public function linkRoom(Request $request, QrCode $event): JsonResponse
{
$validated = $request->validate([
'owner_ref' => ['required', 'string', 'max:64'],
'meet_room_uuid' => ['required', 'string', 'max:64'],
'meet_room_type' => ['required', 'string', 'in:town_hall,webinar'],
'join_url' => ['nullable', 'string', 'max:2048'],
'title' => ['nullable', 'string', 'max:255'],
'scheduled_at' => ['nullable', 'date'],
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'virtual_session_id' => ['nullable', 'string', 'max:64'],
]);
$owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail();
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$result = $this->links->linkRoom($owner, $event, $validated);
return response()->json(['event' => $result]);
}
public function verifyRegistration(Request $request, QrCode $event): JsonResponse
{
$validated = $request->validate([
'owner_ref' => ['required', 'string', 'max:64'],
'badge_code' => ['required', 'string', 'max:16'],
]);
$owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail();
abort_unless($event->user_id === $owner->id, 403);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$result = app(EventRegistrationVerifyService::class)->verifyBadge($event, $validated['badge_code']);
if (! $result) {
return response()->json(['message' => 'Badge not found.'], 404);
}
return response()->json(['registration' => $result]);
}
public function verifySpeaker(Request $request, QrCode $event): JsonResponse
{
$validated = $request->validate([
'owner_ref' => ['required', 'string', 'max:64'],
'speaker_token' => ['required', 'string', 'max:128'],
]);
$owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail();
abort_unless($event->user_id === $owner->id, 403);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$result = app(\App\Services\Events\EventSpeakerInviteService::class)
->verifyToken($event, $validated['speaker_token']);
if (! $result) {
return response()->json(['message' => 'Speaker invite not found.'], 404);
}
return response()->json(['speaker' => $result]);
}
}
@@ -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);
@@ -126,6 +123,8 @@ class SsoLoginController extends Controller
Auth::login($user, remember: true);
$request->session()->regenerate();
app(\App\Services\Events\DemoLoginReseeder::class)->maybeResetAfterLogin($user);
return $this->finishCallback($request, $intended, null, $popup);
}
@@ -141,6 +140,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', ''));
@@ -5,27 +5,27 @@ namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Notifications\EventProgrammeSharedNotification;
use App\Services\Billing\SmsService;
use App\Services\Events\EventCommsService;
use App\Support\Events\EventBadgeZpl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Notification;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class AttendeeController extends Controller
{
public function __construct(private readonly SmsService $sms) {}
public function __construct(
private readonly EventCommsService $comms,
) {}
public function hub(Request $request): View
{
$account = ladill_account();
$search = trim((string) $request->query('search', ''));
$events = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->when($search !== '', fn ($q) => $q->where('label', 'like', "%{$search}%"))
->latest()
->get()
->map(function (QrCode $event) {
@@ -41,18 +41,15 @@ class AttendeeController extends Controller
return view('events.attendees-hub', [
'events' => $events,
'search' => $search,
]);
}
public function badgesHub(Request $request): View
{
$account = ladill_account();
$search = trim((string) $request->query('search', ''));
$events = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->when($search !== '', fn ($q) => $q->where('label', 'like', "%{$search}%"))
->latest()
->get()
->map(function (QrCode $event) {
@@ -65,7 +62,6 @@ class AttendeeController extends Controller
return view('events.badges-hub', [
'events' => $events,
'search' => $search,
]);
}
@@ -131,7 +127,24 @@ class AttendeeController extends Controller
'revenue' => $event->eventRegistrations()->where('status', QrEventRegistration::STATUS_CONFIRMED)->sum('amount_minor') / 100,
];
$content = $event->content();
$format = (string) ($content['format'] ?? 'in_person');
$virtualSessions = collect((array) ($content['virtual_sessions'] ?? []))->filter(fn ($s) => is_array($s));
if (in_array($format, ['virtual', 'hybrid'], true) && $virtualSessions->isNotEmpty()) {
$stats['virtual_sessions'] = $virtualSessions->count();
$stats['virtual_live'] = $virtualSessions->where('meet_status', 'live')->count();
$stats['virtual_ended'] = $virtualSessions->where('meet_status', 'ended')->count();
$stats['virtual_recordings'] = $virtualSessions->filter(fn ($s) => ! empty($s['recording_url']))->count();
}
$programme = $this->programmeFor($event);
$joinUrl = $this->comms->primaryJoinUrl($event);
$commsModes = array_values(array_filter([
$programme ? EventCommsService::MODE_PROGRAMME : null,
$joinUrl !== '' ? EventCommsService::MODE_JOIN : null,
($programme && $joinUrl !== '') ? EventCommsService::MODE_BOTH : null,
]));
return view('events.attendees', [
'qrCode' => $event,
@@ -139,54 +152,67 @@ class AttendeeController extends Controller
'registrations' => $registrations,
'stats' => $stats,
'programme' => $programme,
'commsModes' => $commsModes,
'defaultCommsMode' => $commsModes[0] ?? EventCommsService::MODE_PROGRAMME,
'messagingSettingsUrl' => function_exists('ladill_account_url')
? ladill_account_url('/account/settings/messaging')
: (string) config('smtp.messaging_settings_url', 'https://account.ladill.com/account/settings/messaging'),
]);
}
public function shareProgramme(QrCode $event): RedirectResponse
public function previewComms(QrCode $event, Request $request): JsonResponse
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$programme = $this->programmeFor($event);
if (! $programme) {
return back()->with('error', 'No programme outline is attached to this event yet.');
$mode = (string) $request->query('mode', EventCommsService::MODE_PROGRAMME);
abort_unless(in_array($mode, [EventCommsService::MODE_PROGRAMME, EventCommsService::MODE_JOIN, EventCommsService::MODE_BOTH], true), 422);
return response()->json($this->comms->preview($event, $mode));
}
public function shareComms(QrCode $event, Request $request): RedirectResponse
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$validated = $request->validate([
'mode' => ['required', Rule::in([
EventCommsService::MODE_PROGRAMME,
EventCommsService::MODE_JOIN,
EventCommsService::MODE_BOTH,
])],
]);
$preview = $this->comms->preview($event, $validated['mode']);
if ($preview['integrations_error'] ?? null) {
return back()->with('error', $preview['integrations_error']);
}
$recipients = $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->get();
$eventName = $event->content()['name'] ?? $event->label;
$programmeUrl = $programme->publicUrl();
$emailed = 0;
$texted = 0;
foreach ($recipients as $reg) {
if ($reg->attendee_email) {
Notification::route('mail', $reg->attendee_email)
->notify(new EventProgrammeSharedNotification($event, $programme, $reg->attendee_name));
$emailed++;
}
if ($reg->attendee_phone) {
$this->sms->send(
$reg->attendee_phone,
sprintf(
'Hi %s, the programme for %s is here: %s',
explode(' ', $reg->attendee_name ?? 'there')[0],
$eventName,
$programmeUrl
)
);
$texted++;
}
if ($preview['recipients'] === 0) {
return back()->with('error', 'No confirmed attendees to message yet.');
}
if ($emailed === 0 && $texted === 0) {
return back()->with('error', 'No confirmed attendees with contact details to share with yet.');
$result = $this->comms->send($event, $validated['mode']);
if ($result['emailed'] === 0 && $result['texted'] === 0) {
return back()->with('error', 'No messages were sent — check that attendees have email or phone on file.');
}
return back()->with('success', "Programme shared — {$emailed} email(s) and {$texted} SMS sent.");
return back()->with('success', sprintf(
'Sent — %d email(s), %d SMS, %d skipped.',
$result['emailed'],
$result['texted'],
$result['skipped'],
));
}
/** @deprecated Use shareComms with mode=programme */
public function shareProgramme(QrCode $event): RedirectResponse
{
request()->merge(['mode' => EventCommsService::MODE_PROGRAMME]);
return $this->shareComms($event, request());
}
private function programmeFor(QrCode $event): ?QrCode
@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\CustomDomain;
use App\Models\QrCode;
use App\Services\CustomDomain\CustomDomainService;
use App\Services\CustomDomain\DomainsSslClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class CustomDomainController extends Controller
{
public function __construct(
private readonly CustomDomainService $service,
private readonly DomainsSslClient $domains,
) {}
public function store(Request $request, QrCode $event): RedirectResponse
{
$this->authorize('update', $event);
abort_unless($this->service->enabled(), 404);
$data = $request->validate([
'host' => ['required', 'string', 'max:255', 'regex:/^(?!-)([a-z0-9-]+\.)+[a-z]{2,}$/i'],
'include_www' => ['nullable', 'boolean'],
]);
$host = strtolower(preg_replace('/^www\./', '', trim($data['host'])));
if (CustomDomain::where('host', $host)->exists()) {
return back()->withErrors(['host' => 'That domain is already connected.']);
}
$this->service->attach($event, $host, (bool) ($data['include_www'] ?? true));
// Surface this domain in Ladill Domains' "My Domains" (best-effort).
$this->domains->registerConnected(
$host,
(string) $request->user()->public_id,
'Event: '.$event->label,
route('events.show', $event),
);
return back()->with('success', "Domain added. Point an A record for {$host} (and www) to ".config('customdomain.server_ip').', then click Verify.');
}
public function verify(Request $request, CustomDomain $customDomain): RedirectResponse
{
$this->authorize('update', $customDomain->qrCode);
[$ok, $message] = $this->service->verifyAndProvision($customDomain);
return back()->with($ok ? 'success' : 'error', $message);
}
public function destroy(Request $request, CustomDomain $customDomain): RedirectResponse
{
$this->authorize('update', $customDomain->qrCode);
$host = $customDomain->host;
$customDomain->delete();
$this->domains->removeConnected($host);
return back()->with('success', 'Custom domain removed.');
}
}
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use App\Services\Events\SubscriptionService;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
@@ -14,7 +15,10 @@ use Throwable;
class OverviewController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function __construct(
private BillingClient $billing,
private SubscriptionService $subscriptions,
) {}
public function index(Request $request): View
{
@@ -83,6 +87,7 @@ class OverviewController extends Controller
'recentEvents' => $recentEvents,
'programmeCount' => $programmeCount,
'balanceMinor' => $balanceMinor,
'hasPaidPlan' => $this->subscriptions->hasPaidPlan($account),
]);
}
}
@@ -6,18 +6,24 @@ use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use App\Services\Identity\IdentityClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class PayoutsController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function __construct(
private BillingClient $billing,
private IdentityClient $identity,
) {}
public function index(): View
{
$account = ladill_account();
$eventIds = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->pluck('id');
@@ -25,9 +31,24 @@ class PayoutsController extends Controller
$revenueMinor = (int) QrEventRegistration::query()
->whereIn('qr_code_id', $eventIds)
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->sum('amount_minor');
->where('amount_minor', '>', 0)
->get()
->sum(fn (QrEventRegistration $r) => $r->netAmountMinor());
$transactions = QrEventRegistration::query()
->whereIn('qr_code_id', $eventIds)
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->where('amount_minor', '>', 0)
->with('qrCode:id,label,payload')
->orderByDesc('paid_at')
->orderByDesc('id')
->limit(50)
->get();
$balanceMinor = 0;
$payoutAccount = null;
$withdrawals = [];
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
@@ -37,9 +58,68 @@ class PayoutsController extends Controller
]);
}
try {
$payoutAccount = $this->identity->payoutAccount($account->public_id);
$withdrawals = $this->identity->withdrawals($account->public_id);
} catch (Throwable $e) {
Log::warning('Events payouts could not load payout account or withdrawals', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('events.payouts', [
'revenueMinor' => $revenueMinor,
'balanceMinor' => $balanceMinor,
'balanceGhs' => round($balanceMinor / 100, 2),
'transactions' => $transactions,
'payoutAccount' => $payoutAccount,
'withdrawals' => $withdrawals,
]);
}
public function banks(Request $request): JsonResponse
{
$type = $request->query('type') === 'bank_account' ? 'bank' : 'mobile_money';
try {
return response()->json(['data' => $this->identity->banks($type)]);
} catch (Throwable $e) {
Log::warning('Events payouts could not load banks', ['error' => $e->getMessage()]);
return response()->json(['data' => []]);
}
}
public function updatePayoutAccount(Request $request): RedirectResponse
{
$data = $request->validate([
'account_type' => ['required', 'in:mobile_money,bank_account'],
'account_name' => ['required', 'string', 'max:200'],
'account_number' => ['required', 'string', 'max:30'],
'bank_code' => ['required', 'string', 'max:30'],
'bank_name' => ['required', 'string', 'max:200'],
'currency' => ['sometimes', 'string', 'size:3'],
]);
$data['currency'] = $data['currency'] ?? 'GHS';
$this->identity->updatePayoutAccount(ladill_account()->public_id, $data);
return redirect()
->route('events.payouts')
->with('success', 'Payout account saved.');
}
public function withdraw(Request $request): RedirectResponse
{
$data = $request->validate([
'amount' => ['required', 'numeric', 'min:1', 'max:50000'],
]);
$this->identity->withdraw(ladill_account()->public_id, (float) $data['amount']);
return redirect()
->route('events.payouts')
->with('success', 'Withdrawal request submitted. Funds are sent to your payout account once processed.');
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Services\Billing\BillingClient;
use App\Services\Events\SubscriptionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
/**
* Paid Events plans are retired. Routes remain so old bookmarks and emails
* do not 404; every action redirects to the dashboard.
*/
class ProController extends Controller
{
public function __construct(private readonly SubscriptionService $subscriptions) {}
public function index(Request $request): RedirectResponse
{
return $this->retired();
}
public function subscribe(Request $request): RedirectResponse
{
return $this->retired();
}
public function subscribeEnterprise(Request $request): RedirectResponse
{
return $this->retired();
}
public function subscribePrepaid(Request $request, BillingClient $billing): RedirectResponse
{
return $this->retired();
}
public function paystackCallback(Request $request, BillingClient $billing): RedirectResponse
{
return $this->retired();
}
public function cancel(Request $request): RedirectResponse
{
return $this->retired();
}
private function retired(): RedirectResponse
{
return redirect()->route('events.dashboard')
->with('success', 'Ladill Events is free — paid subscriptions are no longer offered. Ticket payments use Ladill Pay with the standard platform fee.');
}
}
@@ -3,22 +3,45 @@
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrWallet;
use App\Services\Qr\QrCodeManagerService;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProgrammeController extends Controller
{
public function __construct(private QrCodeManagerService $manager) {}
public function create(): View
{
return view('events.programme-create', [
'pricePerQr' => QrWallet::pricePerQr(),
]);
}
public function index(Request $request): View
{
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$programmes = $account->qrCodes()
->whereIn('type', QrTypeCatalog::programmeTypes())
->latest()
->get();
return view('events.programmes', compact('programmes'));
$totalSessions = $programmes->sum(function ($programme) {
$days = (array) ($programme->content()['days'] ?? []);
return collect($days)->sum(fn ($day) => count((array) ($day['items'] ?? [])));
});
return view('events.programmes', [
'programmes' => $programmes,
'wallet' => $wallet,
'totalSessions' => $totalSessions,
'pricePerQr' => QrWallet::pricePerQr(),
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]);
}
}
@@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Events\EventSpeakerInviteService;
use App\Services\Events\EventSpeakerService;
use App\Services\Qr\QrCodeManagerService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SpeakerController extends Controller
{
public function __construct(
private readonly EventSpeakerService $speakers,
private readonly EventSpeakerInviteService $invites,
private readonly QrCodeManagerService $manager,
) {}
public function hub(Request $request): View
{
$account = ladill_account();
$events = $this->speakers->eventsForAccount($account->id)
->map(function (QrCode $event) {
$event->speaker_count = count($this->speakers->rosterFor($event));
return $event;
});
return view('events.speakers-hub', [
'events' => $events,
]);
}
public function index(QrCode $event): View
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$roster = $this->invites->rosterWithInviteState($event);
return view('events.speakers', [
'qrCode' => $event,
'roster' => $roster,
'programmeAssignments' => $this->speakers->programmeAssignments($event),
'programme' => $this->speakers->linkedProgramme($event),
'canManualInvite' => collect($roster)->mapWithKeys(fn ($speaker) => [
$speaker['email'] => $this->invites->canSendManualInvite($event, $speaker),
])->all(),
]);
}
public function update(Request $request, QrCode $event): RedirectResponse
{
$this->authorize('update', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$validated = $request->validate([
'speakers' => ['nullable', 'array'],
'speakers.*.name' => ['nullable', 'string', 'max:120'],
'speakers.*.email' => ['required_with:speakers.*.name', 'email', 'max:190'],
'speakers.*.role' => ['nullable', 'string', 'max:80'],
'speakers.*.bio' => ['nullable', 'string', 'max:500'],
]);
try {
$this->manager->update($event, [
'speakers' => $validated['speakers'] ?? [],
]);
} catch (\RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
return redirect()
->route('speakers.show', $event)
->with('success', 'Speaker roster saved.');
}
public function sendInvite(Request $request, QrCode $event): RedirectResponse
{
$this->authorize('update', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$validated = $request->validate([
'email' => ['required', 'email', 'max:190'],
]);
$owner = ladill_account() ?? $event->user;
abort_unless($owner, 500);
$result = $this->invites->sendManualInvite($event, $owner, $validated['email']);
if (! $result['ok']) {
return back()->with('error', $result['error']);
}
return back()->with('success', 'Speaker invitation sent.');
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Services\Events\EventProgrammeService;
use App\Services\Events\EventSpeakerInviteService;
use Illuminate\View\View;
class SpeakerPortalController extends Controller
{
public function __construct(
private readonly EventSpeakerInviteService $invites,
private readonly EventProgrammeService $programmes,
) {}
public function show(string $shortCode, string $token): View
{
$resolved = $this->invites->resolvePortal($shortCode, $token);
abort_unless($resolved, 404);
$event = $resolved['event'];
$speaker = $resolved['speaker'];
$content = $event->content();
$programme = null;
$programmeSnapshot = null;
$programmeId = (int) ($content['programme_qr_id'] ?? 0);
if ($programmeId > 0) {
$programme = \App\Models\QrCode::query()
->where('id', $programmeId)
->where('type', \App\Models\QrCode::TYPE_ITINERARY)
->first();
$programmeSnapshot = $this->programmes->snapshot($programme);
}
$token = (string) ($speaker['invite_token'] ?? '');
$virtualSessions = collect((array) ($content['virtual_sessions'] ?? []))
->filter(fn ($session) => is_array($session) && trim((string) ($session['join_url'] ?? '')) !== '')
->map(function (array $session) use ($event, $token) {
return [
'title' => trim((string) ($session['title'] ?? 'Virtual session')),
'type' => in_array($session['meet_room_type'] ?? '', ['webinar'], true) ? 'Webinar' : 'Conference',
'scheduled_at' => $session['scheduled_at'] ?? null,
'join_url' => $this->invites->speakerJoinUrl($event, $session, $token),
];
})
->values()
->all();
return view('events.speaker-portal', [
'event' => $event,
'speaker' => $speaker,
'eventContent' => $content,
'programmeSnapshot' => $programmeSnapshot,
'assignments' => $this->invites->assignmentsForSpeaker($event, $speaker),
'virtualSessions' => $virtualSessions,
]);
}
}
@@ -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');
}
}
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Events\EventRegistrationService;
use App\Support\LadillLink;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -29,9 +30,10 @@ class EventRegistrationController extends Controller
'tier' => ['required', 'string', 'max:80'],
'amount' => ['nullable', 'numeric', 'min:1', 'max:1000000'],
'attendee_name' => ['required', 'string', 'max:120'],
'attendee_email' => ['required', 'email', 'max:200'],
'attendee_phone' => ['nullable', 'string', 'max:30'],
'attendee_email' => ['nullable', 'email', 'max:200'],
'attendee_phone' => ['required', 'string', 'max:30'],
'badge_fields' => ['nullable', 'array'],
'meet_return' => ['nullable', 'string', 'max:2048'],
]);
try {
@@ -43,11 +45,11 @@ class EventRegistrationController extends Controller
return response()->json([
'paid' => $result['paid'],
'checkout_url' => $result['checkout_url'],
'access_code' => $result['access_code'] ?? null,
'public_key' => $result['public_key'] ?? null,
'callback_url' => $result['callback_url'] ?? null,
'badge_code' => $result['registration']->badge_code,
'success_url' => route('qr.public.event.confirmed', [
'shortCode' => $qrCode->short_code,
'ref' => $result['registration']->reference,
]),
'success_url' => $this->confirmedUrl($qrCode, $result['registration'], $request->input('meet_return')),
]);
}
@@ -56,31 +58,55 @@ class EventRegistrationController extends Controller
$reference = (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 {
$registration = $this->eventService->complete($reference);
return redirect()->route('qr.public.event.confirmed', [
'shortCode' => $shortCode,
'ref' => $registration->reference,
return view('public.payment-return', [
'redirect' => $this->confirmedUrl($registration->qrCode, $registration),
]);
} catch (\Throwable $e) {
return redirect('/q/' . $shortCode)->with('order_error', 'Payment could not be verified. Reference: ' . $reference);
return view('public.payment-return', [
'redirect' => LadillLink::url($shortCode),
]);
}
}
public function confirmed(string $shortCode, string $ref): View
public function confirmed(Request $request, string $shortCode, string $ref): View
{
$registration = QrEventRegistration::query()
->where('reference', $ref)
->whereHas('qrCode', fn ($q) => $q->where('short_code', $shortCode))
->firstOrFail();
$meetReturn = (string) $request->query('meet_return', '');
if ($meetReturn === '') {
$meetReturn = (string) (($registration->metadata ?? [])['meet_return'] ?? '');
}
return view('public.qr.event-confirmed', [
'registration' => $registration,
'qrCode' => $registration->qrCode,
'meetReturn' => $meetReturn,
]);
}
private function confirmedUrl(QrCode $qrCode, QrEventRegistration $registration, ?string $meetReturn = null): string
{
$url = LadillLink::path($qrCode->short_code, 'registered/'.$registration->reference);
$meetReturn = trim((string) ($meetReturn ?? ''));
if ($meetReturn === '') {
$meetReturn = trim((string) (($registration->metadata ?? [])['meet_return'] ?? ''));
}
if ($meetReturn !== '') {
$url .= '?meet_return='.urlencode($meetReturn);
}
return $url;
}
}
@@ -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()) {
@@ -44,7 +44,10 @@ class QrScanController extends Controller
}
if ($qrCode->usesLandingPage()) {
return view('public.qr.landing', ['qrCode' => $qrCode]);
return view('public.qr.landing', [
'qrCode' => $qrCode,
'meetReturn' => trim((string) $request->query('meet_return', '')),
]);
}
abort(404);
@@ -62,7 +65,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),
]);
}
+70 -1
View File
@@ -3,18 +3,29 @@
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\PlatformEmailClient;
use App\Services\Billing\PlatformSmsClient;
use App\Services\Events\SubscriptionService;
use App\Services\Payments\MerchantGatewayService;
use App\Support\AccountBranding;
use App\Support\Qr\QrCornerStyleCatalog;
use App\Support\Qr\QrFrameStyleCatalog;
use App\Support\Qr\QrModuleStyleCatalog;
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 MerchantGatewayService $gateway,
private SubscriptionService $subscriptions,
) {}
private function topupUrl(): string
{
@@ -75,6 +86,13 @@ class AccountController extends Controller
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
'frameStyles' => QrFrameStyleCatalog::visible(),
'gateway' => $this->gateway->settingFor($account),
'canUsePaymentGateway' => $this->subscriptions->canUsePaymentGateway($account),
'messagingSettingsUrl' => function_exists('ladill_account_url')
? ladill_account_url('/account/settings/messaging')
: (string) config('smtp.messaging_settings_url', 'https://account.ladill.com/account/settings/messaging'),
'suiteMessagingReady' => app(PlatformEmailClient::class)->isConfigured()
|| app(PlatformSmsClient::class)->isConfigured(),
]);
}
@@ -112,11 +130,35 @@ class AccountController extends Controller
'default_style.gradient_color1' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
'default_style.gradient_color2' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
'default_style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'],
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
'remove_logo' => ['nullable', 'boolean'],
'gateway_provider' => ['nullable', Rule::in([
PaymentGatewaySetting::PROVIDER_PAYSTACK,
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
PaymentGatewaySetting::PROVIDER_HUBTEL,
'',
])],
'gateway_public_key' => ['nullable', 'string', 'max:2000'],
'gateway_secret_key' => ['nullable', 'string', 'max:2000'],
'gateway_webhook_secret' => ['nullable', 'string', 'max:2000'],
'gateway_is_active' => ['nullable', 'boolean'],
]);
$eventDefaults = $data['event_defaults'] ?? [];
$eventDefaults['registration_open'] = $request->boolean('event_defaults.registration_open');
$settings = $account->getOrCreateQrSetting();
$logoPath = $settings->logo_path;
if ($request->boolean('remove_logo')) {
AccountBranding::deleteStoredLogo($settings);
$logoPath = null;
}
if ($request->hasFile('logo')) {
$logoPath = AccountBranding::storeLogo($settings, $request->file('logo'));
}
QrSetting::updateOrCreate(
['user_id' => $account->id],
[
@@ -127,9 +169,36 @@ class AccountController extends Controller
'notify_payouts' => $request->boolean('notify_payouts'),
'event_defaults' => QrSetting::sanitizeEventDefaults($eventDefaults),
'default_style' => QrSetting::sanitizeDefaultStyle($data['default_style'] ?? null),
'logo_path' => $logoPath,
],
);
$provider = (string) ($data['gateway_provider'] ?? '');
if ($this->subscriptions->canUsePaymentGateway($account)) {
$existing = PaymentGatewaySetting::query()->firstOrNew([
'owner_ref' => $account->public_id,
]);
if ($provider === '' && ! $existing->exists) {
return redirect()->route('account.settings')->with('success', 'Settings saved.');
}
if ($provider !== '') {
$existing->provider = $provider;
}
$existing->is_active = $provider !== '' && $request->boolean('gateway_is_active');
if (filled($data['gateway_public_key'] ?? null)) {
$existing->public_key = $data['gateway_public_key'];
}
if (filled($data['gateway_secret_key'] ?? null)) {
$existing->secret_key = $data['gateway_secret_key'];
}
if (array_key_exists('gateway_webhook_secret', $data) && $data['gateway_webhook_secret'] !== null) {
$existing->webhook_secret = $data['gateway_webhook_secret'] !== ''
? $data['gateway_webhook_secret']
: null;
}
$existing->save();
}
return redirect()->route('account.settings')->with('success', 'Settings saved.');
}
}
@@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Services\Messaging\CustomerSmsClient;
use App\Services\Messaging\MessagingCredentialsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class IntegrationsController extends Controller
{
public function edit(MessagingCredentialsService $credentials): View
{
$account = ladill_account();
return view('qr.account.integrations', [
'account' => $account,
'credential' => $credentials->forOwner((string) $account->public_id),
'messagingSettingsUrl' => function_exists('ladill_account_url')
? ladill_account_url('/account/settings/messaging')
: (string) config('smtp.messaging_settings_url', 'https://account.ladill.com/account/settings/messaging'),
]);
}
public function saveSms(Request $request, MessagingCredentialsService $credentials): RedirectResponse
{
$account = ladill_account();
$ownerRef = (string) $account->public_id;
$data = $request->validate([
'sms_api_key' => ['required', 'string', 'max:200'],
'sms_sender_id' => ['required', 'string', 'max:11'],
]);
$result = $credentials->validateAndSaveSms(
$ownerRef,
$data['sms_api_key'],
$data['sms_sender_id'],
);
if (! ($result['ok'] ?? false)) {
return back()->withInput()->with('error', $result['error'] ?? 'Could not save SMS credentials.');
}
return back()->with('success', 'SMS credentials saved.');
}
public function disconnectSms(MessagingCredentialsService $credentials): RedirectResponse
{
$account = ladill_account();
$credentials->disconnectSms((string) $account->public_id);
return back()->with('success', 'SMS disconnected.');
}
public function saveBird(Request $request, MessagingCredentialsService $credentials): RedirectResponse
{
$account = ladill_account();
$ownerRef = (string) $account->public_id;
$data = $request->validate([
'bird_api_key' => ['required', 'string', 'max:200'],
'bird_from_email' => ['required', 'email', 'max:255'],
'bird_from_name' => ['nullable', 'string', 'max:100'],
]);
$result = $credentials->validateAndSaveBird(
$ownerRef,
$data['bird_api_key'],
$data['bird_from_email'],
$data['bird_from_name'] ?? null,
);
if (! ($result['ok'] ?? false)) {
return back()->withInput()->with('error', $result['error'] ?? 'Could not save email credentials.');
}
return back()->with('success', 'Email credentials saved.');
}
public function disconnectBird(MessagingCredentialsService $credentials): RedirectResponse
{
$account = ladill_account();
$credentials->disconnectBird((string) $account->public_id);
return back()->with('success', 'Email disconnected.');
}
public function previewSenders(Request $request, CustomerSmsClient $sms): JsonResponse
{
$data = $request->validate([
'sms_api_key' => ['required', 'string', 'max:200'],
]);
$result = $sms->senders(trim($data['sms_api_key']));
if (! ($result['ok'] ?? false)) {
return response()->json(['error' => $result['error'] ?? 'Could not load senders.'], 422);
}
return response()->json($result['data'] ?? []);
}
}
+63 -2
View File
@@ -7,6 +7,7 @@ use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Models\QrWallet;
use App\Services\Billing\BillingClient;
use App\Services\Events\SubscriptionService;
use App\Services\Qr\QrAnalyticsService;
use App\Services\Qr\QrCodeManagerService;
use App\Services\Qr\QrImageGeneratorService;
@@ -37,6 +38,7 @@ class QrCodeController extends Controller
private QrImageGeneratorService $imageGenerator,
private QrPdfExporter $pdfExporter,
private BillingClient $platformBilling,
private SubscriptionService $subscriptions,
) {}
public function index(Request $request): View
@@ -72,8 +74,15 @@ class QrCodeController extends Controller
]);
}
public function create(Request $request): View
public function create(Request $request): View|RedirectResponse
{
$account = ladill_account() ?? $request->user();
$requestedType = $request->query('type', QrCode::TYPE_EVENT);
if ($account && ($requestedType === QrCode::TYPE_EVENT) && ! $this->subscriptions->canCreateEvent($account)) {
return redirect()->route('events.pro.index')
->with('upsell', 'Free plan includes up to '.config('events.free.max_live_events', 2).' live events. Upgrade for unlimited.');
}
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$qrSettings = $account->getOrCreateQrSetting();
@@ -82,6 +91,19 @@ class QrCodeController extends Controller
$requestedType = QrCode::TYPE_EVENT;
}
if ($requestedType === QrCode::TYPE_EVENT) {
return view('events.create', [
'pricePerQr' => QrWallet::pricePerQr(),
'accountEventDefaults' => $qrSettings->resolvedEventDefaults(),
]);
}
if ($requestedType === QrCode::TYPE_ITINERARY) {
return view('events.programme-create', [
'pricePerQr' => QrWallet::pricePerQr(),
]);
}
return view('qr-codes.create', [
'wallet' => $wallet,
'requestedType' => $requestedType,
@@ -113,6 +135,13 @@ class QrCodeController extends Controller
public function store(Request $request): RedirectResponse
{
$account = ladill_account() ?? $request->user();
$requestedType = (string) $request->input('type', QrCode::TYPE_EVENT);
if ($account && $requestedType === QrCode::TYPE_EVENT && ! $this->subscriptions->canCreateEvent($account)) {
return redirect()->route('events.pro.index')
->with('upsell', 'Free plan includes up to '.config('events.free.max_live_events', 2).' live events. Upgrade for unlimited.');
}
$validated = $request->validate([
'label' => ['required', 'string', 'max:120'],
'type' => ['required', 'in:' . implode(',', QrTypeCatalog::keys())],
@@ -206,6 +235,31 @@ class QrCodeController extends Controller
]);
}
$itineraryOptions = $qrCode->type === QrCode::TYPE_EVENT
? $account->qrCodes()
->where('type', QrCode::TYPE_ITINERARY)
->orderBy('label')
->get(['id', 'label'])
: collect();
if ($qrCode->type === QrCode::TYPE_EVENT) {
return view('events.show', [
'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri,
'itineraryOptions' => $itineraryOptions,
'customDomains' => \App\Models\CustomDomain::where('qr_code_id', $qrCode->id)->get(),
'customDomainsEnabled' => app(\App\Services\CustomDomain\CustomDomainService::class)->enabled(),
]);
}
if ($qrCode->type === QrCode::TYPE_ITINERARY) {
return view('events.programme-show', [
'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri,
'linkedEventSpeakers' => app(\App\Services\Events\EventProgrammeService::class)->speakerOptionsForProgramme($qrCode),
]);
}
return view('qr-codes.show', [
'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri,
@@ -226,6 +280,13 @@ class QrCodeController extends Controller
'minTopup' => QrWallet::minTopupGhs(),
'ladillWalletBalance' => $ladillWalletBalance,
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
'customDomains' => \App\Models\CustomDomain::where('qr_code_id', $qrCode->id)->get(),
'customDomainsEnabled' => app(\App\Services\CustomDomain\CustomDomainService::class)->enabled(),
'customDomainServerIp' => config('customdomain.server_ip'),
'itineraryOptions' => $itineraryOptions,
'linkedEventSpeakers' => $qrCode->type === QrCode::TYPE_ITINERARY
? app(\App\Services\Events\EventProgrammeService::class)->speakerOptionsForProgramme($qrCode)
: [],
]);
}
@@ -313,7 +374,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,33 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateService
{
public function handle(Request $request, Closure $next, string $namespace = 'events'): Response
{
$token = (string) $request->bearerToken();
$caller = null;
if ($token !== '') {
foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) {
if (is_string($key) && $key !== '' && hash_equals($key, $token)) {
$caller = $name;
break;
}
}
}
if ($caller === null) {
return response()->json(['error' => 'Unauthorized.'], 401);
}
$request->attributes->set('service_caller', $caller);
return $next($request);
}
}
@@ -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(),
]);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use App\Services\Events\SubscriptionService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsurePro
{
public function __construct(private readonly SubscriptionService $subscriptions) {}
public function handle(Request $request, Closure $next): Response
{
// Paid Events plans are retired — all authenticated users have full access.
return $next($request);
}
}
+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,94 @@
<?php
namespace App\Http\Middleware;
use App\Support\LadillLink;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RedirectLegacyQrToLadillLink
{
/** @var list<string> */
private const STORAGE_ASSET_SUFFIXES = [
'event-logo',
'event-cover',
'itinerary-cover',
'business-logo',
'business-cover',
'menu-logo',
'menu-cover',
'church-logo',
'church-cover',
'book-cover',
'vcard-avatar',
'app-icon',
'file',
];
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 Events-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);
}
if ($this->servesStoredAsset($request) || $this->servesRegistrationTransaction($request)) {
return $next($request);
}
return LadillLink::legacyRedirect($request);
}
private function servesStoredAsset(Request $request): bool
{
$shortCode = $request->route('shortCode');
if (! is_string($shortCode) || $shortCode === '') {
return false;
}
$path = ltrim($request->path(), '/');
$prefix = 'q/'.$shortCode.'/';
if (! str_starts_with($path, $prefix)) {
return false;
}
$suffix = substr($path, strlen($prefix));
if (in_array($suffix, self::STORAGE_ASSET_SUFFIXES, true)) {
return true;
}
return (bool) preg_match('#^(image/\d+|item-image/\d+/\d+)$#', $suffix);
}
private function servesRegistrationTransaction(Request $request): bool
{
$shortCode = $request->route('shortCode');
if (! is_string($shortCode) || $shortCode === '') {
return false;
}
$path = ltrim($request->path(), '/');
$prefix = 'q/'.$shortCode.'/';
if (! str_starts_with($path, $prefix)) {
return false;
}
$suffix = substr($path, strlen($prefix));
return $suffix === 'register'
|| str_starts_with($suffix, 'register/')
|| str_starts_with($suffix, 'registered/')
|| str_starts_with($suffix, 'speaker/');
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* A customer's own domain (e.g. brand.com) pointed at one storefront (QrCode).
* Visiting the domain serves that storefront's public page. DNS is verified to
* resolve to the app, then Ladill Domains issues + installs the TLS cert.
*/
class CustomDomain extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_ACTIVE = 'active';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'qr_code_id', 'user_id', 'host', 'include_www', 'status', 'ssl_status',
'dns_verified_at', 'ssl_issued_at', 'ssl_expires_at', 'last_error',
];
protected function casts(): array
{
return [
'include_www' => 'boolean',
'dns_verified_at' => 'datetime',
'ssl_issued_at' => 'datetime',
'ssl_expires_at' => 'datetime',
];
}
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isLive(): bool
{
return $this->status === self::STATUS_ACTIVE && $this->ssl_status === self::STATUS_ACTIVE;
}
protected static function booted(): void
{
static::saving(function (CustomDomain $d) {
$d->host = strtolower(trim((string) $d->host, " \t\n\r\0\x0B./"));
$d->host = preg_replace('/^www\./', '', $d->host) ?: $d->host;
});
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models\Events;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProSubscription extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_CANCELED = 'canceled';
public const STATUS_PAST_DUE = 'past_due';
public const PLAN_PRO = 'pro';
public const PLAN_ENTERPRISE = 'enterprise';
protected $table = 'events_pro_subscriptions';
protected $fillable = [
'user_id', 'status', 'plan', 'price_minor', 'currency', 'auto_renew',
'started_at', 'current_period_end', 'last_charged_at', 'canceled_at',
'last_reference', 'last_error',
];
protected $casts = [
'auto_renew' => 'boolean',
'price_minor' => 'integer',
'started_at' => 'datetime',
'current_period_end' => 'datetime',
'last_charged_at' => 'datetime',
'canceled_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function entitled(): bool
{
return $this->status !== self::STATUS_PAST_DUE
&& $this->current_period_end !== null
&& $this->current_period_end->isFuture();
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class MessagingCredential extends Model
{
public const STATUS_VALID = 'valid';
public const STATUS_INVALID = 'invalid';
protected $table = 'events_messaging_credentials';
protected $fillable = [
'owner_ref',
'sms_api_key_encrypted',
'sms_api_key_prefix',
'sms_sender_id',
'sms_status',
'sms_validated_at',
'sms_last_error',
'bird_api_key_encrypted',
'bird_api_key_prefix',
'bird_from_email',
'bird_from_name',
'bird_status',
'bird_validated_at',
'bird_last_error',
];
protected $hidden = [
'sms_api_key_encrypted',
'bird_api_key_encrypted',
];
protected $casts = [
'sms_validated_at' => 'datetime',
'bird_validated_at' => 'datetime',
];
public function hasValidSms(): bool
{
return $this->sms_status === self::STATUS_VALID
&& filled($this->sms_api_key_encrypted)
&& filled($this->sms_sender_id);
}
public function hasValidBird(): bool
{
return $this->bird_status === self::STATUS_VALID
&& filled($this->bird_api_key_encrypted)
&& filled($this->bird_from_email);
}
public function smsApiKey(): ?string
{
if (! filled($this->sms_api_key_encrypted)) {
return null;
}
try {
return Crypt::decryptString($this->sms_api_key_encrypted);
} catch (\Throwable) {
return null;
}
}
public function birdApiKey(): ?string
{
if (! filled($this->bird_api_key_encrypted)) {
return null;
}
try {
return Crypt::decryptString($this->bird_api_key_encrypted);
} catch (\Throwable) {
return null;
}
}
public static function encryptKey(string $plain): string
{
return Crypt::encryptString($plain);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaymentGatewaySetting extends Model
{
public const PROVIDER_PAYSTACK = 'paystack';
public const PROVIDER_FLUTTERWAVE = 'flutterwave';
public const PROVIDER_HUBTEL = 'hubtel';
protected $fillable = [
'owner_ref',
'provider',
'public_key',
'secret_key',
'webhook_secret',
'is_active',
'metadata',
];
protected function casts(): array
{
return [
'public_key' => 'encrypted',
'secret_key' => 'encrypted',
'webhook_secret' => 'encrypted',
'is_active' => 'boolean',
'metadata' => 'array',
];
}
public function isConfigured(): bool
{
if (! $this->is_active) {
return false;
}
$public = trim((string) $this->public_key);
$secret = trim((string) $this->secret_key);
return match ($this->provider) {
self::PROVIDER_PAYSTACK => str_starts_with($public, 'pk_') && str_starts_with($secret, 'sk_'),
self::PROVIDER_FLUTTERWAVE, self::PROVIDER_HUBTEL => $public !== '' && $secret !== '',
default => false,
};
}
}
+10 -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;
@@ -85,23 +86,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();
}
/**
+23
View File
@@ -55,6 +55,29 @@ class QrEventRegistration extends Model
return round($this->amount_minor / 100, 2);
}
public function platformFeeMinor(): int
{
$metadata = (array) ($this->metadata ?? []);
if (isset($metadata['ladill_pay']['platform_fee_minor'])) {
return (int) $metadata['ladill_pay']['platform_fee_minor'];
}
if (isset($metadata['platform_fee_ghs'])) {
return (int) round((float) $metadata['platform_fee_ghs'] * 100);
}
$mode = $this->qrCode?->content()['mode'] ?? 'ticketing';
$rate = $mode === 'contributions' ? 0.035 : 0.055;
return (int) round($this->amount_minor * $rate);
}
public function netAmountMinor(): int
{
return max(0, $this->amount_minor - $this->platformFeeMinor());
}
public function isPaid(): bool
{
return $this->amount_minor > 0;
+5
View File
@@ -20,6 +20,7 @@ class QrSetting extends Model
'default_type',
'default_style',
'event_defaults',
'logo_path',
];
protected $casts = [
@@ -42,6 +43,7 @@ class QrSetting extends Model
'organizer',
'registration_open',
'badge_fields',
'format',
];
}
@@ -142,6 +144,9 @@ class QrSetting extends Model
'organizer' => mb_substr(trim((string) ($input['organizer'] ?? '')), 0, 120),
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
'badge_fields' => $badgeFields !== [] ? $badgeFields : ['Company', 'Role'],
'format' => in_array($input['format'] ?? 'in_person', ['in_person', 'virtual', 'hybrid'], true)
? ($input['format'] ?? 'in_person')
: 'in_person',
];
}
+12 -3
View File
@@ -4,10 +4,11 @@ namespace App\Providers;
use App\Models\QrCode;
use App\Policies\QrCodePolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use App\Services\Events\SubscriptionService;
use App\Support\MobileTopbar;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
@@ -19,9 +20,17 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
Gate::policy(QrCode::class, QrCodePolicy::class);
View::composer('partials.sidebar', function ($view) {
$account = function_exists('ladill_account') ? (ladill_account() ?? auth()->user()) : auth()->user();
$svc = app(SubscriptionService::class);
$view->with('isPro', $account ? $svc->isPro($account) : false);
$view->with('hasPaidPlan', $account ? $svc->hasPaidPlan($account) : false);
$view->with('isEnterprise', $account ? $svc->isEnterprise($account) : false);
});
View::composer(['partials.topbar', 'partials.topbar-qr'], function ($view) {
$view->with(MobileTopbar::resolve());
});
}
}
+15 -23
View File
@@ -88,36 +88,28 @@ 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->eventsSystemPrompt($ctx);
}
private function qrSystemPrompt(string $ctx): string
private function eventsSystemPrompt(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 Events (events.ladill.com).
Help users create events, sell tickets, and manage attendees. 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 Events does and where things live:
- Overview (Dashboard): upcoming events, ticket sales, recent attendees, and wallet balance.
- Events: create and manage events set details, date, venue, and ticket types/prices, then publish and share the event link or QR.
- Attendees: people holding tickets view them and check them in (scan their ticket QR at the door).
- Badges: design and print attendee badges.
- Programmes: build the event schedule/agenda (sessions, times, speakers).
- Payouts: ticket revenue lands in your Ladill wallet; request a withdrawal to your bank or mobile money from Payouts.
- Account, Wallet & Team: manage billing, balance, and teammates from the account area.
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 Events events, tickets, attendees, check-in, badges, programmes, and payouts.
- If asked about domains, hosting, email, plain QR codes, storefronts, donations, or payments, briefly say those live in other Ladill apps and suggest the app launcher.
- Never invent prices, ticket numbers, payout amounts, or wallet balances use the user context below or tell them where to check.
Current user context:
{$ctx}
+148
View File
@@ -3,6 +3,8 @@
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use App\Models\User;
/**
* Client for the platform Billing HTTP API the one UserWallet lives on the
@@ -84,4 +86,150 @@ class BillingClient
return (array) $res->json();
}
public function configured(): bool
{
return $this->base() !== '' && $this->token() !== '';
}
/**
* @return array{ok: bool, insufficient: bool, balance_minor: ?int, error: ?string}
*/
public function charge(User $user, int $amountMinor, string $reference, string $description): array
{
if (! $this->configured()) {
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing is not configured.'];
}
try {
$res = Http::withToken($this->token())
->acceptJson()->timeout(20)
->post($this->base().'/debit', [
'user' => $user->public_id,
'amount_minor' => $amountMinor,
'service' => (string) config('billing.service', 'events'),
'source' => 'subscription',
'reference' => $reference,
'description' => $description,
]);
} catch (\Throwable $e) {
Log::warning('Events BillingClient: charge failed', ['user' => $user->public_id, 'error' => $e->getMessage()]);
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Could not reach the billing service.'];
}
if ($res->status() === 402) {
return ['ok' => false, 'insufficient' => true, 'balance_minor' => (int) $res->json('balance_minor', 0), 'error' => 'Insufficient wallet balance.'];
}
if ($res->failed()) {
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing error ('.$res->status().').'];
}
return ['ok' => true, 'insufficient' => false, 'balance_minor' => null, 'error' => null];
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string}
*/
public function initiatePlanCheckout(
User $user,
string $plan,
int $months,
int $amountMinor,
string $returnUrl,
array $metadata = [],
): array {
$res = Http::withToken($this->token())
->acceptJson()->timeout(15)
->post($this->base().'/plan-checkout', [
'user' => $user->public_id,
'app' => (string) config('billing.service', 'events'),
'plan' => $plan,
'months' => $months,
'amount_minor' => $amountMinor,
'return_url' => $returnUrl,
'metadata' => $metadata,
]);
$res->throw();
return [
'checkout_url' => (string) $res->json('checkout_url'),
'reference' => (string) $res->json('reference'),
];
}
/** @return array<string, mixed> */
public function verifyPlanCheckout(string $reference): array
{
$res = Http::withToken($this->token())
->acceptJson()->timeout(15)
->post($this->base().'/plan-checkout/verify', [
'reference' => $reference,
]);
if ($res->status() === 422) {
return ['paid' => false, 'error' => $res->json('error')];
}
$res->throw();
return $res->json();
}
/**
* Write suite messaging plan entitlement (SoT for included email/SMS allowances).
*
* @param array{period_starts_at?: string, period_ends_at?: string|null, source?: string, metadata?: array} $options
*/
public function syncSuiteEntitlement(
string $publicId,
string $plan,
string $status,
string $reference,
array $options = [],
): bool {
if (! $this->configured()) {
return false;
}
$app = (string) config('billing.service', 'events');
$starts = $options['period_starts_at'] ?? now()->toIso8601String();
$ends = array_key_exists('period_ends_at', $options)
? $options['period_ends_at']
: now()->addDays(30)->toIso8601String();
try {
$res = Http::withToken($this->token())
->acceptJson()
->timeout(15)
->post($this->base().'/suite-entitlements', array_filter([
'user' => $publicId,
'app' => $app,
'plan' => $plan,
'status' => $status,
'period_starts_at' => $starts,
'period_ends_at' => $ends,
'source' => $options['source'] ?? 'wallet_debit',
'reference' => $reference,
'metadata' => $options['metadata'] ?? null,
], static fn ($v) => $v !== null));
if ($res->failed()) {
Log::warning('Events suite entitlement sync failed', [
'user' => $publicId,
'status' => $res->status(),
'body' => $res->body(),
]);
return false;
}
return true;
} catch (\Throwable $e) {
Log::warning('Events suite entitlement sync error', ['error' => $e->getMessage()]);
return false;
}
}
}
@@ -0,0 +1,119 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Platform suite email (zero-config): POST /api/smtp/messages/send with channel=suite.
* No Bird API key or smtp_service_id required when platform messaging is enabled.
*/
class PlatformEmailClient
{
private ?string $lastError = null;
private ?string $lastErrorCode = null;
public function lastError(): ?string
{
return $this->lastError;
}
public function lastErrorCode(): ?string
{
return $this->lastErrorCode;
}
private function base(): string
{
return rtrim((string) config('smtp.platform_api_url', 'https://ladill.com/api/smtp'), '/');
}
private function token(): string
{
return (string) config('smtp.platform_api_key', '');
}
public function isConfigured(): bool
{
return $this->token() !== '';
}
public function messagingSettingsUrl(): string
{
return (string) config(
'smtp.messaging_settings_url',
'https://account.ladill.com/account/settings/messaging?reason=messaging_mailbox_required&from=suite',
);
}
public function send(
string $ownerPublicId,
string $to,
string $subject,
string $html,
?string $text = null,
?string $fromName = null,
?string $idempotencyKey = null,
): bool {
$this->lastError = null;
$this->lastErrorCode = null;
if (! $this->isConfigured()) {
$this->lastError = 'Outbound email is not configured on this Events instance.';
return false;
}
$key = $idempotencyKey ?: ('events-email-'.Str::uuid());
try {
$res = Http::withToken($this->token())
->withHeaders(['Idempotency-Key' => $key])
->acceptJson()
->timeout(30)
// from_name: pass the organizer/company display name. Do not default
// to EVENTS_SMTP_FROM_NAME ("Ladill Events") — attendee mail is from the customer.
->post($this->base().'/messages/send', array_filter([
'user' => $ownerPublicId,
'channel' => 'suite',
'from_name' => $fromName,
'to' => $to,
'subject' => $subject,
'html' => $html,
'text' => $text,
'reference' => $key,
], static fn ($v) => $v !== null && $v !== ''));
if ($res->status() === 402) {
$this->lastErrorCode = (string) ($res->json('error') ?: 'allowance_exceeded');
$this->lastError = (string) ($res->json('error') ?: 'Email allowance exceeded. Upgrade your plan or top up your wallet.');
Log::warning('Events suite email: allowance/balance', ['user' => $ownerPublicId, 'body' => $res->body()]);
return false;
}
if ($res->failed()) {
$code = (string) ($res->json('error') ?: 'send_failed');
$this->lastErrorCode = $code;
if ($code === 'messaging_mailbox_required') {
$this->lastError = 'Set a Ladill mailbox for suite email in Account → Messaging settings: '.$this->messagingSettingsUrl();
} else {
$this->lastError = (string) ($res->json('error') ?: 'The email could not be sent. Please try again.');
}
Log::warning('Events suite email failed', ['status' => $res->status(), 'body' => $res->body()]);
return false;
}
return (bool) ($res->json('success') ?? true);
} catch (\Throwable $e) {
$this->lastError = 'The email could not be sent. Please try again.';
Log::warning('Events suite email error', ['error' => $e->getMessage()]);
return false;
}
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Platform suite SMS (zero-config): POST /api/sms/messages/send with channel=suite.
* No customer API key or sms_service_id required when platform messaging is enabled.
*/
class PlatformSmsClient
{
private ?string $lastError = null;
public function lastError(): ?string
{
return $this->lastError;
}
private function base(): string
{
return rtrim((string) config('sms.platform_api_url', 'https://ladill.com/api'), '/');
}
private function token(): string
{
return (string) config('sms.platform_api_key', '');
}
public function isConfigured(): bool
{
return $this->token() !== '';
}
public function send(
string $ownerPublicId,
string $to,
string $message,
?string $senderId = null,
?string $idempotencyKey = null,
): bool {
$this->lastError = null;
if (! $this->isConfigured()) {
$this->lastError = 'SMS is not configured on this Events instance.';
return false;
}
$key = $idempotencyKey ?: ('events-sms-'.Str::uuid());
try {
$res = Http::withToken($this->token())
->withHeaders(['Idempotency-Key' => $key])
->acceptJson()
->timeout(30)
->post($this->base().'/sms/messages/send', array_filter([
'user' => $ownerPublicId,
'channel' => 'suite',
'to' => $to,
'text' => $message,
'sender_id' => $senderId ?? config('sms.default_sender_id'),
'reference' => $key,
], static fn ($v) => $v !== null && $v !== ''));
if ($res->status() === 402) {
$this->lastError = (string) ($res->json('error') ?: 'SMS allowance exceeded. Upgrade your plan or top up your wallet.');
Log::warning('Events suite SMS: allowance/balance', ['user' => $ownerPublicId]);
return false;
}
if ($res->failed()) {
$this->lastError = (string) ($res->json('error') ?: 'The SMS could not be sent.');
Log::warning('Events suite SMS failed', ['status' => $res->status(), 'body' => $res->body()]);
return false;
}
return (bool) ($res->json('success') ?? true);
} catch (\Throwable $e) {
$this->lastError = 'Could not reach the SMS service.';
Log::warning('Events suite SMS error', ['error' => $e->getMessage()]);
return false;
}
}
}
+79 -20
View File
@@ -2,23 +2,46 @@
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use App\Services\Messaging\CustomerSmsClient;
use App\Services\Messaging\MessagingCredentialsService;
/**
* Attendee SMS: prefer platform suite messaging (zero-config), fall back to
* product SMS API keys when suite is unavailable.
*/
class SmsService
{
public function send(string $to, string $message): void
{
$apiKey = config('services.termii.api_key', '');
$senderId = config('services.termii.sender_id', config('app.name', 'LaDill'));
private ?string $lastError = null;
if ($apiKey === '') {
return;
public function __construct(
private readonly CustomerSmsClient $customer,
private readonly MessagingCredentialsService $credentials,
private readonly PlatformSmsClient $platformSms,
) {}
public function lastError(): ?string
{
return $this->lastError;
}
public function isConfiguredForOwner(string $ownerPublicId): bool
{
if ($this->platformSms->isConfigured()) {
return true;
}
return $this->credentials->forOwner($ownerPublicId)->hasValidSms();
}
public function send(string $ownerPublicId, string $to, string $message): bool
{
$this->lastError = null;
$phone = preg_replace('/\D+/', '', $to);
if (strlen($phone) < 9) {
return;
$this->lastError = 'Enter a valid phone number.';
return false;
}
if (strlen($phone) === 9) {
@@ -27,17 +50,53 @@ class SmsService
$phone = '233'.substr($phone, 1);
}
try {
Http::timeout(10)->post('https://v3.api.termii.com/api/sms/send', [
'api_key' => $apiKey,
'to' => $phone,
'from' => $senderId,
'sms' => $message,
'type' => 'plain',
'channel' => 'dnd',
]);
} catch (\Throwable $e) {
Log::warning('SMS send failed', ['to' => $phone, 'error' => $e->getMessage()]);
$credential = $this->credentials->forOwner($ownerPublicId);
if ($this->platformSms->isConfigured()) {
$sender = $credential->sms_sender_id ?: null;
$sent = $this->platformSms->send(
$ownerPublicId,
$phone,
$message,
$sender ? (string) $sender : null,
);
if ($sent) {
return true;
}
$suiteError = $this->platformSms->lastError();
if ($this->tryCustomerSms($credential, $phone, $message)) {
return true;
}
$this->lastError = $suiteError ?: $this->lastError;
return false;
}
return $this->tryCustomerSms($credential, $phone, $message);
}
private function tryCustomerSms(object $credential, string $phone, string $message): bool
{
if (! $credential->hasValidSms()) {
$this->lastError = 'SMS is not available (platform SMS key missing and no product SMS credentials).';
return false;
}
$apiKey = $credential->smsApiKey();
if (! $apiKey) {
$this->lastError = 'SMS credentials could not be decrypted. Reconnect SMS under Advanced keys.';
return false;
}
$sent = $this->customer->send($apiKey, $phone, $message, (string) $credential->sms_sender_id);
if (! $sent) {
$this->lastError = $this->customer->lastError() ?: 'The SMS could not be sent.';
}
return $sent;
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Services\CustomDomain;
use App\Models\CustomDomain;
use App\Models\QrCode;
use Illuminate\Support\Carbon;
class CustomDomainService
{
public function __construct(
private readonly DnsResolver $dns,
private readonly DomainsSslClient $ssl,
) {}
public function enabled(): bool
{
return (bool) config('customdomain.enabled', true) && $this->ssl->configured();
}
/** Attach a custom domain to a storefront (pending DNS). */
public function attach(QrCode $storefront, string $host, bool $includeWww = true): CustomDomain
{
return CustomDomain::create([
'qr_code_id' => $storefront->id,
'user_id' => $storefront->user_id,
'host' => $host,
'include_www' => $includeWww,
'status' => CustomDomain::STATUS_PENDING,
'ssl_status' => CustomDomain::STATUS_PENDING,
]);
}
/**
* Verify the domain's A record resolves to our app server, then ask Domains
* to issue the certificate. Returns [ok, message].
*
* @return array{0:bool,1:string}
*/
public function verifyAndProvision(CustomDomain $domain): array
{
$serverIp = (string) config('customdomain.server_ip');
$ips = $this->dns->aRecords($domain->host);
if (! in_array($serverIp, $ips, true)) {
$domain->forceFill([
'last_error' => 'DNS not pointing to '.$serverIp.' yet (found: '.(implode(', ', $ips) ?: 'none').').',
])->save();
return [false, 'DNS not verified yet. Add an A record for '.$domain->host.' pointing to '.$serverIp.', then try again.'];
}
$domain->forceFill(['dns_verified_at' => Carbon::now(), 'last_error' => null])->save();
$requested = $this->ssl->requestCertificate(
$domain->host,
$domain->include_www,
route('api.ssl-callback'),
);
if (! $requested) {
$domain->forceFill(['last_error' => 'Could not reach the SSL service. Please try again shortly.'])->save();
return [false, 'Domain verified, but issuing the certificate failed. Please retry in a moment.'];
}
return [true, 'Domain verified. Issuing your SSL certificate — this usually takes under a minute.'];
}
/** Apply a signed SSL completion callback from Ladill Domains. */
public function applyCallback(string $host, string $status, ?string $expiresAt, ?string $error): void
{
$domain = CustomDomain::where('host', strtolower(trim($host)))->first();
if (! $domain) {
return;
}
if ($status === 'active') {
$domain->forceFill([
'ssl_status' => CustomDomain::STATUS_ACTIVE,
'status' => CustomDomain::STATUS_ACTIVE,
'ssl_issued_at' => Carbon::now(),
'ssl_expires_at' => $expiresAt ? Carbon::parse($expiresAt) : null,
'last_error' => null,
])->save();
} else {
$domain->forceFill([
'ssl_status' => CustomDomain::STATUS_FAILED,
'status' => CustomDomain::STATUS_FAILED,
'last_error' => $error ?: 'Certificate issuance failed.',
])->save();
}
}
/** Resolve an incoming host to a live storefront, if any. */
public function resolveStorefront(string $host): ?QrCode
{
$host = preg_replace('/^www\./', '', strtolower(trim($host)));
$domain = CustomDomain::where('host', $host)->where('status', CustomDomain::STATUS_ACTIVE)->first();
return $domain?->qrCode;
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Services\CustomDomain;
class DnsResolver
{
/** @return array<int,string> the A-record IPs for a host */
public function aRecords(string $host): array
{
$records = @dns_get_record($host, DNS_A);
if (! is_array($records)) {
return [];
}
return array_values(array_filter(array_map(fn ($r) => $r['ip'] ?? null, $records)));
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Services\CustomDomain;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Thin client for Ladill Domains' central SSL API. Requests/queries certificates
* for a custom domain on the shared app server (target = app).
*/
class DomainsSslClient
{
public function configured(): bool
{
return (string) config('customdomain.ssl_api_key', '') !== '';
}
/**
* Ask Domains to issue + install a certificate for $host, calling back when done.
*/
public function requestCertificate(string $host, bool $includeWww, string $callbackUrl): bool
{
if (! $this->configured()) {
return false;
}
try {
$res = Http::withToken((string) config('customdomain.ssl_api_key'))
->acceptJson()->timeout(20)
->post(config('customdomain.ssl_api_url').'/ssl/certificates', [
'host' => $host,
'target' => 'app',
'include_www' => $includeWww,
'callback_url' => $callbackUrl,
'metadata' => ['nginx_include' => config('customdomain.nginx_include')],
]);
if ($res->failed()) {
Log::warning('DomainsSslClient: request failed', ['host' => $host, 'status' => $res->status()]);
return false;
}
return true;
} catch (\Throwable $e) {
Log::warning('DomainsSslClient: request error', ['host' => $host, 'error' => $e->getMessage()]);
return false;
}
}
/** Register/update this domain in Ladill Domains' connected-domains registry (best-effort). */
public function registerConnected(string $host, string $ownerPublicId, ?string $label = null, ?string $linkUrl = null): void
{
if (! $this->configured() || $ownerPublicId === '') {
return;
}
try {
Http::withToken((string) config('customdomain.ssl_api_key'))->acceptJson()->timeout(10)
->post(config('customdomain.ssl_api_url').'/connected-domains', array_filter([
'host' => $host,
'owner_public_id' => $ownerPublicId,
'label' => $label,
'link_url' => $linkUrl,
]));
} catch (\Throwable $e) {
Log::info('DomainsSslClient: connected register skipped', ['host' => $host, 'error' => $e->getMessage()]);
}
}
/** Remove this domain from the connected-domains registry (best-effort). */
public function removeConnected(string $host): void
{
if (! $this->configured()) {
return;
}
try {
Http::withToken((string) config('customdomain.ssl_api_key'))->acceptJson()->timeout(10)
->delete(config('customdomain.ssl_api_url').'/connected-domains/'.urlencode($host));
} catch (\Throwable $e) {
Log::info('DomainsSslClient: connected remove skipped', ['host' => $host, 'error' => $e->getMessage()]);
}
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Services\Events;
use App\Models\User;
use App\Support\DemoWorld;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Reseed Events demo tenants after a fresh SSO login.
*
* Only owner demo accounts trigger a full reseed. Staff training logins
* share the owner tenant but never wipe mid-demo.
*/
class DemoLoginReseeder
{
public function maybeResetAfterLogin(User $user): void
{
if (! (bool) config('demo_accounts.enabled', false)
|| ! (bool) config('demo_accounts.reset_on_login', true)) {
return;
}
if (DemoWorld::isStaffDemoEmail($user->email)) {
return;
}
$plan = $this->planForEmail($user->email);
if ($plan === null) {
return;
}
$seedUser = $this->resolveSeedUser($user);
if (! $seedUser) {
return;
}
$lockKey = 'demo-reseed:events:'.$seedUser->public_id;
if (! Cache::add($lockKey, 1, 120)) {
return;
}
$identity = (string) ($seedUser->email ?: $seedUser->public_id);
dispatch(function () use ($identity, $plan, $lockKey) {
try {
Artisan::call('demo:seed', [
'identity' => $identity,
'--plan' => $plan,
'--reset' => true,
]);
} catch (\Throwable $e) {
Log::warning('demo_events_login_reseed_failed', [
'identity' => $identity,
'plan' => $plan,
'error' => $e->getMessage(),
]);
} finally {
Cache::forget($lockKey);
}
})->afterResponse();
}
private function planForEmail(?string $email): ?string
{
$email = strtolower(trim((string) $email));
if ($email === '') {
return null;
}
$accounts = (array) config('demo_accounts.accounts', []);
if (isset($accounts[$email])) {
return $accounts[$email];
}
$members = (array) config('demo_accounts.member_emails', []);
$ownerEmail = $members[$email] ?? null;
return $ownerEmail ? ($accounts[$ownerEmail] ?? null) : null;
}
private function resolveSeedUser(User $user): ?User
{
$email = strtolower((string) $user->email);
$ownerEmail = ((array) config('demo_accounts.member_emails', []))[$email] ?? null;
if (! $ownerEmail) {
return $user;
}
return User::query()->where('email', $ownerEmail)->first() ?? $user;
}
}
+208
View File
@@ -0,0 +1,208 @@
<?php
namespace App\Services\Events;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\PlatformEmailClient;
use App\Services\Billing\PlatformSmsClient;
use App\Services\Billing\SmsService;
use App\Services\Messaging\MessagingCredentialsService;
use Illuminate\Support\Collection;
class EventCommsService
{
public const MODE_PROGRAMME = 'programme';
public const MODE_JOIN = 'join';
public const MODE_BOTH = 'both';
private const SMS_SEGMENT_CHARS = 160;
private const SMS_PRICE_GHS = 0.03;
private const EMAIL_PRICE_GHS = 0.0135;
public function __construct(
private readonly EventEmailService $email,
private readonly SmsService $sms,
private readonly MessagingCredentialsService $credentials,
private readonly EventProgrammeService $programmes,
private readonly PlatformEmailClient $platformEmail,
private readonly PlatformSmsClient $platformSms,
) {}
/** @return array{recipients: int, emails: int, sms: int, estimated_ghs: float, affordable: bool, integrations_error: ?string} */
public function preview(QrCode $event, string $mode): array
{
$regs = $this->confirmedRegistrations($event);
$emails = $regs->filter(fn ($r) => (bool) $r->attendee_email)->count();
$phones = $regs->filter(fn ($r) => (bool) $r->attendee_phone)->count();
$smsSegments = $phones * $this->estimateSmsSegments($event, $mode);
$estimated = round(($emails * self::EMAIL_PRICE_GHS) + ($smsSegments * self::SMS_PRICE_GHS), 2);
$ownerRef = (string) $event->user->public_id;
$integrationsError = $this->integrationsError($event, $mode, $emails, $phones);
return [
'recipients' => $regs->count(),
'emails' => $emails,
'sms' => $phones,
'estimated_ghs' => $estimated,
'affordable' => $integrationsError === null,
'integrations_error' => $integrationsError,
];
}
/**
* @return array{emailed: int, texted: int, skipped: int}
*/
public function send(QrCode $event, string $mode): array
{
$preview = $this->preview($event, $mode);
abort_if($preview['integrations_error'] !== null, 422, $preview['integrations_error']);
$programme = $this->programmeFor($event);
$eventName = $event->content()['name'] ?? $event->label;
$programmeUrl = $programme?->publicUrl();
$joinUrl = $this->primaryJoinUrl($event);
$ownerRef = (string) $event->user->public_id;
$organizerEmail = $event->user->email;
$organizerName = $event->user->name;
$emailed = 0;
$texted = 0;
$skipped = 0;
foreach ($this->confirmedRegistrations($event) as $reg) {
$sent = false;
if ($reg->attendee_email && in_array($mode, [self::MODE_PROGRAMME, self::MODE_BOTH], true) && $programmeUrl) {
if ($mode === self::MODE_BOTH) {
$sent = $this->email->sendJoinAndProgramme($ownerRef, $reg->attendee_email, $eventName, $joinUrl, $programmeUrl, $reg->attendee_name, $organizerEmail, $organizerName);
} else {
$sent = $this->email->sendProgrammeShare($ownerRef, $reg->attendee_email, $eventName, $programmeUrl, $reg->attendee_name, $organizerEmail, $organizerName);
}
if ($sent) {
$emailed++;
}
} elseif ($reg->attendee_email && $mode === self::MODE_JOIN && $joinUrl !== '') {
$sent = $this->email->sendJoinLink($ownerRef, $reg->attendee_email, $eventName, $joinUrl, $reg->attendee_name, $organizerEmail, $organizerName);
if ($sent) {
$emailed++;
}
}
if ($reg->attendee_phone) {
$message = $this->smsBody($reg, $eventName, $mode, $programmeUrl, $joinUrl);
if ($message !== '' && $this->sms->send($ownerRef, $reg->attendee_phone, $message)) {
$texted++;
$sent = true;
}
}
if (! $sent) {
$skipped++;
}
}
return compact('emailed', 'texted', 'skipped');
}
/** @return Collection<int, QrEventRegistration> */
private function confirmedRegistrations(QrCode $event): Collection
{
return $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->get();
}
private function programmeFor(QrCode $event): ?QrCode
{
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
if ($programmeId <= 0) {
return null;
}
return QrCode::where('id', $programmeId)
->where('user_id', $event->user_id)
->where('type', QrCode::TYPE_ITINERARY)
->first();
}
public function primaryJoinUrl(QrCode $event): string
{
foreach ((array) ($event->content()['virtual_sessions'] ?? []) as $session) {
if (! is_array($session)) {
continue;
}
$url = trim((string) ($session['join_url'] ?? ''));
if ($url !== '') {
return $url;
}
}
return '';
}
private function smsBody(QrEventRegistration $reg, string $eventName, string $mode, ?string $programmeUrl, string $joinUrl): string
{
$first = explode(' ', $reg->attendee_name ?? 'there')[0];
return match ($mode) {
self::MODE_PROGRAMME => $programmeUrl
? sprintf('Hi %s, the programme for %s: %s', $first, $eventName, $programmeUrl)
: '',
self::MODE_JOIN => $joinUrl !== ''
? sprintf('Hi %s, join %s here: %s', $first, $eventName, $joinUrl)
: '',
self::MODE_BOTH => ($programmeUrl && $joinUrl !== '')
? sprintf('Hi %s, %s — programme: %s | join: %s', $first, $eventName, $programmeUrl, $joinUrl)
: ($joinUrl !== '' ? sprintf('Hi %s, join %s: %s', $first, $eventName, $joinUrl) : ''),
default => '',
};
}
private function estimateSmsSegments(QrCode $event, string $mode): int
{
$sample = $this->smsBody(
new QrEventRegistration(['attendee_name' => 'Guest']),
$event->content()['name'] ?? $event->label,
$mode,
$this->programmeFor($event)?->publicUrl(),
$this->primaryJoinUrl($event),
);
return max(1, (int) ceil(strlen($sample) / self::SMS_SEGMENT_CHARS));
}
private function integrationsError(QrCode $event, string $mode, int $emails, int $phones): ?string
{
$ownerRef = (string) $event->user->public_id;
$credential = $this->credentials->forOwner($ownerRef);
$programmeUrl = $this->programmeFor($event)?->publicUrl();
$joinUrl = $this->primaryJoinUrl($event);
$needsEmail = $emails > 0 && (
(in_array($mode, [self::MODE_PROGRAMME, self::MODE_BOTH], true) && $programmeUrl)
|| ($mode === self::MODE_JOIN && $joinUrl !== '')
);
$needsSms = $phones > 0 && $this->smsBody(
new QrEventRegistration(['attendee_name' => 'Guest']),
$event->content()['name'] ?? $event->label,
$mode,
$programmeUrl,
$joinUrl,
) !== '';
$emailReady = $this->platformEmail->isConfigured() || $credential->hasValidBird();
$smsReady = $this->platformSms->isConfigured() || $credential->hasValidSms();
if ($needsEmail && ! $emailReady) {
return 'Email is not available yet. Bind a mailbox under Account → Messaging once platform keys are configured, or connect Bird under Advanced keys.';
}
if ($needsSms && ! $smsReady) {
return 'SMS is not available yet. Ensure platform SMS is configured on this Events instance, or connect a product SMS key under Advanced keys.';
}
return null;
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace App\Services\Events;
use App\Support\AccountBranding;
use Illuminate\Support\Facades\View;
class EventEmailService
{
public function __construct(private readonly EventMailer $mailer) {}
public function lastError(): ?string
{
return $this->mailer->lastError();
}
public function sendProgrammeShare(
string $ownerPublicId,
string $to,
string $eventName,
string $programmeUrl,
?string $attendeeName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool {
return $this->send(
$ownerPublicId,
$to,
'Programme for '.$eventName,
'mail.notifications.event-programme',
compact('eventName', 'programmeUrl', 'attendeeName'),
$organizerEmail,
$organizerName,
);
}
public function sendJoinLink(
string $ownerPublicId,
string $to,
string $eventName,
string $joinUrl,
?string $attendeeName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool {
return $this->send(
$ownerPublicId,
$to,
'Join '.$eventName,
'mail.notifications.event-join',
compact('eventName', 'joinUrl', 'attendeeName'),
$organizerEmail,
$organizerName,
);
}
public function sendJoinAndProgramme(
string $ownerPublicId,
string $to,
string $eventName,
string $joinUrl,
string $programmeUrl,
?string $attendeeName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool {
return $this->send(
$ownerPublicId,
$to,
$eventName.' — programme & join link',
'mail.notifications.event-join-programme',
compact('eventName', 'joinUrl', 'programmeUrl', 'attendeeName'),
$organizerEmail,
$organizerName,
);
}
public function sendRegistrationConfirmation(
string $ownerPublicId,
string $to,
string $eventName,
string $badgeCode,
?string $joinUrl = null,
?string $attendeeName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool {
return $this->send(
$ownerPublicId,
$to,
'Registration confirmed — '.$eventName,
'mail.notifications.event-registration-confirmed',
compact('eventName', 'badgeCode', 'joinUrl', 'attendeeName'),
$organizerEmail,
$organizerName,
);
}
public function sendSpeakerInvite(
string $ownerPublicId,
string $to,
string $eventName,
string $portalUrl,
?string $speakerName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool {
return $this->send(
$ownerPublicId,
$to,
'Speaker invitation — '.$eventName,
'mail.notifications.event-speaker-invite',
[
'eventName' => $eventName,
'portalUrl' => $portalUrl,
'speakerName' => $speakerName,
],
$organizerEmail,
$organizerName,
);
}
/** @param array<string, mixed> $viewData */
private function send(
string $ownerPublicId,
string $to,
string $subject,
string $view,
array $viewData,
?string $replyToEmail = null,
?string $replyToName = null,
): bool {
$brandingSettings = AccountBranding::forOwnerRef($ownerPublicId);
$viewData['logoUrl'] = AccountBranding::emailLogoUrl($brandingSettings);
$viewData['companyName'] = AccountBranding::companyName($brandingSettings);
// From display: organizer name, else account/company branding — never "Ladill Events".
$fromName = trim((string) ($replyToName ?: $viewData['companyName'] ?? '')) ?: null;
$html = View::make($view, $viewData)->render();
$text = strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html));
return $this->mailer->send(
$ownerPublicId,
$to,
$subject,
$html,
$text,
$replyToEmail,
$replyToName,
$fromName,
);
}
}
@@ -0,0 +1,206 @@
<?php
namespace App\Services\Events;
use App\Models\QrCode;
use App\Models\User;
use App\Services\Identity\IdentityClient;
use App\Services\Integrations\MailCalendarClient;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class EventMailCalendarSyncService
{
public function __construct(
private readonly MailCalendarClient $mail,
private readonly IdentityClient $identity,
) {}
public function sync(QrCode $event, User $owner): QrCode
{
if ($event->type !== QrCode::TYPE_EVENT || ! $this->mail->isConfigured()) {
return $event;
}
$mailbox = $this->resolveMailbox($owner);
if ($mailbox === null) {
return $event;
}
$entries = $this->entriesFor($event);
$previous = (array) data_get($event->payload, 'calendar_sync.items', []);
$synced = [];
foreach ($entries as $entry) {
$result = $this->mail->upsertEvent(array_merge($entry, [
'mailbox_email' => $mailbox,
]));
if ($result && isset($result['id'])) {
$synced[$entry['external_ref']] = (int) $result['id'];
}
}
foreach ($previous as $externalRef => $eventId) {
if (! isset($synced[$externalRef]) && is_numeric($eventId)) {
$this->mail->deleteEvent((int) $eventId, $mailbox, (string) $externalRef);
}
}
if ($entries === [] && $previous !== []) {
foreach ($previous as $externalRef => $eventId) {
if (is_numeric($eventId)) {
$this->mail->deleteEvent((int) $eventId, $mailbox, (string) $externalRef);
}
}
}
$payload = (array) ($event->payload ?? []);
$payload['calendar_sync'] = ['items' => $synced];
$event->payload = $payload;
$event->save();
Log::info('Events mail calendar synced', [
'event_id' => $event->id,
'entries' => count($synced),
]);
return $event->fresh();
}
/**
* @return list<array<string, mixed>>
*/
public function entriesFor(QrCode $event): array
{
$content = $event->content();
$format = (string) ($content['format'] ?? 'in_person');
$sessions = array_values(array_filter(
(array) ($content['virtual_sessions'] ?? []),
fn ($session) => is_array($session) && trim((string) ($session['title'] ?? '')) !== '',
));
$entries = [];
if (in_array($format, ['virtual', 'hybrid'], true) && $sessions !== []) {
foreach ($sessions as $session) {
$entry = $this->sessionEntry($event, $content, $session);
if ($entry !== null) {
$entries[] = $entry;
}
}
if ($entries === [] && filled($content['starts_at'] ?? null)) {
$entries[] = $this->mainEventEntry($event, $content);
} elseif ($format === 'hybrid' && filled($content['starts_at'] ?? null)) {
$entries[] = $this->mainEventEntry($event, $content, ' (in person)');
}
return $entries;
}
if (filled($content['starts_at'] ?? null)) {
$entries[] = $this->mainEventEntry($event, $content);
}
return $entries;
}
/**
* @param array<string, mixed> $content
* @param array<string, mixed> $session
* @return array<string, mixed>|null
*/
private function sessionEntry(QrCode $event, array $content, array $session): ?array
{
$sessionId = trim((string) ($session['id'] ?? ''));
if ($sessionId === '') {
$sessionId = 'vs-'.Str::lower(Str::random(12));
}
$starts = $this->parseDate($session['scheduled_at'] ?? $content['starts_at'] ?? null);
if ($starts === null) {
return null;
}
$duration = max(15, (int) ($session['duration_minutes'] ?? 60));
$joinUrl = trim((string) ($session['join_url'] ?? ''));
$description = trim((string) ($content['description'] ?? ''));
return [
'external_ref' => $this->sessionExternalRef($event->id, $sessionId),
'title' => trim((string) ($session['title'] ?? $content['name'] ?? $event->label)),
'body' => $description !== '' ? $description : null,
'starts_at' => $starts->toIso8601String(),
'ends_at' => $starts->copy()->addMinutes($duration)->toIso8601String(),
'join_url' => $joinUrl !== '' ? $joinUrl : $event->publicUrl(),
];
}
/**
* @param array<string, mixed> $content
* @return array<string, mixed>
*/
private function mainEventEntry(QrCode $event, array $content, string $titleSuffix = ''): array
{
$starts = $this->parseDate($content['starts_at'] ?? null) ?? now();
$ends = $this->parseDate($content['ends_at'] ?? null) ?? $starts->copy()->addHours(2);
$name = trim((string) ($content['name'] ?? $event->label));
$location = trim((string) ($content['location'] ?? ''));
$body = trim((string) ($content['description'] ?? ''));
if ($location !== '') {
$body = $body !== '' ? $body."\n\n".$location : $location;
}
return [
'external_ref' => $this->eventExternalRef($event->id),
'title' => $name.$titleSuffix,
'body' => $body !== '' ? $body : null,
'starts_at' => $starts->toIso8601String(),
'ends_at' => $ends->toIso8601String(),
'join_url' => $event->publicUrl(),
];
}
private function eventExternalRef(int $eventId): string
{
return 'events:'.$eventId;
}
private function sessionExternalRef(int $eventId, string $sessionId): string
{
return 'events:'.$eventId.':session:'.$sessionId;
}
private function parseDate(mixed $value): ?CarbonInterface
{
if ($value === null || $value === '') {
return null;
}
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
private function resolveMailbox(User $user): ?string
{
try {
$status = $this->identity->mailboxLinkStatus($user->public_id);
$linked = strtolower(trim((string) ($status['linked_mailbox'] ?? '')));
if (filter_var($linked, FILTER_VALIDATE_EMAIL)) {
return $linked;
}
} catch (\Throwable) {
// Fall back to the account email.
}
$email = strtolower(trim((string) $user->email));
return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace App\Services\Events;
use App\Services\Billing\PlatformEmailClient;
use App\Services\Messaging\CustomerEmailClient;
use App\Services\Messaging\MessagingCredentialsService;
use App\Support\AccountBranding;
/**
* Attendee/speaker email: prefer platform suite messaging (zero-config mailbox),
* fall back to customer Bird credentials when suite is unavailable.
*/
class EventMailer
{
private ?string $lastError = null;
public function __construct(
private readonly MessagingCredentialsService $credentials,
private readonly CustomerEmailClient $customerEmail,
private readonly PlatformEmailClient $platformEmail,
) {}
public function lastError(): ?string
{
return $this->lastError;
}
public function isConfiguredForOwner(string $ownerPublicId): bool
{
if ($this->platformEmail->isConfigured()) {
return true;
}
return $this->credentials->forOwner($ownerPublicId)->hasValidBird();
}
/** @deprecated Use isConfiguredForOwner() */
public function isConfigured(): bool
{
return $this->platformEmail->isConfigured();
}
public function send(
string $ownerPublicId,
string $to,
string $subject,
string $html,
?string $text = null,
?string $replyToEmail = null,
?string $replyToName = null,
?string $fromName = null,
): bool {
$this->lastError = null;
$displayName = $this->resolveFromName($ownerPublicId, $fromName, $replyToName);
if ($this->platformEmail->isConfigured()) {
$sent = $this->platformEmail->send($ownerPublicId, $to, $subject, $html, $text, $displayName);
if ($sent) {
return true;
}
// Suite unavailable (mailbox, allowance) — try Bird power-user path next.
$suiteError = $this->platformEmail->lastError();
if ($this->tryCustomerBird($ownerPublicId, $to, $subject, $html, $text, $displayName)) {
return true;
}
$this->lastError = $suiteError ?: $this->lastError;
return false;
}
return $this->tryCustomerBird($ownerPublicId, $to, $subject, $html, $text, $displayName);
}
private function resolveFromName(string $ownerPublicId, ?string $fromName, ?string $replyToName): ?string
{
foreach ([$fromName, $replyToName] as $candidate) {
$name = trim((string) $candidate);
if ($name !== '') {
return $name;
}
}
$settings = AccountBranding::forOwnerRef($ownerPublicId);
$company = trim(AccountBranding::companyName($settings));
return $company !== '' && strcasecmp($company, 'Company') !== 0 ? $company : null;
}
private function tryCustomerBird(
string $ownerPublicId,
string $to,
string $subject,
string $html,
?string $text,
?string $fromName = null,
): bool {
$credential = $this->credentials->forOwner($ownerPublicId);
if (! $credential->hasValidBird()) {
$this->lastError ??= 'Connect a Ladill mailbox in Account → Messaging, or connect Ladill Bird in Integrations.';
return false;
}
$apiKey = $credential->birdApiKey();
if (! $apiKey) {
$this->lastError = 'Bird credentials could not be decrypted. Reconnect Bird in Integrations.';
return false;
}
$sent = $this->customerEmail->send(
$apiKey,
(string) $credential->bird_from_email,
$credential->bird_from_name ?: $fromName,
$to,
$subject,
$html,
$text,
);
if (! $sent) {
$this->lastError = $this->customerEmail->lastError() ?: 'The email could not be sent.';
return false;
}
return true;
}
}
@@ -0,0 +1,119 @@
<?php
namespace App\Services\Events;
use App\Models\QrCode;
use Illuminate\Support\Collection;
class EventProgrammeService
{
/** @return array{title: string, days: list<array<string, mixed>>}|null */
public function snapshot(?QrCode $programme): ?array
{
if (! $programme || $programme->type !== QrCode::TYPE_ITINERARY) {
return null;
}
$content = $programme->content();
return [
'title' => (string) ($content['title'] ?? $programme->label),
'subtitle' => (string) ($content['subtitle'] ?? ''),
'location' => (string) ($content['location'] ?? ''),
'days' => array_values((array) ($content['days'] ?? [])),
];
}
/** @param list<string> $itemRefs */
public function itemsForRefs(?array $snapshot, array $itemRefs): array
{
if (! $snapshot) {
return [];
}
$refs = array_flip($itemRefs);
$matched = [];
foreach ((array) ($snapshot['days'] ?? []) as $day) {
foreach ((array) ($day['items'] ?? []) as $item) {
$ref = (string) ($item['ref'] ?? '');
if ($ref !== '' && isset($refs[$ref])) {
$matched[] = $item;
}
}
}
return $matched;
}
/** @param list<array<string, mixed>> $items */
public function hostsFromItems(array $items): array
{
return collect($items)
->flatMap(function ($item) {
if (! is_array($item)) {
return [];
}
$email = strtolower(trim((string) ($item['host_speaker_email'] ?? '')));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
return [$email];
}
$host = trim((string) ($item['host'] ?? ''));
if ($host !== '' && filter_var($host, FILTER_VALIDATE_EMAIL)) {
return [$host];
}
if ($host !== '') {
return [$host];
}
return [];
})
->unique()
->values()
->all();
}
/** @return list<array{email: string, name: string}> */
public function speakerOptionsForProgramme(QrCode $programme): array
{
$options = [];
foreach ($this->eventsLinkedToProgramme($programme) as $event) {
foreach ((array) ($event->content()['speakers'] ?? []) as $speaker) {
if (! is_array($speaker)) {
continue;
}
$email = trim((string) ($speaker['email'] ?? ''));
$name = trim((string) ($speaker['name'] ?? ''));
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL) || $name === '') {
continue;
}
$options[strtolower($email)] = [
'email' => $email,
'name' => $name,
];
}
}
return array_values($options);
}
/** @return Collection<int, QrCode> */
public function eventsLinkedToProgramme(QrCode $programme): Collection
{
if ($programme->type !== QrCode::TYPE_ITINERARY) {
return collect();
}
return QrCode::query()
->where('user_id', $programme->user_id)
->where('type', QrCode::TYPE_EVENT)
->get()
->filter(fn (QrCode $event) => (int) ($event->content()['programme_qr_id'] ?? 0) === $programme->id);
}
}
+185 -83
View File
@@ -4,10 +4,11 @@ namespace App\Services\Events;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use App\Services\Billing\PaystackService;
use App\Services\Billing\SmsService;
use App\Services\Meet\EventMeetAccessService;
use App\Services\Pay\PayClient;
use App\Services\Payments\MerchantGatewayService;
use App\Support\LadillLink;
use Illuminate\Support\Str;
use RuntimeException;
@@ -15,14 +16,16 @@ class EventRegistrationService
{
public function __construct(
private PayClient $pay,
private PaystackService $paystack,
private BillingClient $billing,
private MerchantGatewayService $gateway,
private SubscriptionService $subscriptions,
private SmsService $sms,
private EventEmailService $email,
private EventMeetAccessService $meetAccess,
) {}
/**
* Register an attendee. Free tiers confirm instantly; paid tiers return a
* Paystack checkout URL and stay pending until payment completes.
* Ladill Pay checkout URL and stay pending until payment completes.
*
* @param array<string, mixed> $data
* @return array{registration: QrEventRegistration, paid: bool, checkout_url: ?string}
@@ -93,6 +96,14 @@ class EventRegistrationService
}
$amountMinor = (int) round($priceGhs * 100);
$qrCode->loadMissing('user');
if ($qrCode->user && ! $this->subscriptions->canAcceptTicket($qrCode->user)) {
throw new RuntimeException('This organizer has reached the free monthly ticket limit. Ask them to upgrade Events Pro.');
}
$meetReturn = trim((string) ($data['meet_return'] ?? ''));
$metadata = $meetReturn !== '' ? ['meet_return' => $meetReturn] : null;
$registration = QrEventRegistration::create([
'qr_code_id' => $qrCode->id,
'user_id' => $qrCode->user_id,
@@ -106,6 +117,7 @@ class EventRegistrationService
'attendee_phone' => trim((string) ($data['attendee_phone'] ?? '')) ?: null,
'badge_fields' => $badgeFields ?: null,
'status' => $amountMinor > 0 ? QrEventRegistration::STATUS_PENDING : QrEventRegistration::STATUS_CONFIRMED,
'metadata' => $metadata,
]);
if ($amountMinor === 0) {
@@ -114,50 +126,85 @@ class EventRegistrationService
return ['registration' => $registration, 'paid' => false, 'checkout_url' => null];
}
$qrCode->loadMissing('user');
$feeTier = $mode === 'contributions' ? 'donations' : 'sales';
$lineName = $mode === 'contributions'
? sprintf('%s — %s', $content['name'] ?? $qrCode->label, $tierName)
: sprintf('%s ticket — %s', $content['name'] ?? $qrCode->label, $tierName);
$payOrder = $this->pay->createCheckout([
'merchant' => $qrCode->user->public_id,
'fee_tier' => $feeTier,
'source_service' => 'events',
'source_ref' => (string) $qrCode->id,
'callback_url' => route('qr.public.event.callback', ['shortCode' => $qrCode->short_code]),
'customer_name' => $registration->attendee_name,
'customer_email' => $email,
'customer_phone' => $registration->attendee_phone,
'line_items' => [
[
$feeTier = $mode === 'contributions' ? 'donations' : 'sales';
$callbackUrl = LadillLink::path($qrCode->short_code, 'register/callback');
$usesOwnerGateway = $this->gateway->shouldUseOwnerGateway($qrCode->user);
$payOrder = null;
if ($usesOwnerGateway) {
try {
$payOrder = $this->gateway->initializeCheckout(
$qrCode->user,
$amountMinor,
(string) ($content['currency'] ?? 'GHS'),
(string) config('pay.mini_checkout_email', 'pay@ladill.com'),
$callbackUrl,
'EVTP-'.strtoupper(Str::random(16)),
[
'title' => $lineName,
'registration_id' => $registration->id,
'registration_reference' => $registration->reference,
'qr_code_id' => $qrCode->id,
'mode' => $mode,
'attendee_email' => $email,
],
);
} catch (\Throwable) {
$usesOwnerGateway = false;
}
}
if (! $usesOwnerGateway) {
$payOrder = $this->pay->createCheckout([
'merchant' => $qrCode->user->public_id,
'fee_tier' => $feeTier,
'source_service' => 'events',
'source_ref' => (string) $qrCode->id,
'callback_url' => $callbackUrl,
'customer_name' => $name,
'customer_email' => $email,
'customer_phone' => $registration->attendee_phone,
'line_items' => [[
'name' => $lineName,
'unit_price_minor' => $amountMinor,
'quantity' => 1,
]],
'metadata' => [
'registration_id' => $registration->id,
'registration_reference' => $registration->reference,
'qr_code_id' => $qrCode->id,
'mode' => $mode,
'attendee_email' => $email,
],
],
'metadata' => [
'registration_id' => $registration->id,
'registration_reference' => $registration->reference,
'qr_code_id' => $qrCode->id,
'mode' => $mode,
],
]);
$registration->update([
'pay_order_id' => $payOrder['id'] ?? null,
'payment_reference' => $payOrder['reference'],
]);
]);
}
$checkoutUrl = (string) ($payOrder['checkout_url'] ?? '');
if ($checkoutUrl === '') {
throw new RuntimeException('Could not start checkout. Please try again.');
}
$registration->update([
'pay_order_id' => $usesOwnerGateway ? null : ($payOrder['id'] ?? null),
'payment_reference' => $payOrder['reference'],
'metadata' => array_merge((array) $registration->metadata, [
($usesOwnerGateway ? 'merchant_gateway_init' : 'ladill_pay_init') => [
'provider' => $payOrder['provider'] ?? 'ladill_pay',
'platform_fee_minor' => $payOrder['platform_fee_minor'] ?? null,
'merchant_amount_minor' => $payOrder['merchant_amount_minor'] ?? null,
],
]),
]);
return [
'registration' => $registration->fresh(),
'paid' => true,
'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,
];
}
@@ -167,25 +214,40 @@ class EventRegistrationService
return $this->completeLadillPay($reference);
}
return $this->completeLegacy($reference);
return $this->completeLegacyGateway($reference);
}
private function completeLadillPay(string $reference): QrEventRegistration
{
$registration = QrEventRegistration::where('payment_reference', $reference)
->where('status', QrEventRegistration::STATUS_PENDING)
->with('qrCode.user')
->firstOrFail();
if ($registration->status === QrEventRegistration::STATUS_CONFIRMED) {
return $registration;
}
if ($registration->status !== QrEventRegistration::STATUS_PENDING) {
throw new RuntimeException('This registration can no longer be completed.');
}
$payOrder = $this->pay->verify($reference);
$platformFeeGhs = ((int) ($payOrder['platform_fee_minor'] ?? 0)) / 100;
$verifiedMinor = (int) ($payOrder['amount_minor'] ?? 0);
if ($verifiedMinor > 0 && $verifiedMinor < $registration->amount_minor) {
$registration->update(['status' => QrEventRegistration::STATUS_FAILED]);
throw new RuntimeException('Payment amount did not match the registration total.');
}
$amountMinor = $verifiedMinor > 0 ? $verifiedMinor : $registration->amount_minor;
$registration->update([
'status' => QrEventRegistration::STATUS_CONFIRMED,
'paid_at' => now(),
'pay_order_id' => $payOrder['id'] ?? $registration->pay_order_id,
'amount_minor' => $amountMinor,
'metadata' => array_merge((array) $registration->metadata, [
'ladill_pay' => $payOrder,
'platform_fee_ghs' => $platformFeeGhs,
'platform_fee_ghs' => round(($payOrder['platform_fee_minor'] ?? 0) / 100, 2),
]),
]);
@@ -194,52 +256,41 @@ class EventRegistrationService
return $registration;
}
/** Legacy QREP-* references before Ladill Pay migration. */
private function completeLegacy(string $reference): QrEventRegistration
/** Legacy merchant-gateway references before Ladill Pay migration. */
private function completeLegacyGateway(string $reference): QrEventRegistration
{
$registration = QrEventRegistration::where('payment_reference', $reference)
->where('status', QrEventRegistration::STATUS_PENDING)
->with('qrCode.user')
->firstOrFail();
$data = $this->paystack->verifyTransaction($reference);
if (($data['status'] ?? '') !== 'success') {
$owner = $registration->qrCode?->user ?? $registration->organizer;
$result = $this->gateway->verify($owner, $reference);
if (! $result['paid']) {
$registration->update(['status' => QrEventRegistration::STATUS_FAILED]);
throw new RuntimeException('Payment was not successful.');
}
$feeRate = ($registration->qrCode?->content()['mode'] ?? 'ticketing') === 'contributions' ? 0.09 : 0.15;
$paidAmount = round(($data['amount'] ?? 0) / 100, 2);
$platformFee = round($paidAmount * $feeRate, 2);
$organizerAmountMinor = (int) round(($paidAmount - $platformFee) * 100);
$verifiedMinor = (int) ($result['amount_minor'] ?: 0);
if ($verifiedMinor > 0 && $verifiedMinor < $registration->amount_minor) {
$registration->update(['status' => QrEventRegistration::STATUS_FAILED]);
throw new RuntimeException('Payment amount did not match the registration total.');
}
$registration->update([
'status' => QrEventRegistration::STATUS_CONFIRMED,
'paid_at' => now(),
'amount_minor' => $verifiedMinor > 0 ? $verifiedMinor : $registration->amount_minor,
'metadata' => array_merge((array) $registration->metadata, [
'paystack' => $data,
'platform_fee_ghs' => $platformFee,
'legacy' => true,
'merchant_gateway' => [
'provider' => $result['provider'],
'reference' => $result['reference'],
'raw' => $result['raw'],
],
'platform_fee_ghs' => 0,
]),
]);
$mode = ($registration->qrCode->content()['mode'] ?? 'ticketing') === 'contributions' ? 'contributions' : 'ticketing';
$this->billing->credit(
$registration->organizer->public_id,
$organizerAmountMinor,
'events',
'pay',
$reference,
$registration->id,
sprintf(
'%s — %s (%s)',
$mode === 'contributions' ? 'Event contribution' : 'Event ticket',
$registration->qrCode->label,
$registration->tier_name
),
);
$this->notifyConfirmed($registration->fresh('qrCode'));
return $registration;
@@ -247,35 +298,86 @@ class EventRegistrationService
private function notifyConfirmed(QrEventRegistration $registration): void
{
if (! $registration->attendee_phone) {
$registration->loadMissing('qrCode.user');
$ownerPublicId = (string) $registration->qrCode?->user?->public_id;
if ($ownerPublicId === '') {
return;
}
$content = $registration->qrCode?->content() ?? [];
$eventName = $content['name'] ?? $registration->qrCode?->label ?? config('app.name');
$firstName = explode(' ', $registration->attendee_name)[0];
$joinUrl = $this->nextVirtualJoinUrl($content);
if (($content['mode'] ?? 'ticketing') === 'contributions') {
$message = sprintf(
'Hi %s, thank you for your %s of %s %s to %s. Ref: %s',
$firstName,
$registration->tier_name,
$registration->currency,
number_format($registration->amountCedis(), 2),
if ($registration->attendee_email) {
$this->email->sendRegistrationConfirmation(
$ownerPublicId,
$registration->attendee_email,
$eventName,
$registration->badge_code
);
} else {
$message = sprintf(
'Hi %s, you are registered for %s (%s). Badge code: %s',
$firstName,
$eventName,
$registration->tier_name,
$registration->badge_code
$registration->badge_code,
$joinUrl !== '' ? $joinUrl : null,
$registration->attendee_name,
$registration->qrCode?->user?->email,
$registration->qrCode?->user?->name,
);
}
$this->sms->send($registration->attendee_phone, $message);
if ($registration->attendee_phone) {
if (($content['mode'] ?? 'ticketing') === 'contributions') {
$message = sprintf(
'Hi %s, thank you for your %s of %s %s to %s. Ref: %s',
$firstName,
$registration->tier_name,
$registration->currency,
number_format($registration->amountCedis(), 2),
$eventName,
$registration->badge_code
);
} else {
$message = sprintf(
'Hi %s, you are registered for %s (%s). Badge code: %s',
$firstName,
$eventName,
$registration->tier_name,
$registration->badge_code
);
}
if ($joinUrl !== '') {
$message .= ' Join: '.$joinUrl;
}
$this->sms->send(
$ownerPublicId,
$registration->attendee_phone,
$message,
);
}
$this->meetAccess->syncRegistration($registration->fresh());
}
/** @param array<string, mixed> $content */
private function nextVirtualJoinUrl(array $content): string
{
$format = (string) ($content['format'] ?? 'in_person');
if (! in_array($format, ['virtual', 'hybrid'], true)) {
return '';
}
foreach ((array) ($content['virtual_sessions'] ?? []) as $session) {
if (! is_array($session)) {
continue;
}
$joinUrl = trim((string) ($session['join_url'] ?? ''));
if ($joinUrl !== '') {
return $joinUrl;
}
}
return '';
}
private function uniqueBadgeCode(): string
@@ -0,0 +1,340 @@
<?php
namespace App\Services\Events;
use App\Models\QrCode;
use App\Models\User;
use Illuminate\Support\Str;
class EventSpeakerInviteService
{
public const SOURCE_PROGRAMME = 'programme';
public const SOURCE_MANUAL = 'manual';
public function __construct(
private readonly EventEmailService $email,
private readonly EventProgrammeService $programmes,
private readonly EventSpeakerService $speakers,
) {}
/** @return list<array<string, mixed>> */
public function rosterWithInviteState(QrCode $event): array
{
return collect((array) ($event->content()['speakers'] ?? []))
->map(fn ($speaker) => $this->normalizeSpeakerRow(is_array($speaker) ? $speaker : []))
->filter(fn ($speaker) => ($speaker['name'] ?? '') !== '')
->values()
->all();
}
public function canSendManualInvite(QrCode $event, array $speaker): bool
{
return ($speaker['email'] ?? '') !== '';
}
/** @return array{ok: true}|array{ok: false, error: string} */
public function sendManualInvite(QrCode $event, User $owner, string $email): array
{
$email = strtolower(trim($email));
$event = $event->fresh() ?? $event;
$speaker = $this->findSpeakerByEmail($event, $email);
if ($speaker === null) {
return [
'ok' => false,
'error' => 'That speaker is not on the saved roster. Click Save speakers first, then send the invitation.',
];
}
if (! $this->canSendManualInvite($event, $speaker)) {
return [
'ok' => false,
'error' => 'Add a valid email address for this speaker before sending an invitation.',
];
}
return $this->deliverInvite($event, $owner, $speaker, self::SOURCE_MANUAL);
}
public function inviteProgrammeHosts(QrCode $programme, User $owner): int
{
if ($programme->type !== QrCode::TYPE_ITINERARY) {
return 0;
}
$sent = 0;
foreach ($this->programmes->eventsLinkedToProgramme($programme) as $event) {
$sent += $this->inviteHostsForEvent($event->fresh(), $owner, $programme);
}
return $sent;
}
public function inviteHostsForEvent(QrCode $event, User $owner, ?QrCode $programme = null): int
{
$programme ??= $this->speakers->linkedProgramme($event);
if (! $programme) {
return 0;
}
$hostEmails = $this->hostSpeakerEmailsFromProgramme($programme);
if ($hostEmails === []) {
return 0;
}
$sent = 0;
$speakers = $this->rosterWithInviteState($event);
foreach ($speakers as $speaker) {
$email = strtolower((string) ($speaker['email'] ?? ''));
if ($email === '' || ! in_array($email, $hostEmails, true)) {
continue;
}
if (($speaker['invite_source'] ?? '') === self::SOURCE_PROGRAMME && ! empty($speaker['invited_at'])) {
continue;
}
if ($this->deliverInvite($event, $owner, $speaker, self::SOURCE_PROGRAMME)['ok']) {
$sent++;
}
}
return $sent;
}
/** @return array{event: QrCode, speaker: array<string, mixed>}|null */
public function resolvePortal(string $shortCode, string $token): ?array
{
$token = trim($token);
if ($token === '') {
return null;
}
$event = QrCode::query()
->where('short_code', $shortCode)
->where('type', QrCode::TYPE_EVENT)
->where('is_active', true)
->first();
if (! $event) {
return null;
}
foreach ($this->rosterWithInviteState($event) as $speaker) {
if (hash_equals((string) ($speaker['invite_token'] ?? ''), $token)) {
return ['event' => $event, 'speaker' => $speaker];
}
}
return null;
}
/** @return array<string, mixed>|null */
public function verifyToken(QrCode $event, string $token): ?array
{
$token = trim($token);
if ($token === '') {
return null;
}
foreach ($this->rosterWithInviteState($event) as $speaker) {
if (hash_equals((string) ($speaker['invite_token'] ?? ''), $token)) {
return [
'email' => $speaker['email'],
'name' => $speaker['name'],
'role' => $speaker['role'] ?? '',
];
}
}
return null;
}
public function portalUrl(QrCode $event, string $token): string
{
return $event->publicPath('speaker/'.$token);
}
/** @param array<string, mixed> $session */
public function speakerJoinUrl(QrCode $event, array $session, string $token): ?string
{
$joinUrl = trim((string) ($session['join_url'] ?? ''));
if ($joinUrl === '') {
return null;
}
$separator = str_contains($joinUrl, '?') ? '&' : '?';
return $joinUrl.$separator.'speaker='.urlencode($token);
}
/** @return list<array{session: string, day: string, time: string, location: string}> */
public function assignmentsForSpeaker(QrCode $event, array $speaker): array
{
$programme = $this->speakers->linkedProgramme($event);
$snapshot = $this->programmes->snapshot($programme);
if (! $snapshot) {
return [];
}
$email = strtolower((string) ($speaker['email'] ?? ''));
$name = trim((string) ($speaker['name'] ?? ''));
$assignments = [];
foreach ((array) ($snapshot['days'] ?? []) as $day) {
$dayLabel = trim((string) ($day['date'] ?? $day['label'] ?? ''));
foreach ((array) ($day['items'] ?? []) as $item) {
if (! is_array($item)) {
continue;
}
$hostEmail = strtolower(trim((string) ($item['host_speaker_email'] ?? '')));
$hostName = trim((string) ($item['host'] ?? ''));
$matches = ($email !== '' && $hostEmail === $email)
|| ($name !== '' && strcasecmp($hostName, $name) === 0);
if (! $matches) {
continue;
}
$assignments[] = [
'session' => trim((string) ($item['title'] ?? '')),
'day' => $dayLabel,
'time' => trim((string) ($item['time'] ?? '')),
'location' => trim((string) ($item['location'] ?? '')),
];
}
}
return $assignments;
}
/** @return array{ok: true}|array{ok: false, error: string} */
private function deliverInvite(QrCode $event, User $owner, array $speaker, string $source): array
{
$email = trim((string) ($speaker['email'] ?? ''));
if ($email === '') {
return ['ok' => false, 'error' => 'Add a valid email address for this speaker before sending an invitation.'];
}
$ownerRef = trim((string) ($owner->public_id ?? ''));
if ($ownerRef === '') {
return ['ok' => false, 'error' => 'Could not resolve the event owner account for sending email.'];
}
if (! app(EventMailer::class)->isConfiguredForOwner($ownerRef)) {
return ['ok' => false, 'error' => 'Email is not available yet. Bind a mailbox under Account → Messaging once platform keys are configured, or connect Bird under Advanced keys.'];
}
$token = (string) ($speaker['invite_token'] ?? '');
if ($token === '') {
$token = Str::random(48);
}
$eventName = (string) ($event->content()['name'] ?? $event->label);
$portalUrl = $this->portalUrl($event, $token);
$sent = $this->email->sendSpeakerInvite(
$ownerRef,
$email,
$eventName,
$portalUrl,
$speaker['name'] ?? null,
$owner->email,
$owner->name,
);
if (! $sent) {
return [
'ok' => false,
'error' => $this->email->lastError()
?: 'The invitation email could not be sent. Check your Bird email balance and verified sending domain, then try again.',
];
}
$this->persistSpeakerInviteMeta($event, $email, $token, $source);
return ['ok' => true];
}
/** @return array<string, mixed>|null */
private function findSpeakerByEmail(QrCode $event, string $email): ?array
{
foreach ($this->rosterWithInviteState($event) as $speaker) {
if (strcasecmp((string) ($speaker['email'] ?? ''), $email) === 0) {
return $speaker;
}
}
return null;
}
private function persistSpeakerInviteMeta(QrCode $event, string $email, string $token, string $source): void
{
$email = strtolower(trim($email));
$content = $event->content();
$speakers = [];
foreach ((array) ($content['speakers'] ?? []) as $row) {
if (! is_array($row)) {
continue;
}
$rowEmail = strtolower(trim((string) ($row['email'] ?? '')));
if ($rowEmail === $email) {
$row['invite_token'] = $token;
$row['invited_at'] = now()->toIso8601String();
$row['invite_source'] = $source;
}
$speakers[] = $row;
}
$payload = (array) ($event->payload ?? []);
$payload['content'] = array_merge($content, ['speakers' => $speakers]);
$event->payload = $payload;
$event->save();
}
/** @return list<string> */
private function hostSpeakerEmailsFromProgramme(QrCode $programme): array
{
$emails = [];
foreach ((array) ($programme->content()['days'] ?? []) as $day) {
foreach ((array) ($day['items'] ?? []) as $item) {
if (! is_array($item)) {
continue;
}
$email = strtolower(trim((string) ($item['host_speaker_email'] ?? '')));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$emails[] = $email;
}
}
}
return array_values(array_unique($emails));
}
/** @param array<string, mixed> $speaker */
private function normalizeSpeakerRow(array $speaker): array
{
$name = trim((string) ($speaker['name'] ?? ''));
$email = trim((string) ($speaker['email'] ?? ''));
return [
'name' => $name,
'email' => filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : '',
'role' => trim((string) ($speaker['role'] ?? '')),
'bio' => trim((string) ($speaker['bio'] ?? '')),
'invite_token' => trim((string) ($speaker['invite_token'] ?? '')),
'invited_at' => $speaker['invited_at'] ?? null,
'invite_source' => $speaker['invite_source'] ?? null,
];
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Services\Events;
use App\Models\QrCode;
use Illuminate\Support\Collection;
class EventSpeakerService
{
public function __construct(private readonly EventProgrammeService $programmes) {}
public function linkedProgramme(QrCode $event): ?QrCode
{
if ($event->type !== QrCode::TYPE_EVENT) {
return null;
}
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
if ($programmeId <= 0) {
return null;
}
return QrCode::query()
->where('id', $programmeId)
->where('user_id', $event->user_id)
->where('type', QrCode::TYPE_ITINERARY)
->first();
}
/** @return list<array{name: string, email: string, role: string, bio: string, source: string}> */
public function rosterFor(QrCode $event): array
{
if ($event->type !== QrCode::TYPE_EVENT) {
return [];
}
$content = $event->content();
$roster = [];
foreach ((array) ($content['speakers'] ?? []) as $speaker) {
if (! is_array($speaker)) {
continue;
}
$name = trim((string) ($speaker['name'] ?? ''));
if ($name === '') {
continue;
}
$roster[] = [
'name' => $name,
'email' => trim((string) ($speaker['email'] ?? '')),
'role' => trim((string) ($speaker['role'] ?? '')),
'bio' => trim((string) ($speaker['bio'] ?? '')),
'source' => 'roster',
];
}
foreach ($this->programmeAssignments($event) as $assignment) {
$name = trim((string) ($assignment['name'] ?? ''));
if ($name === '') {
continue;
}
$exists = collect($roster)->contains(fn ($row) => strcasecmp($row['name'], $name) === 0);
if ($exists) {
continue;
}
$roster[] = [
'name' => $name,
'email' => '',
'role' => trim((string) ($assignment['session'] ?? '')),
'bio' => '',
'source' => 'programme',
];
}
return $roster;
}
/** @return list<array{name: string, session: string, day: string, time: string}> */
public function programmeAssignments(QrCode $event): array
{
$programme = $this->linkedProgramme($event);
$snapshot = $this->programmes->snapshot($programme);
if (! $snapshot) {
return [];
}
$assignments = [];
foreach ((array) ($snapshot['days'] ?? []) as $day) {
$dayLabel = trim((string) ($day['date'] ?? $day['label'] ?? ''));
foreach ((array) ($day['items'] ?? []) as $item) {
if (! is_array($item)) {
continue;
}
$host = trim((string) ($item['host'] ?? ''));
if ($host === '') {
continue;
}
$assignments[] = [
'name' => $host,
'session' => trim((string) ($item['title'] ?? '')),
'day' => $dayLabel,
'time' => trim((string) ($item['time'] ?? '')),
];
}
}
return $assignments;
}
/** @return Collection<int, QrCode> */
public function eventsForAccount(int $userId, ?string $search = null): Collection
{
return QrCode::query()
->where('user_id', $userId)
->where('type', QrCode::TYPE_EVENT)
->when($search !== null && $search !== '', fn ($q) => $q->where('label', 'like', "%{$search}%"))
->latest()
->get();
}
}
+305
View File
@@ -0,0 +1,305 @@
<?php
namespace App\Services\Events;
use App\Models\Events\ProSubscription;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Models\User;
use App\Services\Billing\BillingClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class SubscriptionService
{
public function __construct(private readonly BillingClient $billing) {}
public function gatingActive(): bool
{
// Paid Events subscriptions are retired — product is free for all accounts.
return false;
}
public function priceMinor(): int
{
return (int) config('events.plans.pro.price_minor', config('events.pro.price_minor', 4900));
}
public function enterprisePriceMinor(): int
{
return (int) config('events.plans.enterprise.price_minor', 14900);
}
public function subscriptionFor(User $user): ?ProSubscription
{
if (! Schema::hasTable('events_pro_subscriptions')) {
return null;
}
return ProSubscription::where('user_id', $user->id)->first();
}
public function planKey(User $user): string
{
if (! $this->gatingActive()) {
return ProSubscription::PLAN_PRO;
}
$sub = $this->subscriptionFor($user);
if (! $sub || ! $sub->entitled()) {
return 'free';
}
return $sub->plan === ProSubscription::PLAN_ENTERPRISE
? ProSubscription::PLAN_ENTERPRISE
: ProSubscription::PLAN_PRO;
}
public function hasPaidPlan(User $user): bool
{
if (! $this->gatingActive()) {
return true;
}
return (bool) $this->subscriptionFor($user)?->entitled();
}
public function isEnterprise(User $user): bool
{
return $this->planKey($user) === ProSubscription::PLAN_ENTERPRISE;
}
public function isPro(User $user): bool
{
return $this->hasPaidPlan($user);
}
/** Custom / owner payment gateways are retired — Ladill Pay only. */
public function canUsePaymentGateway(User $user): bool
{
return false;
}
public function liveEventCount(User $user): int
{
return QrCode::query()
->where('user_id', $user->id)
->where('type', QrCode::TYPE_EVENT)
->count();
}
public function canCreateEvent(User $user): bool
{
if ($this->isPro($user)) {
return true;
}
return $this->liveEventCount($user) < (int) config('events.free.max_live_events', 2);
}
public function ticketsThisMonth(User $user): int
{
return QrEventRegistration::query()
->where('user_id', $user->id)
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->where('created_at', '>=', now()->startOfMonth())
->count();
}
public function canAcceptTicket(User $user): bool
{
if ($this->isPro($user)) {
return true;
}
return $this->ticketsThisMonth($user) < (int) config('events.free.max_tickets_per_month', 100);
}
/** @return array{0:bool,1:string} */
public function subscribe(User $user): array
{
$existing = $this->subscriptionFor($user);
if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) {
return [true, 'You already have an active subscription.'];
}
if ($existing?->plan === ProSubscription::PLAN_ENTERPRISE) {
return $this->subscribeEnterprise($user);
}
return $this->activateWalletPlan($user, ProSubscription::PLAN_PRO, $this->priceMinor(), 'Ladill Events Pro — monthly subscription', 'Welcome to Ladill Events Pro!');
}
/** @return array{0:bool,1:string} */
public function subscribeEnterprise(User $user): array
{
$existing = $this->subscriptionFor($user);
if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) {
return [true, 'You already have an active subscription.'];
}
return $this->activateWalletPlan(
$user,
ProSubscription::PLAN_ENTERPRISE,
$this->enterprisePriceMinor(),
'Ladill Events Business — monthly subscription',
'Welcome to Ladill Events Business!',
);
}
public function activatePrepaid(User $user, string $plan, int $months): void
{
$existing = $this->subscriptionFor($user);
$price = $plan === ProSubscription::PLAN_ENTERPRISE
? $this->enterprisePriceMinor()
: $this->priceMinor();
$periodEnd = Carbon::now()->addMonths($months);
$reference = 'events-prepaid-'.$plan.'-'.$user->id.'-'.now()->format('YmdHis');
ProSubscription::updateOrCreate(
['user_id' => $user->id],
[
'plan' => $plan,
'status' => ProSubscription::STATUS_ACTIVE,
'price_minor' => $price,
'currency' => (string) config('events.pro.currency', 'GHS'),
'auto_renew' => false,
'started_at' => $existing?->started_at ?? now(),
'current_period_end' => $periodEnd,
'last_charged_at' => now(),
'canceled_at' => null,
'last_reference' => $reference,
'last_error' => null,
],
);
// Prepaid checkout already writes entitlements on the platform; this is a repair/sync.
$this->syncMessagingEntitlement($user, $plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd, 'checkout');
}
/** @return array{0:bool,1:string} */
public function cancel(User $user): array
{
$sub = $this->subscriptionFor($user);
if (! $sub || ! $sub->entitled()) {
return [false, 'You do not have an active subscription.'];
}
$sub->forceFill([
'status' => ProSubscription::STATUS_CANCELED,
'auto_renew' => false,
'canceled_at' => now(),
])->save();
$until = optional($sub->current_period_end)->format('d M Y');
return [true, "Auto-renew is off. You keep access until {$until}."];
}
public function renewIfDue(ProSubscription $sub): void
{
if (! $sub->auto_renew || $sub->status !== ProSubscription::STATUS_ACTIVE) {
return;
}
if ($sub->current_period_end && $sub->current_period_end->isFuture()) {
return;
}
$user = $sub->user;
if (! $user) {
return;
}
$planLabel = $sub->plan === ProSubscription::PLAN_ENTERPRISE ? 'Business' : 'Pro';
$reference = 'events-'.$sub->plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6);
$result = $this->billing->charge($user, $sub->price_minor, $reference, "Ladill Events {$planLabel} — renewal");
if ($result['ok']) {
$periodEnd = Carbon::now()->addDays((int) config('events.pro.period_days', 30));
$sub->forceFill([
'current_period_end' => $periodEnd,
'last_charged_at' => now(),
'last_reference' => $reference,
'last_error' => null,
])->save();
$this->syncMessagingEntitlement($user, $sub->plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd);
return;
}
$graceEnd = optional($sub->current_period_end)->addDays((int) config('events.pro.grace_days', 3));
if ($graceEnd && $graceEnd->isPast()) {
$sub->forceFill(['status' => ProSubscription::STATUS_PAST_DUE, 'last_error' => $result['error']])->save();
$this->syncMessagingEntitlement($user, $sub->plan, 'past_due', $reference.'-pastdue', $sub->current_period_end);
} else {
$sub->forceFill(['last_error' => $result['error']])->save();
}
}
/** @return array{0:bool,1:string} */
private function activateWalletPlan(User $user, string $plan, int $price, string $description, string $successMessage): array
{
$existing = $this->subscriptionFor($user);
$reference = 'events-'.$plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6);
$result = $this->billing->charge($user, $price, $reference, $description);
if (! $result['ok']) {
if ($result['insufficient']) {
$bal = number_format(((int) $result['balance_minor']) / 100, 2);
return [false, "Your wallet balance (GHS {$bal}) is too low. Top up, then subscribe."];
}
return [false, $result['error'] ?? 'Could not start your subscription. Please try again.'];
}
$periodEnd = Carbon::now()->addDays((int) config('events.pro.period_days', 30));
ProSubscription::updateOrCreate(
['user_id' => $user->id],
[
'plan' => $plan,
'status' => ProSubscription::STATUS_ACTIVE,
'price_minor' => $price,
'currency' => (string) config('events.pro.currency', 'GHS'),
'auto_renew' => true,
'started_at' => $existing?->started_at ?? now(),
'current_period_end' => $periodEnd,
'last_charged_at' => now(),
'canceled_at' => null,
'last_reference' => $reference,
'last_error' => null,
],
);
$this->syncMessagingEntitlement($user, $plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd);
return [true, $successMessage.' Your subscription is active.'];
}
/**
* Map Events plan platform suite entitlement plan (enterprise = Business tier).
*/
private function syncMessagingEntitlement(
User $user,
string $plan,
string $status,
string $reference,
?Carbon $periodEnd,
string $source = 'wallet_debit',
): void {
$platformPlan = $plan === ProSubscription::PLAN_ENTERPRISE ? 'enterprise' : ($plan === 'free' ? 'free' : 'pro');
$publicId = (string) ($user->public_id ?? '');
if ($publicId === '') {
return;
}
$this->billing->syncSuiteEntitlement($publicId, $platformPlan, $status, $reference, [
'period_starts_at' => now()->toIso8601String(),
'period_ends_at' => $periodEnd?->toIso8601String(),
'source' => $source,
]);
}
}
+75
View File
@@ -2,7 +2,9 @@
namespace App\Services\Identity;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
class IdentityClient
{
@@ -43,6 +45,79 @@ class IdentityClient
return (array) $response->json('data', []);
}
/** @return array<int, array<string, mixed>> */
public function banks(string $type = 'mobile_money'): array
{
$queryType = $type === 'mobile_money' ? 'mobile_money' : 'bank';
$response = $this->request()->get($this->url('/identity/banks'), [
'type' => $queryType,
]);
$response->throw();
return (array) $response->json('data', []);
}
/** @return array<string, mixed>|null */
public function payoutAccount(string $publicId): ?array
{
$response = $this->request()->get($this->url('/identity/payout-account'), [
'user' => $publicId,
]);
$response->throw();
$account = $response->json('data.payout_account');
return is_array($account) ? $account : null;
}
/** @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function updatePayoutAccount(string $publicId, array $data): array
{
$response = $this->request()->put($this->url('/identity/payout-account'), array_merge(
['user' => $publicId],
$data,
));
$this->throwValidation($response, 'account_number');
$account = $response->json('data.payout_account');
return is_array($account) ? $account : [];
}
/** @return array<int, array<string, mixed>> */
public function withdrawals(string $publicId): array
{
$response = $this->request()->get($this->url('/identity/wallet/withdrawals'), [
'user' => $publicId,
]);
$response->throw();
return (array) $response->json('data', []);
}
/** @return array<string, mixed> */
public function withdraw(string $publicId, float $amountGhs): array
{
$response = $this->request()->post($this->url('/identity/wallet/withdraw'), [
'user' => $publicId,
'amount' => $amountGhs,
]);
$this->throwValidation($response, 'amount');
return (array) $response->json('data', []);
}
private function throwValidation(Response $response, string $fallbackField = 'base'): void
{
if ($response->status() === 422) {
throw ValidationException::withMessages(
$response->json('errors') ?: [$fallbackField => [$response->json('message') ?: 'Request failed.']],
);
}
}
private function request()
{
return Http::withToken((string) config('identity.api_key'))
@@ -0,0 +1,81 @@
<?php
namespace App\Services\Integrations;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MailCalendarClient
{
public function isConfigured(): bool
{
return filled(config('mail_calendar.api_key')) && filled(config('mail_calendar.api_url'));
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>|null
*/
public function upsertEvent(array $payload): ?array
{
if (! $this->isConfigured()) {
return null;
}
try {
$response = Http::withToken((string) config('mail_calendar.api_key'))
->acceptJson()
->asJson()
->connectTimeout(10)
->timeout(15)
->post($this->baseUrl().'/calendar/events', $payload);
} catch (ConnectionException $e) {
Log::warning('Events mail calendar upsert failed', ['error' => $e->getMessage()]);
return null;
}
if (! $response->successful()) {
Log::warning('Events mail calendar upsert failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
return null;
}
$data = $response->json('data');
return is_array($data) ? $data : null;
}
public function deleteEvent(int $eventId, string $mailboxEmail, string $externalRef): bool
{
if (! $this->isConfigured()) {
return false;
}
try {
$response = Http::withToken((string) config('mail_calendar.api_key'))
->acceptJson()
->connectTimeout(10)
->timeout(15)
->delete($this->baseUrl().'/calendar/events/'.$eventId, [
'mailbox_email' => strtolower(trim($mailboxEmail)),
'external_ref' => $externalRef,
]);
} catch (ConnectionException $e) {
Log::warning('Events mail calendar delete failed', ['error' => $e->getMessage()]);
return false;
}
return $response->successful();
}
protected function baseUrl(): string
{
return rtrim((string) config('mail_calendar.api_url'), '/');
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Services\Meet;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use Illuminate\Support\Facades\Log;
class EventMeetAccessService
{
public function syncRegistration(QrEventRegistration $registration): void
{
$event = $registration->qrCode;
if (! $event || $event->type !== QrCode::TYPE_EVENT) {
return;
}
$format = (string) ($event->content()['format'] ?? 'in_person');
if (! in_array($format, ['virtual', 'hybrid'], true)) {
return;
}
if ((string) config('meet.key', '') === '' || ! $registration->attendee_email) {
return;
}
$ownerRef = (string) $event->user->public_id;
$client = MeetClient::for($ownerRef);
foreach ((array) ($event->content()['virtual_sessions'] ?? []) as $session) {
if (! is_array($session)) {
continue;
}
$roomUuid = trim((string) ($session['meet_room_uuid'] ?? ''));
if ($roomUuid === '') {
continue;
}
try {
$client->updateRoom($roomUuid, [
'invite_emails' => [$registration->attendee_email],
]);
} catch (\Throwable $e) {
Log::warning('Events Meet invite failed', [
'registration_id' => $registration->id,
'room_uuid' => $roomUuid,
'error' => $e->getMessage(),
]);
}
}
$metadata = (array) ($registration->metadata ?? []);
$metadata['meet_invited_at'] = now()->toIso8601String();
$registration->update(['metadata' => $metadata]);
}
public function isJoinWindowOpen(array $session, int $minutesBefore = 15): bool
{
$scheduled = trim((string) ($session['scheduled_at'] ?? ''));
if ($scheduled === '') {
return true;
}
try {
$start = \Carbon\Carbon::parse($scheduled);
} catch (\Throwable) {
return true;
}
$duration = max(15, (int) ($session['duration_minutes'] ?? 60));
return now()->between($start->copy()->subMinutes($minutesBefore), $start->copy()->addMinutes($duration));
}
}
@@ -0,0 +1,151 @@
<?php
namespace App\Services\Meet;
use App\Models\QrCode;
use App\Models\User;
use Illuminate\Support\Str;
class EventMeetRoomLinkService
{
public function __construct(private readonly EventMeetSyncService $sync) {}
/**
* @return list<array<string, mixed>>
*/
public function linkableEvents(User $owner): array
{
return QrCode::query()
->where('user_id', $owner->id)
->where('type', QrCode::TYPE_EVENT)
->orderByDesc('updated_at')
->get()
->filter(function (QrCode $event) {
$format = (string) ($event->content()['format'] ?? 'in_person');
return in_array($format, ['virtual', 'hybrid'], true);
})
->map(fn (QrCode $event) => $this->summarizeEvent($event))
->values()
->all();
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function linkRoom(User $owner, QrCode $event, array $payload): array
{
abort_unless($event->user_id === $owner->id, 403);
abort_unless($event->type === QrCode::TYPE_EVENT, 422);
$format = (string) ($event->content()['format'] ?? 'in_person');
abort_unless(in_array($format, ['virtual', 'hybrid'], true), 422, 'Only virtual or hybrid events can be linked.');
$roomUuid = trim((string) ($payload['meet_room_uuid'] ?? ''));
$roomType = in_array($payload['meet_room_type'] ?? '', ['town_hall', 'webinar'], true)
? (string) $payload['meet_room_type']
: 'town_hall';
abort_unless($roomUuid !== '', 422, 'meet_room_uuid is required.');
$joinUrl = trim((string) ($payload['join_url'] ?? ''));
$title = trim((string) ($payload['title'] ?? $event->label ?? 'Virtual session'));
$sessionId = trim((string) ($payload['virtual_session_id'] ?? ''));
$content = $event->content();
$sessions = array_values(array_filter(
(array) ($content['virtual_sessions'] ?? []),
fn ($session) => is_array($session),
));
$matchedIndex = null;
if ($sessionId !== '') {
foreach ($sessions as $index => $session) {
if ((string) ($session['id'] ?? '') === $sessionId) {
$matchedIndex = $index;
break;
}
}
}
if ($matchedIndex === null) {
foreach ($sessions as $index => $session) {
$existingUuid = trim((string) ($session['meet_room_uuid'] ?? ''));
$existingType = (string) ($session['meet_room_type'] ?? 'town_hall');
if ($existingUuid === '' && $existingType === $roomType) {
$matchedIndex = $index;
break;
}
}
}
if ($matchedIndex === null) {
$sessionId = $sessionId !== '' ? $sessionId : 'vs-'.Str::lower(Str::random(12));
$sessions[] = [
'id' => $sessionId,
'title' => $title,
'meet_room_type' => $roomType,
'scheduled_at' => $payload['scheduled_at'] ?? $content['starts_at'] ?? null,
'duration_minutes' => max(15, (int) ($payload['duration_minutes'] ?? 60)),
];
$matchedIndex = count($sessions) - 1;
} else {
$sessionId = (string) ($sessions[$matchedIndex]['id'] ?? $sessionId);
}
$sessions[$matchedIndex] = array_merge($sessions[$matchedIndex], [
'id' => $sessionId,
'title' => $title !== '' ? $title : (string) ($sessions[$matchedIndex]['title'] ?? 'Virtual session'),
'meet_room_type' => $roomType,
'meet_room_uuid' => $roomUuid,
'join_url' => $joinUrl !== '' ? $joinUrl : ($sessions[$matchedIndex]['join_url'] ?? null),
'scheduled_at' => $payload['scheduled_at'] ?? $sessions[$matchedIndex]['scheduled_at'] ?? null,
'duration_minutes' => max(15, (int) ($payload['duration_minutes'] ?? ($sessions[$matchedIndex]['duration_minutes'] ?? 60))),
]);
$payloadData = (array) ($event->payload ?? []);
$payloadData['content'] = array_merge($content, ['virtual_sessions' => $sessions]);
$event->payload = $payloadData;
$event->save();
$event = $this->sync->sync($event->fresh(), $owner);
$linkedSession = collect($event->content()['virtual_sessions'] ?? [])
->first(fn ($session) => is_array($session) && (string) ($session['id'] ?? '') === $sessionId);
return array_merge($this->summarizeEvent($event), [
'entity_id' => $sessionId,
'meet_room_uuid' => $roomUuid,
'join_url' => $joinUrl,
], $this->sync->programmePayloadForSession($event, is_array($linkedSession) ? $linkedSession : $sessions[$matchedIndex]));
}
/** @return array<string, mixed> */
private function summarizeEvent(QrCode $event): array
{
$content = $event->content();
$sessions = collect((array) ($content['virtual_sessions'] ?? []))
->filter(fn ($session) => is_array($session))
->map(fn (array $session) => [
'id' => (string) ($session['id'] ?? ''),
'title' => (string) ($session['title'] ?? ''),
'meet_room_type' => (string) ($session['meet_room_type'] ?? 'town_hall'),
'meet_room_uuid' => ($uuid = trim((string) ($session['meet_room_uuid'] ?? ''))) !== '' ? $uuid : null,
'scheduled_at' => $session['scheduled_at'] ?? null,
'duration_minutes' => (int) ($session['duration_minutes'] ?? 60),
])
->values()
->all();
return [
'id' => $event->id,
'title' => (string) ($content['title'] ?? $event->label ?? 'Event'),
'format' => (string) ($content['format'] ?? 'in_person'),
'short_code' => $event->short_code,
'registration_url' => $event->publicUrl(),
'admin_url' => url('/events/'.$event->id.'/attendees'),
'edit_url' => url('/events/'.$event->id),
'virtual_sessions' => $sessions,
];
}
}
+213
View File
@@ -0,0 +1,213 @@
<?php
namespace App\Services\Meet;
use App\Models\QrCode;
use App\Models\User;
use App\Services\Events\EventProgrammeService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class EventMeetSyncService
{
public function __construct(private readonly EventProgrammeService $programmes) {}
public function sync(QrCode $event, User $owner): QrCode
{
if ($event->type !== QrCode::TYPE_EVENT) {
return $event;
}
$content = $event->content();
$format = (string) ($content['format'] ?? 'in_person');
if (! in_array($format, ['virtual', 'hybrid'], true)) {
$this->cancelRemovedSessions($event, $owner, []);
return $event;
}
if ((string) config('meet.key', '') === '') {
return $event;
}
$programmeSnapshot = $this->programmeSnapshotFor($event);
$sessions = (array) ($content['virtual_sessions'] ?? []);
$client = MeetClient::for($owner->public_id);
$synced = [];
$activeIds = [];
foreach ($sessions as $session) {
if (! is_array($session)) {
continue;
}
$sessionId = trim((string) ($session['id'] ?? ''));
if ($sessionId === '') {
$sessionId = 'vs-'.Str::lower(Str::random(12));
}
$activeIds[] = $sessionId;
$title = trim((string) ($session['title'] ?? ''));
if ($title === '') {
$synced[] = array_merge($session, ['id' => $sessionId]);
continue;
}
$roomType = in_array($session['meet_room_type'] ?? '', ['town_hall', 'webinar'], true)
? (string) $session['meet_room_type']
: 'town_hall';
$scheduledAt = $session['scheduled_at'] ?? $content['starts_at'] ?? null;
$itemRefs = (array) ($session['programme_item_refs'] ?? []);
$linkedItems = $this->programmes->itemsForRefs($programmeSnapshot, $itemRefs);
$inviteEmails = $this->inviteEmailsFromHosts($this->programmes->hostsFromItems($linkedItems));
$settings = [
'programme_snapshot' => $programmeSnapshot,
'linked_programme_items' => $linkedItems,
];
if ($roomType === 'town_hall' && $linkedItems !== []) {
$settings['panels'] = [[
'id' => 'panel-'.$sessionId,
'name' => $title,
'speaker_refs' => [],
]];
$settings['panel_discussions'] = true;
}
$roomPayload = [
'title' => $title,
'description' => trim((string) ($content['description'] ?? '')) ?: null,
'type' => $roomType,
'scheduled_at' => $scheduledAt,
'duration_minutes' => max(15, (int) ($session['duration_minutes'] ?? 60)),
'timezone' => config('app.timezone', 'UTC'),
'settings' => $settings,
'invite_emails' => $inviteEmails,
'source' => [
'app' => 'events',
'entity_type' => 'virtual_session',
'entity_id' => $sessionId,
'event_id' => (string) $event->id,
],
];
$existingRoomUuid = trim((string) ($session['meet_room_uuid'] ?? ''));
try {
$result = $existingRoomUuid !== ''
? $client->updateRoom($existingRoomUuid, $roomPayload)
: $client->upsertRoom($roomPayload);
} catch (\Throwable $e) {
Log::warning('Events Meet sync failed', [
'event_id' => $event->id,
'session_id' => $sessionId,
'error' => $e->getMessage(),
]);
$synced[] = array_merge($session, ['id' => $sessionId]);
continue;
}
$room = (array) ($result['room'] ?? []);
$synced[] = array_merge($session, [
'id' => $sessionId,
'meet_room_uuid' => $room['uuid'] ?? $session['meet_room_uuid'] ?? null,
'join_url' => $result['join_url'] ?? $session['join_url'] ?? null,
]);
}
$this->cancelRemovedSessions($event, $owner, $activeIds);
$payload = (array) ($event->payload ?? []);
$payload['content'] = array_merge($content, ['virtual_sessions' => array_values($synced)]);
$event->payload = $payload;
$event->save();
return $event->fresh();
}
public function syncEventsForProgramme(QrCode $programme, User $owner): void
{
foreach ($this->programmes->eventsLinkedToProgramme($programme) as $event) {
$this->sync($event->fresh(), $owner);
}
}
/**
* @param array<string, mixed> $session
* @return array{programme_snapshot: array<string, mixed>|null, linked_programme_items: list<array<string, mixed>>}
*/
public function programmePayloadForSession(QrCode $event, array $session): array
{
$programmeSnapshot = $this->programmeSnapshotFor($event);
$itemRefs = (array) ($session['programme_item_refs'] ?? []);
return [
'programme_snapshot' => $programmeSnapshot,
'linked_programme_items' => $this->programmes->itemsForRefs($programmeSnapshot, $itemRefs),
];
}
/** @return array<string, mixed>|null */
private function programmeSnapshotFor(QrCode $event): ?array
{
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
if ($programmeId <= 0) {
return null;
}
$programme = QrCode::where('id', $programmeId)
->where('user_id', $event->user_id)
->where('type', QrCode::TYPE_ITINERARY)
->first();
return $this->programmes->snapshot($programme);
}
/** @param list<string> $hosts */
private function inviteEmailsFromHosts(array $hosts): array
{
return collect($hosts)
->filter(fn ($host) => filter_var($host, FILTER_VALIDATE_EMAIL))
->values()
->all();
}
/** @param list<string> $keepIds */
private function cancelRemovedSessions(QrCode $event, User $owner, array $keepIds): void
{
if ((string) config('meet.key', '') === '') {
return;
}
$previous = (array) ($event->content()['virtual_sessions'] ?? []);
$client = MeetClient::for($owner->public_id);
foreach ($previous as $session) {
if (! is_array($session)) {
continue;
}
$sessionId = (string) ($session['id'] ?? '');
$roomUuid = (string) ($session['meet_room_uuid'] ?? '');
if ($sessionId === '' || in_array($sessionId, $keepIds, true) || $roomUuid === '') {
continue;
}
try {
$client->cancelRoom($roomUuid);
} catch (\Throwable $e) {
Log::warning('Events Meet cancel failed', [
'event_id' => $event->id,
'session_id' => $sessionId,
'error' => $e->getMessage(),
]);
}
}
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Services\Meet;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
class EventRegistrationVerifyService
{
/** @return array<string, mixed>|null */
public function verifyBadge(QrCode $event, string $badgeCode): ?array
{
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$normalized = strtoupper(trim($badgeCode));
if ($normalized === '') {
return null;
}
$registration = QrEventRegistration::query()
->where('qr_code_id', $event->id)
->where('badge_code', $normalized)
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->first();
if (! $registration) {
return null;
}
return [
'attendee_name' => $registration->attendee_name,
'attendee_email' => $registration->attendee_email,
'badge_code' => $registration->badge_code,
'tier_name' => $registration->tier_name,
];
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Services\Meet;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
/**
* Client for the Ladill Meet service API virtual sessions linked to Events.
*/
class MeetClient
{
public function __construct(private readonly string $ownerRef) {}
public static function for(string $ownerRef): self
{
return new self($ownerRef);
}
/**
* @return array<string, mixed>|null
*/
public function findBySource(string $app, string $entityId): ?array
{
try {
$response = $this->client()->get('rooms/by-source', [
'source_app' => $app,
'entity_id' => $entityId,
'owner_ref' => $this->ownerRef,
]);
} catch (ConnectionException) {
return null;
}
if ($response->status() === 404) {
return null;
}
if ($response->failed()) {
return null;
}
return (array) $response->json();
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function upsertRoom(array $data): array
{
return $this->post('rooms', array_merge($data, [
'owner_ref' => $this->ownerRef,
'host_user_ref' => $data['host_user_ref'] ?? $this->ownerRef,
]));
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function updateRoom(string $roomUuid, array $data): array
{
return $this->patch("rooms/{$roomUuid}", array_merge($data, [
'owner_ref' => $this->ownerRef,
'host_user_ref' => $data['host_user_ref'] ?? $this->ownerRef,
]));
}
public function cancelRoom(string $roomUuid, ?string $hostUserRef = null): array
{
return $this->post("rooms/{$roomUuid}/cancel", [
'owner_ref' => $this->ownerRef,
'host_user_ref' => $hostUserRef ?? $this->ownerRef,
]);
}
private function client(): PendingRequest
{
return Http::baseUrl((string) config('meet.url'))
->withToken((string) config('meet.key'))
->acceptJson()
->asJson()
->connectTimeout(10)
->timeout(20);
}
/**
* @return array<string, mixed>
*/
private function post(string $path, array $data): array
{
return $this->request('post', $path, $data);
}
/**
* @return array<string, mixed>
*/
private function patch(string $path, array $data): array
{
return $this->request('patch', $path, $data);
}
/**
* @return array<string, mixed>
*/
private function request(string $method, string $path, array $data): array
{
try {
$response = $this->client()->{$method}($path, $data);
} catch (ConnectionException) {
throw new \RuntimeException('Could not reach the Meet service.');
}
if ($response->failed()) {
throw new \RuntimeException('Meet service error: '.($response->json('message') ?? $response->body()));
}
return (array) $response->json();
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Services\Meet;
use App\Models\QrCode;
use Illuminate\Support\Facades\Log;
class MeetLifecycleService
{
/** @param array<string, mixed> $payload */
public function handle(string $event, array $payload): void
{
$room = (array) ($payload['room'] ?? []);
$source = (array) ($room['source'] ?? []);
if (($source['app'] ?? '') !== 'events') {
return;
}
$eventId = (int) ($source['event_id'] ?? 0);
$sessionId = (string) ($source['entity_id'] ?? '');
if ($eventId <= 0 || $sessionId === '') {
return;
}
$qrCode = QrCode::find($eventId);
if (! $qrCode || $qrCode->type !== QrCode::TYPE_EVENT) {
return;
}
$content = $qrCode->content();
$sessions = (array) ($content['virtual_sessions'] ?? []);
$updated = false;
foreach ($sessions as $index => $session) {
if (! is_array($session) || ($session['id'] ?? '') !== $sessionId) {
continue;
}
$sessions[$index] = array_merge($session, $this->patchForEvent($event, $payload, $room));
$updated = true;
break;
}
if (! $updated) {
return;
}
$payloadData = (array) ($qrCode->payload ?? []);
$payloadData['content'] = array_merge($content, ['virtual_sessions' => array_values($sessions)]);
$qrCode->update(['payload' => $payloadData]);
Log::info('Events Meet lifecycle applied', ['event' => $event, 'event_id' => $eventId, 'session_id' => $sessionId]);
}
/** @return array<string, mixed> */
private function patchForEvent(string $event, array $payload, array $room): array
{
return match ($event) {
'meeting.started' => ['meet_status' => 'live', 'meet_live_at' => now()->toIso8601String()],
'meeting.ended' => ['meet_status' => 'ended', 'meet_ended_at' => now()->toIso8601String()],
'meeting.cancelled' => ['meet_status' => 'cancelled'],
'recording.ready' => [
'recording_url' => (string) (($payload['recording']['download_url'] ?? '') ?: ''),
'recording_ready_at' => now()->toIso8601String(),
],
default => [],
};
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Services\Messaging;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CustomerEmailClient
{
private ?string $lastError = null;
public function lastError(): ?string
{
return $this->lastError;
}
private function base(): string
{
return rtrim((string) config('smtp.customer_relay_url', 'https://ladill.com/api/smtp'), '/');
}
/** @return array{ok: bool, data?: array<string, mixed>, error?: string, status?: int} */
public function me(string $apiKey): array
{
try {
$res = Http::withToken($apiKey)->acceptJson()->timeout(15)
->get($this->base().'/me');
if ($res->status() === 401) {
return ['ok' => false, 'error' => 'Invalid Bird API key.', 'status' => 401];
}
if ($res->failed()) {
return [
'ok' => false,
'error' => (string) ($res->json('error') ?: 'Could not validate Bird credentials.'),
'status' => $res->status(),
];
}
return ['ok' => true, 'data' => $res->json() ?? [], 'status' => $res->status()];
} catch (\Throwable $e) {
Log::warning('Customer Bird meta request failed', ['error' => $e->getMessage()]);
return ['ok' => false, 'error' => 'Could not reach Ladill Bird. Please try again.'];
}
}
public function send(
string $apiKey,
string $from,
?string $fromName,
string $to,
string $subject,
string $html,
?string $text = null,
): bool {
$this->lastError = null;
try {
$res = Http::withToken($apiKey)->acceptJson()->timeout(30)
->post($this->base().'/send', array_filter([
'from' => $from,
'from_name' => $fromName,
'to' => [$to],
'subject' => $subject,
'html' => $html,
'text' => $text,
], fn ($value) => $value !== null && $value !== ''));
if ($res->status() === 402) {
$this->lastError = (string) ($res->json('error') ?: 'Insufficient Bird email credits. Top up Bird and try again.');
return false;
}
if ($res->failed()) {
$this->lastError = (string) ($res->json('error') ?: $res->json('message') ?: 'The email could not be sent.');
Log::warning('Customer email send failed', ['status' => $res->status()]);
return false;
}
return (bool) ($res->json('success') ?? true);
} catch (\Throwable $e) {
$this->lastError = 'Could not reach the email service. Please try again.';
Log::warning('Customer email send error', ['error' => $e->getMessage()]);
return false;
}
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Services\Messaging;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CustomerSmsClient
{
private ?string $lastError = null;
public function lastError(): ?string
{
return $this->lastError;
}
private function base(): string
{
return rtrim((string) config('sms.customer_relay_url', 'https://ladill.com/api/sms'), '/');
}
/** @return array{ok: bool, data?: array<string, mixed>, error?: string, status?: int} */
public function me(string $apiKey): array
{
return $this->get($apiKey, '/me');
}
/** @return array{ok: bool, data?: array<string, mixed>, error?: string, status?: int} */
public function senders(string $apiKey): array
{
return $this->get($apiKey, '/senders');
}
public function send(string $apiKey, string $to, string $text, string $senderId): bool
{
$this->lastError = null;
try {
$res = Http::withToken($apiKey)->acceptJson()->timeout(30)
->post($this->base().'/send', [
'to' => $to,
'text' => $text,
'sender_id' => $senderId,
]);
if ($res->status() === 402) {
$this->lastError = (string) ($res->json('error') ?: 'Insufficient SMS credit. Top up your Ladill SMS wallet and try again.');
return false;
}
if ($res->failed()) {
$this->lastError = (string) ($res->json('error') ?: $res->json('message') ?: 'The SMS could not be sent.');
Log::warning('Customer SMS send failed', ['status' => $res->status()]);
return false;
}
return (bool) ($res->json('success') ?? true);
} catch (\Throwable $e) {
$this->lastError = 'Could not reach the SMS service. Please try again.';
Log::warning('Customer SMS send error', ['error' => $e->getMessage()]);
return false;
}
}
/** @return array{ok: bool, data?: array<string, mixed>, error?: string, status?: int} */
private function get(string $apiKey, string $path): array
{
try {
$res = Http::withToken($apiKey)->acceptJson()->timeout(15)
->get($this->base().$path);
if ($res->status() === 401) {
return ['ok' => false, 'error' => 'Invalid SMS API key.', 'status' => 401];
}
if ($res->failed()) {
return [
'ok' => false,
'error' => (string) ($res->json('error') ?: 'Could not validate SMS credentials.'),
'status' => $res->status(),
];
}
return ['ok' => true, 'data' => $res->json() ?? [], 'status' => $res->status()];
} catch (\Throwable $e) {
Log::warning('Customer SMS meta request failed', ['path' => $path, 'error' => $e->getMessage()]);
return ['ok' => false, 'error' => 'Could not reach Ladill SMS. Please try again.'];
}
}
}
@@ -0,0 +1,163 @@
<?php
namespace App\Services\Messaging;
use App\Models\MessagingCredential;
class MessagingCredentialsService
{
public function __construct(
private CustomerSmsClient $sms,
private CustomerEmailClient $email,
) {}
public function forOwner(string $ownerRef): MessagingCredential
{
return MessagingCredential::query()->firstOrCreate(
['owner_ref' => $ownerRef],
);
}
/**
* @return array{ok: bool, error?: string, credential?: MessagingCredential, senders?: list<string>}
*/
public function validateAndSaveSms(string $ownerRef, string $apiKey, string $senderId): array
{
$apiKey = trim($apiKey);
$senderId = trim($senderId);
if (! str_starts_with($apiKey, 'lsk_sms_live_')) {
return ['ok' => false, 'error' => 'SMS API keys must start with lsk_sms_live_.'];
}
if ($senderId === '' || strlen($senderId) > 11) {
return ['ok' => false, 'error' => 'Sender ID must be 111 characters.'];
}
$me = $this->sms->me($apiKey);
if (! ($me['ok'] ?? false)) {
$credential = $this->forOwner($ownerRef);
$credential->update([
'sms_status' => MessagingCredential::STATUS_INVALID,
'sms_last_error' => $me['error'] ?? 'Invalid SMS API key.',
]);
return ['ok' => false, 'error' => $me['error'] ?? 'Invalid SMS API key.', 'credential' => $credential];
}
$senders = $this->sms->senders($apiKey);
$approved = collect($senders['data']['data'] ?? [])->pluck('sender_id')->map(fn ($id) => (string) $id)->all();
$defaultSender = (string) ($senders['data']['default_sender'] ?? '');
$allowed = array_values(array_unique(array_filter([...$approved, $defaultSender])));
if ($allowed !== [] && ! in_array($senderId, $allowed, true)) {
$error = 'Sender ID is not approved for this SMS key. Approved: '.implode(', ', $allowed);
$credential = $this->forOwner($ownerRef);
$credential->update([
'sms_status' => MessagingCredential::STATUS_INVALID,
'sms_last_error' => $error,
]);
return ['ok' => false, 'error' => $error, 'credential' => $credential, 'senders' => $allowed];
}
$credential = $this->forOwner($ownerRef);
$credential->update([
'sms_api_key_encrypted' => MessagingCredential::encryptKey($apiKey),
'sms_api_key_prefix' => substr($apiKey, 0, 16),
'sms_sender_id' => $senderId,
'sms_status' => MessagingCredential::STATUS_VALID,
'sms_validated_at' => now(),
'sms_last_error' => null,
]);
return ['ok' => true, 'credential' => $credential->fresh(), 'senders' => $allowed];
}
/**
* @return array{ok: bool, error?: string, credential?: MessagingCredential}
*/
public function validateAndSaveBird(string $ownerRef, string $apiKey, string $fromEmail, ?string $fromName): array
{
$apiKey = trim($apiKey);
$fromEmail = strtolower(trim($fromEmail));
$fromName = trim((string) $fromName);
if (! str_starts_with($apiKey, 'lsk_live_') && ! str_starts_with($apiKey, 'lsk_acct_')) {
return ['ok' => false, 'error' => 'Bird API keys must start with lsk_live_ or lsk_acct_.'];
}
if (! filter_var($fromEmail, FILTER_VALIDATE_EMAIL)) {
return ['ok' => false, 'error' => 'Enter a valid from email address.'];
}
$me = $this->email->me($apiKey);
if (! ($me['ok'] ?? false)) {
$credential = $this->forOwner($ownerRef);
$credential->update([
'bird_status' => MessagingCredential::STATUS_INVALID,
'bird_last_error' => $me['error'] ?? 'Invalid Bird API key.',
]);
return ['ok' => false, 'error' => $me['error'] ?? 'Invalid Bird API key.', 'credential' => $credential];
}
$domains = collect($me['data']['verified_domains'] ?? [])->map(fn ($d) => strtolower((string) $d))->all();
$fromDomain = strtolower((string) (explode('@', $fromEmail, 2)[1] ?? ''));
if ($domains !== [] && ! in_array($fromDomain, $domains, true)) {
$error = 'From email must use a verified Bird domain ('.implode(', ', $domains).').';
$credential = $this->forOwner($ownerRef);
$credential->update([
'bird_status' => MessagingCredential::STATUS_INVALID,
'bird_last_error' => $error,
]);
return ['ok' => false, 'error' => $error, 'credential' => $credential];
}
$credential = $this->forOwner($ownerRef);
$credential->update([
'bird_api_key_encrypted' => MessagingCredential::encryptKey($apiKey),
'bird_api_key_prefix' => substr($apiKey, 0, 16),
'bird_from_email' => $fromEmail,
'bird_from_name' => $fromName !== '' ? $fromName : null,
'bird_status' => MessagingCredential::STATUS_VALID,
'bird_validated_at' => now(),
'bird_last_error' => null,
]);
return ['ok' => true, 'credential' => $credential->fresh()];
}
public function disconnectSms(string $ownerRef): MessagingCredential
{
$credential = $this->forOwner($ownerRef);
$credential->update([
'sms_api_key_encrypted' => null,
'sms_api_key_prefix' => null,
'sms_sender_id' => null,
'sms_status' => null,
'sms_validated_at' => null,
'sms_last_error' => null,
]);
return $credential->fresh();
}
public function disconnectBird(string $ownerRef): MessagingCredential
{
$credential = $this->forOwner($ownerRef);
$credential->update([
'bird_api_key_encrypted' => null,
'bird_api_key_prefix' => null,
'bird_from_email' => null,
'bird_from_name' => null,
'bird_status' => null,
'bird_validated_at' => null,
'bird_last_error' => null,
]);
return $credential->fresh();
}
}
@@ -0,0 +1,310 @@
<?php
namespace App\Services\Payments;
use App\Models\PaymentGatewaySetting;
use App\Models\User;
use App\Services\Events\SubscriptionService;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class MerchantGatewayService
{
public function settingFor(User|string $owner): ?PaymentGatewaySetting
{
$ownerRef = $owner instanceof User ? (string) $owner->public_id : $owner;
return PaymentGatewaySetting::query()->where('owner_ref', $ownerRef)->first();
}
public function isConfigured(User|string $owner): bool
{
return $this->shouldUseOwnerGateway($owner);
}
public function shouldUseOwnerGateway(User|string $owner): bool
{
// Custom / owner payment gateways are retired. All checkouts use Ladill Pay
// with platform fees.
return false;
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
public function initializeCheckout(
User|string $owner,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata = [],
): array {
$setting = $this->requireConfigured($owner);
$currency = strtoupper($currency ?: 'GHS');
return match ($setting->provider) {
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
default => throw new RuntimeException('Unsupported payment provider.'),
};
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
public function verify(User|string $owner, string $reference): array
{
// Do not re-check the current plan here: an owner-gateway payment
// initiated before a downgrade must remain verifiable.
$setting = $this->settingFor($owner);
if (! $setting?->isConfigured()) {
throw new RuntimeException('The payment gateway used for this transaction is no longer configured.');
}
return match ($setting->provider) {
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackVerify($setting, $reference),
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveVerify($setting, $reference),
PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelVerify($setting, $reference),
default => throw new RuntimeException('Unsupported payment provider.'),
};
}
protected function requireConfigured(User|string $owner): PaymentGatewaySetting
{
if (! $this->ownerMayUseGateway($owner)) {
throw new RuntimeException('Connecting your own payment gateway requires a paid plan. Upgrade to Pro or Business.');
}
$setting = $this->settingFor($owner);
if (! $setting?->isConfigured()) {
throw new RuntimeException('Connect Paystack, Flutterwave, or Hubtel in Settings before accepting online payments.');
}
return $setting;
}
protected function ownerMayUseGateway(User|string $owner): bool
{
return false;
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function paystackInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->post('https://api.paystack.co/transaction/initialize', [
'email' => $email !== '' ? $email : 'payer@example.com',
'amount' => $amountMinor,
'currency' => $currency,
'reference' => $reference,
'callback_url' => $callbackUrl,
'metadata' => $metadata,
]);
if (! $response->successful() || ! ($response->json('status') ?? false)) {
throw new RuntimeException($response->json('message') ?: 'Paystack could not start checkout.');
}
$url = (string) $response->json('data.authorization_url');
if ($url === '') {
throw new RuntimeException('Paystack did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => (string) ($response->json('data.reference') ?: $reference),
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function paystackVerify(PaymentGatewaySetting $setting, string $reference): array
{
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->get('https://api.paystack.co/transaction/verify/'.rawurlencode($reference));
$data = (array) ($response->json('data') ?? []);
$paid = ($response->json('status') ?? false)
&& strtolower((string) ($data['status'] ?? '')) === 'success';
return [
'paid' => $paid,
'amount_minor' => (int) ($data['amount'] ?? 0),
'reference' => (string) ($data['reference'] ?? $reference),
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
'raw' => $data,
];
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function flutterwaveInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->post('https://api.flutterwave.com/v3/payments', [
'tx_ref' => $reference,
'amount' => round($amountMinor / 100, 2),
'currency' => $currency,
'redirect_url' => $callbackUrl,
'customer' => [
'email' => $email !== '' ? $email : 'payer@example.com',
],
'customizations' => [
'title' => (string) ($metadata['title'] ?? 'Payment'),
],
'meta' => $metadata,
]);
if (! $response->successful() || ($response->json('status') ?? '') !== 'success') {
throw new RuntimeException($response->json('message') ?: 'Flutterwave could not start checkout.');
}
$url = (string) $response->json('data.link');
if ($url === '') {
throw new RuntimeException('Flutterwave did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function flutterwaveVerify(PaymentGatewaySetting $setting, string $reference): array
{
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->get('https://api.flutterwave.com/v3/transactions/verify_by_reference', [
'tx_ref' => $reference,
]);
$data = (array) ($response->json('data') ?? []);
$paid = ($response->json('status') ?? '') === 'success'
&& strtolower((string) ($data['status'] ?? '')) === 'successful';
$amountMajor = (float) ($data['amount'] ?? 0);
return [
'paid' => $paid,
'amount_minor' => (int) round($amountMajor * 100),
'reference' => (string) ($data['tx_ref'] ?? $reference),
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
'raw' => $data,
];
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function hubtelInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
// Hubtel: public_key = merchant account number, secret_key = API key (client secret),
// webhook_secret optionally stores client id for Basic auth as "clientId:clientSecret".
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
if (! str_contains($auth, ':')) {
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
}
$response = Http::withBasicAuth(...explode(':', $auth, 2))
->acceptJson()
->timeout(20)
->post('https://payproxyapi.hubtel.com/items/initiate', [
'totalAmount' => round($amountMinor / 100, 2),
'description' => (string) ($metadata['title'] ?? 'Payment'),
'callbackUrl' => $callbackUrl,
'returnUrl' => $callbackUrl,
'merchantAccountNumber' => (string) $setting->public_key,
'cancellationUrl' => $callbackUrl,
'clientReference' => $reference,
]);
if (! $response->successful()) {
throw new RuntimeException($response->json('message') ?: 'Hubtel could not start checkout.');
}
$url = (string) ($response->json('data.checkoutUrl') ?? $response->json('data.checkoutDirectUrl') ?? '');
if ($url === '') {
throw new RuntimeException('Hubtel did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function hubtelVerify(PaymentGatewaySetting $setting, string $reference): array
{
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
if (! str_contains($auth, ':')) {
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
}
$response = Http::withBasicAuth(...explode(':', $auth, 2))
->acceptJson()
->timeout(20)
->get('https://api-txnstatus.hubtel.com/transactions/'.$setting->public_key.'/status', [
'clientReference' => $reference,
]);
$data = (array) ($response->json('data') ?? $response->json() ?? []);
$status = strtolower((string) ($data['status'] ?? $data['transactionStatus'] ?? ''));
$paid = in_array($status, ['success', 'successful', 'paid', 'completed'], true);
$amountMajor = (float) ($data['amount'] ?? $data['totalAmount'] ?? 0);
return [
'paid' => $paid,
'amount_minor' => (int) round($amountMajor * 100),
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
'raw' => $data,
];
}
}
+24 -2
View File
@@ -19,6 +19,8 @@ class QrCodeManagerService
private QrWalletBillingService $billing,
private QrImageGeneratorService $imageGenerator,
private QrPayloadValidator $payloadValidator,
private \App\Services\Meet\EventMeetSyncService $meetSync,
private \App\Services\Events\EventMailCalendarSyncService $mailCalendar,
) {}
public function walletFor(User $user): QrWallet
@@ -42,7 +44,11 @@ class QrCodeManagerService
$type = (string) ($data['type'] ?? '');
$validated = $this->payloadValidator->validateForCreate($type, $data);
$style = QrStyleDefaults::merge($data['style'] ?? null);
$styleInput = $data['style'] ?? null;
if (in_array($type, [QrCode::TYPE_EVENT, QrCode::TYPE_ITINERARY], true) && empty(array_filter((array) $styleInput))) {
$styleInput = $user->getOrCreateQrSetting()->resolvedDefaultStyle();
}
$style = QrStyleDefaults::merge($styleInput);
return DB::transaction(function () use ($user, $wallet, $data, $type, $validated, $style) {
$documentId = null;
@@ -158,6 +164,11 @@ class QrCodeManagerService
$this->billing->debitForQrCreation($wallet, $qrCode);
$this->imageGenerator->generateAndStore($qrCode);
if ($type === QrCode::TYPE_EVENT) {
$qrCode = $this->meetSync->sync($qrCode->fresh(), $user);
$qrCode = $this->mailCalendar->sync($qrCode->fresh(), $user);
}
return $qrCode->fresh(['document']);
});
}
@@ -294,6 +305,17 @@ class QrCodeManagerService
$this->imageGenerator->generateAndStore($qrCode);
}
if ($qrCode->type === QrCode::TYPE_EVENT) {
$qrCode = $this->meetSync->sync($qrCode->fresh(), $qrCode->user);
$qrCode = $this->mailCalendar->sync($qrCode->fresh(), $qrCode->user);
}
if ($qrCode->type === QrCode::TYPE_ITINERARY) {
$this->meetSync->syncEventsForProgramme($qrCode->fresh(), $qrCode->user);
app(\App\Services\Events\EventSpeakerInviteService::class)
->inviteProgrammeHosts($qrCode->fresh(), $qrCode->user);
}
return $qrCode->fresh(['document']);
}
@@ -347,7 +369,7 @@ class QrCodeManagerService
QrCode::TYPE_VCARD => ['first_name', 'last_name', 'phone', 'email', 'company', 'website', 'address', 'note', 'social'],
QrCode::TYPE_BUSINESS => ['name', 'tagline', 'phone', 'email', 'website', 'address', 'hours'],
QrCode::TYPE_CHURCH => ['name', 'denomination', 'description', 'phone', 'email', 'website', 'address', 'service_times', 'org_type', 'accepts_payment', 'collection_types', 'brand_color'],
QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open'],
QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open', 'format', 'virtual_sessions', 'speakers', 'programme_qr_id'],
QrCode::TYPE_ITINERARY => ['title', 'subtitle', 'description', 'event_date', 'location', 'brand_color', 'days'],
QrCode::TYPE_DOCUMENT => ['allow_download'],
QrCode::TYPE_MENU => ['menu_title', 'sections', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
+162 -8
View File
@@ -4,6 +4,7 @@ namespace App\Services\Qr;
use App\Models\QrCode;
use App\Support\Qr\QrDateFormatter;
use Illuminate\Support\Str;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\UploadedFile;
use RuntimeException;
@@ -51,7 +52,16 @@ class QrPayloadValidator
$merged['destination_url'] = $merged['url'];
}
return $this->validateForCreate($qrCode->type, $merged);
$result = $this->validateForCreate($qrCode->type, $merged);
if ($qrCode->type === QrCode::TYPE_EVENT && isset($result['content']['speakers'])) {
$result['content']['speakers'] = $this->mergeSpeakerInviteMeta(
(array) ($qrCode->content()['speakers'] ?? []),
$result['content']['speakers'],
);
}
return $result;
}
/** @param array<string, mixed> $input */
@@ -260,6 +270,13 @@ class QrPayloadValidator
$categories = [];
}
$format = in_array($input['format'] ?? 'in_person', ['in_person', 'virtual', 'hybrid'], true)
? ($input['format'] ?? 'in_person')
: 'in_person';
$virtualSessions = $this->normalizeVirtualSessions($input['virtual_sessions'] ?? [], $format);
$speakers = $this->normalizeSpeakers($input['speakers'] ?? null, $input);
// Contributions always take payment; ticketing only for paid tiers; free never.
$acceptsPayment = match ($mode) {
'contributions' => true,
@@ -289,11 +306,136 @@ class QrPayloadValidator
'accepts_payment' => $acceptsPayment,
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
'programme_qr_id' => ($pid = (int) ($input['programme_qr_id'] ?? 0)) > 0 ? $pid : null,
'format' => $format,
'virtual_sessions' => $virtualSessions,
'speakers' => $speakers,
],
'destination_url' => null,
];
}
/** @param mixed $sessions */
private function normalizeVirtualSessions(mixed $sessions, string $format): array
{
if ($format === 'in_person') {
return [];
}
if (! is_array($sessions)) {
return [];
}
$normalized = [];
foreach ($sessions as $session) {
if (! is_array($session)) {
continue;
}
$title = trim((string) ($session['title'] ?? ''));
if ($title === '') {
continue;
}
$id = trim((string) ($session['id'] ?? ''));
if ($id === '') {
$id = 'vs-'.Str::lower(Str::random(12));
}
$roomType = in_array($session['meet_room_type'] ?? '', ['town_hall', 'webinar'], true)
? (string) $session['meet_room_type']
: 'town_hall';
$itemRefs = [];
foreach ((array) ($session['programme_item_refs'] ?? []) as $ref) {
$ref = trim((string) $ref);
if ($ref !== '') {
$itemRefs[] = mb_substr($ref, 0, 80);
}
}
$normalized[] = [
'id' => mb_substr($id, 0, 64),
'title' => mb_substr($title, 0, 120),
'meet_room_type' => $roomType,
'scheduled_at' => QrDateFormatter::normalize((string) ($session['scheduled_at'] ?? '')),
'duration_minutes' => max(15, min(480, (int) ($session['duration_minutes'] ?? 60))),
'meet_room_uuid' => ($uuid = trim((string) ($session['meet_room_uuid'] ?? ''))) !== '' ? $uuid : null,
'join_url' => ($url = trim((string) ($session['join_url'] ?? ''))) !== '' ? $url : null,
'programme_item_refs' => array_values($itemRefs),
];
}
return array_values($normalized);
}
/** @return list<array{name: string, email: string, role: string, bio: string, invite_token?: string, invited_at?: string, invite_source?: string}> */
private function normalizeSpeakers(mixed $speakers, array $input): array
{
$rows = is_array($speakers) ? $speakers : (array) ($input['speakers'] ?? []);
$normalized = [];
foreach ($rows as $speaker) {
if (! is_array($speaker)) {
continue;
}
$name = trim((string) ($speaker['name'] ?? ''));
if ($name === '') {
continue;
}
$email = trim((string) ($speaker['email'] ?? ''));
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Each speaker must have a valid email address.');
}
$row = [
'name' => mb_substr($name, 0, 120),
'email' => $email,
'role' => mb_substr(trim((string) ($speaker['role'] ?? '')), 0, 80),
'bio' => mb_substr(trim((string) ($speaker['bio'] ?? '')), 0, 500),
];
foreach (['invite_token', 'invited_at', 'invite_source'] as $key) {
if (! empty($speaker[$key])) {
$row[$key] = $speaker[$key];
}
}
$normalized[] = $row;
}
return array_values($normalized);
}
/**
* @param list<array<string, mixed>> $previous
* @param list<array<string, mixed>> $current
* @return list<array<string, mixed>>
*/
private function mergeSpeakerInviteMeta(array $previous, array $current): array
{
$byEmail = collect($previous)
->filter(fn ($row) => is_array($row) && filled($row['email'] ?? null))
->keyBy(fn ($row) => strtolower((string) $row['email']));
return collect($current)->map(function (array $speaker) use ($byEmail) {
$email = strtolower((string) ($speaker['email'] ?? ''));
$prev = $byEmail->get($email);
if (! $prev) {
return $speaker;
}
foreach (['invite_token', 'invited_at', 'invite_source'] as $key) {
if (! empty($prev[$key]) && empty($speaker[$key])) {
$speaker[$key] = $prev[$key];
}
}
return $speaker;
})->values()->all();
}
/** @param array<int, array{price: float}> $tiers */
private function eventHasPaidTier(array $tiers): bool
{
@@ -316,12 +458,12 @@ class QrPayloadValidator
// Days: [{ label, date, items: [{ time, title, description, location, host }] }]
$days = [];
foreach ((array) ($input['days'] ?? []) as $day) {
foreach ((array) ($input['days'] ?? []) as $dayIndex => $day) {
if (! is_array($day)) {
continue;
}
$items = [];
foreach ((array) ($day['items'] ?? []) as $item) {
foreach ((array) ($day['items'] ?? []) as $itemIndex => $item) {
if (! is_array($item)) {
continue;
}
@@ -329,12 +471,24 @@ class QrPayloadValidator
if ($itemTitle === '') {
continue;
}
$hostSpeakerEmail = strtolower(trim((string) ($item['host_speaker_email'] ?? '')));
if ($hostSpeakerEmail !== '' && ! filter_var($hostSpeakerEmail, FILTER_VALIDATE_EMAIL)) {
$hostSpeakerEmail = '';
}
$host = mb_substr(trim((string) ($item['host'] ?? '')), 0, 120);
if ($host === '' && $hostSpeakerEmail !== '') {
$host = mb_substr(strtok($hostSpeakerEmail, '@') ?: $hostSpeakerEmail, 0, 120);
}
$items[] = [
'time' => mb_substr(trim((string) ($item['time'] ?? '')), 0, 40),
'title' => mb_substr($itemTitle, 0, 140),
'description' => mb_substr(trim((string) ($item['description'] ?? '')), 0, 400),
'location' => mb_substr(trim((string) ($item['location'] ?? '')), 0, 120),
'host' => mb_substr(trim((string) ($item['host'] ?? '')), 0, 120),
'ref' => 'day'.$dayIndex.'-item'.$itemIndex,
'time' => mb_substr(trim((string) ($item['time'] ?? '')), 0, 40),
'title' => mb_substr($itemTitle, 0, 140),
'description' => mb_substr(trim((string) ($item['description'] ?? '')), 0, 400),
'location' => mb_substr(trim((string) ($item['location'] ?? '')), 0, 120),
'host' => $host,
'host_speaker_email' => $hostSpeakerEmail,
];
}
if (empty($items) && trim((string) ($day['label'] ?? '')) === '') {
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Support;
use App\Models\QrSetting;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class AccountBranding
{
public static function forUser(User $user): QrSetting
{
return $user->getOrCreateQrSetting();
}
public static function forOwnerRef(string $ownerPublicId): ?QrSetting
{
$user = User::query()->where('public_id', $ownerPublicId)->first();
return $user ? $user->getOrCreateQrSetting() : null;
}
public static function companyName(?QrSetting $settings, ?User $user = null): string
{
if ($user) {
return (string) ($user->name ?: 'Company');
}
if ($settings?->user) {
return (string) ($settings->user->name ?: 'Company');
}
if ($settings?->user_id) {
$owner = User::query()->find($settings->user_id);
return (string) ($owner?->name ?: 'Company');
}
return 'Company';
}
public static function hasCustomLogo(?QrSetting $settings): bool
{
return $settings !== null
&& $settings->logo_path !== null
&& Storage::disk('public')->exists($settings->logo_path);
}
public static function emailLogoUrl(?QrSetting $settings): ?string
{
if (! self::hasCustomLogo($settings)) {
return null;
}
$ext = strtolower(pathinfo((string) $settings->logo_path, PATHINFO_EXTENSION));
if ($ext === 'svg') {
return null;
}
$version = $settings->updated_at?->getTimestamp() ?? time();
return Storage::disk('public')->url($settings->logo_path).'?v='.$version;
}
public static function logoUrl(?QrSetting $settings): ?string
{
if (! self::hasCustomLogo($settings)) {
return null;
}
$version = $settings->updated_at?->getTimestamp() ?? time();
return Storage::disk('public')->url($settings->logo_path).'?v='.$version;
}
public static function storeLogo(QrSetting $settings, UploadedFile $file): string
{
self::deleteStoredLogo($settings);
return $file->store('events/accounts/'.$settings->user_id, 'public');
}
public static function deleteStoredLogo(QrSetting $settings): void
{
if ($settings->logo_path) {
Storage::disk('public')->delete($settings->logo_path);
}
}
}
+634
View File
@@ -0,0 +1,634 @@
<?php
namespace App\Support;
/**
* Canonical shared demo world for the Ladill suite.
*
* Single source of truth for business identity, people, companies, branches,
* and staff rosters used across Care, Queue, Frontdesk, CRM, Invoice,
* Accounting, POS, Meet, Events, and Woo. Suite apps copy this file via
* `php scripts/sync-demo-world.php` keep copies byte-identical.
*
* Continuity is by matching identifiers (phone, email, national_id, company
* name), not cross-database foreign keys.
*
* Reseed policy: only owner demo emails (`demo-{tier}@ladill.com`) trigger
* full product reseed on login. Staff logins never wipe mid-demo.
*/
final class DemoWorld
{
public const PASSWORD_ENV = 'DEMO_ACCOUNT_PASSWORD';
public const DEFAULT_PASSWORD = 'DemoLadill2026!';
public const TIMEZONE = 'Africa/Accra';
public const CURRENCY = 'GHS';
/** Anchor “today” for same-day appointments / tickets / visits (relative). */
public const STORY_DAY_OFFSET = 0;
/**
* @var array<string, array{
* name: string,
* legal_name: string,
* phone: string,
* email: string,
* address: string,
* city: string,
* region: string,
* timezone: string
* }>
*/
public const BUSINESS = [
'free' => [
'name' => 'Ridge Family Clinic',
'legal_name' => 'Ridge Family Clinic Ltd',
'phone' => '+233302400100',
'email' => 'hello@ridgeclinic.demo.ladill.com',
'address' => '12 Castle Road, Ridge, Accra',
'city' => 'Accra',
'region' => 'Greater Accra',
'timezone' => self::TIMEZONE,
],
'pro' => [
'name' => 'Ridge Medical Centre',
'legal_name' => 'Ridge Medical Centre Ltd',
'phone' => '+233302400200',
'email' => 'hello@ridgemedical.demo.ladill.com',
'address' => '45 Independence Ave, Ridge, Accra',
'city' => 'Accra',
'region' => 'Greater Accra',
'timezone' => self::TIMEZONE,
],
'enterprise' => [
'name' => 'Accra Healthcare Group',
'legal_name' => 'Accra Healthcare Group Ltd',
'phone' => '+233302400300',
'email' => 'hello@accrahealth.demo.ladill.com',
'address' => '1 Liberation Road, Airport Residential, Accra',
'city' => 'Accra',
'region' => 'Greater Accra',
'timezone' => self::TIMEZONE,
],
];
/**
* Shared branch catalog. Plans take the first N (free=1, pro=3, enterprise=6).
*
* @var list<array{code: string, name: string, phone: string, address: string}>
*/
public const BRANCHES = [
['code' => 'HQ', 'name' => 'Ridge Clinic', 'phone' => '+233302400101', 'address' => '12 Castle Road, Ridge, Accra'],
['code' => 'EL', 'name' => 'East Legon Care', 'phone' => '+233302400102', 'address' => '18 Boundary Rd, East Legon, Accra'],
['code' => 'TM', 'name' => 'Tema Harbour Clinic', 'phone' => '+233302400103', 'address' => 'Harbour Road, Community 1, Tema'],
['code' => 'OS', 'name' => 'Osu Medical Centre', 'phone' => '+233302400104', 'address' => 'Oxford Street, Osu, Accra'],
['code' => 'SX', 'name' => 'Spintex Wellness', 'phone' => '+233302400105', 'address' => 'Spintex Road, Accra'],
['code' => 'AP', 'name' => 'Airport Residential Care', 'phone' => '+233302400106', 'address' => 'Liberation Rd, Airport Residential, Accra'],
];
/**
* Canonical people reused as patients / visitors / ticket guests / CRM
* contacts / invoice clients / POS customers / event attendees.
*
* @var list<array{
* key: string,
* first_name: string,
* last_name: string,
* phone: string,
* email: string,
* national_id: string,
* gender: string,
* dob: string,
* company_key: string,
* city: string,
* address: string
* }>
*/
public const PEOPLE = [
['key' => 'ama-mensah', 'first_name' => 'Ama', 'last_name' => 'Mensah', 'phone' => '+233241000001', 'email' => 'ama.mensah@demo.ladill.com', 'national_id' => 'GHA-729001001', 'gender' => 'female', 'dob' => '1990-03-12', 'company_key' => 'accra-retail', 'city' => 'Accra', 'address' => '14 Ring Road Central, Accra'],
['key' => 'kofi-asante', 'first_name' => 'Kofi', 'last_name' => 'Asante', 'phone' => '+233241000002', 'email' => 'kofi.asante@demo.ladill.com', 'national_id' => 'GHA-729001002', 'gender' => 'male', 'dob' => '1985-07-21', 'company_key' => 'kumasi-foods', 'city' => 'Accra', 'address' => '3 Labone Crescent, Accra'],
['key' => 'abena-osei', 'first_name' => 'Abena', 'last_name' => 'Osei', 'phone' => '+233241000003', 'email' => 'abena.osei@demo.ladill.com', 'national_id' => 'GHA-729001003', 'gender' => 'female', 'dob' => '1992-11-05', 'company_key' => 'tema-logistics', 'city' => 'Tema', 'address' => 'Community 4, Tema'],
['key' => 'kwame-boateng', 'first_name' => 'Kwame', 'last_name' => 'Boateng', 'phone' => '+233241000004', 'email' => 'kwame.boateng@demo.ladill.com', 'national_id' => 'GHA-729001004', 'gender' => 'male', 'dob' => '1978-01-30', 'company_key' => 'cape-coast-tours', 'city' => 'Accra', 'address' => '8 Cantonments Road, Accra'],
['key' => 'akosua-owusu', 'first_name' => 'Akosua', 'last_name' => 'Owusu', 'phone' => '+233241000005', 'email' => 'akosua.owusu@demo.ladill.com', 'national_id' => 'GHA-729001005', 'gender' => 'female', 'dob' => '1995-06-18', 'company_key' => 'tamale-agro', 'city' => 'Accra', 'address' => '22 Dansoman Estates, Accra'],
['key' => 'yaw-adjei', 'first_name' => 'Yaw', 'last_name' => 'Adjei', 'phone' => '+233241000006', 'email' => 'yaw.adjei@demo.ladill.com', 'national_id' => 'GHA-729001006', 'gender' => 'male', 'dob' => '1988-09-09', 'company_key' => 'spintex-clinics', 'city' => 'Accra', 'address' => 'Spintex Junction, Accra'],
['key' => 'efua-appiah', 'first_name' => 'Efua', 'last_name' => 'Appiah', 'phone' => '+233241000007', 'email' => 'efua.appiah@demo.ladill.com', 'national_id' => 'GHA-729001007', 'gender' => 'female', 'dob' => '1993-02-14', 'company_key' => 'osu-cafe', 'city' => 'Accra', 'address' => 'Oxford Street, Osu, Accra'],
['key' => 'kojo-darko', 'first_name' => 'Kojo', 'last_name' => 'Darko', 'phone' => '+233241000008', 'email' => 'kojo.darko@demo.ladill.com', 'national_id' => 'GHA-729001008', 'gender' => 'male', 'dob' => '1982-12-01', 'company_key' => 'legon-tech', 'city' => 'Accra', 'address' => 'University Area, Legon'],
['key' => 'adwoa-agyeman', 'first_name' => 'Adwoa', 'last_name' => 'Agyeman', 'phone' => '+233241000009', 'email' => 'adwoa.agyeman@demo.ladill.com', 'national_id' => 'GHA-729001009', 'gender' => 'female', 'dob' => '1991-04-22', 'company_key' => 'airport-hotels', 'city' => 'Accra', 'address' => 'Airport Residential, Accra'],
['key' => 'kwesi-sarpong', 'first_name' => 'Kwesi', 'last_name' => 'Sarpong', 'phone' => '+233241000010', 'email' => 'kwesi.sarpong@demo.ladill.com', 'national_id' => 'GHA-729001010', 'gender' => 'male', 'dob' => '1987-08-16', 'company_key' => 'kaneshie-traders', 'city' => 'Accra', 'address' => 'Kaneshie Market, Accra'],
['key' => 'afia-nyarko', 'first_name' => 'Afia', 'last_name' => 'Nyarko', 'phone' => '+233241000011', 'email' => 'afia.nyarko@demo.ladill.com', 'national_id' => 'GHA-729001011', 'gender' => 'female', 'dob' => '1994-10-03', 'company_key' => 'accra-retail', 'city' => 'Accra', 'address' => 'Adabraka, Accra'],
['key' => 'kwadwo-ankrah', 'first_name' => 'Kwadwo', 'last_name' => 'Ankrah', 'phone' => '+233241000012', 'email' => 'kwadwo.ankrah@demo.ladill.com', 'national_id' => 'GHA-729001012', 'gender' => 'male', 'dob' => '1980-05-27', 'company_key' => 'kumasi-foods', 'city' => 'Accra', 'address' => 'Mamprobi, Accra'],
['key' => 'esi-tetteh', 'first_name' => 'Esi', 'last_name' => 'Tetteh', 'phone' => '+233241000013', 'email' => 'esi.tetteh@demo.ladill.com', 'national_id' => 'GHA-729001013', 'gender' => 'female', 'dob' => '1996-01-19', 'company_key' => 'tema-logistics', 'city' => 'Tema', 'address' => 'Community 8, Tema'],
['key' => 'fiifi-quaye', 'first_name' => 'Fiifi', 'last_name' => 'Quaye', 'phone' => '+233241000014', 'email' => 'fiifi.quaye@demo.ladill.com', 'national_id' => 'GHA-729001014', 'gender' => 'male', 'dob' => '1989-07-07', 'company_key' => 'cape-coast-tours', 'city' => 'Accra', 'address' => 'Tesano, Accra'],
['key' => 'mansa-addo', 'first_name' => 'Mansa', 'last_name' => 'Addo', 'phone' => '+233241000015', 'email' => 'mansa.addo@demo.ladill.com', 'national_id' => 'GHA-729001015', 'gender' => 'female', 'dob' => '1993-09-25', 'company_key' => 'tamale-agro', 'city' => 'Accra', 'address' => 'Abelemkpe, Accra'],
['key' => 'nana-frimpong', 'first_name' => 'Nana', 'last_name' => 'Frimpong', 'phone' => '+233241000016', 'email' => 'nana.frimpong@demo.ladill.com', 'national_id' => 'GHA-729001016', 'gender' => 'male', 'dob' => '1984-03-11', 'company_key' => 'spintex-clinics', 'city' => 'Accra', 'address' => 'Teshie, Accra'],
['key' => 'ama-boateng', 'first_name' => 'Ama', 'last_name' => 'Boateng', 'phone' => '+233241000017', 'email' => 'ama.boateng@demo.ladill.com', 'national_id' => 'GHA-729001017', 'gender' => 'female', 'dob' => '1997-12-08', 'company_key' => 'osu-cafe', 'city' => 'Accra', 'address' => 'Kokomlemle, Accra'],
['key' => 'kofi-owusu', 'first_name' => 'Kofi', 'last_name' => 'Owusu', 'phone' => '+233241000018', 'email' => 'kofi.owusu@demo.ladill.com', 'national_id' => 'GHA-729001018', 'gender' => 'male', 'dob' => '1986-06-02', 'company_key' => 'legon-tech', 'city' => 'Accra', 'address' => 'Haatso, Accra'],
['key' => 'abena-darko', 'first_name' => 'Abena', 'last_name' => 'Darko', 'phone' => '+233241000019', 'email' => 'abena.darko@demo.ladill.com', 'national_id' => 'GHA-729001019', 'gender' => 'female', 'dob' => '1990-08-29', 'company_key' => 'airport-hotels', 'city' => 'Accra', 'address' => 'Dzorwulu, Accra'],
['key' => 'kwame-mensah', 'first_name' => 'Kwame', 'last_name' => 'Mensah', 'phone' => '+233241000020', 'email' => 'kwame.mensah@demo.ladill.com', 'national_id' => 'GHA-729001020', 'gender' => 'male', 'dob' => '1979-11-13', 'company_key' => 'kaneshie-traders', 'city' => 'Accra', 'address' => 'Ablekuma, Accra'],
['key' => 'akosua-asante', 'first_name' => 'Akosua', 'last_name' => 'Asante', 'phone' => '+233241000021', 'email' => 'akosua.asante@demo.ladill.com', 'national_id' => 'GHA-729001021', 'gender' => 'female', 'dob' => '1992-02-20', 'company_key' => 'accra-retail', 'city' => 'Accra', 'address' => 'Roman Ridge, Accra'],
['key' => 'yaw-osei', 'first_name' => 'Yaw', 'last_name' => 'Osei', 'phone' => '+233241000022', 'email' => 'yaw.osei@demo.ladill.com', 'national_id' => 'GHA-729001022', 'gender' => 'male', 'dob' => '1983-04-15', 'company_key' => 'kumasi-foods', 'city' => 'Accra', 'address' => 'Nungua, Accra'],
['key' => 'efua-boateng', 'first_name' => 'Efua', 'last_name' => 'Boateng', 'phone' => '+233241000023', 'email' => 'efua.boateng@demo.ladill.com', 'national_id' => 'GHA-729001023', 'gender' => 'female', 'dob' => '1995-07-01', 'company_key' => 'tema-logistics', 'city' => 'Tema', 'address' => 'Community 2, Tema'],
['key' => 'kojo-owusu', 'first_name' => 'Kojo', 'last_name' => 'Owusu', 'phone' => '+233241000024', 'email' => 'kojo.owusu@demo.ladill.com', 'national_id' => 'GHA-729001024', 'gender' => 'male', 'dob' => '1981-10-28', 'company_key' => 'cape-coast-tours', 'city' => 'Accra', 'address' => 'Madina, Accra'],
['key' => 'adwoa-adjei', 'first_name' => 'Adwoa', 'last_name' => 'Adjei', 'phone' => '+233241000025', 'email' => 'adwoa.adjei@demo.ladill.com', 'national_id' => 'GHA-729001025', 'gender' => 'female', 'dob' => '1994-05-06', 'company_key' => 'tamale-agro', 'city' => 'Accra', 'address' => 'Ashaiman, Accra'],
['key' => 'kwesi-appiah', 'first_name' => 'Kwesi', 'last_name' => 'Appiah', 'phone' => '+233241000026', 'email' => 'kwesi.appiah@demo.ladill.com', 'national_id' => 'GHA-729001026', 'gender' => 'male', 'dob' => '1988-12-17', 'company_key' => 'spintex-clinics', 'city' => 'Accra', 'address' => 'Baatsona, Accra'],
['key' => 'afia-darko', 'first_name' => 'Afia', 'last_name' => 'Darko', 'phone' => '+233241000027', 'email' => 'afia.darko@demo.ladill.com', 'national_id' => 'GHA-729001027', 'gender' => 'female', 'dob' => '1991-09-23', 'company_key' => 'osu-cafe', 'city' => 'Accra', 'address' => 'Labadi, Accra'],
['key' => 'kwadwo-agyeman', 'first_name' => 'Kwadwo', 'last_name' => 'Agyeman', 'phone' => '+233241000028', 'email' => 'kwadwo.agyeman@demo.ladill.com', 'national_id' => 'GHA-729001028', 'gender' => 'male', 'dob' => '1985-01-04', 'company_key' => 'legon-tech', 'city' => 'Accra', 'address' => 'Kwabenya, Accra'],
['key' => 'esi-sarpong', 'first_name' => 'Esi', 'last_name' => 'Sarpong', 'phone' => '+233241000029', 'email' => 'esi.sarpong@demo.ladill.com', 'national_id' => 'GHA-729001029', 'gender' => 'female', 'dob' => '1996-03-30', 'company_key' => 'airport-hotels', 'city' => 'Accra', 'address' => 'East Legon Hills, Accra'],
['key' => 'fiifi-nyarko', 'first_name' => 'Fiifi', 'last_name' => 'Nyarko', 'phone' => '+233241000030', 'email' => 'fiifi.nyarko@demo.ladill.com', 'national_id' => 'GHA-729001030', 'gender' => 'male', 'dob' => '1987-06-14', 'company_key' => 'kaneshie-traders', 'city' => 'Accra', 'address' => 'Odorkor, Accra'],
['key' => 'mansa-ankrah', 'first_name' => 'Mansa', 'last_name' => 'Ankrah', 'phone' => '+233241000031', 'email' => 'mansa.ankrah@demo.ladill.com', 'national_id' => 'GHA-729001031', 'gender' => 'female', 'dob' => '1993-11-11', 'company_key' => 'accra-retail', 'city' => 'Accra', 'address' => 'Korle Bu, Accra'],
['key' => 'nana-tetteh', 'first_name' => 'Nana', 'last_name' => 'Tetteh', 'phone' => '+233241000032', 'email' => 'nana.tetteh@demo.ladill.com', 'national_id' => 'GHA-729001032', 'gender' => 'male', 'dob' => '1982-08-08', 'company_key' => 'kumasi-foods', 'city' => 'Accra', 'address' => 'Weija, Accra'],
['key' => 'ama-quaye', 'first_name' => 'Ama', 'last_name' => 'Quaye', 'phone' => '+233241000033', 'email' => 'ama.quaye@demo.ladill.com', 'national_id' => 'GHA-729001033', 'gender' => 'female', 'dob' => '1990-04-04', 'company_key' => 'tema-logistics', 'city' => 'Tema', 'address' => 'Community 11, Tema'],
['key' => 'kofi-addo', 'first_name' => 'Kofi', 'last_name' => 'Addo', 'phone' => '+233241000034', 'email' => 'kofi.addo@demo.ladill.com', 'national_id' => 'GHA-729001034', 'gender' => 'male', 'dob' => '1989-02-26', 'company_key' => 'cape-coast-tours', 'city' => 'Accra', 'address' => 'Kasoa Road, Accra'],
['key' => 'abena-frimpong', 'first_name' => 'Abena', 'last_name' => 'Frimpong', 'phone' => '+233241000035', 'email' => 'abena.frimpong@demo.ladill.com', 'national_id' => 'GHA-729001035', 'gender' => 'female', 'dob' => '1994-07-19', 'company_key' => 'tamale-agro', 'city' => 'Accra', 'address' => 'Achimota, Accra'],
['key' => 'kwame-agyeman', 'first_name' => 'Kwame', 'last_name' => 'Agyeman', 'phone' => '+233241000036', 'email' => 'kwame.agyeman@demo.ladill.com', 'national_id' => 'GHA-729001036', 'gender' => 'male', 'dob' => '1980-09-01', 'company_key' => 'spintex-clinics', 'city' => 'Accra', 'address' => 'Sakumono, Accra'],
];
/**
* @var list<array{key: string, name: string, email: string, phone: string, address: string}>
*/
public const COMPANIES = [
['key' => 'accra-retail', 'name' => 'Accra Retail Ltd', 'email' => 'accounts@accra-retail.demo.ladill.com', 'phone' => '+233302500001', 'address' => 'Oxford Street, Accra'],
['key' => 'kumasi-foods', 'name' => 'Kumasi Foods', 'email' => 'billing@kumasi-foods.demo.ladill.com', 'phone' => '+233302500002', 'address' => 'Kejetia Road, Kumasi'],
['key' => 'tema-logistics', 'name' => 'Tema Logistics', 'email' => 'finance@tema-logistics.demo.ladill.com', 'phone' => '+233302500003', 'address' => 'Harbour Road, Tema'],
['key' => 'cape-coast-tours', 'name' => 'Cape Coast Tours', 'email' => 'pay@cape-coast-tours.demo.ladill.com', 'phone' => '+233302500004', 'address' => 'Castle Road, Cape Coast'],
['key' => 'tamale-agro', 'name' => 'Tamale Agro', 'email' => 'accounts@tamale-agro.demo.ladill.com', 'phone' => '+233302500005', 'address' => 'Salaga Road, Tamale'],
['key' => 'spintex-clinics', 'name' => 'Spintex Clinics', 'email' => 'billing@spintex-clinics.demo.ladill.com', 'phone' => '+233302500006', 'address' => 'Spintex Road, Accra'],
['key' => 'osu-cafe', 'name' => 'Osu Café Co', 'email' => 'hello@osu-cafe.demo.ladill.com', 'phone' => '+233302500007', 'address' => 'Oxford Street, Osu'],
['key' => 'legon-tech', 'name' => 'Legon Tech', 'email' => 'ap@legon-tech.demo.ladill.com', 'phone' => '+233302500008', 'address' => 'University Area, Legon'],
['key' => 'airport-hotels', 'name' => 'Airport City Hotels', 'email' => 'finance@airport-hotels.demo.ladill.com', 'phone' => '+233302500009', 'address' => 'Airport City, Accra'],
['key' => 'kaneshie-traders', 'name' => 'Kaneshie Market Traders', 'email' => 'pay@kaneshie-traders.demo.ladill.com', 'phone' => '+233302500010', 'address' => 'Kaneshie Market, Accra'],
];
/**
* Plausible shared story amounts (GHS major units) for Accounting / Invoice / POS labels.
*
* @var array{care_consult: int, care_lab: int, invoice_typical: int, pos_typical: int, crm_deal: int}
*/
public const STORY_AMOUNTS_GHS = [
'care_consult' => 250,
'care_lab' => 180,
'invoice_typical' => 1500,
'pos_typical' => 85,
'crm_deal' => 12000,
];
/**
* Staff roster keyed by tier. Free: owner + receptionist. Pro/Enterprise: full roster.
*
* @var array<string, list<array{
* key: string,
* email: string,
* name: string,
* apps: list<string>,
* roles: array<string, string>,
* account_role?: string
* }>>
*/
/**
* Care specialty module doctors (Pro / Enterprise). Emails: demo-{tier}-{key}@ladill.com
*
* @var array<string, string> module key => display name
*/
public const SPECIALTY_DOCTORS = [
'emergency' => 'Dr. Kojo Emergency',
'blood_bank' => 'Dr. Ama Blood Bank',
'dentistry' => 'Dr. Ama Dental',
'ophthalmology' => 'Dr. Kofi Eye',
'physiotherapy' => 'Dr. Efua Physio',
'maternity' => 'Dr. Abena Maternity',
'radiology' => 'Dr. Yaw Radiology',
'cardiology' => 'Dr. Kwesi Cardiology',
'psychiatry' => 'Dr. Adwoa Psychiatry',
'pediatrics' => 'Dr. Fiifi Pediatrics',
'orthopedics' => 'Dr. Kojo Orthopedics',
'ent' => 'Dr. Esi ENT',
'oncology' => 'Dr. Mansa Oncology',
'renal' => 'Dr. Nana Renal',
'surgery' => 'Dr. Kwadwo Surgery',
'vaccination' => 'Dr. Afia Vaccination',
'pathology' => 'Dr. Yaw Pathology',
'infusion' => 'Dr. Akosua Infusion',
'dermatology' => 'Dr. Ama Dermatology',
'podiatry' => 'Dr. Kofi Podiatry',
'fertility' => 'Dr. Abena Fertility',
'child_welfare' => 'Dr. Efua Child Welfare',
];
public const STAFF = [
'free' => [
[
'key' => 'receptionist',
'email' => 'demo-free-receptionist@ladill.com',
'name' => 'Abena Reception (Free)',
'apps' => ['care', 'frontdesk'],
'roles' => [
'care' => 'receptionist',
'frontdesk' => 'receptionist',
],
],
[
'key' => 'queue',
'email' => 'demo-free-queue@ladill.com',
'name' => 'Ama Queue (Free)',
'apps' => ['queue'],
'roles' => ['queue' => 'receptionist'],
],
],
'pro' => [
[
'key' => 'doctor',
'email' => 'demo-pro-doctor@ladill.com',
'name' => 'Dr. Kwame Mensah (Pro)',
'apps' => ['care'],
'roles' => ['care' => 'doctor'],
],
[
'key' => 'nurse',
'email' => 'demo-pro-nurse@ladill.com',
'name' => 'Akosua Nurse (Pro)',
'apps' => ['care'],
'roles' => ['care' => 'nurse'],
],
[
'key' => 'receptionist',
'email' => 'demo-pro-receptionist@ladill.com',
'name' => 'Abena Reception (Pro)',
'apps' => ['care', 'frontdesk'],
'roles' => [
'care' => 'receptionist',
'frontdesk' => 'receptionist',
],
],
[
'key' => 'queue',
'email' => 'demo-pro-queue@ladill.com',
'name' => 'Ama Queue (Pro)',
'apps' => ['queue'],
'roles' => ['queue' => 'receptionist'],
],
[
'key' => 'pharmacist',
'email' => 'demo-pro-pharmacist@ladill.com',
'name' => 'Yaw Pharmacist (Pro)',
'apps' => ['care'],
'roles' => ['care' => 'pharmacist'],
],
[
'key' => 'lab',
'email' => 'demo-pro-lab@ladill.com',
'name' => 'Efua Lab Tech (Pro)',
'apps' => ['care'],
'roles' => ['care' => 'lab_technician'],
],
[
'key' => 'cashier',
'email' => 'demo-pro-cashier@ladill.com',
'name' => 'Kojo Cashier (Pro)',
'apps' => ['care', 'pos'],
'roles' => [
'care' => 'cashier',
'pos' => 'cashier',
],
],
[
'key' => 'accountant',
'email' => 'demo-pro-accountant@ladill.com',
'name' => 'Adwoa Accountant (Pro)',
'apps' => ['accounting', 'invoice'],
'roles' => [
'accounting' => 'member',
'invoice' => 'member',
'care' => 'accountant',
],
],
[
'key' => 'sales',
'email' => 'demo-pro-sales@ladill.com',
'name' => 'Kofi Sales (Pro)',
'apps' => ['crm', 'invoice', 'meet'],
'roles' => [
'crm' => 'member',
'invoice' => 'member',
'meet' => 'member',
],
],
],
'enterprise' => [
[
'key' => 'doctor',
'email' => 'demo-enterprise-doctor@ladill.com',
'name' => 'Dr. Kwame Mensah',
'apps' => ['care'],
'roles' => ['care' => 'doctor'],
],
[
'key' => 'nurse',
'email' => 'demo-enterprise-nurse@ladill.com',
'name' => 'Akosua Nurse',
'apps' => ['care'],
'roles' => ['care' => 'nurse'],
],
[
'key' => 'receptionist',
'email' => 'demo-enterprise-receptionist@ladill.com',
'name' => 'Abena Reception',
'apps' => ['care', 'frontdesk'],
'roles' => [
'care' => 'receptionist',
'frontdesk' => 'receptionist',
],
],
[
'key' => 'queue',
'email' => 'demo-enterprise-queue@ladill.com',
'name' => 'Ama Queue',
'apps' => ['queue'],
'roles' => ['queue' => 'receptionist'],
],
[
'key' => 'pharmacist',
'email' => 'demo-enterprise-pharmacist@ladill.com',
'name' => 'Yaw Pharmacist',
'apps' => ['care'],
'roles' => ['care' => 'pharmacist'],
],
[
'key' => 'lab',
'email' => 'demo-enterprise-lab@ladill.com',
'name' => 'Efua Lab Tech',
'apps' => ['care'],
'roles' => ['care' => 'lab_technician'],
],
[
'key' => 'cashier',
'email' => 'demo-enterprise-cashier@ladill.com',
'name' => 'Kojo Cashier',
'apps' => ['care', 'pos'],
'roles' => [
'care' => 'cashier',
'pos' => 'cashier',
],
],
[
'key' => 'accountant',
'email' => 'demo-enterprise-accountant@ladill.com',
'name' => 'Adwoa Accountant',
'apps' => ['accounting', 'invoice', 'care'],
'roles' => [
'accounting' => 'member',
'invoice' => 'member',
'care' => 'accountant',
],
],
[
'key' => 'sales',
'email' => 'demo-enterprise-sales@ladill.com',
'name' => 'Kofi Sales',
'apps' => ['crm', 'invoice', 'meet', 'events'],
'roles' => [
'crm' => 'member',
'invoice' => 'member',
'meet' => 'member',
'events' => 'member',
],
],
],
];
/** @var array<string, string> owner email → tier */
public const OWNER_EMAILS = [
'demo-free@ladill.com' => 'free',
'demo-pro@ladill.com' => 'pro',
'demo-enterprise@ladill.com' => 'enterprise',
];
public static function normalizeTier(string $tier): string
{
$tier = strtolower(trim($tier));
return in_array($tier, ['free', 'pro', 'enterprise'], true) ? $tier : 'free';
}
public static function ownerEmailForTier(string $tier): string
{
$tier = self::normalizeTier($tier);
return match ($tier) {
'pro' => 'demo-pro@ladill.com',
'enterprise' => 'demo-enterprise@ladill.com',
default => 'demo-free@ladill.com',
};
}
public static function tierForOwnerEmail(?string $email): ?string
{
$email = strtolower(trim((string) $email));
return self::OWNER_EMAILS[$email] ?? null;
}
/**
* @return array{name: string, legal_name: string, phone: string, email: string, address: string, city: string, region: string, timezone: string}
*/
public static function business(string $tier): array
{
return self::BUSINESS[self::normalizeTier($tier)];
}
/**
* @return list<array{code: string, name: string, phone: string, address: string}>
*/
public static function branches(string $tier, ?int $limit = null): array
{
$tier = self::normalizeTier($tier);
$limit ??= match ($tier) {
'enterprise' => 6,
'pro' => 3,
default => 1,
};
return array_slice(self::BRANCHES, 0, max(1, $limit));
}
/**
* @return list<array{key: string, first_name: string, last_name: string, phone: string, email: string, national_id: string, gender: string, dob: string, company_key: string, city: string, address: string}>
*/
public static function people(?int $limit = null): array
{
if ($limit === null) {
return self::PEOPLE;
}
return array_slice(self::PEOPLE, 0, max(0, $limit));
}
/**
* @return array{key: string, first_name: string, last_name: string, phone: string, email: string, national_id: string, gender: string, dob: string, company_key: string, city: string, address: string}|null
*/
public static function personByKey(string $key): ?array
{
foreach (self::PEOPLE as $person) {
if ($person['key'] === $key) {
return $person;
}
}
return null;
}
public static function fullName(array $person): string
{
return trim($person['first_name'].' '.$person['last_name']);
}
/**
* @return list<array{key: string, name: string, email: string, phone: string, address: string}>
*/
public static function companies(?int $limit = null): array
{
if ($limit === null) {
return self::COMPANIES;
}
return array_slice(self::COMPANIES, 0, max(0, $limit));
}
/**
* @return array{key: string, name: string, email: string, phone: string, address: string}|null
*/
public static function companyByKey(string $key): ?array
{
foreach (self::COMPANIES as $company) {
if ($company['key'] === $key) {
return $company;
}
}
return null;
}
public static function companyNameForPerson(array $person): string
{
return self::companyByKey($person['company_key'])['name'] ?? 'Demo Co';
}
/**
* @return list<array{key: string, email: string, name: string, apps: list<string>, roles: array<string, string>, account_role?: string}>
*/
public static function staff(string $tier): array
{
$tier = self::normalizeTier($tier);
$roster = self::STAFF[$tier] ?? [];
if (! in_array($tier, ['pro', 'enterprise'], true)) {
return $roster;
}
foreach (self::SPECIALTY_DOCTORS as $key => $name) {
$email = sprintf('demo-%s-%s@ladill.com', $tier, $key);
$already = false;
foreach ($roster as $row) {
if (strtolower((string) ($row['email'] ?? '')) === $email) {
$already = true;
break;
}
}
if ($already) {
continue;
}
$suffix = $tier === 'pro' ? ' (Pro)' : '';
$roster[] = [
'key' => $key,
'email' => $email,
'name' => $name.$suffix,
'apps' => ['care'],
'roles' => ['care' => 'doctor'],
];
}
return $roster;
}
/**
* @return array{key: string, email: string, name: string, apps: list<string>, roles: array<string, string>, account_role?: string}|null
*/
public static function staffByEmail(?string $email): ?array
{
$email = strtolower(trim((string) $email));
if ($email === '') {
return null;
}
foreach (['free', 'pro', 'enterprise'] as $tier) {
foreach (self::staff($tier) as $member) {
if (strtolower($member['email']) === $email) {
return $member;
}
}
}
return null;
}
public static function isOwnerDemoEmail(?string $email): bool
{
return self::tierForOwnerEmail($email) !== null;
}
public static function isStaffDemoEmail(?string $email): bool
{
return self::staffByEmail($email) !== null;
}
/** Owner email for a staff member, or null if not staff. */
public static function ownerEmailForStaff(?string $email): ?string
{
$email = strtolower(trim((string) $email));
if ($email === '') {
return null;
}
// Use staff() so dynamically appended specialty doctors resolve too.
foreach (['free', 'pro', 'enterprise'] as $tier) {
foreach (self::staff($tier) as $member) {
if (strtolower((string) $member['email']) === $email) {
return self::ownerEmailForTier($tier);
}
}
}
return null;
}
/**
* Build Identity AccountTeamMember metadata from a staff roles map.
*
* @param array<string, string> $roles
* @return array<string, array{role: string}>
*/
public static function metadataFromRoles(array $roles): array
{
$metadata = [];
foreach ($roles as $app => $role) {
$metadata[$app] = ['role' => $role];
}
return $metadata;
}
public static function storyDay(): \Carbon\CarbonInterface
{
return now(self::TIMEZONE)->startOfDay()->addDays(self::STORY_DAY_OFFSET);
}
/** Local phone form used by some apps (024####### without +233). */
public static function phoneLocal(string $e164): string
{
if (str_starts_with($e164, '+233')) {
return '0'.substr($e164, 4);
}
return $e164;
}
}
+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 {
// Public event registration is posted from ladl.link (cross-site); session CSRF cannot bind there.
$middleware->validateCsrfTokens(except: [
'q/*/register',
]);
$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,
'auth.service' => \App\Http\Middleware\AuthenticateService::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
+2
View File
@@ -4,4 +4,6 @@ return [
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
'api_key' => env('BILLING_API_KEY_EVENTS'),
'service' => 'events',
'wallet_balance_route' => 'wallet.balance',
'currency' => 'GHS',
];
+27
View File
@@ -0,0 +1,27 @@
<?php
$appHost = parse_url((string) env('APP_URL', 'https://events.ladill.com'), PHP_URL_HOST) ?: 'events.ladill.com';
return [
// Custom domains are opt-in; the feature only surfaces/provisions when a
// Domains SSL API key is configured.
'enabled' => filter_var(env('CUSTOM_DOMAINS_ENABLED', true), FILTER_VALIDATE_BOOLEAN),
// This app's own host(s) — requests on any other host are treated as a
// customer custom domain and resolved to the mapped event page.
'app_host' => $appHost,
// Where customers point their A records (apex + www).
'server_ip' => env('LADILL_APP_SERVER_IP', '161.97.138.149'),
// Central SSL provisioning (Ladill Domains).
'ssl_api_url' => rtrim(env('DOMAINS_SSL_API_URL', 'https://domains.ladill.com/api'), '/'),
'ssl_api_key' => env('DOMAINS_API_KEY_EVENTS', ''),
// certbot issues + installs an nginx server block that includes this snippet
// (root + fastcgi for the Events app) so the custom domain serves this app.
'nginx_include' => env('CUSTOM_DOMAINS_NGINX_INCLUDE', '/etc/nginx/snippets/ladill-events-app.conf'),
// Shared secret to verify the signed completion callback from Domains.
'callback_secret' => env('SSL_CALLBACK_SECRET', ''),
];
+18
View File
@@ -0,0 +1,18 @@
<?php
return [
'enabled' => (bool) env('DEMO_ACCOUNTS_ENABLED', false),
'reset_on_login' => (bool) env('DEMO_RESET_ON_LOGIN', true),
'accounts' => [
'demo-free@ladill.com' => 'free',
'demo-pro@ladill.com' => 'pro',
'demo-enterprise@ladill.com' => 'enterprise',
],
// Org members share the enterprise owner tenant.
'member_emails' => [
'demo-enterprise-member-1@ladill.com' => 'demo-enterprise@ladill.com',
'demo-enterprise-member-2@ladill.com' => 'demo-enterprise@ladill.com',
],
];
+34
View File
@@ -0,0 +1,34 @@
<?php
return [
'service_api_keys' => [
'meet' => env('EVENTS_API_KEY_MEET', env('MEET_API_KEY_EVENTS')),
],
'meet_app_url' => env('LADILL_MEET_APP_URL', 'https://meet.ladill.com'),
/*
| Paid Events subscriptions (Pro / Business) are retired. Keep pro.* keys for
| legacy renew command / DB rows; gating is always off via SubscriptionService.
*/
'pro' => [
'enabled' => false,
'price_minor' => (int) env('EVENTS_PRO_PRICE_MINOR', 49000),
'currency' => env('EVENTS_PRO_CURRENCY', 'GHS'),
'period_days' => (int) env('EVENTS_PRO_PERIOD_DAYS', 30),
'grace_days' => (int) env('EVENTS_PRO_GRACE_DAYS', 3),
],
'plans' => [],
'prepaid_months' => [],
'free' => [
'max_live_events' => (int) env('EVENTS_FREE_MAX_LIVE_EVENTS', 2),
'max_tickets_per_month' => (int) env('EVENTS_FREE_MAX_TICKETS_PER_MONTH', 100),
],
'upgrade_banner' => null,
];
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'product_slug' => 'events',
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/events'),
];
+25 -20
View File
@@ -2,39 +2,44 @@
/*
|--------------------------------------------------------------------------
| Ladill App Launcher SHARED, IDENTICAL across every app/service repo
| Ladill App Launcher GENERATED, IDENTICAL across every app/service repo
|--------------------------------------------------------------------------
|
| Lists ONLY fully-extracted apps (each on its own subdomain). To replicate the
| launcher in a new app, copy these three things verbatim into that repo:
| - config/ladill_launcher.php (this file)
| - resources/views/partials/launcher.blade.php
| - public/images/launcher-icons/* (the icon set)
| DO NOT EDIT BY HAND. This file is generated from the app registry in the
| monolith (config/ladill_apps.php). To change the launcher, edit that
| registry and run: php artisan ladill:launcher:sync --propagate
|
| When a service becomes FULLY extracted: add one row below AND drop its icon in
| public/images/launcher-icons/ in EVERY repo. That's the only edit needed.
| Do NOT list a service here until it is fully extracted to its own subdomain.
|
| URLs are absolute (built from the platform root) so this file is byte-identical
| in every app; the blade omits whichever row matches the app's APP_URL host.
| Icons are served from /images/launcher-icons/<icon>.
| URLs are absolute (built from the platform root) so this file is
| byte-identical in every repo; the blade omits whichever row matches the
| app's own host. Icons are served from /images/launcher-icons/<icon>.
*/
$root = config('app.platform_domain', 'ladill.com');
return [
'apps' => [
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'],
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
['name' => 'Woo Manager', 'url' => 'https://woo.'.$root.'/sso/connect?redirect='.urlencode('https://woo.'.$root.'/dashboard'), 'icon' => 'woomanager.svg'],
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'],
['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'],
['name' => 'CRM', 'url' => 'https://crm.'.$root.'/sso/connect?redirect='.urlencode('https://crm.'.$root.'/dashboard'), 'icon' => 'crm.svg'],
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
['name' => 'Link', 'url' => 'https://link.'.$root.'/sso/connect?redirect='.urlencode('https://link.'.$root.'/dashboard'), 'icon' => 'link.svg'],
['name' => 'Frontdesk', 'url' => 'https://frontdesk.'.$root.'/sso/connect?redirect='.urlencode('https://frontdesk.'.$root.'/dashboard'), 'icon' => 'frontdesk.svg'],
['name' => 'Care', 'url' => 'https://care.'.$root.'/sso/connect?redirect='.urlencode('https://care.'.$root.'/dashboard'), 'icon' => 'care.svg'],
['name' => 'Queue', 'url' => 'https://queue.'.$root.'/sso/connect?redirect='.urlencode('https://queue.'.$root.'/dashboard'), 'icon' => 'queue.svg'],
['name' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.svg'],
['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'],
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'],
['name' => 'Meet', 'url' => 'https://meet.'.$root.'/sso/connect?redirect='.urlencode('https://meet.'.$root.'/dashboard'), 'icon' => 'meet.svg'],
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
['name' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'],
['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'],
['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.svg'],
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
],
];
+5
View File
@@ -0,0 +1,5 @@
<?php
return [
'public_domain' => env('LINK_PUBLIC_DOMAIN', 'ladl.link'),
];
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'api_url' => env('LADILL_MAIL_API_URL', 'https://mail.ladill.com/api/service/v1'),
'api_key' => env('MAIL_API_KEY_EVENTS'),
];
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
'url' => rtrim((string) env('MEET_API_URL', 'https://meet.ladill.com/api/service/v1'), '/'),
'key' => env('MEET_API_KEY_EVENTS'),
'webhook_secret' => env('MEET_WEBHOOK_SECRET_EVENTS'),
];
-43
View File
@@ -1,43 +0,0 @@
<?php
return [
'slug' => 'events',
'name' => 'Events',
'logo' => 'images/logo/ladillevents-logo.svg',
'icon' => 'events.svg',
'tagline' => 'Tickets and event registration',
'headline' => 'Sell tickets and manage check-in',
'accent' => 'registration, ticketing, and attendance',
'description' => 'Events handles ticket sales, RSVPs, and check-in for conferences, concerts, church programs, and community gatherings.',
'benefits' =>
[
0 =>
[
'title' => 'Paid or free events',
'text' => 'Sell tickets with mobile money and cards, or run RSVP-only events.',
,]
1 =>
[
'title' => 'Check-in tools',
'text' => 'Scan attendees at the door with QR codes.',
,]
2 =>
[
'title' => 'Attendee management',
'text' => 'Export guest lists and track attendance in real time.',
,]
,]
'use_cases' =>
[
0 => 'Conferences',
1 => 'Church programs',
2 => 'Concerts & meetups',
,]
'gradient_from' => '#7c3aed',
'gradient_to' => '#1c75bc',
'dashboard_route' => 'events.dashboard',
'platform_url' => 'https://localhost',
'marketing_url' => 'https://localhost/products/events',
'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),
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'platform_api_url' => env('SMS_PLATFORM_API_URL', 'https://ladill.com/api'),
'platform_api_key' => env('SMS_API_KEY_EVENTS'),
'customer_relay_url' => env('SMS_CUSTOMER_RELAY_URL', 'https://ladill.com/api/sms'),
'default_sender_id' => env('SMS_DEFAULT_SENDER_ID', 'Ladill'),
];
+14
View File
@@ -0,0 +1,14 @@
<?php
return [
'platform_api_url' => env('SMTP_PLATFORM_API_URL', 'https://ladill.com/api/smtp'),
'platform_api_key' => env('SMTP_API_KEY_EVENTS'),
'customer_relay_url' => env('SMTP_CUSTOMER_RELAY_URL', 'https://ladill.com/api/smtp'),
'from' => env('EVENTS_SMTP_FROM', 'events@ladill.com'),
'from_name' => env('EVENTS_SMTP_FROM_NAME', 'Ladill Events'),
// Deep link when suite path returns messaging_mailbox_required
'messaging_settings_url' => env(
'MESSAGING_SETTINGS_URL',
'https://account.ladill.com/account/settings/messaging?reason=messaging_mailbox_required&from=suite',
),
];
@@ -0,0 +1,33 @@
<?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('custom_domains', function (Blueprint $table) {
$table->id();
$table->foreignId('qr_code_id')->constrained('qr_codes')->cascadeOnDelete(); // the storefront
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('host')->unique();
$table->boolean('include_www')->default(true);
$table->string('status', 16)->default('pending'); // pending | active | failed
$table->string('ssl_status', 16)->default('pending'); // pending | active | failed
$table->timestamp('dns_verified_at')->nullable();
$table->timestamp('ssl_issued_at')->nullable();
$table->timestamp('ssl_expires_at')->nullable();
$table->text('last_error')->nullable();
$table->timestamps();
$table->index('qr_code_id');
});
}
public function down(): void
{
Schema::dropIfExists('custom_domains');
}
};
@@ -0,0 +1,35 @@
<?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('events_messaging_credentials', function (Blueprint $table) {
$table->id();
$table->string('owner_ref')->unique();
$table->text('sms_api_key_encrypted')->nullable();
$table->string('sms_api_key_prefix', 32)->nullable();
$table->string('sms_sender_id', 11)->nullable();
$table->string('sms_status', 32)->nullable();
$table->timestamp('sms_validated_at')->nullable();
$table->text('sms_last_error')->nullable();
$table->text('bird_api_key_encrypted')->nullable();
$table->string('bird_api_key_prefix', 32)->nullable();
$table->string('bird_from_email')->nullable();
$table->string('bird_from_name')->nullable();
$table->string('bird_status', 32)->nullable();
$table->timestamp('bird_validated_at')->nullable();
$table->text('bird_last_error')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('events_messaging_credentials');
}
};
@@ -0,0 +1,22 @@
<?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::table('qr_settings', function (Blueprint $table) {
$table->string('logo_path')->nullable()->after('event_defaults');
});
}
public function down(): void
{
Schema::table('qr_settings', function (Blueprint $table) {
$table->dropColumn('logo_path');
});
}
};
@@ -0,0 +1,28 @@
<?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); // paystack|flutterwave|hubtel
$table->text('public_key')->nullable();
$table->text('secret_key')->nullable();
$table->text('webhook_secret')->nullable();
$table->boolean('is_active')->default(true);
$table->json('metadata')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('payment_gateway_settings');
}
};
@@ -0,0 +1,35 @@
<?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('events_pro_subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
$table->string('status', 16)->default('active');
$table->string('plan', 16)->default('pro');
$table->unsignedInteger('price_minor');
$table->char('currency', 3)->default('GHS');
$table->boolean('auto_renew')->default(true);
$table->timestamp('started_at')->nullable();
$table->timestamp('current_period_end')->nullable();
$table->timestamp('last_charged_at')->nullable();
$table->timestamp('canceled_at')->nullable();
$table->string('last_reference')->nullable();
$table->text('last_error')->nullable();
$table->timestamps();
$table->index(['status', 'current_period_end']);
});
}
public function down(): void
{
Schema::dropIfExists('events_pro_subscriptions');
}
};
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "ladill-qr-plus",
"name": "ladill-events",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -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

Some files were not shown because too many files have changed in this diff Show More