commit e5d2b843880ac82faaf5aa849d35303fb1f51850 Author: isaacclad Date: Tue Jun 23 22:52:24 2026 +0000 Add Ladill POS v1 — register, Pay checkout, and commerce links. Staff-facing counter register at pos.ladill.com with catalog cart, cash and MoMo/card checkout via Ladill Pay, CRM timeline/import, invoice prefill, and Merchant catalog import. Co-authored-by: Cursor diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a186cd2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[compose.yaml] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..775cea7 --- /dev/null +++ b/.env.example @@ -0,0 +1,54 @@ +APP_NAME="Ladill POS" +APP_ENV=production +APP_KEY= +APP_DEBUG=false +APP_URL=https://pos.ladill.com + +PLATFORM_URL=https://ladill.com +PLATFORM_DOMAIN=ladill.com +AUTH_DOMAIN=auth.ladill.com +ACCOUNT_DOMAIN=account.ladill.com +POS_DOMAIN=pos.ladill.com + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=ladill_pos +DB_USERNAME=ladill_pos +DB_PASSWORD= + +PLATFORM_DB_HOST=127.0.0.1 +PLATFORM_DB_PORT=3306 +PLATFORM_DB_DATABASE=ladilldb +PLATFORM_DB_USERNAME=ladill_pos +PLATFORM_DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_DOMAIN=.ladill.com + +LADILL_SSO_CLIENT_ID= +LADILL_SSO_CLIENT_SECRET= + +BILLING_API_URL=https://ladill.com/api/billing +BILLING_API_KEY_POS= + +PAY_API_URL=https://ladill.com/api/pay +PAY_API_KEY_POS= + +IDENTITY_API_URL=https://ladill.com/api +IDENTITY_API_KEY_POS= + +POS_DEFAULT_CURRENCY=GHS +POS_MERCHANT_IMPORT_ENABLED=true + +CRM_API_URL=https://crm.ladill.com/api +CRM_API_KEY_POS= + +MERCHANT_DB_HOST=127.0.0.1 +MERCHANT_DB_PORT=3306 +MERCHANT_DB_DATABASE=ladill_merchant +MERCHANT_DB_USERNAME=ladill_pos +MERCHANT_DB_PASSWORD= + +VITE_APP_NAME="${APP_NAME}" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..ab0d5b0 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,110 @@ +name: Deploy Ladill Mini + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-mini + cancel-in-progress: true + +jobs: + deploy: + runs-on: deploy + env: + NODE_ROOT: /tmp/ladill-node-22-r1 + NODE_VERSION: "22.14.0" + RELEASE_ARCHIVE: /tmp/ladill-mini-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz + WORKSPACE: /tmp/${{ gitea.repository_owner }}-mini-${{ gitea.run_id }}-${{ gitea.run_attempt }} + LADILL_APP_ROOT: /var/www/ladill-mini + steps: + - name: Checkout + shell: bash {0} + run: | + set -Eeuo pipefail + DEPLOY_GITEA_TOKEN="$(cat /home/deploy/.ladill-deploy-gitea-token)" + REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git" + rm -rf "$WORKSPACE" + mkdir -p "$WORKSPACE" + export GIT_TERMINAL_PROMPT=0 + git \ + -c credential.helper= \ + -c http.extraHeader="Authorization: token ${DEPLOY_GITEA_TOKEN}" \ + clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \ + "$REPO_URL" "$WORKSPACE" + + - name: Setup Node.js + shell: bash {0} + run: | + set -Eeuo pipefail + if [ ! -x "$NODE_ROOT/bin/node" ]; then + rm -rf "$NODE_ROOT" + mkdir -p "$NODE_ROOT" + case "$(uname -m)" in + x86_64|amd64) NODE_ARCH="x64" ;; + aarch64|arm64) NODE_ARCH="arm64" ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac + curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \ + | tar -xJ --strip-components=1 -C "$NODE_ROOT" + fi + "$NODE_ROOT/bin/node" -v + + - name: Build frontend assets + shell: bash {0} + run: | + set -Eeuo pipefail + cd "$WORKSPACE" + export PATH="$NODE_ROOT/bin:$PATH" + npm ci --no-audit --no-fund + npm run build + + - name: Build release archive + shell: bash {0} + run: | + set -Eeuo pipefail + cd "$WORKSPACE" + printf '%s\n' "${{ gitea.sha }}" > REVISION + rm -f "$RELEASE_ARCHIVE" + tar -czf "$RELEASE_ARCHIVE" \ + --exclude=.git --exclude=.gitea --exclude=.github --exclude=.env \ + --exclude=node_modules --exclude=vendor --exclude=storage --exclude=tests . + + - name: Deploy release + shell: bash {0} + env: + LADILL_RELEASE_ARCHIVE: /tmp/ladill-mini-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz + run: | + set -Eeuo pipefail + : "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}" + bash "$WORKSPACE/deploy/deploy.sh" + + - name: Ensure nginx vhost + shell: bash {0} + run: | + set -Eeuo pipefail + NGINX_SCRIPT="$WORKSPACE/deployment/setup-service-subdomain-nginx.sh" + if [ ! -f "$NGINX_SCRIPT" ]; then + NGINX_SCRIPT="/var/www/ladill.com/current/deployment/setup-service-subdomain-nginx.sh" + fi + if [ ! -f "$NGINX_SCRIPT" ]; then + echo "WARN: setup-service-subdomain-nginx.sh not found — configure mini.ladill.com vhost manually" + exit 0 + fi + if sudo -n bash "$NGINX_SCRIPT" mini --app /var/www/ladill-mini/current; then + echo "nginx vhost updated for mini.ladill.com" + else + echo "WARN: nginx vhost step skipped (deploy user cannot sudo) — vhost must already be provisioned" + fi + + - name: Cleanup + if: always() + shell: bash {0} + run: | + rm -rf "$WORKSPACE" + rm -f "$RELEASE_ARCHIVE" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dea2985 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.fleet +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/storage/framework/views/* +!/storage/framework/views/.gitignore +/vendor +Homestead.json +Homestead.yaml +Thumbs.db + +# Native mobile apps — kept locally, not tracked in this repo +/apps diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..fb68ade --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,26 @@ +# Ladill POS — deploy runbook + +Standalone **in-store register** at `pos.ladill.com`. Charges via Ladill Pay (`source_service: pos`, `fee_tier: sales`). + +## Platform wiring + +1. OIDC client: `php artisan passport:client` on monolith — redirect `https://pos.ladill.com/sso/callback` +2. Env keys on monolith: `BILLING_API_KEY_POS`, `PAY_API_KEY_POS`, `IDENTITY_API_KEY_POS` +3. Env on POS app: matching consumer keys + `LADILL_SSO_CLIENT_*` +4. Database: `ladill_pos` (MySQL) — migrations via deploy script +5. Marketing: `php scripts/ensure-app-entry-routes.php` (includes `ladill-pos`) + +## Deploy + +Same release model as Mini/Merchant — see `deploy/deploy.sh`. + +```bash +php artisan migrate --force +php artisan config:cache && php artisan route:cache && php artisan view:cache +``` + +## Commerce links + +- **CRM** — `CRM_API_KEY_POS` on CRM + `CRM_API_URL` / `CRM_API_KEY_POS` on POS; timeline on paid sales; product import from CRM API +- **Invoice** — "Create invoice" on paid sale receipt (`kind: pos_sale` prefill) +- **Merchant** — `MERCHANT_DB_*` read-only connection; import storefront catalog into `pos_products` diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b2dcbb --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Ladill POS + +In-store register at **pos.ladill.com** — product grid, cart, cash or Ladill Pay checkout. + +## Features (v1) + +- **Register** — catalog + quick-amount sales +- **Products** — local catalog CRUD +- **Sales** — history and receipt view +- **Ladill Pay** — MoMo/card via `source_service: pos`, `fee_tier: sales` + +## Local dev + +```bash +cp .env.example .env +composer install +php artisan key:generate +touch database/database.sqlite +# set DB_CONNECTION=sqlite in .env +php artisan migrate +npm install && npm run build +php artisan serve +``` + +## Tests + +```bash +php artisan test +``` + +## Deploy + +See [DEPLOY.md](DEPLOY.md). + +## Roadmap + +- CRM timeline + product import +- Invoice receipt prefill +- Merchant catalog import diff --git a/REVISION b/REVISION new file mode 100644 index 0000000..df4b9b8 --- /dev/null +++ b/REVISION @@ -0,0 +1 @@ +manual-deploy diff --git a/app/Console/Commands/CancelStalePayments.php b/app/Console/Commands/CancelStalePayments.php new file mode 100644 index 0000000..00dfb00 --- /dev/null +++ b/app/Console/Commands/CancelStalePayments.php @@ -0,0 +1,34 @@ +subHours(MiniPayment::STALE_PENDING_HOURS); + + $count = MiniPayment::query() + ->where('status', MiniPayment::STATUS_PENDING) + ->where('created_at', '<', $cutoff) + ->update([ + 'status' => MiniPayment::STATUS_CANCELED, + 'updated_at' => now(), + ]); + + $this->info("Canceled {$count} stale pending payment(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ProcessAutoWithdrawals.php b/app/Console/Commands/ProcessAutoWithdrawals.php new file mode 100644 index 0000000..c686913 --- /dev/null +++ b/app/Console/Commands/ProcessAutoWithdrawals.php @@ -0,0 +1,21 @@ +processAll(); + $this->info("Processed {$count} auto-withdrawal(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000..74cc874 --- /dev/null +++ b/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,196 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + 'device_name' => ['nullable', 'string', 'max:120'], + ]); + + $response = $this->identityPost('/api/identity/auth/login', [ + 'email' => $credentials['email'], + 'password' => $credentials['password'], + ]); + + if ($response->status() === 422) { + throw ValidationException::withMessages([ + 'email' => ['The email or password is incorrect.'], + ]); + } + + $user = $this->provisionFromResponse($response); + + return $this->issueToken($user, $credentials['device_name'] ?? null); + } + + public function register(Request $request): JsonResponse + { + // Mirror the web signup form; the monolith validates authoritatively, but + // we pre-validate so the app gets fast, field-level feedback. + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + 'company' => ['nullable', 'string', 'max:255'], + 'address' => ['required', 'string', 'max:255'], + 'city' => ['required', 'string', 'max:255'], + 'state' => ['required', 'string', 'max:255'], + 'country' => ['required', 'string', 'size:2'], + 'zipcode' => ['required', 'string', 'max:32'], + 'phone_cc' => ['required', 'string', 'max:8'], + 'phone' => ['required', 'string', 'max:32'], + 'mobile_cc' => ['nullable', 'string', 'max:8'], + 'mobile' => ['nullable', 'string', 'max:32'], + 'terms' => ['accepted'], + 'device_name' => ['nullable', 'string', 'max:120'], + ]); + + $response = $this->identityPost('/api/identity/auth/register', array_merge( + $request->only([ + 'name', 'email', 'password', 'password_confirmation', 'company', + 'address', 'city', 'state', 'country', 'zipcode', + 'phone_cc', 'phone', 'mobile_cc', 'mobile', + ]), + ['terms' => $request->boolean('terms')], + )); + + // Surface the monolith's validation errors (e.g. email already taken). + if ($response->status() === 422) { + throw ValidationException::withMessages( + $response->json('errors') ?: ['email' => [$response->json('message') ?: 'Registration failed.']], + ); + } + + $user = $this->provisionFromResponse($response); + + return $this->issueToken($user, $data['device_name'] ?? null); + } + + public function logout(Request $request): JsonResponse + { + $request->user()?->currentAccessToken()?->delete(); + + return response()->json(['data' => ['message' => 'Signed out.']]); + } + + public function me(Request $request): JsonResponse + { + return response()->json(['data' => $this->presentUser($request->user())]); + } + + private function identity(): \Illuminate\Http\Client\PendingRequest + { + return Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/')) + ->withToken((string) config('services.ladill_identity.key')) + ->connectTimeout(10) + ->timeout(20) + ->acceptJson() + ->asJson(); + } + + /** POST to the identity API, turning connection failures into a clean message. */ + private function identityPost(string $path, array $payload): HttpResponse + { + try { + return $this->identity()->post($path, $payload); + } catch (ConnectionException $e) { + throw ValidationException::withMessages([ + 'email' => ['Could not reach Ladill sign-in. Please try again in a moment.'], + ]); + } + } + + /** Upsert the local mirror from the identity API's OIDC claims. */ + private function provisionFromResponse(HttpResponse $response): User + { + if ($response->failed()) { + throw ValidationException::withMessages([ + 'email' => ['We could not reach Ladill sign-in. Please try again.'], + ]); + } + + $claims = (array) $response->json('data.user', []); + $sub = (string) ($claims['sub'] ?? ''); + + if ($sub === '') { + throw ValidationException::withMessages([ + 'email' => ['We could not verify your Ladill account. Please try again.'], + ]); + } + + $email = (string) ($claims['email'] ?? ''); + + return User::updateOrCreate( + ['public_id' => $sub], + [ + 'name' => $claims['name'] ?? null, + 'email' => $email !== '' ? $email : $sub.'@users.ladill.com', + 'avatar_url' => $claims['picture'] ?? null, + ], + ); + } + + private function issueToken(User $user, ?string $deviceName): JsonResponse + { + QrTeamMember::linkPendingInvitesFor($user); + $user->update(['last_app_active_at' => now()]); + + $token = $user->createToken($deviceName ?: 'Ladill Mini Android', ['mini:read', 'mini:write']); + + return response()->json([ + 'data' => [ + 'token' => $token->plainTextToken, + 'user' => $this->presentUser($user), + ], + ]); + } + + /** @return array */ + private function presentUser(User $user): array + { + // On the public login/register routes SetActingAccount hasn't run and + // there's no authenticated guard yet, so ladill_account() is null — + // the acting account at sign-in is simply the user themselves. + $account = ladill_account() ?? $user; + + return [ + 'id' => $user->id, + 'public_id' => $user->public_id, + 'name' => $user->name, + 'email' => $user->email, + 'avatar_url' => $user->avatarUrl(), + 'acting_account' => [ + 'id' => $account->id, + 'public_id' => $account->public_id, + 'name' => $account->name, + 'email' => $account->email, + ], + 'accessible_account_ids' => $user->accessibleAccounts()->pluck('id')->values(), + ]; + } +} diff --git a/app/Http/Controllers/Api/Concerns/CallsIdentityApi.php b/app/Http/Controllers/Api/Concerns/CallsIdentityApi.php new file mode 100644 index 0000000..d7689fa --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/CallsIdentityApi.php @@ -0,0 +1,47 @@ +withToken((string) config('services.ladill_identity.key')) + ->connectTimeout(10) + ->timeout(20) + ->acceptJson() + ->asJson(); + } + + protected function identitySend(string $method, string $path, array $payload): HttpResponse + { + try { + return $this->identity()->send($method, $path, ['json' => $payload]); + } catch (ConnectionException) { + throw ValidationException::withMessages([ + 'base' => ['Could not reach Ladill. Please try again in a moment.'], + ]); + } + } + + /** Re-throw a 422 from the identity API as local validation errors. */ + protected function rethrowValidation(HttpResponse $response, string $fallbackField = 'base'): void + { + if ($response->status() === 422) { + throw ValidationException::withMessages( + $response->json('errors') ?: [$fallbackField => [$response->json('message') ?: 'Request failed.']], + ); + } + } +} diff --git a/app/Http/Controllers/Api/MeController.php b/app/Http/Controllers/Api/MeController.php new file mode 100644 index 0000000..7791803 --- /dev/null +++ b/app/Http/Controllers/Api/MeController.php @@ -0,0 +1,32 @@ +user(); + $account = ladill_account(); + + return response()->json([ + 'data' => [ + 'id' => $user->id, + 'public_id' => $user->public_id, + 'name' => $user->name, + 'email' => $user->email, + 'acting_account' => [ + 'id' => $account->id, + 'public_id' => $account->public_id, + 'name' => $account->name, + 'email' => $account->email, + ], + 'accessible_account_ids' => $user->accessibleAccounts()->pluck('id')->values(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Mini/AccountController.php b/app/Http/Controllers/Api/Mini/AccountController.php new file mode 100644 index 0000000..f690f7e --- /dev/null +++ b/app/Http/Controllers/Api/Mini/AccountController.php @@ -0,0 +1,156 @@ +getOrCreateQrSetting(); + + // Phone lives on the central account, not the local mirror — fetch it, + // but never let a transient identity-API hiccup break settings loading. + $phone = ''; + $phoneCc = ''; + try { + $profile = $this->identitySend('GET', '/api/identity/profile?user='.urlencode((string) $account->public_id), []); + if ($profile->successful()) { + $phone = (string) $profile->json('data.phone', ''); + $phoneCc = (string) $profile->json('data.phone_cc', ''); + } + } catch (\Throwable) { + // leave phone blank + } + + return response()->json([ + 'data' => [ + 'profile' => [ + 'name' => $account->name, + 'email' => $account->email, + 'phone' => $phone, + 'phone_cc' => $phoneCc, + ], + 'notifications' => [ + 'notify_email' => $settings->notify_email ?: $account->email, + 'product_updates' => (bool) ($settings->product_updates ?? true), + 'notify_registrations' => (bool) ($settings->notify_registrations ?? true), + 'notify_payouts' => (bool) ($settings->notify_payouts ?? true), + ], + ], + ]); + } + + public function updateSettings(Request $request): JsonResponse + { + $account = ladill_account(); + + $data = $request->validate([ + 'notify_email' => ['nullable', 'email', 'max:255'], + 'product_updates' => ['sometimes', 'boolean'], + 'notify_registrations' => ['sometimes', 'boolean'], + 'notify_payouts' => ['sometimes', 'boolean'], + ]); + + $settings = QrSetting::updateOrCreate( + ['user_id' => $account->id], + [ + 'notify_email' => $data['notify_email'] ?? $account->email, + 'product_updates' => $request->boolean('product_updates'), + 'notify_registrations' => $request->boolean('notify_registrations'), + 'notify_payouts' => $request->boolean('notify_payouts'), + ], + ); + + return response()->json([ + 'data' => [ + 'notify_email' => $settings->notify_email, + 'product_updates' => (bool) $settings->product_updates, + 'notify_registrations' => (bool) $settings->notify_registrations, + 'notify_payouts' => (bool) $settings->notify_payouts, + ], + ]); + } + + public function updateProfile(Request $request): JsonResponse + { + $account = ladill_account(); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'phone_cc' => ['nullable', 'string', 'max:8'], + 'phone' => ['nullable', 'string', 'max:32'], + ]); + + $response = $this->identitySend('PUT', '/api/identity/profile', array_merge( + ['user' => $account->public_id], + $data, + )); + $this->rethrowValidation($response, 'name'); + + if ($response->successful()) { + $account->update(['name' => $data['name']]); + } + + return response()->json([ + 'data' => ['name' => $account->fresh()->name, 'email' => $account->email], + ]); + } + + public function uploadAvatar(Request $request): JsonResponse + { + $account = ladill_account(); + + $request->validate([ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'], + ]); + + $file = $request->file('avatar'); + + $response = Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/')) + ->withToken((string) config('services.ladill_identity.key')) + ->acceptJson() + ->attach('avatar', (string) file_get_contents($file->getRealPath()), $file->getClientOriginalName()) + ->post('/api/identity/avatar', ['user' => $account->public_id]); + + $this->rethrowValidation($response, 'avatar'); + + if ($response->successful()) { + $picture = (string) $response->json('data.user.picture', ''); + if ($picture !== '') { + $account->update(['avatar_url' => $picture]); + } + } + + return response()->json(['data' => ['avatar_url' => $account->fresh()->avatarUrl()]]); + } + + public function changePassword(Request $request): JsonResponse + { + $account = ladill_account(); + + $request->validate([ + 'current_password' => ['required', 'string'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + ]); + + $response = $this->identitySend('POST', '/api/identity/auth/change-password', [ + 'user' => $account->public_id, + 'current_password' => $request->string('current_password'), + 'password' => $request->string('password'), + 'password_confirmation' => $request->string('password_confirmation'), + ]); + $this->rethrowValidation($response, 'current_password'); + + return response()->json(['data' => ['message' => 'Password updated.']]); + } +} diff --git a/app/Http/Controllers/Api/Mini/AfiaController.php b/app/Http/Controllers/Api/Mini/AfiaController.php new file mode 100644 index 0000000..51ad2f1 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/AfiaController.php @@ -0,0 +1,82 @@ +validate([ + 'message' => ['required', 'string', 'max:2000'], + 'history' => ['nullable', 'array', 'max:20'], + 'history.*.role' => ['nullable', 'string'], + 'history.*.text' => ['nullable', 'string'], + ]); + + if (! $afia->enabled()) { + return response()->json(['message' => 'Afia is not available right now.'], 503); + } + + try { + $reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context()); + } catch (\Throwable $e) { + report($e); + + return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502); + } + + return response()->json(['reply' => $reply]); + } + + /** @return array */ + private function context(): array + { + $account = ladill_account(); + $types = QrTypeCatalog::paymentTypes(); + $codes = $account->qrCodes()->whereIn('type', $types); + + $ctx = [ + 'signed_in' => 'yes', + 'qr_codes_total' => (clone $codes)->count(), + 'qr_codes_active' => (clone $codes)->where('is_active', true)->count(), + 'scans_total' => (int) (clone $codes)->sum('scans_total'), + 'top_code_type' => (clone $codes) + ->selectRaw('type, count(*) as total') + ->groupBy('type') + ->orderByDesc('total') + ->value('type') ?: 'none yet', + ]; + + if ($account->public_id) { + try { + $ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor((string) $account->public_id) / 100, 2); + } catch (\Throwable) { + } + } + + $recent = $account->qrCodes() + ->whereIn('type', $types) + ->latest('updated_at') + ->limit(3) + ->get(['type', 'label', 'short_code', 'scans_total']); + + if ($recent->isNotEmpty()) { + $ctx['recent_codes'] = $recent->map(fn (QrCode $code): string => sprintf( + '%s (%s, %d scans)', + $code->label ?: $code->short_code, + QrTypeCatalog::label($code->type), + $code->scans_total, + ))->implode('; '); + } + + return $ctx; + } +} diff --git a/app/Http/Controllers/Api/Mini/NotificationController.php b/app/Http/Controllers/Api/Mini/NotificationController.php new file mode 100644 index 0000000..d8c29db --- /dev/null +++ b/app/Http/Controllers/Api/Mini/NotificationController.php @@ -0,0 +1,76 @@ +notifications() + ->latest() + ->limit(100) + ->get() + ->map(fn ($n) => $this->present($n)); + + $unread = $account->unreadNotifications()->count(); + + return response()->json([ + 'data' => $notifications, + 'unread_count' => $unread, + ]); + } + + public function unreadCount(Request $request): JsonResponse + { + $account = ladill_account(); + + return response()->json([ + 'data' => [ + 'unread_count' => $account->unreadNotifications()->count(), + ], + ]); + } + + public function markAsRead(Request $request, string $id): JsonResponse + { + $notification = ladill_account() + ->notifications() + ->where('id', $id) + ->first(); + + if ($notification) { + $notification->markAsRead(); + } + + return response()->json(['data' => ['success' => true]]); + } + + public function markAllAsRead(Request $request): JsonResponse + { + ladill_account()->unreadNotifications->markAsRead(); + + return response()->json(['data' => ['success' => true]]); + } + + /** @return array */ + private function present(mixed $notification): array + { + return [ + 'id' => $notification->id, + 'type' => class_basename($notification->type), + 'title' => $notification->data['title'] ?? 'Notification', + 'message' => $notification->data['message'] ?? '', + 'icon' => $notification->data['icon'] ?? 'bell', + 'milestone' => $notification->data['milestone'] ?? null, + 'url' => $notification->data['url'] ?? null, + 'read_at' => $notification->read_at?->toIso8601String(), + 'created_at' => $notification->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/Mini/OverviewController.php b/app/Http/Controllers/Api/Mini/OverviewController.php new file mode 100644 index 0000000..af105c9 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/OverviewController.php @@ -0,0 +1,59 @@ +qrCodes() + ->where('type', QrCode::TYPE_PAYMENT) + ->pluck('id'); + + $todayPayments = MiniPayment::query() + ->whereIn('qr_code_id', $qrIds) + ->where('status', MiniPayment::STATUS_PAID) + ->where('paid_at', '>=', now()->startOfDay()); + + $recentPayments = MiniPayment::query() + ->whereIn('qr_code_id', $qrIds) + ->where('status', MiniPayment::STATUS_PAID) + ->with('qrCode') + ->latest('paid_at') + ->limit(8) + ->get(); + + $balanceMinor = 0; + try { + $balanceMinor = $this->billing->balanceMinor($account->public_id); + } catch (Throwable $e) { + Log::warning('Mini API overview could not load wallet balance', [ + 'user' => $account->public_id, + 'error' => $e->getMessage(), + ]); + } + + return response()->json([ + 'data' => [ + 'currency' => 'GHS', + 'today_takings_minor' => (int) (clone $todayPayments)->sum('merchant_amount_minor'), + 'today_count' => (clone $todayPayments)->count(), + 'payment_qr_count' => $qrIds->count(), + 'wallet_balance_minor' => $balanceMinor, + 'recent_payments' => $recentPayments->map(fn (MiniPayment $p) => PaymentPresenter::present($p))->values(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Mini/PaymentPresenter.php b/app/Http/Controllers/Api/Mini/PaymentPresenter.php new file mode 100644 index 0000000..b05c1d0 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/PaymentPresenter.php @@ -0,0 +1,56 @@ + */ + public static function present(MiniPayment $payment): array + { + return [ + 'id' => $payment->id, + 'reference' => $payment->reference, + 'amount_minor' => $payment->amount_minor, + 'merchant_amount_minor' => $payment->merchant_amount_minor, + 'platform_fee_minor' => $payment->platform_fee_minor, + 'currency' => $payment->currency, + 'status' => $payment->status, + 'payer_name' => $payment->payer_name, + 'payer_email' => $payment->payer_email, + 'payer_phone' => $payment->payer_phone, + 'payer_note' => $payment->payer_note, + 'qr_code_id' => $payment->qr_code_id, + 'qr_label' => $payment->qrCode?->label, + 'paid_at' => $payment->paid_at?->toIso8601String(), + 'created_at' => $payment->created_at?->toIso8601String(), + ]; + } + + /** @return array */ + public static function presentQr(QrCode $qr, ?string $previewUrl = null): array + { + $content = $qr->content(); + + return [ + 'id' => $qr->id, + 'label' => $qr->label, + 'business_name' => $content['business_name'] ?? null, + 'branch_label' => $content['branch_label'] ?? null, + 'currency' => $content['currency'] ?? 'GHS', + 'short_code' => $qr->short_code, + 'public_url' => $qr->publicUrl(), + 'is_active' => $qr->is_active, + 'scans_total' => $qr->scans_total, + 'preview_url' => $previewUrl, + 'created_at' => $qr->created_at?->toIso8601String(), + 'updated_at' => $qr->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/Mini/PaymentQrController.php b/app/Http/Controllers/Api/Mini/PaymentQrController.php new file mode 100644 index 0000000..b310fb0 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/PaymentQrController.php @@ -0,0 +1,131 @@ +qrCodes() + ->where('type', QrCode::TYPE_PAYMENT) + ->latest() + ->get(); + + return response()->json([ + 'data' => $qrCodes->map(fn (QrCode $qr) => PaymentPresenter::presentQr($qr, $this->previewUrl($qr)))->values(), + ]); + } + + public function store(Request $request): JsonResponse + { + abort_unless($request->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.'); + + $data = $request->validate([ + 'label' => ['required', 'string', 'max:120'], + 'business_name' => ['required', 'string', 'max:120'], + 'branch_label' => ['nullable', 'string', 'max:80'], + ]); + + $data['type'] = QrCode::TYPE_PAYMENT; + $data['currency'] = 'GHS'; + + try { + $qrCode = $this->manager->create(ladill_account(), $data); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json([ + 'data' => PaymentPresenter::presentQr($qrCode->fresh(), $this->previewUrl($qrCode)), + ], 201); + } + + public function show(QrCode $paymentQr): JsonResponse + { + $this->authorizePaymentQr($paymentQr); + + return response()->json([ + 'data' => PaymentPresenter::presentQr($paymentQr, $this->previewUrl($paymentQr)), + ]); + } + + public function update(Request $request, QrCode $paymentQr): JsonResponse + { + abort_unless($request->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.'); + $this->authorizePaymentQr($paymentQr); + + $data = $request->validate([ + 'label' => ['sometimes', 'string', 'max:120'], + 'business_name' => ['sometimes', 'string', 'max:120'], + 'branch_label' => ['nullable', 'string', 'max:80'], + 'is_active' => ['sometimes', 'boolean'], + ]); + + if ($request->has('is_active')) { + $data['is_active'] = $request->boolean('is_active'); + } + + try { + $this->manager->update($paymentQr, $data); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json([ + 'data' => PaymentPresenter::presentQr($paymentQr->fresh(), $this->previewUrl($paymentQr)), + ]); + } + + public function destroy(QrCode $paymentQr): JsonResponse + { + abort_unless(request()->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.'); + $this->authorizePaymentQr($paymentQr); + + $this->manager->delete($paymentQr); + + return response()->json(['data' => ['message' => 'Payment QR deleted.']]); + } + + public function preview(QrCode $paymentQr): Response + { + $this->authorizePaymentQr($paymentQr); + + $qrCode = $this->imageGenerator->ensureValidImages($paymentQr); + $bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path); + + if ($bytes === null) { + $bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style()); + $this->imageGenerator->generateAndStore($qrCode); + } + + return response($bytes, 200, [ + 'Content-Type' => 'image/png', + 'Cache-Control' => 'private, max-age=3600', + ]); + } + + private function previewUrl(QrCode $qr): string + { + return route('api.mini.payment-qrs.preview', $qr); + } + + private function authorizePaymentQr(QrCode $paymentQr): void + { + abort_unless($paymentQr->type === QrCode::TYPE_PAYMENT, 404); + abort_unless($paymentQr->user_id === ladill_account()->id, 403); + } +} diff --git a/app/Http/Controllers/Api/Mini/PaymentsController.php b/app/Http/Controllers/Api/Mini/PaymentsController.php new file mode 100644 index 0000000..1bec5a4 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/PaymentsController.php @@ -0,0 +1,49 @@ +query('q', '')); + + $qrIds = ladill_account()->qrCodes() + ->where('type', QrCode::TYPE_PAYMENT) + ->pluck('id'); + + $payments = MiniPayment::query() + ->whereIn('qr_code_id', $qrIds) + ->when($search !== '', function ($query) use ($search) { + $like = '%'.$search.'%'; + $query->where(function ($inner) use ($like) { + $inner->where('payer_name', 'like', $like) + ->orWhere('payer_email', 'like', $like) + ->orWhere('payer_note', 'like', $like) + ->orWhere('reference', 'like', $like) + ->orWhere('payment_reference', 'like', $like) + ->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like)); + }); + }) + ->with('qrCode') + ->latest('created_at') + ->paginate(25) + ->withQueryString(); + + return response()->json([ + 'data' => collect($payments->items())->map(fn (MiniPayment $p) => PaymentPresenter::present($p))->values(), + 'meta' => [ + 'current_page' => $payments->currentPage(), + 'last_page' => $payments->lastPage(), + 'per_page' => $payments->perPage(), + 'total' => $payments->total(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Mini/PayoutsController.php b/app/Http/Controllers/Api/Mini/PayoutsController.php new file mode 100644 index 0000000..7903784 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/PayoutsController.php @@ -0,0 +1,49 @@ +qrCodes() + ->where('type', QrCode::TYPE_PAYMENT) + ->pluck('id'); + + $revenueMinor = (int) MiniPayment::query() + ->whereIn('qr_code_id', $qrIds) + ->where('status', MiniPayment::STATUS_PAID) + ->sum('merchant_amount_minor'); + + $balanceMinor = 0; + try { + $balanceMinor = $this->billing->balanceMinor($account->public_id); + } catch (Throwable $e) { + Log::warning('Mini API payouts could not load wallet balance', [ + 'user' => $account->public_id, + 'error' => $e->getMessage(), + ]); + } + + return response()->json([ + 'data' => [ + 'currency' => 'GHS', + 'total_revenue_minor' => $revenueMinor, + 'wallet_balance_minor' => $balanceMinor, + 'account_wallet_url' => 'https://'.config('app.account_domain').'/wallet', + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Mini/PushTokenController.php b/app/Http/Controllers/Api/Mini/PushTokenController.php new file mode 100644 index 0000000..e29ab85 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/PushTokenController.php @@ -0,0 +1,51 @@ +validate([ + 'token' => ['required', 'string', 'max:512'], + 'platform' => ['nullable', 'string', 'max:32'], + 'device_name' => ['nullable', 'string', 'max:120'], + ]); + + $user = $request->user(); + $now = now(); + + UserPushToken::updateOrCreate( + ['token' => $data['token']], + [ + 'user_id' => $user->id, + 'platform' => $data['platform'] ?? 'android', + 'device_name' => $data['device_name'] ?? $user->currentAccessToken()?->name, + 'last_seen_at' => $now, + ], + ); + + $user->update(['last_app_active_at' => $now]); + + return response()->json(['data' => ['registered' => true]]); + } + + public function destroy(Request $request): JsonResponse + { + $data = $request->validate([ + 'token' => ['required', 'string', 'max:512'], + ]); + + $request->user() + ->pushTokens() + ->where('token', $data['token']) + ->delete(); + + return response()->json(['data' => ['removed' => true]]); + } +} diff --git a/app/Http/Controllers/Api/Mini/SupportController.php b/app/Http/Controllers/Api/Mini/SupportController.php new file mode 100644 index 0000000..b200a7c --- /dev/null +++ b/app/Http/Controllers/Api/Mini/SupportController.php @@ -0,0 +1,56 @@ +identitySend('GET', '/api/identity/support/tickets?user='.urlencode((string) ladill_account()->public_id), []); + + return response()->json(['data' => $response->json('data', [])]); + } + + public function ticket(int $ticket): JsonResponse + { + $response = $this->identitySend( + 'GET', + '/api/identity/support/tickets/'.$ticket.'?user='.urlencode((string) ladill_account()->public_id), + [], + ); + + if ($response->status() === 404) { + return response()->json(['message' => 'Ticket not found.'], 404); + } + + return response()->json(['data' => $response->json('data')]); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'subject' => ['required', 'string', 'max:255'], + 'message' => ['required', 'string', 'max:5000'], + 'priority' => ['sometimes', 'in:low,normal,high'], + ]); + + $response = $this->identitySend('POST', '/api/identity/support/tickets', array_merge( + ['user' => ladill_account()->public_id], + $data, + )); + $this->rethrowValidation($response, 'subject'); + + return response()->json(['data' => $response->json('data')], 201); + } +} diff --git a/app/Http/Controllers/Api/Mini/WalletController.php b/app/Http/Controllers/Api/Mini/WalletController.php new file mode 100644 index 0000000..876f4c4 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/WalletController.php @@ -0,0 +1,161 @@ +billing->balanceMinor($account->public_id); + $ledger = $this->billing->serviceLedger($account->public_id, config('billing.service', 'mini')); + } catch (Throwable $e) { + Log::warning('Mini API wallet load failed', ['user' => $account->public_id, 'error' => $e->getMessage()]); + } + + $settings = $account->getOrCreateQrSetting(); + + return response()->json([ + 'data' => [ + 'currency' => 'GHS', + 'balance_minor' => $balanceMinor, + 'spent_minor' => (int) ($ledger['spent_minor'] ?? 0), + 'credited_minor' => (int) ($ledger['credited_minor'] ?? 0), + 'auto_withdraw_amount_minor' => $settings->auto_withdraw_amount_minor, + ], + ]); + } + + public function updateAutoWithdraw(Request $request): JsonResponse + { + $account = ladill_account(); + + $data = $request->validate([ + 'amount' => ['nullable', 'numeric', 'min:1', 'max:50000'], + ]); + + $amountMinor = isset($data['amount']) ? (int) round((float) $data['amount'] * 100) : null; + + $settings = $account->getOrCreateQrSetting(); + $settings->update(['auto_withdraw_amount_minor' => $amountMinor]); + + if ($amountMinor !== null) { + $this->autoWithdraw->attemptForUser($account); + } + + return response()->json([ + 'data' => [ + 'auto_withdraw_amount_minor' => $settings->fresh()->auto_withdraw_amount_minor, + ], + ]); + } + + public function topup(Request $request): JsonResponse + { + $account = ladill_account(); + + $data = $request->validate([ + 'amount' => ['required', 'numeric', 'min:1', 'max:10000'], + ]); + + $response = $this->identitySend('POST', '/api/identity/wallet-topup-account', [ + 'user' => $account->public_id, + 'amount' => (float) $data['amount'], + // Paystack returns the customer here after payment; the app catches the deep link. + 'return_url' => 'https://'.config('app.platform_domain').'/mini/wallet/topup-complete', + ]); + $this->rethrowValidation($response, 'amount'); + + $checkoutUrl = (string) $response->json('data.checkout_url', ''); + if ($checkoutUrl === '') { + return response()->json(['message' => 'Could not start top-up. Please try again.'], 422); + } + + return response()->json(['data' => ['checkout_url' => $checkoutUrl]]); + } + + public function banks(Request $request): JsonResponse + { + $type = $request->query('type') === 'mobile_money' ? 'mobile_money' : 'bank'; + $response = $this->identitySend('GET', '/api/identity/banks?type='.urlencode($type), []); + + return response()->json(['data' => $response->json('data', [])]); + } + + public function payoutAccount(): JsonResponse + { + $response = $this->identitySend('GET', '/api/identity/payout-account?user='.urlencode((string) ladill_account()->public_id), []); + + return response()->json(['data' => ['payout_account' => $response->json('data.payout_account')]]); + } + + public function updatePayoutAccount(Request $request): JsonResponse + { + $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'; + + $response = $this->identitySend('PUT', '/api/identity/payout-account', array_merge( + ['user' => ladill_account()->public_id], + $data, + )); + $this->rethrowValidation($response, 'account_number'); + + return response()->json(['data' => ['payout_account' => $response->json('data.payout_account')]]); + } + + public function withdrawals(): JsonResponse + { + $response = $this->identitySend('GET', '/api/identity/wallet/withdrawals?user='.urlencode((string) ladill_account()->public_id), []); + + return response()->json(['data' => $response->json('data', [])]); + } + + public function withdraw(Request $request): JsonResponse + { + $data = $request->validate([ + 'amount' => ['required', 'numeric', 'min:1', 'max:50000'], + ]); + + $response = $this->identitySend('POST', '/api/identity/wallet/withdraw', [ + 'user' => ladill_account()->public_id, + 'amount' => (float) $data['amount'], + ]); + $this->rethrowValidation($response, 'amount'); + + $this->notifications->withdrawalSubmitted( + ladill_account(), + (float) $data['amount'], + ); + + return response()->json(['data' => $response->json('data', [])]); + } +} diff --git a/app/Http/Controllers/Api/QrCodeController.php b/app/Http/Controllers/Api/QrCodeController.php new file mode 100644 index 0000000..ac4fbab --- /dev/null +++ b/app/Http/Controllers/Api/QrCodeController.php @@ -0,0 +1,153 @@ +accountQuery() + ->latest() + ->get() + ->map(fn (QrCode $code) => $this->present($code)); + + return response()->json(['data' => $codes]); + } + + public function show(QrCode $qrCode): JsonResponse + { + $this->ensurePlusCode($qrCode); + $this->authorize('view', $qrCode); + + return response()->json(['data' => $this->present($qrCode, detailed: true)]); + } + + public function analytics(QrCode $qrCode): JsonResponse + { + $this->ensurePlusCode($qrCode); + $this->authorize('view', $qrCode); + + return response()->json([ + 'data' => [ + 'summary' => $this->analytics->summaryFor($qrCode), + 'daily_scans' => $this->analytics->dailyScans($qrCode, 30)->values(), + 'devices' => $this->analytics->breakdown($qrCode, 'device_type'), + 'browsers' => $this->analytics->breakdown($qrCode, 'browser'), + ], + ]); + } + + public function store(Request $request): JsonResponse + { + abort_unless($request->user()->tokenCan('qr:write'), 403, 'Token requires qr:write ability.'); + + $validated = $request->validate([ + 'label' => ['required', 'string', 'max:120'], + 'type' => ['required', 'in:'.implode(',', QrTypeCatalog::keys())], + 'destination_url' => ['nullable', 'url', 'max:2048'], + 'custom_short_code' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', 'unique:qr_codes,short_code'], + 'is_active' => ['sometimes', 'boolean'], + ]); + + if ($validated['type'] === QrCode::TYPE_DOCUMENT) { + return response()->json([ + 'message' => 'PDF QR codes must be created in the QR Plus app (file upload required).', + ], 422); + } + + try { + $qrCode = $this->manager->create(ladill_account(), array_merge( + $request->all(), + $validated, + )); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json(['data' => $this->present($qrCode->fresh(), detailed: true)], 201); + } + + public function update(Request $request, QrCode $qrCode): JsonResponse + { + abort_unless($request->user()->tokenCan('qr:write'), 403, 'Token requires qr:write ability.'); + + $this->ensurePlusCode($qrCode); + $this->authorize('update', $qrCode); + + $request->validate([ + 'label' => ['sometimes', 'string', 'max:120'], + 'destination_url' => ['nullable', 'url', 'max:2048'], + 'is_active' => ['sometimes', 'boolean'], + ]); + + if ($qrCode->isDocumentType() && $request->hasFile('document')) { + return response()->json([ + 'message' => 'Replace PDF files in the QR Plus app.', + ], 422); + } + + try { + $updated = $this->manager->update($qrCode, $request->all()); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json(['data' => $this->present($updated->fresh(), detailed: true)]); + } + + /** @return \Illuminate\Database\Eloquent\Builder */ + private function accountQuery() + { + return QrCode::query() + ->where('user_id', ladill_account()->id) + ->whereIn('type', QrTypeCatalog::eventTypes()); + } + + private function ensurePlusCode(QrCode $qrCode): void + { + abort_unless( + $qrCode->user_id === ladill_account()->id && QrTypeCatalog::isValid($qrCode->type), + 404, + ); + } + + /** @return array */ + private function present(QrCode $qrCode, bool $detailed = false): array + { + $data = [ + 'id' => $qrCode->id, + 'label' => $qrCode->label, + 'type' => $qrCode->type, + 'type_label' => $qrCode->typeLabel(), + 'short_code' => $qrCode->short_code, + 'public_url' => $qrCode->publicUrl(), + 'is_active' => $qrCode->is_active, + 'scans_total' => $qrCode->scans_total, + 'unique_scans_total' => $qrCode->unique_scans_total, + 'last_scanned_at' => $qrCode->last_scanned_at?->toIso8601String(), + 'created_at' => $qrCode->created_at?->toIso8601String(), + 'updated_at' => $qrCode->updated_at?->toIso8601String(), + ]; + + if ($detailed) { + $data['destination_url'] = $qrCode->destination_url; + $data['content'] = $qrCode->content(); + } + + return $data; + } +} diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php new file mode 100644 index 0000000..22fcc00 --- /dev/null +++ b/app/Http/Controllers/Auth/SsoLoginController.php @@ -0,0 +1,324 @@ +query('redirect', route('pos.dashboard')); + + if (Auth::check()) { + return $this->safeRedirect($intended, route('pos.dashboard')); + } + + if ($this->attemptSilentRefresh($request, $intended)) { + return $this->safeRedirect($intended, route('pos.dashboard')); + } + + $verifier = Str::random(64); + $state = Str::random(40); + $request->session()->put('sso.verifier', $verifier); + $request->session()->put('sso.state', $state); + $request->session()->put('sso.intended', $intended); + + $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + + $query = [ + 'response_type' => 'code', + 'client_id' => (string) config('services.ladill_sso.client_id'), + 'redirect_uri' => (string) config('services.ladill_sso.redirect'), + 'scope' => 'openid profile email', + 'state' => $state, + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', + ]; + + $loginHint = (string) $request->session()->get('sso.login_hint', ''); + if ($loginHint !== '') { + $query['login_hint'] = $loginHint; + } + + if (! $request->boolean('interactive')) { + $query['prompt'] = 'none'; + } + + $authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query); + + if ($request->boolean('interactive') && ! $request->boolean('fallback')) { + $request->session()->put('sso.popup', true); + + return view('auth.sso-signing-in', [ + 'authorizeUrl' => $authorizeUrl, + 'intended' => $intended, + 'fallbackUrl' => route('sso.connect', [ + 'redirect' => $intended, + 'interactive' => 1, + 'fallback' => 1, + ]), + ]); + } + + return redirect()->away($authorizeUrl); + } + + public function callback(Request $request): RedirectResponse|View + { + $intended = (string) $request->session()->get('sso.intended', route('pos.dashboard')); + + $popup = (bool) $request->session()->get('sso.popup'); + + if ($request->filled('error')) { + if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true) + && ! $request->boolean('interactive')) { + return redirect()->away((string) config('ladill.marketing_url')); + } + + return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup); + } + + if (! $request->filled('code') + || $request->query('state') !== $request->session()->pull('sso.state')) { + return $this->finishCallback($request, $intended, 'invalid_state', $popup); + } + + $issuer = rtrim((string) config('services.ladill_sso.issuer'), '/'); + + $tokenRes = Http::asForm()->post($issuer.'/oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => (string) config('services.ladill_sso.client_id'), + 'client_secret' => (string) config('services.ladill_sso.client_secret'), + 'redirect_uri' => (string) config('services.ladill_sso.redirect'), + 'code' => (string) $request->query('code'), + 'code_verifier' => (string) $request->session()->pull('sso.verifier'), + ]); + if ($tokenRes->failed()) { + return $this->finishCallback($request, $intended, 'token_exchange_failed', $popup); + } + + $user = $this->loginFromTokenResponse($request, $tokenRes); + if (! $user) { + return $this->finishCallback($request, $intended, 'userinfo_failed', $popup); + } + + QrTeamMember::linkPendingInvitesFor($user); + + Auth::login($user, remember: true); + $request->session()->regenerate(); + + return $this->finishCallback($request, $intended, null, $popup); + } + + public function logout(Request $request): RedirectResponse + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + // Per-app sign-out: end only this app's session and keep the platform + // (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently + // without a password. Full "sign out of all Ladill apps" lives on account. + 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', '')); + $hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([ + 'embedded' => 1, + 'return' => $return, + ]); + + return view('auth.sso-logout-bridge', [ + 'hubUrl' => $hubUrl, + 'return' => $return, + 'authOrigin' => 'https://'.config('app.auth_domain'), + ]); + } + + public function frontchannelLogout(Request $request): Response|RedirectResponse + { + if ($this->shouldLogoutForMailbox($request)) { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } + + $return = (string) $request->query('return', ''); + $root = (string) config('app.platform_domain', 'ladill.com'); + $host = parse_url($return, PHP_URL_HOST); + if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) { + return redirect()->away($return); + } + + return response('', 204); + } + + private function attemptSilentRefresh(Request $request, string $intended): bool + { + $refreshToken = (string) $request->session()->get('sso.refresh_token', ''); + if ($refreshToken === '') { + return false; + } + + $issuer = rtrim((string) config('services.ladill_sso.issuer'), '/'); + $tokenRes = Http::asForm()->post($issuer.'/oauth/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + 'client_id' => (string) config('services.ladill_sso.client_id'), + 'client_secret' => (string) config('services.ladill_sso.client_secret'), + 'scope' => 'openid profile email', + ]); + + if ($tokenRes->failed()) { + $request->session()->forget('sso.refresh_token'); + + return false; + } + + $user = $this->loginFromTokenResponse($request, $tokenRes); + + if (! $user) { + return false; + } + + Auth::login($user, remember: true); + $request->session()->put('sso.intended', $intended); + + return true; + } + + private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User + { + $refreshToken = (string) $tokenRes->json('refresh_token', ''); + if ($refreshToken !== '') { + $request->session()->put('sso.refresh_token', $refreshToken); + } + + $issuer = rtrim((string) config('services.ladill_sso.issuer'), '/'); + $claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo'); + if ($claims->failed() || ! $claims->json('sub')) { + return null; + } + + $email = (string) ($claims->json('email') ?: ''); + if ($email !== '') { + $request->session()->put('sso.login_hint', $email); + } + + return User::updateOrCreate( + ['public_id' => (string) $claims->json('sub')], + [ + 'name' => $claims->json('name'), + 'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com', + 'avatar_url' => $claims->json('picture'), + ], + ); + } + + private function shouldLogoutForMailbox(Request $request): bool + { + $mailbox = strtolower(trim((string) $request->query('mailbox', ''))); + if ($mailbox === '' || ! str_contains($mailbox, '@')) { + return true; + } + + $user = Auth::user(); + if (! $user) { + return false; + } + + return strtolower((string) $user->email) !== $mailbox; + } + + private function finishCallback(Request $request, string $intended, ?string $error = null, bool $popup = false): RedirectResponse|View + { + if ($popup) { + return view('auth.sso-popup-done', [ + 'intended' => $intended, + 'error' => $error, + 'appOrigin' => rtrim((string) config('app.url'), '/'), + 'fallbackUrl' => route('sso.connect', [ + 'redirect' => $intended, + 'interactive' => 1, + 'fallback' => 1, + ]), + ]); + } + + if ($error) { + return redirect()->route('sso.connect', [ + 'redirect' => $intended, + 'interactive' => 1, + ]); + } + + return $this->safeRedirect($intended, route('pos.dashboard')); + } + + private function defaultSignedOutUrl(): string + { + foreach (array_keys(Route::getRoutes()->getRoutesByName()) as $name) { + if (str_ends_with($name, '.signed-out')) { + return route($name); + } + } + + return 'https://'.config('app.platform_domain'); + } + + private function safeReturnUrl(string $url): string + { + $host = parse_url($url, PHP_URL_HOST); + $root = (string) config('app.platform_domain', 'ladill.com'); + + if (is_string($host) && str_starts_with($url, 'https://') + && ($host === $root || str_ends_with($host, '.'.$root))) { + return $url; + } + + return $this->defaultSignedOutUrl(); + } + + private function safeRedirect(string $url, string $fallback): RedirectResponse + { + $host = parse_url($url, PHP_URL_HOST); + $root = (string) config('app.platform_domain', 'ladill.com'); + + if (is_string($host) && str_starts_with($url, 'https://') + && ($host === $root || str_ends_with($host, '.'.$root))) { + return redirect()->away($url); + } + + return redirect()->away($fallback); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..e7f7c94 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,10 @@ +qrCodes() + ->where('type', QrCode::TYPE_PAYMENT) + ->pluck('id'); + + $todayStart = now()->startOfDay(); + + $todayPayments = MiniPayment::query() + ->whereIn('qr_code_id', $qrIds) + ->where('status', MiniPayment::STATUS_PAID) + ->where('paid_at', '>=', $todayStart); + + $todayCount = (clone $todayPayments)->count(); + $todayMinor = (int) (clone $todayPayments)->sum('merchant_amount_minor'); + + $recentPayments = MiniPayment::query() + ->whereIn('qr_code_id', $qrIds) + ->where('status', MiniPayment::STATUS_PAID) + ->with('qrCode') + ->latest('paid_at') + ->limit(8) + ->get(); + + $paymentQrCount = $qrIds->count(); + + $balanceMinor = 0; + try { + $balanceMinor = $this->billing->balanceMinor($account->public_id); + } catch (Throwable $e) { + Log::warning('Mini dashboard could not load wallet balance', [ + 'user' => $account->public_id, + 'error' => $e->getMessage(), + ]); + } + + return view('mini.dashboard', [ + 'todayCount' => $todayCount, + 'todayMinor' => $todayMinor, + 'paymentQrCount' => $paymentQrCount, + 'recentPayments' => $recentPayments, + 'balanceMinor' => $balanceMinor, + ]); + } +} diff --git a/app/Http/Controllers/Mini/PaymentQrController.php b/app/Http/Controllers/Mini/PaymentQrController.php new file mode 100644 index 0000000..fab5e86 --- /dev/null +++ b/app/Http/Controllers/Mini/PaymentQrController.php @@ -0,0 +1,180 @@ +qrCodes() + ->where('type', QrCode::TYPE_PAYMENT) + ->latest() + ->get(); + + $previewDataUris = $qrCodes->mapWithKeys(function (QrCode $qr) { + return [$qr->id => $this->imageGenerator->previewDataUri($qr)]; + }); + + return view('mini.payment-qrs.index', [ + 'qrCodes' => $qrCodes, + 'previewDataUris' => $previewDataUris, + ]); + } + + public function create(): View + { + return view('mini.payment-qrs.create'); + } + + public function store(Request $request): RedirectResponse + { + $account = ladill_account(); + + $data = $request->validate([ + 'label' => 'required|string|max:120', + 'business_name' => 'required|string|max:120', + 'branch_label' => 'nullable|string|max:80', + ]); + + $data['type'] = QrCode::TYPE_PAYMENT; + $data['currency'] = 'GHS'; + + try { + $qrCode = $this->manager->create($account, $data); + } catch (RuntimeException $e) { + return back()->withInput()->with('error', $e->getMessage()); + } + + return redirect()->route('mini.payment-qrs.show', $qrCode)->with('success', 'Payment QR created.'); + } + + public function show(QrCode $paymentQr): View + { + $this->authorizePaymentQr($paymentQr); + + $previewDataUri = $this->imageGenerator->previewDataUri($paymentQr); + + return view('mini.payment-qrs.show', [ + 'qrCode' => $paymentQr->fresh(), + 'previewDataUri' => $previewDataUri, + ]); + } + + public function update(Request $request, QrCode $paymentQr): RedirectResponse + { + $this->authorizePaymentQr($paymentQr); + + $data = $request->validate([ + 'label' => 'sometimes|string|max:120', + 'business_name' => 'sometimes|string|max:120', + 'branch_label' => 'nullable|string|max:80', + 'is_active' => 'sometimes|boolean', + ]); + + $data['is_active'] = $request->boolean('is_active', $paymentQr->is_active); + + try { + $this->manager->update($paymentQr, $data); + } catch (RuntimeException $e) { + return back()->withInput()->with('error', $e->getMessage()); + } + + return back()->with('success', 'Payment QR updated.'); + } + + public function destroy(QrCode $paymentQr): RedirectResponse + { + $this->authorizePaymentQr($paymentQr); + + $this->manager->delete($paymentQr); + + return redirect() + ->route('mini.payment-qrs.index') + ->with('success', 'Payment QR deleted.'); + } + + public function preview(QrCode $paymentQr): Response + { + $this->authorizePaymentQr($paymentQr); + + $qrCode = $this->imageGenerator->ensureValidImages($paymentQr); + $bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path); + + if ($bytes === null) { + $bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style()); + $this->imageGenerator->generateAndStore($qrCode); + } + + return response($bytes, 200, [ + 'Content-Type' => 'image/png', + 'Cache-Control' => 'private, max-age=3600', + ]); + } + + public function download(QrCode $paymentQr, string $format): StreamedResponse + { + $this->authorizePaymentQr($paymentQr); + + $qrCode = $this->imageGenerator->ensureValidImages($paymentQr); + $path = $format === 'svg' ? $qrCode->svg_path : $qrCode->png_path; + $filename = Str::slug($qrCode->label).'-qr.'.($format === 'svg' ? 'svg' : 'png'); + + if ($format === 'png') { + $bytes = $this->imageGenerator->normalizeStoredPng($path); + if ($bytes === null) { + $bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style()); + $this->imageGenerator->generateAndStore($qrCode); + } + + return response()->streamDownload(fn () => print($bytes), $filename, [ + 'Content-Type' => 'image/png', + ]); + } + + if ($format === 'pdf') { + $bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path); + if ($bytes === null) { + $bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style()); + $this->imageGenerator->generateAndStore($qrCode); + } + + $pdf = $this->pdfExporter->fromPng($bytes, $qrCode->label); + $filename = Str::slug($qrCode->label).'-qr.pdf'; + + return response()->streamDownload(fn () => print($pdf), $filename, [ + 'Content-Type' => 'application/pdf', + ]); + } + + abort_unless($path && Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->download($path, $filename); + } + + private function authorizePaymentQr(QrCode $paymentQr): void + { + abort_unless($paymentQr->type === QrCode::TYPE_PAYMENT, 404); + abort_unless($paymentQr->user_id === ladill_account()->id, 403); + } +} diff --git a/app/Http/Controllers/Mini/PaymentsController.php b/app/Http/Controllers/Mini/PaymentsController.php new file mode 100644 index 0000000..c60277c --- /dev/null +++ b/app/Http/Controllers/Mini/PaymentsController.php @@ -0,0 +1,45 @@ +query('q', '')); + + $qrIds = $account->qrCodes() + ->where('type', QrCode::TYPE_PAYMENT) + ->pluck('id'); + + $payments = MiniPayment::query() + ->whereIn('qr_code_id', $qrIds) + ->when($search !== '', function ($query) use ($search) { + $like = '%'.$search.'%'; + $query->where(function ($inner) use ($like) { + $inner->where('payer_name', 'like', $like) + ->orWhere('payer_email', 'like', $like) + ->orWhere('payer_note', 'like', $like) + ->orWhere('reference', 'like', $like) + ->orWhere('payment_reference', 'like', $like) + ->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like)); + }); + }) + ->with('qrCode') + ->latest('created_at') + ->paginate(25) + ->withQueryString(); + + return view('mini.payments', [ + 'payments' => $payments, + 'search' => $search, + ]); + } +} diff --git a/app/Http/Controllers/Mini/PayoutsController.php b/app/Http/Controllers/Mini/PayoutsController.php new file mode 100644 index 0000000..291f9c2 --- /dev/null +++ b/app/Http/Controllers/Mini/PayoutsController.php @@ -0,0 +1,48 @@ +qrCodes() + ->where('type', QrCode::TYPE_PAYMENT) + ->pluck('id'); + + $revenueMinor = (int) MiniPayment::query() + ->whereIn('qr_code_id', $qrIds) + ->where('status', MiniPayment::STATUS_PAID) + ->sum('merchant_amount_minor'); + + $balanceMinor = 0; + try { + $balanceMinor = $this->billing->balanceMinor($account->public_id); + } catch (Throwable $e) { + Log::warning('Mini payouts could not load wallet balance', [ + 'user' => $account->public_id, + 'error' => $e->getMessage(), + ]); + } + + $accountWalletUrl = 'https://'.config('app.account_domain').'/wallet'; + + return view('mini.payouts', [ + 'revenueMinor' => $revenueMinor, + 'balanceMinor' => $balanceMinor, + 'accountWalletUrl' => $accountWalletUrl, + ]); + } +} diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..d438a3a --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,64 @@ +user() + ->notifications() + ->latest() + ->paginate(20); + + return view('notifications.index', compact('notifications')); + } + + public function unread(Request $request): JsonResponse + { + $notifications = $request->user() + ->unreadNotifications() + ->latest() + ->take(10) + ->get() + ->map(fn ($n) => [ + 'id' => $n->id, + 'type' => class_basename($n->type), + 'title' => $n->data['title'] ?? 'Notification', + 'message' => $n->data['message'] ?? '', + 'icon' => $n->data['icon'] ?? 'bell', + 'url' => $n->data['url'] ?? null, + 'created_at' => $n->created_at->diffForHumans(), + ]); + + return response()->json([ + 'notifications' => $notifications, + 'unread_count' => $request->user()->unreadNotifications()->count(), + ]); + } + + public function markAsRead(Request $request, string $id): JsonResponse + { + $notification = $request->user() + ->notifications() + ->where('id', $id) + ->first(); + + if ($notification) { + $notification->markAsRead(); + } + + return response()->json(['success' => true]); + } + + public function markAllAsRead(Request $request): JsonResponse + { + $request->user()->unreadNotifications->markAsRead(); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Controllers/Pos/Concerns/ScopesToAccount.php b/app/Http/Controllers/Pos/Concerns/ScopesToAccount.php new file mode 100644 index 0000000..37517e2 --- /dev/null +++ b/app/Http/Controllers/Pos/Concerns/ScopesToAccount.php @@ -0,0 +1,21 @@ +user(); + + return (string) $user->public_id; + } + + protected function authorizeOwner(Request $request, Model $model): void + { + abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404); + } +} diff --git a/app/Http/Controllers/Pos/DashboardController.php b/app/Http/Controllers/Pos/DashboardController.php new file mode 100644 index 0000000..d74a2d8 --- /dev/null +++ b/app/Http/Controllers/Pos/DashboardController.php @@ -0,0 +1,43 @@ +ownerRef($request); + $todayStart = now()->startOfDay(); + + $todaySales = PosSale::owned($owner) + ->where('status', PosSale::STATUS_PAID) + ->where('paid_at', '>=', $todayStart); + + $stats = [ + 'today_total_minor' => (int) (clone $todaySales)->sum('total_minor'), + 'today_count' => (clone $todaySales)->count(), + 'product_count' => PosProduct::owned($owner)->active()->count(), + 'open_pending' => PosSale::owned($owner)->where('status', PosSale::STATUS_PENDING)->count(), + ]; + + $recentSales = PosSale::owned($owner) + ->with('lines') + ->latest() + ->limit(8) + ->get(); + + return view('pos.dashboard', compact('stats', 'recentSales')); + } +} diff --git a/app/Http/Controllers/Pos/ProductController.php b/app/Http/Controllers/Pos/ProductController.php new file mode 100644 index 0000000..91d613d --- /dev/null +++ b/app/Http/Controllers/Pos/ProductController.php @@ -0,0 +1,86 @@ +ownerRef($request)) + ->orderBy('name') + ->paginate(30) + ->withQueryString(); + + return view('pos.products.index', compact('products')); + } + + public function create(): View + { + return view('pos.products.create', ['product' => new PosProduct([ + 'currency' => config('pos.default_currency', 'GHS'), + 'is_active' => true, + ])]); + } + + public function store(Request $request): RedirectResponse + { + PosProduct::create([ + ...$this->validated($request), + 'owner_ref' => $this->ownerRef($request), + ]); + + return redirect()->route('pos.products.index')->with('success', 'Product added.'); + } + + public function edit(Request $request, PosProduct $product): View + { + $this->authorizeOwner($request, $product); + + return view('pos.products.edit', compact('product')); + } + + public function update(Request $request, PosProduct $product): RedirectResponse + { + $this->authorizeOwner($request, $product); + $product->update($this->validated($request)); + + return redirect()->route('pos.products.index')->with('success', 'Product updated.'); + } + + public function destroy(Request $request, PosProduct $product): RedirectResponse + { + $this->authorizeOwner($request, $product); + $product->delete(); + + return redirect()->route('pos.products.index')->with('success', 'Product removed.'); + } + + /** @return array */ + private function validated(Request $request): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:200'], + 'sku' => ['nullable', 'string', 'max:80'], + 'price' => ['required', 'numeric', 'min:0.01'], + 'currency' => ['nullable', 'string', 'size:3'], + 'is_active' => ['sometimes', 'boolean'], + ]); + + return [ + 'name' => $data['name'], + 'sku' => $data['sku'] ?? null, + 'price_minor' => (int) round(((float) $data['price']) * 100), + 'currency' => strtoupper($data['currency'] ?? config('pos.default_currency', 'GHS')), + 'is_active' => $request->boolean('is_active', true), + ]; + } +} diff --git a/app/Http/Controllers/Pos/RegisterController.php b/app/Http/Controllers/Pos/RegisterController.php new file mode 100644 index 0000000..70bb85a --- /dev/null +++ b/app/Http/Controllers/Pos/RegisterController.php @@ -0,0 +1,93 @@ +ownerRef($request); + $location = $this->locations->ensureDefault($owner); + + $products = PosProduct::owned($owner) + ->active() + ->orderBy('name') + ->get(); + + return view('pos.register', [ + 'products' => $products, + 'location' => $location, + 'crmCustomers' => $this->crmCustomers($owner), + ]); + } + + /** @return list> */ + private function crmCustomers(string $owner): array + { + try { + return (array) ((CrmClient::for($owner)->customers(['per_page' => 200])['data']) ?? []); + } catch (\Throwable) { + return []; + } + } + + public function charge(Request $request): RedirectResponse + { + $data = $request->validate([ + 'lines' => ['required', 'array', 'min:1'], + 'lines.*.product_id' => ['nullable', 'integer'], + 'lines.*.name' => ['required', 'string', 'max:200'], + 'lines.*.unit_price_minor' => ['required', 'integer', 'min:1'], + 'lines.*.quantity' => ['required', 'integer', 'min:1', 'max:999'], + 'customer_name' => ['nullable', 'string', 'max:120'], + 'customer_email' => ['nullable', 'email', 'max:255'], + 'customer_phone' => ['nullable', 'string', 'max:40'], + 'crm_customer_id' => ['nullable', 'integer'], + 'payment_method' => ['required', 'in:pay,cash'], + ]); + + $merchant = ladill_account() ?? $request->user(); + $location = $this->locations->ensureDefault($this->ownerRef($request)); + + try { + $sale = $this->sales->createSale($merchant, $data['lines'], [ + 'location_id' => $location->id, + 'currency' => $location->currency, + 'customer_name' => $data['customer_name'] ?? null, + 'customer_email' => $data['customer_email'] ?? null, + 'customer_phone' => $data['customer_phone'] ?? null, + 'crm_customer_id' => $data['crm_customer_id'] ?? null, + ]); + + if ($data['payment_method'] === 'cash') { + $this->sales->recordCashPayment($sale); + + return redirect() + ->route('pos.sales.show', $sale) + ->with('success', 'Cash sale recorded.'); + } + + $result = $this->sales->initiatePayCheckout($sale, $merchant); + + return redirect()->away($result['checkout_url']); + } catch (RuntimeException $e) { + return back()->withInput()->with('error', $e->getMessage()); + } + } +} diff --git a/app/Http/Controllers/Pos/SaleController.php b/app/Http/Controllers/Pos/SaleController.php new file mode 100644 index 0000000..b9d0535 --- /dev/null +++ b/app/Http/Controllers/Pos/SaleController.php @@ -0,0 +1,65 @@ +ownerRef($request)) + ->with('lines') + ->latest() + ->paginate(25) + ->withQueryString(); + + return view('pos.sales.index', compact('sales')); + } + + public function show(Request $request, PosSale $sale): View + { + $this->authorizeOwner($request, $sale); + $sale->load('lines', 'location'); + + $invoiceUrl = $sale->isPaid() + ? $this->links->invoiceFromSale($sale) + : null; + + return view('pos.sales.show', compact('sale', 'invoiceUrl')); + } + + public function callback(Request $request, PosSale $sale): RedirectResponse + { + $reference = trim((string) $request->query('reference', $sale->payment_reference ?? '')); + + if ($reference === '') { + return redirect()->route('pos.sales.show', $sale)->with('error', 'Missing payment reference.'); + } + + try { + $sale = $this->sales->completePayCheckout($reference); + } catch (RuntimeException $e) { + return redirect()->route('pos.sales.show', $sale)->with('error', $e->getMessage()); + } + + return redirect() + ->route('pos.sales.show', $sale) + ->with('success', 'Payment received.'); + } +} diff --git a/app/Http/Controllers/Pos/SettingsController.php b/app/Http/Controllers/Pos/SettingsController.php new file mode 100644 index 0000000..3e4a8e9 --- /dev/null +++ b/app/Http/Controllers/Pos/SettingsController.php @@ -0,0 +1,70 @@ +locations->ensureDefault($this->ownerRef($request)); + + return view('pos.settings', [ + 'location' => $location, + 'merchantImportEnabled' => (bool) config('pos.merchant_import_enabled', true), + ]); + } + + public function update(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:120'], + 'currency' => ['required', 'string', 'size:3'], + 'receipt_footer' => ['nullable', 'string', 'max:1000'], + ]); + + $location = $this->locations->ensureDefault($this->ownerRef($request)); + $location->update([ + 'name' => $data['name'], + 'currency' => strtoupper($data['currency']), + 'receipt_footer' => $data['receipt_footer'] ?? null, + ]); + + return back()->with('success', 'Settings saved.'); + } + + public function importCrm(Request $request, CrmProductImportService $import): RedirectResponse + { + try { + $result = $import->import($this->ownerRef($request)); + + return back()->with('success', "Imported {$result['imported']} product(s), updated {$result['updated']}."); + } catch (RuntimeException $e) { + return back()->with('error', $e->getMessage()); + } + } + + public function importMerchant(Request $request, MerchantCatalogImportService $import): RedirectResponse + { + try { + $result = $import->import($this->ownerRef($request)); + + return back()->with('success', "Imported {$result['imported']} item(s) from {$result['storefronts']} storefront(s), updated {$result['updated']}."); + } catch (RuntimeException $e) { + return back()->with('error', $e->getMessage()); + } + } +} diff --git a/app/Http/Controllers/Public/PaymentController.php b/app/Http/Controllers/Public/PaymentController.php new file mode 100644 index 0000000..e9f0917 --- /dev/null +++ b/app/Http/Controllers/Public/PaymentController.php @@ -0,0 +1,66 @@ +with('user.qrSetting') + ->where('short_code', $shortCode) + ->where('type', QrCode::TYPE_PAYMENT) + ->where('is_active', true) + ->firstOrFail(); + + $validated = $request->validate([ + 'amount' => 'required|numeric|min:0.01', + ]); + + try { + $result = $this->payments->initiate($qrCode, $validated); + } catch (RuntimeException $e) { + if ($request->expectsJson()) { + return response()->json(['error' => $e->getMessage()], 422); + } + + return back()->withInput()->with('error', $e->getMessage()); + } + + if ($request->expectsJson()) { + return response()->json(['checkout_url' => $result['checkout_url']]); + } + + return redirect()->away($result['checkout_url']); + } + + public function callback(Request $request, string $shortCode): RedirectResponse|View + { + $reference = trim((string) $request->query('reference', '')); + if ($reference === '') { + return redirect('/q/'.$shortCode)->with('error', 'Missing payment reference.'); + } + + try { + $payment = $this->payments->complete($reference); + } catch (RuntimeException $e) { + return redirect('/q/'.$shortCode)->with('error', $e->getMessage()); + } + + return view('public.qr.payment-confirmed', [ + 'payment' => $payment, + 'qrCode' => $payment->qrCode, + ]); + } +} diff --git a/app/Http/Controllers/Public/QrScanController.php b/app/Http/Controllers/Public/QrScanController.php new file mode 100644 index 0000000..f901f25 --- /dev/null +++ b/app/Http/Controllers/Public/QrScanController.php @@ -0,0 +1,345 @@ +where('short_code', $shortCode)->firstOrFail(); + + $this->scanRecorder->record($qrCode, $request); + + if (! $qrCode->is_active) { + return view('public.qr.inactive', ['qrCode' => $qrCode]); + } + + if ($qrCode->resolvesToRedirect()) { + $url = $qrCode->redirectUrl(); + abort_unless($url, 404); + + return redirect()->away($url); + } + + if ($qrCode->isDocumentType()) { + return redirect()->route('qr.public.view', $shortCode); + } + + if ($qrCode->isBookType()) { + return view('public.qr.book-landing', ['qrCode' => $qrCode]); + } + + if ($qrCode->isPaymentType()) { + return view('public.qr.payment-landing', ['qrCode' => $qrCode]); + } + + if ($qrCode->usesLandingPage()) { + return view('public.qr.landing', ['qrCode' => $qrCode]); + } + + abort(404); + } + + public function view(string $shortCode): View + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->with('document') + ->firstOrFail(); + + abort_unless($qrCode->isDocumentType() && $qrCode->document, 404); + + return view('public.qr.document-viewer', [ + 'qrCode' => $qrCode, + 'fileUrl' => route('qr.public.file', $shortCode), + 'allowDownload' => (bool) ($qrCode->content()['allow_download'] ?? true), + ]); + } + + public function file(string $shortCode): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->with('document') + ->firstOrFail(); + + $document = $qrCode->document; + abort_unless($document && Storage::disk($document->disk)->exists($document->path), 404); + + return Storage::disk($document->disk)->response( + $document->path, + ($document->title ?: 'document') . '.pdf', + ['Content-Type' => 'application/pdf'], + ); + } + + public function image(string $shortCode, int $index): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->firstOrFail(); + + abort_unless($qrCode->isImageType(), 404); + + $images = $qrCode->content()['images'] ?? []; + abort_unless(isset($images[$index]['path']), 404); + + $path = $images[$index]['path']; + abort_unless(Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->response($path); + } + + public function itemImage(string $shortCode, int $sectionIndex, int $itemIndex): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->firstOrFail(); + + abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404); + + $sections = $qrCode->content()['sections'] ?? []; + $path = $sections[$sectionIndex]['items'][$itemIndex]['image_path'] ?? null; + abort_unless($path && Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->response($path); + } + + public function vcard(string $shortCode): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->firstOrFail(); + + abort_unless($qrCode->type === QrCode::TYPE_VCARD, 404); + + $c = $qrCode->content(); + $lines = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'N:' . ($c['last_name'] ?? '') . ';' . ($c['first_name'] ?? '') . ';;;', + 'FN:' . trim(($c['first_name'] ?? '') . ' ' . ($c['last_name'] ?? '')), + ]; + + if (! empty($c['company'])) { + $lines[] = 'ORG:' . $this->escapeVcard($c['company']); + } + if (! empty($c['phone'])) { + $lines[] = 'TEL;TYPE=CELL:' . $this->escapeVcard($c['phone']); + } + if (! empty($c['email'])) { + $lines[] = 'EMAIL;TYPE=INTERNET:' . $this->escapeVcard($c['email']); + } + if (! empty($c['website'])) { + $lines[] = 'URL:' . $this->escapeVcard($c['website']); + } + if (! empty($c['address'])) { + $lines[] = 'ADR;TYPE=WORK:;;' . $this->escapeVcard($c['address']) . ';;;;'; + } + if (! empty($c['note'])) { + $lines[] = 'NOTE:' . $this->escapeVcard($c['note']); + } + + $lines[] = 'END:VCARD'; + $vcf = implode("\r\n", $lines) . "\r\n"; + $filename = Str::slug($qrCode->label ?: 'contact') . '.vcf'; + + return response()->streamDownload(fn () => print($vcf), $filename, [ + 'Content-Type' => 'text/vcard', + ]); + } + + public function vcardAvatar(string $shortCode): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->firstOrFail(); + + abort_unless($qrCode->type === QrCode::TYPE_VCARD, 404); + + $path = $qrCode->content()['avatar_path'] ?? null; + abort_unless($path && Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->response($path); + } + + public function bookCover(string $shortCode): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->firstOrFail(); + + abort_unless($qrCode->isBookType(), 404); + + $path = $qrCode->content()['cover_path'] ?? null; + abort_unless($path && Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->response($path); + } + + public function menuLogo(string $shortCode): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->firstOrFail(); + + abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404); + + $path = $qrCode->content()['logo_path'] ?? null; + abort_unless($path && Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->response($path); + } + + public function menuCover(string $shortCode): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->firstOrFail(); + + abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404); + + $path = $qrCode->content()['cover_path'] ?? null; + abort_unless($path && Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->response($path); + } + + public function businessLogo(string $shortCode): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->firstOrFail(); + + abort_unless($qrCode->type === QrCode::TYPE_BUSINESS, 404); + + $path = $qrCode->content()['logo_path'] ?? null; + abort_unless($path && Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->response($path); + } + + public function businessCover(string $shortCode): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->firstOrFail(); + + abort_unless($qrCode->type === QrCode::TYPE_BUSINESS, 404); + + $path = $qrCode->content()['cover_path'] ?? null; + abort_unless($path && Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->response($path); + } + + public function churchLogo(string $shortCode): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->firstOrFail(); + + abort_unless($qrCode->type === QrCode::TYPE_CHURCH, 404); + + $path = $qrCode->content()['logo_path'] ?? null; + abort_unless($path && Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->response($path); + } + + public function churchCover(string $shortCode): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->firstOrFail(); + + abort_unless($qrCode->type === QrCode::TYPE_CHURCH, 404); + + $path = $qrCode->content()['cover_path'] ?? null; + abort_unless($path && Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->response($path); + } + + public function paymentLogo(string $shortCode): StreamedResponse + { + return $this->serveContentImage($shortCode, QrCode::TYPE_PAYMENT, 'logo_path'); + } + + public function eventLogo(string $shortCode): StreamedResponse + { + return $this->serveContentImage($shortCode, QrCode::TYPE_EVENT, 'logo_path'); + } + + public function eventCover(string $shortCode): StreamedResponse + { + return $this->serveContentImage($shortCode, QrCode::TYPE_EVENT, 'cover_path'); + } + + public function itineraryCover(string $shortCode): StreamedResponse + { + return $this->serveContentImage($shortCode, QrCode::TYPE_ITINERARY, 'cover_path'); + } + + private function serveContentImage(string $shortCode, string $type, string $key): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->firstOrFail(); + + abort_unless($qrCode->type === $type, 404); + + $path = $qrCode->content()[$key] ?? null; + abort_unless($path && Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->response($path); + } + + public function appIcon(string $shortCode): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->firstOrFail(); + + abort_unless($qrCode->type === QrCode::TYPE_APP, 404); + + $path = $qrCode->content()['icon_path'] ?? null; + abort_unless($path && Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->response($path); + } + + private function escapeVcard(string $value): string + { + return str_replace(["\n", "\r", ',', ';'], [' ', ' ', '\\,', '\\;'], $value); + } +} diff --git a/app/Http/Controllers/Qr/AccountController.php b/app/Http/Controllers/Qr/AccountController.php new file mode 100644 index 0000000..67f66d7 --- /dev/null +++ b/app/Http/Controllers/Qr/AccountController.php @@ -0,0 +1,94 @@ +} */ + private function billingSnapshot(?string $publicId): array + { + if (! $publicId) { + return [0, []]; + } + + $balanceMinor = $this->billing->balanceMinor($publicId); + $ledger = $this->billing->serviceLedger($publicId, config('billing.service', 'mini')); + + return [$balanceMinor, $ledger]; + } + + public function wallet(): View + { + $user = ladill_account(); + [$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id); + + return view('qr.account.wallet', [ + 'balanceMinor' => $balanceMinor, + 'spentMinor' => (int) ($ledger['spent_minor'] ?? 0), + 'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0), + 'topupUrl' => $this->topupUrl(), + ]); + } + + public function billing(): View + { + $user = ladill_account(); + [$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id); + + return view('qr.account.billing', [ + 'balanceMinor' => $balanceMinor, + 'spentMinor' => (int) ($ledger['spent_minor'] ?? 0), + 'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0), + 'topupUrl' => $this->topupUrl(), + ]); + } + + public function settings(): View + { + $account = ladill_account(); + $settings = $account->getOrCreateQrSetting(); + + return view('mini.settings', [ + 'account' => $account, + 'settings' => $settings, + ]); + } + + public function updateSettings(Request $request): RedirectResponse + { + $account = ladill_account(); + + $data = $request->validate([ + 'notify_email' => ['nullable', 'email', 'max:255'], + 'product_updates' => ['nullable', 'boolean'], + 'notify_registrations' => ['nullable', 'boolean'], + 'notify_payouts' => ['nullable', 'boolean'], + ]); + + QrSetting::updateOrCreate( + ['user_id' => $account->id], + [ + 'notify_email' => $data['notify_email'] ?? null, + 'product_updates' => $request->boolean('product_updates'), + 'notify_registrations' => $request->boolean('notify_registrations'), + 'notify_payouts' => $request->boolean('notify_payouts'), + ], + ); + + return redirect()->route('account.settings')->with('success', 'Settings saved.'); + } +} diff --git a/app/Http/Controllers/Qr/AfiaController.php b/app/Http/Controllers/Qr/AfiaController.php new file mode 100644 index 0000000..e734bee --- /dev/null +++ b/app/Http/Controllers/Qr/AfiaController.php @@ -0,0 +1,87 @@ +validate([ + 'message' => ['required', 'string', 'max:2000'], + 'history' => ['nullable', 'array', 'max:20'], + 'history.*.role' => ['nullable', 'string'], + 'history.*.text' => ['nullable', 'string'], + ]); + + if (! $afia->enabled()) { + return response()->json(['message' => 'Afia is not available right now.'], 503); + } + + try { + $reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context()); + } catch (\Throwable $e) { + report($e); + + return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502); + } + + return response()->json(['reply' => $reply]); + } + + /** @return array */ + private function context(): array + { + $account = ladill_account(); + + if (! $account) { + return ['signed_in' => 'no']; + } + + $types = QrTypeCatalog::paymentTypes(); + $codes = $account->qrCodes()->whereIn('type', $types); + + $ctx = [ + 'signed_in' => 'yes', + 'qr_codes_total' => (clone $codes)->count(), + 'qr_codes_active' => (clone $codes)->where('is_active', true)->count(), + 'scans_total' => (int) (clone $codes)->sum('scans_total'), + 'top_code_type' => (clone $codes) + ->selectRaw('type, count(*) as total') + ->groupBy('type') + ->orderByDesc('total') + ->value('type') ?: 'none yet', + ]; + + if ($account->public_id) { + try { + $ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor((string) $account->public_id) / 100, 2); + } catch (\Throwable) { + } + } + + $recent = $account->qrCodes() + ->whereIn('type', $types) + ->latest('updated_at') + ->limit(3) + ->get(['type', 'label', 'short_code', 'scans_total']); + + if ($recent->isNotEmpty()) { + $ctx['recent_codes'] = $recent->map(fn (QrCode $code): string => sprintf( + '%s (%s, %d scans)', + $code->label ?: $code->short_code, + QrTypeCatalog::label($code->type), + $code->scans_total, + ))->implode('; '); + } + + return $ctx; + } +} diff --git a/app/Http/Controllers/Qr/DeveloperController.php b/app/Http/Controllers/Qr/DeveloperController.php new file mode 100644 index 0000000..98b53a5 --- /dev/null +++ b/app/Http/Controllers/Qr/DeveloperController.php @@ -0,0 +1,45 @@ + $request->user()->tokens()->latest()->get(), + 'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1', + 'newToken' => session('new_token'), + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:60'], + ]); + + $token = $request->user()->createToken($data['name'], ['qr:read', 'qr:write']); + + return redirect()->route('account.developers') + ->with('new_token', $token->plainTextToken) + ->with('success', 'Token created — copy it now, it won’t be shown again.'); + } + + public function destroy(Request $request, int $token): RedirectResponse + { + $request->user()->tokens()->whereKey($token)->delete(); + + return redirect()->route('account.developers')->with('success', 'Token revoked.'); + } +} diff --git a/app/Http/Controllers/Qr/QrCodeController.php b/app/Http/Controllers/Qr/QrCodeController.php new file mode 100644 index 0000000..879af1b --- /dev/null +++ b/app/Http/Controllers/Qr/QrCodeController.php @@ -0,0 +1,454 @@ +manager->walletFor($account); + $qrCodes = $account->qrCodes() + ->whereIn('type', QrTypeCatalog::eventTypes()) + ->withCount(['eventRegistrations as registrations_count' => fn ($q) => $q->where('status', QrEventRegistration::STATUS_CONFIRMED)]) + ->latest() + ->get(); + + $totalRegistrations = (int) $qrCodes->sum('registrations_count'); + + $ladillWalletBalance = 0.0; + try { + $ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100; + } catch (Throwable $e) { + Log::warning('Events index could not load Ladill wallet balance', [ + 'user' => $account->public_id, + 'error' => $e->getMessage(), + ]); + } + + return view('qr-codes.index', [ + 'wallet' => $wallet, + 'qrCodes' => $qrCodes, + 'totalRegistrations' => $totalRegistrations, + 'pricePerQr' => QrWallet::pricePerQr(), + 'minTopup' => QrWallet::minTopupGhs(), + 'ladillWalletBalance' => $ladillWalletBalance, + 'topupUrl' => 'https://'.config('app.account_domain').'/wallet', + ]); + } + + public function create(Request $request): View + { + $account = ladill_account(); + $wallet = $this->manager->walletFor($account); + $qrSettings = $account->getOrCreateQrSetting(); + $requestedType = $request->query('type', QrCode::TYPE_EVENT); + if (! QrTypeCatalog::isValid($requestedType)) { + $requestedType = QrCode::TYPE_EVENT; + } + + return view('qr-codes.create', [ + 'wallet' => $wallet, + 'requestedType' => $requestedType, + 'moduleStyles' => QrModuleStyleCatalog::visible(), + 'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(), + 'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(), + 'frameStyles' => QrFrameStyleCatalog::visible(), + 'pricePerQr' => QrWallet::pricePerQr(), + 'minTopup' => QrWallet::minTopupGhs(), + 'ladillWalletBalance' => $this->platformBilling->balanceMinor($account->public_id) / 100, + 'topupUrl' => 'https://'.config('app.account_domain').'/wallet', + 'accountDefaultStyle' => $qrSettings->resolvedDefaultStyle(), + 'accountEventDefaults' => $qrSettings->resolvedEventDefaults(), + ]); + } + + public function checkSlug(Request $request): JsonResponse + { + $code = (string) $request->query('code', ''); + + if (! preg_match('/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', $code)) { + return response()->json(['available' => false, 'reason' => 'invalid']); + } + + $taken = QrCode::where('short_code', $code)->exists(); + + return response()->json(['available' => ! $taken]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'label' => ['required', 'string', 'max:120'], + 'type' => ['required', 'in:' . implode(',', QrTypeCatalog::keys())], + 'custom_short_code' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', 'unique:qr_codes,short_code'], + 'destination_url' => ['nullable', 'url', 'max:2048'], + 'document' => ['nullable', 'file', 'mimes:pdf', 'max:102400'], + 'image' => ['nullable', 'image', 'max:10240'], + 'images' => ['nullable', 'array'], + 'images.*' => ['image', 'max:10240'], + 'item_images' => ['nullable', 'array'], + 'item_images.*' => ['array'], + 'item_images.*.*' => ['image', 'max:4096'], + 'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'], + 'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'], + 'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'], + 'business_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'], + 'business_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'], + 'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'], + 'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'], + 'event_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'], + 'event_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'], + 'itinerary_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'], + ...$this->styleRules(), + ]); + + if ($validated['type'] === QrCode::TYPE_DOCUMENT && ! $request->hasFile('document')) { + return back()->withInput()->with('error', 'Upload a PDF for PDF QR codes.'); + } + + if ($validated['type'] === QrCode::TYPE_IMAGE && ! $request->hasFile('image') && ! $request->hasFile('images')) { + return back()->withInput()->with('error', 'Upload at least one image.'); + } + + try { + $qrCode = $this->manager->create(ladill_account(), array_merge( + $request->all(), + [ + 'style' => $validated['style'] ?? [], + 'custom_short_code' => $validated['custom_short_code'] ?? null, + 'avatar' => $request->file('avatar'), + 'book_file' => $request->file('book_file'), + 'cover' => $request->file('cover'), + 'menu_logo' => $request->file('menu_logo'), + 'menu_cover' => $request->file('menu_cover'), + 'business_logo' => $request->file('business_logo'), + 'business_cover' => $request->file('business_cover'), + 'church_logo' => $request->file('church_logo'), + 'church_cover' => $request->file('church_cover'), + 'event_logo' => $request->file('event_logo'), + 'event_cover' => $request->file('event_cover'), + 'itinerary_cover' => $request->file('itinerary_cover'), + ], + )); + } catch (RuntimeException $e) { + if (str_contains($e->getMessage(), 'Insufficient') || str_contains($e->getMessage(), 'Add at least')) { + return back()->withInput()->with('open_topup_modal', 'qr'); + } + + return back()->withInput()->with('error', $e->getMessage()); + } + + return redirect()->route('events.show', $qrCode) + ->with('success', QrTypeCatalog::label($qrCode->type).' created.'); + } + + public function show(Request $request, QrCode $event): View + { + $this->authorize('view', $event); + + $previewDataUri = $this->imageGenerator->previewDataUri($event); + $qrCode = $event->fresh(); + $qrStyle = $qrCode->style(); + $logoDataUri = ! empty($qrStyle['logo_path']) + ? $this->imageGenerator->logoDataUri($qrStyle['logo_path']) + : null; + $account = ladill_account(); + $wallet = $this->manager->walletFor($account); + $summary = $this->analytics->summaryFor($qrCode); + $dailyScans = $this->analytics->dailyScans($qrCode, 30); + $devices = $this->analytics->breakdown($qrCode, 'device_type'); + $browsers = $this->analytics->breakdown($qrCode, 'browser'); + $recentScans = $this->analytics->recentScans($qrCode); + + $ladillWalletBalance = 0.0; + try { + $ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100; + } catch (Throwable $e) { + Log::warning('QR Plus show could not load Ladill wallet balance', [ + 'user' => $account->public_id, + 'error' => $e->getMessage(), + ]); + } + + return view('qr-codes.show', [ + 'qrCode' => $qrCode, + 'previewDataUri' => $previewDataUri, + 'logoDataUri' => $logoDataUri, + 'types' => QrTypeCatalog::all(), + 'moduleStyles' => QrModuleStyleCatalog::visible(), + 'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(), + 'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(), + 'frameStyles' => QrFrameStyleCatalog::visible(), + 'wallet' => $wallet, + 'summary' => $summary, + 'dailyScans' => $dailyScans, + 'devices' => $devices, + 'browsers' => $browsers, + 'recentScans' => $recentScans, + 'orders' => null, + 'pricePerQr' => QrWallet::pricePerQr(), + 'minTopup' => QrWallet::minTopupGhs(), + 'ladillWalletBalance' => $ladillWalletBalance, + 'topupUrl' => 'https://'.config('app.account_domain').'/wallet', + ]); + } + + public function update(Request $request, QrCode $event): RedirectResponse + { + $this->authorize('update', $event); + $qrCode = $event; + + $validated = $request->validate([ + 'label' => ['sometimes', 'string', 'max:120'], + 'destination_url' => ['nullable', 'url', 'max:2048'], + 'document' => ['nullable', 'file', 'mimes:pdf', 'max:102400'], + 'image' => ['nullable', 'image', 'max:10240'], + 'images' => ['nullable', 'array'], + 'images.*' => ['image', 'max:10240'], + 'item_images' => ['nullable', 'array'], + 'item_images.*' => ['array'], + 'item_images.*.*' => ['image', 'max:4096'], + 'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'], + 'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'], + 'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'], + 'business_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'], + 'business_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'], + 'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'], + 'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'], + 'event_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'], + 'event_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'], + 'itinerary_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'], + 'remove_logo' => ['sometimes', 'boolean'], + 'is_active' => ['sometimes', 'boolean'], + ...$this->styleRules(), + ]); + + try { + $this->manager->update($qrCode, array_merge( + $request->all(), + [ + 'document' => $request->file('document'), + 'logo' => $request->file('logo'), + 'style' => $validated['style'] ?? null, + 'is_active' => $request->boolean('is_active', $qrCode->is_active), + 'remove_logo' => $request->boolean('remove_logo'), + 'avatar' => $request->file('avatar'), + 'book_file' => $request->file('book_file'), + 'cover' => $request->file('cover'), + 'menu_logo' => $request->file('menu_logo'), + 'menu_cover' => $request->file('menu_cover'), + 'business_logo' => $request->file('business_logo'), + 'business_cover' => $request->file('business_cover'), + 'church_logo' => $request->file('church_logo'), + 'church_cover' => $request->file('church_cover'), + 'event_logo' => $request->file('event_logo'), + 'event_cover' => $request->file('event_cover'), + 'itinerary_cover' => $request->file('itinerary_cover'), + ], + )); + } catch (RuntimeException $e) { + return back()->with('error', $e->getMessage()); + } + + return back()->with('success', QrTypeCatalog::label($qrCode->type).' updated.'); + } + + public function stylePreview(Request $request): Response + { + $validated = $request->validate(array_merge($this->styleRules(), [ + 'short_code' => ['nullable', 'string', 'max:16'], + 'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'], + 'use_existing_logo' => ['nullable', 'boolean'], + 'existing_logo_path' => ['nullable', 'string', 'max:500'], + ])); + + $shortCode = $validated['short_code'] ?? 'preview'; + $style = $validated['style'] ?? []; + $tempLogoPath = null; + + if ($request->hasFile('logo')) { + $tempLogoPath = $request->file('logo')->store('tmp/previews', 'qr'); + $style['logo_path'] = $tempLogoPath; + } elseif ($request->boolean('use_existing_logo') && ! empty($validated['existing_logo_path'])) { + $existingPath = $validated['existing_logo_path']; + $userPrefix = ladill_account()->id . '/'; + if (str_starts_with($existingPath, $userPrefix) && Storage::disk('qr')->exists($existingPath)) { + $style['logo_path'] = $existingPath; + } + } + + $png = $this->imageGenerator->renderPng(QrCode::publicBaseUrl() . '/q/' . $shortCode, $style); + + if ($tempLogoPath) { + Storage::disk('qr')->delete($tempLogoPath); + } + + return response($png, 200, [ + 'Content-Type' => 'image/png', + 'Cache-Control' => 'no-store', + ]); + } + + /** @return array */ + private function styleRules(): array + { + return [ + 'style' => ['nullable', 'array'], + 'style.foreground' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'style.background' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'style.error_correction' => ['nullable', 'in:L,M,Q,H'], + 'style.margin' => ['nullable', 'integer', 'min:0', 'max:10'], + 'style.module_style' => ['nullable', 'in:' . implode(',', QrModuleStyleCatalog::keys())], + 'style.finder_outer' => ['nullable', 'in:' . implode(',', array_keys(QrCornerStyleCatalog::outerStyles()))], + 'style.finder_inner' => ['nullable', 'in:' . implode(',', array_keys(QrCornerStyleCatalog::innerStyles()))], + 'style.frame_style' => ['nullable', 'in:' . implode(',', QrFrameStyleCatalog::keys())], + 'style.frame_text' => ['nullable', 'string', 'max:100'], + 'style.frame_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'style.scale' => ['nullable', 'integer', 'min:4', 'max:16'], + 'style.gradient_type' => ['nullable', 'in:none,linear,radial'], + 'style.gradient_color1' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'style.gradient_color2' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'], + 'style.logo_size' => ['nullable', 'numeric', 'min:0.1', 'max:0.4'], + 'style.logo_margin' => ['nullable', 'integer', 'min:0', 'max:15'], + 'style.logo_white_bg' => ['nullable', 'boolean'], + 'style.logo_shape' => ['nullable', 'in:none,rounded,circle'], + ]; + } + + public function preview(QrCode $event): Response + { + $this->authorize('view', $event); + + $qrCode = $this->imageGenerator->ensureValidImages($event); + + $bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path); + + if ($bytes === null) { + $bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style()); + $this->imageGenerator->generateAndStore($qrCode); + } + + return response($bytes, 200, [ + 'Content-Type' => 'image/png', + 'Cache-Control' => 'private, max-age=3600', + ]); + } + + public function download(QrCode $event, string $format): StreamedResponse + { + $this->authorize('view', $event); + + $qrCode = $this->imageGenerator->ensureValidImages($event); + $path = $format === 'svg' ? $qrCode->svg_path : $qrCode->png_path; + $filename = Str::slug($qrCode->label) . '-qr.' . ($format === 'svg' ? 'svg' : 'png'); + + if ($format === 'png') { + $bytes = $this->imageGenerator->normalizeStoredPng($path); + if ($bytes === null) { + $bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style()); + $this->imageGenerator->generateAndStore($qrCode); + } + + return response()->streamDownload(fn () => print($bytes), $filename, [ + 'Content-Type' => 'image/png', + ]); + } + + if ($format === 'pdf') { + $bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path); + if ($bytes === null) { + $bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style()); + $this->imageGenerator->generateAndStore($qrCode); + } + + $pdf = $this->pdfExporter->fromPng($bytes, $qrCode->label); + $filename = Str::slug($qrCode->label) . '-qr.pdf'; + + return response()->streamDownload(fn () => print($pdf), $filename, [ + 'Content-Type' => 'application/pdf', + ]); + } + + abort_unless($path && Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->download($path, $filename); + } + + /** + * Persist the exact client-rendered QR (qr-code-styling) as the canonical + * SVG + PNG so downloads match the preview pixel-for-pixel. The browser + * renders with the real /q/{code} URL on the show page, then posts here. + */ + public function storeCanonicalImage(Request $request, QrCode $event): JsonResponse + { + $this->authorize('update', $event); + $qrCode = $event; + + $validated = $request->validate([ + 'svg' => ['required', 'string', 'max:600000'], + 'png' => ['required', 'string', 'max:4000000'], + ]); + + $svg = $this->imageGenerator->sanitizeSvg($validated['svg']); + if ($svg === null) { + return response()->json(['error' => 'Invalid SVG.'], 422); + } + + $pngB64 = $validated['png']; + if (str_contains($pngB64, ',')) { + $pngB64 = substr($pngB64, strpos($pngB64, ',') + 1); + } + $png = base64_decode($pngB64, true); + if ($png === false || ! $this->imageGenerator->isValidPngBinary($png)) { + return response()->json(['error' => 'Invalid PNG.'], 422); + } + + $basePath = $qrCode->user_id . '/codes/' . $qrCode->id; + Storage::disk('qr')->put($basePath . '/qr.svg', $svg); + Storage::disk('qr')->put($basePath . '/qr.png', $png); + + $qrCode->update([ + 'svg_path' => $basePath . '/qr.svg', + 'png_path' => $basePath . '/qr.png', + ]); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Qr/TeamController.php b/app/Http/Controllers/Qr/TeamController.php new file mode 100644 index 0000000..4891ee5 --- /dev/null +++ b/app/Http/Controllers/Qr/TeamController.php @@ -0,0 +1,108 @@ +where('account_id', $account->id) + ->with('member') + ->orderBy('status')->orderBy('email') + ->get(); + + return view('qr.account.team', [ + 'account' => $account, + 'members' => $members, + 'canManage' => $this->canManage($request), + 'isOwner' => $request->user()->id === $account->id, + ]); + } + + public function store(Request $request): RedirectResponse + { + abort_unless($this->canManage($request), 403); + + $validated = $request->validate([ + 'email' => ['required', 'email', 'max:255'], + 'role' => ['required', 'in:admin,member'], + ]); + + $account = ladill_account(); + $email = strtolower($validated['email']); + + if ($email === strtolower($account->email)) { + return back()->withErrors(['email' => 'The owner is already on the account.']); + } + + $member = QrTeamMember::firstOrNew(['account_id' => $account->id, 'email' => $email]); + $member->role = $validated['role']; + if (! $member->exists) { + $member->status = QrTeamMember::STATUS_INVITED; + $member->token = Str::random(40); + } + $member->save(); + + if ($existing = User::whereRaw('LOWER(email) = ?', [$email])->first()) { + QrTeamMember::linkPendingInvitesFor($existing); + } + + return back()->with('success', $email.' invited.'); + } + + public function updateRole(Request $request, QrTeamMember $member): RedirectResponse + { + abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403); + + $validated = $request->validate(['role' => ['required', 'in:admin,member']]); + $member->update(['role' => $validated['role']]); + + return back()->with('success', 'Role updated.'); + } + + public function destroy(Request $request, QrTeamMember $member): RedirectResponse + { + abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403); + + $member->delete(); + + return back()->with('success', 'Member removed.'); + } + + public function switchAccount(Request $request): RedirectResponse + { + $validated = $request->validate(['account' => ['required', 'integer']]); + + abort_unless($request->user()->canAccessAccount((int) $validated['account']), 403); + + $request->session()->put('ladill_account', (int) $validated['account']); + + return redirect()->route('mini.dashboard'); + } + + private function canManage(Request $request): bool + { + $user = $request->user(); + $account = ladill_account(); + + if ($user->id === $account->id) { + return true; + } + + return $user->memberships() + ->where('account_id', $account->id) + ->where('role', QrTeamMember::ROLE_ADMIN) + ->exists(); + } +} diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php new file mode 100644 index 0000000..9cc2bae --- /dev/null +++ b/app/Http/Controllers/SearchController.php @@ -0,0 +1,71 @@ +query('q')); + $results = mb_strlen($q) >= 2 ? $this->results($q) : []; + + if ($request->expectsJson() || $request->ajax() || $request->wantsJson()) { + return response()->json(['results' => $results]); + } + + return view('search.index', [ + 'query' => $q, + 'results' => $results, + ]); + } + + /** @return list */ + private function results(string $q): array + { + $account = ladill_account(); + $like = '%'.$q.'%'; + + $qrIds = $account->qrCodes() + ->where('type', QrCode::TYPE_PAYMENT) + ->pluck('id'); + + if ($qrIds->isEmpty()) { + return []; + } + + return MiniPayment::query() + ->whereIn('qr_code_id', $qrIds) + ->with('qrCode') + ->where(function ($query) use ($like) { + $query->where('payer_name', 'like', $like) + ->orWhere('payer_email', 'like', $like) + ->orWhere('payer_note', 'like', $like) + ->orWhere('reference', 'like', $like) + ->orWhere('payment_reference', 'like', $like) + ->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like)); + }) + ->latest('created_at') + ->limit(15) + ->get() + ->map(function (MiniPayment $payment): array { + $amount = number_format( + ($payment->status === MiniPayment::STATUS_PAID ? $payment->merchant_amount_minor : $payment->amount_minor) / 100, + 2, + ); + + return [ + 'type' => 'payment', + 'title' => $payment->payer_name ?: 'Walk-in customer', + 'subtitle' => 'GHS '.$amount.' · '.($payment->qrCode?->label ?? 'Payment QR').' · '.ucfirst($payment->status), + 'url' => route('mini.payments.index', ['q' => $payment->reference]), + ]; + }) + ->all(); + } +} diff --git a/app/Http/Controllers/WalletBalanceController.php b/app/Http/Controllers/WalletBalanceController.php new file mode 100644 index 0000000..29d4267 --- /dev/null +++ b/app/Http/Controllers/WalletBalanceController.php @@ -0,0 +1,40 @@ +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), + ]); + } +} diff --git a/app/Http/Controllers/WellKnown/AssetLinksController.php b/app/Http/Controllers/WellKnown/AssetLinksController.php new file mode 100644 index 0000000..aa62c76 --- /dev/null +++ b/app/Http/Controllers/WellKnown/AssetLinksController.php @@ -0,0 +1,27 @@ +json([ + [ + 'relation' => ['delegate_permission/common.handle_all_urls'], + 'target' => [ + 'namespace' => 'android_app', + 'package_name' => config('android_app_links.package_name'), + 'sha256_cert_fingerprints' => $fingerprints, + ], + ], + ], 200, [], JSON_UNESCAPED_SLASHES); + } +} diff --git a/app/Http/Middleware/EnsurePlatformSession.php b/app/Http/Middleware/EnsurePlatformSession.php new file mode 100644 index 0000000..db642f5 --- /dev/null +++ b/app/Http/Middleware/EnsurePlatformSession.php @@ -0,0 +1,58 @@ +headers->get('Cookie', ''); + if ($cookieHeader === '') { + return $this->clearAppSession($request); + } + + try { + $response = Http::withHeaders(['Cookie' => $cookieHeader]) + ->timeout(3) + ->get('https://'.$authDomain.'/sso/ping'); + } catch (\Throwable) { + return $next($request); + } + + if ($response->status() === 401) { + return $this->clearAppSession($request); + } + + 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(), + ]); + } +} diff --git a/app/Http/Middleware/SetActingAccount.php b/app/Http/Middleware/SetActingAccount.php new file mode 100644 index 0000000..c9f4c1a --- /dev/null +++ b/app/Http/Middleware/SetActingAccount.php @@ -0,0 +1,41 @@ +user()) { + if ($request->is('api/*')) { + $accountId = (int) ($request->header('X-Ladill-Account') ?: $user->id); + } else { + $accountId = (int) $request->session()->get('ladill_account', $user->id); + } + + if (! $user->canAccessAccount($accountId)) { + $accountId = $user->id; + if (! $request->is('api/*')) { + $request->session()->put('ladill_account', $accountId); + } + } + + $account = $accountId === $user->id ? $user : (User::find($accountId) ?? $user); + + $request->attributes->set('actingAccount', $account); + + if (! $request->is('api/*')) { + View::share('actingAccount', $account); + View::share('accessibleAccounts', $user->accessibleAccounts()); + } + } + + return $next($request); + } +} diff --git a/app/Models/MiniPayment.php b/app/Models/MiniPayment.php new file mode 100644 index 0000000..8e9f704 --- /dev/null +++ b/app/Models/MiniPayment.php @@ -0,0 +1,62 @@ + 'integer', + 'platform_fee_minor' => 'integer', + 'merchant_amount_minor' => 'integer', + 'metadata' => 'array', + 'paid_at' => 'datetime', + ]; + + public function qrCode(): BelongsTo + { + return $this->belongsTo(QrCode::class); + } + + public function merchant(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function amountMajor(): float + { + return $this->amount_minor / 100; + } +} diff --git a/app/Models/PlatformSetting.php b/app/Models/PlatformSetting.php new file mode 100644 index 0000000..b3b603b --- /dev/null +++ b/app/Models/PlatformSetting.php @@ -0,0 +1,27 @@ + 'array', + 'is_secret' => 'boolean', + 'is_active' => 'boolean', + ]; +} diff --git a/app/Models/PosLocation.php b/app/Models/PosLocation.php new file mode 100644 index 0000000..9d5886f --- /dev/null +++ b/app/Models/PosLocation.php @@ -0,0 +1,32 @@ +hasMany(PosProduct::class, 'location_id'); + } + + public function sales(): HasMany + { + return $this->hasMany(PosSale::class, 'location_id'); + } + + public function scopeOwned(Builder $query, string $ownerRef): Builder + { + return $query->where('owner_ref', $ownerRef); + } +} diff --git a/app/Models/PosProduct.php b/app/Models/PosProduct.php new file mode 100644 index 0000000..7820ee1 --- /dev/null +++ b/app/Models/PosProduct.php @@ -0,0 +1,43 @@ + 'integer', + 'is_active' => 'boolean', + ]; + } + + public function location(): BelongsTo + { + return $this->belongsTo(PosLocation::class, 'location_id'); + } + + public function scopeOwned(Builder $query, string $ownerRef): Builder + { + return $query->where('owner_ref', $ownerRef); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } +} diff --git a/app/Models/PosSale.php b/app/Models/PosSale.php new file mode 100644 index 0000000..d9e5618 --- /dev/null +++ b/app/Models/PosSale.php @@ -0,0 +1,72 @@ + 'integer', + 'total_minor' => 'integer', + 'pay_order_id' => 'integer', + 'crm_customer_id' => 'integer', + 'paid_at' => 'datetime', + ]; + } + + public function location(): BelongsTo + { + return $this->belongsTo(PosLocation::class, 'location_id'); + } + + public function lines(): HasMany + { + return $this->hasMany(PosSaleLine::class)->orderBy('position'); + } + + public function isPaid(): bool + { + return $this->status === self::STATUS_PAID; + } + + public function scopeOwned(Builder $query, string $ownerRef): Builder + { + return $query->where('owner_ref', $ownerRef); + } +} diff --git a/app/Models/PosSaleLine.php b/app/Models/PosSaleLine.php new file mode 100644 index 0000000..472c731 --- /dev/null +++ b/app/Models/PosSaleLine.php @@ -0,0 +1,39 @@ + 'integer', + 'quantity' => 'integer', + 'line_total_minor' => 'integer', + 'position' => 'integer', + ]; + } + + public function sale(): BelongsTo + { + return $this->belongsTo(PosSale::class, 'pos_sale_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(PosProduct::class, 'product_id'); + } +} diff --git a/app/Models/QrCode.php b/app/Models/QrCode.php new file mode 100644 index 0000000..020a348 --- /dev/null +++ b/app/Models/QrCode.php @@ -0,0 +1,212 @@ + 'array', + 'is_active' => 'boolean', + 'scans_total' => 'integer', + 'unique_scans_total' => 'integer', + 'last_scanned_at' => 'datetime', + 'destination_updated_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function document(): BelongsTo + { + return $this->belongsTo(QrDocument::class, 'qr_document_id'); + } + + public function scanEvents(): HasMany + { + return $this->hasMany(QrScanEvent::class); + } + + public function transactions(): HasMany + { + return $this->hasMany(QrTransaction::class); + } + + public function miniPayments(): HasMany + { + return $this->hasMany(MiniPayment::class); + } + + public function publicUrl(): string + { + return self::publicBaseUrl() . '/q/' . $this->short_code; + } + + /** + * Base URL for public QR links. Always the short platform domain + * (ladill.com) — never the signed-in account/product host — so printed + * codes and shared links stay short and host-independent of where the QR + * was created. + */ + public static function publicBaseUrl(): string + { + $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; + } + + /** + * String encoded inside the QR image. + * + * Most types encode the stable short link so the printed code never changes + * when content is edited. WiFi is the exception: it bakes the network + * credentials directly into the image so devices auto-join on scan. That + * payload is frozen at creation (payload.wifi_encoded) — editing the network + * afterwards updates the saved info but never re-encodes the printed code. + */ + public function encodedPayload(): string + { + if ($this->type === self::TYPE_WIFI) { + $frozen = $this->payload['wifi_encoded'] ?? null; + + return is_string($frozen) && $frozen !== '' + ? $frozen + : QrWifiPayload::encode($this->content()); + } + + return $this->publicUrl(); + } + + /** WiFi codes encode their join payload directly (auto-join), not a redirect link. */ + public function encodesDirectPayload(): bool + { + return $this->type === self::TYPE_WIFI; + } + + public function typeLabel(): string + { + return QrTypeCatalog::label($this->type); + } + + /** @return array */ + public function content(): array + { + return (array) ($this->payload['content'] ?? []); + } + + /** @return array */ + public function style(): array + { + if ($this->isPaymentType()) { + return QrStyleDefaults::defaults(); + } + + return QrStyleDefaults::merge($this->payload['style'] ?? null); + } + + public function isUrlType(): bool + { + return $this->type === self::TYPE_URL; + } + + public function isDocumentType(): bool + { + return $this->type === self::TYPE_DOCUMENT; + } + + public function isImageType(): bool + { + return $this->type === self::TYPE_IMAGE; + } + + public function isMenuType(): bool + { + return $this->type === self::TYPE_MENU; + } + + public function isShopType(): bool + { + return $this->type === self::TYPE_SHOP; + } + + public function isBookType(): bool + { + return $this->type === self::TYPE_BOOK; + } + + public function acceptsOrders(): bool + { + return in_array($this->type, [self::TYPE_MENU, self::TYPE_SHOP, self::TYPE_CHURCH, self::TYPE_EVENT], true); + } + + public function isPaymentType(): bool + { + return $this->type === self::TYPE_PAYMENT; + } + + public function usesLandingPage(): bool + { + return in_array($this->type, [ + self::TYPE_PAYMENT, + ], true); + } + + public function resolvesToRedirect(): bool + { + return $this->type === self::TYPE_URL; + } + + public function redirectUrl(): ?string + { + if ($this->destination_url) { + return $this->destination_url; + } + + return $this->content()['url'] ?? null; + } +} diff --git a/app/Models/QrDocument.php b/app/Models/QrDocument.php new file mode 100644 index 0000000..1ce2d16 --- /dev/null +++ b/app/Models/QrDocument.php @@ -0,0 +1,35 @@ + 'integer', + 'page_count' => 'integer', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function qrCodes(): HasMany + { + return $this->hasMany(QrCode::class); + } +} diff --git a/app/Models/QrScanEvent.php b/app/Models/QrScanEvent.php new file mode 100644 index 0000000..de86c74 --- /dev/null +++ b/app/Models/QrScanEvent.php @@ -0,0 +1,32 @@ + 'datetime', + 'is_unique' => 'boolean', + ]; + + public function qrCode(): BelongsTo + { + return $this->belongsTo(QrCode::class); + } +} diff --git a/app/Models/QrSetting.php b/app/Models/QrSetting.php new file mode 100644 index 0000000..ebe6e5f --- /dev/null +++ b/app/Models/QrSetting.php @@ -0,0 +1,174 @@ + 'boolean', + 'low_balance_alerts' => 'boolean', + 'notify_registrations' => 'boolean', + 'notify_payouts' => 'boolean', + 'auto_withdraw_amount_minor' => 'integer', + 'default_style' => 'array', + 'event_defaults' => 'array', + ]; + + /** @return list */ + public static function storableEventDefaultKeys(): array + { + return [ + 'currency', + 'mode', + 'badge_size', + 'brand_color', + 'organizer', + 'registration_open', + 'badge_fields', + ]; + } + + /** @return list */ + public static function storableStyleKeys(): array + { + return [ + 'foreground', + 'background', + 'error_correction', + 'margin', + 'module_style', + 'finder_outer', + 'finder_inner', + 'frame_style', + 'frame_text', + 'frame_color', + 'gradient_type', + 'gradient_color1', + 'gradient_color2', + 'gradient_rotation', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function resolvedDefaultType(): string + { + $type = (string) ($this->default_type ?? QrCode::TYPE_EVENT); + + return in_array($type, QrTypeCatalog::eventsTypes(), true) ? $type : QrCode::TYPE_EVENT; + } + + /** @return array */ + public function resolvedEventDefaults(): array + { + $defaults = [ + 'currency' => 'GHS', + 'mode' => 'ticketing', + 'badge_size' => '4x3', + 'brand_color' => '#4f46e5', + 'organizer' => '', + 'registration_open' => true, + 'badge_fields' => ['Company', 'Role'], + ]; + + $stored = collect($this->event_defaults ?? []) + ->only(self::storableEventDefaultKeys()) + ->filter(fn ($value) => $value !== null && $value !== '') + ->all(); + + if (isset($stored['badge_fields']) && is_array($stored['badge_fields'])) { + $stored['badge_fields'] = array_values(array_filter( + array_map('strval', $stored['badge_fields']), + fn (string $field) => trim($field) !== '', + )); + if ($stored['badge_fields'] === []) { + unset($stored['badge_fields']); + } + } + + return array_merge($defaults, $stored); + } + + /** @param array|null $input */ + public static function sanitizeEventDefaults(?array $input): ?array + { + if ($input === null) { + return null; + } + + $allowedModes = ['ticketing', 'contributions', 'free']; + $allowedSizes = ['4x3', '4x6', 'cr80']; + $allowedCurrencies = ['GHS', 'USD', 'NGN', 'KES']; + + $mode = (string) ($input['mode'] ?? 'ticketing'); + $badgeSize = (string) ($input['badge_size'] ?? '4x3'); + $currency = strtoupper(trim((string) ($input['currency'] ?? 'GHS'))); + + $badgeFields = array_values(array_filter( + array_map(fn ($field) => mb_substr(trim((string) $field), 0, 40), (array) ($input['badge_fields'] ?? [])), + fn (string $field) => $field !== '', + )); + + $brandColor = (string) ($input['brand_color'] ?? '#4f46e5'); + if (! preg_match('/^#[0-9a-fA-F]{6}$/', $brandColor)) { + $brandColor = '#4f46e5'; + } + + return [ + 'currency' => in_array($currency, $allowedCurrencies, true) ? $currency : 'GHS', + 'mode' => in_array($mode, $allowedModes, true) ? $mode : 'ticketing', + 'badge_size' => in_array($badgeSize, $allowedSizes, true) ? $badgeSize : '4x3', + 'brand_color' => $brandColor, + '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'], + ]; + } + + /** @return array */ + public function resolvedDefaultStyle(): array + { + $stored = collect($this->default_style ?? []) + ->only(self::storableStyleKeys()) + ->filter(fn ($value) => $value !== null && $value !== '') + ->all(); + + return QrStyleDefaults::merge($stored !== [] ? $stored : null); + } + + /** @param array|null $input */ + public static function sanitizeDefaultStyle(?array $input): ?array + { + if ($input === null) { + return null; + } + + $merged = QrStyleDefaults::merge( + collect($input)->only(self::storableStyleKeys())->all(), + ); + + return collect($merged)->only(self::storableStyleKeys())->all(); + } +} diff --git a/app/Models/QrTeamMember.php b/app/Models/QrTeamMember.php new file mode 100644 index 0000000..c8d350c --- /dev/null +++ b/app/Models/QrTeamMember.php @@ -0,0 +1,46 @@ + 'datetime']; + + public function account(): BelongsTo + { + return $this->belongsTo(User::class, 'account_id'); + } + + public function member(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public static function linkPendingInvitesFor(User $user): void + { + static::query() + ->whereNull('user_id') + ->where('status', self::STATUS_INVITED) + ->whereRaw('LOWER(email) = ?', [strtolower($user->email)]) + ->update([ + 'user_id' => $user->id, + 'status' => self::STATUS_ACTIVE, + 'accepted_at' => now(), + 'token' => null, + ]); + } +} diff --git a/app/Models/QrTransaction.php b/app/Models/QrTransaction.php new file mode 100644 index 0000000..b1f29da --- /dev/null +++ b/app/Models/QrTransaction.php @@ -0,0 +1,46 @@ + 'decimal:4', + 'balance_after_ghs' => 'decimal:4', + 'metadata' => 'array', + ]; + + public function wallet(): BelongsTo + { + return $this->belongsTo(QrWallet::class, 'qr_wallet_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function qrCode(): BelongsTo + { + return $this->belongsTo(QrCode::class); + } +} diff --git a/app/Models/QrWallet.php b/app/Models/QrWallet.php new file mode 100644 index 0000000..05b1563 --- /dev/null +++ b/app/Models/QrWallet.php @@ -0,0 +1,66 @@ + 'decimal:4', + 'qr_codes_total' => 'integer', + 'scans_total' => 'integer', + ]; + + public static function pricePerQr(): float + { + return (float) config('qr.price_per_qr_ghs', 5.0); + } + + public static function minTopupGhs(): float + { + return (float) config('qr.min_topup_ghs', 5.0); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function transactions(): HasMany + { + return $this->hasMany(QrTransaction::class)->latest(); + } + + /** + * Single-wallet (siloing step 2): QR spends from the one UserWallet (tagged + * 'qr'). Delegates to the billing service (lazily folds any legacy + * credit_balance in). `spendableBalance()` is the unified balance for display. + */ + public function spendableBalance(): float + { + return $this->user + ? app(\App\Services\Qr\QrWalletBillingService::class)->balanceCedis($this->user) + : (float) $this->credit_balance; + } + + public function canCreateQr(): bool + { + return $this->user + ? app(\App\Services\Qr\QrWalletBillingService::class)->canCreate($this->user) + : false; + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..b158607 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,94 @@ + */ + use HasApiTokens, HasFactory, Notifiable; + + protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password', 'last_app_active_at']; + + protected $hidden = ['password', 'remember_token']; + + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'last_app_active_at' => 'datetime', + 'password' => 'hashed', + ]; + } + + public function memberships(): HasMany + { + return $this->hasMany(QrTeamMember::class, 'user_id') + ->where('status', QrTeamMember::STATUS_ACTIVE); + } + + public function canAccessAccount(int $accountId): bool + { + return $accountId === $this->id + || $this->memberships()->where('account_id', $accountId)->exists(); + } + + /** @return Collection */ + public function accessibleAccounts(): Collection + { + $ids = $this->memberships()->pluck('account_id')->all(); + + return collect([$this])->merge(self::whereIn('id', $ids)->get())->unique('id')->values(); + } + + public function qrWallet(): HasOne + { + return $this->hasOne(QrWallet::class); + } + + public function qrCodes(): HasMany + { + return $this->hasMany(QrCode::class); + } + + public function qrSetting(): HasOne + { + return $this->hasOne(QrSetting::class); + } + + public function pushTokens(): HasMany + { + return $this->hasMany(UserPushToken::class); + } + + public function getOrCreateQrSetting(): QrSetting + { + return $this->qrSetting()->firstOrCreate([]); + } + + public function getOrCreateQrWallet(): QrWallet + { + return $this->qrWallet()->firstOrCreate( + [], + ['credit_balance' => 0, 'qr_codes_total' => 0, 'scans_total' => 0, 'status' => QrWallet::STATUS_ACTIVE], + ); + } + + public function avatarUrl(): ?string + { + $url = trim((string) $this->avatar_url); + + return $url !== '' ? $url : null; + } +} diff --git a/app/Models/UserPushToken.php b/app/Models/UserPushToken.php new file mode 100644 index 0000000..ebe33ff --- /dev/null +++ b/app/Models/UserPushToken.php @@ -0,0 +1,26 @@ + 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Notifications/DomainVerifiedNotification.php b/app/Notifications/DomainVerifiedNotification.php new file mode 100644 index 0000000..666597c --- /dev/null +++ b/app/Notifications/DomainVerifiedNotification.php @@ -0,0 +1,45 @@ +subject("Domain Verified: {$this->domain->host} is now active!") + ->view('mail.notifications.domain-verified', [ + 'domain' => $this->domain, + 'manageUrl' => route('user.domains.show', $this->domain), + 'emailUrl' => route('user.mailboxes.index'), + ]); + } + + public function toArray($notifiable): array + { + return [ + 'title' => 'Domain Verified', + 'message' => "Your domain {$this->domain->host} has been verified and is now active.", + 'icon' => 'success', + 'url' => route('user.domains.show', $this->domain), + ]; + } +} diff --git a/app/Notifications/EventProgrammeSharedNotification.php b/app/Notifications/EventProgrammeSharedNotification.php new file mode 100644 index 0000000..ffc4729 --- /dev/null +++ b/app/Notifications/EventProgrammeSharedNotification.php @@ -0,0 +1,38 @@ +event->content()['name'] ?? $this->event->label; + + return (new MailMessage()) + ->subject('Programme for ' . $eventName) + ->view('mail.notifications.event-programme', [ + 'eventName' => $eventName, + 'programmeUrl' => $this->programme->publicUrl(), + 'attendeeName' => $this->attendeeName, + ]); + } +} diff --git a/app/Notifications/HostingActivatedNotification.php b/app/Notifications/HostingActivatedNotification.php new file mode 100644 index 0000000..35d8bba --- /dev/null +++ b/app/Notifications/HostingActivatedNotification.php @@ -0,0 +1,53 @@ +subject('Your hosting is now active!') + ->view('mail.notifications.hosting-activated', [ + 'planName' => $this->planName, + 'activationDate' => $this->activationDate, + 'domainName' => $this->domainName, + 'manageUrl' => $this->manageUrl ?? route('hosting.index'), + ]); + } + + public function toArray($notifiable): array + { + $message = "Your {$this->planName} hosting plan is now active"; + if ($this->domainName) { + $message .= " for {$this->domainName}"; + } + + return [ + 'title' => 'Hosting Activated', + 'message' => $message . '.', + 'icon' => 'hosting', + 'url' => $this->manageUrl ?? route('hosting.index'), + ]; + } +} diff --git a/app/Notifications/HostingDeveloperAddedNotification.php b/app/Notifications/HostingDeveloperAddedNotification.php new file mode 100644 index 0000000..877bb0a --- /dev/null +++ b/app/Notifications/HostingDeveloperAddedNotification.php @@ -0,0 +1,54 @@ + $accountLabels + */ + public function __construct( + private string $ownerName, + private array $accountLabels, + private ?string $setupUrl = null + ) {} + + public function via($notifiable): array + { + return ['mail', 'database']; + } + + public function toMail($notifiable): MailMessage + { + return (new MailMessage()) + ->subject('You were added to a Ladill hosting team') + ->view('mail.notifications.hosting-developer-added', [ + 'developer' => $notifiable, + 'ownerName' => $this->ownerName, + 'accountLabels' => $this->accountLabels, + 'setupUrl' => $this->setupUrl, + 'loginUrl' => route('login'), + 'dashboardUrl' => route('dashboard'), + ]); + } + + public function toArray($notifiable): array + { + $accountCount = count($this->accountLabels); + $scope = $accountCount === 1 ? $this->accountLabels[0] : $accountCount.' hosting accounts'; + + return [ + 'title' => 'Hosting team access granted', + 'message' => "You were added by {$this->ownerName} to {$scope}.", + 'icon' => 'hosting', + 'url' => route('hosting.single-domain'), + ]; + } +} diff --git a/app/Notifications/HostingExpiringNotification.php b/app/Notifications/HostingExpiringNotification.php new file mode 100644 index 0000000..48e83cf --- /dev/null +++ b/app/Notifications/HostingExpiringNotification.php @@ -0,0 +1,68 @@ +subject($this->subjectLine()) + ->view('mail.notifications.hosting-expiring', [ + 'account' => $this->account, + 'daysRemaining' => $this->daysRemaining, + 'renewUrl' => route('hosting.accounts.show', $this->account), + ]); + } + + public function toArray($notifiable): array + { + $label = $this->account->primary_domain ?: $this->account->username; + + return [ + 'title' => $this->headline(), + 'message' => "Hosting for {$label} expires in {$this->daysRemaining} days.", + 'icon' => 'hosting', + 'url' => route('hosting.accounts.show', $this->account), + ]; + } + + private function subjectLine(): string + { + $label = $this->account->primary_domain ?: $this->account->username; + + return match (true) { + $this->daysRemaining <= 1 => "Hosting expires tomorrow: {$label}", + $this->daysRemaining <= 7 => "Urgent: hosting for {$label} expires in {$this->daysRemaining} days", + default => "Hosting renewal reminder: {$label}", + }; + } + + private function headline(): string + { + return match (true) { + $this->daysRemaining <= 1 => 'Hosting expires soon', + $this->daysRemaining <= 7 => 'Urgent hosting renewal', + default => 'Hosting renewal reminder', + }; + } +} diff --git a/app/Notifications/HostingResourceWarningNotification.php b/app/Notifications/HostingResourceWarningNotification.php new file mode 100644 index 0000000..ba80d04 --- /dev/null +++ b/app/Notifications/HostingResourceWarningNotification.php @@ -0,0 +1,47 @@ +subject($this->subjectLine) + ->greeting('Hosting resource update') + ->line($this->messageBody) + ->line('Domain: ' . ($this->account->primary_domain ?: $this->account->username)) + ->line('Resource state: ' . ucfirst((string) ($this->account->resource_status ?: 'active'))) + ->action('Manage Hosting', route('hosting.accounts.show', $this->account)); + } + + public function toArray($notifiable): array + { + return [ + 'title' => $this->subjectLine, + 'message' => $this->messageBody, + 'icon' => 'hosting', + 'url' => route('hosting.accounts.show', $this->account), + ]; + } +} diff --git a/app/Notifications/HostingSuspendedNotification.php b/app/Notifications/HostingSuspendedNotification.php new file mode 100644 index 0000000..e164bde --- /dev/null +++ b/app/Notifications/HostingSuspendedNotification.php @@ -0,0 +1,48 @@ +subject('Hosting suspended: '.($this->account->primary_domain ?: $this->account->username)) + ->view('mail.notifications.hosting-suspended', [ + 'account' => $this->account, + 'reason' => $this->reason, + 'dashboardUrl' => route('hosting.accounts.show', $this->account), + ]); + } + + public function toArray($notifiable): array + { + $label = $this->account->primary_domain ?: $this->account->username; + + return [ + 'title' => 'Hosting suspended', + 'message' => "Hosting for {$label} has been suspended. {$this->reason}", + 'icon' => 'hosting', + 'url' => route('hosting.accounts.show', $this->account), + ]; + } +} diff --git a/app/Notifications/Mini/MiniAlertNotification.php b/app/Notifications/Mini/MiniAlertNotification.php new file mode 100644 index 0000000..954e951 --- /dev/null +++ b/app/Notifications/Mini/MiniAlertNotification.php @@ -0,0 +1,42 @@ + $extra + */ + public function __construct( + private readonly string $title, + private readonly string $message, + private readonly string $icon, + private readonly ?string $url, + private readonly string $milestone, + private readonly array $extra = [], + ) {} + + /** @return list */ + public function via(object $notifiable): array + { + return ['database']; + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return array_merge([ + 'title' => $this->title, + 'message' => $this->message, + 'icon' => $this->icon, + 'url' => $this->url, + 'milestone' => $this->milestone, + ], $this->extra); + } +} diff --git a/app/Notifications/SslExpiringNotification.php b/app/Notifications/SslExpiringNotification.php new file mode 100644 index 0000000..25e2940 --- /dev/null +++ b/app/Notifications/SslExpiringNotification.php @@ -0,0 +1,47 @@ +subject("SSL Certificate Expiring Soon: {$this->domain->host}") + ->view('mail.notifications.ssl-expiring', [ + 'domain' => $this->domain, + 'daysUntilExpiry' => $this->daysUntilExpiry, + 'expiresAt' => $this->domain->ssl_expires_at, + 'manageUrl' => route('user.domains.show', $this->domain), + ]); + } + + public function toArray($notifiable): array + { + return [ + 'title' => 'SSL Certificate Expiring', + 'message' => "Your SSL certificate for {$this->domain->host} expires in {$this->daysUntilExpiry} days.", + 'icon' => 'warning', + 'url' => route('user.domains.show', $this->domain), + ]; + } +} diff --git a/app/Notifications/SslProvisionedNotification.php b/app/Notifications/SslProvisionedNotification.php new file mode 100644 index 0000000..7d8246d --- /dev/null +++ b/app/Notifications/SslProvisionedNotification.php @@ -0,0 +1,45 @@ +subject("SSL Certificate Active: {$this->domain->host}") + ->view('mail.notifications.ssl-provisioned', [ + 'domain' => $this->domain, + 'expiresAt' => $this->domain->ssl_expires_at, + 'websiteUrl' => "https://{$this->domain->host}", + ]); + } + + public function toArray($notifiable): array + { + return [ + 'title' => 'SSL Certificate Active', + 'message' => "Your SSL certificate for {$this->domain->host} is now active. Your site is secure!", + 'icon' => 'ssl', + 'url' => route('user.domains.show', $this->domain), + ]; + } +} diff --git a/app/Policies/QrCodePolicy.php b/app/Policies/QrCodePolicy.php new file mode 100644 index 0000000..d98f4f2 --- /dev/null +++ b/app/Policies/QrCodePolicy.php @@ -0,0 +1,24 @@ +canAccessAccount($qrCode->user_id); + } + + public function update(User $user, QrCode $qrCode): bool + { + return $user->canAccessAccount($qrCode->user_id); + } + + public function delete(User $user, QrCode $qrCode): bool + { + return $user->canAccessAccount($qrCode->user_id); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..ac11018 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,27 @@ +with(MobileTopbar::resolve()); + }); + + } +} diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php new file mode 100644 index 0000000..937b034 --- /dev/null +++ b/app/Services/Afia/AfiaService.php @@ -0,0 +1,117 @@ + $history + * @param array $context + */ + public function chat(string $message, array $history, array $context): string + { + if (! $this->enabled()) { + throw new RuntimeException('Afia is not configured.'); + } + + $provider = (string) config('afia.provider', 'openai'); + $model = (string) config('afia.model', 'gpt-4o-mini'); + $apiKey = (string) config('afia.api_key'); + + $messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]]; + foreach (array_slice($history, -8) as $turn) { + $role = ($turn['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user'; + $text = trim((string) ($turn['text'] ?? '')); + if ($text !== '') { + $messages[] = ['role' => $role, 'content' => $text]; + } + } + $messages[] = ['role' => 'user', 'content' => $message]; + + return $provider === 'anthropic' + ? $this->viaAnthropic($model, $apiKey, $messages) + : $this->viaOpenAi($model, $apiKey, $messages); + } + + private function viaOpenAi(string $model, string $apiKey, array $messages): string + { + $res = Http::withToken($apiKey)->acceptJson()->timeout(45) + ->post('https://api.openai.com/v1/chat/completions', [ + 'model' => $model, + 'temperature' => 0.3, + 'max_tokens' => 600, + 'messages' => $messages, + ]); + + if ($res->failed()) { + throw new RuntimeException('OpenAI request failed: '.$res->status()); + } + + return trim((string) $res->json('choices.0.message.content', '')); + } + + private function viaAnthropic(string $model, string $apiKey, array $messages): string + { + $system = $messages[0]['content']; + $turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system')); + + $res = Http::withHeaders([ + 'x-api-key' => $apiKey, + 'anthropic-version' => '2023-06-01', + ])->acceptJson()->timeout(45)->post('https://api.anthropic.com/v1/messages', [ + 'model' => $model, + 'max_tokens' => 600, + 'system' => $system, + 'messages' => array_map(fn ($m) => ['role' => $m['role'], 'content' => $m['content']], $turns), + ]); + + if ($res->failed()) { + throw new RuntimeException('Anthropic request failed: '.$res->status()); + } + + return trim((string) $res->json('content.0.text', '')); + } + + /** @param array $context */ + private function systemPrompt(array $context): string + { + $ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n"); + + return $this->miniSystemPrompt($ctx); + } + + private function miniSystemPrompt(string $ctx): string + { + return <<token())->acceptJson()->timeout(10)->get($this->base().$path, $query); + $res->throw(); + + return (array) $res->json(); + } + + public function balanceMinor(string $publicId): int + { + return (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0); + } + + public function canAfford(string $publicId, int $amountMinor): bool + { + return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false); + } + + public function serviceLedger(string $publicId, string $service): array + { + return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]); + } + + /** + * Debit the wallet. Returns true on success, false on insufficient balance + * (HTTP 402). Idempotent by $reference. + */ + public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): bool + { + $res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([ + 'user' => $publicId, + 'amount_minor' => $amountMinor, + 'service' => $service, + 'source' => $source, + 'reference' => $reference, + 'service_id' => $serviceId, + 'description' => $description, + ], static fn ($v) => $v !== null)); + + if ($res->status() === 402) { + return false; + } + $res->throw(); + + return true; + } + + public function credit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): array + { + $res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/credit', array_filter([ + 'user' => $publicId, + 'amount_minor' => $amountMinor, + 'service' => $service, + 'source' => $source, + 'reference' => $reference, + 'service_id' => $serviceId, + 'description' => $description, + ], static fn ($v) => $v !== null)); + $res->throw(); + + return (array) $res->json(); + } +} diff --git a/app/Services/Billing/PaystackService.php b/app/Services/Billing/PaystackService.php new file mode 100644 index 0000000..c8bc514 --- /dev/null +++ b/app/Services/Billing/PaystackService.php @@ -0,0 +1,171 @@ +settingValue('paystack_public_key', config('services.paystack.public_key', '')); + } + + public function initializeTransaction(array $payload): array + { + return $this->initializeTransactionWithCredentials($payload); + } + + public function initializeTransactionWithCredentials(array $payload, ?string $secretKey = null, ?string $baseUrl = null): array + { + $response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl) + ->post('/transaction/initialize', $payload); + + return $this->extractData($response, 'Failed to initialize Paystack transaction.'); + } + + public function verifyTransaction(string $reference): array + { + return $this->verifyTransactionWithCredentials($reference); + } + + public function verifyTransactionWithCredentials(string $reference, ?string $secretKey = null, ?string $baseUrl = null): array + { + $response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl) + ->get('/transaction/verify/' . urlencode($reference)); + + return $this->extractData($response, 'Failed to verify Paystack transaction.'); + } + + public function verifyWebhookSignature(string $rawBody, ?string $signature): bool + { + $secret = (string) $this->settingValue('paystack_webhook_secret', config('services.paystack.webhook_secret')); + if ($secret === '') { + $secret = (string) $this->settingValue('paystack_secret_key', config('services.paystack.secret_key')); + } + if ($secret === '' || !$signature) { + return false; + } + + $computed = hash_hmac('sha512', $rawBody, $secret); + return hash_equals($computed, $signature); + } + + protected function request(?string $secretKey = null, ?string $baseUrl = null) + { + $resolvedBaseUrl = rtrim((string) ($baseUrl ?: $this->settingValue('paystack_base_url', config('services.paystack.base_url', 'https://api.paystack.co'))), '/'); + $secret = $this->normalizeSecretKey( + (string) ($secretKey ?: $this->settingValue('paystack_secret_key', config('services.paystack.secret_key'))) + ); + + return Http::baseUrl($resolvedBaseUrl) + ->withToken($secret) + ->acceptJson() + ->asJson(); + } + + protected function settingsConnection(): string + { + return (string) config('billing.platform_settings_connection', 'platform'); + } + + protected function settingValue(string $key, mixed $fallback = null): mixed + { + try { + $connection = $this->settingsConnection(); + + if (! Schema::connection($connection)->hasTable('platform_settings')) { + return $fallback; + } + + $setting = PlatformSetting::query() + ->where('key', $key) + ->where('is_active', true) + ->first(); + + return $setting ? ($setting->value['value'] ?? $fallback) : $fallback; + } catch (\Throwable) { + return $fallback; + } + } + + protected function extractData(Response $response, string $message): array + { + if ($response->failed()) { + throw new RuntimeException($message); + } + + $json = $response->json(); + if (!is_array($json) || !($json['status'] ?? false)) { + throw new RuntimeException((string) ($json['message'] ?? $message)); + } + + $data = $json['data'] ?? null; + return is_array($data) ? $data : []; + } + + /** + * List supported banks / mobile money providers. + * @param string $country e.g. 'ghana', 'nigeria' + * @param string $type 'nuban', 'mobile_money', or '' for all + */ + public function listBanks(string $country = 'ghana', string $type = '', ?string $currency = null): array + { + $query = ['perPage' => 200]; + if ($currency !== null && $currency !== '') { + $query['currency'] = strtoupper($currency); + } else { + $query['country'] = $country; + } + if ($type !== '') { + $query['type'] = $type; + } + $response = $this->request()->get('/bank', $query); + $json = $response->json(); + return is_array($json['data'] ?? null) ? $json['data'] : []; + } + + /** + * Create a transfer recipient (bank account or mobile money). + * @param array{type: string, name: string, account_number: string, bank_code: string, currency?: string} $payload + */ + public function createTransferRecipient(array $payload): array + { + $response = $this->request()->post('/transferrecipient', $payload); + return $this->extractData($response, 'Failed to create transfer recipient.'); + } + + /** + * Initiate a transfer to a recipient. + * @param array{source: string, amount: int, recipient: string, reason?: string, reference?: string} $payload + */ + public function initiateTransfer(array $payload): array + { + $response = $this->request()->post('/transfer', $payload); + return $this->extractData($response, 'Failed to initiate transfer.'); + } + + /** + * Verify a transfer by reference. + */ + public function verifyTransfer(string $reference): array + { + $response = $this->request()->get('/transfer/verify/' . urlencode($reference)); + return $this->extractData($response, 'Failed to verify transfer.'); + } + + protected function normalizeSecretKey(string $secret): string + { + $normalized = trim($secret); + + if (str_starts_with(strtolower($normalized), 'bearer ')) { + $normalized = trim(substr($normalized, 7)); + } + + return $normalized; + } +} diff --git a/app/Services/Billing/SmsService.php b/app/Services/Billing/SmsService.php new file mode 100644 index 0000000..8be3d00 --- /dev/null +++ b/app/Services/Billing/SmsService.php @@ -0,0 +1,43 @@ +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()]); + } + } +} diff --git a/app/Services/Crm/CrmClient.php b/app/Services/Crm/CrmClient.php new file mode 100644 index 0000000..e7d816a --- /dev/null +++ b/app/Services/Crm/CrmClient.php @@ -0,0 +1,70 @@ +get('customers', $filters); + } + + public function products(array $filters = []): array + { + return $this->get('products', $filters); + } + + public function pushTimeline(array $data): array + { + return $this->post('timeline', $data); + } + + private function client(): PendingRequest + { + return Http::baseUrl((string) config('crm.url')) + ->withToken((string) config('crm.key')) + ->acceptJson() + ->asJson() + ->connectTimeout(10) + ->timeout(20); + } + + private function get(string $path, array $query = []): array + { + return $this->handle(fn () => $this->client()->get($path, [...$query, 'owner' => $this->owner])); + } + + private function post(string $path, array $data): array + { + return $this->handle(fn () => $this->client()->post($path, [...$data, 'owner' => $this->owner])); + } + + private function handle(callable $request): array + { + try { + $response = $request(); + } catch (ConnectionException) { + throw ValidationException::withMessages([ + 'crm' => ['Could not reach the CRM service. Please try again in a moment.'], + ]); + } + + if ($response->failed()) { + abort($response->status() === 404 ? 404 : 502, 'CRM service error.'); + } + + return (array) $response->json(); + } +} diff --git a/app/Services/CrossApp/CrossAppLinkService.php b/app/Services/CrossApp/CrossAppLinkService.php new file mode 100644 index 0000000..de442d5 --- /dev/null +++ b/app/Services/CrossApp/CrossAppLinkService.php @@ -0,0 +1,33 @@ +loadMissing('lines'); + + $lines = $sale->lines->map(fn ($line) => [ + 'description' => $line->name, + 'quantity' => (float) $line->quantity, + 'unit_price' => number_format($line->unit_price_minor / 100, 2, '.', ''), + ])->values()->all(); + + $prefill = CrmPrefillCodec::encode([ + 'kind' => 'pos_sale', + 'crm_customer_id' => $sale->crm_customer_id, + 'client_name' => $sale->customer_name ?: 'Walk-in customer', + 'client_email' => $sale->customer_email, + 'notes' => 'Receipt for POS sale '.$sale->reference, + 'payment_enabled' => false, + 'lines' => $lines, + ]); + + return LadillAppUrl::connect('invoice', '/invoices/create?prefill='.urlencode($prefill)); + } +} diff --git a/app/Services/Identity/IdentityClient.php b/app/Services/Identity/IdentityClient.php new file mode 100644 index 0000000..6033f57 --- /dev/null +++ b/app/Services/Identity/IdentityClient.php @@ -0,0 +1,57 @@ + */ + public function mailboxLinkStatus(string $publicId): array + { + $response = $this->request()->get($this->url('/identity/mailbox-link'), [ + 'user' => $publicId, + ]); + + $response->throw(); + + return (array) $response->json('data', []); + } + + /** @return array */ + public function linkMailbox(string $publicId, string $mailboxAddress): array + { + $response = $this->request()->put($this->url('/identity/mailbox-link'), [ + 'user' => $publicId, + 'mailbox_address' => $mailboxAddress, + ]); + + $response->throw(); + + return (array) $response->json('data', []); + } + + /** @return array */ + public function unlinkMailbox(string $publicId): array + { + $response = $this->request()->delete($this->url('/identity/mailbox-link'), [ + 'user' => $publicId, + ]); + + $response->throw(); + + return (array) $response->json('data', []); + } + + private function request() + { + return Http::withToken((string) config('identity.api_key')) + ->acceptJson() + ->timeout(15); + } + + private function url(string $path): string + { + return rtrim((string) config('identity.api_url'), '/').$path; + } +} diff --git a/app/Services/Import/CrmProductImportService.php b/app/Services/Import/CrmProductImportService.php new file mode 100644 index 0000000..8c35560 --- /dev/null +++ b/app/Services/Import/CrmProductImportService.php @@ -0,0 +1,58 @@ +products(['active' => 1, 'per_page' => 500]); + $rows = (array) ($response['data'] ?? []); + + if ($rows === []) { + throw new RuntimeException('No active products found in CRM.'); + } + + $imported = 0; + $updated = 0; + + foreach ($rows as $row) { + $name = trim((string) ($row['name'] ?? '')); + if ($name === '') { + continue; + } + + $sku = isset($row['sku']) && $row['sku'] !== '' ? (string) $row['sku'] : null; + $matchQuery = PosProduct::owned($ownerRef)->where('name', $name); + if ($sku) { + $matchQuery = PosProduct::owned($ownerRef)->where(fn ($q) => $q->where('sku', $sku)->orWhere('name', $name)); + } + $existing = $matchQuery->first(); + + $attrs = [ + 'name' => $name, + 'sku' => $sku, + 'price_minor' => max(0, (int) ($row['unit_price_minor'] ?? 0)), + 'currency' => strtoupper((string) ($row['currency'] ?? config('pos.default_currency', 'GHS'))), + 'is_active' => (bool) ($row['active'] ?? true), + ]; + + if ($existing) { + $existing->update($attrs); + $updated++; + } else { + PosProduct::create([...$attrs, 'owner_ref' => $ownerRef]); + $imported++; + } + } + + return compact('imported', 'updated'); + } +} diff --git a/app/Services/Import/MerchantCatalogImportService.php b/app/Services/Import/MerchantCatalogImportService.php new file mode 100644 index 0000000..6d0bbec --- /dev/null +++ b/app/Services/Import/MerchantCatalogImportService.php @@ -0,0 +1,98 @@ +connectionReady($connection)) { + throw new RuntimeException('Merchant database connection is not available.'); + } + + $storefronts = DB::connection($connection) + ->table('qr_codes') + ->join('users', 'users.id', '=', 'qr_codes.user_id') + ->where('users.public_id', $ownerRef) + ->where('qr_codes.is_active', true) + ->whereIn('qr_codes.type', ['shop', 'menu']) + ->select('qr_codes.id', 'qr_codes.payload', 'qr_codes.label') + ->get(); + + if ($storefronts->isEmpty()) { + throw new RuntimeException('No active Merchant storefronts found for this account.'); + } + + $imported = 0; + $updated = 0; + + foreach ($storefronts as $storefront) { + $payload = json_decode((string) $storefront->payload, true); + $content = is_array($payload) ? (array) ($payload['content'] ?? []) : []; + $sections = (array) ($content['sections'] ?? []); + + foreach ($sections as $section) { + foreach ((array) ($section['items'] ?? []) as $item) { + $name = trim((string) ($item['name'] ?? '')); + $priceGhs = (float) ($item['price'] ?? 0); + if ($name === '' || $priceGhs <= 0) { + continue; + } + + $priceMinor = (int) round($priceGhs * 100); + $sku = 'm'.$storefront->id.'-'.substr(md5($name), 0, 8); + + $existing = PosProduct::owned($ownerRef)->where('sku', $sku)->first(); + $attrs = [ + 'name' => $name, + 'sku' => $sku, + 'price_minor' => $priceMinor, + 'currency' => config('pos.default_currency', 'GHS'), + 'is_active' => true, + ]; + + if ($existing) { + $existing->update($attrs); + $updated++; + } else { + PosProduct::create([...$attrs, 'owner_ref' => $ownerRef]); + $imported++; + } + } + } + } + + if ($imported === 0 && $updated === 0) { + throw new RuntimeException('No catalog items found in Merchant storefronts.'); + } + + return [ + 'imported' => $imported, + 'updated' => $updated, + 'storefronts' => $storefronts->count(), + ]; + } + + private function connectionReady(string $connection): bool + { + try { + DB::connection($connection)->getPdo(); + + return true; + } catch (\Throwable) { + return false; + } + } +} diff --git a/app/Services/Mini/AutoWithdrawService.php b/app/Services/Mini/AutoWithdrawService.php new file mode 100644 index 0000000..d5756ca --- /dev/null +++ b/app/Services/Mini/AutoWithdrawService.php @@ -0,0 +1,148 @@ +getOrCreateQrSetting(); + $thresholdMinor = $settings->auto_withdraw_amount_minor; + + if ($thresholdMinor === null || $thresholdMinor < 100) { + return false; + } + + try { + $balanceMinor = $this->billing->balanceMinor($user->public_id); + } catch (Throwable $e) { + Log::warning('Auto-withdraw balance check failed', [ + 'user' => $user->public_id, + 'error' => $e->getMessage(), + ]); + + return false; + } + + if ($balanceMinor < $thresholdMinor) { + return false; + } + + if (! $this->hasPayoutAccount($user)) { + return false; + } + + if ($this->hasPendingWithdrawal($user)) { + return false; + } + + $amountMajor = round($balanceMinor / 100, 2); + if ($amountMajor < 1) { + return false; + } + + try { + $response = $this->identitySend('POST', '/api/identity/wallet/withdraw', [ + 'user' => $user->public_id, + 'amount' => $amountMajor, + ]); + + if (! $response->successful()) { + Log::warning('Auto-withdraw failed', [ + 'user' => $user->public_id, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return false; + } + + $this->notifications->withdrawalSubmitted($user, $amountMajor); + + return true; + } catch (Throwable $e) { + Log::warning('Auto-withdraw exception', [ + 'user' => $user->public_id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + public function processAll(): int + { + $count = 0; + + QrSetting::query() + ->whereNotNull('auto_withdraw_amount_minor') + ->where('auto_withdraw_amount_minor', '>=', 100) + ->with('user') + ->chunkById(50, function ($settings) use (&$count) { + foreach ($settings as $setting) { + $user = $setting->user; + if ($user && $this->attemptForUser($user)) { + $count++; + } + } + }); + + return $count; + } + + private function hasPayoutAccount(User $user): bool + { + $response = $this->identitySend( + 'GET', + '/api/identity/payout-account?user='.urlencode((string) $user->public_id), + [], + ); + + if (! $response->successful()) { + return false; + } + + return filled($response->json('data.payout_account.account_number')); + } + + private function hasPendingWithdrawal(User $user): bool + { + $response = $this->identitySend( + 'GET', + '/api/identity/wallet/withdrawals?user='.urlencode((string) $user->public_id), + [], + ); + + if (! $response->successful()) { + return false; + } + + return collect($response->json('data', [])) + ->contains(fn ($withdrawal) => in_array($withdrawal['status'] ?? '', ['pending', 'processing'], true)); + } + + private function identitySend(string $method, string $path, array $payload): HttpResponse + { + return Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/')) + ->withToken((string) config('services.ladill_identity.key')) + ->connectTimeout(10) + ->timeout(20) + ->acceptJson() + ->asJson() + ->send($method, $path, ['json' => $payload]); + } +} diff --git a/app/Services/Mini/MiniPaymentService.php b/app/Services/Mini/MiniPaymentService.php new file mode 100644 index 0000000..0e371ce --- /dev/null +++ b/app/Services/Mini/MiniPaymentService.php @@ -0,0 +1,198 @@ +type !== QrCode::TYPE_PAYMENT) { + throw new RuntimeException('This QR is not a payment code.'); + } + + $amountGhs = round((float) ($data['amount'] ?? 0), 2); + if ($amountGhs <= 0) { + throw new RuntimeException('Enter an amount greater than zero.'); + } + + $qrCode->loadMissing('user'); + $amountMinor = (int) round($amountGhs * 100); + $reference = 'MINP-'.strtoupper(Str::random(16)); + $businessName = $qrCode->content()['business_name'] ?? $qrCode->label ?? 'Payment'; + + $payment = MiniPayment::create([ + 'qr_code_id' => $qrCode->id, + 'user_id' => $qrCode->user_id, + 'reference' => $reference, + 'amount_minor' => $amountMinor, + 'currency' => $qrCode->content()['currency'] ?? 'GHS', + 'payer_name' => null, + 'payer_email' => null, + 'payer_phone' => null, + 'payer_note' => null, + 'status' => MiniPayment::STATUS_PENDING, + 'payment_reference' => null, + ]); + + $payOrder = $this->pay->createCheckout([ + 'merchant' => $qrCode->user->public_id, + 'fee_tier' => 'payments', + 'source_service' => 'mini', + 'source_ref' => (string) $qrCode->id, + 'callback_url' => route('qr.public.payment.callback', ['shortCode' => $qrCode->short_code]), + 'line_items' => [ + [ + 'name' => $businessName, + 'unit_price_minor' => $amountMinor, + 'quantity' => 1, + ], + ], + 'metadata' => [ + 'mini_payment_id' => $payment->id, + 'mini_reference' => $reference, + 'qr_code_id' => $qrCode->id, + ], + ]); + + $payment->update([ + 'pay_order_id' => $payOrder['id'] ?? null, + 'payment_reference' => $payOrder['reference'], + 'platform_fee_minor' => $payOrder['platform_fee_minor'] ?? null, + 'merchant_amount_minor' => $payOrder['merchant_amount_minor'] ?? null, + ]); + + $checkoutUrl = (string) ($payOrder['checkout_url'] ?? ''); + if ($checkoutUrl === '') { + throw new RuntimeException('Could not start checkout. Please try again.'); + } + + return [ + 'payment' => $payment->fresh(), + 'checkout_url' => $checkoutUrl, + ]; + } + + public function complete(string $paymentReference): MiniPayment + { + if (str_starts_with($paymentReference, 'LP-')) { + return $this->completeLadillPay($paymentReference); + } + + return $this->completeLegacy($paymentReference); + } + + private function completeLadillPay(string $reference): MiniPayment + { + $payment = MiniPayment::where('payment_reference', $reference) + ->where('status', MiniPayment::STATUS_PENDING) + ->firstOrFail(); + + $payOrder = $this->pay->verify($reference); + + $payment->update([ + 'status' => MiniPayment::STATUS_PAID, + 'amount_minor' => (int) ($payOrder['amount_minor'] ?? $payment->amount_minor), + 'platform_fee_minor' => (int) ($payOrder['platform_fee_minor'] ?? 0), + 'merchant_amount_minor' => (int) ($payOrder['merchant_amount_minor'] ?? 0), + 'pay_order_id' => $payOrder['id'] ?? $payment->pay_order_id, + 'paid_at' => now(), + 'metadata' => array_merge((array) $payment->metadata, ['ladill_pay' => $payOrder]), + ]); + + $payment = $payment->fresh(['qrCode', 'merchant']); + $this->notifyPayer($payment); + $this->notifications->paymentReceived($payment); + $this->autoWithdraw->attemptForUser($payment->merchant); + + return $payment; + } + + /** Legacy MIN-* references before Ladill Pay migration. */ + private function completeLegacy(string $paymentReference): MiniPayment + { + $payment = MiniPayment::where('payment_reference', $paymentReference) + ->where('status', MiniPayment::STATUS_PENDING) + ->firstOrFail(); + + $data = $this->paystack->verifyTransaction($paymentReference); + + if (($data['status'] ?? '') !== 'success') { + $payment->update(['status' => MiniPayment::STATUS_FAILED]); + throw new RuntimeException('Payment was not successful.'); + } + + $paidMinor = (int) ($data['amount'] ?? $payment->amount_minor); + $platformFeeMinor = (int) round($paidMinor * MiniPayment::PLATFORM_FEE_RATE); + $merchantMinor = $paidMinor - $platformFeeMinor; + + $payment->update([ + 'status' => MiniPayment::STATUS_PAID, + 'amount_minor' => $paidMinor, + 'platform_fee_minor' => $platformFeeMinor, + 'merchant_amount_minor' => $merchantMinor, + 'paid_at' => now(), + 'metadata' => array_merge((array) $payment->metadata, ['paystack' => $data, 'legacy' => true]), + ]); + + $businessName = $payment->qrCode?->content()['business_name'] ?? $payment->qrCode?->label ?? 'Payment QR'; + $this->billing->credit( + $payment->merchant->public_id, + $merchantMinor, + 'mini', + 'pay', + $paymentReference, + $payment->id, + sprintf('Payment via %s', $businessName), + ); + + $payment = $payment->fresh(['qrCode', 'merchant']); + $this->notifyPayer($payment); + $this->notifications->paymentReceived($payment); + $this->autoWithdraw->attemptForUser($payment->merchant); + + return $payment; + } + + private function notifyPayer(MiniPayment $payment): void + { + if (! $payment->payer_phone) { + return; + } + + $businessName = $payment->qrCode?->content()['business_name'] ?? $payment->qrCode?->label ?? 'Payment QR'; + + $this->sms->send( + $payment->payer_phone, + sprintf( + 'Payment of %s %s to %s confirmed. Ref: %s', + $payment->currency, + number_format($payment->amount_minor / 100, 2), + $businessName, + $payment->reference + ) + ); + } +} diff --git a/app/Services/Notifications/FcmService.php b/app/Services/Notifications/FcmService.php new file mode 100644 index 0000000..ea3e2ea --- /dev/null +++ b/app/Services/Notifications/FcmService.php @@ -0,0 +1,151 @@ +isConfigured()) { + return self::FAILED; + } + + $projectId = (string) config('services.fcm.project_id'); + $accessToken = $this->accessToken(); + + $response = Http::withToken($accessToken) + ->timeout(15) + ->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", [ + 'message' => [ + 'token' => $fcmToken, + 'notification' => compact('title', 'body'), + 'data' => array_map('strval', $data), + 'android' => [ + 'priority' => 'high', + 'notification' => [ + 'sound' => 'default', + 'channel_id' => 'payments', + ], + ], + ], + ]); + + if ($response->successful()) { + return self::DELIVERED; + } + + $outcome = $this->classifyFailure($response); + + Log::warning('FCM push failed', [ + 'token' => substr($fcmToken, 0, 20).'…', + 'status' => $response->status(), + 'outcome' => $outcome, + 'body' => $response->body(), + ]); + + return $outcome; + } + + /** + * @return self::INVALID_TOKEN|self::FAILED + */ + private function classifyFailure(Response $response): string + { + $statusCode = $response->status(); + $body = strtolower((string) $response->body()); + $apiStatus = strtoupper((string) data_get($response->json(), 'error.status', '')); + + if ($statusCode === 404 + || $apiStatus === 'NOT_FOUND' + || str_contains($body, 'not_found') + || str_contains($body, 'requested entity was not found') + || str_contains($body, 'registration token is not a valid') + || str_contains($body, 'invalid registration') + || str_contains($body, 'unregistered')) { + return self::INVALID_TOKEN; + } + + return self::FAILED; + } + + private function accessToken(): string + { + return Cache::remember('mini_fcm_access_token', 3300, function (): string { + $sa = $this->serviceAccount(); + $jwt = $this->buildJwt($sa); + + $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]); + + if (! $response->successful()) { + throw new \RuntimeException('FCM OAuth2 token exchange failed: '.$response->body()); + } + + return $response->json('access_token'); + }); + } + + private function buildJwt(array $sa): string + { + $header = $this->base64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $now = time(); + $payload = $this->base64url(json_encode([ + 'iss' => $sa['client_email'], + 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', + 'aud' => 'https://oauth2.googleapis.com/token', + 'iat' => $now, + 'exp' => $now + 3600, + ])); + + $message = "{$header}.{$payload}"; + openssl_sign($message, $signature, $sa['private_key'], 'sha256WithRSAEncryption'); + + return "{$message}.{$this->base64url($signature)}"; + } + + private function base64url(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** @return array */ + private function serviceAccount(): array + { + $value = config('services.fcm.service_account_json'); + + if (blank($value)) { + throw new \RuntimeException('FCM service account JSON is not configured.'); + } + + $json = is_file($value) ? file_get_contents($value) : $value; + $decoded = json_decode((string) $json, true); + + if (! is_array($decoded) || empty($decoded['private_key'])) { + throw new \RuntimeException('FCM service account JSON is invalid or missing private_key.'); + } + + return $decoded; + } +} diff --git a/app/Services/Notifications/MiniNotificationService.php b/app/Services/Notifications/MiniNotificationService.php new file mode 100644 index 0000000..ac89daf --- /dev/null +++ b/app/Services/Notifications/MiniNotificationService.php @@ -0,0 +1,177 @@ +loadMissing(['qrCode', 'merchant']); + $merchant = $payment->merchant; + + if (! $merchant) { + return; + } + + $businessName = $payment->qrCode?->content()['business_name'] ?? $payment->qrCode?->label ?? 'Payment QR'; + $amount = $this->formatMoney($payment->currency, $payment->merchant_amount_minor ?: $payment->amount_minor); + $title = 'Payment received'; + $message = sprintf('%s paid %s to %s.', $this->payerLabel($payment), $amount, $businessName); + + $this->alert( + $merchant, + $title, + $message, + 'payment', + route('mini.payments.index'), + 'payment_received', + [ + 'payment_id' => $payment->id, + 'reference' => $payment->reference, + 'amount_minor' => $payment->amount_minor, + 'currency' => $payment->currency, + ], + respectPayoutPref: false, + ); + } + + public function withdrawalSubmitted(User $user, float $amountMajor, string $currency = 'GHS'): void + { + if (! $this->payoutAlertsEnabled($user)) { + return; + } + + $amount = sprintf('%s %s', $currency, number_format($amountMajor, 2)); + $this->alert( + $user, + 'Withdrawal requested', + sprintf('Your withdrawal of %s has been submitted and is being processed.', $amount), + 'payout', + route('mini.payouts'), + 'withdrawal_submitted', + ['amount_major' => $amountMajor, 'currency' => $currency], + respectPayoutPref: false, + ); + } + + public function walletCredited(User $user, int $amountMinor, string $currency, string $description): void + { + if (! $this->payoutAlertsEnabled($user)) { + return; + } + + $amount = $this->formatMoney($currency, $amountMinor); + $this->alert( + $user, + 'Wallet credited', + sprintf('%s added to your wallet. %s', $amount, $description), + 'wallet', + route('mini.payouts'), + 'wallet_credited', + ['amount_minor' => $amountMinor, 'currency' => $currency], + respectPayoutPref: false, + ); + } + + /** + * @param array $extra + */ + private function alert( + User $user, + string $title, + string $message, + string $icon, + ?string $url, + string $milestone, + array $extra = [], + bool $respectPayoutPref = true, + ): void { + if ($respectPayoutPref && ! $this->payoutAlertsEnabled($user)) { + return; + } + + $user->notify(new MiniAlertNotification($title, $message, $icon, $url, $milestone, $extra)); + $this->pushToDevices($user, $title, $message, array_merge($extra, [ + 'milestone' => $milestone, + 'url' => $url, + ])); + } + + /** @param array $data */ + private function pushToDevices(User $user, string $title, string $body, array $data = []): void + { + if (! $this->fcm->isConfigured() || ! $this->shouldAttemptFcm($user)) { + return; + } + + $windowDays = (int) config('notifications.fcm_active_user_within_days', 60); + $cutoff = now()->subDays($windowDays); + + $tokens = $user->pushTokens() + ->where(function ($query) use ($cutoff) { + $query->where('last_seen_at', '>=', $cutoff) + ->orWhere('updated_at', '>=', $cutoff); + }) + ->get(); + + foreach ($tokens as $device) { + try { + $outcome = $this->fcm->send($device->token, $title, $body, $data); + + if ($outcome === FcmService::INVALID_TOKEN) { + $device->delete(); + } + } catch (\Throwable $e) { + Log::warning('Mini push failed', [ + 'user_id' => $user->id, + 'error' => $e->getMessage(), + ]); + } + } + } + + private function shouldAttemptFcm(User $user): bool + { + if ($user->pushTokens()->doesntExist()) { + return false; + } + + $windowDays = (int) config('notifications.fcm_active_user_within_days', 60); + + if ($user->last_app_active_at === null) { + return false; + } + + return $user->last_app_active_at->gte(now()->subDays($windowDays)); + } + + private function payoutAlertsEnabled(User $user): bool + { + return (bool) ($user->getOrCreateQrSetting()->notify_payouts ?? true); + } + + private function formatMoney(string $currency, int $amountMinor): string + { + return sprintf('%s %s', $currency, number_format($amountMinor / 100, 2)); + } + + private function payerLabel(MiniPayment $payment): string + { + if ($payment->payer_name) { + return $payment->payer_name; + } + + if ($payment->payer_phone) { + return $payment->payer_phone; + } + + return 'A customer'; + } +} diff --git a/app/Services/Pay/PayClient.php b/app/Services/Pay/PayClient.php new file mode 100644 index 0000000..19c94b5 --- /dev/null +++ b/app/Services/Pay/PayClient.php @@ -0,0 +1,50 @@ + $payload */ + public function createCheckout(array $payload): array + { + $res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/checkouts', $payload); + $res->throw(); + + return (array) $res->json(); + } + + public function verify(string $reference): array + { + $res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/checkouts/verify', [ + 'reference' => $reference, + ]); + $res->throw(); + + return (array) $res->json(); + } + + public function show(string $reference): array + { + $res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().'/orders/'.$reference); + $res->throw(); + + return (array) $res->json(); + } +} diff --git a/app/Services/Pos/PosLocationService.php b/app/Services/Pos/PosLocationService.php new file mode 100644 index 0000000..6e1557a --- /dev/null +++ b/app/Services/Pos/PosLocationService.php @@ -0,0 +1,16 @@ +firstOrCreate( + ['owner_ref' => $ownerRef, 'name' => 'Main register'], + ['currency' => config('pos.default_currency', 'GHS')], + ); + } +} diff --git a/app/Services/Pos/PosSaleService.php b/app/Services/Pos/PosSaleService.php new file mode 100644 index 0000000..cfd8e2a --- /dev/null +++ b/app/Services/Pos/PosSaleService.php @@ -0,0 +1,184 @@ + $lines + * @param array{customer_name?: ?string, customer_email?: ?string, customer_phone?: ?string, crm_customer_id?: ?int, location_id?: ?int, currency?: string} $meta + */ + public function createSale(User $merchant, array $lines, array $meta = []): PosSale + { + $normalized = $this->normalizeLines($lines); + if ($normalized === []) { + throw new RuntimeException('Add at least one item to the sale.'); + } + + $subtotal = collect($normalized)->sum('line_total_minor'); + if ($subtotal <= 0) { + throw new RuntimeException('Sale total must be greater than zero.'); + } + + $currency = strtoupper((string) ($meta['currency'] ?? config('pos.default_currency', 'GHS'))); + + $sale = PosSale::create([ + 'owner_ref' => $merchant->public_id, + 'location_id' => $meta['location_id'] ?? null, + 'reference' => 'POS-'.strtoupper(Str::random(12)), + 'status' => PosSale::STATUS_PENDING, + 'payment_method' => PosSale::METHOD_PAY, + 'customer_name' => $meta['customer_name'] ?? null, + 'customer_email' => $meta['customer_email'] ?? null, + 'customer_phone' => $meta['customer_phone'] ?? null, + 'crm_customer_id' => $meta['crm_customer_id'] ?? null, + 'subtotal_minor' => $subtotal, + 'total_minor' => $subtotal, + 'currency' => $currency, + ]); + + foreach ($normalized as $index => $line) { + PosSaleLine::create([ + 'pos_sale_id' => $sale->id, + 'product_id' => $line['product_id'] ?? null, + 'name' => $line['name'], + 'unit_price_minor' => $line['unit_price_minor'], + 'quantity' => $line['quantity'], + 'line_total_minor' => $line['line_total_minor'], + 'position' => $index, + ]); + } + + return $sale->fresh('lines'); + } + + /** + * @return array{sale: PosSale, checkout_url: string} + */ + public function initiatePayCheckout(PosSale $sale, User $merchant): array + { + if ($sale->isPaid()) { + throw new RuntimeException('This sale is already paid.'); + } + + $sale->load('lines'); + $callbackUrl = route('pos.sales.callback', $sale); + + $payOrder = $this->pay->createCheckout([ + 'merchant' => $merchant->public_id, + 'fee_tier' => 'sales', + 'source_service' => 'pos', + 'source_ref' => (string) $sale->id, + 'callback_url' => $callbackUrl, + 'customer_name' => $sale->customer_name, + 'customer_email' => $sale->customer_email, + 'customer_phone' => $sale->customer_phone, + 'line_items' => $sale->lines->map(fn (PosSaleLine $line) => [ + 'name' => $line->name, + 'unit_price_minor' => $line->unit_price_minor, + 'quantity' => $line->quantity, + ])->all(), + 'metadata' => [ + 'pos_sale_id' => $sale->id, + 'pos_reference' => $sale->reference, + ], + ]); + + $checkoutUrl = (string) ($payOrder['checkout_url'] ?? ''); + if ($checkoutUrl === '') { + throw new RuntimeException('Could not start checkout. Please try again.'); + } + + $sale->forceFill([ + 'payment_method' => PosSale::METHOD_PAY, + 'pay_order_id' => $payOrder['id'] ?? null, + 'payment_reference' => $payOrder['reference'] ?? null, + ])->save(); + + return [ + 'sale' => $sale->fresh('lines'), + 'checkout_url' => $checkoutUrl, + ]; + } + + public function recordCashPayment(PosSale $sale): PosSale + { + if ($sale->isPaid()) { + throw new RuntimeException('This sale is already paid.'); + } + + $sale->forceFill([ + 'payment_method' => PosSale::METHOD_CASH, + 'status' => PosSale::STATUS_PAID, + 'paid_at' => now(), + ])->save(); + + $sale = $sale->fresh('lines'); + $this->timeline->pushPaidSale($sale); + + return $sale; + } + + public function completePayCheckout(string $paymentReference): PosSale + { + $sale = PosSale::query() + ->where('payment_reference', $paymentReference) + ->where('status', PosSale::STATUS_PENDING) + ->firstOrFail(); + + $payOrder = $this->pay->verify($paymentReference); + + $sale->forceFill([ + 'status' => PosSale::STATUS_PAID, + 'total_minor' => (int) ($payOrder['amount_minor'] ?? $sale->total_minor), + 'pay_order_id' => $payOrder['id'] ?? $sale->pay_order_id, + 'paid_at' => now(), + ])->save(); + + $sale = $sale->fresh('lines'); + $this->timeline->pushPaidSale($sale); + + return $sale; + } + + /** + * @param list $lines + * @return list + */ + private function normalizeLines(array $lines): array + { + $out = []; + + foreach ($lines as $line) { + $qty = max(1, (int) ($line['quantity'] ?? 1)); + $unit = max(0, (int) ($line['unit_price_minor'] ?? 0)); + $name = trim((string) ($line['name'] ?? '')); + + if ($name === '' || $unit <= 0) { + continue; + } + + $out[] = [ + 'product_id' => $line['product_id'] ?? null, + 'name' => $name, + 'unit_price_minor' => $unit, + 'quantity' => $qty, + 'line_total_minor' => $unit * $qty, + ]; + } + + return $out; + } +} diff --git a/app/Services/Pos/PosTimelineService.php b/app/Services/Pos/PosTimelineService.php new file mode 100644 index 0000000..c074759 --- /dev/null +++ b/app/Services/Pos/PosTimelineService.php @@ -0,0 +1,39 @@ +status !== PosSale::STATUS_PAID) { + return; + } + + try { + CrmClient::for($sale->owner_ref)->pushTimeline([ + 'event' => 'order.paid', + 'title' => 'POS sale '.$sale->reference, + 'external_id' => (string) $sale->id, + 'amount_minor' => (int) $sale->total_minor, + 'currency' => (string) $sale->currency, + 'url' => route('pos.sales.show', $sale), + 'customer_id' => $sale->crm_customer_id, + 'customer_email' => $sale->customer_email, + 'customer_phone' => $sale->customer_phone, + 'customer_name' => $sale->customer_name, + 'description' => ucfirst($sale->payment_method).' payment at register', + 'occurred_at' => ($sale->paid_at ?? now())->toIso8601String(), + ]); + } catch (\Throwable $e) { + Log::info('CRM timeline push skipped for POS sale', [ + 'sale_id' => $sale->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Qr/QrAnalyticsService.php b/app/Services/Qr/QrAnalyticsService.php new file mode 100644 index 0000000..2a51a9d --- /dev/null +++ b/app/Services/Qr/QrAnalyticsService.php @@ -0,0 +1,91 @@ + + */ + public function summaryFor(QrCode $qrCode): array + { + $now = now(); + $events = QrScanEvent::query()->where('qr_code_id', $qrCode->id); + + return [ + 'total_scans' => (int) $qrCode->scans_total, + 'unique_scans' => (int) $qrCode->unique_scans_total, + 'scans_7d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(7))->count(), + 'scans_30d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(30))->count(), + 'last_scanned_at' => $qrCode->last_scanned_at, + ]; + } + + /** + * @return Collection + */ + public function dailyScans(QrCode $qrCode, int $days = 30): Collection + { + $start = now()->subDays($days - 1)->startOfDay(); + + $rows = QrScanEvent::query() + ->selectRaw('DATE(scanned_at) as scan_date, COUNT(*) as total') + ->where('qr_code_id', $qrCode->id) + ->where('scanned_at', '>=', $start) + ->groupBy('scan_date') + ->orderBy('scan_date') + ->get() + ->keyBy('scan_date'); + + $series = collect(); + for ($i = 0; $i < $days; $i++) { + $date = $start->copy()->addDays($i)->toDateString(); + $series->push((object) [ + 'date' => $date, + 'total' => (int) ($rows->get($date)?->total ?? 0), + ]); + } + + return $series; + } + + /** + * @return array + */ + public function breakdown(QrCode $qrCode, string $column, int $limit = 5): array + { + return QrScanEvent::query() + ->select($column, DB::raw('COUNT(*) as total')) + ->where('qr_code_id', $qrCode->id) + ->whereNotNull($column) + ->groupBy($column) + ->orderByDesc('total') + ->limit($limit) + ->get() + ->map(fn ($row) => [ + 'label' => (string) $row->{$column}, + 'total' => (int) $row->total, + ]) + ->all(); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + public function recentScans(QrCode $qrCode, int $limit = 20) + { + return QrScanEvent::query() + ->where('qr_code_id', $qrCode->id) + ->latest('scanned_at') + ->limit($limit) + ->get(); + } +} diff --git a/app/Services/Qr/QrCodeManagerService.php b/app/Services/Qr/QrCodeManagerService.php new file mode 100644 index 0000000..62b07f4 --- /dev/null +++ b/app/Services/Qr/QrCodeManagerService.php @@ -0,0 +1,556 @@ +qrWallet()->firstOrCreate( + [], + ['credit_balance' => 0, 'status' => QrWallet::STATUS_ACTIVE], + ); + } + + /** + * @param array $data + */ + public function create(User $user, array $data): QrCode + { + $wallet = $this->walletFor($user); + $type = (string) ($data['type'] ?? ''); + + if ($type !== QrCode::TYPE_PAYMENT && ! $wallet->canCreateQr()) { + throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.'); + } + + $validated = $this->payloadValidator->validateForCreate($type, $data); + $style = $type === QrCode::TYPE_PAYMENT + ? QrStyleDefaults::defaults() + : QrStyleDefaults::merge($data['style'] ?? null); + + return DB::transaction(function () use ($user, $wallet, $data, $type, $validated, $style) { + $documentId = null; + $content = $validated['content']; + + if ($type === QrCode::TYPE_DOCUMENT) { + $file = $data['document'] ?? null; + if (! $file instanceof UploadedFile) { + throw new RuntimeException('A PDF document is required for PDF QR codes.'); + } + $documentId = $this->storeDocument($user, $file, $data['label'] ?? 'Document')->id; + } + + if ($type === QrCode::TYPE_IMAGE) { + $images = $this->storeImages($user, $data); + $content['images'] = $images; + } + + if ($type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) { + $content['avatar_path'] = $this->storeVcardAvatar($user, $data['avatar']); + } + + if ($type === QrCode::TYPE_BOOK) { + $bookFile = $data['book_file'] ?? null; + if (! $bookFile instanceof UploadedFile) { + throw new RuntimeException('Upload the book file (PDF or EPUB).'); + } + $bookData = $this->storeBookFile($user, $bookFile); + $content['file_path'] = $bookData['path']; + $content['file_type'] = $bookData['type']; + $content['file_size'] = $bookData['size']; + + if (($data['cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeBookCover($user, $data['cover']); + } + } + + if ($type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) { + $content['icon_path'] = $this->storeMenuBrandImage($user, $data['app_icon'], 'app-icons'); + } + + if (in_array($type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) { + $content['sections'] = $this->injectItemImages($user, $content['sections'] ?? [], $data, $type); + if (($data['menu_logo'] ?? null) instanceof UploadedFile) { + $content['logo_path'] = $this->storeMenuBrandImage($user, $data['menu_logo'], 'menu-logos'); + } + if (($data['menu_cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeMenuBrandImage($user, $data['menu_cover'], 'menu-covers'); + } + } + + if ($type === QrCode::TYPE_BUSINESS) { + if (($data['business_logo'] ?? null) instanceof UploadedFile) { + $content['logo_path'] = $this->storeMenuBrandImage($user, $data['business_logo'], 'business-logos'); + } + if (($data['business_cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeMenuBrandImage($user, $data['business_cover'], 'business-covers'); + } + } + + if ($type === QrCode::TYPE_CHURCH) { + if (($data['church_logo'] ?? null) instanceof UploadedFile) { + $content['logo_path'] = $this->storeMenuBrandImage($user, $data['church_logo'], 'church-logos'); + } + if (($data['church_cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeMenuBrandImage($user, $data['church_cover'], 'church-covers'); + } + } + + if ($type === QrCode::TYPE_EVENT) { + if (($data['event_logo'] ?? null) instanceof UploadedFile) { + $content['logo_path'] = $this->storeMenuBrandImage($user, $data['event_logo'], 'event-logos'); + } + if (($data['event_cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeMenuBrandImage($user, $data['event_cover'], 'event-covers'); + } + } + + if ($type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeMenuBrandImage($user, $data['itinerary_cover'], 'itinerary-covers'); + } + + if ($type === QrCode::TYPE_PAYMENT && ($data['payment_logo'] ?? null) instanceof UploadedFile) { + $content['logo_path'] = $this->storeMenuBrandImage($user, $data['payment_logo'], 'payment-logos'); + } + + if ($style['logo_path'] === null && ($data['logo'] ?? null) instanceof UploadedFile && $type !== QrCode::TYPE_PAYMENT) { + $style['logo_path'] = $this->storeLogo($user, $data['logo']); + } + + $shortCode = (isset($data['custom_short_code']) && $data['custom_short_code'] !== '') + ? (string) $data['custom_short_code'] + : $this->generateUniqueShortCode(); + + $payload = [ + 'content' => $content, + 'style' => $style, + ]; + + // Freeze the WiFi auto-join payload so the printed code never changes on edit. + if ($type === QrCode::TYPE_WIFI) { + $payload['wifi_encoded'] = \App\Support\Qr\QrWifiPayload::encode($content); + } + + $qrCode = QrCode::create([ + 'user_id' => $user->id, + 'short_code' => $shortCode, + 'type' => $type, + 'label' => trim((string) $data['label']), + 'destination_url' => $validated['destination_url'], + 'qr_document_id' => $documentId, + 'payload' => $payload, + 'is_active' => true, + 'destination_updated_at' => now(), + ]); + + if ($type !== QrCode::TYPE_PAYMENT) { + $this->billing->debitForQrCreation($wallet, $qrCode); + } + $this->imageGenerator->generateAndStore($qrCode); + + return $qrCode->fresh(['document']); + }); + } + + /** + * @param array $data + */ + public function update(QrCode $qrCode, array $data): QrCode + { + $content = $qrCode->content(); + $style = $qrCode->style(); + $destinationUrl = $qrCode->destination_url; + $regenerate = false; + + if (isset($data['label']) && trim((string) $data['label']) !== '') { + $qrCode->label = trim((string) $data['label']); + } + + if (array_key_exists('is_active', $data)) { + $qrCode->is_active = (bool) $data['is_active']; + } + + $typeFields = array_merge($content, $data); + if ($this->hasContentChanges($qrCode, $data)) { + $validated = $this->payloadValidator->validateForUpdate($qrCode, $typeFields); + $content = $validated['content']; + $destinationUrl = $validated['destination_url']; + $qrCode->destination_updated_at = now(); + } + + if ($qrCode->isDocumentType() && isset($data['document']) && $data['document'] instanceof UploadedFile) { + $document = $this->storeDocument($qrCode->user, $data['document'], $data['label'] ?? $qrCode->label); + $qrCode->qr_document_id = $document->id; + $qrCode->destination_updated_at = now(); + } + + if ($qrCode->isImageType()) { + $newImages = $this->storeImages($qrCode->user, $data, false); + if ($newImages !== []) { + $content['images'] = array_merge($content['images'] ?? [], $newImages); + $qrCode->destination_updated_at = now(); + } + } + + if ($qrCode->type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) { + if ($oldPath = $content['avatar_path'] ?? null) { + Storage::disk('qr')->delete($oldPath); + } + $content['avatar_path'] = $this->storeVcardAvatar($qrCode->user, $data['avatar']); + } + + if ($qrCode->type === QrCode::TYPE_BOOK) { + if (($data['book_file'] ?? null) instanceof UploadedFile) { + $bookData = $this->storeBookFile($qrCode->user, $data['book_file']); + $content['file_path'] = $bookData['path']; + $content['file_type'] = $bookData['type']; + $content['file_size'] = $bookData['size']; + $qrCode->destination_updated_at = now(); + } + if (($data['cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeBookCover($qrCode->user, $data['cover']); + } + } + + if ($qrCode->type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) { + $content['icon_path'] = $this->storeMenuBrandImage($qrCode->user, $data['app_icon'], 'app-icons'); + } + + if (in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) { + $content['sections'] = $this->injectItemImages($qrCode->user, $content['sections'] ?? [], $data, $qrCode->type); + if (($data['menu_logo'] ?? null) instanceof UploadedFile) { + $content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_logo'], 'menu-logos'); + } + if (($data['menu_cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_cover'], 'menu-covers'); + } + } + + if ($qrCode->type === QrCode::TYPE_BUSINESS) { + if (($data['business_logo'] ?? null) instanceof UploadedFile) { + $content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['business_logo'], 'business-logos'); + } + if (($data['business_cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['business_cover'], 'business-covers'); + } + } + + if ($qrCode->type === QrCode::TYPE_CHURCH) { + if (($data['church_logo'] ?? null) instanceof UploadedFile) { + $content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_logo'], 'church-logos'); + } + if (($data['church_cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_cover'], 'church-covers'); + } + } + + if ($qrCode->type === QrCode::TYPE_PAYMENT && ($data['payment_logo'] ?? null) instanceof UploadedFile) { + $content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['payment_logo'], 'payment-logos'); + } + + if ($qrCode->type === QrCode::TYPE_EVENT) { + if (($data['event_logo'] ?? null) instanceof UploadedFile) { + $content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['event_logo'], 'event-logos'); + } + if (($data['event_cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['event_cover'], 'event-covers'); + } + } + + if ($qrCode->type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['itinerary_cover'], 'itinerary-covers'); + } + + if ($qrCode->type === QrCode::TYPE_PAYMENT) { + $style = QrStyleDefaults::defaults(); + } else { + if (isset($data['style']) && is_array($data['style'])) { + $style = QrStyleDefaults::merge(array_merge($style, $data['style'])); + $regenerate = true; + } + + if (($data['logo'] ?? null) instanceof UploadedFile) { + $style['logo_path'] = $this->storeLogo($qrCode->user, $data['logo']); + $regenerate = true; + } + + if (($data['remove_logo'] ?? false) && $style['logo_path']) { + $style['logo_path'] = null; + $regenerate = true; + } + } + + $qrCode->destination_url = $destinationUrl; + // Preserve frozen keys (e.g. wifi_encoded) so the printed WiFi code stays put. + $payload = (array) ($qrCode->payload ?? []); + $payload['content'] = $content; + $payload['style'] = $style; + $qrCode->payload = $payload; + $qrCode->save(); + + if ($regenerate) { + $this->imageGenerator->generateAndStore($qrCode); + } + + return $qrCode->fresh(['document']); + } + + /** + * Inject uploaded item images into the sections array. + * Form field: item_images[sectionIndex][itemIndex] (UploadedFile) + * + * @param array $sections + * @param array $data + * @return array + */ + private function injectItemImages(User $user, array $sections, array $data, string $type): array + { + $uploads = $data['item_images'] ?? []; + if (! is_array($uploads) || $uploads === []) { + return $sections; + } + + foreach ($uploads as $sIndex => $itemUploads) { + if (! is_array($itemUploads)) { + continue; + } + foreach ($itemUploads as $iIndex => $file) { + if (! ($file instanceof UploadedFile)) { + continue; + } + if (! isset($sections[$sIndex]['items'][$iIndex])) { + continue; + } + $mime = $file->getMimeType() ?: ''; + if (! str_starts_with($mime, 'image/')) { + continue; + } + $subdir = $type === QrCode::TYPE_SHOP ? 'shop-items' : 'menu-items'; + $ext = $file->getClientOriginalExtension() ?: 'jpg'; + $path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext; + $file->storeAs('', $path, 'qr'); + $sections[$sIndex]['items'][$iIndex]['image_path'] = $path; + } + } + + return $sections; + } + + /** @param array $data */ + private function hasContentChanges(QrCode $qrCode, array $data): bool + { + $keys = match ($qrCode->type) { + QrCode::TYPE_URL => ['destination_url'], + QrCode::TYPE_LINK_LIST => ['links'], + 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_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'], + QrCode::TYPE_SHOP => ['shop_title', 'sections', 'currency', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'], + QrCode::TYPE_APP => ['app_name', 'ios_url', 'android_url', 'web_url', 'app_icon'], + QrCode::TYPE_BOOK => ['book_title', 'author', 'description', 'price_ghs'], + QrCode::TYPE_WIFI => ['ssid', 'password', 'encryption', 'hidden'], + QrCode::TYPE_PAYMENT => ['business_name', 'branch_label', 'currency'], + default => [], + }; + + foreach ($keys as $key) { + if (array_key_exists($key, $data)) { + return true; + } + } + + return false; + } + + public function storeDocument(User $user, UploadedFile $file, string $title): QrDocument + { + $maxBytes = (int) config('qr.max_pdf_bytes', 104857600); + + if ($file->getSize() > $maxBytes) { + throw new RuntimeException('PDF must be 100 MB or smaller.'); + } + + $mime = $file->getMimeType() ?: ''; + if (! in_array($mime, ['application/pdf', 'application/x-pdf'], true)) { + throw new RuntimeException('Only PDF documents are supported.'); + } + + $uuid = Str::uuid()->toString(); + $path = $user->id . '/documents/' . $uuid . '.pdf'; + + $file->storeAs('', $path, 'qr'); + + return QrDocument::create([ + 'user_id' => $user->id, + 'title' => $title, + 'disk' => 'qr', + 'path' => $path, + 'mime_type' => 'application/pdf', + 'size_bytes' => (int) $file->getSize(), + ]); + } + + /** + * @param array $data + * @return list + */ + private function storeImages(User $user, array $data, bool $required = true): array + { + $files = []; + if (($data['image'] ?? null) instanceof UploadedFile) { + $files[] = $data['image']; + } + if (is_array($data['images'] ?? null)) { + foreach ($data['images'] as $file) { + if ($file instanceof UploadedFile) { + $files[] = $file; + } + } + } + + if ($required && $files === []) { + $this->payloadValidator->validateImageUpload(null); + } + + $stored = []; + foreach ($files as $index => $file) { + $this->payloadValidator->validateImageUpload($file); + $uuid = Str::uuid()->toString(); + $ext = $file->getClientOriginalExtension() ?: 'jpg'; + $path = $user->id . '/images/' . $uuid . '.' . $ext; + $file->storeAs('', $path, 'qr'); + $stored[] = [ + 'path' => $path, + 'title' => $file->getClientOriginalName() ?: ('Image ' . ($index + 1)), + ]; + } + + return $stored; + } + + private function storeVcardAvatar(User $user, UploadedFile $file): string + { + $mime = $file->getMimeType() ?: ''; + if (! str_starts_with($mime, 'image/')) { + throw new RuntimeException('Avatar must be an image file.'); + } + + $ext = $file->getClientOriginalExtension() ?: 'jpg'; + $path = $user->id . '/vcards/' . Str::uuid()->toString() . '.' . $ext; + $file->storeAs('', $path, 'qr'); + + return $path; + } + + private function storeBookFile(User $user, UploadedFile $file): array + { + $ext = strtolower($file->getClientOriginalExtension() ?: ''); + $mime = $file->getMimeType() ?: ''; + + if ($ext === 'pdf' || in_array($mime, ['application/pdf', 'application/x-pdf'], true)) { + $type = 'pdf'; + } elseif ($ext === 'epub' || str_contains($mime, 'epub')) { + $type = 'epub'; + } else { + throw new RuntimeException('Only PDF and EPUB files are supported for books.'); + } + + $path = $user->id . '/books/' . Str::uuid()->toString() . '.' . $type; + $file->storeAs('', $path, 'qr'); + + return ['path' => $path, 'type' => $type, 'size' => (int) $file->getSize()]; + } + + private function storeMenuBrandImage(User $user, UploadedFile $file, string $subdir): string + { + $mime = $file->getMimeType() ?: ''; + if (! str_starts_with($mime, 'image/')) { + throw new RuntimeException('Only image files are supported.'); + } + + $ext = $file->getClientOriginalExtension() ?: 'jpg'; + $path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext; + $file->storeAs('', $path, 'qr'); + + return $path; + } + + private function storeBookCover(User $user, UploadedFile $file): string + { + $mime = $file->getMimeType() ?: ''; + if (! str_starts_with($mime, 'image/')) { + throw new RuntimeException('Book cover must be an image file.'); + } + + $ext = $file->getClientOriginalExtension() ?: 'jpg'; + $path = $user->id . '/book-covers/' . Str::uuid()->toString() . '.' . $ext; + $file->storeAs('', $path, 'qr'); + + return $path; + } + + private function storeLogo(User $user, UploadedFile $file): string + { + $mime = $file->getMimeType() ?: ''; + if (! str_starts_with($mime, 'image/')) { + throw new RuntimeException('Logo must be an image file.'); + } + + $path = $user->id . '/logos/' . Str::uuid()->toString() . '.' . ($file->getClientOriginalExtension() ?: 'png'); + $file->storeAs('', $path, 'qr'); + + return $path; + } + + private function generateUniqueShortCode(): string + { + $length = (int) config('qr.short_code_length', 8); + + for ($attempt = 0; $attempt < 20; $attempt++) { + $code = Str::lower(Str::random($length)); + if (! QrCode::query()->where('short_code', $code)->exists()) { + return $code; + } + } + + throw new RuntimeException('Could not generate a unique QR short code.'); + } + + public function delete(QrCode $qrCode): void + { + $paths = array_filter([$qrCode->png_path, $qrCode->svg_path]); + + $logoPath = $qrCode->content()['logo_path'] ?? null; + if (is_string($logoPath) && $logoPath !== '') { + $paths[] = $logoPath; + } + + foreach ($paths as $path) { + Storage::disk('qr')->delete($path); + } + + $qrCode->delete(); + } +} diff --git a/app/Services/Qr/QrImageGeneratorService.php b/app/Services/Qr/QrImageGeneratorService.php new file mode 100644 index 0000000..7dfa2e8 --- /dev/null +++ b/app/Services/Qr/QrImageGeneratorService.php @@ -0,0 +1,488 @@ +encodedPayload(); + $style = $qrCode->style(); + $basePath = $qrCode->user_id . '/codes/' . $qrCode->id; + + $pngBinary = $this->renderPng($url, $style); + $svgMarkup = $this->renderSvg($url, $style); + + $pngPath = $basePath . '/qr.png'; + $svgPath = $basePath . '/qr.svg'; + + Storage::disk('qr')->put($pngPath, $pngBinary); + Storage::disk('qr')->put($svgPath, $svgMarkup); + + $qrCode->update([ + 'png_path' => $pngPath, + 'svg_path' => $svgPath, + ]); + } + + public function ensureValidImages(QrCodeModel $qrCode): QrCodeModel + { + if ($this->pngIsValid($qrCode->png_path)) { + return $qrCode; + } + + $this->generateAndStore($qrCode); + + return $qrCode->fresh(); + } + + public function previewDataUri(QrCodeModel $qrCode): string + { + $qrCode = $this->ensureValidImages($qrCode); + + if ($qrCode->png_path && Storage::disk('qr')->exists($qrCode->png_path)) { + $bytes = Storage::disk('qr')->get($qrCode->png_path); + if ($this->isValidPngBinary($bytes)) { + return 'data:image/png;base64,' . base64_encode($bytes); + } + } + + $png = $this->renderPng($qrCode->encodedPayload(), $qrCode->style()); + $this->generateAndStore($qrCode); + + return 'data:image/png;base64,' . base64_encode($png); + } + + public function logoDataUri(string $path): ?string + { + if (! Storage::disk('qr')->exists($path)) { + return null; + } + $content = Storage::disk('qr')->get($path); + if ($content === null) { + return null; + } + $mimeType = Storage::disk('qr')->mimeType($path); + if (! $mimeType) { + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $mimeType = match ($ext) { + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + 'svg' => 'image/svg+xml', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + default => 'image/png', + }; + } + + return 'data:' . $mimeType . ';base64,' . base64_encode($content); + } + + /** + * @param array $style + */ + public function renderPng(string $content, array $style): string + { + $style = QrStyleDefaults::mergeForRender($style); + $options = $this->buildOptions($style, QRCode::OUTPUT_IMAGE_PNG); + $binary = (new QRCode($options))->render($content); + + if ($style['logo_path'] && Storage::disk('qr')->exists($style['logo_path'])) { + $binary = $this->applyLogo($binary, Storage::disk('qr')->path($style['logo_path'])); + } + + return $this->applyFrame($binary, (string) ($style['frame_style'] ?? 'none'), $style); + } + + /** + * @param array $style + */ + public function renderSvg(string $content, array $style): string + { + $style = QrStyleDefaults::mergeForRender($style); + $options = $this->buildOptions($style, QRCode::OUTPUT_MARKUP_SVG); + + return (new QRCode($options))->render($content); + } + + /** @param array $style */ + private function buildOptions(array $style, string $outputType): QROptions + { + $fg = $this->hexToRgb((string) $style['foreground']); + $bg = $this->hexToRgb((string) $style['background']); + $ecc = match ($style['error_correction']) { + 'L' => EccLevel::L, + 'Q' => EccLevel::Q, + 'H' => EccLevel::H, + default => EccLevel::M, + }; + + $hasLogo = ! empty($style['logo_path']); + $module = QrModuleStyleCatalog::optionsFor((string) $style['module_style']); + $moduleUsesCircular = (bool) $module['circular']; + $finderOuter = (string) ($style['finder_outer'] ?? 'square'); + $finderInner = (string) ($style['finder_inner'] ?? 'square'); + $finderUsesCircular = $finderOuter !== 'square' || $finderInner === 'dot'; + $usesCircular = $moduleUsesCircular || $finderUsesCircular; + $connectPaths = $module['connect_paths'] && $outputType === QRCode::OUTPUT_MARKUP_SVG; + + $circleRadius = (float) $module['circle_radius']; + if ($finderOuter === 'circle') { + $circleRadius = max($circleRadius, 0.5); + } elseif ($finderOuter === 'rounded') { + $circleRadius = max($circleRadius, 0.38); + } + + $keepAsSquare = $this->resolveKeepAsSquare($moduleUsesCircular, $finderOuter, $finderInner); + + return new QROptions([ + 'outputType' => $outputType, + 'outputBase64' => false, + 'scale' => (int) $style['scale'], + 'eccLevel' => $ecc, + 'addQuietzone' => true, + 'quietzoneSize' => (int) $style['margin'], + 'bgColor' => $bg, + 'drawCircularModules' => $usesCircular, + 'circleRadius' => $circleRadius, + 'connectPaths' => $connectPaths, + 'gdImageUseUpscale' => true, + 'keepAsSquare' => $keepAsSquare, + 'addLogoSpace' => $hasLogo, + 'logoSpaceWidth' => $hasLogo ? 13 : null, + 'logoSpaceHeight' => $hasLogo ? 13 : null, + 'moduleValues' => [ + QRMatrix::M_DATA_DARK => $fg, + QRMatrix::M_FINDER_DARK => $fg, + QRMatrix::M_ALIGNMENT_DARK => $fg, + QRMatrix::M_TIMING_DARK => $fg, + QRMatrix::M_FORMAT_DARK => $fg, + QRMatrix::M_VERSION_DARK => $fg, + QRMatrix::M_FINDER_DOT => $fg, + ], + ]); + } + + /** @return list */ + private function resolveKeepAsSquare(bool $moduleUsesCircular, string $finderOuter, string $finderInner): array + { + $finderUsesCircular = ($finderOuter !== 'square') || ($finderInner === 'dot') || ($finderInner === 'rounded'); + + if (! $moduleUsesCircular && ! $finderUsesCircular) { + return []; + } + + $keep = [ + // Structural modules must always stay square for reliable scanning. + QRMatrix::M_ALIGNMENT_DARK, + QRMatrix::M_TIMING_DARK, + QRMatrix::M_FORMAT_DARK, + QRMatrix::M_VERSION_DARK, + // White separator (M_FINDER light modules) must always stay square. + // Removing it causes the circular dark-ring modules to lose their solid + // white backdrop, which produces a broken / empty-looking eye pattern. + QRMatrix::M_FINDER, + ]; + + // Data modules stay square when only the finder style is circular. + if (! $moduleUsesCircular) { + $keep[] = QRMatrix::M_DATA_DARK; + } + + // Outer finder frame: square keeps the ring solid; non-square renders it as dots. + if ($finderOuter === 'square') { + $keep[] = QRMatrix::M_FINDER_DARK; + } + + // Inner finder dot: 'dot' and 'rounded' both get circular treatment. + if ($finderInner !== 'dot' && $finderInner !== 'rounded') { + $keep[] = QRMatrix::M_FINDER_DOT; + } + + return array_values(array_unique($keep)); + } + + /** @param array $style */ + private function applyFrame(string $pngBinary, string $frameStyle, array $style = []): string + { + $frame = QrFrameStyleCatalog::all()[$frameStyle] ?? QrFrameStyleCatalog::all()['none']; + $borderPx = (int) $frame['border_px']; + $mode = (string) ($frame['mode'] ?? 'border'); + + if ($borderPx === 0 || ! extension_loaded('gd')) { + return $pngBinary; + } + + $source = @imagecreatefromstring($pngBinary); + if (! $source) { + return $pngBinary; + } + + $width = imagesx($source); + $height = imagesy($source); + $labelHeight = in_array($mode, ['label', 'pill'], true) ? max(44, (int) round($height * 0.18)) : 0; + $canvasWidth = $width + ($borderPx * 2); + $canvasHeight = $height + ($borderPx * 2) + $labelHeight; + $frameColorHex = trim((string) ($style['frame_color'] ?? '#000000')); + if (! preg_match('/^#[0-9a-fA-F]{6}$/', $frameColorHex)) { + $frameColorHex = '#000000'; + } + + $canvas = imagecreatetruecolor($canvasWidth, $canvasHeight); + $white = imagecolorallocate($canvas, 255, 255, 255); + + // For border mode the padding area IS the frame — fill with frame colour. + // For label/pill modes the background stays white; only the CTA element uses frame colour. + if ($mode === 'border') { + [$fr, $fg, $fb] = $this->hexToRgb($frameColorHex); + $frameBg = imagecolorallocate($canvas, $fr, $fg, $fb); + imagefilledrectangle($canvas, 0, 0, $canvasWidth, $canvasHeight, $frameBg); + } else { + imagefilledrectangle($canvas, 0, 0, $canvasWidth, $canvasHeight, $white); + } + + imagecopy($canvas, $source, $borderPx, $borderPx, 0, 0, $width, $height); + + if ($labelHeight > 0) { + $customText = trim((string) ($style['frame_text'] ?? '')); + $ctaText = $customText !== '' ? $customText : (string) ($frame['cta'] ?? 'SCAN ME'); + $this->drawFrameLabel($canvas, $ctaText, $borderPx, $width, $height, $labelHeight, $mode, $frameColorHex); + } + + ob_start(); + imagepng($canvas); + $result = (string) ob_get_clean(); + imagedestroy($source); + imagedestroy($canvas); + + return $result; + } + + private function drawFrameLabel(\GdImage $canvas, string $text, int $borderPx, int $qrWidth, int $qrHeight, int $labelHeight, string $mode, string $frameColorHex = '#000000'): void + { + $canvasWidth = imagesx($canvas); + $canvasHeight = imagesy($canvas); + [$fr, $fg, $fb] = $this->hexToRgb($frameColorHex); + $frameColor = imagecolorallocate($canvas, $fr, $fg, $fb); + // Choose black or white text depending on frame colour luminance + $luminance = (0.2126 * $fr + 0.7152 * $fg + 0.0722 * $fb) / 255; + $textOnFrame = $luminance > 0.35 + ? imagecolorallocate($canvas, 0, 0, 0) + : imagecolorallocate($canvas, 255, 255, 255); + $dividerColor = imagecolorallocate($canvas, $fr, $fg, $fb); + $labelTop = $borderPx + $qrHeight; + $labelBottom = $canvasHeight - max(8, (int) round($borderPx * 0.6)); + + if ($mode === 'pill') { + $pillMargin = max(12, (int) round($borderPx * 0.9)); + $pillTop = $labelTop + max(7, (int) round($labelHeight * 0.18)); + $pillBottom = $labelBottom - max(5, (int) round($labelHeight * 0.12)); + imagefilledrectangle($canvas, $pillMargin + 10, $pillTop, $canvasWidth - $pillMargin - 10, $pillBottom, $frameColor); + imagefilledellipse($canvas, $pillMargin + 10, (int) (($pillTop + $pillBottom) / 2), $pillBottom - $pillTop, $pillBottom - $pillTop, $frameColor); + imagefilledellipse($canvas, $canvasWidth - $pillMargin - 10, (int) (($pillTop + $pillBottom) / 2), $pillBottom - $pillTop, $pillBottom - $pillTop, $frameColor); + $this->drawCenteredString($canvas, $text, $textOnFrame, 5, $pillTop, $pillBottom); + + return; + } + + imageline($canvas, $borderPx + 8, $labelTop + 3, $borderPx + $qrWidth - 8, $labelTop + 3, $dividerColor); + $this->drawCenteredString($canvas, $text, $frameColor, 5, $labelTop + 7, $labelBottom); + } + + private function drawCenteredString(\GdImage $canvas, string $text, int $color, int $font, int $top, int $bottom): void + { + $text = strtoupper($text); + $font = max(1, min(5, $font)); + $textWidth = imagefontwidth($font) * strlen($text); + $textHeight = imagefontheight($font); + $x = max(0, (int) round((imagesx($canvas) - $textWidth) / 2)); + $y = max($top, (int) round($top + (($bottom - $top - $textHeight) / 2))); + imagestring($canvas, $font, $x, $y, $text, $color); + } + + /** @return array{0: int, 1: int, 2: int} */ + private function hexToRgb(string $hex): array + { + $hex = ltrim(trim($hex), '#'); + if (strlen($hex) === 3) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + + if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) { + return [0, 0, 0]; + } + + return [ + hexdec(substr($hex, 0, 2)), + hexdec(substr($hex, 2, 2)), + hexdec(substr($hex, 4, 2)), + ]; + } + + private function applyLogo(string $pngBinary, string $logoPath): string + { + if (! extension_loaded('gd')) { + return $pngBinary; + } + + $qr = imagecreatefromstring($pngBinary); + $logo = @imagecreatefromstring((string) file_get_contents($logoPath)); + + if (! $qr || ! $logo) { + return $pngBinary; + } + + $qrW = imagesx($qr); + $qrH = imagesy($qr); + $logoW = imagesx($logo); + $logoH = imagesy($logo); + $target = (int) round(min($qrW, $qrH) * 0.22); + $resized = imagecreatetruecolor($target, $target); + imagealphablending($resized, false); + imagesavealpha($resized, true); + $transparent = imagecolorallocatealpha($resized, 255, 255, 255, 127); + imagefilledrectangle($resized, 0, 0, $target, $target, $transparent); + imagecopyresampled($resized, $logo, 0, 0, 0, 0, $target, $target, $logoW, $logoH); + + $pad = (int) round($target * 0.12); + $bgSize = $target + ($pad * 2); + $bgX = (int) (($qrW - $bgSize) / 2); + $bgY = (int) (($qrH - $bgSize) / 2); + $white = imagecolorallocate($qr, 255, 255, 255); + imagefilledrectangle($qr, $bgX, $bgY, $bgX + $bgSize, $bgY + $bgSize, $white); + imagecopy($qr, $resized, $bgX + $pad, $bgY + $pad, 0, 0, $target, $target); + + ob_start(); + imagepng($qr); + $result = (string) ob_get_clean(); + imagedestroy($qr); + imagedestroy($logo); + imagedestroy($resized); + + return $result; + } + + private function pngIsValid(?string $path): bool + { + if (! $path || ! Storage::disk('qr')->exists($path)) { + return false; + } + + return $this->isValidPngBinary(Storage::disk('qr')->get($path)); + } + + /** + * Sanitize a client-supplied QR SVG before storing it. QR SVGs are paths, + * rects, gradients, clip-paths and an embedded data: logo — never scripts. + * Strips script/foreignObject, inline event handlers, javascript: URIs and + * any non-data external image/href references. Returns null if it isn't an + * SVG. Downloads are served as attachments, so this is defence-in-depth. + */ + public function sanitizeSvg(string $svg): ?string + { + $svg = trim($svg); + if (! preg_match('/]/i', $svg)) { + return null; + } + + // Drop XML declaration / doctype. + $svg = preg_replace('/<\?xml.*?\?>/is', '', $svg); + $svg = preg_replace('//is', '', $svg); + + // Remove dangerous elements entirely (with or without content). + $svg = preg_replace('#<\s*(script|foreignObject|iframe|style)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $svg); + $svg = preg_replace('#<\s*(script|foreignObject|iframe|style)\b[^>]*/?>#is', '', $svg); + + // Strip inline event handlers (onload, onclick, …). + $svg = preg_replace('/\son[a-z]+\s*=\s*"[^"]*"/i', '', $svg); + $svg = preg_replace("/\son[a-z]+\s*=\s*'[^']*'/i", '', $svg); + + // Neutralise javascript: in any href / xlink:href. + $svg = preg_replace('/((?:xlink:)?href)\s*=\s*"\s*javascript:[^"]*"/i', '$1="#"', $svg); + $svg = preg_replace("/((?:xlink:)?href)\s*=\s*'\s*javascript:[^']*'/i", '$1="#"', $svg); + + // Remove external (non-data:) image references; keep embedded data: logos. + $svg = preg_replace('/])*(?:xlink:)?href\s*=\s*"(?!data:)[^"]*"[^>]*>/is', '', $svg); + + $svg = trim($svg); + if (! str_contains($svg, ']*>/i', $svg, $m)) { + $open = $m[0]; + $fixed = $open; + if (! preg_match('/\sxmlns\s*=/i', $fixed)) { + $fixed = preg_replace('/exists($path)) { + return null; + } + + $bytes = Storage::disk('qr')->get($path); + + if ($this->isValidPngBinary($bytes)) { + return $bytes; + } + + if (str_starts_with($bytes, 'iVBORw0KGgo')) { + $decoded = base64_decode($bytes, true); + if ($decoded !== false && $this->isValidPngBinary($decoded)) { + Storage::disk('qr')->put($path, $decoded); + + return $decoded; + } + } + + return null; + } +} diff --git a/app/Services/Qr/QrPayloadValidator.php b/app/Services/Qr/QrPayloadValidator.php new file mode 100644 index 0000000..fbfc8c9 --- /dev/null +++ b/app/Services/Qr/QrPayloadValidator.php @@ -0,0 +1,663 @@ + $input + * @return array{content: array, destination_url: ?string} + */ + public function validateForCreate(string $type, array $input): array + { + if (! QrTypeCatalog::isValid($type)) { + throw new RuntimeException('Invalid QR type selected.'); + } + + return match ($type) { + QrCode::TYPE_URL => $this->validateUrl($input), + QrCode::TYPE_DOCUMENT => $this->validateDocument($input), + QrCode::TYPE_LINK_LIST => $this->validateLinkList($input), + QrCode::TYPE_VCARD => $this->validateVcard($input), + QrCode::TYPE_BUSINESS => $this->validateBusiness($input), + QrCode::TYPE_CHURCH => $this->validateChurch($input), + QrCode::TYPE_EVENT => $this->validateEvent($input), + QrCode::TYPE_ITINERARY => $this->validateItinerary($input), + QrCode::TYPE_IMAGE => ['content' => [], 'destination_url' => null], + QrCode::TYPE_MENU => $this->validateMenu($input), + QrCode::TYPE_SHOP => $this->validateShop($input), + QrCode::TYPE_APP => $this->validateApp($input), + QrCode::TYPE_BOOK => $this->validateBook($input), + QrCode::TYPE_WIFI => $this->validateWifi($input), + QrCode::TYPE_PAYMENT => $this->validatePayment($input), + default => throw new RuntimeException('Unsupported QR type.'), + }; + } + + /** + * @param array $input + * @return array{content: array, destination_url: ?string} + */ + public function validateForUpdate(QrCode $qrCode, array $input): array + { + $merged = array_merge($qrCode->content(), $input); + + if ($qrCode->isUrlType() && empty($merged['destination_url']) && ! empty($merged['url'])) { + $merged['destination_url'] = $merged['url']; + } + + return $this->validateForCreate($qrCode->type, $merged); + } + + /** @param array $input */ + private function validateDocument(array $input): array + { + $allowDownload = filter_var($input['allow_download'] ?? true, FILTER_VALIDATE_BOOL); + + return ['content' => ['allow_download' => $allowDownload], 'destination_url' => null]; + } + + /** @param array $input */ + private function validateUrl(array $input): array + { + $url = $this->requireUrl($input['destination_url'] ?? null, 'Enter a valid destination URL.'); + + return ['content' => ['url' => $url], 'destination_url' => $url]; + } + + /** @param array $input */ + private function validateLinkList(array $input): array + { + $links = $this->normalizeLinks($input['links'] ?? []); + + if ($links === []) { + throw new RuntimeException('Add at least one link.'); + } + + return ['content' => ['links' => $links], 'destination_url' => null]; + } + + /** @param array $input */ + private function validateVcard(array $input): array + { + $first = trim((string) ($input['first_name'] ?? '')); + $last = trim((string) ($input['last_name'] ?? '')); + + if ($first === '' && $last === '') { + throw new RuntimeException('Enter a first or last name for the vCard.'); + } + + $social = []; + foreach (['linkedin', 'twitter', 'instagram', 'facebook', 'tiktok', 'youtube', 'whatsapp', 'snapchat'] as $platform) { + $raw = trim((string) (($input['social'] ?? [])[$platform] ?? '')); + if ($raw !== '') { + $social[$platform] = static::normalizeSocialUrl($platform, $raw); + } + } + + return [ + 'content' => [ + 'first_name' => $first, + 'last_name' => $last, + 'phone' => trim((string) ($input['phone'] ?? '')), + 'email' => trim((string) ($input['email'] ?? '')), + 'company' => trim((string) ($input['company'] ?? '')), + 'website' => trim((string) ($input['website'] ?? '')), + 'address' => trim((string) ($input['address'] ?? '')), + 'note' => trim((string) ($input['note'] ?? '')), + 'avatar_path' => $input['avatar_path'] ?? null, + 'social' => $social, + ], + 'destination_url' => null, + ]; + } + + /** @param array $input */ + private function validateBusiness(array $input): array + { + $name = trim((string) ($input['name'] ?? '')); + + if ($name === '') { + throw new RuntimeException('Enter a business name.'); + } + + $social = []; + foreach (['linkedin', 'twitter', 'instagram', 'facebook', 'tiktok', 'youtube', 'whatsapp', 'snapchat'] as $platform) { + $raw = trim((string) (($input['social'] ?? [])[$platform] ?? '')); + if ($raw !== '') { + $social[$platform] = static::normalizeSocialUrl($platform, $raw); + } + } + + return [ + 'content' => [ + 'name' => $name, + 'tagline' => trim((string) ($input['tagline'] ?? '')), + 'phone' => trim((string) ($input['phone'] ?? '')), + 'email' => trim((string) ($input['email'] ?? '')), + 'website' => trim((string) ($input['website'] ?? '')), + 'address' => trim((string) ($input['address'] ?? '')), + 'hours' => trim((string) ($input['hours'] ?? '')), + 'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null), + 'logo_path' => $input['logo_path'] ?? null, + 'cover_path' => $input['cover_path'] ?? null, + 'social' => $social, + ], + 'destination_url' => null, + ]; + } + + /** @param array $input */ + private function validateChurch(array $input): array + { + $name = trim((string) ($input['name'] ?? '')); + if ($name === '') { + throw new RuntimeException('Enter the church name.'); + } + + $orgTypes = ['church', 'school', 'mosque', 'ngo', 'club']; + $orgType = in_array($input['org_type'] ?? 'church', $orgTypes) ? $input['org_type'] : 'church'; + + // Normalise legacy lowercase slugs → display strings + $legacyMap = ['offering' => 'Offering', 'tithe' => 'Tithe', 'donation' => 'Donation', 'harvest' => 'Harvest']; + $collectionTypes = array_values(array_unique(array_filter( + array_map(fn ($t) => $legacyMap[strtolower(trim((string) $t))] ?? ucwords(trim((string) $t)), (array) ($input['collection_types'] ?? [])), + fn ($t) => $t !== '' && strlen($t) <= 60 + ))); + if (empty($collectionTypes)) { + $collectionTypes = ['Offering', 'Tithe', 'Donation', 'Harvest']; + } + + return [ + 'content' => [ + 'name' => $name, + 'denomination' => trim((string) ($input['denomination'] ?? '')), + 'description' => trim((string) ($input['description'] ?? '')), + 'phone' => trim((string) ($input['phone'] ?? '')), + 'email' => trim((string) ($input['email'] ?? '')), + 'website' => trim((string) ($input['website'] ?? '')), + 'address' => trim((string) ($input['address'] ?? '')), + 'service_times' => trim((string) ($input['service_times'] ?? '')), + 'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#1a3a5c', + 'logo_path' => $input['logo_path'] ?? null, + 'cover_path' => $input['cover_path'] ?? null, + 'org_type' => $orgType, + 'accepts_payment' => filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL), + 'currency' => 'GHS', + 'collection_types' => $collectionTypes, + ], + 'destination_url' => null, + ]; + } + + /** @param array $input */ + private function validateEvent(array $input): array + { + $name = trim((string) ($input['name'] ?? '')); + if ($name === '') { + throw new RuntimeException('Enter the event name.'); + } + + // Ticket tiers: [{ name, price, capacity }] + $tiers = []; + foreach ((array) ($input['tiers'] ?? []) as $tier) { + if (! is_array($tier)) { + continue; + } + $tierName = trim((string) ($tier['name'] ?? '')); + if ($tierName === '') { + continue; + } + $price = round((float) ($tier['price'] ?? 0), 2); + $capacity = (int) ($tier['capacity'] ?? 0); + $tiers[] = [ + 'name' => mb_substr($tierName, 0, 80), + 'price' => max(0, $price), + 'capacity' => max(0, $capacity), // 0 = unlimited + ]; + } + if (empty($tiers)) { + $tiers = [['name' => 'General Admission', 'price' => 0.0, 'capacity' => 0]]; + } + + // Extra registration/badge fields the organiser wants captured. + $badgeFields = []; + foreach ((array) ($input['badge_fields'] ?? []) as $field) { + $label = trim((string) (is_array($field) ? ($field['label'] ?? '') : $field)); + if ($label !== '' && strlen($label) <= 60) { + $badgeFields[] = mb_substr($label, 0, 60); + } + } + + // Mode: sell tickets, collect cash contributions (weddings, non-profits), + // or a free event (registration only — no tickets, no contributions). + $mode = in_array($input['mode'] ?? 'ticketing', ['ticketing', 'contributions', 'free'], true) + ? ($input['mode'] ?? 'ticketing') + : 'ticketing'; + + // Contribution categories: ['Wedding Gift', 'Donation', …] + $categories = []; + foreach ((array) ($input['contribution_categories'] ?? []) as $cat) { + $label = trim((string) (is_array($cat) ? ($cat['name'] ?? '') : $cat)); + if ($label !== '') { + $categories[] = mb_substr($label, 0, 80); + } + } + if ($mode === 'contributions' && empty($categories)) { + $categories = ['Contribution']; + } + + // Free events collect neither tickets nor contributions — a single free + // registration. Forced deterministically so switching from a paid setup + // can never leave a chargeable tier behind. + if ($mode === 'free') { + $tiers = [['name' => 'Registration', 'price' => 0.0, 'capacity' => 0]]; + $categories = []; + } + + // Contributions always take payment; ticketing only for paid tiers; free never. + $acceptsPayment = match ($mode) { + 'contributions' => true, + 'free' => false, + default => $this->eventHasPaidTier($tiers), + }; + + return [ + 'content' => [ + 'name' => mb_substr($name, 0, 120), + 'tagline' => trim((string) ($input['tagline'] ?? '')), + 'description' => trim((string) ($input['description'] ?? '')), + 'location' => trim((string) ($input['location'] ?? '')), + 'starts_at' => QrDateFormatter::normalize((string) ($input['starts_at'] ?? '')), + 'ends_at' => QrDateFormatter::normalize((string) ($input['ends_at'] ?? '')), + 'organizer' => trim((string) ($input['organizer'] ?? '')), + 'website' => trim((string) ($input['website'] ?? '')), + 'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#4f46e5', + 'logo_path' => $input['logo_path'] ?? null, + 'cover_path' => $input['cover_path'] ?? null, + 'currency' => 'GHS', + 'mode' => $mode, + 'tiers' => array_values($tiers), + 'contribution_categories' => array_values($categories), + 'badge_fields' => array_values($badgeFields), + 'badge_size' => in_array($input['badge_size'] ?? '4x3', ['4x3', '4x6', 'cr80'], true) ? ($input['badge_size'] ?? '4x3') : '4x3', + '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, + ], + 'destination_url' => null, + ]; + } + + /** @param array $tiers */ + private function eventHasPaidTier(array $tiers): bool + { + foreach ($tiers as $tier) { + if ((float) ($tier['price'] ?? 0) > 0) { + return true; + } + } + + return false; + } + + /** @param array $input */ + private function validateItinerary(array $input): array + { + $title = trim((string) ($input['title'] ?? '')); + if ($title === '') { + throw new RuntimeException('Enter the itinerary title.'); + } + + // Days: [{ label, date, items: [{ time, title, description, location, host }] }] + $days = []; + foreach ((array) ($input['days'] ?? []) as $day) { + if (! is_array($day)) { + continue; + } + $items = []; + foreach ((array) ($day['items'] ?? []) as $item) { + if (! is_array($item)) { + continue; + } + $itemTitle = trim((string) ($item['title'] ?? '')); + if ($itemTitle === '') { + continue; + } + $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), + ]; + } + if (empty($items) && trim((string) ($day['label'] ?? '')) === '') { + continue; + } + $days[] = [ + 'label' => mb_substr(trim((string) ($day['label'] ?? '')), 0, 80), + 'date' => QrDateFormatter::normalize((string) ($day['date'] ?? '')), + 'items' => array_values($items), + ]; + } + if (empty($days)) { + throw new RuntimeException('Add at least one programme item.'); + } + + return [ + 'content' => [ + 'title' => mb_substr($title, 0, 120), + 'subtitle' => trim((string) ($input['subtitle'] ?? '')), + 'description' => trim((string) ($input['description'] ?? '')), + 'event_date' => QrDateFormatter::normalize((string) ($input['event_date'] ?? '')), + 'location' => trim((string) ($input['location'] ?? '')), + 'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#b45309', + 'cover_path' => $input['cover_path'] ?? null, + 'days' => array_values($days), + ], + 'destination_url' => null, + ]; + } + + /** @param array $input */ + private function validateMenu(array $input): array + { + $title = trim((string) ($input['menu_title'] ?? 'Menu')); + $sections = $this->normalizeMenuSections($input['sections'] ?? []); + $acceptsPayment = filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL); + + $shippingType = in_array($input['shipping_type'] ?? 'none', ['none', 'flat'], true) + ? (string) ($input['shipping_type'] ?? 'none') + : 'none'; + $shippingFee = $shippingType === 'flat' ? round(max(0, (float) ($input['shipping_fee'] ?? 0)), 2) : 0.0; + $freeShippingAbove = round(max(0, (float) ($input['free_shipping_above'] ?? 0)), 2); + + if ($sections === []) { + throw new RuntimeException('Add at least one menu section with items.'); + } + + return [ + 'content' => [ + 'title' => $title, + 'sections' => $sections, + 'accepts_payment' => $acceptsPayment, + 'shipping_type' => $shippingType, + 'shipping_fee' => $shippingFee, + 'free_shipping_above' => $freeShippingAbove, + 'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null), + 'logo_path' => $input['logo_path'] ?? null, + 'cover_path' => $input['cover_path'] ?? null, + ], + 'destination_url' => null, + ]; + } + + /** @param array $input */ + private function validateShop(array $input): array + { + $title = trim((string) ($input['shop_title'] ?? $input['menu_title'] ?? 'Shop')); + $currency = trim((string) ($input['currency'] ?? 'GHS')); + $sections = $this->normalizeMenuSections($input['sections'] ?? []); + $acceptsPayment = filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL); + + $shippingType = in_array($input['shipping_type'] ?? 'none', ['none', 'flat'], true) + ? (string) ($input['shipping_type'] ?? 'none') + : 'none'; + $shippingFee = $shippingType === 'flat' ? round(max(0, (float) ($input['shipping_fee'] ?? 0)), 2) : 0.0; + $freeShippingAbove = round(max(0, (float) ($input['free_shipping_above'] ?? 0)), 2); + + if ($sections === []) { + throw new RuntimeException('Add at least one category with products.'); + } + + return [ + 'content' => [ + 'title' => $title, + 'currency' => $currency, + 'sections' => $sections, + 'accepts_payment' => $acceptsPayment, + 'shipping_type' => $shippingType, + 'shipping_fee' => $shippingFee, + 'free_shipping_above' => $freeShippingAbove, + 'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null), + 'logo_path' => $input['logo_path'] ?? null, + 'cover_path' => $input['cover_path'] ?? null, + ], + 'destination_url' => null, + ]; + } + + private function normalizeBrandColor(mixed $value): ?string + { + if ($value === null) { + return null; + } + $hex = trim((string) $value); + + return preg_match('/^#[0-9A-Fa-f]{6}$/', $hex) ? $hex : null; + } + + /** @param array $input */ + private function validateApp(array $input): array + { + $name = trim((string) ($input['app_name'] ?? '')); + $ios = trim((string) ($input['ios_url'] ?? '')); + $android = trim((string) ($input['android_url'] ?? '')); + $web = trim((string) ($input['web_url'] ?? '')); + + if ($name === '') { + throw new RuntimeException('Enter an app name.'); + } + + if ($ios === '' && $android === '' && $web === '') { + throw new RuntimeException('Add at least one app store or website link.'); + } + + foreach (['ios' => $ios, 'android' => $android, 'web' => $web] as $label => $url) { + if ($url !== '' && ! filter_var($url, FILTER_VALIDATE_URL)) { + throw new RuntimeException("Enter a valid {$label} URL."); + } + } + + return [ + 'content' => [ + 'name' => $name, + 'ios_url' => $ios, + 'android_url' => $android, + 'web_url' => $web, + 'icon_path' => $input['icon_path'] ?? null, + ], + 'destination_url' => $web ?: ($ios ?: $android), + ]; + } + + /** @param array $input */ + private function validateBook(array $input): array + { + $title = trim((string) ($input['book_title'] ?? '')); + $author = trim((string) ($input['author'] ?? '')); + $price = (float) ($input['price_ghs'] ?? 0); + + if ($title === '') { + throw new RuntimeException('Enter the book title.'); + } + if ($author === '') { + throw new RuntimeException('Enter the author name.'); + } + if ($price <= 0) { + throw new RuntimeException('Enter a price greater than zero.'); + } + + return [ + 'content' => [ + 'book_title' => $title, + 'author' => $author, + 'description' => trim((string) ($input['description'] ?? '')), + 'price_ghs' => round($price, 2), + 'cover_path' => $input['cover_path'] ?? null, + 'file_path' => $input['file_path'] ?? null, + 'file_type' => $input['file_type'] ?? null, + 'file_size' => (int) ($input['file_size'] ?? 0), + ], + 'destination_url' => null, + ]; + } + + /** @param array $input */ + private function validateWifi(array $input): array + { + $ssid = trim((string) ($input['ssid'] ?? '')); + if ($ssid === '') { + throw new RuntimeException('Enter a WiFi network name (SSID).'); + } + + $encryption = strtoupper(trim((string) ($input['encryption'] ?? 'WPA'))); + if (! in_array($encryption, ['WPA', 'WEP', 'NOPASS'], true)) { + $encryption = 'WPA'; + } + + return [ + 'content' => [ + 'ssid' => $ssid, + 'password' => (string) ($input['password'] ?? ''), + 'encryption' => $encryption, + 'hidden' => filter_var($input['hidden'] ?? false, FILTER_VALIDATE_BOOL), + ], + 'destination_url' => null, + ]; + } + + public static function normalizeSocialUrl(string $platform, string $value): string + { + // Already a full URL — leave as-is + if (str_starts_with($value, 'http://') || str_starts_with($value, 'https://')) { + return $value; + } + + $handle = ltrim($value, '@'); + + return match ($platform) { + 'linkedin' => 'https://linkedin.com/in/' . $handle, + 'twitter' => 'https://x.com/' . $handle, + 'instagram' => 'https://instagram.com/' . $handle, + 'facebook' => 'https://facebook.com/' . $handle, + 'tiktok' => 'https://tiktok.com/@' . $handle, + 'youtube' => 'https://youtube.com/@' . $handle, + 'snapchat' => 'https://snapchat.com/add/' . $handle, + 'whatsapp' => 'https://wa.me/' . preg_replace('/\D+/', '', $value), + default => $value, + }; + } + + private function requireUrl(mixed $value, string $message): string + { + $url = trim((string) $value); + if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) { + throw new RuntimeException($message); + } + + return $url; + } + + /** @return list */ + private function normalizeLinks(mixed $links): array + { + if (! is_array($links)) { + return []; + } + + $normalized = []; + foreach ($links as $link) { + if (! is_array($link)) { + continue; + } + $title = trim((string) ($link['title'] ?? '')); + $url = trim((string) ($link['url'] ?? '')); + if ($title === '' || $url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) { + continue; + } + $normalized[] = ['title' => $title, 'url' => $url]; + } + + return $normalized; + } + + /** + * Normalize menu/shop sections. Preserves existing image_path on items so that + * the manager can inject freshly uploaded paths after validation. + * + * @return list}> + */ + private function normalizeMenuSections(mixed $sections): array + { + if (! is_array($sections)) { + return []; + } + + $normalized = []; + foreach ($sections as $section) { + if (! is_array($section)) { + continue; + } + $name = trim((string) ($section['name'] ?? '')); + $items = []; + foreach (($section['items'] ?? []) as $item) { + if (! is_array($item)) { + continue; + } + $itemName = trim((string) ($item['name'] ?? '')); + if ($itemName === '') { + continue; + } + $items[] = [ + 'name' => $itemName, + 'description' => trim((string) ($item['description'] ?? '')), + 'price' => trim((string) ($item['price'] ?? '')), + 'image_path' => ($item['image_path'] ?? null) ?: null, + ]; + } + if ($name !== '' && $items !== []) { + $normalized[] = ['name' => $name, 'items' => $items]; + } + } + + return $normalized; + } + + public function validateImageUpload(?UploadedFile $file): void + { + if (! $file instanceof UploadedFile) { + throw new RuntimeException('Upload at least one image.'); + } + + $mime = $file->getMimeType() ?: ''; + if (! str_starts_with($mime, 'image/')) { + throw new RuntimeException('Only image files are supported.'); + } + } + + /** @param array $input */ + private function validatePayment(array $input): array + { + $businessName = trim((string) ($input['business_name'] ?? '')); + if ($businessName === '') { + throw new RuntimeException('Enter your business or display name.'); + } + + return [ + 'content' => [ + 'business_name' => mb_substr($businessName, 0, 120), + 'branch_label' => mb_substr(trim((string) ($input['branch_label'] ?? '')), 0, 80) ?: null, + 'currency' => strtoupper(trim((string) ($input['currency'] ?? 'GHS'))) ?: 'GHS', + ], + 'destination_url' => null, + ]; + } +} diff --git a/app/Services/Qr/QrPdfExporter.php b/app/Services/Qr/QrPdfExporter.php new file mode 100644 index 0000000..dc2e6cc --- /dev/null +++ b/app/Services/Qr/QrPdfExporter.php @@ -0,0 +1,93 @@ +buildPdf($jpeg, $title, $width, $height); + } + + private function buildPdf(string $jpeg, string $title, int $imgW, int $imgH): string + { + $pageW = 595.28; + $pageH = 841.89; + $margin = 48; + $maxQr = min($pageW - ($margin * 2), 320); + $scale = min($maxQr / max(1, $imgW), $maxQr / max(1, $imgH)); + $drawW = $imgW * $scale; + $drawH = $imgH * $scale; + $x = ($pageW - $drawW) / 2; + $y = 120; + $safeTitle = $this->pdfEscape(substr($title, 0, 80)); + + $objects = []; + $objects[] = "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"; + $objects[] = "2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n"; + $objects[] = sprintf( + "3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2F %.2F] /Resources << /Font << /F1 4 0 R >> /XObject << /Im1 5 0 R >> >> /Contents 6 0 R >> endobj\n", + $pageW, + $pageH, + ); + $objects[] = "4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> endobj\n"; + $objects[] = sprintf( + "5 0 obj << /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >> stream\n%s\nendstream\nendobj\n", + $imgW, + $imgH, + strlen($jpeg), + $jpeg, + ); + + $content = "BT /F1 16 Tf 48 " . ($pageH - 72) . " Td ({$safeTitle}) Tj ET\n"; + $content .= sprintf("q %.4F 0 0 %.4F %.2F %.2F cm /Im1 Do Q\n", $drawW, $drawH, $x, $pageH - $y - $drawH); + $content .= "BT /F1 10 Tf 48 48 Td (Generated by Ladill QR Codes) Tj ET\n"; + $objects[] = sprintf("6 0 obj << /Length %d >> stream\n%s\nendstream\nendobj\n", strlen($content), $content); + + $pdf = "%PDF-1.4\n"; + $offsets = [0]; + + foreach ($objects as $object) { + $offsets[] = strlen($pdf); + $pdf .= $object; + } + + $xrefPos = strlen($pdf); + $pdf .= "xref\n0 " . count($offsets) . "\n"; + $pdf .= "0000000000 65535 f \n"; + for ($i = 1; $i < count($offsets); $i++) { + $pdf .= sprintf("%010d 00000 n \n", $offsets[$i]); + } + $pdf .= "trailer << /Size " . count($offsets) . " /Root 1 0 R >>\n"; + $pdf .= "startxref\n{$xrefPos}\n%%EOF"; + + return $pdf; + } + + private function pdfEscape(string $text): string + { + return str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $text); + } +} diff --git a/app/Services/Qr/QrScanRecorder.php b/app/Services/Qr/QrScanRecorder.php new file mode 100644 index 0000000..c3f381d --- /dev/null +++ b/app/Services/Qr/QrScanRecorder.php @@ -0,0 +1,89 @@ +parseUserAgent((string) $request->userAgent()); + $ipHash = $this->hashIp((string) $request->ip()); + $windowHours = (int) config('qr.scan_unique_window_hours', 24); + $isUnique = ! QrScanEvent::query() + ->where('qr_code_id', $qrCode->id) + ->where('ip_hash', $ipHash) + ->where('scanned_at', '>=', now()->subHours($windowHours)) + ->exists(); + + $event = QrScanEvent::create([ + 'qr_code_id' => $qrCode->id, + 'scanned_at' => now(), + 'ip_hash' => $ipHash, + 'user_agent' => Str::limit((string) $request->userAgent(), 500, ''), + 'device_type' => $parsed['device_type'], + 'browser' => $parsed['browser'], + 'os' => $parsed['os'], + 'referrer' => Str::limit((string) $request->headers->get('referer'), 255, ''), + 'is_unique' => $isUnique, + ]); + + $qrCode->increment('scans_total'); + if ($isUnique) { + $qrCode->increment('unique_scans_total'); + } + $qrCode->update(['last_scanned_at' => now()]); + + QrWallet::query() + ->where('user_id', $qrCode->user_id) + ->increment('scans_total'); + + return $event; + } + + private function hashIp(string $ip): string + { + return hash('sha256', $ip . '|' . (string) config('app.key')); + } + + /** + * @return array{device_type: string, browser: string, os: string} + */ + private function parseUserAgent(string $ua): array + { + $uaLower = strtolower($ua); + + $device = str_contains($uaLower, 'mobile') || str_contains($uaLower, 'android') || str_contains($uaLower, 'iphone') + ? 'mobile' + : (str_contains($uaLower, 'tablet') || str_contains($uaLower, 'ipad') ? 'tablet' : 'desktop'); + + $browser = match (true) { + str_contains($uaLower, 'edg/') => 'Edge', + str_contains($uaLower, 'chrome/') && ! str_contains($uaLower, 'edg/') => 'Chrome', + str_contains($uaLower, 'safari/') && ! str_contains($uaLower, 'chrome/') => 'Safari', + str_contains($uaLower, 'firefox/') => 'Firefox', + default => 'Other', + }; + + $os = match (true) { + str_contains($uaLower, 'iphone') || str_contains($uaLower, 'ipad') => 'iOS', + str_contains($uaLower, 'android') => 'Android', + str_contains($uaLower, 'windows') => 'Windows', + str_contains($uaLower, 'mac os') || str_contains($uaLower, 'macintosh') => 'macOS', + str_contains($uaLower, 'linux') => 'Linux', + default => 'Other', + }; + + return [ + 'device_type' => $device, + 'browser' => $browser, + 'os' => $os, + ]; + } +} diff --git a/app/Services/Qr/QrWalletBillingService.php b/app/Services/Qr/QrWalletBillingService.php new file mode 100644 index 0000000..5969f3d --- /dev/null +++ b/app/Services/Qr/QrWalletBillingService.php @@ -0,0 +1,74 @@ +billing->balanceMinor($user->public_id) / 100; + } + + public function canCreate(User $user): bool + { + $priceMinor = (int) round(QrWallet::pricePerQr() * 100); + + return $this->billing->canAfford($user->public_id, $priceMinor); + } + + public function debitForQrCreation(QrWallet $wallet, QrCode $qrCode): QrTransaction + { + $price = QrWallet::pricePerQr(); + $priceMinor = (int) round($price * 100); + $user = $wallet->user; + $reference = 'QR-DEBIT-'.strtoupper(Str::random(12)); + + return DB::transaction(function () use ($wallet, $user, $qrCode, $price, $priceMinor, $reference) { + $ok = $this->billing->debit( + $user->public_id, + $priceMinor, + 'qr', + 'qr_create', + $reference, + $qrCode->id, + sprintf('Created QR code: %s', $qrCode->label), + ); + + if (! $ok) { + throw new RuntimeException('Insufficient wallet balance.'); + } + + $wallet->increment('qr_codes_total'); + $balanceAfter = $this->billing->balanceMinor($user->public_id) / 100; + + return QrTransaction::create([ + 'user_id' => $wallet->user_id, + 'qr_wallet_id' => $wallet->id, + 'qr_code_id' => $qrCode->id, + 'type' => QrTransaction::TYPE_DEBIT, + 'amount_ghs' => round($price, 4), + 'balance_after_ghs' => $balanceAfter, + 'reference' => $reference, + 'status' => 'completed', + 'description' => sprintf('Created QR code: %s', $qrCode->label), + 'metadata' => ['qr_code_id' => $qrCode->id], + ]); + }); + } +} diff --git a/app/Support/CrmPrefillCodec.php b/app/Support/CrmPrefillCodec.php new file mode 100644 index 0000000..b91e2b1 --- /dev/null +++ b/app/Support/CrmPrefillCodec.php @@ -0,0 +1,34 @@ +|null */ + public static function decode(?string $token): ?array + { + if (! is_string($token) || $token === '') { + return null; + } + + $padded = $token.str_repeat('=', (4 - strlen($token) % 4) % 4); + $json = base64_decode(strtr($padded, '-_', '+/'), true); + + if ($json === false) { + return null; + } + + try { + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + + return is_array($data) ? $data : null; + } +} diff --git a/app/Support/DomainConfig.php b/app/Support/DomainConfig.php new file mode 100644 index 0000000..71f7d25 --- /dev/null +++ b/app/Support/DomainConfig.php @@ -0,0 +1,369 @@ + + */ + public static function featuredTlds(): array + { + $allTlds = self::allTlds(); + $featured = array_values(array_intersect(self::PRIORITY_TLDS, $allTlds)); + + if ($featured !== []) { + return $featured; + } + + return array_slice($allTlds, 0, self::DEFAULT_PAGE_SIZE); + } + + /** + * Get all available TLDs sorted by priority. + * + * @return list + */ + public static function allTlds(): array + { + $current = self::normalizeTlds(config('domain.search_tlds', [])); + $legacy = self::normalizeTlds(config('mailinfra.domain_search_tlds', [])); + + $tlds = $legacy !== [] ? $legacy : $current; + $livePricedTlds = self::livePricedTlds(); + + if ($livePricedTlds === []) { + return []; + } + + $livePricedLookup = array_flip($livePricedTlds); + $tlds = array_values(array_filter( + $tlds, + static fn (string $tld): bool => isset($livePricedLookup[$tld]) + )); + + return self::sortWithPriority($tlds); + } + + /** + * Get paginated TLDs (excluding featured ones). + * + * @return array{tlds: list, hasMore: bool, total: int} + */ + public static function paginatedTlds(int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array + { + $allTlds = self::allTlds(); + $featured = self::featuredTlds(); + + $remaining = array_values(array_filter($allTlds, fn ($tld) => !in_array($tld, $featured, true))); + $total = count($remaining); + + $offset = ($page - 1) * $perPage; + $tlds = array_slice($remaining, $offset, $perPage); + + return [ + 'tlds' => $tlds, + 'hasMore' => ($offset + $perPage) < $total, + 'total' => $total, + ]; + } + + /** + * @deprecated Use featuredTlds() or allTlds() instead + * @return list + */ + public static function searchTlds(): array + { + return self::featuredTlds(); + } + + /** + * Build the TLD list and query term for a search page request. + * + * @return array{query: string, tlds: list, hasMore: bool, exact_domain: string|null} + */ + public static function searchPageConfig(string $query, int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array + { + $query = strtolower(trim($query)); + $allTlds = self::allTlds(); + + if ($allTlds === []) { + return [ + 'query' => $query, + 'tlds' => [], + 'hasMore' => false, + 'exact_domain' => null, + ]; + } + + $exactDomain = self::normalizeExactDomain($query); + + if ($exactDomain === null) { + if ($page === 1) { + $featured = self::featuredTlds(); + $paginatedData = self::paginatedTlds(1, $perPage); + + return [ + 'query' => $query, + 'tlds' => $featured, + 'hasMore' => $paginatedData['hasMore'] || count($paginatedData['tlds']) > 0, + 'exact_domain' => null, + ]; + } + + $paginatedData = self::paginatedTlds($page - 1, $perPage); + + return [ + 'query' => $query, + 'tlds' => $paginatedData['tlds'], + 'hasMore' => $paginatedData['hasMore'], + 'exact_domain' => null, + ]; + } + + $exactTld = self::extractTld($exactDomain); + $keyword = explode('.', $exactDomain, 2)[0] ?? $exactDomain; + $firstPageTlds = array_values(array_unique(array_filter([ + $exactTld, + ...self::featuredTlds(), + ]))); + $remainingTlds = array_values(array_filter( + $allTlds, + static fn (string $tld): bool => ! in_array($tld, $firstPageTlds, true) + )); + + if ($page === 1) { + return [ + 'query' => $keyword, + 'tlds' => $firstPageTlds, + 'hasMore' => $remainingTlds !== [], + 'exact_domain' => $exactDomain, + ]; + } + + $offset = max(0, ($page - 2) * $perPage); + + return [ + 'query' => $keyword, + 'tlds' => array_slice($remainingTlds, $offset, $perPage), + 'hasMore' => ($offset + $perPage) < count($remainingTlds), + 'exact_domain' => $exactDomain, + ]; + } + + /** + * Sort domain search results by TLD priority. + * + * @param array $results + * @return array + */ + public static function sortResultsByTldPriority(array $results, ?string $query = null): array + { + $priorityMap = array_flip(self::PRIORITY_TLDS); + $exactDomain = self::normalizeExactDomain((string) $query); + + usort($results, static function (array $a, array $b) use ($priorityMap, $exactDomain): int { + $aDomain = strtolower((string) ($a['domain'] ?? '')); + $bDomain = strtolower((string) ($b['domain'] ?? '')); + + $aExact = $exactDomain !== null && $aDomain === $exactDomain ? 0 : 1; + $bExact = $exactDomain !== null && $bDomain === $exactDomain ? 0 : 1; + + if ($aExact !== $bExact) { + return $aExact <=> $bExact; + } + + $aPremium = self::isPremiumResult($a) ? 0 : 1; + $bPremium = self::isPremiumResult($b) ? 0 : 1; + + if ($aPremium !== $bPremium) { + return $aPremium <=> $bPremium; + } + + $aAvailable = ! empty($a['available']) ? 0 : 1; + $bAvailable = ! empty($b['available']) ? 0 : 1; + + if ($aAvailable !== $bAvailable) { + return $aAvailable <=> $bAvailable; + } + + $aTld = self::extractTld($a['domain'] ?? ''); + $bTld = self::extractTld($b['domain'] ?? ''); + + $aPriority = $priorityMap[$aTld] ?? PHP_INT_MAX; + $bPriority = $priorityMap[$bTld] ?? PHP_INT_MAX; + + if ($aPriority !== $bPriority) { + return $aPriority <=> $bPriority; + } + + return $aDomain <=> $bDomain; + }); + + return $results; + } + + /** + * Extract TLD from a domain name. + */ + private static function extractTld(string $domain): string + { + $parts = explode('.', strtolower($domain), 2); + + return $parts[1] ?? ''; + } + + private static function normalizeExactDomain(string $query): ?string + { + $query = strtolower(trim($query)); + + if ($query === '' || str_starts_with($query, '.') || str_ends_with($query, '.') || ! str_contains($query, '.')) { + return null; + } + + return preg_match('/^(?=.{1,253}$)(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/', $query) === 1 + ? $query + : null; + } + + /** + * @param array $result + */ + private static function isPremiumResult(array $result): bool + { + $premium = $result['premium'] ?? false; + + if (is_bool($premium)) { + return $premium; + } + + return in_array(strtolower(trim((string) $premium)), ['1', 'yes', 'true', 'premium'], true); + } + + /** + * @param list $tlds + * @return list + */ + private static function sortWithPriority(array $tlds): array + { + $priorityMap = array_flip(self::PRIORITY_TLDS); + $pricingMap = self::pricingMapForTlds($tlds); + + usort($tlds, static function (string $a, string $b) use ($priorityMap, $pricingMap): int { + $aPriority = $priorityMap[$a] ?? PHP_INT_MAX; + $bPriority = $priorityMap[$b] ?? PHP_INT_MAX; + + if ($aPriority !== $bPriority) { + return $aPriority <=> $bPriority; + } + + $aPrice = (int) ($pricingMap[$a]['register'] ?? 0); + $bPrice = (int) ($pricingMap[$b]['register'] ?? 0); + + if ($aPrice !== $bPrice) { + return $bPrice <=> $aPrice; + } + + return $a <=> $b; + }); + + return $tlds; + } + + /** + * @return array + */ + public static function resellerClubTldProductKeys(): array + { + return array_replace( + self::normalizeProductKeyMap(config('domain.resellerclub.tld_product_keys', [])), + self::normalizeProductKeyMap(config('mailinfra.resellerclub_tld_product_keys', [])) + ); + } + + public static function resellerClubProductKeyCacheTtlSeconds(): int + { + $ttl = (int) config('domain.resellerclub.product_key_cache_ttl_seconds', 86400); + + return $ttl > 0 ? $ttl : 86400; + } + + /** + * @param mixed $value + * @return list + */ + private static function normalizeTlds(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + return array_values(array_unique(array_filter(array_map( + static fn ($tld): string => ltrim(strtolower(trim((string) $tld)), '.'), + $value + )))); + } + + /** + * @param mixed $value + * @return array + */ + private static function normalizeProductKeyMap(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $map = []; + + foreach ($value as $tld => $productKey) { + $normalizedTld = ltrim(strtolower(trim((string) $tld)), '.'); + $normalizedProductKey = trim((string) $productKey); + + if ($normalizedTld === '' || $normalizedProductKey === '') { + continue; + } + + $map[$normalizedTld] = $normalizedProductKey; + } + + return $map; + } + + /** + * @return list + */ + private static function livePricedTlds(): array + { + try { + return app(DomainPricingService::class)->getLivePricedTlds(); + } catch (\Throwable) { + return []; + } + } + + /** + * @param list $tlds + * @return array + */ + private static function pricingMapForTlds(array $tlds): array + { + try { + return app(DomainPricingService::class)->getPricingForTlds($tlds); + } catch (\Throwable) { + return []; + } + } +} diff --git a/app/Support/DomainGlobeIcon.php b/app/Support/DomainGlobeIcon.php new file mode 100644 index 0000000..747265f --- /dev/null +++ b/app/Support/DomainGlobeIcon.php @@ -0,0 +1,28 @@ +' + .'' + .''; + } + + public static function svg(string $class = 'h-5 w-5'): string + { + return sprintf( + '', + e($class), + self::VIEW_BOX, + self::paths() + ); + } +} diff --git a/app/Support/Events/EventBadgeZpl.php b/app/Support/Events/EventBadgeZpl.php new file mode 100644 index 0000000..e98a10e --- /dev/null +++ b/app/Support/Events/EventBadgeZpl.php @@ -0,0 +1,70 @@ + $registrations */ + public static function forRegistrations(QrCode $qrCode, Collection $registrations): string + { + $content = $qrCode->content(); + $eventName = self::sanitize($content['name'] ?? $qrCode->label); + $size = $content['badge_size'] ?? '4x3'; + + // Label dimensions in dots @ 203 dpi. + [$widthDots, $heightDots] = match ($size) { + '4x6' => [812, 1218], + 'cr80' => [685, 431], + default => [812, 609], // 4x3 + }; + + $labels = []; + foreach ($registrations as $reg) { + $name = self::sanitize($reg->attendee_name); + $tier = self::sanitize($reg->tier_name); + $extra = collect($reg->badge_fields ?? []) + ->map(fn ($v, $k) => self::sanitize($k . ': ' . $v)) + ->implode(' | '); + + $zpl = "^XA\n"; + $zpl .= "^PW{$widthDots}\n"; + $zpl .= "^LL{$heightDots}\n"; + $zpl .= "^CI28\n"; // UTF-8 + // Event name (top) + $zpl .= "^FO40,40^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$eventName}^FS\n"; + // Attendee name (large, centered) + $zpl .= "^FO40,150^A0N,80,80^FB" . ($widthDots - 80) . ",2,0,C^FD{$name}^FS\n"; + // Tier + $zpl .= "^FO40,320^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$tier}^FS\n"; + // Extra fields + if ($extra !== '') { + $zpl .= "^FO40,375^A0N,28,28^FB" . ($widthDots - 80) . ",2,0,C^FD{$extra}^FS\n"; + } + // QR of the badge code (bottom) + $qrX = (int) (($widthDots / 2) - 70); + $zpl .= "^FO{$qrX}," . ($heightDots - 200) . "^BQN,2,5^FDLA,{$reg->badge_code}^FS\n"; + // Badge code text + $zpl .= "^FO40," . ($heightDots - 60) . "^A0N,34,34^FB" . ($widthDots - 80) . ",1,0,C^FD{$reg->badge_code}^FS\n"; + $zpl .= "^XZ\n"; + + $labels[] = $zpl; + } + + return implode("\n", $labels); + } + + private static function sanitize(string $value): string + { + // Escape ZPL control chars (^ and ~) and collapse whitespace. + $value = str_replace(['^', '~'], [' ', ' '], $value); + + return trim(preg_replace('/\s+/', ' ', $value) ?? ''); + } +} diff --git a/app/Support/LadillAppUrl.php b/app/Support/LadillAppUrl.php new file mode 100644 index 0000000..c894f57 --- /dev/null +++ b/app/Support/LadillAppUrl.php @@ -0,0 +1,15 @@ + */ + public static function tiers(): array + { + return array_map(fn ($t) => [ + 'mb' => (int) $t['mb'], + 'price_minor' => (int) $t['price_minor'], + 'label' => self::label((int) $t['mb']), + ], (array) config('email.quota_tiers', [])); + } + + public static function isValidQuota(int $mb): bool + { + foreach (self::tiers() as $t) { + if ($t['mb'] === $mb) { + return true; + } + } + + return false; + } + + public static function priceMinorFor(int $mb): int + { + foreach (self::tiers() as $t) { + if ($t['mb'] === $mb) { + return $t['price_minor']; + } + } + + return 0; + } + + /** A tier is free when its monthly price is zero (the 1 GB plan). */ + public static function isFree(int $mb): bool + { + return self::priceMinorFor($mb) === 0; + } + + /** The smallest free tier's quota (the default for new mailboxes). */ + public static function freeQuotaMb(): int + { + foreach (self::tiers() as $t) { + if ($t['price_minor'] === 0) { + return $t['mb']; + } + } + + return self::tiers()[0]['mb'] ?? 1024; + } + + public static function defaultQuotaMb(): int + { + $default = (int) config('email.default_quota_mb', 1024); + + return self::isValidQuota($default) ? $default : self::freeQuotaMb(); + } + + public static function label(int $mb): string + { + return $mb % 1024 === 0 ? ($mb / 1024).' GB' : $mb.' MB'; + } +} diff --git a/app/Support/MobileTopbar.php b/app/Support/MobileTopbar.php new file mode 100644 index 0000000..cc91c71 --- /dev/null +++ b/app/Support/MobileTopbar.php @@ -0,0 +1,19 @@ + $title, + ]; + } +} diff --git a/app/Support/Qr/EventBadgeZpl.php b/app/Support/Qr/EventBadgeZpl.php new file mode 100644 index 0000000..69929f0 --- /dev/null +++ b/app/Support/Qr/EventBadgeZpl.php @@ -0,0 +1,70 @@ + $registrations */ + public static function forRegistrations(QrCode $qrCode, Collection $registrations): string + { + $content = $qrCode->content(); + $eventName = self::sanitize($content['name'] ?? $qrCode->label); + $size = $content['badge_size'] ?? '4x3'; + + // Label dimensions in dots @ 203 dpi. + [$widthDots, $heightDots] = match ($size) { + '4x6' => [812, 1218], + 'cr80' => [685, 431], + default => [812, 609], // 4x3 + }; + + $labels = []; + foreach ($registrations as $reg) { + $name = self::sanitize($reg->attendee_name); + $tier = self::sanitize($reg->tier_name); + $extra = collect($reg->badge_fields ?? []) + ->map(fn ($v, $k) => self::sanitize($k . ': ' . $v)) + ->implode(' | '); + + $zpl = "^XA\n"; + $zpl .= "^PW{$widthDots}\n"; + $zpl .= "^LL{$heightDots}\n"; + $zpl .= "^CI28\n"; // UTF-8 + // Event name (top) + $zpl .= "^FO40,40^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$eventName}^FS\n"; + // Attendee name (large, centered) + $zpl .= "^FO40,150^A0N,80,80^FB" . ($widthDots - 80) . ",2,0,C^FD{$name}^FS\n"; + // Tier + $zpl .= "^FO40,320^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$tier}^FS\n"; + // Extra fields + if ($extra !== '') { + $zpl .= "^FO40,375^A0N,28,28^FB" . ($widthDots - 80) . ",2,0,C^FD{$extra}^FS\n"; + } + // QR of the badge code (bottom) + $qrX = (int) (($widthDots / 2) - 70); + $zpl .= "^FO{$qrX}," . ($heightDots - 200) . "^BQN,2,5^FDLA,{$reg->badge_code}^FS\n"; + // Badge code text + $zpl .= "^FO40," . ($heightDots - 60) . "^A0N,34,34^FB" . ($widthDots - 80) . ",1,0,C^FD{$reg->badge_code}^FS\n"; + $zpl .= "^XZ\n"; + + $labels[] = $zpl; + } + + return implode("\n", $labels); + } + + private static function sanitize(string $value): string + { + // Escape ZPL control chars (^ and ~) and collapse whitespace. + $value = str_replace(['^', '~'], [' ', ' '], $value); + + return trim(preg_replace('/\s+/', ' ', $value) ?? ''); + } +} diff --git a/app/Support/Qr/QrCornerStyleCatalog.php b/app/Support/Qr/QrCornerStyleCatalog.php new file mode 100644 index 0000000..0e57264 --- /dev/null +++ b/app/Support/Qr/QrCornerStyleCatalog.php @@ -0,0 +1,36 @@ + */ + public static function outerStyles(): array + { + return [ + 'square' => ['label' => 'Square eye'], + 'rounded' => ['label' => 'Rounded'], + 'circle' => ['label' => 'Circular'], + ]; + } + + /** @return array */ + public static function innerStyles(): array + { + return [ + 'square' => ['label' => 'Square'], + 'rounded' => ['label' => 'Rounded'], + 'dot' => ['label' => 'Dot'], + ]; + } + + public static function isValidOuter(string $style): bool + { + return isset(self::outerStyles()[$style]); + } + + public static function isValidInner(string $style): bool + { + return isset(self::innerStyles()[$style]); + } +} diff --git a/app/Support/Qr/QrCoverImageSpec.php b/app/Support/Qr/QrCoverImageSpec.php new file mode 100644 index 0000000..aa1af9d --- /dev/null +++ b/app/Support/Qr/QrCoverImageSpec.php @@ -0,0 +1,20 @@ + self::BOOK, + default => self::BANNER, + }; + } +} diff --git a/app/Support/Qr/QrDateFormatter.php b/app/Support/Qr/QrDateFormatter.php new file mode 100644 index 0000000..f672ba4 --- /dev/null +++ b/app/Support/Qr/QrDateFormatter.php @@ -0,0 +1,55 @@ +format('Y-m-d'); + } catch (\Throwable) { + return ''; + } + } + + public static function forDisplay(?string $value): string + { + if ($value === null || trim($value) === '') { + return ''; + } + + $value = trim($value); + + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + try { + return Carbon::createFromFormat('Y-m-d', $value)->format('D, j M Y'); + } catch (\Throwable) { + return $value; + } + } + + return $value; + } + + public static function normalize(?string $value, int $maxLength = 60): string + { + if ($value === null || trim($value) === '') { + return ''; + } + + $value = trim($value); + + try { + return Carbon::parse($value)->format('Y-m-d'); + } catch (\Throwable) { + return mb_substr($value, 0, $maxLength); + } + } +} diff --git a/app/Support/Qr/QrFrameStyleCatalog.php b/app/Support/Qr/QrFrameStyleCatalog.php new file mode 100644 index 0000000..aa6d660 --- /dev/null +++ b/app/Support/Qr/QrFrameStyleCatalog.php @@ -0,0 +1,63 @@ + */ + public static function all(): array + { + return [ + 'none' => [ + 'label' => 'None', + 'description' => 'Code only', + 'border_px' => 0, + 'mode' => 'none', + ], + 'thin' => [ + 'label' => 'Border', + 'description' => 'Simple white sticker edge', + 'border_px' => 8, + 'mode' => 'border', + ], + 'bold' => [ + 'label' => 'Wide border', + 'description' => 'Legacy wide border', + 'border_px' => 16, + 'mode' => 'border', + 'visible' => false, + ], + 'scan_me' => [ + 'label' => 'Scan me', + 'description' => 'CTA sticker below code', + 'border_px' => 14, + 'mode' => 'label', + 'cta' => 'SCAN ME', + ], + 'tap_to_scan' => [ + 'label' => 'Tap to scan', + 'description' => 'Rounded CTA sticker', + 'border_px' => 14, + 'mode' => 'pill', + 'cta' => 'TAP TO SCAN', + ], + ]; + } + + /** @return array */ + public static function visible(): array + { + return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true); + } + + /** @return list */ + public static function keys(): array + { + return array_keys(self::all()); + } + + public static function isValid(string $style): bool + { + return isset(self::all()[$style]); + } +} diff --git a/app/Support/Qr/QrModuleStyleCatalog.php b/app/Support/Qr/QrModuleStyleCatalog.php new file mode 100644 index 0000000..8d0e166 --- /dev/null +++ b/app/Support/Qr/QrModuleStyleCatalog.php @@ -0,0 +1,93 @@ + */ + public static function all(): array + { + return [ + 'square' => [ + 'label' => 'Standard', + 'description' => 'Traditional square QR modules', + 'circular' => false, + 'circle_radius' => 0.4, + 'connect_paths' => false, + 'scan_risk' => 'low', + ], + 'soft' => [ + 'label' => 'Rounded', + 'description' => 'Soft rounded modules', + 'circular' => true, + 'circle_radius' => 0.36, + 'connect_paths' => false, + 'scan_risk' => 'medium', + 'visible' => false, + ], + 'dots' => [ + 'label' => 'Dots', + 'description' => 'Round dot modules', + 'circular' => true, + 'circle_radius' => 0.45, + 'connect_paths' => false, + 'scan_risk' => 'medium', + ], + 'bubble' => [ + 'label' => 'Large dots', + 'description' => 'Bolder round dot modules', + 'circular' => true, + 'circle_radius' => 0.52, + 'connect_paths' => false, + 'scan_risk' => 'high', + 'visible' => false, + ], + 'fluid' => [ + 'label' => 'Fluid', + 'description' => 'Legacy decorative style', + 'circular' => true, + 'circle_radius' => 0.38, + 'connect_paths' => true, + 'scan_risk' => 'high', + 'visible' => false, + ], + 'bold' => [ + 'label' => 'Bold', + 'description' => 'Legacy thick square modules', + 'circular' => false, + 'circle_radius' => 0.4, + 'connect_paths' => false, + 'scan_risk' => 'low', + 'visible' => false, + ], + ]; + } + + /** @return array */ + public static function visible(): array + { + return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true); + } + + /** @return list */ + public static function keys(): array + { + return array_keys(self::all()); + } + + public static function isValid(string $style): bool + { + return isset(self::all()[$style]); + } + + /** @return array */ + public static function optionsFor(string $style): array + { + return self::all()[$style] ?? self::all()['square']; + } + + public static function isDecorative(string $style): bool + { + return in_array(self::optionsFor($style)['scan_risk'], ['medium', 'high'], true); + } +} diff --git a/app/Support/Qr/QrScanReliability.php b/app/Support/Qr/QrScanReliability.php new file mode 100644 index 0000000..9a56cd6 --- /dev/null +++ b/app/Support/Qr/QrScanReliability.php @@ -0,0 +1,102 @@ + $style */ + public static function contrastRatio(array $style): float + { + $fg = self::relativeLuminance((string) ($style['foreground'] ?? '#000000')); + $bg = self::relativeLuminance((string) ($style['background'] ?? '#ffffff')); + + $lighter = max($fg, $bg); + $darker = min($fg, $bg); + + return ($lighter + 0.05) / ($darker + 0.05); + } + + /** + * @param array $style + * @return array{level: string, messages: list} + */ + public static function assess(array $style): array + { + $style = QrStyleDefaults::merge($style); + $messages = []; + $level = 'good'; + + $contrast = self::contrastRatio($style); + $isClassic = self::isClassicBlackOnWhite($style); + $moduleStyle = (string) ($style['module_style'] ?? 'square'); + $moduleMeta = QrModuleStyleCatalog::optionsFor($moduleStyle); + + if ($moduleMeta['scan_risk'] === 'medium') { + $messages[] = 'This module style may not scan on every phone camera. Square is the safest choice.'; + $level = 'fair'; + } + + if ($moduleMeta['scan_risk'] === 'high') { + $messages[] = 'Decorative styles like ' . $moduleMeta['label'] . ' often fail on Samsung Camera. Use square for print and signage.'; + $level = 'poor'; + } + + if (! $isClassic) { + $messages[] = 'Colored QR codes often fail on Samsung Camera and other basic scanners. Black on white works everywhere.'; + $level = $level === 'good' ? 'fair' : 'poor'; + } + + if ($contrast < 4.5) { + $messages[] = sprintf( + 'Contrast is low (%.1f:1). Aim for at least 4.5:1 — dark foreground, white background.', + $contrast, + ); + $level = 'poor'; + } + + if ($moduleMeta['scan_risk'] !== 'low' && ! $isClassic) { + $level = 'poor'; + } + + if ($messages === []) { + $messages[] = 'Black square modules on white give the best compatibility with Samsung, iPhone, and printed codes.'; + } + + return ['level' => $level, 'messages' => $messages]; + } + + /** @param array $style */ + public static function isClassicBlackOnWhite(array $style): bool + { + $fg = strtolower(ltrim((string) ($style['foreground'] ?? ''), '#')); + $bg = strtolower(ltrim((string) ($style['background'] ?? ''), '#')); + + $fg = strlen($fg) === 3 ? $fg[0] . $fg[0] . $fg[1] . $fg[1] . $fg[2] . $fg[2] : $fg; + $bg = strlen($bg) === 3 ? $bg[0] . $bg[0] . $bg[1] . $bg[1] . $bg[2] . $bg[2] : $bg; + + return in_array($fg, ['000000', '0f172a', '111111', '1a1a1a'], true) + && in_array($bg, ['ffffff', 'fff'], true); + } + + private static function relativeLuminance(string $hex): float + { + $hex = ltrim(trim($hex), '#'); + if (strlen($hex) === 3) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + + if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) { + return 0.0; + } + + $channels = []; + foreach ([0, 2, 4] as $offset) { + $value = hexdec(substr($hex, $offset, 2)) / 255; + $channels[] = $value <= 0.03928 + ? $value / 12.92 + : (($value + 0.055) / 1.055) ** 2.4; + } + + return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]); + } +} diff --git a/app/Support/Qr/QrStyleDefaults.php b/app/Support/Qr/QrStyleDefaults.php new file mode 100644 index 0000000..c0dd045 --- /dev/null +++ b/app/Support/Qr/QrStyleDefaults.php @@ -0,0 +1,114 @@ + */ + public static function defaults(): array + { + return [ + 'foreground' => '#000000', + 'background' => '#ffffff', + 'error_correction' => 'M', + 'margin' => 4, + 'module_style' => 'square', + 'finder_outer' => 'square', + 'finder_inner' => 'square', + 'frame_style' => 'none', + 'frame_text' => '', + 'frame_color' => '#000000', + 'scale' => 8, + 'logo_path' => null, + 'gradient_type' => 'none', + 'gradient_color1' => '#000000', + 'gradient_color2' => '#7c3aed', + 'gradient_rotation' => 45, + 'logo_size' => 0.3, + 'logo_margin' => 5, + 'logo_white_bg' => false, + 'logo_shape' => 'none', + ]; + } + + /** @param array|null $style */ + public static function merge(?array $style): array + { + $merged = array_merge(self::defaults(), $style ?? []); + + if (! in_array($merged['error_correction'], ['L', 'M', 'Q', 'H'], true)) { + $merged['error_correction'] = 'M'; + } + + if (! in_array($merged['module_style'], QrModuleStyleCatalog::keys(), true)) { + $merged['module_style'] = 'square'; + } + + if (! QrCornerStyleCatalog::isValidOuter((string) ($merged['finder_outer'] ?? 'square'))) { + $merged['finder_outer'] = 'square'; + } + + if (! QrCornerStyleCatalog::isValidInner((string) ($merged['finder_inner'] ?? 'square'))) { + $merged['finder_inner'] = 'square'; + } + + if (! QrFrameStyleCatalog::isValid((string) ($merged['frame_style'] ?? 'none'))) { + $merged['frame_style'] = 'none'; + } + + $merged['frame_text'] = substr(trim((string) ($merged['frame_text'] ?? '')), 0, 100); + $frameColor = trim((string) ($merged['frame_color'] ?? '#000000')); + $merged['frame_color'] = preg_match('/^#[0-9a-fA-F]{6}$/', $frameColor) ? $frameColor : '#000000'; + + if (! in_array($merged['gradient_type'], ['none', 'linear', 'radial'], true)) { + $merged['gradient_type'] = 'none'; + } + + $merged['logo_size'] = max(0.1, min(0.4, (float) ($merged['logo_size'] ?? 0.3))); + $merged['logo_margin'] = max(0, min(15, (int) ($merged['logo_margin'] ?? 5))); + if (! in_array($merged['logo_shape'], ['none', 'rounded', 'circle'], true)) { + $merged['logo_shape'] = 'none'; + } + $merged['logo_white_bg'] = (bool) ($merged['logo_white_bg'] ?? false); + + if ($merged['module_style'] === 'bold') { + $merged['scale'] = min(16, (int) $merged['scale'] + 2); + } + + $merged['margin'] = max(0, min(10, (int) $merged['margin'])); + $merged['scale'] = max(4, min(16, (int) $merged['scale'])); + + return $merged; + } + + /** @param array|null $style */ + public static function mergeForRender(?array $style): array + { + return QrStyleNormalizer::normalize(self::merge($style)); + } + + /** @param array $style */ + public static function recommendedEcc(array $style): string + { + $ecc = (string) ($style['error_correction'] ?? 'M'); + $order = ['L' => 0, 'M' => 1, 'Q' => 2, 'H' => 3]; + $minimum = 'M'; + $moduleStyle = (string) ($style['module_style'] ?? 'square'); + + if (in_array($moduleStyle, ['bubble', 'fluid'], true)) { + $minimum = 'H'; + } elseif (QrModuleStyleCatalog::isDecorative($moduleStyle)) { + $minimum = 'Q'; + } + + if (! empty($style['logo_path'])) { + $minimum = 'H'; + } + + if (QrScanReliability::contrastRatio($style) < 4.5) { + $minimum = 'H'; + } + + return ($order[$minimum] ?? 1) > ($order[$ecc] ?? 1) ? $minimum : $ecc; + } +} diff --git a/app/Support/Qr/QrStyleNormalizer.php b/app/Support/Qr/QrStyleNormalizer.php new file mode 100644 index 0000000..5742b56 --- /dev/null +++ b/app/Support/Qr/QrStyleNormalizer.php @@ -0,0 +1,138 @@ + $style + * @return array + */ + public static function normalize(array $style): array + { + $style = self::ensureContrast($style); + $style = self::ensureQuietZone($style); + $style = self::ensureRenderScale($style); + + $style['error_correction'] = QrStyleDefaults::recommendedEcc($style); + + return $style; + } + + /** @param array $style */ + private static function ensureContrast(array $style): array + { + $target = 4.5; + $attempts = 0; + + while (QrScanReliability::contrastRatio($style) < $target && $attempts < 16) { + $fg = self::hexToRgb((string) $style['foreground']); + $bg = self::hexToRgb((string) $style['background']); + + if (self::relativeLuminance($fg) > self::relativeLuminance($bg)) { + $style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.18)); + } else { + $style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.15)); + $style['background'] = self::rgbToHex(self::lightenToward($bg, 0.15)); + } + + $attempts++; + } + + if (QrScanReliability::contrastRatio($style) < $target) { + $style['foreground'] = '#000000'; + $style['background'] = '#ffffff'; + } + + return $style; + } + + /** @param array $style */ + private static function ensureQuietZone(array $style): array + { + $margin = (int) ($style['margin'] ?? 4); + $moduleStyle = (string) ($style['module_style'] ?? 'square'); + + if (QrModuleStyleCatalog::isDecorative($moduleStyle)) { + $margin = max($margin, 5); + } + + $style['margin'] = max(4, min(10, $margin)); + + return $style; + } + + /** @param array $style */ + private static function ensureRenderScale(array $style): array + { + $scale = (int) ($style['scale'] ?? 8); + $module = QrModuleStyleCatalog::optionsFor((string) ($style['module_style'] ?? 'square')); + + if ($module['circular'] ?? false) { + $scale = max($scale, 10); + } + + if (! empty($style['logo_path'])) { + $scale = max($scale, 10); + } + + $style['scale'] = max(6, min(16, $scale)); + + return $style; + } + + /** @return array{0: int, 1: int, 2: int} */ + private static function hexToRgb(string $hex): array + { + $hex = ltrim(trim($hex), '#'); + if (strlen($hex) === 3) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + + if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) { + return [0, 0, 0]; + } + + return [hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2))]; + } + + /** @param array{0: int, 1: int, 2: int} $rgb */ + private static function rgbToHex(array $rgb): string + { + return sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]); + } + + /** @param array{0: int, 1: int, 2: int} $rgb */ + private static function darkenToward(array $rgb, float $amount): array + { + return [ + (int) max(0, $rgb[0] * (1 - $amount)), + (int) max(0, $rgb[1] * (1 - $amount)), + (int) max(0, $rgb[2] * (1 - $amount)), + ]; + } + + /** @param array{0: int, 1: int, 2: int} $rgb */ + private static function lightenToward(array $rgb, float $amount): array + { + return [ + (int) min(255, $rgb[0] + ((255 - $rgb[0]) * $amount)), + (int) min(255, $rgb[1] + ((255 - $rgb[1]) * $amount)), + (int) min(255, $rgb[2] + ((255 - $rgb[2]) * $amount)), + ]; + } + + /** @param array{0: int, 1: int, 2: int} $rgb */ + private static function relativeLuminance(array $rgb): float + { + $channels = []; + foreach ($rgb as $value) { + $v = $value / 255; + $channels[] = $v <= 0.03928 ? $v / 12.92 : (($v + 0.055) / 1.055) ** 2.4; + } + + return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]); + } +} diff --git a/app/Support/Qr/QrTypeCatalog.php b/app/Support/Qr/QrTypeCatalog.php new file mode 100644 index 0000000..7fd0c21 --- /dev/null +++ b/app/Support/Qr/QrTypeCatalog.php @@ -0,0 +1,58 @@ + */ + public static function paymentTypes(): array + { + return [QrCode::TYPE_PAYMENT]; + } + + /** @return array */ + public static function all(): array + { + return [ + QrCode::TYPE_PAYMENT => [ + 'label' => 'Payment QR', + 'description' => 'A static QR customers scan to pay you any amount', + 'category' => 'payments', + 'icon' => 'payment.svg', + ], + ]; + } + + /** @return list */ + public static function keys(): array + { + return array_keys(self::all()); + } + + public static function label(string $type): string + { + return self::all()[$type]['label'] ?? ucfirst(str_replace('_', ' ', $type)); + } + + public static function isValid(string $type): bool + { + return isset(self::all()[$type]); + } + + public static function iconUrl(string $icon): string + { + $path = public_path('images/qr-icons/'.$icon); + $url = '/images/qr-icons/'.$icon; + + if (is_file($path)) { + return $url.'?v='.filemtime($path); + } + + return $url; + } +} diff --git a/app/Support/Qr/QrWifiPayload.php b/app/Support/Qr/QrWifiPayload.php new file mode 100644 index 0000000..e6b4180 --- /dev/null +++ b/app/Support/Qr/QrWifiPayload.php @@ -0,0 +1,45 @@ + $content + */ + public static function encode(array $content): string + { + $encryption = strtoupper(trim((string) ($content['encryption'] ?? 'WPA'))); + $auth = $encryption === 'NOPASS' ? 'nopass' : $encryption; + if (! in_array($auth, ['WPA', 'WEP', 'nopass'], true)) { + $auth = 'WPA'; + } + + $ssid = self::escape(trim((string) ($content['ssid'] ?? ''))); + $payload = 'WIFI:T:' . $auth . ';S:' . $ssid; + + if ($auth !== 'nopass') { + $password = trim((string) ($content['password'] ?? '')); + if ($password !== '') { + $payload .= ';P:' . self::escape($password); + } + } + + if (filter_var($content['hidden'] ?? false, FILTER_VALIDATE_BOOL)) { + $payload .= ';H:true'; + } + + return $payload . ';;'; + } + + private static function escape(string $value): string + { + return str_replace( + ['\\', ';', ',', '"', ':'], + ['\\\\', '\\;', '\\,', '\\"', '\\:'], + $value + ); + } +} diff --git a/app/Support/ResellerClubLegacy.php b/app/Support/ResellerClubLegacy.php new file mode 100644 index 0000000..44f6f96 --- /dev/null +++ b/app/Support/ResellerClubLegacy.php @@ -0,0 +1,122 @@ +order_type === RcServiceOrder::TYPE_RENEWAL) { + return true; + } + + return (bool) data_get($order->meta, 'is_renewal', false); + } + + public static function isRenewalDomainOrder(DomainOrder $order): bool + { + return $order->order_type === DomainOrder::TYPE_RENEWAL; + } + + public static function canAutomateFulfillment(RcServiceOrder $order): bool + { + if (! self::integrationEnabled()) { + return false; + } + + if (self::newOrdersEnabled()) { + return true; + } + + return self::renewalsEnabled() && self::isRenewalRcServiceOrder($order); + } + + /** + * @param Collection|iterable $items + */ + public static function assertCartCheckoutAllowed(iterable $items): void + { + $items = $items instanceof Collection ? $items : collect($items); + + if ($items->isEmpty()) { + throw new RuntimeException('Your cart is empty.'); + } + + if (self::newOrdersEnabled()) { + return; + } + + if (! self::renewalsEnabled()) { + throw new RuntimeException(self::renewalsDisabledMessage()); + } + + if ($items->contains(fn (RcServiceOrder $item) => ! self::isRenewalRcServiceOrder($item))) { + throw new RuntimeException( + 'ResellerClub checkout is limited to renewals for existing services. ' + .'New product purchases use our current domain and hosting products instead.' + ); + } + } + + public static function assertNewSaleAllowed(): void + { + if (self::newOrdersEnabled()) { + return; + } + + throw new RuntimeException( + 'ResellerClub is no longer available for new product purchases. ' + .'Use our domain and hosting products instead, or renew an existing ResellerClub service from your dashboard.' + ); + } + + public static function assertRenewalsAllowed(): void + { + if (! self::renewalsEnabled()) { + throw new RuntimeException(self::renewalsDisabledMessage()); + } + } + + /** @deprecated Use assertNewSaleAllowed() */ + public static function assertNewOrdersAllowed(): void + { + self::assertNewSaleAllowed(); + } + + public static function renewalsDisabledMessage(): string + { + return 'ResellerClub renewals are not available at this time. Please contact support if you need assistance.'; + } + + public static function fulfillmentDisabledMessage(): string + { + return 'Automated ResellerClub fulfillment for new purchases is no longer available. ' + .'Open a support ticket if you need help with a legacy order.'; + } +} diff --git a/app/Support/UserProfileMenu.php b/app/Support/UserProfileMenu.php new file mode 100644 index 0000000..2806cdb --- /dev/null +++ b/app/Support/UserProfileMenu.php @@ -0,0 +1,164 @@ +> + */ + 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); + } +} diff --git a/app/Support/helpers.php b/app/Support/helpers.php new file mode 100644 index 0000000..1bd5a31 --- /dev/null +++ b/app/Support/helpers.php @@ -0,0 +1,40 @@ +attributes->get('actingAccount') ?? $request->user(); + } +} + +if (! function_exists('ladill_domains_url')) { + function ladill_domains_url(string $path = ''): string + { + return 'https://'.config('app.domains_domain').($path !== '' ? '/'.ltrim($path, '/') : ''); + } +} + +if (! function_exists('ladill_account_url')) { + function ladill_account_url(string $path = ''): string + { + return 'https://'.config('app.account_domain').($path !== '' ? '/'.ltrim($path, '/') : ''); + } +} + +if (! function_exists('pos_money')) { + function pos_money(?int $minor, ?string $currency = null): string + { + $currency = $currency ?: config('pos.default_currency', 'GHS'); + + return $currency.' '.number_format(((int) $minor) / 100, 2); + } +} diff --git a/app/View/Components/UserLayout.php b/app/View/Components/UserLayout.php new file mode 100644 index 0000000..5d71f0f --- /dev/null +++ b/app/View/Components/UserLayout.php @@ -0,0 +1,14 @@ +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..5955084 --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,28 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [ + 'redirect' => $request->fullUrl(), + ])); + $middleware->web(append: [ + \App\Http\Middleware\SetActingAccount::class, + ]); + $middleware->alias([ + 'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 0000000..fc94ae6 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,7 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "chillerlan/php-qrcode", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-qrcode.git", + "reference": "7b66282572fc14075c0507d74d9837dab25b38d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/7b66282572fc14075c0507d74d9837dab25b38d6", + "reference": "7b66282572fc14075c0507d74d9837dab25b38d6", + "shasum": "" + }, + "require": { + "chillerlan/php-settings-container": "^2.1.6 || ^3.2.1", + "ext-mbstring": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "chillerlan/php-authenticator": "^4.3.1 || ^5.2.1", + "ext-fileinfo": "*", + "phan/phan": "^5.5.2", + "phpcompatibility/php-compatibility": "10.x-dev", + "phpmd/phpmd": "^2.15", + "phpunit/phpunit": "^9.6", + "setasign/fpdf": "^1.8.2", + "slevomat/coding-standard": "^8.23.0", + "squizlabs/php_codesniffer": "^4.0.0" + }, + "suggest": { + "chillerlan/php-authenticator": "Yet another Google authenticator! Also creates URIs for mobile apps.", + "setasign/fpdf": "Required to use the QR FPDF output.", + "simple-icons/simple-icons": "SVG icons that you can use to embed as logos in the QR Code" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\QRCode\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT", + "Apache-2.0" + ], + "authors": [ + { + "name": "Kazuhiko Arase", + "homepage": "https://github.com/kazuhikoarase/qrcode-generator" + }, + { + "name": "ZXing Authors", + "homepage": "https://github.com/zxing/zxing" + }, + { + "name": "Ashot Khanamiryan", + "homepage": "https://github.com/khanamiryan/php-qrcode-detector-decoder" + }, + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + }, + { + "name": "Contributors", + "homepage": "https://github.com/chillerlan/php-qrcode/graphs/contributors" + } + ], + "description": "A QR Code generator and reader with a user-friendly API. PHP 7.4+", + "homepage": "https://github.com/chillerlan/php-qrcode", + "keywords": [ + "phpqrcode", + "qr", + "qr code", + "qr-reader", + "qrcode", + "qrcode-generator", + "qrcode-reader" + ], + "support": { + "docs": "https://php-qrcode.readthedocs.io", + "issues": "https://github.com/chillerlan/php-qrcode/issues", + "source": "https://github.com/chillerlan/php-qrcode" + }, + "funding": [ + { + "url": "https://ko-fi.com/codemasher", + "type": "Ko-Fi" + } + ], + "time": "2025-11-23T23:51:44+00:00" + }, + { + "name": "chillerlan/php-settings-container", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-settings-container.git", + "reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/a0a487cbf5344f721eb504bf0f59bada40c381b7", + "reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.1" + }, + "require-dev": { + "phan/phan": "^5.5.2", + "phpmd/phpmd": "^2.15", + "phpstan/phpstan": "^2.1.31", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpunit/phpunit": "^10.5", + "slevomat/coding-standard": "^8.22", + "squizlabs/php_codesniffer": "^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\Settings\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + } + ], + "description": "A container class for immutable settings objects. Not a DI container.", + "homepage": "https://github.com/chillerlan/php-settings-container", + "keywords": [ + "Settings", + "configuration", + "container", + "helper", + "property hook" + ], + "support": { + "issues": "https://github.com/chillerlan/php-settings-container/issues", + "source": "https://github.com/chillerlan/php-settings-container" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate?hosted_button_id=WLYUNAT9ZTJZ4", + "type": "custom" + }, + { + "url": "https://ko-fi.com/codemasher", + "type": "ko_fi" + } + ], + "time": "2026-03-20T21:10:52+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.11.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "c987f8ce84b8434fa430795eca0f3430663da72b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/c987f8ce84b8434fa430795eca0f3430663da72b", + "reference": "c987f8ce84b8434fa430795eca0f3430663da72b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.11", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.4", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.11.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:40:51+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:23:43+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.11.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.11.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:30:48+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/eef7f87bab6f204eba3c39224d8075c70c637946", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.6" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-05-23T22:00:21+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.61.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "e8472ca9774452fe50841d9bdced060679f4d58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/e8472ca9774452fe50841d9bdced060679f4d58d", + "reference": "e8472ca9774452fe50841d9bdced060679f4d58d", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.9.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.1.41", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-06-04T14:22:52+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.18", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.18" + }, + "time": "2026-05-19T00:47:18+00:00" + }, + { + "name": "laravel/sanctum", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-04-30T11:46:25+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.13", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-04-16T14:03:50+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.11.1" + }, + "time": "2026-02-06T14:12:35+00:00" + }, + { + "name": "league/commonmark", + "version": "2.8.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-03-19T13:16:38+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.34.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" + }, + "time": "2026-05-14T10:28:08+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.11.4", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-04-07T09:57:54+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.4", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.4" + }, + "time": "2026-05-11T20:49:54+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.52", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-04-27T07:02:15+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.23", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" + }, + "time": "2026-05-23T13:41:31+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.2", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "8429c78ca35a09f27565311b98101e2826affde0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.2" + }, + "time": "2025-12-14T04:43:48+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T08:56:14+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "e0be088d22278583a82da281886e8c3592fbf149" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "bc354f47c62301e990b7874fa662326368508e2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", + "reference": "bc354f47c62301e990b7874fa662326368508e2c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T11:20:33+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "9df847980c436451f4f51d1284491bb4356dd989" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", + "reference": "9df847980c436451f4f51d1284491bb4356dd989", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T08:31:43+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff", + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T07:20:23+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:22:37+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "dc21118016c039a66235cf93d96b435ffb282412" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T15:22:23+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "8339098cae28673c15cce00d80734af0453054e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", + "reference": "8339098cae28673c15cce00d80734af0453054e2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T02:25:22+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "f5804be144caceb570f6747519999636b664f24c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T11:20:33+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2676b524340abcfe4d6151ec698463cebafee439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-30T15:19:22+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T13:44:50+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.29.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.3" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-04-20T15:26:14+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.62.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", + "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2026-05-27T04:02:01+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.4", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.8 || ^8.0.8" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-04-21T14:04:20+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/afia.php b/config/afia.php new file mode 100644 index 0000000..2ac107c --- /dev/null +++ b/config/afia.php @@ -0,0 +1,10 @@ + env('AFIA_PRODUCT', 'mini'), + 'enabled' => (bool) env('AFIA_ENABLED', true), + 'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic + 'model' => env('AFIA_MODEL', 'gpt-4o-mini'), + 'api_key' => env('AFIA_API_KEY'), +]; diff --git a/config/android_app_links.php b/config/android_app_links.php new file mode 100644 index 0000000..7401ae4 --- /dev/null +++ b/config/android_app_links.php @@ -0,0 +1,25 @@ + env('ANDROID_APP_PACKAGE', 'com.ladill.mini'), + + 'sha256_cert_fingerprints' => array_values(array_filter(array_map( + static fn (string $fingerprint): string => strtoupper(trim($fingerprint)), + explode(',', (string) env('ANDROID_APP_SHA256_FINGERPRINTS', '')), + ))), + +]; diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..737118c --- /dev/null +++ b/config/app.php @@ -0,0 +1,134 @@ + env('APP_NAME', 'Ladill Mini'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + + // Platform identity surfaces (Zoho One model). Ladill Mini (mini.ladill.com). + 'platform_domain' => env('PLATFORM_DOMAIN', parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com'), + 'auth_domain' => env('AUTH_DOMAIN', 'auth.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), + 'account_domain' => env('ACCOUNT_DOMAIN', 'account.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), + 'mini_domain' => env('MINI_DOMAIN', parse_url((string) env('APP_URL', 'https://mini.ladill.com'), PHP_URL_HOST) ?: 'mini.ladill.com'), + 'servers_domain' => env('SERVERS_DOMAIN', 'servers.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), + 'domains_domain' => env('DOMAINS_DOMAIN', 'domains.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..d7568ff --- /dev/null +++ b/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/billing.php b/config/billing.php new file mode 100644 index 0000000..16008d9 --- /dev/null +++ b/config/billing.php @@ -0,0 +1,10 @@ + env('BILLING_API_URL', 'https://ladill.com/api/billing'), + 'api_key' => env('BILLING_API_KEY_POS'), + 'service' => 'pos', + 'wallet_balance_route' => 'wallet.balance', + 'currency' => 'GHS', + 'platform_settings_connection' => env('BILLING_PLATFORM_SETTINGS_CONNECTION', 'platform'), +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..b32aead --- /dev/null +++ b/config/cache.php @@ -0,0 +1,117 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", + | "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + +]; diff --git a/config/crm.php b/config/crm.php new file mode 100644 index 0000000..fa41030 --- /dev/null +++ b/config/crm.php @@ -0,0 +1,6 @@ + rtrim((string) env('CRM_API_URL', 'https://crm.ladill.com/api'), '/'), + 'key' => env('CRM_API_KEY_POS'), +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..0c484c1 --- /dev/null +++ b/config/database.php @@ -0,0 +1,220 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + // Read-only access to platform admin settings (Paystack keys, etc.). + 'platform' => [ + 'driver' => 'mysql', + 'host' => env('PLATFORM_DB_HOST', env('DB_HOST', '127.0.0.1')), + 'port' => env('PLATFORM_DB_PORT', env('DB_PORT', '3306')), + 'database' => env('PLATFORM_DB_DATABASE', 'ladilldb'), + 'username' => env('PLATFORM_DB_USERNAME', env('DB_USERNAME', 'root')), + 'password' => env('PLATFORM_DB_PASSWORD', env('DB_PASSWORD', '')), + 'unix_socket' => env('PLATFORM_DB_SOCKET', env('DB_SOCKET', '')), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + // Read-only access to Merchant storefront catalog (shop/menu QR payloads). + 'merchant' => [ + 'driver' => 'mysql', + 'host' => env('MERCHANT_DB_HOST', env('DB_HOST', '127.0.0.1')), + 'port' => env('MERCHANT_DB_PORT', env('DB_PORT', '3306')), + 'database' => env('MERCHANT_DB_DATABASE', 'ladill_merchant'), + 'username' => env('MERCHANT_DB_USERNAME', env('DB_USERNAME', 'root')), + 'password' => env('MERCHANT_DB_PASSWORD', env('DB_PASSWORD', '')), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/config/domain.php b/config/domain.php new file mode 100644 index 0000000..6e51838 --- /dev/null +++ b/config/domain.php @@ -0,0 +1,6 @@ + env('DOMAIN_API_URL', 'https://ladill.com/api/domains'), + 'api_key' => env('DOMAIN_API_KEY_HOSTING'), +]; diff --git a/config/email.php b/config/email.php new file mode 100644 index 0000000..de2a4ea --- /dev/null +++ b/config/email.php @@ -0,0 +1,21 @@ + env('EMAIL_CURRENCY', 'GHS'), + 'default_quota_mb' => (int) env('EMAIL_DEFAULT_QUOTA_MB', 1024), // new mailboxes start on the free 1 GB tier + + // Storage tiers: quota (MB) → monthly price (minor units). 1 GB is free. + 'quota_tiers' => [ + ['mb' => 1024, 'price_minor' => 0], // 1 GB — Free forever + ['mb' => 5120, 'price_minor' => 1000], // 5 GB — GHS 10 + ['mb' => 10240, 'price_minor' => 2000], // 10 GB — GHS 20 + ['mb' => 25600, 'price_minor' => 3000], // 25 GB — GHS 30 + ['mb' => 51200, 'price_minor' => 6000], // 50 GB — GHS 60 + ], +]; diff --git a/config/emaildomain.php b/config/emaildomain.php new file mode 100644 index 0000000..69d5cd6 --- /dev/null +++ b/config/emaildomain.php @@ -0,0 +1,7 @@ + env('EMAILDOMAIN_API_URL', 'https://ladill.com/api/email-domains'), + 'api_key' => env('EMAILDOMAIN_API_KEY_EMAIL'), +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..4ebaa0b --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,87 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + 'qr' => [ + 'driver' => 'local', + 'root' => storage_path('app/private/qr'), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/hosting.php b/config/hosting.php new file mode 100644 index 0000000..a0b0375 --- /dev/null +++ b/config/hosting.php @@ -0,0 +1,703 @@ + [ + 'client_id' => env('CONTABO_CLIENT_ID'), + 'client_secret' => env('CONTABO_CLIENT_SECRET'), + 'api_user' => env('CONTABO_API_USER'), + 'api_password' => env('CONTABO_API_PASSWORD'), + 'product_catalog_endpoint' => env('CONTABO_PRODUCT_CATALOG_ENDPOINT'), + ], + + /* + |-------------------------------------------------------------------------- + | Pricing Configuration + |-------------------------------------------------------------------------- + | + | Dynamic pricing settings for VPS/Dedicated servers. + | Base prices from Contabo are in USD, converted to GHS using exchange rate. + | + */ + 'pricing' => [ + 'base_currency' => 'USD', + 'display_currency' => 'GHS', + + // Fallback rate used when live API fails + 'fallback_usd_to_ghs_rate' => env('FALLBACK_USD_TO_GHS_RATE', 15.50), + + // Profit margins (percentage on top of converted Contabo USD price) + 'margins' => [ + 'vps' => env('VPS_PROFIT_MARGIN', 45), + 'dedicated' => env('DEDICATED_PROFIT_MARGIN', 30), + ], + + 'contabo_price_cache_ttl' => env('CONTABO_PRICE_CACHE_TTL', 3600), + + 'term_discounts' => [ + 'quarterly' => 5, + 'semiannual' => 10, + 'yearly' => 20, + ], + + 'setup_fee_rules' => [ + // VPS 10 only — monthly and semiannual cycles carry a setup fee equal to 1× the monthly price + [ + 'product_ids' => ['V91'], + 'billing_cycles' => ['monthly', 'semiannual'], + 'monthly_price_multiplier' => 1, + 'label' => 'One-time setup fee', + ], + // Dedicated servers — fixed EUR fee that decreases with longer commitment; waived on yearly + [ + 'product_ids' => ['amd-ryzen-12-cores', 'amd-genoa-24-cores'], + 'billing_cycles' => ['monthly', 'quarterly', 'semiannual'], + 'fixed_eur_by_cycle' => [ + 'monthly' => 39.99, + 'quarterly' => 29.99, + 'semiannual' => 19.99, + ], + 'label' => 'One-time setup fee', + ], + ], + + // Fallback Contabo base prices in USD. Live API/feed prices are preferred. + // Keep these values as a fail-safe for API downtime. + 'contabo_base_prices' => [ + 'V91' => ['monthly' => 4.99, 'name' => 'Cloud VPS 10 NVMe', 'cpu' => 3, 'ram_gb' => 8, 'disk_gb' => 75], + 'V94' => ['monthly' => 7.00, 'name' => 'Cloud VPS 20 NVMe', 'cpu' => 6, 'ram_gb' => 12, 'disk_gb' => 100], + 'V97' => ['monthly' => 14.00, 'name' => 'Cloud VPS 30 NVMe', 'cpu' => 8, 'ram_gb' => 24, 'disk_gb' => 200], + 'V100' => ['monthly' => 25.00, 'name' => 'Cloud VPS 40 NVMe', 'cpu' => 12, 'ram_gb' => 48, 'disk_gb' => 250], + 'amd-ryzen-12-cores' => ['monthly' => 104.64, 'name' => 'AMD Ryzen 12 Cores', 'cpu' => 12, 'ram_gb' => 64, 'disk_gb' => 1000], + 'amd-genoa-24-cores' => ['monthly' => 184.21, 'name' => 'AMD Genoa 24 Cores', 'cpu' => 24, 'ram_gb' => 128, 'disk_gb' => 2000], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Server Order Options + |-------------------------------------------------------------------------- + | + | Surcharges shown here are Contabo-side monthly USD add-on costs before + | the Ladill exchange-rate conversion and product margin are applied. + | Update these values whenever Contabo changes its commercial pricing. + | + */ + 'server_order' => [ + 'regions' => [ + 'EU' => ['label' => 'Europe (Germany)', 'monthly_usd' => 0.00, 'automated' => true], + 'US-central' => ['label' => 'US Central', 'monthly_usd' => 0.00, 'automated' => true], + 'US-east' => ['label' => 'US East', 'monthly_usd' => 0.00, 'automated' => true], + 'US-west' => ['label' => 'US West', 'monthly_usd' => 0.00, 'automated' => true], + 'UK' => ['label' => 'United Kingdom', 'monthly_usd' => 0.00, 'automated' => true], + 'SIN' => ['label' => 'Singapore', 'monthly_usd' => 0.00, 'automated' => true], + 'AUS' => ['label' => 'Australia', 'monthly_usd' => 0.00, 'automated' => true], + 'JPN' => ['label' => 'Japan', 'monthly_usd' => 0.00, 'automated' => true], + ], + 'image_pricing_rules' => [ + [ + 'key' => 'windows', + 'label' => 'Windows Server', + 'monthly_usd' => 9.30, + 'match' => ['windows'], + 'os_family' => 'windows', + 'default_user' => 'administrator', + 'requires_custom_image_addon' => false, + 'automated' => true, + ], + [ + 'key' => 'ubuntu', + 'label' => 'Ubuntu', + 'monthly_usd' => 0.00, + 'match' => ['ubuntu'], + 'os_family' => 'linux', + 'default_user' => 'root', + 'requires_custom_image_addon' => false, + 'automated' => true, + ], + [ + 'key' => 'rhel', + 'label' => 'RHEL Variants', + 'monthly_usd' => 0.00, + 'match' => ['alma', 'rocky', 'rhel', 'centos'], + 'os_family' => 'linux', + 'default_user' => 'root', + 'requires_custom_image_addon' => false, + 'automated' => true, + ], + [ + 'key' => 'custom', + 'label' => 'Custom Images', + 'monthly_usd' => 0.00, + 'custom_image' => true, + 'os_family' => 'linux', + 'default_user' => 'root', + 'requires_custom_image_addon' => true, + 'automated' => true, + ], + [ + 'key' => 'linux', + 'label' => 'Linux', + 'monthly_usd' => 0.00, + 'match' => ['debian', 'fedora', 'linux', 'bsd'], + 'os_family' => 'linux', + 'default_user' => 'root', + 'requires_custom_image_addon' => false, + 'automated' => true, + ], + ], + 'fallback_images' => [ + [ + 'value' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb', + 'label' => 'Ubuntu 22.04', + 'description' => 'Fallback Ubuntu image', + 'os_family' => 'linux', + 'monthly_usd' => 0.00, + 'default_user' => 'root', + 'requires_custom_image_addon' => false, + 'automated' => true, + ], + ], + 'managed_stack_supported_images' => [ + [ + 'value' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb', + 'label' => 'Ubuntu 22.04 LTS', + 'match' => ['ubuntu 22.04'], + 'os_family' => 'linux', + ], + [ + 'label' => 'Ubuntu 24.04 LTS', + 'match' => ['ubuntu 24.04'], + 'os_family' => 'linux', + ], + [ + 'label' => 'Debian 12', + 'match' => ['debian 12', 'bookworm'], + 'os_family' => 'linux', + ], + [ + 'label' => 'AlmaLinux', + 'match' => ['almalinux', 'alma linux', 'alma'], + 'os_family' => 'linux', + ], + [ + 'label' => 'Rocky Linux', + 'match' => ['rocky linux', 'rocky'], + 'os_family' => 'linux', + ], + [ + 'label' => 'Fedora', + 'match' => ['fedora'], + 'os_family' => 'linux', + ], + [ + 'label' => 'openSUSE Leap', + 'match' => ['opensuse leap', 'open suse leap', 'opensuse', 'leap'], + 'os_family' => 'linux', + ], + [ + 'label' => 'CentOS', + 'match' => ['centos'], + 'os_family' => 'linux', + ], + ], + 'licenses' => [ + 'none' => [ + 'label' => 'Remote Login Only', + 'description' => 'No hosting panel or commercial control-panel license.', + 'monthly_usd' => 0.00, + 'license' => null, + 'panel' => null, + 'automated' => true, + ], + 'ladill_panel' => [ + 'label' => 'Ladill Server Manager', + 'description' => 'Manage power, status, and server details from the Ladill server manager.', + 'monthly_usd' => 0.00, + 'license' => null, + 'panel' => 'ladill', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'requires_managed_stack_image' => true, + 'automated' => true, + ], + 'plesk_host' => [ + 'label' => 'Plesk + Linux', + 'description' => 'Plesk host edition on Linux.', + 'monthly_usd' => 15.00, + 'license' => 'PleskHost', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'plesk_windows' => [ + 'label' => 'Plesk + Windows', + 'description' => 'Plesk on Windows Server.', + 'monthly_usd' => 22.70, + 'license' => 'PleskHost', + 'compatible_os_families' => ['windows'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_5' => [ + 'label' => 'cPanel 5', + 'description' => 'cPanel license for up to 5 accounts.', + 'monthly_usd' => 35.99, + 'license' => 'cPanel5', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_30' => [ + 'label' => 'cPanel 30', + 'description' => 'cPanel license for up to 30 accounts.', + 'monthly_usd' => 53.99, + 'license' => 'cPanel30', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_50' => [ + 'label' => 'cPanel 50', + 'description' => 'cPanel license for up to 50 accounts.', + 'monthly_usd' => 53.99 + (0.49 * 20), + 'license' => 'cPanel50', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_100' => [ + 'label' => 'cPanel 100', + 'description' => 'cPanel license for up to 100 accounts.', + 'monthly_usd' => 69.99, + 'license' => 'cPanel100', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_150' => [ + 'label' => 'cPanel 150', + 'description' => 'cPanel license for up to 150 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 50), + 'license' => 'cPanel150', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_200' => [ + 'label' => 'cPanel 200', + 'description' => 'cPanel license for up to 200 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 100), + 'license' => 'cPanel200', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_250' => [ + 'label' => 'cPanel 250', + 'description' => 'cPanel license for up to 250 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 150), + 'license' => 'cPanel250', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_300' => [ + 'label' => 'cPanel 300', + 'description' => 'cPanel license for up to 300 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 200), + 'license' => 'cPanel300', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_350' => [ + 'label' => 'cPanel 350', + 'description' => 'cPanel license for up to 350 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 250), + 'license' => 'cPanel350', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_400' => [ + 'label' => 'cPanel 400', + 'description' => 'cPanel license for up to 400 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 300), + 'license' => 'cPanel400', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_450' => [ + 'label' => 'cPanel 450', + 'description' => 'cPanel license for up to 450 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 350), + 'license' => 'cPanel450', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_500' => [ + 'label' => 'cPanel 500', + 'description' => 'cPanel license for up to 500 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 400), + 'license' => 'cPanel500', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_550' => [ + 'label' => 'cPanel 550', + 'description' => 'cPanel license for up to 550 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 450), + 'license' => 'cPanel550', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_600' => [ + 'label' => 'cPanel 600', + 'description' => 'cPanel license for up to 600 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 500), + 'license' => 'cPanel600', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_650' => [ + 'label' => 'cPanel 650', + 'description' => 'cPanel license for up to 650 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 550), + 'license' => 'cPanel650', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_700' => [ + 'label' => 'cPanel 700', + 'description' => 'cPanel license for up to 700 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 600), + 'license' => 'cPanel700', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_750' => [ + 'label' => 'cPanel 750', + 'description' => 'cPanel license for up to 750 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 650), + 'license' => 'cPanel750', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_800' => [ + 'label' => 'cPanel 800', + 'description' => 'cPanel license for up to 800 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 700), + 'license' => 'cPanel800', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_850' => [ + 'label' => 'cPanel 850', + 'description' => 'cPanel license for up to 850 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 750), + 'license' => 'cPanel850', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_900' => [ + 'label' => 'cPanel 900', + 'description' => 'cPanel license for up to 900 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 800), + 'license' => 'cPanel900', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_950' => [ + 'label' => 'cPanel 950', + 'description' => 'cPanel license for up to 950 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 850), + 'license' => 'cPanel950', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_1000' => [ + 'label' => 'cPanel 1000', + 'description' => 'cPanel license for up to 1000 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 900), + 'license' => 'cPanel1000', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + ], + 'applications' => [ + 'none' => [ + 'label' => 'No Preinstalled App', + 'description' => 'Provision the server without an extra preinstalled application.', + 'monthly_usd' => 0.00, + 'automated' => true, + ], + 'webmin' => [ + 'label' => 'Webmin', + 'description' => 'Free Webmin server panel installed automatically on Linux.', + 'monthly_usd' => 0.00, + 'cloud_init_preset' => 'webmin', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'webmin_lamp' => [ + 'label' => 'Webmin + LAMP', + 'description' => 'Free Webmin plus Apache, MariaDB, and PHP on Linux.', + 'monthly_usd' => 0.00, + 'cloud_init_preset' => 'webmin_lamp', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'ipfs_node' => [ + 'label' => 'IPFS Node', + 'description' => 'Contabo application profile for IPFS nodes.', + 'monthly_usd' => 0.00, + 'application_id' => env('CONTABO_APP_ID_IPFS_NODE'), + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'flux_node' => [ + 'label' => 'Flux Node', + 'description' => 'Contabo application profile for Flux nodes.', + 'monthly_usd' => 0.00, + 'application_id' => env('CONTABO_APP_ID_FLUX_NODE'), + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'horizon_node' => [ + 'label' => 'Horizen Node', + 'description' => 'Contabo application profile for Horizen nodes.', + 'monthly_usd' => 0.00, + 'application_id' => env('CONTABO_APP_ID_HORIZON_NODE'), + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'ethereum_node' => [ + 'label' => 'Ethereum Node', + 'description' => 'Contabo application profile for Ethereum 2.0 nodes.', + 'monthly_usd' => 0.00, + 'application_id' => null, + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'bitcoin_node' => [ + 'label' => 'Bitcoin Full Node', + 'description' => 'Contabo application profile for Bitcoin full nodes.', + 'monthly_usd' => 0.00, + 'application_id' => null, + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + ], + 'additional_ip' => [ + 'none' => [ + 'label' => '1 IP Address', + 'description' => 'Default primary IP only.', + 'monthly_usd' => 0.00, + 'add_on' => null, + 'automated' => true, + ], + 'one_extra' => [ + 'label' => '1 Additional IP', + 'description' => 'Adds one extra IPv4 address.', + 'monthly_usd' => 4.50, + 'add_on' => 'additionalIps', + 'automated' => true, + ], + ], + 'private_networking' => [ + 'disabled' => [ + 'label' => 'No Private Networking', + 'description' => 'Do not enable the private networking add-on.', + 'monthly_usd' => 0.00, + 'add_on' => null, + 'automated' => true, + ], + 'enabled' => [ + 'label' => 'Private Networking Enabled', + 'description' => 'Purchases the private networking add-on.', + 'monthly_usd' => 2.99, + 'add_on' => 'privateNetworking', + 'automated' => true, + ], + ], + 'storage_types' => [ + 'included' => [ + 'label' => 'Included Storage', + 'description' => 'Use the plan default storage.', + 'monthly_usd' => 0.00, + 'add_on' => null, + 'automated' => true, + ], + 'ssd_300' => [ + 'label' => '300 GB SSD', + 'description' => 'Higher SSD storage tier.', + 'monthly_usd' => 1.95, + 'add_on' => 'extraStorage', + 'automated' => false, + ], + 'nvme_150' => [ + 'label' => '150 GB NVMe', + 'description' => 'Higher NVMe storage tier.', + 'monthly_usd' => 2.30, + 'add_on' => 'extraStorage', + 'automated' => false, + ], + ], + 'object_storage' => [ + 'none' => [ + 'label' => 'No Object Storage', + 'description' => 'Do not order object storage with this server.', + 'monthly_usd' => 0.00, + 'automated' => false, + ], + 'eu_250' => [ + 'label' => '250 GB Object Storage (EU)', + 'description' => 'S3-compatible object storage in the European Union.', + 'monthly_usd' => 2.99, + 'automated' => false, + ], + 'eu_500' => [ + 'label' => '500 GB Object Storage (EU)', + 'description' => 'S3-compatible object storage in the European Union.', + 'monthly_usd' => 5.98, + 'automated' => false, + ], + 'eu_750' => [ + 'label' => '750 GB Object Storage (EU)', + 'description' => 'S3-compatible object storage in the European Union.', + 'monthly_usd' => 8.97, + 'automated' => false, + ], + 'eu_1024' => [ + 'label' => '1 TB Object Storage (EU)', + 'description' => 'S3-compatible object storage in the European Union.', + 'monthly_usd' => 11.96, + 'automated' => false, + ], + ], + 'linux_default_users' => [ + 'root' => ['label' => 'root'], + 'admin' => ['label' => 'admin'], + ], + 'windows_default_users' => [ + 'admin' => ['label' => 'admin'], + 'administrator' => ['label' => 'administrator'], + ], + ], + + 'server_agent' => [ + 'release_version' => env('SERVER_AGENT_RELEASE_VERSION', '0.2.0'), + 'signed_release_ttl_minutes' => (int) env('SERVER_AGENT_SIGNED_RELEASE_TTL_MINUTES', 10080), + 'heartbeat_interval_seconds' => 15, + ], + + /* + |-------------------------------------------------------------------------- + | Shared Hosting Configuration + |-------------------------------------------------------------------------- + | + | Settings for the shared hosting nodes. + | + */ + 'shared' => [ + 'default_php_version' => '8.2', + 'available_php_versions' => ['8.0', '8.1', '8.2', '8.3'], + 'default_document_root' => 'public_html', + 'max_upload_size_mb' => 64, + 'max_execution_time' => 300, + 'memory_limit_mb' => 256, + 'phpmyadmin_url' => env('HOSTING_PHPMYADMIN_URL', ''), + 'phpmyadmin_sso_secret' => env('HOSTING_PHPMYADMIN_SSO_SECRET', ''), + ], + + /* + |-------------------------------------------------------------------------- + | VPS Configuration + |-------------------------------------------------------------------------- + | + | Default settings for VPS instances. + | + */ + 'vps' => [ + 'default_region' => 'EU', + 'default_image' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb', // Ubuntu 22.04 + 'available_regions' => ['EU', 'US-central', 'US-east', 'US-west', 'SIN', 'UK', 'AUS', 'JPN'], + ], + + /* + |-------------------------------------------------------------------------- + | App Installer Configuration + |-------------------------------------------------------------------------- + | + | Settings for the one-click app installer. + | + */ + 'apps' => [ + 'enabled' => ['wordpress', 'joomla', 'drupal', 'opencart'], + 'magento_enabled' => false, // Requires higher resource plans + 'wordpress' => [ + 'default_version' => '6.4', + 'wp_cli_path' => '/usr/local/bin/wp', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Provisioning Settings + |-------------------------------------------------------------------------- + | + | General provisioning configuration. + | + */ + 'provisioning' => [ + 'max_retries' => 3, + 'retry_delay_minutes' => 5, + 'timeout_minutes' => 30, + ], + + /* + |-------------------------------------------------------------------------- + | Legacy ResellerClub Integration + |-------------------------------------------------------------------------- + | + | Keep RC integration active for existing customers. + | + */ + 'legacy' => [ + // Keep RC data/API available for customers with existing ResellerClub services. + 'rc_enabled' => (bool) env('LADILL_RC_LEGACY_ENABLED', true), + // New sales and automated fulfillment use Ladill (Dynadot domains, native hosting). + 'rc_new_orders_enabled' => (bool) env('LADILL_RC_NEW_ORDERS_ENABLED', false), + // Renew existing ResellerClub services (hosting, domains, VPS, etc.). + 'rc_renewals_enabled' => (bool) env('LADILL_RC_RENEWALS_ENABLED', true), + ], +]; diff --git a/config/identity.php b/config/identity.php new file mode 100644 index 0000000..cc1538e --- /dev/null +++ b/config/identity.php @@ -0,0 +1,6 @@ + env('IDENTITY_API_URL', 'https://ladill.com/api'), + 'api_key' => env('IDENTITY_API_KEY_MINI'), +]; diff --git a/config/ladill.php b/config/ladill.php new file mode 100644 index 0000000..2ad669d --- /dev/null +++ b/config/ladill.php @@ -0,0 +1,6 @@ + 'mini', + 'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/mini'), +]; diff --git a/config/ladill_launcher.php b/config/ladill_launcher.php new file mode 100644 index 0000000..f0ec899 --- /dev/null +++ b/config/ladill_launcher.php @@ -0,0 +1,44 @@ +. +*/ + +$root = config('app.platform_domain', 'ladill.com'); + +return [ + 'apps' => [ + ['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' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.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' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.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' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'], + ['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'], + ['name' => 'CRM', 'url' => 'https://crm.'.$root.'/sso/connect?redirect='.urlencode('https://crm.'.$root.'/dashboard'), 'icon' => 'crm.svg'], + ], +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..b09cb25 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/config/mail_brands.php b/config/mail_brands.php new file mode 100644 index 0000000..c0fa4a7 --- /dev/null +++ b/config/mail_brands.php @@ -0,0 +1,123 @@ + env( + 'LADILL_ACCOUNT_URL', + 'https://'.env('ACCOUNT_DOMAIN', 'account.'.$platformHost) + ), + + 'brands' => [ + 'ladill' => [ + 'name' => 'Ladill', + 'logo' => 'ladill-logo-white.png', + 'logo_class' => 'email-logo', + 'app_url' => env('APP_URL', 'https://ladill.com'), + 'footer_account' => 'an account with Ladill', + 'dashboard_path' => '/dashboard', + 'support_path' => '/support', + 'home_label' => 'ladill.com', + ], + 'hosting' => [ + 'name' => 'Ladill Hosting', + 'logo' => 'ladillhosting-logo-email.png', + 'logo_class' => 'email-logo', + 'app_url' => env('LADILL_HOSTING_APP_URL', 'https://hosting.ladill.com'), + 'footer_account' => 'a Ladill Hosting account', + 'dashboard_path' => '/', + 'support_path' => '/support', + 'home_label' => 'hosting.ladill.com', + ], + 'domains' => [ + 'name' => 'Ladill Domains', + 'logo' => 'ladilldomains-logo-email.png', + 'logo_class' => 'email-logo', + 'app_url' => env('LADILL_DOMAINS_APP_URL', 'https://domains.ladill.com'), + 'footer_account' => 'a Ladill Domains account', + 'dashboard_path' => '/', + 'support_path' => '/support', + 'home_label' => 'domains.ladill.com', + ], + 'bird' => [ + 'name' => 'Ladill Bird', + 'logo' => 'ladillbird-logo-email.png', + 'logo_class' => 'email-logo', + 'app_url' => env('LADILL_BIRD_APP_URL', 'https://bird.ladill.com'), + 'footer_account' => 'a Ladill Bird account', + 'dashboard_path' => '/', + 'support_path' => '/support', + 'home_label' => 'bird.ladill.com', + ], + 'mail' => [ + 'name' => 'Ladill Mail', + 'logo' => 'ladillmail-logo-email.png', + 'logo_class' => 'email-logo', + 'app_url' => env('LADILL_WEBMAIL_URL', 'https://mail.ladill.com'), + 'footer_account' => 'a Ladill Mail account', + 'dashboard_path' => '/', + 'support_path' => '/support', + 'home_label' => 'mail.ladill.com', + ], + 'email' => [ + 'name' => 'Ladill Email', + 'logo' => 'ladillemail-logo-email.png', + 'logo_class' => 'email-logo', + 'app_url' => env('LADILL_EMAIL_APP_URL', 'https://email.ladill.com'), + 'footer_account' => 'a Ladill Email account', + 'dashboard_path' => '/', + 'support_path' => '/support', + 'home_label' => 'email.ladill.com', + ], + 'qrplus' => [ + 'name' => 'Ladill QR Plus', + 'logo' => 'ladillqrplus-logo-email.png', + 'logo_class' => 'email-logo', + 'app_url' => env('LADILL_QR_APP_URL', 'https://qrplus.ladill.com'), + 'footer_account' => 'a Ladill QR Plus account', + 'dashboard_path' => '/', + 'support_path' => '/support', + 'home_label' => 'qrplus.ladill.com', + ], + 'events' => [ + 'name' => 'Ladill Events', + 'logo' => 'ladillevents-logo-email.png', + 'logo_class' => 'email-logo', + 'app_url' => env('LADILL_EVENTS_APP_URL', 'https://events.ladill.com'), + 'footer_account' => 'a Ladill Events account', + 'dashboard_path' => '/', + 'support_path' => '/support', + 'home_label' => 'events.ladill.com', + ], + 'transfer' => [ + 'name' => 'Ladill Transfer', + 'logo' => 'ladilltransfer-logo-email.png', + 'logo_class' => 'email-logo email-logo-transfer', + 'app_url' => env('LADILL_TRANSFER_APP_URL', 'https://transfer.ladill.com'), + 'footer_account' => 'a Ladill Transfer account', + 'dashboard_path' => '/', + 'support_path' => '/support', + 'home_label' => 'transfer.ladill.com', + ], + 'mini' => [ + 'name' => 'Ladill Mini', + 'logo' => 'ladillmini-logo-email.png', + 'logo_class' => 'email-logo', + 'app_url' => env('LADILL_MINI_APP_URL', 'https://mini.ladill.com'), + 'footer_account' => 'a Ladill Mini account', + 'dashboard_path' => '/', + 'support_path' => '/support', + 'home_label' => 'mini.ladill.com', + ], + 'servers' => [ + 'name' => 'Ladill Servers', + 'logo' => 'ladillservers-logo-email.png', + 'logo_class' => 'email-logo', + 'app_url' => env('LADILL_SERVERS_APP_URL', 'https://servers.ladill.com'), + 'footer_account' => 'a Ladill Servers account', + 'dashboard_path' => '/', + 'support_path' => '/support', + 'home_label' => 'servers.ladill.com', + ], + ], +]; diff --git a/config/mailbox.php b/config/mailbox.php new file mode 100644 index 0000000..14a403c --- /dev/null +++ b/config/mailbox.php @@ -0,0 +1,9 @@ + env('MAILBOX_API_URL', 'https://ladill.com/api/mailboxes'), + 'api_key' => env('MAILBOX_API_KEY_EMAIL'), +]; diff --git a/config/mobile-topbar.php b/config/mobile-topbar.php new file mode 100644 index 0000000..9ac615a --- /dev/null +++ b/config/mobile-topbar.php @@ -0,0 +1,5 @@ + 'Mini', +]; diff --git a/config/notifications.php b/config/notifications.php new file mode 100644 index 0000000..82c6f9b --- /dev/null +++ b/config/notifications.php @@ -0,0 +1,17 @@ + (int) env('NOTIFICATIONS_FCM_ACTIVE_USER_DAYS', 60), + +]; diff --git a/config/pay.php b/config/pay.php new file mode 100644 index 0000000..92fe62a --- /dev/null +++ b/config/pay.php @@ -0,0 +1,6 @@ + env('PAY_API_URL', 'https://ladill.com/api/pay'), + 'api_key' => env('PAY_API_KEY_POS'), +]; diff --git a/config/pos.php b/config/pos.php new file mode 100644 index 0000000..1e6f10c --- /dev/null +++ b/config/pos.php @@ -0,0 +1,6 @@ + env('POS_DEFAULT_CURRENCY', 'GHS'), + 'merchant_import_enabled' => (bool) env('POS_MERCHANT_IMPORT_ENABLED', true), +]; diff --git a/config/qr.php b/config/qr.php new file mode 100644 index 0000000..50706a9 --- /dev/null +++ b/config/qr.php @@ -0,0 +1,9 @@ + (float) env('QR_PRICE_PER_QR_GHS', 5.0), + 'min_topup_ghs' => (float) env('QR_MIN_TOPUP_GHS', 5.0), + 'max_pdf_bytes' => (int) env('QR_MAX_PDF_BYTES', 104857600), // 100 MB + 'short_code_length' => (int) env('QR_SHORT_CODE_LENGTH', 8), + 'scan_unique_window_hours' => (int) env('QR_SCAN_UNIQUE_WINDOW_HOURS', 24), +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..1c3a4f2 --- /dev/null +++ b/config/services.php @@ -0,0 +1,81 @@ + [ + 'issuer' => 'https://'.config('app.auth_domain'), + 'client_id' => env('LADILL_SSO_CLIENT_ID'), + 'client_secret' => env('LADILL_SSO_CLIENT_SECRET'), + 'redirect' => rtrim((string) env('APP_URL', 'https://mini.ladill.com'), '/').'/sso/callback', + ], + + // Central Ladill identity API (auth.ladill.com /api/identity/auth/*). The + // mobile app's native login/register proxy through here, gated by the + // shared first-party service key (config/identity.php on the monolith). + 'ladill_identity' => [ + 'url' => 'https://'.config('app.auth_domain'), + 'key' => env('IDENTITY_API_KEY_MINI'), + ], + + 'ladill_webmail' => [ + 'url' => env('LADILL_WEBMAIL_URL', 'https://mail.ladill.com'), + ], + + 'postmark' => [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + + 'termii' => [ + 'api_key' => env('TERMII_API_KEY'), + 'sender_id' => env('TERMII_SENDER_ID', 'LaDill'), + ], + + // Paystack — guest checkout (and the card fallback for logged-in buyers). + 'paystack' => [ + 'base_url' => env('PAYSTACK_BASE_URL', 'https://api.paystack.co'), + 'public_key' => env('PAYSTACK_PUBLIC_KEY'), + 'secret_key' => env('PAYSTACK_SECRET_KEY'), + 'webhook_secret' => env('PAYSTACK_WEBHOOK_SECRET', env('PAYSTACK_SECRET_KEY')), + 'checkout_email' => env('PAYSTACK_CHECKOUT_EMAIL', 'pay@ladill.com'), + ], + + 'fcm' => [ + 'project_id' => env('FIREBASE_PROJECT_ID'), + 'service_account_json' => env('FIREBASE_SERVICE_ACCOUNT_JSON'), + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..5b541b7 --- /dev/null +++ b/config/session.php @@ -0,0 +1,217 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + +]; diff --git a/config/signed_out.php b/config/signed_out.php new file mode 100644 index 0000000..88e500d --- /dev/null +++ b/config/signed_out.php @@ -0,0 +1,7 @@ + 'Ladill POS', + 'logo' => 'images/logo/ladillpos-logo.svg', + 'description' => 'Your Ladill POS session has ended. Sign in again to open the register.', +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..c4ceb07 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..a138076 --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,52 @@ +id(); + $table->uuid('public_id')->unique(); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->string('avatar_url')->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password')->nullable(); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..ed758bd --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..425e705 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2026_05_28_200000_create_qr_product_tables.php b/database/migrations/2026_05_28_200000_create_qr_product_tables.php new file mode 100644 index 0000000..20b2fd0 --- /dev/null +++ b/database/migrations/2026_05_28_200000_create_qr_product_tables.php @@ -0,0 +1,101 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->decimal('credit_balance', 10, 4)->default(0); + $table->unsignedInteger('qr_codes_total')->default(0); + $table->unsignedBigInteger('scans_total')->default(0); + $table->string('status', 20)->default('active'); + $table->timestamps(); + }); + + Schema::create('qr_documents', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('title')->nullable(); + $table->string('disk', 32)->default('qr'); + $table->string('path'); + $table->string('mime_type', 128)->default('application/pdf'); + $table->unsignedBigInteger('size_bytes')->default(0); + $table->unsignedSmallInteger('page_count')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'created_at']); + }); + + Schema::create('qr_codes', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('short_code', 16)->unique(); + $table->string('type', 32); + $table->string('label'); + $table->text('destination_url')->nullable(); + $table->foreignId('qr_document_id')->nullable()->constrained('qr_documents')->nullOnDelete(); + $table->json('payload')->nullable(); + $table->boolean('is_active')->default(true); + $table->string('png_path')->nullable(); + $table->string('svg_path')->nullable(); + $table->unsignedBigInteger('scans_total')->default(0); + $table->unsignedBigInteger('unique_scans_total')->default(0); + $table->timestamp('last_scanned_at')->nullable(); + $table->timestamp('destination_updated_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'created_at']); + $table->index(['user_id', 'is_active']); + }); + + Schema::create('qr_transactions', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('qr_wallet_id')->constrained()->cascadeOnDelete(); + $table->foreignId('qr_code_id')->nullable()->constrained()->nullOnDelete(); + $table->string('type', 16); + $table->decimal('amount_ghs', 10, 4); + $table->decimal('balance_after_ghs', 10, 4); + $table->string('reference')->nullable()->index(); + $table->string('status', 20)->default('completed'); + $table->string('description')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['qr_wallet_id', 'created_at']); + }); + + Schema::create('qr_scan_events', function (Blueprint $table) { + $table->id(); + $table->foreignId('qr_code_id')->constrained()->cascadeOnDelete(); + $table->timestamp('scanned_at'); + $table->string('ip_hash', 64)->nullable(); + $table->text('user_agent')->nullable(); + $table->string('device_type', 32)->nullable(); + $table->string('browser', 64)->nullable(); + $table->string('os', 64)->nullable(); + $table->string('country_code', 8)->nullable(); + $table->string('referrer')->nullable(); + $table->boolean('is_unique')->default(false); + $table->timestamps(); + + $table->index(['qr_code_id', 'scanned_at']); + $table->index(['qr_code_id', 'ip_hash']); + }); + } + + public function down(): void + { + Schema::dropIfExists('qr_scan_events'); + Schema::dropIfExists('qr_transactions'); + Schema::dropIfExists('qr_codes'); + Schema::dropIfExists('qr_documents'); + Schema::dropIfExists('qr_wallets'); + } +}; diff --git a/database/migrations/2026_05_31_000001_widen_qr_codes_short_code.php b/database/migrations/2026_05_31_000001_widen_qr_codes_short_code.php new file mode 100644 index 0000000..f84bb4a --- /dev/null +++ b/database/migrations/2026_05_31_000001_widen_qr_codes_short_code.php @@ -0,0 +1,22 @@ +string('short_code', 32)->change(); + }); + } + + public function down(): void + { + Schema::table('qr_codes', function (Blueprint $table) { + $table->string('short_code', 16)->change(); + }); + } +}; diff --git a/database/migrations/2026_06_05_075448_create_personal_access_tokens_table.php b/database/migrations/2026_06_05_075448_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_06_05_075448_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_06_06_200000_create_notifications_table.php b/database/migrations/2026_06_06_200000_create_notifications_table.php new file mode 100644 index 0000000..52e3b00 --- /dev/null +++ b/database/migrations/2026_06_06_200000_create_notifications_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_06_06_210000_create_qr_team_members_table.php b/database/migrations/2026_06_06_210000_create_qr_team_members_table.php new file mode 100644 index 0000000..b8f9aa4 --- /dev/null +++ b/database/migrations/2026_06_06_210000_create_qr_team_members_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('account_id')->index(); + $table->unsignedBigInteger('user_id')->nullable()->index(); + $table->string('email'); + $table->string('role', 20)->default('member'); + $table->string('status', 20)->default('invited'); + $table->string('token', 64)->nullable(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamps(); + + $table->unique(['account_id', 'email']); + }); + } + + public function down(): void + { + Schema::dropIfExists('qr_team_members'); + } +}; diff --git a/database/migrations/2026_06_06_220000_create_qr_settings_table.php b/database/migrations/2026_06_06_220000_create_qr_settings_table.php new file mode 100644 index 0000000..f8a8f2c --- /dev/null +++ b/database/migrations/2026_06_06_220000_create_qr_settings_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('notify_email')->nullable(); + $table->boolean('product_updates')->default(true); + $table->boolean('low_balance_alerts')->default(true); + $table->string('default_type', 32)->nullable(); + $table->json('default_style')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('qr_settings'); + } +}; diff --git a/database/migrations/2026_06_07_200000_create_mini_payments_table.php b/database/migrations/2026_06_07_200000_create_mini_payments_table.php new file mode 100644 index 0000000..eceae6c --- /dev/null +++ b/database/migrations/2026_06_07_200000_create_mini_payments_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('qr_code_id')->constrained('qr_codes')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('reference', 32)->unique(); + $table->unsignedInteger('amount_minor'); + $table->string('currency', 3)->default('GHS'); + $table->unsignedInteger('platform_fee_minor')->default(0); + $table->unsignedInteger('merchant_amount_minor')->default(0); + $table->string('payer_name')->nullable(); + $table->string('payer_email')->nullable(); + $table->string('payer_phone', 32)->nullable(); + $table->string('payer_note')->nullable(); + $table->string('status', 16)->default('pending'); + $table->string('payment_reference', 64)->nullable()->unique(); + $table->timestamp('paid_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'status', 'paid_at']); + $table->index(['qr_code_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('mini_payments'); + } +}; diff --git a/database/migrations/2026_06_08_130000_add_pay_order_id_to_mini_payments_table.php b/database/migrations/2026_06_08_130000_add_pay_order_id_to_mini_payments_table.php new file mode 100644 index 0000000..13696bb --- /dev/null +++ b/database/migrations/2026_06_08_130000_add_pay_order_id_to_mini_payments_table.php @@ -0,0 +1,22 @@ +unsignedBigInteger('pay_order_id')->nullable()->after('id'); + }); + } + + public function down(): void + { + Schema::table('mini_payments', function (Blueprint $table) { + $table->dropColumn('pay_order_id'); + }); + } +}; diff --git a/database/migrations/2026_06_11_120000_create_user_push_tokens_table.php b/database/migrations/2026_06_11_120000_create_user_push_tokens_table.php new file mode 100644 index 0000000..d785369 --- /dev/null +++ b/database/migrations/2026_06_11_120000_create_user_push_tokens_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('token', 512)->unique(); + $table->string('platform', 32)->default('android'); + $table->string('device_name')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'updated_at']); + }); + + Schema::table('users', function (Blueprint $table) { + $table->timestamp('last_app_active_at')->nullable()->after('remember_token'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('last_app_active_at'); + }); + + Schema::dropIfExists('user_push_tokens'); + } +}; diff --git a/database/migrations/2026_06_12_120000_add_auto_withdraw_to_qr_settings_table.php b/database/migrations/2026_06_12_120000_add_auto_withdraw_to_qr_settings_table.php new file mode 100644 index 0000000..dac2564 --- /dev/null +++ b/database/migrations/2026_06_12_120000_add_auto_withdraw_to_qr_settings_table.php @@ -0,0 +1,22 @@ +unsignedBigInteger('auto_withdraw_amount_minor')->nullable(); + }); + } + + public function down(): void + { + Schema::table('qr_settings', function (Blueprint $table) { + $table->dropColumn('auto_withdraw_amount_minor'); + }); + } +}; diff --git a/database/migrations/2026_06_26_100000_create_pos_tables.php b/database/migrations/2026_06_26_100000_create_pos_tables.php new file mode 100644 index 0000000..b601649 --- /dev/null +++ b/database/migrations/2026_06_26_100000_create_pos_tables.php @@ -0,0 +1,76 @@ +id(); + $table->string('owner_ref')->index(); + $table->string('name'); + $table->string('currency', 3)->default('GHS'); + $table->text('receipt_footer')->nullable(); + $table->timestamps(); + }); + + Schema::create('pos_products', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('location_id')->nullable()->constrained('pos_locations')->nullOnDelete(); + $table->string('name'); + $table->string('sku')->nullable(); + $table->unsignedInteger('price_minor'); + $table->string('currency', 3)->default('GHS'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['owner_ref', 'is_active']); + }); + + Schema::create('pos_sales', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('location_id')->nullable()->constrained('pos_locations')->nullOnDelete(); + $table->string('reference')->unique(); + $table->string('status')->default('pending'); // pending | paid | failed | cancelled + $table->string('payment_method')->default('pay'); // pay | cash + $table->unsignedBigInteger('pay_order_id')->nullable(); + $table->string('payment_reference')->nullable()->index(); + $table->string('customer_name')->nullable(); + $table->string('customer_email')->nullable(); + $table->string('customer_phone', 40)->nullable(); + $table->unsignedBigInteger('crm_customer_id')->nullable(); + $table->unsignedInteger('subtotal_minor')->default(0); + $table->unsignedInteger('total_minor')->default(0); + $table->string('currency', 3)->default('GHS'); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + + $table->index(['owner_ref', 'status', 'created_at']); + }); + + Schema::create('pos_sale_lines', function (Blueprint $table) { + $table->id(); + $table->foreignId('pos_sale_id')->constrained('pos_sales')->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('pos_products')->nullOnDelete(); + $table->string('name'); + $table->unsignedInteger('unit_price_minor'); + $table->unsignedInteger('quantity')->default(1); + $table->unsignedInteger('line_total_minor'); + $table->unsignedSmallInteger('position')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pos_sale_lines'); + Schema::dropIfExists('pos_sales'); + Schema::dropIfExists('pos_products'); + Schema::dropIfExists('pos_locations'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..a81da2d --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,29 @@ +call([ + HostingProductSeeder::class, + ]); + // User::factory(10)->create(); + + User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } +} diff --git a/database/seeders/HostingNodeSeeder.php b/database/seeders/HostingNodeSeeder.php new file mode 100644 index 0000000..4b70748 --- /dev/null +++ b/database/seeders/HostingNodeSeeder.php @@ -0,0 +1,40 @@ + 'local', 'ip_address' => '127.0.0.1'], + [ + 'name' => 'Primary Server', + 'hostname' => gethostname() ?: 'localhost', + 'ip_address' => '127.0.0.1', + 'ipv6_address' => '::1', + 'type' => 'shared', + 'segment' => 'general', + 'provider' => 'local', + 'cpu_cores' => 4, + 'ram_mb' => 8192, + 'disk_gb' => 150, + 'oversell_ratio' => 3, + 'bandwidth_tb' => 10, + 'max_accounts' => 100, + 'current_accounts' => 0, + 'status' => 'active', + 'ssh_port' => 22, + 'features' => ['php', 'mysql', 'nginx', 'ssl'], + 'installed_software' => [ + 'php' => ['8.1', '8.2', '8.3'], + 'mysql' => '8.0', + 'nginx' => '1.24', + ], + ] + ); + } +} diff --git a/deploy/bin/ladill-hosting-admin b/deploy/bin/ladill-hosting-admin new file mode 100755 index 0000000..88b5ff1 --- /dev/null +++ b/deploy/bin/ladill-hosting-admin @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' >&2 +Usage: + ladill-hosting-admin self-check + ladill-hosting-admin mkdir + ladill-hosting-admin chown + ladill-hosting-admin chmod + ladill-hosting-admin write-file + ladill-hosting-admin symlink + ladill-hosting-admin nginx-test + ladill-hosting-admin reload-nginx + ladill-hosting-admin certbot-webroot + ladill-hosting-admin certbot-renew + ladill-hosting-admin run-cmd + ladill-hosting-admin read-file +EOF + exit 64 +} + +require_abs_path() { + local path="${1:-}" + + if [[ -z "$path" || "${path#/}" == "$path" ]]; then + echo "Expected an absolute path, got: $path" >&2 + exit 64 + fi +} + +ensure_domain() { + local value="${1:-}" + + if [[ ! "$value" =~ ^[A-Za-z0-9.-]+$ ]]; then + echo "Invalid domain: $value" >&2 + exit 64 + fi +} + +ensure_owner_group() { + local value="${1:-}" + + if [[ ! "$value" =~ ^[A-Za-z_][A-Za-z0-9_-]*:[A-Za-z_][A-Za-z0-9_-]*$ ]]; then + echo "Invalid owner:group value: $value" >&2 + exit 64 + fi +} + +ensure_mode() { + local value="${1:-}" + + if [[ ! "$value" =~ ^[0-7]{3,4}$ && ! "$value" =~ ^[ugoa,+-=rwxX]+$ ]]; then + echo "Invalid chmod mode: $value" >&2 + exit 64 + fi +} + +command="${1:-}" + +case "$command" in + self-check) + exit 0 + ;; + mkdir) + [[ $# -eq 2 ]] || usage + require_abs_path "$2" + exec mkdir -p "$2" + ;; + chown) + [[ $# -eq 3 ]] || usage + ensure_owner_group "$2" + require_abs_path "$3" + exec chown -R "$2" "$3" + ;; + chmod) + [[ $# -eq 3 ]] || usage + ensure_mode "$2" + require_abs_path "$3" + exec chmod "$2" "$3" + ;; + write-file) + [[ $# -eq 3 ]] || usage + require_abs_path "$2" + tmpfile="$(mktemp)" + trap 'rm -f "$tmpfile"' EXIT + printf '%s' "$3" | base64 --decode > "$tmpfile" + install -m 0644 "$tmpfile" "$2" + rm -f "$tmpfile" + trap - EXIT + ;; + symlink) + [[ $# -eq 3 ]] || usage + require_abs_path "$2" + require_abs_path "$3" + exec ln -sfn "$2" "$3" + ;; + nginx-test) + exec nginx -t + ;; + reload-nginx) + exec systemctl reload nginx + ;; + certbot-webroot) + [[ $# -eq 4 ]] || usage + ensure_domain "$3" + require_abs_path "$4" + exec certbot certonly --webroot --non-interactive --agree-tos --no-eff-email -m "$2" -w "$4" -d "$3" -d "www.$3" + ;; + certbot-renew) + exec certbot renew --quiet --no-random-sleep-on-renew + ;; + run-cmd) + [[ $# -ge 2 ]] || usage + shift + exec bash -c "$*" + ;; + read-file) + [[ $# -eq 2 ]] || usage + require_abs_path "$2" + exec cat "$2" + ;; + *) + usage + ;; +esac diff --git a/deploy/bin/ladill-hosting-user b/deploy/bin/ladill-hosting-user new file mode 100755 index 0000000..bc3a45a --- /dev/null +++ b/deploy/bin/ladill-hosting-user @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "Usage: ladill-hosting-user " >&2 + exit 64 +} + +[[ $# -eq 2 ]] || usage + +username="$1" +payload_b64="$2" + +if [[ ! "$username" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then + echo "Invalid username: $username" >&2 + exit 64 +fi + +command="$(printf '%s' "$payload_b64" | base64 --decode)" + +exec runuser -u "$username" -- bash -lc "$command" diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..47fca9d --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# Fast on-host release deploy (same model as climpme/web/deploy/deploy.sh). +set -Eeuo pipefail + +APP_ROOT="${LADILL_APP_ROOT:-/var/www/ladill-mini}" +RELEASES_DIR="$APP_ROOT/releases" +SHARED_DIR="$APP_ROOT/shared" +CURRENT_LINK="$APP_ROOT/current" +RELEASE_ARCHIVE="${LADILL_RELEASE_ARCHIVE:-/tmp/ladill-release.tgz}" +KEEP_RELEASES="${LADILL_KEEP_RELEASES:-5}" + +STAMP="$(date +%Y%m%d%H%M%S)" +NEW_RELEASE="$RELEASES_DIR/$STAMP" + +log() { + printf '[%s] %s\n' "$(date '+%F %T')" "$*" +} + +bootstrap_shared_env() { + local candidate="" + + if [ -f "$SHARED_DIR/.env" ]; then + return 0 + fi + + for candidate in "$CURRENT_LINK/.env" "$APP_ROOT/.env"; do + if [ -f "$candidate" ]; then + cp -fL "$candidate" "$SHARED_DIR/.env" + log "Bootstrapped shared .env from $candidate" + return 0 + fi + done + + return 1 +} + +ensure_writable_paths() { + local paths=("$@") + + [ "${#paths[@]}" -gt 0 ] || return 0 + + chmod -R ug+rwX "${paths[@]}" 2>/dev/null || true + + if command -v find >/dev/null 2>&1; then + find "${paths[@]}" -type d -exec chmod ug+rwx {} + 2>/dev/null || true + find "${paths[@]}" -type d -exec chmod g+s {} + 2>/dev/null || true + fi +} + +normalize_shared_permissions() { + local owner="${LADILL_DEPLOY_USER:-deploy}" + local group="${LADILL_DEPLOY_GROUP:-www-data}" + local shared_paths=("$SHARED_DIR/storage" "$SHARED_DIR/bootstrap-cache") + + id -u "$owner" >/dev/null 2>&1 || return 0 + getent group "$group" >/dev/null 2>&1 || return 0 + + if chown -R "$owner:$group" "${shared_paths[@]}" 2>/dev/null; then + : + elif command -v sudo >/dev/null 2>&1; then + sudo -n chown -R "$owner:$group" "${shared_paths[@]}" 2>/dev/null || true + fi + + ensure_writable_paths "${shared_paths[@]}" +} + +run_artisan() { + local command="$1" + + if [ -f "$NEW_RELEASE/artisan" ]; then + (cd "$NEW_RELEASE" && php artisan ${command}) + fi +} + +uses_sqlite() { + [ -f "$SHARED_DIR/.env" ] && grep -Eq '^DB_CONNECTION=sqlite([[:space:]]*)$' "$SHARED_DIR/.env" +} + +sqlite_database_setting() { + [ -f "$SHARED_DIR/.env" ] || return 1 + + grep -E '^DB_DATABASE=' "$SHARED_DIR/.env" | tail -n 1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//" +} + +prepare_sqlite_database() { + local configured_path="" + local db_name="" + local shared_sqlite_path="" + local current_sqlite_path="" + local release_sqlite_path="" + + configured_path="$(sqlite_database_setting || true)" + [ -n "$configured_path" ] || configured_path="database/database.sqlite" + + if [[ "$configured_path" = /* ]]; then + shared_sqlite_path="$configured_path" + mkdir -p "$(dirname "$shared_sqlite_path")" + else + db_name="$(basename "$configured_path")" + shared_sqlite_path="$SHARED_DIR/database/$db_name" + current_sqlite_path="$CURRENT_LINK/$configured_path" + release_sqlite_path="$NEW_RELEASE/$configured_path" + + mkdir -p "$SHARED_DIR/database" "$(dirname "$release_sqlite_path")" + + if [ ! -f "$shared_sqlite_path" ]; then + if [ -f "$current_sqlite_path" ]; then + cp -fL "$current_sqlite_path" "$shared_sqlite_path" + log "Bootstrapped shared sqlite database from $current_sqlite_path" + elif [ -f "$APP_ROOT/$configured_path" ]; then + cp -fL "$APP_ROOT/$configured_path" "$shared_sqlite_path" + log "Bootstrapped shared sqlite database from $APP_ROOT/$configured_path" + else + touch "$shared_sqlite_path" + log "Created shared sqlite database at $shared_sqlite_path" + fi + fi + + rm -f "$release_sqlite_path" + ln -sfn "$shared_sqlite_path" "$release_sqlite_path" + ensure_writable_paths "$SHARED_DIR/database" + return 0 + fi + + if [ ! -f "$shared_sqlite_path" ]; then + touch "$shared_sqlite_path" + log "Created shared sqlite database at $shared_sqlite_path" + fi + + ensure_writable_paths "$(dirname "$shared_sqlite_path")" +} + +log "Preparing release $STAMP" +[ -f "$RELEASE_ARCHIVE" ] || { echo "Release archive not found: $RELEASE_ARCHIVE" >&2; exit 1; } + +mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$NEW_RELEASE" +tar -xzf "$RELEASE_ARCHIVE" -C "$NEW_RELEASE" +chmod -R u+rwX "$NEW_RELEASE" 2>/dev/null || true + +log "Linking shared paths" +mkdir -p "$SHARED_DIR/storage/"{app/public,app/private,framework/{cache/data,sessions,testing,views},logs} +mkdir -p "$SHARED_DIR/bootstrap-cache" +normalize_shared_permissions + +if bootstrap_shared_env; then + ln -sfn "$SHARED_DIR/.env" "$NEW_RELEASE/.env" +else + echo "Missing deployment environment file. Expected $SHARED_DIR/.env or an existing $CURRENT_LINK/.env / $APP_ROOT/.env to bootstrap from." >&2 + exit 1 +fi + +rm -rf "$NEW_RELEASE/storage" +ln -sfn "$SHARED_DIR/storage" "$NEW_RELEASE/storage" +mkdir -p "$NEW_RELEASE/bootstrap" +rm -rf "$NEW_RELEASE/bootstrap/cache" +ln -sfn "$SHARED_DIR/bootstrap-cache" "$NEW_RELEASE/bootstrap/cache" +normalize_shared_permissions + +if uses_sqlite; then + log "Preparing shared sqlite database" + prepare_sqlite_database + if id -u www-data >/dev/null 2>&1; then + chgrp www-data "$SHARED_DIR/database" 2>/dev/null || true + find "$SHARED_DIR/database" -type f -exec chgrp www-data {} + 2>/dev/null || true + find "$SHARED_DIR/database" -type d -exec chgrp www-data {} + 2>/dev/null || true + fi +fi + +log "Installing PHP dependencies" +if [ -f "$NEW_RELEASE/composer.json" ]; then + if [ "$(id -u)" -eq 0 ]; then + export COMPOSER_ALLOW_SUPERUSER=1 + fi + export COMPOSER_HOME="${COMPOSER_HOME:-/home/deploy/.composer}" + ( + cd "$NEW_RELEASE" + composer install \ + --no-dev \ + --prefer-dist \ + --no-interaction \ + --no-progress \ + --optimize-autoloader \ + --classmap-authoritative + ) +fi + +log "Running migrations" +if ! run_artisan "migrate --force"; then + log "Migration failed — clearing shared bootstrap cache" + run_artisan "optimize:clear" || true + exit 1 +fi + +switch_current_release() { + if ln -sfnT "$NEW_RELEASE" "$CURRENT_LINK" 2>/dev/null; then + return 0 + fi + + if command -v sudo >/dev/null 2>&1 && sudo -n ln -sfnT "$NEW_RELEASE" "$CURRENT_LINK"; then + return 0 + fi + + echo "Unable to update current release symlink at $CURRENT_LINK" >&2 + return 1 +} + +log "Switching current release" +if ! switch_current_release; then + echo "Failed to point $CURRENT_LINK at $NEW_RELEASE" >&2 + exit 1 +fi + +LIVE_REV="$(cat "$CURRENT_LINK/REVISION" 2>/dev/null || echo unknown)" +log "Live release: $STAMP (revision $LIVE_REV)" + +log "Optimizing Laravel" +if [ -L "$CURRENT_LINK" ] && [ -f "$CURRENT_LINK/artisan" ]; then + ( + cd "$CURRENT_LINK" + php artisan storage:link || true + php artisan optimize:clear || true + php artisan config:cache || true + php artisan route:cache || true + php artisan view:cache || true + ) +fi + +log "Reloading services" +if command -v systemctl >/dev/null 2>&1; then + systemctl reload php8.4-fpm 2>/dev/null || sudo -n systemctl reload php8.4-fpm + systemctl reload nginx 2>/dev/null || sudo -n systemctl reload nginx +fi + +log "Restarting queues" +if [ -L "$CURRENT_LINK" ] && [ -f "$CURRENT_LINK/artisan" ]; then + (cd "$CURRENT_LINK" && php artisan queue:restart) || true +fi +if command -v supervisorctl >/dev/null 2>&1; then + supervisorctl restart ladill-mini-worker:* 2>/dev/null || true +fi + +log "Cleaning old releases" +mapfile -t OLD_RELEASES < <(ls -1dt "$RELEASES_DIR"/* 2>/dev/null | tail -n "+$((KEEP_RELEASES + 1))" || true) +if [ "${#OLD_RELEASES[@]}" -gt 0 ]; then + chmod -R u+rwX "${OLD_RELEASES[@]}" 2>/dev/null || true + rm -rf "${OLD_RELEASES[@]}" 2>/dev/null || true +fi + +log "Deploy completed: $STAMP" diff --git a/deploy/sudoers.ladill-hosting.example b/deploy/sudoers.ladill-hosting.example new file mode 100644 index 0000000..4dc5ce2 --- /dev/null +++ b/deploy/sudoers.ladill-hosting.example @@ -0,0 +1,7 @@ +# Replace `www-data` with the actual PHP-FPM/web user that runs the app. +# Install as `/etc/sudoers.d/ladill-hosting` with mode `0440`. +# +# These helpers are root-owned fixed entry points installed by the deploy script. + +www-data ALL=(root) NOPASSWD: /usr/local/bin/ladill-hosting-admin * +www-data ALL=(root) NOPASSWD: /usr/local/bin/ladill-hosting-user * diff --git a/deployment/setup-service-subdomain-nginx.sh b/deployment/setup-service-subdomain-nginx.sh new file mode 100755 index 0000000..afde633 --- /dev/null +++ b/deployment/setup-service-subdomain-nginx.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# +# Provision an nginx vhost for one of Ladill's own service subdomains +# (auth./account./pay./qr./... .ladill.com), reusing the existing +# *.ladill.com wildcard TLS certificate. +# +# The monolith phase needs nothing here — the ladill.com vhost already +# serves *.ladill.com over the wildcard cert. Use this when a service gets +# its OWN app (separate Laravel app dir, or a backend on another host/port), +# so its subdomain stops falling through to the catch-all and routes to the +# real service instead. +# +# Idempotent + safe: writes to sites-available, validates with `nginx -t`, +# and only reloads on success (restores the previous config on failure). +# +# Usage: +# setup-service-subdomain-nginx.sh --app [--fpm ] +# setup-service-subdomain-nginx.sh --proxy +# +# Options: +# --app DIR Serve a Laravel app from DIR/public via PHP-FPM. +# --fpm SOCK PHP-FPM socket for --app (default: /run/php/php8.4-fpm-ladill.sock). +# --proxy URL Reverse-proxy the subdomain to URL (e.g. http://127.0.0.1:9001). +# --zone ZONE Apex zone (default: ladill.com). +# --cert NAME Let's Encrypt lineage under /etc/letsencrypt/live (default: ladill-wildcard). +# --dry-run Print the rendered vhost and exit without writing anything. +# +# Examples: +# setup-service-subdomain-nginx.sh auth --app /var/www/auth.ladill.com/current +# setup-service-subdomain-nginx.sh pay --proxy http://127.0.0.1:9002 +set -euo pipefail + +SUB="${1:-}"; shift || true +[ -n "$SUB" ] || { echo "ERROR: missing (e.g. 'auth')"; exit 2; } + +MODE=""; APP_DIR=""; PROXY_URL="" +FPM_SOCK="/run/php/php8.4-fpm-ladill.sock" +ZONE="ladill.com" +CERT="ladill-wildcard" +DRY_RUN=0 + +while [ $# -gt 0 ]; do + case "$1" in + --app) MODE="app"; APP_DIR="${2:?--app needs a dir}"; shift 2;; + --proxy) MODE="proxy"; PROXY_URL="${2:?--proxy needs a url}"; shift 2;; + --fpm) FPM_SOCK="${2:?}"; shift 2;; + --zone) ZONE="${2:?}"; shift 2;; + --cert) CERT="${2:?}"; shift 2;; + --dry-run) DRY_RUN=1; shift;; + *) echo "ERROR: unknown arg '$1'"; exit 2;; + esac +done + +[ -n "$MODE" ] || { echo "ERROR: choose a backend: --app or --proxy "; exit 2; } + +FQDN="${SUB}.${ZONE}" +CERT_DIR="/etc/letsencrypt/live/${CERT}" +WEBROOT="${APP_DIR:-/var/www/${ZONE}/current}/public" + +if [ "$MODE" = "app" ]; then + BODY=$(cat <> Backed up existing vhost to $BK" +fi + +printf '%s\n' "$VHOST" > "$AVAIL" +ln -sfn "$AVAIL" "$ENABLED" + +if nginx -t; then + systemctl reload nginx + echo ">> ${FQDN} vhost active (${MODE})." +else + echo "ERROR: nginx config test failed — rolling back." + if [ -n "${BK:-}" ] && [ -f "${BK:-}" ]; then + cp -a "$BK" "$AVAIL" + else + rm -f "$AVAIL" "$ENABLED" + fi + nginx -t && systemctl reload nginx || true + exit 1 +fi diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0f544b4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2558 @@ +{ + "name": "ladill-qr-plus", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@alpinejs/collapse": "^3.15.12", + "@tailwindcss/forms": "^0.5.11", + "alpinejs": "^3.15.12", + "qr-code-styling": "^1.9.2", + "qrcode-generator": "^2.0.4" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "vite": "^7.0.7" + } + }, + "node_modules/@alpinejs/collapse": { + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/@alpinejs/collapse/-/collapse-3.15.12.tgz", + "integrity": "sha512-BKNANLtNXuWYOSAnajSKLPjTsmHRNrv0ALFTbpmqt2/klHFooPhctSwkhFVPQb7rZ8BjEKHmNaBwnSbgtpk6xg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alpinejs": { + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz", + "integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz", + "integrity": "sha512-z+ck2BSV6KWtYcoIzk9Y5+p4NEjqM+Y4i8/H+VZRLq0OgNjW2DqyADquwYu5j8qRvaXwzNmfCWl1KrMlV1zpsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^7.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qr-code-styling": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/qr-code-styling/-/qr-code-styling-1.9.2.tgz", + "integrity": "sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==", + "license": "MIT", + "dependencies": { + "qrcode-generator": "^1.4.4" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/qr-code-styling/node_modules/qrcode-generator": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.5.2.tgz", + "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==", + "license": "MIT" + }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f594a7f --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "vite": "^7.0.7" + }, + "dependencies": { + "@alpinejs/collapse": "^3.15.12", + "@tailwindcss/forms": "^0.5.11", + "alpinejs": "^3.15.12", + "qr-code-styling": "^1.9.2", + "qrcode-generator": "^2.0.4" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..5d8de89 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,37 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..c2b46bd Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..84b9280 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/ladill-icons/bird.svg b/public/images/ladill-icons/bird.svg new file mode 100644 index 0000000..ad8a1a1 --- /dev/null +++ b/public/images/ladill-icons/bird.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/public/images/ladill-icons/domains.svg b/public/images/ladill-icons/domains.svg new file mode 100644 index 0000000..ba4956e --- /dev/null +++ b/public/images/ladill-icons/domains.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/public/images/ladill-icons/events.svg b/public/images/ladill-icons/events.svg new file mode 100644 index 0000000..6465988 --- /dev/null +++ b/public/images/ladill-icons/events.svg @@ -0,0 +1,2 @@ + + diff --git a/public/images/ladill-icons/pro.svg b/public/images/ladill-icons/pro.svg new file mode 100644 index 0000000..8ab5fc4 --- /dev/null +++ b/public/images/ladill-icons/pro.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/ladill-icons/qrplus.svg b/public/images/ladill-icons/qrplus.svg new file mode 100644 index 0000000..023c58d --- /dev/null +++ b/public/images/ladill-icons/qrplus.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/ladill-icons/server.svg b/public/images/ladill-icons/server.svg new file mode 100644 index 0000000..5fbb2ac --- /dev/null +++ b/public/images/ladill-icons/server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/ladill-icons/wordpress.svg b/public/images/ladill-icons/wordpress.svg new file mode 100644 index 0000000..0be90e4 --- /dev/null +++ b/public/images/ladill-icons/wordpress.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/launcher-icons/bird.svg b/public/images/launcher-icons/bird.svg new file mode 100644 index 0000000..ba4956e --- /dev/null +++ b/public/images/launcher-icons/bird.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/crm.svg b/public/images/launcher-icons/crm.svg new file mode 100644 index 0000000..5a15828 --- /dev/null +++ b/public/images/launcher-icons/crm.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/domains.svg b/public/images/launcher-icons/domains.svg new file mode 100644 index 0000000..ad8a1a1 --- /dev/null +++ b/public/images/launcher-icons/domains.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/email.svg b/public/images/launcher-icons/email.svg new file mode 100644 index 0000000..72877c8 --- /dev/null +++ b/public/images/launcher-icons/email.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/events.svg b/public/images/launcher-icons/events.svg new file mode 100644 index 0000000..feb13f8 --- /dev/null +++ b/public/images/launcher-icons/events.svg @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/give.svg b/public/images/launcher-icons/give.svg new file mode 100644 index 0000000..8985473 --- /dev/null +++ b/public/images/launcher-icons/give.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/hosting.svg b/public/images/launcher-icons/hosting.svg new file mode 100644 index 0000000..bd7b96f --- /dev/null +++ b/public/images/launcher-icons/hosting.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/invoice.svg b/public/images/launcher-icons/invoice.svg new file mode 100644 index 0000000..8c0e070 --- /dev/null +++ b/public/images/launcher-icons/invoice.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/mail.svg b/public/images/launcher-icons/mail.svg new file mode 100644 index 0000000..17ee6ca --- /dev/null +++ b/public/images/launcher-icons/mail.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/merchant.svg b/public/images/launcher-icons/merchant.svg new file mode 100644 index 0000000..5029df3 --- /dev/null +++ b/public/images/launcher-icons/merchant.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/mini.svg b/public/images/launcher-icons/mini.svg new file mode 100644 index 0000000..62957e6 --- /dev/null +++ b/public/images/launcher-icons/mini.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/pos.svg b/public/images/launcher-icons/pos.svg new file mode 100644 index 0000000..84b9280 --- /dev/null +++ b/public/images/launcher-icons/pos.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/qrplus.svg b/public/images/launcher-icons/qrplus.svg new file mode 100644 index 0000000..023c58d --- /dev/null +++ b/public/images/launcher-icons/qrplus.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/servers.svg b/public/images/launcher-icons/servers.svg new file mode 100644 index 0000000..169dc36 --- /dev/null +++ b/public/images/launcher-icons/servers.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/sms.svg b/public/images/launcher-icons/sms.svg new file mode 100644 index 0000000..bc628c1 --- /dev/null +++ b/public/images/launcher-icons/sms.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/transfer.svg b/public/images/launcher-icons/transfer.svg new file mode 100644 index 0000000..1a18372 --- /dev/null +++ b/public/images/launcher-icons/transfer.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladilldomains-logo-white.svg b/public/images/logo/ladilldomains-logo-white.svg new file mode 100644 index 0000000..0ba849c --- /dev/null +++ b/public/images/logo/ladilldomains-logo-white.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladilldomains-logo.svg b/public/images/logo/ladilldomains-logo.svg new file mode 100644 index 0000000..72e4745 --- /dev/null +++ b/public/images/logo/ladilldomains-logo.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillemail-logo.svg b/public/images/logo/ladillemail-logo.svg new file mode 100644 index 0000000..f5d4f15 --- /dev/null +++ b/public/images/logo/ladillemail-logo.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillevents-logo.svg b/public/images/logo/ladillevents-logo.svg new file mode 100644 index 0000000..e23909f --- /dev/null +++ b/public/images/logo/ladillevents-logo.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillhosting-logo.svg b/public/images/logo/ladillhosting-logo.svg new file mode 100644 index 0000000..6a66d01 --- /dev/null +++ b/public/images/logo/ladillhosting-logo.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillmerchant-logo.svg b/public/images/logo/ladillmerchant-logo.svg new file mode 100644 index 0000000..a49d124 --- /dev/null +++ b/public/images/logo/ladillmerchant-logo.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillmini-logo-email.png b/public/images/logo/ladillmini-logo-email.png new file mode 100644 index 0000000..c22d791 Binary files /dev/null and b/public/images/logo/ladillmini-logo-email.png differ diff --git a/public/images/logo/ladillmini-logo.svg b/public/images/logo/ladillmini-logo.svg new file mode 100644 index 0000000..76652b5 --- /dev/null +++ b/public/images/logo/ladillmini-logo.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillpos-logo.svg b/public/images/logo/ladillpos-logo.svg new file mode 100644 index 0000000..e96ae46 --- /dev/null +++ b/public/images/logo/ladillpos-logo.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillqrplus-logo-white.svg b/public/images/logo/ladillqrplus-logo-white.svg new file mode 100644 index 0000000..3ea536b --- /dev/null +++ b/public/images/logo/ladillqrplus-logo-white.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillqrplus-logo.svg b/public/images/logo/ladillqrplus-logo.svg new file mode 100644 index 0000000..d9b81fd --- /dev/null +++ b/public/images/logo/ladillqrplus-logo.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/qr-icons/app.svg b/public/images/qr-icons/app.svg new file mode 100644 index 0000000..cf2c12f --- /dev/null +++ b/public/images/qr-icons/app.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/qr-icons/business-profile.svg b/public/images/qr-icons/business-profile.svg new file mode 100644 index 0000000..77284d8 --- /dev/null +++ b/public/images/qr-icons/business-profile.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/qr-icons/facebook.svg b/public/images/qr-icons/facebook.svg new file mode 100644 index 0000000..94a3b7b --- /dev/null +++ b/public/images/qr-icons/facebook.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/qr-icons/instagram.svg b/public/images/qr-icons/instagram.svg new file mode 100644 index 0000000..658eb6e --- /dev/null +++ b/public/images/qr-icons/instagram.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/qr-icons/linkedin.svg b/public/images/qr-icons/linkedin.svg new file mode 100644 index 0000000..875694f --- /dev/null +++ b/public/images/qr-icons/linkedin.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/qr-icons/list.svg b/public/images/qr-icons/list.svg new file mode 100644 index 0000000..ccf7f95 --- /dev/null +++ b/public/images/qr-icons/list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/qr-icons/terms.svg b/public/images/qr-icons/terms.svg new file mode 100644 index 0000000..6b352e0 --- /dev/null +++ b/public/images/qr-icons/terms.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/qr-icons/tik-tok.svg b/public/images/qr-icons/tik-tok.svg new file mode 100644 index 0000000..7e6913c --- /dev/null +++ b/public/images/qr-icons/tik-tok.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/qr-icons/twitter.svg b/public/images/qr-icons/twitter.svg new file mode 100644 index 0000000..2e05e5f --- /dev/null +++ b/public/images/qr-icons/twitter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/qr-icons/website.svg b/public/images/qr-icons/website.svg new file mode 100644 index 0000000..e6b7459 --- /dev/null +++ b/public/images/qr-icons/website.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/qr-icons/whatsapp.svg b/public/images/qr-icons/whatsapp.svg new file mode 100644 index 0000000..17ee32c --- /dev/null +++ b/public/images/qr-icons/whatsapp.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/qr-icons/wifi.svg b/public/images/qr-icons/wifi.svg new file mode 100644 index 0000000..d56723c --- /dev/null +++ b/public/images/qr-icons/wifi.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/qr-icons/youtube.svg b/public/images/qr-icons/youtube.svg new file mode 100644 index 0000000..cf2e8d9 --- /dev/null +++ b/public/images/qr-icons/youtube.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 0000000..6834036 --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,166 @@ +@import 'tailwindcss'; +@plugin '@tailwindcss/forms'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; +@source '../**/*.blade.php'; +@source '../**/*.js'; + +@theme { + --font-sans: 'Figtree', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +} + +[x-cloak] { + display: none !important; +} + +/* QR create/show: flush mobile action bar to the physical screen bottom (mobile only) */ +@media (max-width: 1023px) { + .mobile-action-bar { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 50; + display: flex; + flex-direction: column; + background-color: #fff; + } + + .mobile-action-bar__toolbar { + display: flex; + height: 4rem; + flex-shrink: 0; + align-items: center; + gap: 0.75rem; + border-top: 1px solid rgb(226 232 240); + background-color: #fff; + padding-inline: 1rem; + } + + .mobile-action-bar__inset-fill { + flex-shrink: 0; + width: 100%; + background-color: #fff; + height: env(safe-area-inset-bottom, 0px); + min-height: max(env(safe-area-inset-bottom, 0px), 20px); + } +} + +/* Extra white bleed behind the bar for any remaining viewport gap on mobile */ +.qr-mobile-bottom-bleed { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 48; + pointer-events: none; + background-color: #fff; + height: calc(4rem + env(safe-area-inset-bottom, 0px) + 24px); + min-height: calc(4rem + 24px); +} + +html.qr-mobile-page { + background-color: #fff; +} + +html.qr-mobile-page body { + background-color: #fff; +} + +/* Payment QR landing: lock scroll and keep the amount sheet flush to the screen bottom */ +@media (max-width: 767px) { + html.mini-payment-page, + html.mini-payment-page body { + overflow: hidden; + width: 100%; + height: 100%; + } + + html.mini-payment-page body { + position: fixed; + inset: 0; + } + + .mini-payment-sheet { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 20; + } + + .mini-payment-bottom-bleed { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 19; + pointer-events: none; + background-color: #fff; + height: calc(12rem + env(safe-area-inset-bottom, 0px)); + min-height: 8rem; + } +} + +.mobile-stats-card { + width: calc(66.666667% - 0.5rem); +} + +@layer components { + .btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: 9999px; + background-image: linear-gradient(to right, rgb(79 70 229), rgb(124 58 237)); + padding: 0.5rem 1rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 600; + color: #fff; + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + transition: filter 0.15s ease; + } + + .btn-primary:hover { + filter: brightness(0.92); + } + + .btn-primary:disabled { + opacity: 0.6; + pointer-events: none; + } + + .btn-primary-sm { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + line-height: 1rem; + } + + .btn-primary-lg { + padding: 0.625rem 1.25rem; + } + + .btn-fab { + display: inline-flex; + height: 2.75rem; + width: 2.75rem; + align-items: center; + justify-content: center; + border-radius: 9999px; + background-image: linear-gradient(to right, rgb(79 70 229), rgb(124 58 237)); + color: #fff; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + transition: filter 0.15s ease; + } + + .btn-fab:hover { + filter: brightness(0.92); + } +} + + +@media (min-width: 640px) { + .mobile-stats-card { + width: auto; + } +} diff --git a/resources/js/app.js b/resources/js/app.js new file mode 100644 index 0000000..90dfca5 --- /dev/null +++ b/resources/js/app.js @@ -0,0 +1,328 @@ +import './bootstrap'; + +import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals'; +import { registerLadillSearchShortcut } from './ladill-search-shortcut'; + +registerLadillModalHelpers(); +registerLadillSearchShortcut(); + + +import Alpine from 'alpinejs'; +import collapse from '@alpinejs/collapse'; +import QRCodeStyling from 'qr-code-styling'; +window.QRCodeStyling = QRCodeStyling; +import qrcode from 'qrcode-generator'; +window.qrcode = qrcode; + +Alpine.plugin(collapse); + +Alpine.data('notificationDropdown', (config = {}) => ({ + open: false, + loading: false, + notifications: [], + unreadCount: 0, + unreadUrl: config.unreadUrl || '/notifications/unread', + markReadUrl: config.markReadUrl || '/notifications/__ID__/read', + markAllReadUrl: config.markAllReadUrl || '/notifications/mark-all-read', + indexUrl: config.indexUrl || '/notifications', + csrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '', + + init() { + this.fetchUnread(); + setInterval(() => this.fetchUnread(), 60000); + }, + + async fetchUnread() { + try { + const res = await fetch(this.unreadUrl, { + headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + }); + const data = await res.json(); + this.notifications = data.notifications || []; + this.unreadCount = data.unread_count || 0; + } catch (e) { + console.error('Failed to fetch notifications', e); + } + }, + + toggle() { + this.open = !this.open; + if (this.open) { + this.loading = true; + this.fetchUnread().finally(() => { this.loading = false; }); + } + }, + + async markRead(id) { + const url = this.markReadUrl.replace('__ID__', id); + try { + await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': this.csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + this.notifications = this.notifications.filter(n => n.id !== id); + this.unreadCount = Math.max(0, this.unreadCount - 1); + } catch (e) { + console.error('Failed to mark notification as read', e); + } + }, + + async markAllRead() { + try { + await fetch(this.markAllReadUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': this.csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + this.notifications = []; + this.unreadCount = 0; + } catch (e) { + console.error('Failed to mark all notifications as read', e); + } + }, + + getIconBg(icon) { + const map = { domain: 'bg-emerald-50', hosting: 'bg-violet-50', email: 'bg-pink-50', billing: 'bg-amber-50', success: 'bg-green-50' }; + return map[icon] || 'bg-slate-100'; + }, + + getIconColor(icon) { + const map = { domain: 'text-emerald-600', hosting: 'text-violet-600', email: 'text-pink-600', billing: 'text-amber-600', success: 'text-green-600' }; + return map[icon] || 'text-slate-500'; + }, +})); + +// Afia — Ladill in-app AI assistant (slide-over chat). Opened via the topbar AI button +// which dispatches a window 'afia-open' event. Greeting/suggestions are passed from Blade. +Alpine.data('afia', (config = {}) => ({ + open: false, + input: '', + loading: false, + messages: [ + { role: 'assistant', text: config.greeting || "Hi, I'm Afia 👋 How can I help?" }, + ], + suggestions: config.suggestions || [], + init() { + window.addEventListener('afia-open', () => { + this.open = true; + this.$nextTick(() => this.$refs.input && this.$refs.input.focus()); + }); + }, + close() { this.open = false; }, + useSuggestion(s) { this.input = s; this.send(); }, + scrollDown() { + this.$nextTick(() => { const el = this.$refs.scroll; if (el) el.scrollTop = el.scrollHeight; }); + }, + async send() { + const text = this.input.trim(); + if (!text || this.loading) return; + const history = this.messages.map((m) => ({ role: m.role, text: m.text })); + this.messages.push({ role: 'user', text }); + this.input = ''; + this.loading = true; + this.scrollDown(); + try { + const res = await fetch(config.chatUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': config.csrf, 'Accept': 'application/json' }, + body: JSON.stringify({ message: text, history }), + }); + const data = await res.json(); + this.messages.push({ role: 'assistant', text: data.reply || data.message || 'Sorry, I could not respond.' }); + } catch (e) { + this.messages.push({ role: 'assistant', text: 'Network error — please try again.' }); + } + this.loading = false; + this.scrollDown(); + }, +})); + +function mobileKeyboardBottomOffset() { + const viewport = window.visualViewport; + if (!viewport) { + return 0; + } + + return Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop)); +} + +Alpine.data('miniPaymentLanding', (config = {}) => ({ + amount: config.amount ?? '', + loading: false, + errorMsg: config.errorMsg ?? '', + showSheet: false, + checkoutUrl: '', + paymentSheetStyle: '', + sheetBleedStyle: '', + + init() { + this._syncSheet = () => { + if (window.innerWidth >= 768) { + this.paymentSheetStyle = ''; + this.sheetBleedStyle = ''; + return; + } + + const offset = mobileKeyboardBottomOffset(); + const safePad = offset > 0 ? '1.25rem' : 'max(1.25rem, env(safe-area-inset-bottom))'; + this.paymentSheetStyle = `bottom: ${offset}px; padding-bottom: ${safePad};`; + this.sheetBleedStyle = `bottom: ${offset}px;`; + }; + + this._onViewportChange = () => this._syncSheet(); + this._onFocusChange = () => { + requestAnimationFrame(this._syncSheet); + setTimeout(this._syncSheet, 150); + setTimeout(this._syncSheet, 350); + }; + + window.visualViewport?.addEventListener('resize', this._onViewportChange); + window.visualViewport?.addEventListener('scroll', this._onViewportChange); + document.addEventListener('focusin', this._onFocusChange); + document.addEventListener('focusout', this._onFocusChange); + + if (window.innerWidth < 768) { + document.documentElement.classList.add('mini-payment-page'); + } + + this._syncSheet(); + }, + + destroy() { + window.visualViewport?.removeEventListener('resize', this._onViewportChange); + window.visualViewport?.removeEventListener('scroll', this._onViewportChange); + document.removeEventListener('focusin', this._onFocusChange); + document.removeEventListener('focusout', this._onFocusChange); + document.documentElement.classList.remove('mini-payment-page'); + }, + + async submitPay() { + const value = parseFloat(this.amount); + if (!value || value <= 0) { + this.errorMsg = 'Enter an amount greater than zero.'; + return; + } + + this.errorMsg = ''; + this.loading = true; + + try { + const res = await fetch(config.payUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': config.csrf, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ amount: value }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.error || data.message) { + this.errorMsg = data.error || data.message || 'Could not start payment. Please try again.'; + this.loading = false; + return; + } + if (!data.checkout_url) { + this.errorMsg = 'Could not start payment. Please try again.'; + this.loading = false; + return; + } + if (window.innerWidth < 768) { + this.checkoutUrl = data.checkout_url; + this.showSheet = true; + this.loading = false; + } else { + window.location.href = data.checkout_url; + } + } catch (e) { + this.errorMsg = 'Network error. Please try again.'; + this.loading = false; + } + }, +})); + +Alpine.data('topbarSearch', (config = {}) => ({ + query: config.initialQuery || '', + results: Array.isArray(config.initialResults) ? config.initialResults : [], + open: !!config.openOnInit, + loading: false, + active: 0, + _abort: null, + searchUrl: config.searchUrl || '/search', + + init() { + if (config.autoFocus) { + this.$nextTick(() => this.$refs.input?.focus()); + } + }, + + onFocus() { + if (this.results.length > 0 || this.query.trim().length >= 2) this.open = true; + }, + + async search() { + const q = this.query.trim(); + if (q.length < 2) { this.results = []; this.open = false; return; } + + this.loading = true; + this.open = true; + this.active = 0; + + if (this._abort) this._abort.abort(); + this._abort = new AbortController(); + + try { + const res = await fetch(`${this.searchUrl}?q=${encodeURIComponent(q)}`, { + signal: this._abort.signal, + headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + }); + const data = await res.json(); + this.results = data.results || []; + } catch (e) { + if (e.name !== 'AbortError') this.results = []; + } finally { + this.loading = false; + } + }, + + moveDown() { if (this.active < this.results.length - 1) this.active++; }, + moveUp() { if (this.active > 0) this.active--; }, + go() { + if (this.results[this.active]) window.location.href = this.results[this.active].url; + }, +})); + + +// Wallet balance peek for the avatar dropdown. +Alpine.data('walletWidget', (config = {}) => ({ + display: '…', + async load() { + if (! config.url) { + this.display = 'View wallet'; + + return; + } + try { + const res = await fetch(config.url, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } }); + const data = await res.json(); + this.display = data.available ? data.formatted : 'View wallet'; + } catch (e) { + this.display = 'View wallet'; + } + }, +})); + +window.Alpine = Alpine; +registerLadillConfirmStore(Alpine); + +Alpine.start(); diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js new file mode 100644 index 0000000..5f1390b --- /dev/null +++ b/resources/js/bootstrap.js @@ -0,0 +1,4 @@ +import axios from 'axios'; +window.axios = axios; + +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/js/ladill-modals.js b/resources/js/ladill-modals.js new file mode 100644 index 0000000..eb2bf74 --- /dev/null +++ b/resources/js/ladill-modals.js @@ -0,0 +1,75 @@ +/** + * Lightweight modal dispatch helpers (optional — balanceGate in layout is primary). + */ + +export function openLadillModal(name) { + if (!name) { + return false; + } + + window.dispatchEvent(new CustomEvent('open-modal', { detail: name, bubbles: true })); + + return true; +} + +export function closeLadillModal(name) { + if (!name) { + return false; + } + + window.dispatchEvent(new CustomEvent('close-modal', { detail: name, bubbles: true })); + + return true; +} + +export function ladillNeedsTopup(balance, price) { + const normalizedBalance = Number.parseFloat(balance); + const normalizedPrice = Number.parseFloat(price); + + if (!Number.isFinite(normalizedBalance) || !Number.isFinite(normalizedPrice)) { + return false; + } + + return normalizedBalance <= 0 || normalizedBalance < normalizedPrice; +} + +export function registerLadillConfirmStore(Alpine) { + Alpine.store('ladillConfirm', { + open: false, + title: '', + message: '', + confirmLabel: 'Confirm', + cancelLabel: 'Cancel', + variant: 'danger', + _resolve: null, + + ask(options = {}) { + return new Promise((resolve) => { + this.title = options.title || 'Are you sure?'; + this.message = options.message || ''; + this.confirmLabel = options.confirmLabel || 'Confirm'; + this.cancelLabel = options.cancelLabel || 'Cancel'; + this.variant = options.variant || 'danger'; + this._resolve = resolve; + this.open = true; + }); + }, + + answer(confirmed) { + if (this._resolve) { + this._resolve(confirmed); + } + + this.open = false; + this._resolve = null; + }, + }); + + window.ladillConfirm = (options = {}) => Alpine.store('ladillConfirm').ask(options); +} + +export function registerLadillModalHelpers() { + window.openLadillModal = openLadillModal; + window.closeLadillModal = closeLadillModal; + window.ladillNeedsTopup = ladillNeedsTopup; +} diff --git a/resources/js/ladill-search-shortcut.js b/resources/js/ladill-search-shortcut.js new file mode 100644 index 0000000..5ea893d --- /dev/null +++ b/resources/js/ladill-search-shortcut.js @@ -0,0 +1,97 @@ +function isEditableTarget(element) { + if (!(element instanceof HTMLElement)) { + return false; + } + + const tag = element.tagName; + + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { + return true; + } + + return element.isContentEditable; +} + +function isVisibleInput(input) { + if (!(input instanceof HTMLElement)) { + return false; + } + + const style = window.getComputedStyle(input); + + return style.display !== 'none' + && style.visibility !== 'hidden' + && style.opacity !== '0'; +} + +function findSearchInput() { + const marked = [...document.querySelectorAll('[data-ladill-search-input]')]; + const visibleMarked = marked.find(isVisibleInput); + + if (visibleMarked) { + return visibleMarked; + } + + if (marked.length > 0) { + return marked[0]; + } + + return document.querySelector('[x-data*="topbarSearch"] input'); +} + +function focusSearchInput() { + const input = findSearchInput(); + + if (!input) { + const fallbackUrl = document.body?.dataset?.ladillSearchUrl; + + if (fallbackUrl) { + window.location.href = fallbackUrl; + } + + return false; + } + + if (!isVisibleInput(input)) { + const fallbackUrl = document.body?.dataset?.ladillSearchUrl; + + if (fallbackUrl) { + window.location.href = fallbackUrl; + + return true; + } + } + + input.focus(); + + if (input instanceof HTMLInputElement && input.type === 'text') { + input.select(); + } + + return true; +} + +export function registerLadillSearchShortcut() { + if (window.__ladillSearchShortcutRegistered) { + return; + } + + window.__ladillSearchShortcutRegistered = true; + + document.addEventListener('keydown', (event) => { + if (event.key !== '/' && event.code !== 'Slash') { + return; + } + + if (event.ctrlKey || event.metaKey || event.altKey) { + return; + } + + if (isEditableTarget(document.activeElement)) { + return; + } + + event.preventDefault(); + focusSearchInput(); + }); +} diff --git a/resources/views/auth/signed-out.blade.php b/resources/views/auth/signed-out.blade.php new file mode 100644 index 0000000..c6306cc --- /dev/null +++ b/resources/views/auth/signed-out.blade.php @@ -0,0 +1,52 @@ +@php + $signedOut = (array) config('signed_out'); + $logo = (string) ($signedOut['logo'] ?? 'images/logo/ladillgive-logo.svg'); + $logoPath = public_path($logo); +@endphp + + + + + + Signed out — {{ $signedOut['title'] ?? config('app.name') }} + @include('partials.favicon') + + + @vite(['resources/css/app.css']) + + + +
+
+ +
+ {{ $signedOut['title'] ?? config('app.name') }} + +
+ +
+ +

You’re signed out

+

+ {{ $signedOut['description'] ?? ('Your '.($signedOut['title'] ?? config('app.name')).' session has ended. Sign in again to continue.') }} +

+ + + Sign in again + + + + Go to ladill.com + +
+
+ + diff --git a/resources/views/auth/sso-logout-bridge.blade.php b/resources/views/auth/sso-logout-bridge.blade.php new file mode 100644 index 0000000..2540367 --- /dev/null +++ b/resources/views/auth/sso-logout-bridge.blade.php @@ -0,0 +1,26 @@ + + + + + + Signing out… + @include('partials.favicon') + + + + +

Signing you out of Ladill…

+ + + + diff --git a/resources/views/auth/sso-popup-done.blade.php b/resources/views/auth/sso-popup-done.blade.php new file mode 100644 index 0000000..cfa0a36 --- /dev/null +++ b/resources/views/auth/sso-popup-done.blade.php @@ -0,0 +1,32 @@ + + + + + Signing in… + + + + + + diff --git a/resources/views/auth/sso-signing-in.blade.php b/resources/views/auth/sso-signing-in.blade.php new file mode 100644 index 0000000..232c698 --- /dev/null +++ b/resources/views/auth/sso-signing-in.blade.php @@ -0,0 +1,38 @@ + + + + + + Signing in… + @include('partials.favicon') + + + + +

Signing you in with Ladill…

+ + + diff --git a/resources/views/components/app-layout.blade.php b/resources/views/components/app-layout.blade.php new file mode 100644 index 0000000..0004f4d --- /dev/null +++ b/resources/views/components/app-layout.blade.php @@ -0,0 +1,31 @@ +@props(['title' => 'Ladill POS', 'heading' => null]) + + + + + + + {{ $title }} · Ladill POS + @include('partials.favicon') + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+
+ +
+ @include('partials.topbar', ['heading' => $heading ?? $title]) +
+ @include('partials.flash') + {{ $slot }} +
+
+
+ + diff --git a/resources/views/components/btn/create.blade.php b/resources/views/components/btn/create.blade.php new file mode 100644 index 0000000..caa7d06 --- /dev/null +++ b/resources/views/components/btn/create.blade.php @@ -0,0 +1,17 @@ +@props([ + 'href' => null, + 'type' => 'button', +]) + +@php + $tag = $href ? 'a' : 'button'; +@endphp + +<{{ $tag }} + @if ($href) href="{{ $href }}" @endif + @if ($tag === 'button') type="{{ $type }}" @endif + {{ $attributes->class(['btn-primary']) }} +> + + {{ $slot }} + diff --git a/resources/views/components/btn/primary.blade.php b/resources/views/components/btn/primary.blade.php new file mode 100644 index 0000000..b9cb905 --- /dev/null +++ b/resources/views/components/btn/primary.blade.php @@ -0,0 +1,16 @@ +@props([ + 'href' => null, + 'type' => 'button', +]) + +@php + $tag = $href ? 'a' : 'button'; +@endphp + +<{{ $tag }} + @if ($href) href="{{ $href }}" @endif + @if ($tag === 'button') type="{{ $type }}" @endif + {{ $attributes->class(['btn-primary']) }} +> + {{ $slot }} + diff --git a/resources/views/components/confirm-dialog.blade.php b/resources/views/components/confirm-dialog.blade.php new file mode 100644 index 0000000..1d0ee69 --- /dev/null +++ b/resources/views/components/confirm-dialog.blade.php @@ -0,0 +1,69 @@ +@props([ + 'name', + 'title', + 'message' => null, + 'action', + 'method' => 'POST', + 'confirmLabel' => 'Confirm', + 'cancelLabel' => 'Cancel', + 'variant' => 'danger', +]) + +@php + $confirmBtnClass = $variant === 'danger' + ? 'bg-red-600 hover:bg-red-700' + : 'bg-violet-600 hover:bg-violet-700'; + $iconWrapClass = $variant === 'danger' + ? 'bg-red-100 text-red-600' + : 'bg-violet-100 text-violet-600'; +@endphp + +@if(isset($trigger)) + + {{ $trigger }} + +@endif + + +
+
+
+ @if($variant === 'danger') + + + + @else + + + + @endif +
+

{{ $title }}

+ @if($message) +

{{ $message }}

+ @endif + @isset($details) +
{{ $details }}
+ @endisset +
+ +
+ @csrf + @if(strtoupper($method) !== 'POST') + @method($method) + @endif + @isset($fields) + {{ $fields }} + @endisset + + +
+
+
diff --git a/resources/views/components/hosting-panel-layout.blade.php b/resources/views/components/hosting-panel-layout.blade.php new file mode 100644 index 0000000..978ac9e --- /dev/null +++ b/resources/views/components/hosting-panel-layout.blade.php @@ -0,0 +1,73 @@ + + + + + + + @include('partials.favicon') + {{ $title ?? 'Hosting Panel' }} - Ladill + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + +@php + $canViewAccountDetails = auth()->check() && auth()->user()->can('viewAccount', $account); + $exitUrl = $canViewAccountDetails + ? route('hosting.accounts.show', $account) + : route('hosting.single-domain'); +@endphp + +
+ {{-- Mobile sidebar overlay --}} +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+ {{-- Top bar --}} +
+ +
+

{{ $header ?? 'Hosting Panel' }}

+ @unless ($canViewAccountDetails) +

Developer access

+ @endunless +
+ + + Exit Panel + +
+ + {{-- Flash messages --}} + @include('partials.flash') + + {{-- Expired Account Banner --}} + @if ($account->isExpired() && $account->isInGracePeriod()) +
+
+ +

Account expired. Your site is offline. Files are preserved until {{ $account->gracePeriodEndsAt()->format('M d, Y') }}. + Renew now +

+
+
+ @endif + + {{-- Page content --}} +
+ {{ $slot }} +
+
+
+ + @stack('scripts') + + diff --git a/resources/views/components/icons/domain-globe.blade.php b/resources/views/components/icons/domain-globe.blade.php new file mode 100644 index 0000000..f1d97c5 --- /dev/null +++ b/resources/views/components/icons/domain-globe.blade.php @@ -0,0 +1,4 @@ +@php + $class = $class ?? 'h-5 w-5'; +@endphp +{!! \App\Support\DomainGlobeIcon::svg($class) !!} diff --git a/resources/views/components/icons/multi-domain-hosting.blade.php b/resources/views/components/icons/multi-domain-hosting.blade.php new file mode 100644 index 0000000..43890e5 --- /dev/null +++ b/resources/views/components/icons/multi-domain-hosting.blade.php @@ -0,0 +1,6 @@ +@php + $class = $class ?? 'h-5 w-5'; +@endphp + diff --git a/resources/views/components/icons/qr-code.blade.php b/resources/views/components/icons/qr-code.blade.php new file mode 100644 index 0000000..3cdcc63 --- /dev/null +++ b/resources/views/components/icons/qr-code.blade.php @@ -0,0 +1,5 @@ +@props(['class' => 'h-5 w-5']) + +merge(['class' => $class]) }} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> + + diff --git a/resources/views/components/icons/single-domain-hosting.blade.php b/resources/views/components/icons/single-domain-hosting.blade.php new file mode 100644 index 0000000..69304cd --- /dev/null +++ b/resources/views/components/icons/single-domain-hosting.blade.php @@ -0,0 +1,7 @@ +@php + $class = $class ?? 'h-5 w-5'; + $strokeWidth = $strokeWidth ?? '1.5'; +@endphp + diff --git a/resources/views/components/icons/unlimited.blade.php b/resources/views/components/icons/unlimited.blade.php new file mode 100644 index 0000000..e7553f4 --- /dev/null +++ b/resources/views/components/icons/unlimited.blade.php @@ -0,0 +1,6 @@ +@php + $class = $class ?? 'h-5 w-5'; +@endphp + diff --git a/resources/views/components/input-error.blade.php b/resources/views/components/input-error.blade.php new file mode 100644 index 0000000..9e6da21 --- /dev/null +++ b/resources/views/components/input-error.blade.php @@ -0,0 +1,9 @@ +@props(['messages']) + +@if ($messages) +
    merge(['class' => 'text-sm text-red-600 space-y-1']) }}> + @foreach ((array) $messages as $message) +
  • {{ $message }}
  • + @endforeach +
+@endif diff --git a/resources/views/components/mobile-page-header.blade.php b/resources/views/components/mobile-page-header.blade.php new file mode 100644 index 0000000..dc4aa60 --- /dev/null +++ b/resources/views/components/mobile-page-header.blade.php @@ -0,0 +1,32 @@ +@props([ + 'title', + 'subtitle' => null, + 'backUrl' => null, + 'badge' => null, + 'hideAfia' => false, +]) + +
+
+ @if ($backUrl) + + + + @endif +
+ @if ($subtitle) +

{{ $subtitle }}

+ @endif +

{{ $title }}

+
+ @if ($badge) + {{ $badge }} + @endif + @unless ($hideAfia) + @include('partials.afia-button', ['compact' => true]) + @endunless + {{ $actions ?? '' }} +
+ {{ $slot }} +
diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php new file mode 100644 index 0000000..a823707 --- /dev/null +++ b/resources/views/components/modal.blade.php @@ -0,0 +1,88 @@ +@props([ + 'name', + 'show' => false, + 'maxWidth' => '2xl' +]) + +@php +$maxWidth = [ + 'sm' => 'sm:max-w-sm', + 'md' => 'sm:max-w-md', + 'lg' => 'sm:max-w-lg', + 'xl' => 'sm:max-w-xl', + '2xl' => 'sm:max-w-2xl', +][$maxWidth]; +@endphp + +
+ {{-- Backdrop click to close --}} +
+ + {{-- Panel: bottom-sheet on mobile, centered card on sm+ --}} +
+ {{-- Drag handle (mobile only) --}} +
+
+
+ + {{-- Close button (desktop only) --}} + + + {{ $slot }} +
+
diff --git a/resources/views/components/qr/cover-image-hint.blade.php b/resources/views/components/qr/cover-image-hint.blade.php new file mode 100644 index 0000000..9e67ed4 --- /dev/null +++ b/resources/views/components/qr/cover-image-hint.blade.php @@ -0,0 +1,5 @@ +@props(['variant' => 'banner']) + +

merge(['class' => 'mt-1 text-[10px] leading-snug text-slate-400']) }}> + Recommended: {{ \App\Support\Qr\QrCoverImageSpec::label($variant) }}. JPG, PNG, or WebP. +

diff --git a/resources/views/components/qr/customization-section.blade.php b/resources/views/components/qr/customization-section.blade.php new file mode 100644 index 0000000..9ecd8c7 --- /dev/null +++ b/resources/views/components/qr/customization-section.blade.php @@ -0,0 +1,30 @@ +@props([ + 'id', + 'title', + 'description', +]) + +
+ + +
+
+ {{ $slot }} +
+
+
diff --git a/resources/views/components/user-layout.blade.php b/resources/views/components/user-layout.blade.php new file mode 100644 index 0000000..96a7909 --- /dev/null +++ b/resources/views/components/user-layout.blade.php @@ -0,0 +1,33 @@ +@props(['title' => 'Ladill Hosting']) + + + + + + + {{ $title ?? 'Ladill Hosting' }} + @include('partials.favicon') + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+
+ +
+ @include('partials.topbar') +
+ @include('partials.flash') + {{ $slot }} +
+
+
+ @auth + @include('partials.afia') + @endauth + + diff --git a/resources/views/components/user/service-topup-modal.blade.php b/resources/views/components/user/service-topup-modal.blade.php new file mode 100644 index 0000000..f9a5675 --- /dev/null +++ b/resources/views/components/user/service-topup-modal.blade.php @@ -0,0 +1,124 @@ +@props([ + 'id', + 'title' => 'Add credits', + 'description' => '', + 'topupAction', + 'minTopup' => 5, + 'suggestedAmount' => 10, + 'ladillWalletBalance' => 0, + 'serviceBalance' => null, + 'serviceBalanceLabel' => 'Current balance', + 'openOnLoad' => false, + 'returnUrl' => null, +]) + +@php + // Single-wallet (siloing step 2): there is one Ladill wallet, so "pay from + // wallet into service credits" is a meaningless round-trip — top up the one + // wallet directly (Paystack). Transfer option removed. + $canPayFromWallet = false; +@endphp + + +
+
+

{{ $title }}

+ @if($description) +

{{ $description }}

+ @endif +
+ +
+ @csrf + @if($returnUrl) + + @endif + + @if($serviceBalance !== null) +
+

GHS {{ number_format((float) $serviceBalance, 2) }}

+

{{ $serviceBalanceLabel }}

+
+ @endif + +
+ +
+ + +

Minimum GHS {{ number_format($minTopup, 2) }}

+
+ + + + @if($canPayFromWallet) +
+

Payment method

+
+ + +
+
+ @else +

Pay with Paystack.

+ @endif + +
+ + +
+ + @include('partials.paystack-sheet') +
+
+
diff --git a/resources/views/email/account/billing.blade.php b/resources/views/email/account/billing.blade.php new file mode 100644 index 0000000..2f7442a --- /dev/null +++ b/resources/views/email/account/billing.blade.php @@ -0,0 +1,52 @@ +@extends('layouts.email') +@section('title', 'Billing — Ladill Email') +@section('content') +@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp +
+

Billing

+

Your Ladill wallet funds mailboxes across every Ladill app.

+ +
+
+

Wallet balance

+

{{ $fmt($balanceMinor) }}

+ Add funds +
+
+

Spent on email

+

{{ $fmt($spentMinor) }}

+
+
+

Refunded / credited

+

{{ $fmt($creditedMinor) }}

+
+
+ +
+
+

Mailbox subscriptions

+ priced by storage plan +
+ @if(empty($paidMailboxes)) +

No paid mailboxes yet — your free allowance covers you.

+ @else +
    + @foreach($paidMailboxes as $m) +
  • +
    +

    {{ $m['address'] ?? '—' }}

    +

    {{ $m['quota_label'] }} · {{ ucfirst($m['status'] ?? 'active') }}

    +
    + {{ $fmt($m['price_minor']) }}/mo +
  • + @endforeach +
+
+ Monthly total + {{ $fmt($monthlyTotalMinor) }} +
+ @endif +
+

Mailboxes beyond your free allowance renew monthly from your wallet. Keep it funded to avoid interruption.

+
+@endsection diff --git a/resources/views/email/account/developers.blade.php b/resources/views/email/account/developers.blade.php new file mode 100644 index 0000000..d7fa0cd --- /dev/null +++ b/resources/views/email/account/developers.blade.php @@ -0,0 +1,78 @@ +@extends('layouts.email') +@section('title', 'Developers — Ladill Email') +@section('content') +
+

Developers

+

API tokens to manage your mailboxes programmatically.

+ + @if($newToken) +
+

Your new token — copy it now

+

This is the only time it will be shown.

+
+ {{ $newToken }} + +
+
+ @endif + + {{-- Create --}} +
+

Create a token

+
+ @csrf +
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+
+ + {{-- Tokens --}} +
+

Your tokens

+ @forelse($tokens as $token) +
+
+

{{ $token->name }}

+

+ Created {{ $token->created_at->diffForHumans() }} · + {{ $token->last_used_at ? 'last used '.$token->last_used_at->diffForHumans() : 'never used' }} +

+
+ + + + + +
+ @empty +

No tokens yet.

+ @endforelse +
+ + {{-- Docs --}} +
+

Quick start

+

Authenticate with a Bearer token. Base URL:

+ {{ $apiBase }} +
curl {{ $apiBase }}/mailboxes \
+  -H "Authorization: Bearer <your-token>" \
+  -H "Accept: application/json"
+

Endpoints: GET /me, GET /mailboxes. More coming soon.

+
+
+@endsection diff --git a/resources/views/email/account/settings.blade.php b/resources/views/email/account/settings.blade.php new file mode 100644 index 0000000..8d4e4b1 --- /dev/null +++ b/resources/views/email/account/settings.blade.php @@ -0,0 +1,110 @@ +@extends('layouts.email') +@section('title', 'Settings — Ladill Email') +@section('content') +
+

Settings

+

Defaults, preferences, and account mailbox linking.

+ + @if($showMailboxLinkUi ?? (($linkStatus['linked_mailbox'] ?? null) || ($linkStatus['show_reminder'] ?? false))) +
+
+ + @include('partials.ladill-pro-icon') + +
+

Link to mailbox

+

+ Your Ladill account uses {{ $linkStatus['account_email'] ?? $account->email }}. + Link a mailbox so Ladill Mail opens with your Ladill sign-in. +

+ + @if($linkStatus['linked_mailbox'] ?? null) +
+ Linked mailbox: {{ $linkStatus['linked_mailbox'] }} +
+ + + + + + @elseif(($linkStatus['stage'] ?? '') === 'needs_domain') +

Add and verify an email domain first.

+ + Go to domains + + + @elseif(($linkStatus['stage'] ?? '') === 'needs_mailbox') +

Create a mailbox on your verified domain first.

+ + Create mailbox + + + @elseif(count($mailboxOptions) > 0) +
+ @csrf @method('PUT') +
+ + + @error('mailbox_address')

{{ $message }}

@enderror +
+ +
+ @endif +
+
+
+ @endif + +
+ @csrf @method('PUT') + +
+

Mailbox defaults

+
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + +
+
+@endsection diff --git a/resources/views/email/account/team.blade.php b/resources/views/email/account/team.blade.php new file mode 100644 index 0000000..c1baeaa --- /dev/null +++ b/resources/views/email/account/team.blade.php @@ -0,0 +1,90 @@ +@extends('layouts.email') +@section('title', 'Team — Ladill Email') +@section('content') +
+

Team

+

Invite people to help manage this account’s mailboxes & domains.

+ + @if($canManage) +
+

Invite a teammate

+
+ @csrf +
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + +
+ +
+

Admins can manage mailboxes and the team. Members can manage mailboxes. Invitees join by signing in with that email.

+
+ @endif + +
+

Members

+
    +
  • +
    + {{ strtoupper(substr($account->name ?? $account->email, 0, 1)) }} +
    +

    {{ $account->name ?? $account->email }} (you)

    +

    {{ $account->email }}

    +
    +
    + Owner +
  • + + @forelse($members as $member) +
  • +
    + {{ strtoupper(substr($member->email, 0, 1)) }} +
    +

    {{ $member->member->name ?? $member->email }}

    +

    {{ $member->email }}

    +
    +
    +
    + @if($member->status === 'invited') + Invited + @endif + @if($canManage) +
    + @csrf @method('PATCH') + +
    + + + + + + @else + {{ $member->role }} + @endif +
    +
  • + @empty +
  • No teammates yet.
  • + @endforelse +
+
+
+@endsection diff --git a/resources/views/email/account/wallet.blade.php b/resources/views/email/account/wallet.blade.php new file mode 100644 index 0000000..59a772b --- /dev/null +++ b/resources/views/email/account/wallet.blade.php @@ -0,0 +1,27 @@ +@extends('layouts.email') +@section('title', 'Wallet — Ladill Email') +@section('content') +@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp +
+

Wallet

+

Your Ladill wallet funds mailboxes across every Ladill app.

+ +
+

Balance

+

{{ $fmt($balanceMinor) }}

+ Add funds +
+ +
+
+

Spent on email

+

{{ $fmt($spentMinor) }}

+
+
+

Refunded / credited

+

{{ $fmt($creditedMinor) }}

+
+
+

Mailboxes beyond your free allowance are billed monthly from this wallet.

+
+@endsection diff --git a/resources/views/email/dashboard.blade.php b/resources/views/email/dashboard.blade.php new file mode 100644 index 0000000..34d9b43 --- /dev/null +++ b/resources/views/email/dashboard.blade.php @@ -0,0 +1,47 @@ +@extends('layouts.email') +@section('title', 'Overview — Ladill Email') +@section('content') +
+
+
+

Overview

+

Your mailboxes at a glance.

+
+ New mailbox +
+ + @if($error)
{{ $error }}
@endif + +
+ @foreach([['Mailboxes', $mailboxCount, route('email.mailboxes.index')], ['Domains', $domainCount, route('email.domains.index')], ['Verified domains', $verifiedDomainCount, route('email.domains.index')]] as [$label, $value, $link]) + +

{{ $label }}

+

{{ $value }}

+
+ @endforeach +
+ +
+
+

Recent mailboxes

+ View all +
+ @if(empty($recent)) +
+

No mailboxes yet

+

Add a domain, verify it, then create your first mailbox.

+ Set up a domain +
+ @else +
+ @foreach($recent as $m) + + {{ $m['address'] }} + {{ $m['status'] }} + + @endforeach +
+ @endif +
+
+@endsection diff --git a/resources/views/email/domains/index.blade.php b/resources/views/email/domains/index.blade.php new file mode 100644 index 0000000..7ff52db --- /dev/null +++ b/resources/views/email/domains/index.blade.php @@ -0,0 +1,30 @@ +@extends('layouts.email') +@section('title', 'Domains — Ladill Email') +@section('content') +
+

Email domains

+

Add a domain and verify it to create mailboxes on it.

+ @if($error)
{{ $error }}
@endif + +
+ @csrf + + Add domain +
+ + @if(!empty($domains)) + + @endif +
+@endsection diff --git a/resources/views/email/domains/show.blade.php b/resources/views/email/domains/show.blade.php new file mode 100644 index 0000000..f1c75b5 --- /dev/null +++ b/resources/views/email/domains/show.blade.php @@ -0,0 +1,71 @@ +@extends('layouts.email') +@section('title', $domain['domain'].' — Ladill Email') +@section('content') +
+
+ Domains/ + {{ $domain['domain'] }} +
+
+

{{ $domain['domain'] }}

+ @if($domain['active'] ?? false) + Verified + @else + Pending + @endif +
+ + @unless($domain['active'] ?? false) +
+

Publish these DNS records

+ @php $records = $domain['dns_records'] ?? []; @endphp + @if(empty($records)) +

No DNS records returned. Try refreshing.

+ @else + + + + + + @foreach($records as $r) + + + + + + @endforeach + +
TypeNameValue
{{ $r['type'] ?? '' }}{{ $r['name'] ?? ($r['host'] ?? '@') }}{{ $r['value'] ?? '' }}
+ @endif +
+
+ @csrf + + DNS changes can take time to propagate. +
+ @else +
+

This domain is verified. Create a mailbox on it.

+
+
SPF {{ ($domain['spf'] ?? false) ? '✓' : '✗' }}
+
DKIM {{ ($domain['dkim'] ?? false) ? '✓' : '✗' }}
+
DMARC {{ ($domain['dmarc'] ?? false) ? '✓' : '✗' }}
+
+
+ @endunless + + + + + + +
+@endsection diff --git a/resources/views/email/mailboxes/create.blade.php b/resources/views/email/mailboxes/create.blade.php new file mode 100644 index 0000000..e53c52c --- /dev/null +++ b/resources/views/email/mailboxes/create.blade.php @@ -0,0 +1,97 @@ +@extends('layouts.email') +@section('title', 'New mailbox — Ladill Email') +@section('content') +
+

Create a mailbox

+ @if(empty($domains)) +
+ You need a verified domain first. Set up a domain. +
+ @else + @php $fmt = fn ($m) => $quota['currency'].' '.number_format($m / 100, 2); @endphp +
+ {{-- Pricing banner (reacts to the selected storage plan) --}} + + + +
+ @csrf +
+ + +
+
+ + + @error('local_part')

{{ $message }}

@enderror +
+
+ + +
+ + {{-- Storage plan (sets quota + price) --}} +
+ +
+ @foreach($quota['tiers'] as $t) + + @endforeach +
+ @error('quota_mb')

{{ $message }}

@enderror +
+ +
+
+ + +
+
+ + +
+
+ @error('password')

{{ $message }}

@enderror + + +
+
+ @endif +
+@endsection diff --git a/resources/views/email/mailboxes/index.blade.php b/resources/views/email/mailboxes/index.blade.php new file mode 100644 index 0000000..f3a9f24 --- /dev/null +++ b/resources/views/email/mailboxes/index.blade.php @@ -0,0 +1,32 @@ +@extends('layouts.email') +@section('title', 'Mailboxes — Ladill Email') +@section('content') +
+
+

Mailboxes

+ New mailbox +
+ @if($error)
{{ $error }}
@endif + + @if(empty($mailboxes)) +
+

No mailboxes yet

+

Verify a domain, then create a mailbox like you@yourdomain.com.

+ Create mailbox +
+ @else + + @endif +
+@endsection diff --git a/resources/views/email/mailboxes/show.blade.php b/resources/views/email/mailboxes/show.blade.php new file mode 100644 index 0000000..256156a --- /dev/null +++ b/resources/views/email/mailboxes/show.blade.php @@ -0,0 +1,92 @@ +@extends('layouts.email') +@section('title', $mailbox['address'].' — Ladill Email') +@section('content') +@php $host = 'mail.'.config('app.platform_domain'); @endphp +
+
+ Mailboxes/ + {{ $mailbox['address'] }} +
+
+

{{ $mailbox['address'] }}

+ {{ $mailbox['status'] ?? '' }} +
+ +
+

Connection settings

+

Use these in any mail client, or just open webmail.

+
+
Username
{{ $mailbox['address'] }}
+
IMAP
{{ $host }}:993 (SSL)
+
SMTP
{{ $host }}:587 (STARTTLS)
+
+ Open Webmail ↗ +
+ + @php + $quotaMb = (int) ($mailbox['quota_mb'] ?? 0); + $usedBytes = (int) ($mailbox['used_bytes'] ?? 0); + $price = \App\Support\MailboxPricing::priceMinorFor($quotaMb); + $tiers = \App\Support\MailboxPricing::tiers(); + $maxMb = collect($tiers)->max('mb'); + $pct = $quotaMb > 0 ? min(100, (int) round($usedBytes / ($quotaMb * 1048576) * 100)) : 0; + $fmt = fn ($m) => config('email.currency', 'GHS').' '.number_format($m / 100, 2); + @endphp +
+
+
+

Storage plan

+

+ {{ \App\Support\MailboxPricing::label($quotaMb) }} + @if($price === 0) + Free + @else + {{ $fmt($price) }}/month + @endif +

+
+ @if($quotaMb < $maxMb) + Upgrade + @else + Top plan + @endif +
+ @if($quotaMb > 0) +
+
+
+
+

{{ number_format($usedBytes / 1048576, 0) }} MB of {{ \App\Support\MailboxPricing::label($quotaMb) }} used ({{ $pct }}%)

+
+ @endif +
+ +
+

Reset password

+
+ @csrf @method('PATCH') + + + +
+
+ +
+

Delete mailbox

+

This permanently removes the mailbox and its email.

+ + + + + +
+
+@endsection diff --git a/resources/views/email/mailboxes/upgrade.blade.php b/resources/views/email/mailboxes/upgrade.blade.php new file mode 100644 index 0000000..ef6211d --- /dev/null +++ b/resources/views/email/mailboxes/upgrade.blade.php @@ -0,0 +1,52 @@ +@extends('layouts.email') +@section('title', 'Upgrade storage — Ladill Email') +@section('content') +@php $fmt = fn ($m) => $currency.' '.number_format($m / 100, 2); @endphp +
+
+ {{ $mailbox['address'] }}/ + Upgrade +
+

Upgrade storage

+

+ Currently on {{ \App\Support\MailboxPricing::label($currentMb) }}. Pick a larger plan — billed monthly from your wallet. +

+ +
+
+ New plan: /month, charged now from your wallet + (balance: ). + Top up +
+ +
+ @csrf @method('PATCH') +
+ +
+ @foreach($tiers as $t) + + @endforeach +
+ @error('quota_mb')

{{ $message }}

@enderror +
+ +
+
+
+@endsection diff --git a/resources/views/email/signed-out.blade.php b/resources/views/email/signed-out.blade.php new file mode 100644 index 0000000..02f453e --- /dev/null +++ b/resources/views/email/signed-out.blade.php @@ -0,0 +1,17 @@ + + + + + Signed out — Ladill Email + @include('partials.favicon') + @vite(['resources/css/app.css']) + + + +
+ Ladill Email +

You’ve been signed out. Redirecting…

+ Go to ladill.com +
+ + diff --git a/resources/views/layouts/hosting.blade.php b/resources/views/layouts/hosting.blade.php new file mode 100644 index 0000000..2d0f9ac --- /dev/null +++ b/resources/views/layouts/hosting.blade.php @@ -0,0 +1,56 @@ + + + + + + + @yield('title', 'Ladill Hosting') + @include('partials.favicon') + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+
+ +
+ @include('partials.topbar') +
+ @include('partials.flash') + @yield('content') +
+
+
+ @auth + @php + $navUser = auth()->user(); + $navInitials = collect(explode(' ', trim((string) $navUser?->name))) + ->filter()->take(2) + ->map(fn ($part) => strtoupper(substr($part, 0, 1))) + ->implode(''); + $navAcct = 'https://'.config('app.account_domain'); + @endphp + @include('partials.mobile-bottom-nav', [ + 'homeUrl' => route('hosting.dashboard'), + 'homeActive' => request()->routeIs('hosting.dashboard') || request()->routeIs('hosting.index'), + 'searchUrl' => route('hosting.dashboard'), + 'searchActive' => request()->routeIs('hosting.*'), + 'notificationsUrl' => route('notifications.index'), + 'notificationsActive' => request()->routeIs('notifications.*'), + 'unreadUrl' => route('notifications.unread'), + 'profileActive' => false, + 'profileName' => $navUser?->name ?? '', + 'profileSubtitle' => $navUser?->email ?? '', + 'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser), + 'avatarUrl' => $navUser?->avatar_url, + 'initials' => $navInitials !== '' ? $navInitials : 'U', + ]) + @include('partials.afia') + @endauth +@include('partials.confirm-prompt') + + diff --git a/resources/views/layouts/user.blade.php b/resources/views/layouts/user.blade.php new file mode 100644 index 0000000..1ad0819 --- /dev/null +++ b/resources/views/layouts/user.blade.php @@ -0,0 +1,260 @@ +@php + $mobileFullScreenPage = request()->routeIs('account.settings') + || request()->routeIs('mini.payment-qrs.create') + || request()->routeIs('mini.payment-qrs.show'); + + $qrMobilePage = request()->routeIs('mini.payment-qrs.create') || request()->routeIs('mini.payment-qrs.show'); +@endphp + + $qrMobilePage])> + + + + + @include('partials.favicon') + {{ $title ?? 'Dashboard' }} - Ladill + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+ {{-- Mobile sidebar overlay --}} +
+ + {{-- Sidebar — full product nav (Bird and other extracted apps have their + own nav in their own apps). --}} + @php $sidebarPartial = 'partials.sidebar'; @endphp + + + {{-- Main content --}} +
+
+ @include('partials.topbar-qr') +
+ +
$mobileFullScreenPage, + ])> + @include('partials.flash') +
+ +
$qrMobilePage, + 'p-0 pb-24 lg:p-6 lg:pb-6' => $mobileFullScreenPage && ! $qrMobilePage, + 'p-6 pb-24 lg:pb-6' => ! $mobileFullScreenPage, + ])> + {{ $slot }} +
+
+ + {{-- Mobile Bottom Navigation (hidden on QR create/show which have their own action bar) --}} + @unless(request()->routeIs('mini.payment-qrs.create') || request()->routeIs('mini.payment-qrs.show')) + @php + $navUser = auth()->user(); + $navInitials = collect(explode(' ', trim((string) $navUser?->name))) + ->filter()->take(2) + ->map(fn ($part) => strtoupper(substr($part, 0, 1))) + ->implode(''); + @endphp + @include('partials.mobile-bottom-nav', [ + 'homeUrl' => route('mini.dashboard'), + 'homeActive' => request()->routeIs('mini.dashboard'), + 'searchUrl' => route('mini.search'), + 'searchActive' => request()->routeIs('mini.search'), + 'notificationsUrl' => route('notifications.index'), + 'notificationsActive' => request()->routeIs('notifications.*'), + 'unreadUrl' => route('notifications.unread'), + 'profileActive' => request()->routeIs('account.settings'), + 'profileName' => $navUser?->name ?? '', + 'profileSubtitle' => $navUser?->email ?? '', + 'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser), + 'avatarUrl' => $navUser?->avatarUrl(), + 'initials' => $navInitials !== '' ? $navInitials : 'U', + ]) + @endunless + + @if ($qrMobilePage) + + @endif +
+ +@include('partials.afia') +@include('partials.sso-keepalive') +@include('partials.confirm-prompt') + + diff --git a/resources/views/mail/notifications/domain-verified.blade.php b/resources/views/mail/notifications/domain-verified.blade.php new file mode 100644 index 0000000..1db6f71 --- /dev/null +++ b/resources/views/mail/notifications/domain-verified.blade.php @@ -0,0 +1,90 @@ +@extends('mail.notifications.layout') + +@section('email-header') + @include('mail.partials.brand-header', ['brand' => 'domains']) +@endsection + +@section('email-footer') + @include('mail.partials.brand-footer', ['brand' => 'domains']) +@endsection + +@section('content') +
+

Domain Verified!

+

{{ $domain->host }} is now active

+
+ +

Your Domain is Ready

+ + + + + + + +
    +
  • Create email addresses like hello@{{ $domain->host }}
  • +
  • Connect this domain to your website
  • +
  • Manage DNS records from your dashboard
  • +
+ +

+ +
+ +

+@endsection diff --git a/resources/views/mail/notifications/event-programme.blade.php b/resources/views/mail/notifications/event-programme.blade.php new file mode 100644 index 0000000..ba5db16 --- /dev/null +++ b/resources/views/mail/notifications/event-programme.blade.php @@ -0,0 +1,37 @@ +@extends('mail.notifications.layout') + +@section('email-header') + @include('mail.partials.brand-header', ['brand' => 'events']) +@endsection + +@section('email-footer') + @include('mail.partials.brand-footer', ['brand' => 'events']) +@endsection + +@section('content') +
+

📋 Programme Outline

+

{{ $eventName }}

+
+ +

Here's the programme{{ $attendeeName ? ', ' . explode(' ', $attendeeName)[0] : '' }}!

+ + + + + + + +
+ +
+ + +@endsection diff --git a/resources/views/mail/notifications/hosting-activated.blade.php b/resources/views/mail/notifications/hosting-activated.blade.php new file mode 100644 index 0000000..4e38851 --- /dev/null +++ b/resources/views/mail/notifications/hosting-activated.blade.php @@ -0,0 +1,64 @@ +@extends('mail.notifications.layout') + +@section('email-header') + @include('mail.partials.brand-header', ['brand' => 'hosting']) +@endsection + +@section('email-footer') + @include('mail.partials.brand-footer', ['brand' => 'hosting']) +@endsection + +@section('content') +

Your hosting is now active! 🖥️

+ + + +
+

{{ $planName }}

+

Your hosting is live and ready

+
+ + + +

+ +

+ + + + +@endsection diff --git a/resources/views/mail/notifications/hosting-developer-added.blade.php b/resources/views/mail/notifications/hosting-developer-added.blade.php new file mode 100644 index 0000000..5a0048e --- /dev/null +++ b/resources/views/mail/notifications/hosting-developer-added.blade.php @@ -0,0 +1,47 @@ +@extends('mail.notifications.layout') + +@section('email-header') + @include('mail.partials.brand-header', ['brand' => 'hosting']) +@endsection + +@section('email-footer') + @include('mail.partials.brand-footer', ['brand' => 'hosting']) +@endsection + +@section('content') +

You were added to a hosting team

+

+ {{ $ownerName }} granted you developer access to the following Ladill hosting environment{{ count($accountLabels) === 1 ? '' : 's' }}: +

+ +
    + @foreach ($accountLabels as $label) +
  • {{ $label }}
  • + @endforeach +
+ +

+ You now have full access to the hosting panel for {{ count($accountLabels) === 1 ? 'this account' : 'these accounts' }} — manage files, domains, databases, SSL, cron jobs, and more. +

+ +

+ To connect via SSH or SFTP, add your public key from the hosting panel Settings page after signing in. +

+ + @if ($setupUrl) + +

+ Use the button above to activate your account before signing in. +

+ @else + + @endif + +

+ After signing in, open your dashboard here: {{ $dashboardUrl }} +

+@endsection diff --git a/resources/views/mail/notifications/hosting-expiring.blade.php b/resources/views/mail/notifications/hosting-expiring.blade.php new file mode 100644 index 0000000..dce0a10 --- /dev/null +++ b/resources/views/mail/notifications/hosting-expiring.blade.php @@ -0,0 +1,56 @@ +@extends('mail.notifications.layout') + +@section('email-header') + @include('mail.partials.brand-header', ['brand' => 'hosting']) +@endsection + +@section('email-footer') + @include('mail.partials.brand-footer', ['brand' => 'hosting']) +@endsection + +@section('content') +
+

Hosting Expiring Soon

+

{{ $account->primary_domain ?: $account->username }}

+
+ +

Your Hosting Plan is Expiring Soon

+ + + + + + + +

+ +

+ + + + +@endsection diff --git a/resources/views/mail/notifications/hosting-suspended.blade.php b/resources/views/mail/notifications/hosting-suspended.blade.php new file mode 100644 index 0000000..f63ca41 --- /dev/null +++ b/resources/views/mail/notifications/hosting-suspended.blade.php @@ -0,0 +1,59 @@ +@extends('mail.notifications.layout') + +@section('email-header') + @include('mail.partials.brand-header', ['brand' => 'hosting']) +@endsection + +@section('email-footer') + @include('mail.partials.brand-footer', ['brand' => 'hosting']) +@endsection + +@section('content') +
+

Account Suspended

+

Action Required

+
+ +

Your Hosting Account Has Been Suspended

+ + + + + + + +

+ +

+ + + + +@endsection diff --git a/resources/views/mail/notifications/layout.blade.php b/resources/views/mail/notifications/layout.blade.php new file mode 100644 index 0000000..3662c00 --- /dev/null +++ b/resources/views/mail/notifications/layout.blade.php @@ -0,0 +1,404 @@ + + + + + + + {{ $subject ?? 'Ladill Notification' }} + + + + + + + diff --git a/resources/views/mail/notifications/ssl-expiring.blade.php b/resources/views/mail/notifications/ssl-expiring.blade.php new file mode 100644 index 0000000..99547a8 --- /dev/null +++ b/resources/views/mail/notifications/ssl-expiring.blade.php @@ -0,0 +1,64 @@ +@extends('mail.notifications.layout') + +@section('email-header') + @include('mail.partials.brand-header', ['brand' => 'hosting']) +@endsection + +@section('email-footer') + @include('mail.partials.brand-footer', ['brand' => 'hosting']) +@endsection + +@section('content') +
+

⚠️ SSL Certificate Expiring

+

{{ $daysUntilExpiry }} days remaining

+
+ +

Action May Be Required

+ + + + + + + +
    +
  • Ensure your domain's nameservers still point to Ladill
  • +
  • Verify your domain hasn't expired at your registrar
  • +
  • Check that your website is accessible
  • +
+ +

+ +

+ + + + +@endsection diff --git a/resources/views/mail/notifications/ssl-provisioned.blade.php b/resources/views/mail/notifications/ssl-provisioned.blade.php new file mode 100644 index 0000000..8a3fa31 --- /dev/null +++ b/resources/views/mail/notifications/ssl-provisioned.blade.php @@ -0,0 +1,64 @@ +@extends('mail.notifications.layout') + +@section('email-header') + @include('mail.partials.brand-header', ['brand' => 'hosting']) +@endsection + +@section('email-footer') + @include('mail.partials.brand-footer', ['brand' => 'hosting']) +@endsection + +@section('content') +
+

🔒 SSL Certificate Active

+

{{ $domain->host }} is now secure

+
+ +

Your Site is Secure

+ + + + + + + +
    +
  • All traffic to your site is encrypted
  • +
  • Visitors see a secure padlock in their browser
  • +
  • Better search engine rankings (Google prefers HTTPS)
  • +
  • Certificate auto-renews before expiry
  • +
+ +

+ +

+ + + + +@endsection diff --git a/resources/views/mail/partials/brand-footer.blade.php b/resources/views/mail/partials/brand-footer.blade.php new file mode 100644 index 0000000..6f6b66a --- /dev/null +++ b/resources/views/mail/partials/brand-footer.blade.php @@ -0,0 +1,20 @@ +@php + $brandKey = $brand ?? 'ladill'; + $brandConfig = config("mail_brands.brands.{$brandKey}", config('mail_brands.brands.ladill')); + $appBase = rtrim((string) ($brandConfig['app_url'] ?? config('app.url')), '/'); + $dashboardPath = $brandConfig['dashboard_path'] ?? '/'; + $accountBase = rtrim((string) ($brandConfig['account_url'] ?? config('mail_brands.account_url', 'https://account.ladill.com')), '/'); + $supportUrl = $brandConfig['support_url'] ?? $accountBase.'/support-tickets'; + $homeLabel = $brandConfig['home_label'] ?? parse_url($appBase, PHP_URL_HOST) ?: 'ladill.com'; +@endphp + + + diff --git a/resources/views/mail/partials/brand-header.blade.php b/resources/views/mail/partials/brand-header.blade.php new file mode 100644 index 0000000..f6bce5a --- /dev/null +++ b/resources/views/mail/partials/brand-header.blade.php @@ -0,0 +1,7 @@ +@php + $brandKey = $brand ?? 'ladill'; + $brandConfig = config("mail_brands.brands.{$brandKey}", config('mail_brands.brands.ladill')); + $assetBase = rtrim((string) ($brandConfig['asset_url'] ?? config('app.url')), '/'); + $logoClass = trim((string) ($brandConfig['logo_class'] ?? 'email-logo')); +@endphp +{{ $brandConfig['name'] }} diff --git a/resources/views/mini/dashboard.blade.php b/resources/views/mini/dashboard.blade.php new file mode 100644 index 0000000..e0248eb --- /dev/null +++ b/resources/views/mini/dashboard.blade.php @@ -0,0 +1,68 @@ + + Overview + @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp +
+
+

Overview

+

Today's takings, your payment QRs, and recent incoming payments.

+
+
+
+

Today's takings

+

{{ $fmt($todayMinor) }}

+
+
+

Payments today

+

{{ number_format($todayCount) }}

+
+
+

Payment QRs

+

{{ $paymentQrCount }}

+
+
+

Wallet balance

+

{{ $fmt($balanceMinor) }}

+
+
+
+
+
+

Recent payments

+ View all +
+ @if($recentPayments->isEmpty()) +

No payments yet. Print your payment QR and start accepting payments.

+ @else +
+ @foreach($recentPayments as $payment) +
+
+

{{ $payment->payer_name ?: 'Walk-in customer' }}

+

{{ $payment->qrCode?->label }} · {{ $payment->paid_at?->diffForHumans() }}

+
+ {{ $fmt($payment->merchant_amount_minor) }} +
+ @endforeach +
+ @endif +
+ +
+
+
diff --git a/resources/views/mini/payment-qrs/create.blade.php b/resources/views/mini/payment-qrs/create.blade.php new file mode 100644 index 0000000..5ec89a9 --- /dev/null +++ b/resources/views/mini/payment-qrs/create.blade.php @@ -0,0 +1,48 @@ + + Create Payment QR +
+
+ + + + + + Ladill Mini + Create payment QR +
+ + @if(session('error')) +
{{ session('error') }}
+ @endif +
+ @csrf +
+ + +

Internal name — shown in your dashboard only.

+
+
+ + +

Shown on the customer payment screen.

+
+
+ + +
+ +
+
+
diff --git a/resources/views/mini/payment-qrs/index.blade.php b/resources/views/mini/payment-qrs/index.blade.php new file mode 100644 index 0000000..2ff9122 --- /dev/null +++ b/resources/views/mini/payment-qrs/index.blade.php @@ -0,0 +1,40 @@ + + My Payment QR +
+
+
+

My Payment QR

+

Static QRs to print or display — one per till or branch.

+
+ New payment QR +
+ @if(session('success')) +
{{ session('success') }}
+ @endif + @if($qrCodes->isEmpty()) +
+

No payment QRs yet.

+ Create your first payment QR +
+ @else + + @endif +
+
diff --git a/resources/views/mini/payment-qrs/partials/delete-modal.blade.php b/resources/views/mini/payment-qrs/partials/delete-modal.blade.php new file mode 100644 index 0000000..2fdb3e5 --- /dev/null +++ b/resources/views/mini/payment-qrs/partials/delete-modal.blade.php @@ -0,0 +1,38 @@ +@php + $businessName = $qrCode->content()['business_name'] ?? $qrCode->label; +@endphp + + +
+
+
+ + + +
+

Delete payment QR?

+

+ {{ $qrCode->label }} + @if($businessName !== $qrCode->label) + · {{ $businessName }} + @endif + will be removed permanently. The payment link will stop working and printed codes for this till will no longer accept payments. +

+

{{ $qrCode->publicUrl() }}

+
+ +
+ @csrf + @method('DELETE') + + +
+
+
diff --git a/resources/views/mini/payment-qrs/partials/header-actions.blade.php b/resources/views/mini/payment-qrs/partials/header-actions.blade.php new file mode 100644 index 0000000..d03c0a8 --- /dev/null +++ b/resources/views/mini/payment-qrs/partials/header-actions.blade.php @@ -0,0 +1,37 @@ +@php + $shareUrl = $qrCode->publicUrl(); + $shareEnc = urlencode($shareUrl); + $shareText = urlencode($qrCode->label . ' — pay with QR'); +@endphp +
+ PNG + SVG + PDF +
+ +
+ + WhatsApp + + +
+
+
diff --git a/resources/views/mini/payment-qrs/partials/preview-card.blade.php b/resources/views/mini/payment-qrs/partials/preview-card.blade.php new file mode 100644 index 0000000..36272ac --- /dev/null +++ b/resources/views/mini/payment-qrs/partials/preview-card.blade.php @@ -0,0 +1,35 @@ +@php + $showDownloads = $showDownloads ?? true; +@endphp + +
+
+
+
+ Payment QR code for {{ $qrCode->label }} +
+
+
+ + @if ($showDownloads) +
+

Download

+ +
+ @endif +
diff --git a/resources/views/mini/payment-qrs/show.blade.php b/resources/views/mini/payment-qrs/show.blade.php new file mode 100644 index 0000000..5df9600 --- /dev/null +++ b/resources/views/mini/payment-qrs/show.blade.php @@ -0,0 +1,81 @@ + + {{ $qrCode->label }} + @php $c = $qrCode->content(); @endphp +
+
+
+
+ + + + + + Ladill Mini + {{ $qrCode->label }} +
+ + +
+ + @include('mini.payment-qrs.partials.header-actions', ['qrCode' => $qrCode]) +
+ + @if(session('success')) +
{{ session('success') }}
+ @endif + +
+
+ @include('mini.payment-qrs.partials.preview-card', [ + 'qrCode' => $qrCode, + 'previewDataUri' => $previewDataUri, + 'showDownloads' => false, + ]) +

{{ $qrCode->publicUrl() }}

+
+ +
+
+ @csrf + @method('PATCH') +

Settings

+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+

Danger zone

+

Remove this payment QR and deactivate its link.

+ +
+ + @include('mini.payment-qrs.partials.delete-modal', ['qrCode' => $qrCode]) +
+
+
+
diff --git a/resources/views/mini/payments.blade.php b/resources/views/mini/payments.blade.php new file mode 100644 index 0000000..388dbbd --- /dev/null +++ b/resources/views/mini/payments.blade.php @@ -0,0 +1,54 @@ + + Payments + @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp +
+
+

Payments

+

Live feed of incoming customer payments across all your payment QRs.

+
+
+ @if($payments->isEmpty()) +

No payments yet.

+ @else +
+ + + + + + + + + + + + + @foreach($payments as $payment) + + + + + + + + + @endforeach + +
WhenPayerQR / tillNoteAmountStatus
{{ $payment->paid_at?->format('M j, g:i A') ?? $payment->created_at->format('M j, g:i A') }} +

{{ $payment->payer_name ?: 'Walk-in customer' }}

+ @if($payment->payer_email) +

{{ $payment->payer_email }}

+ @endif +
{{ $payment->qrCode?->label }}{{ $payment->payer_note ?: '—' }}{{ $fmt($payment->status === \App\Models\MiniPayment::STATUS_PAID ? $payment->merchant_amount_minor : $payment->amount_minor) }} + + {{ ucfirst($payment->status) }} + +
+
+ @if($payments->hasPages()) +
{{ $payments->links() }}
+ @endif + @endif +
+
+
diff --git a/resources/views/mini/payouts.blade.php b/resources/views/mini/payouts.blade.php new file mode 100644 index 0000000..b995736 --- /dev/null +++ b/resources/views/mini/payouts.blade.php @@ -0,0 +1,27 @@ + + Payouts + @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp +
+
+

Payouts

+

Takings settle into your Ladill wallet (3.5% fee), then withdraw to bank or MoMo.

+
+
+
+

Total received (net)

+

{{ $fmt($revenueMinor) }}

+
+
+

Available in wallet

+

{{ $fmt($balanceMinor) }}

+
+
+
+ Payments are collected through Ladill Pay and credited to your Ladill wallet. Withdraw to bank or MoMo from your account wallet — payout and withdrawal history is there. +
+ + Open wallet & withdraw + + +
+
diff --git a/resources/views/mini/settings.blade.php b/resources/views/mini/settings.blade.php new file mode 100644 index 0000000..428f899 --- /dev/null +++ b/resources/views/mini/settings.blade.php @@ -0,0 +1,69 @@ + + Settings + +
+

Settings

+

Notification preferences for your payment QRs.

+ + @if (session('success')) +
{{ session('success') }}
+ @endif + +
+ @csrf + @method('PUT') + +
+

Notifications

+

Choose what we email you about payments and your account.

+ +
+ + + @error('notify_email')

{{ $message }}

@enderror +
+ + + + + + +
+ + +
+ +

+ Profile, password, and security are managed on + account.ladill.com. +

+
+
diff --git a/resources/views/mini/signed-out.blade.php b/resources/views/mini/signed-out.blade.php new file mode 100644 index 0000000..10d87e7 --- /dev/null +++ b/resources/views/mini/signed-out.blade.php @@ -0,0 +1 @@ +@include('auth.signed-out') diff --git a/resources/views/notifications/_list.blade.php b/resources/views/notifications/_list.blade.php new file mode 100644 index 0000000..09c6ebb --- /dev/null +++ b/resources/views/notifications/_list.blade.php @@ -0,0 +1,93 @@ +
+
+
+

Notifications

+

{{ $subtitle ?? 'Stay updated on your account activity.' }}

+
+ @if ($notifications->where('read_at', null)->count() > 0) +
+ @csrf + +
+ @endif +
+ +
+ @forelse ($notifications as $notification) + @php + $data = $notification->data; + $icon = $data['icon'] ?? 'bell'; + $iconBg = match ($icon) { + 'domain' => 'bg-emerald-50', + 'hosting' => 'bg-violet-50', + 'email' => 'bg-pink-50', + 'billing' => 'bg-amber-50', + 'success' => 'bg-green-50', + default => 'bg-slate-100', + }; + $iconColor = match ($icon) { + 'domain' => 'text-emerald-600', + 'hosting' => 'text-violet-600', + 'email' => 'text-pink-600', + 'billing' => 'text-amber-600', + 'success' => 'text-green-600', + default => 'text-slate-500', + }; + @endphp +
+ + @if ($icon === 'domain' && view()->exists('components.icons.domain-globe')) + @include('components.icons.domain-globe', ['class' => 'h-5 w-5 ' . $iconColor]) + @elseif ($icon === 'email') + + @elseif ($icon === 'hosting') + + @elseif ($icon === 'billing') + + @elseif ($icon === 'success') + + @else + + @endif + +
+
+
+

{{ $data['title'] ?? 'Notification' }}

+

{{ $data['message'] ?? '' }}

+
+ {{ $notification->created_at->diffForHumans() }} +
+ @if (! empty($data['url'])) + + View details → + + @endif +
+ @if (! $notification->read_at) +
+ @csrf + +
+ @endif +
+ @empty +
+ + + +

No notifications

+

{{ $emptyMessage ?? "You're all caught up." }}

+
+ @endforelse +
+ + @if ($notifications->hasPages()) +
{{ $notifications->links() }}
+ @endif +
diff --git a/resources/views/notifications/index.blade.php b/resources/views/notifications/index.blade.php new file mode 100644 index 0000000..9901476 --- /dev/null +++ b/resources/views/notifications/index.blade.php @@ -0,0 +1,10 @@ +@extends('layouts.hosting') + +@section('title', 'Notifications') + +@section('content') + @include('notifications._list', [ + 'subtitle' => 'Hosting activity for your account.', + 'emptyMessage' => "You're all caught up. We'll notify you when something happens with your hosting.", + ]) +@endsection diff --git a/resources/views/partials/afia-button.blade.php b/resources/views/partials/afia-button.blade.php new file mode 100644 index 0000000..0588ba6 --- /dev/null +++ b/resources/views/partials/afia-button.blade.php @@ -0,0 +1,24 @@ +@props([ + 'compact' => false, + 'handler' => 'dispatch', +]) + +@auth + +@endauth diff --git a/resources/views/partials/afia.blade.php b/resources/views/partials/afia.blade.php new file mode 100644 index 0000000..1aff09d --- /dev/null +++ b/resources/views/partials/afia.blade.php @@ -0,0 +1,106 @@ +@php + $afiaGreeting = "Hi, I'm Afia 👋 Ask me about payment QRs — creating one, accepting payments, payouts, or the 3.5% platform fee…"; + $afiaSuggestions = [ + 'How do I create a payment QR?', + 'How do customers pay me?', + 'When do payouts reach my wallet?', + 'What is the platform fee?', + ]; +@endphp +{{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}} +
+
+ +
+
+
+ + + + +
+

Afia

+

Mini assistant

+
+
+
+ History + +
+
+ +
+
+ +
+
+
+ + + +
+
+
+
+ +
+ +
+
+ +
+
+ + +
+

Afia can make mistakes — verify important details.

+
+
+
diff --git a/resources/views/partials/confirm-prompt.blade.php b/resources/views/partials/confirm-prompt.blade.php new file mode 100644 index 0000000..c519339 --- /dev/null +++ b/resources/views/partials/confirm-prompt.blade.php @@ -0,0 +1,65 @@ +
+
+ +
+
+
+
+ +
+
+
+ + +
+

+

+
+ +
+ + +
+
+
+
diff --git a/resources/views/partials/favicon.blade.php b/resources/views/partials/favicon.blade.php new file mode 100644 index 0000000..8933ddf --- /dev/null +++ b/resources/views/partials/favicon.blade.php @@ -0,0 +1,8 @@ +@php + $svgVer = @filemtime(public_path('favicon.svg')) ?: '1'; + $icoVer = @filemtime(public_path('favicon.ico')) ?: '1'; +@endphp + + + + diff --git a/resources/views/partials/flash.blade.php b/resources/views/partials/flash.blade.php new file mode 100644 index 0000000..23fc086 --- /dev/null +++ b/resources/views/partials/flash.blade.php @@ -0,0 +1,31 @@ +@if (session('success')) +
+
+

{{ session('success') }}

+
+
+@endif + +@if (session('error')) +
+
+

{{ session('error') }}

+
+
+@endif + +@if (session('warning')) +
+
+

{{ session('warning') }}

+
+
+@endif + +@if (session('info')) +
+
+

{{ session('info') }}

+
+
+@endif diff --git a/resources/views/partials/ladill-pro-icon.blade.php b/resources/views/partials/ladill-pro-icon.blade.php new file mode 100644 index 0000000..4ec9e3f --- /dev/null +++ b/resources/views/partials/ladill-pro-icon.blade.php @@ -0,0 +1,2 @@ +@props(['class' => 'h-5 w-5']) + diff --git a/resources/views/partials/launcher.blade.php b/resources/views/partials/launcher.blade.php new file mode 100644 index 0000000..c26873d --- /dev/null +++ b/resources/views/partials/launcher.blade.php @@ -0,0 +1,53 @@ +@php + // Shared Ladill app launcher — IDENTICAL across every app/service. + // Driven by config/ladill_launcher.php (fully-extracted apps only) + icons + // from public/images/launcher-icons/. Omits the current app (matched by + // APP_URL host). To replicate elsewhere, copy this file + the config + + // the launcher-icons folder verbatim. + $sidebar = (bool) ($sidebar ?? false); + $selfHost = parse_url((string) config('app.url'), PHP_URL_HOST); + $launcherApps = array_values(array_filter( + config('ladill_launcher.apps', []), + fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost + )); +@endphp +@if (! empty($launcherApps)) +
+ +
! $sidebar, + 'bottom-full left-0 mb-2' => $sidebar, + ])> +

All apps

+
+ @foreach ($launcherApps as $app) + + + + + {{ $app['name'] }} + + @endforeach +
+
+
+@endif diff --git a/resources/views/partials/mailbox-link-banner.blade.php b/resources/views/partials/mailbox-link-banner.blade.php new file mode 100644 index 0000000..8e01144 --- /dev/null +++ b/resources/views/partials/mailbox-link-banner.blade.php @@ -0,0 +1,46 @@ +@if(($mailboxLinkReminder['visible'] ?? false) === true) + @php + $stage = $mailboxLinkReminder['stage'] ?? 'needs_link'; + $copy = match ($stage) { + 'needs_domain' => [ + 'title' => 'Set up Ladill Email', + 'body' => 'Add and verify an email domain before you can create a mailbox and link it to your Ladill account.', + 'cta' => 'Add email domain', + 'url' => route('email.domains.index'), + ], + 'needs_mailbox' => [ + 'title' => 'Create your Ladill mailbox', + 'body' => 'Your domain is ready. Create a mailbox, then link it in Settings so Ladill Mail opens with your Ladill sign-in.', + 'cta' => 'Create mailbox', + 'url' => route('email.mailboxes.create'), + ], + default => [ + 'title' => 'Link your Ladill mailbox', + 'body' => 'Your account uses '.$mailboxLinkReminder['account_email'].' — choose a mailbox in Settings to connect Ladill Mail sign-in.', + 'cta' => 'Link mailbox', + 'url' => route('account.settings'), + ], + }; + @endphp +
+
+ +
+

{{ $copy['title'] }}

+

{{ $copy['body'] }}

+ + {{ $copy['cta'] }} + + +
+
+ @csrf + +
+
+
+@endif diff --git a/resources/views/partials/mini-paystack-frame-footer.blade.php b/resources/views/partials/mini-paystack-frame-footer.blade.php new file mode 100644 index 0000000..b08ecc4 --- /dev/null +++ b/resources/views/partials/mini-paystack-frame-footer.blade.php @@ -0,0 +1,3 @@ +
+

Secured by Paystack. Powered by Ladill Pay

+
diff --git a/resources/views/partials/mini-paystack-frame-header.blade.php b/resources/views/partials/mini-paystack-frame-header.blade.php new file mode 100644 index 0000000..bdee833 --- /dev/null +++ b/resources/views/partials/mini-paystack-frame-header.blade.php @@ -0,0 +1,15 @@ +
+
+
+ Ladill Mini + +
+
diff --git a/resources/views/partials/mobile-bottom-nav.blade.php b/resources/views/partials/mobile-bottom-nav.blade.php new file mode 100644 index 0000000..8613313 --- /dev/null +++ b/resources/views/partials/mobile-bottom-nav.blade.php @@ -0,0 +1,123 @@ +@php + $gridCols = !empty($centerCompose) ? 'grid-cols-5' : 'grid-cols-4'; + $avatarUrl = $avatarUrl ?? null; + $initials = $initials ?? 'U'; + $notificationsUrl = $notificationsUrl ?? '#'; + $unreadUrl = $unreadUrl ?? null; + $profileUrl = $profileUrl ?? '#'; + $profileName = trim((string) ($profileName ?? '')); + $profileSubtitle = trim((string) ($profileSubtitle ?? '')); + $profileMenuItems = $profileMenuItems ?? []; + if ($profileMenuItems === [] && $profileUrl !== '#') { + $profileMenuItems = [['type' => 'link', 'label' => 'Profile', 'href' => $profileUrl]]; + } + $navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600'; +@endphp +
+ + + {{-- Profile menu bottom sheet (matches desktop avatar dropdown) --}} +
+
+ +
+
+ +
+ + @if ($profileName !== '' || $profileSubtitle !== '') +
+ @if ($avatarUrl) + + @else + {{ $initials }} + @endif +
+ @if ($profileName !== '') +

{{ $profileName }}

+ @endif + @if ($profileSubtitle !== '') +

{{ $profileSubtitle }}

+ @endif +
+
+ @endif + + @include('partials.user-profile-menu', [ + 'items' => $profileMenuItems, + 'variant' => 'sheet', + 'onNavigate' => 'profileOpen = false', + ]) +
+
+
diff --git a/resources/views/partials/mobile-header-cart.blade.php b/resources/views/partials/mobile-header-cart.blade.php new file mode 100644 index 0000000..244bcd4 --- /dev/null +++ b/resources/views/partials/mobile-header-cart.blade.php @@ -0,0 +1,9 @@ +{{-- Mobile header cart icon — only include when the app has a shopping cart. --}} + + + @if (($cartCount ?? 0) > 0) + {{ $cartCount }} + @endif + diff --git a/resources/views/partials/mobile-topbar-title.blade.php b/resources/views/partials/mobile-topbar-title.blade.php new file mode 100644 index 0000000..ff10b4a --- /dev/null +++ b/resources/views/partials/mobile-topbar-title.blade.php @@ -0,0 +1,6 @@ +@php + $title = $mobileTopbarTitle ?? 'Ladill'; +@endphp +
+

{{ $title }}

+
diff --git a/resources/views/partials/notification-dropdown.blade.php b/resources/views/partials/notification-dropdown.blade.php new file mode 100644 index 0000000..8935cf0 --- /dev/null +++ b/resources/views/partials/notification-dropdown.blade.php @@ -0,0 +1,106 @@ +{{-- In-app notification bell + dropdown (scoped to this app). --}} + diff --git a/resources/views/partials/paystack-sheet.blade.php b/resources/views/partials/paystack-sheet.blade.php new file mode 100644 index 0000000..ce2f127 --- /dev/null +++ b/resources/views/partials/paystack-sheet.blade.php @@ -0,0 +1,44 @@ +{{-- + Paystack mobile bottom-sheet iframe. + Requires Alpine.js ancestor with: showSheet (bool), checkoutUrl (string). + On mobile (< md) the sheet slides up; on desktop nothing renders. +--}} + diff --git a/resources/views/partials/search-screen.blade.php b/resources/views/partials/search-screen.blade.php new file mode 100644 index 0000000..be97ed7 --- /dev/null +++ b/resources/views/partials/search-screen.blade.php @@ -0,0 +1,100 @@ +@php + $searchUrl = $searchUrl ?? url('/search'); +@endphp + +
+ +
+
+ + +
+
+
+ + + +
+ + + + + + + +
+
diff --git a/resources/views/partials/sidebar-support.blade.php b/resources/views/partials/sidebar-support.blade.php new file mode 100644 index 0000000..817932f --- /dev/null +++ b/resources/views/partials/sidebar-support.blade.php @@ -0,0 +1,23 @@ +@php + $useInternal = $internal ?? false; + if ($useInternal) { + $supportUrl = route('user.support-tickets.index'); + $openExternal = false; + } else { + $supportUrl = function_exists('ladill_account_url') + ? ladill_account_url('/support-tickets') + : 'https://'.config('app.account_domain', 'account.ladill.com').'/support-tickets'; + $openExternal = true; + } +@endphp + + + Support + @if($openExternal) + + @endif + diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php new file mode 100644 index 0000000..faca108 --- /dev/null +++ b/resources/views/partials/sidebar.blade.php @@ -0,0 +1,29 @@ +
+
+ + Ladill POS + +
+ @php + $nav = [ + ['name' => 'Overview', 'route' => route('pos.dashboard'), 'active' => request()->routeIs('pos.dashboard'), + 'icon' => ''], + ['name' => 'Register', 'route' => route('pos.register'), 'active' => request()->routeIs('pos.register'), + 'icon' => ''], + ['name' => 'Products', 'route' => route('pos.products.index'), 'active' => request()->routeIs('pos.products.*'), + 'icon' => ''], + ['name' => 'Sales', 'route' => route('pos.sales.index'), 'active' => request()->routeIs('pos.sales.*'), + 'icon' => ''], + ['name' => 'Settings', 'route' => route('pos.settings'), 'active' => request()->routeIs('pos.settings*'), + 'icon' => ''], + ]; + @endphp + +
diff --git a/resources/views/partials/sso-keepalive.blade.php b/resources/views/partials/sso-keepalive.blade.php new file mode 100644 index 0000000..2d297c5 --- /dev/null +++ b/resources/views/partials/sso-keepalive.blade.php @@ -0,0 +1,21 @@ +@if (auth()->check()) + @php + $authPing = 'https://'.config('app.auth_domain').'/sso/ping'; + $platformSignedOutUrl = route('sso.platform-signed-out', ['redirect' => url()->current()]); + @endphp + +@endif diff --git a/resources/views/partials/topbar-account-switcher.blade.php b/resources/views/partials/topbar-account-switcher.blade.php new file mode 100644 index 0000000..dfcaf02 --- /dev/null +++ b/resources/views/partials/topbar-account-switcher.blade.php @@ -0,0 +1,24 @@ +{{-- Account switcher (desktop) — only when the user belongs to more than one account. --}} +@if (isset($accessibleAccounts) && $accessibleAccounts->count() > 1) + +@endif diff --git a/resources/views/partials/topbar-desktop-widgets.blade.php b/resources/views/partials/topbar-desktop-widgets.blade.php new file mode 100644 index 0000000..bb1eca5 --- /dev/null +++ b/resources/views/partials/topbar-desktop-widgets.blade.php @@ -0,0 +1,44 @@ +{{-- + Standard desktop top-right widgets (CRM / Invoice layout): + Afia → notifications → launcher → divider → avatar dropdown. +--}} +@php + $topbarUser = $user ?? auth()->user(); + $initials = collect(explode(' ', trim((string) $topbarUser?->name))) + ->filter()->take(2)->map(fn ($p) => strtoupper(substr($p, 0, 1)))->implode(''); + $avatarUrl = $topbarUser && method_exists($topbarUser, 'avatarUrl') + ? $topbarUser->avatarUrl() + : ($topbarUser?->avatar_url ?? null); + $showUserHeader = $showUser ?? true; +@endphp + +@includeIf('partials.afia-button', ['compact' => true]) + +@includeIf('partials.notification-dropdown') + +@include('partials.launcher') + +@includeIf('partials.topbar-widgets-mid') + + + +
+ + +
+ @include('partials.user-profile-menu', [ + 'items' => \App\Support\UserProfileMenu::items($topbarUser), + 'user' => $topbarUser, + 'showUser' => $showUserHeader, + ]) +
+
diff --git a/resources/views/partials/topbar-qr.blade.php b/resources/views/partials/topbar-qr.blade.php new file mode 100644 index 0000000..24232d0 --- /dev/null +++ b/resources/views/partials/topbar-qr.blade.php @@ -0,0 +1,29 @@ +@php + $user = auth()->user(); + $initials = collect(explode(' ', trim((string) $user?->name))) + ->filter() + ->take(2) + ->map(fn ($part) => strtoupper(substr($part, 0, 1))) + ->implode(''); +@endphp + +
+
+ + @include('partials.mobile-topbar-title') +
+ +
+ @auth + @includeIf('partials.topbar-account-switcher') + @includeIf('partials.topbar-widgets-prepend') + @include('partials.topbar-desktop-widgets', ['user' => $user ?? $u ?? auth()->user(), 'showUser' => true]) + @includeIf('partials.topbar-widgets-append') + @else + Sign in + @include('partials.launcher') + @endauth +
+
diff --git a/resources/views/partials/topbar-widgets-append.blade.php b/resources/views/partials/topbar-widgets-append.blade.php new file mode 100644 index 0000000..7b28c07 --- /dev/null +++ b/resources/views/partials/topbar-widgets-append.blade.php @@ -0,0 +1,7 @@ +{{-- Mobile cart icon (after avatar controls). --}} +@if (! empty($cartRoute)) + @include('partials.mobile-header-cart', [ + 'cartUrl' => $cartRoute, + 'cartCount' => $cartCount ?? 0, + ]) +@endif diff --git a/resources/views/partials/topbar-widgets-mid.blade.php b/resources/views/partials/topbar-widgets-mid.blade.php new file mode 100644 index 0000000..a99c4b0 --- /dev/null +++ b/resources/views/partials/topbar-widgets-mid.blade.php @@ -0,0 +1 @@ +{{-- POS has no platform cart widget. --}} diff --git a/resources/views/partials/topbar.blade.php b/resources/views/partials/topbar.blade.php new file mode 100644 index 0000000..1411e69 --- /dev/null +++ b/resources/views/partials/topbar.blade.php @@ -0,0 +1,20 @@ +@php + $user = auth()->user(); + $initials = collect(explode(' ', trim((string) $user?->name))) + ->filter()->take(2)->map(fn ($p) => strtoupper(substr($p, 0, 1)))->implode(''); +@endphp +
+
+ +

{{ $heading ?? 'Ladill POS' }}

+ +
+ +
+ @includeIf('partials.topbar-widgets-prepend') + @include('partials.topbar-desktop-widgets', ['user' => $user, 'showUser' => true]) + @includeIf('partials.topbar-widgets-append') +
+
diff --git a/resources/views/partials/user-profile-menu.blade.php b/resources/views/partials/user-profile-menu.blade.php new file mode 100644 index 0000000..c78cb3e --- /dev/null +++ b/resources/views/partials/user-profile-menu.blade.php @@ -0,0 +1,70 @@ +@props([ + 'items' => [], + 'user' => null, + 'showUser' => false, + 'variant' => 'dropdown', + 'onNavigate' => null, +]) + +@php + $linkClass = match ($variant) { + 'dark' => 'block rounded-md px-3 py-2 text-sm text-slate-200 hover:bg-slate-800', + 'sheet' => 'block rounded-xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-100', + default => 'block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100', + }; + + $logoutClass = match ($variant) { + 'dark' => 'w-full rounded-md px-3 py-2 text-left text-sm text-rose-300 hover:bg-rose-950/40', + 'sheet' => 'w-full rounded-xl px-4 py-3 text-left text-sm font-medium text-rose-600 transition hover:bg-rose-50', + default => 'w-full rounded-lg px-3 py-2 text-left text-sm text-rose-600 hover:bg-rose-50', + }; + + $logoutWrapperClass = match ($variant) { + 'sheet' => 'border-t border-slate-100 pt-2', + 'dark' => '', + default => '', + }; + + $dividerClass = match ($variant) { + 'dark' => 'my-1 border-t border-slate-800', + default => 'my-1 border-t border-slate-100', + }; + + $containerClass = match ($variant) { + 'dark' => 'mt-2 rounded-lg border border-slate-800 bg-slate-900 p-1', + 'sheet' => 'space-y-1 p-2 pb-4', + default => 'p-1', + }; +@endphp + +
merge(['class' => $containerClass]) }}> + @if ($showUser && $user) +
+

{{ $user->name ?? 'Your account' }}

+

{{ $user->email }}

+
+
+ @endif + + @foreach ($items as $item) + @if (($item['type'] ?? 'link') === 'link') + + {{ $item['label'] }} + + @elseif (($item['type'] ?? '') === 'wallet') + @includeIf('partials.wallet-widget') + @elseif (($item['type'] ?? '') === 'logout') + @if ($variant !== 'sheet') +
+ @endif +
+ @csrf + +
+ @endif + @endforeach +
diff --git a/resources/views/partials/wallet-widget.blade.php b/resources/views/partials/wallet-widget.blade.php new file mode 100644 index 0000000..8661874 --- /dev/null +++ b/resources/views/partials/wallet-widget.blade.php @@ -0,0 +1,23 @@ +{{-- Wallet balance peek (links to the account wallet on account.ladill.com). --}} +@php + $balanceRoute = (string) config('billing.wallet_balance_route', 'wallet.balance'); + $balanceUrl = \Illuminate\Support\Facades\Route::has($balanceRoute) ? route($balanceRoute) : null; +@endphp +@if ($balanceUrl) + + + + + + + + + Wallet balance + + + + + +@endif diff --git a/resources/views/partials/wordpress-icon.blade.php b/resources/views/partials/wordpress-icon.blade.php new file mode 100644 index 0000000..a92f0e7 --- /dev/null +++ b/resources/views/partials/wordpress-icon.blade.php @@ -0,0 +1,2 @@ +@props(['class' => 'h-8 w-8']) +WordPress diff --git a/resources/views/pos/dashboard.blade.php b/resources/views/pos/dashboard.blade.php new file mode 100644 index 0000000..245a9c8 --- /dev/null +++ b/resources/views/pos/dashboard.blade.php @@ -0,0 +1,51 @@ + +
+
+
+

Overview

+

Today's register takings and recent sales.

+
+ Open register +
+ +
+
+

Today's sales

+

{{ pos_money($stats['today_total_minor']) }}

+
+
+

Transactions

+

{{ number_format($stats['today_count']) }}

+
+
+

Active products

+

{{ number_format($stats['product_count']) }}

+
+
+

Pending checkouts

+

{{ number_format($stats['open_pending']) }}

+
+
+ + +
+
diff --git a/resources/views/pos/partials/customer-picker.blade.php b/resources/views/pos/partials/customer-picker.blade.php new file mode 100644 index 0000000..e4d13e6 --- /dev/null +++ b/resources/views/pos/partials/customer-picker.blade.php @@ -0,0 +1,28 @@ +@props(['customers' => []]) +@if (!empty($customers)) +
+ + + +
+@endif diff --git a/resources/views/pos/products/_form.blade.php b/resources/views/pos/products/_form.blade.php new file mode 100644 index 0000000..1db2067 --- /dev/null +++ b/resources/views/pos/products/_form.blade.php @@ -0,0 +1,23 @@ +
+ + + @error('name')

{{ $message }}

@enderror +
+
+ + +
+
+ + + @error('price')

{{ $message }}

@enderror +
+
+ is_active ?? true)) + class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"> + +
diff --git a/resources/views/pos/products/create.blade.php b/resources/views/pos/products/create.blade.php new file mode 100644 index 0000000..ba4b9ca --- /dev/null +++ b/resources/views/pos/products/create.blade.php @@ -0,0 +1,15 @@ + +
+ ← Products +

Add product

+ +
+ @csrf + @include('pos.products._form') +
+ Cancel + +
+
+
+
diff --git a/resources/views/pos/products/edit.blade.php b/resources/views/pos/products/edit.blade.php new file mode 100644 index 0000000..e9c72ce --- /dev/null +++ b/resources/views/pos/products/edit.blade.php @@ -0,0 +1,23 @@ + +
+ ← Products +

Edit product

+ +
+ @csrf + @method('PUT') + @include('pos.products._form') +
+ + @csrf + @method('DELETE') + + +
+ Cancel + +
+
+ +
+
diff --git a/resources/views/pos/products/index.blade.php b/resources/views/pos/products/index.blade.php new file mode 100644 index 0000000..208467c --- /dev/null +++ b/resources/views/pos/products/index.blade.php @@ -0,0 +1,36 @@ + +
+
+

Products

+ Add product +
+ +
+ + + + + + + + + + + @forelse ($products as $product) + + + + + + + @empty + + @endforelse + +
NameSKUPriceStatus
{{ $product->name }}{{ $product->sku ?: '—' }}{{ pos_money($product->price_minor, $product->currency) }}{{ $product->is_active ? 'Active' : 'Hidden' }}
No products yet.
+ @if ($products->hasPages()) +
{{ $products->links() }}
+ @endif +
+
+
diff --git a/resources/views/pos/register.blade.php b/resources/views/pos/register.blade.php new file mode 100644 index 0000000..a52757f --- /dev/null +++ b/resources/views/pos/register.blade.php @@ -0,0 +1,162 @@ + +
+
+
+

Register

+

{{ $location->name }} · {{ $location->currency }}

+
+
+ + +
+
+ +
+
+
+ + @if ($products->isEmpty()) +

No products yet. Add products or use Quick amount.

+ @endif +
+ +
+ + + + + +
+
+ +
+
+ @csrf +

Cart

+
    + +
  • Cart is empty.
  • +
+ +
+
+ Total + +
+
+ +
+ @include('pos.partials.customer-picker', ['customers' => $crmCustomers]) + + + +
+ +
+ +
+ + +
+
+
+
+
+ + +
diff --git a/resources/views/pos/sales/index.blade.php b/resources/views/pos/sales/index.blade.php new file mode 100644 index 0000000..213fc01 --- /dev/null +++ b/resources/views/pos/sales/index.blade.php @@ -0,0 +1,38 @@ + +
+
+

Sales

+ New sale +
+ +
+ + + + + + + + + + + + @forelse ($sales as $sale) + + + + + + + + @empty + + @endforelse + +
ReferenceStatusMethodTotalDate
{{ $sale->reference }}{{ $sale->status }}{{ $sale->payment_method }}{{ pos_money($sale->total_minor, $sale->currency) }}{{ $sale->created_at->format('M j, Y g:i A') }}
No sales yet.
+ @if ($sales->hasPages()) +
{{ $sales->links() }}
+ @endif +
+
+
diff --git a/resources/views/pos/sales/show.blade.php b/resources/views/pos/sales/show.blade.php new file mode 100644 index 0000000..5bfbfde --- /dev/null +++ b/resources/views/pos/sales/show.blade.php @@ -0,0 +1,50 @@ + +
+ ← Sales + +
+
+
+

{{ $sale->reference }}

+

{{ $sale->created_at->format('M j, Y g:i A') }}

+
+ + {{ $sale->status }} + +
+ +
+
Payment
{{ $sale->payment_method }}
+
Total
{{ pos_money($sale->total_minor, $sale->currency) }}
+ @if ($sale->customer_name) +
Customer
{{ $sale->customer_name }}
+ @endif +
+ + + + + + + @foreach ($sale->lines as $line) + + + + + + @endforeach + +
ItemQtyAmount
{{ $line->name }}{{ $line->quantity }}{{ pos_money($line->line_total_minor, $sale->currency) }}
+ + @if ($sale->location?->receipt_footer) +

{{ $sale->location->receipt_footer }}

+ @endif + + @if ($sale->isPaid() && !empty($invoiceUrl)) + + @endif +
+
+
diff --git a/resources/views/pos/settings.blade.php b/resources/views/pos/settings.blade.php new file mode 100644 index 0000000..b835132 --- /dev/null +++ b/resources/views/pos/settings.blade.php @@ -0,0 +1,53 @@ + +
+
+

Settings

+

Register location, receipt footer, and catalog imports.

+
+ +
+ @csrf + @method('PUT') +

Location

+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Import catalog

+

Pull products into your local POS catalog from CRM or Merchant storefronts.

+
+
+ @csrf + +
+ @if ($merchantImportEnabled) +
+ @csrf + +
+ @endif +
+
+
+
diff --git a/resources/views/public/qr/document-viewer.blade.php b/resources/views/public/qr/document-viewer.blade.php new file mode 100644 index 0000000..b5f1845 --- /dev/null +++ b/resources/views/public/qr/document-viewer.blade.php @@ -0,0 +1,178 @@ + + + + + + @include('partials.favicon') + {{ $qrCode->label }} + + + + +
+ {{ $qrCode->label }} + @if($allowDownload) + + + + + Download + + @else + + @endif +
+ +
+
+
+ Loading document… +
+
+ +
+ + + + diff --git a/resources/views/public/qr/event-confirmed.blade.php b/resources/views/public/qr/event-confirmed.blade.php new file mode 100644 index 0000000..222b28b --- /dev/null +++ b/resources/views/public/qr/event-confirmed.blade.php @@ -0,0 +1,73 @@ +@php + $c = $qrCode->content(); + $evColor = $c['brand_color'] ?? '#4f46e5'; + $evName = $c['name'] ?? $qrCode->label; + $isContribution = ($c['mode'] ?? 'ticketing') === 'contributions'; + $badgeUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=' . urlencode($registration->badge_code); +@endphp + + + + + + {{ $isContribution ? 'Thank you' : "You're registered" }} — {{ $evName }} + @include('partials.favicon') + + + +
+
+
+
+ +
+

{{ $isContribution ? 'Thank you!' : "You're registered!" }}

+

{{ $evName }}

+
+ +
+ @if($isContribution) +

Hi {{ $registration->attendee_name }}, your contribution has been received.

+ +
+

{{ $registration->tier_name }}

+

{{ $registration->currency }} {{ number_format($registration->amountCedis(), 2) }}

+

Reference

+

{{ $registration->badge_code }}

+
+ + @if(($registration->badge_fields ?? [])) +
+ @foreach(($registration->badge_fields ?? []) as $label => $value) +
{{ $label }}{{ $value }}
+ @endforeach +
+ @endif + +

A receipt was sent to {{ $registration->attendee_email }}.

+ @else +

Hi {{ $registration->attendee_name }}, your spot is confirmed.

+ +
+ Badge QR +

Badge code

+

{{ $registration->badge_code }}

+
+ +
+
Ticket{{ $registration->tier_name }}
+ @if($registration->isPaid()) +
Paid{{ $registration->currency }} {{ number_format($registration->amountCedis(), 2) }}
+ @endif + @foreach(($registration->badge_fields ?? []) as $label => $value) +
{{ $label }}{{ $value }}
+ @endforeach +
+ +

Show this badge code at check-in. A confirmation was sent to {{ $registration->attendee_email }}.

+ @endif +
+
+
+ + diff --git a/resources/views/public/qr/inactive.blade.php b/resources/views/public/qr/inactive.blade.php new file mode 100644 index 0000000..40e828e --- /dev/null +++ b/resources/views/public/qr/inactive.blade.php @@ -0,0 +1,18 @@ + + + + + + @include('partials.favicon') + QR code inactive + + + +
+

This QR code is not active

+

The owner has paused this link. Try again later.

+
+ + diff --git a/resources/views/public/qr/landing.blade.php b/resources/views/public/qr/landing.blade.php new file mode 100644 index 0000000..f4a6755 --- /dev/null +++ b/resources/views/public/qr/landing.blade.php @@ -0,0 +1,1491 @@ + + + + + + @include('partials.favicon') + {{ $qrCode->label }} + + @vite(['resources/css/app.css']) + + + +@php + $content = $qrCode->content(); + $type = $qrCode->type; +@endphp + +{{-- ===== PDF ===== --}} +@if($type === \App\Models\QrCode::TYPE_DOCUMENT) + + +{{-- ===== LIST OF LINKS ===== --}} +@elseif($type === \App\Models\QrCode::TYPE_LINK_LIST) +
+

{{ $qrCode->label }}

+
+ @foreach($content['links'] ?? [] as $link) + + {{ $link['title'] }} + + + + + @endforeach +
+
+ +{{-- ===== VCARD ===== --}} +@elseif($type === \App\Models\QrCode::TYPE_VCARD) +@php + $avatarUrl = !empty($content['avatar_path']) ? route('qr.public.vcard.avatar', $qrCode->short_code) : null; + $rawSocial = $content['social'] ?? []; + $social = []; + foreach ($rawSocial as $platform => $url) { + $social[$platform] = \App\Services\Qr\QrPayloadValidator::normalizeSocialUrl($platform, trim((string) $url)); + } + $fullName = trim(($content['first_name'] ?? '') . ' ' . ($content['last_name'] ?? '')); + $initials = strtoupper(substr($content['first_name'] ?? ($content['last_name'] ?? '?'), 0, 1)); + + $socialIcons = [ + 'linkedin' => 'linkedin.svg', + 'twitter' => 'twitter.svg', + 'instagram' => 'instagram.svg', + 'facebook' => 'facebook.svg', + 'tiktok' => 'tik-tok.svg', + 'youtube' => 'youtube.svg', + 'whatsapp' => 'whatsapp.svg', + 'snapchat' => 'snapchat.svg', + ]; +@endphp +
+ + {{-- Colored header strip --}} +
+ + {{-- Floating card --}} +
+
+ + {{-- Avatar --}} +
+ @if($avatarUrl) + {{ $fullName }} + @else +
+ {{ $initials }} +
+ @endif +
+ + {{-- Name & company --}} +
+

{{ $fullName }}

+ @if(!empty($content['company'])) +

{{ $content['company'] }}

+ @endif +
+ + {{-- Contact rows --}} +
+ @if(!empty($content['phone'])) + + + {{ $content['phone'] }} + + @endif + @if(!empty($content['email'])) + + + {{ $content['email'] }} + + @endif + @if(!empty($content['website'])) + + + {{ $content['website'] }} + + @endif + @if(!empty($content['address'])) +
+ + {{ $content['address'] }} +
+ @endif + @if(!empty($content['note'])) +

{{ $content['note'] }}

+ @endif +
+ + {{-- Social links --}} + @if(!empty($social)) +
+ @foreach($social as $platform => $url) + @if(isset($socialIcons[$platform])) + + {{ ucfirst($platform) }} + + @endif + @endforeach +
+ @endif + + {{-- Save contact button --}} + + +
+
+
+ +{{-- ===== BUSINESS ===== --}} +@elseif($type === \App\Models\QrCode::TYPE_BUSINESS) +@php + $bizName = $content['name'] ?? $qrCode->label; + $bizTagline = $content['tagline'] ?? ''; + $bizColor = $content['brand_color'] ?? '#1e3a5f'; + $bizHasLogo = !empty($content['logo_path']); + $bizHasCover = !empty($content['cover_path']); + $bizLogoUrl = $bizHasLogo ? route('qr.public.business.logo', $qrCode->short_code) : null; + $bizCoverUrl = $bizHasCover ? route('qr.public.business.cover', $qrCode->short_code) : null; + $bizInitials = strtoupper(substr($bizName, 0, 1)); + $bizRawSocial = $content['social'] ?? []; + $bizSocial = []; + foreach ($bizRawSocial as $platform => $url) { + $bizSocial[$platform] = \App\Services\Qr\QrPayloadValidator::normalizeSocialUrl($platform, trim((string) $url)); + } + $socialIcons = [ + 'linkedin' => 'linkedin.svg', + 'twitter' => 'twitter.svg', + 'instagram' => 'instagram.svg', + 'facebook' => 'facebook.svg', + 'tiktok' => 'tik-tok.svg', + 'youtube' => 'youtube.svg', + 'whatsapp' => 'whatsapp.svg', + ]; +@endphp +
+ + {{-- Cover strip + logo anchor --}} +
+ {{-- Cover --}} +
+ @if($bizHasCover) + {{ $bizName }} +
+ @else +
+
+ @endif +
+ + {{-- Logo: floats at the cover/card seam --}} +
+ @if($bizHasLogo) +
+ {{ $bizName }} +
+ @else +
+ {{ $bizInitials }} +
+ @endif +
+
+ + {{-- Floating card --}} +
+
+ + {{-- Name & tagline centred --}} +
+

{{ $bizName }}

+ @if($bizTagline) +

{{ $bizTagline }}

+ @endif +
+ + {{-- Contact rows --}} +
+ @if(!empty($content['phone'])) + + + {{ $content['phone'] }} + + @endif + @if(!empty($content['email'])) + + + {{ $content['email'] }} + + @endif + @if(!empty($content['website'])) + + + {{ $content['website'] }} + + @endif + @if(!empty($content['address'])) +
+ + {{ $content['address'] }} +
+ @endif + @if(!empty($content['hours'])) +
+ + {{ $content['hours'] }} +
+ @endif +
+ + {{-- Social links --}} + @if(!empty($bizSocial)) +
+ @foreach($bizSocial as $platform => $url) + @if(isset($socialIcons[$platform])) + + {{ ucfirst($platform) }} + + @endif + @endforeach +
+ @endif + + {{-- CTA: website button if present --}} + @if(!empty($content['website'])) + + @endif + +
+
+
+ +{{-- ===== CHURCH ===== --}} +@elseif($type === \App\Models\QrCode::TYPE_CHURCH) +@php + $churchName = $content['name'] ?? $qrCode->label; + $churchColor = $content['brand_color'] ?? '#1a3a5c'; + $churchHasLogo = !empty($content['logo_path']); + $churchHasCover = !empty($content['cover_path']); + $churchLogoUrl = $churchHasLogo ? route('qr.public.church.logo', $qrCode->short_code) : null; + $churchCoverUrl = $churchHasCover ? route('qr.public.church.cover', $qrCode->short_code) : null; + $churchInitials = strtoupper(substr($churchName, 0, 2)); + $churchAcceptsPay = !empty($content['accepts_payment']); + $churchCurrency = $content['currency'] ?? 'GHS'; + $churchTypes = $content['collection_types'] ?? ['offering', 'tithe', 'donation', 'harvest']; + // Normalise legacy lowercase slugs → display strings + $legacyLabels = ['offering'=>'Offering','tithe'=>'Tithe','donation'=>'Donation','harvest'=>'Harvest']; + $churchTypeLabels = array_combine( + $churchTypes, + array_map(fn($t) => $legacyLabels[strtolower(trim($t))] ?? trim($t), $churchTypes) + ); + $churchOrderRoute = route('qr.public.order', $qrCode->short_code); + $churchCsrf = csrf_token(); +@endphp +
+ + {{-- Cover strip + logo anchor --}} +
+
+ @if($churchHasCover) + {{ $churchName }} +
+ @else +
+
+ @endif +
+ + {{-- Logo: floats at cover/card seam --}} +
+ @if($churchHasLogo) +
+ {{ $churchName }} +
+ @else +
+ {{ $churchInitials }} +
+ @endif +
+
+ + {{-- Floating card --}} +
+
+ + {{-- Name + denomination + description --}} +
+

{{ $churchName }}

+ @if(!empty($content['denomination'])) +

{{ $content['denomination'] }}

+ @endif + @if(!empty($content['description'])) +

{{ $content['description'] }}

+ @endif +
+ + {{-- Service times badge --}} + @if(!empty($content['service_times'])) +
+ + + {{ $content['service_times'] }} + +
+ @endif + + {{-- Compact icon action row --}} + @php $hasContact = !empty($content['phone']) || !empty($content['email']) || !empty($content['address']) || !empty($content['website']); @endphp + @if($hasContact) +
+ @if(!empty($content['phone'])) + + + + @endif + @if(!empty($content['email'])) + + + + @endif + @if(!empty($content['address'])) + + + + @endif + @if(!empty($content['website'])) + + + + @endif +
+ @endif + +
+ + @if($churchAcceptsPay && !empty($churchTypes)) + {{-- Brand color CSS variable so focus rings match --}} + + + {{-- Giving card --}} +
+ + {{-- Header --}} +
+

Give Online

+

Secured by Paystack. Powered by Ladill Pay

+
+ +
+ + {{-- Collection type icon cards --}} + @php + // Keyed by lowercase label for case-insensitive lookup + $typeIconMap = [ + 'offering' => 'M21 11.25v8.25a1.5 1.5 0 0 1-1.5 1.5H5.25a1.5 1.5 0 0 1-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 1 0 9.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1 1 14.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z', + 'tithe' => 'M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z', + 'donation' => 'M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z', + 'harvest' => 'M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z', + 'school fees' => 'M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.63 48.63 0 0 1 12 20.904a48.63 48.63 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.57 50.57 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0 1 12 13.489a50.702 50.702 0 0 1 3.741-1.342M6.75 14.25v4.5', + 'pta levy' => 'M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z', + 'exam fees' => 'M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10', + 'development fund' => 'M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Z', + 'sadaqah' => 'M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z', + 'zakat' => 'M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z', + "waqf" => 'M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Z', + "jumu'ah" => 'M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z', + 'general donation' => 'M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z', + 'project fund' => 'M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z', + 'emergency relief' => 'M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z', + 'sponsorship' => 'M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z', + 'membership dues' => 'M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z', + 'event fund' => 'M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5', + ]; + $defaultIcon = 'M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09Z'; + // Resolve icon for each type by lowercase key + $typeIcons = []; + foreach ($churchTypeLabels as $key => $label) { + $typeIcons[$key] = $typeIconMap[strtolower($label)] ?? $defaultIcon; + } + @endphp + @if(count($churchTypeLabels) > 1) +
+
+
+

I'm giving for

+

Choose a giving category

+
+ + + + + +
+
+ @foreach($churchTypeLabels as $ct => $ctLabel) + @php $iconPath = $typeIcons[$ct] ?? $defaultIcon; @endphp + + @endforeach +
+
+ @endif + + {{-- Quick amounts carousel — shows 2 full chips + peek at 3rd --}} +
+

Quick amount

+ +
+ + {{-- Amount input — inline styles to prevent overflow on mobile --}} +
+

Or enter amount

+
+ {{ $churchCurrency }} + +
+
+ + {{-- Contact fields --}} +
+

Your details

+ + + +
+ + {{-- Error --}} +
+ + {{-- Give button --}} + + +

Your giving is safe & secure

+
+
+ @endif + + @include('partials.paystack-sheet') +
+
+ +{{-- ===== EVENT ===== --}} +@elseif($type === \App\Models\QrCode::TYPE_EVENT) +@php + $evColor = $content['brand_color'] ?? '#4f46e5'; + $evHasLogo = !empty($content['logo_path']); + $evHasCover = !empty($content['cover_path']); + $evLogoUrl = $evHasLogo ? route('qr.public.event.logo', $qrCode->short_code) : null; + $evCoverUrl = $evHasCover ? route('qr.public.event.cover', $qrCode->short_code) : null; + $evName = $content['name'] ?? $qrCode->label; + $evInitials = strtoupper(mb_substr($evName, 0, 2)); + $evTiers = $content['tiers'] ?? []; + $evCurrency = $content['currency'] ?? 'GHS'; + $evFields = $content['badge_fields'] ?? []; + $evOpen = (bool) ($content['registration_open'] ?? true); + $evMode = in_array($content['mode'] ?? 'ticketing', ['contributions', 'free'], true) ? ($content['mode'] ?? 'ticketing') : 'ticketing'; + $evCategories = $content['contribution_categories'] ?? ['Contribution']; + $evHeading = $evMode === 'contributions' ? 'Make a contribution' : 'Register'; + $evRegRoute = route('qr.public.event.register', $qrCode->short_code); + $evCsrf = csrf_token(); +@endphp +
+ + {{-- Hero --}} +
+ @if($evHasCover) + {{ $evName }} +
+ @else +
+ @endif +
+ @if($evHasLogo) + {{ $evName }} + @else +
{{ $evInitials }}
+ @endif +

{{ $evName }}

+ @if(!empty($content['tagline']))

{{ $content['tagline'] }}

@endif +
+ @if(!empty($content['starts_at'])) + + + {{ \App\Support\Qr\QrDateFormatter::forDisplay($content['starts_at']) }} + + @endif + @if(!empty($content['location'])) + + + {{ $content['location'] }} + + @endif +
+
+
+ +
+ @if(!empty($content['description'])) +

{{ $content['description'] }}

+ @endif + + @if(! $evOpen) +
{{ $evMode === 'contributions' ? 'Contributions are currently closed.' : 'Registration is currently closed.' }}
+ @else +
+
+

{{ $evHeading }}

+
+
+ + {{-- Ticket tiers --}} + @if($evMode === 'ticketing' && count($evTiers) > 0) +
+
+
+

Ticket

+

Choose a ticket type

+
+ + + + + +
+
+ @foreach($evTiers as $t) + @php $tPrice = (float) ($t['price'] ?? 0); @endphp + + @endforeach +
+
+ @endif + + {{-- Contribution categories + amount --}} + @if($evMode === 'contributions') + @if(count($evCategories) > 1) +
+
+
+

I'm giving for

+

Choose a category

+
+ + + + + +
+
+ @foreach($evCategories as $cat) + + @endforeach +
+
+ @endif + + {{-- Quick amounts --}} +
+

Quick amount

+
+ @foreach([50, 100, 200, 500, 1000] as $qa) + + @endforeach +
+
+ + {{-- Custom amount --}} +
+

Or enter amount

+
+ {{ $evCurrency }} + +
+
+ @endif + + {{-- Attendee details --}} +
+

Your details

+ + + + @foreach($evFields as $field) + + @endforeach +
+ +
+ + +

{{ $evMode === 'contributions' ? 'Your contribution is safe & secure.' : 'A badge will be issued on registration.' }}

+
+
+ @endif +
+ + @include('partials.paystack-sheet') +
+ +{{-- ===== ITINERARY ===== --}} +@elseif($type === \App\Models\QrCode::TYPE_ITINERARY) +@php + $itinColor = $content['brand_color'] ?? '#b45309'; + $itinHasCover = !empty($content['cover_path']); + $itinCoverUrl = $itinHasCover ? route('qr.public.itinerary.cover', $qrCode->short_code) : null; + $itinTitle = $content['title'] ?? $qrCode->label; + $itinDays = $content['days'] ?? []; + $multiDay = count($itinDays) > 1; +@endphp +
+ + {{-- Hero --}} +
+ @if($itinHasCover) + {{ $itinTitle }} +
+ @else +
+
+ @endif +
+

{{ $itinTitle }}

+ @if(!empty($content['subtitle'])) +

{{ $content['subtitle'] }}

+ @endif +
+ @if(!empty($content['event_date'])) + + + {{ \App\Support\Qr\QrDateFormatter::forDisplay($content['event_date']) }} + + @endif + @if(!empty($content['location'])) + + + {{ $content['location'] }} + + @endif +
+
+
+ +
+ @if(!empty($content['description'])) +

{{ $content['description'] }}

+ @endif + + {{-- Programme --}} +
+ @if($multiDay) +
+ @foreach($itinDays as $di => $day) + + @endforeach +
+ @endif + + @foreach($itinDays as $di => $day) +
+
+

{{ $day['label'] ?: ('Day ' . ($di + 1)) }}

+ @if(!empty($day['date'])){{ \App\Support\Qr\QrDateFormatter::forDisplay($day['date']) }}@endif +
+ +
+ @foreach($day['items'] ?? [] as $ii => $item) +
+ {{-- Timeline rail --}} +
+ + @if(! $loop->last)@endif +
+
+ @if(!empty($item['time'])) +

{{ $item['time'] }}

+ @endif +

{{ $item['title'] }}

+ @if(!empty($item['host']) || !empty($item['location'])) +

+ {{ $item['host'] ?? '' }}{{ !empty($item['host']) && !empty($item['location']) ? ' · ' : '' }}{{ $item['location'] ?? '' }} +

+ @endif + @if(!empty($item['description'])) +

{{ $item['description'] }}

+ @endif +
+
+ @endforeach +
+
+ @endforeach +
+
+
+ +{{-- ===== WIFI ===== --}} +@elseif($type === \App\Models\QrCode::TYPE_WIFI) +
+
+
+
+ + + +
+

Join WiFi

+

{{ $qrCode->label }}

+
+ +
+
+
+

Network

+

{{ $content['ssid'] ?? '' }}

+
+ @if(($content['encryption'] ?? 'WPA') !== 'NOPASS' && !empty($content['password'])) +
+

Password

+
+

{{ $content['password'] }}

+ +
+
+ @endif +
+ + {{ $content['encryption'] ?? 'WPA' }} + {{ !empty($content['hidden']) ? ' · Hidden' : '' }} + +
+
+
+
+
+ +{{-- ===== IMAGES ===== --}} +@elseif($type === \App\Models\QrCode::TYPE_IMAGE) +
+

{{ $qrCode->label }}

+
+ @foreach($content['images'] ?? [] as $index => $image) + {{ $image['title'] ?? 'Image ' . ($index + 1) }} + @endforeach +
+
+ +{{-- ===== APP ===== --}} +@elseif($type === \App\Models\QrCode::TYPE_APP) +@php $appIconUrl = !empty($content['icon_path']) ? route('qr.public.app.icon', $qrCode->short_code) : null; @endphp +
+
+ @if($appIconUrl) + {{ $content['name'] ?? $qrCode->label }} + @else +
+ + + +
+ @endif +

{{ $content['name'] ?? $qrCode->label }}

+
+ @if(!empty($content['ios_url'])) + + + + + + +
+

Download on the

+

App Store

+
+
+ @endif + @if(!empty($content['android_url'])) + + + + +
+

Get it on

+

Google Play

+
+
+ @endif + @if(!empty($content['web_url'])) + + +
+

Open

+

Website

+
+
+ @endif +
+
+
+ +{{-- ===== COUPON ===== --}} +@elseif($type === \App\Models\QrCode::TYPE_COUPON) +
+
+
+
+ + + +

{{ $content['title'] ?? $qrCode->label }}

+
+
+
+

Your code

+

{{ $content['code'] ?? '' }}

+
+ @if(!empty($content['description']))

{{ $content['description'] }}

@endif + @if(!empty($content['expires_at']))

Expires {{ $content['expires_at'] }}

@endif + @if(!empty($content['button_url'])) + + {{ $content['button_text'] ?? 'Shop now' }} + + @endif +
+
+
+
+ +{{-- ===== MENU & SHOP (shared cart/order UI) ===== --}} +@elseif(in_array($type, [\App\Models\QrCode::TYPE_MENU, \App\Models\QrCode::TYPE_SHOP])) + +@php + $isShop = $type === \App\Models\QrCode::TYPE_SHOP; + $currency = $content['currency'] ?? 'GHS'; + $sections = $content['sections'] ?? []; + $title = $content['title'] ?? $qrCode->label; + $acceptsPayment = (bool) ($content['accepts_payment'] ?? false); + $orderRoute = route('qr.public.order', $qrCode->short_code); + $csrfToken = csrf_token(); + $paystackPublicKey = $acceptsPayment ? app(\App\Services\Billing\PaystackService::class)->publicKey() : ''; + $brandColor = $content['brand_color'] ?? ($isShop ? '#7c3aed' : '#f97316'); + $hasLogo = !empty($content['logo_path']); + $hasCover = !empty($content['cover_path']); + $logoUrl = $hasLogo ? route('qr.public.menu.logo', $qrCode->short_code) : null; + $coverUrl = $hasCover ? route('qr.public.menu.cover', $qrCode->short_code) : null; + $shippingType = $content['shipping_type'] ?? 'none'; + $shippingFee = (float) ($content['shipping_fee'] ?? 0); + $freeShippingAbove = (float) ($content['free_shipping_above'] ?? 0); + $itemCount = array_sum(array_map(fn($s) => count($s['items'] ?? []), $sections)); +@endphp + +
+ +{{-- ===== HERO ===== --}} +
+ @if($hasCover) + {{ $title }} + @endif + {{-- gradient overlay always present --}} +
+ @if(!$hasCover) +
+ @endif + + {{-- Hero content --}} +
+
+ @if($hasLogo) + {{ $title }} + @else +
+ {{ strtoupper(substr($title, 0, 1)) }} +
+ @endif +
+

{{ $title }}

+
+ + {{ $isShop ? 'Shop' : 'Restaurant' }} + + + {{ $itemCount }} {{ $isShop ? ($itemCount === 1 ? 'product' : 'products') : ($itemCount === 1 ? 'item' : 'items') }} + + @if($acceptsPayment) + + Order online + + @endif +
+
+
+
+
+ +{{-- ===== SECTION TABS ===== --}} +@if(count($sections) > 1) +
+
+ @foreach($sections as $si => $section) + + @endforeach +
+
+@endif + +{{-- ===== ITEMS ===== --}} +
+ @foreach($sections as $si => $section) +
+ @if(count($sections) === 1) +

{{ $section['name'] }}

+ @endif +
+ @foreach($section['items'] ?? [] as $ii => $item) + @php $hasImage = !empty($item['image_path']); @endphp +
+ {{-- Item image --}} + @if($hasImage) + {{ $item['name'] }} + @endif + + {{-- Item details --}} +
+
+

{{ $item['name'] }}

+ @if(!empty($item['description'])) +

{{ $item['description'] }}

+ @endif +
+
+ @if(!empty($item['price'])) +
+ {{ $currency }} + {{ $item['price'] }} +
+ @else + + @endif + + @if($acceptsPayment) +
+ + + +
+ @endif +
+
+
+ @endforeach +
+
+ @endforeach +
+ +@if($acceptsPayment) +{{-- ===== STICKY CART BAR ===== --}} +
+ +
+ +{{-- ===== PAYMENT MODAL (bottom sheet) ===== --}} +
+ +
+ + {{-- Drag handle --}} +
+
+
+ + {{-- Header --}} +
+

Your order

+ +
+ +
+ {{-- Cart items --}} +
+ +
+ + {{-- Totals --}} +
+
+ Subtotal + +
+ + +
+ Total + +
+
+ + {{-- Contact fields --}} +
+

Your details

+ + + +
+ +
+ + + +

Secured by Paystack. Powered by Ladill Pay

+
+
+
+@endif + +@if($acceptsPayment) +@include('partials.paystack-sheet') +@endif + +
{{-- end x-data --}} + +@endif + + + + + diff --git a/resources/views/public/qr/payment-confirmed.blade.php b/resources/views/public/qr/payment-confirmed.blade.php new file mode 100644 index 0000000..a961e03 --- /dev/null +++ b/resources/views/public/qr/payment-confirmed.blade.php @@ -0,0 +1,26 @@ + + + + + + Payment confirmed + + + @vite(['resources/css/app.css']) + + +
+
+
+ +
+

Payment successful

+

+ {{ $payment->currency }} {{ number_format($payment->amount_minor / 100, 2) }} paid to + {{ $qrCode->content()['business_name'] ?? $qrCode->label }}. +

+

Reference: {{ $payment->reference }}

+
+
+ + diff --git a/resources/views/public/qr/payment-landing.blade.php b/resources/views/public/qr/payment-landing.blade.php new file mode 100644 index 0000000..fa0cd18 --- /dev/null +++ b/resources/views/public/qr/payment-landing.blade.php @@ -0,0 +1,114 @@ +@php + $content = $qrCode->content(); + $businessName = $content['business_name'] ?? $qrCode->label; + $currency = $content['currency'] ?? 'GHS'; + $payUrl = route('qr.public.payment.pay', $qrCode->short_code); + $csrf = csrf_token(); +@endphp + + + + + + + Pay {{ $businessName }} + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + {{-- Mobile: Ladill Mini branding + fixed payment sheet --}} +
+
+
+ Ladill Mini +

{{ $businessName }}

+ @if(!empty($content['branch_label'])) +

{{ $content['branch_label'] }}

+ @endif +
+
+ + + +
+
+ +
+ + +
+ {{ $currency }} + +
+ + + +

Secured by Paystack. Powered by Ladill Pay

+
+
+ + {{-- Desktop: centered card --}} +
+
+
+ @if(!empty($content['logo_path'])) + + @endif +

{{ $businessName }}

+ @if(!empty($content['branch_label'])) +

{{ $content['branch_label'] }}

+ @endif +
+ +
+ +
+ +
+ {{ $currency }} + +
+ +
+ +

Secured by Paystack. Powered by Ladill Pay

+
+
+ + @include('partials.paystack-sheet') + + diff --git a/resources/views/qr-codes/attendees.blade.php b/resources/views/qr-codes/attendees.blade.php new file mode 100644 index 0000000..0cb596f --- /dev/null +++ b/resources/views/qr-codes/attendees.blade.php @@ -0,0 +1,176 @@ + + @php $isContribution = ($qrCode->content()['mode'] ?? 'ticketing') === 'contributions'; @endphp + {{ $isContribution ? 'Contributions' : 'Attendees' }} — {{ $qrCode->label }} + +
+ + {{-- Flash --}} + @foreach(['success', 'error'] as $flash) + @if(session($flash)) +
+ {{ session($flash) }} +
+ @endif + @endforeach + + {{-- Header --}} +
+ + + {{ $qrCode->label }} + +

{{ $isContribution ? 'Contributions' : 'Attendees' }}

+
+ + {{-- Share programme outline --}} + @if($programme) +
+
+ + + +
+

Programme outline

+

Email & text the {{ $programme->label }} programme link to all confirmed attendees.

+
+
+
+ @csrf + +
+
+ @endif + + {{-- Stats --}} +
+
+

{{ number_format($stats['total']) }}

+

{{ $isContribution ? 'Contributions' : 'Registered' }}

+
+ @unless($isContribution) +
+

{{ number_format($stats['checked_in']) }}

+

Checked in

+
+ @endunless +
+

GHS {{ number_format($stats['revenue'], 2) }}

+

{{ $isContribution ? 'Total raised' : 'Revenue' }}

+
+
+ + {{-- Toolbar --}} +
+
+ + +
+ @unless($isContribution) +
+ selected + + + Print all badges + Download ZPL +
+ @endunless +
+ + {{-- Table --}} +
+
+ + + + @unless($isContribution)@endunless + + + @unless($isContribution)@endunless + + @unless($isContribution)@endunless + + + + @forelse($registrations as $reg) + + @unless($isContribution) + + @endunless + + + @unless($isContribution) + + @endunless + + @unless($isContribution) + + @endunless + + @empty + + @endforelse + +
{{ $isContribution ? 'Contributor' : 'Attendee' }}{{ $isContribution ? 'Category' : 'Ticket' }}BadgeStatusCheck-in
+ @if($reg->status === \App\Models\QrEventRegistration::STATUS_CONFIRMED) + + @endif + +

{{ $reg->attendee_name }}

+

{{ $reg->attendee_email }}

+ @foreach(($reg->badge_fields ?? []) as $k => $v) + {{ $k }}: {{ $v }} + @endforeach +
+ {{ $reg->tier_name }} + @if($reg->isPaid())

GHS {{ number_format($reg->amountCedis(), 2) }}

@endif +
{{ $reg->badge_code }} + + {{ ucfirst($reg->status) }} + + +
+ @csrf @method('PATCH') + +
+
{{ $isContribution ? 'No contributions yet.' : 'No registrations yet.' }}
+
+ @if($registrations->hasPages()) +
{{ $registrations->links() }}
+ @endif +
+
+
diff --git a/resources/views/qr-codes/badges.blade.php b/resources/views/qr-codes/badges.blade.php new file mode 100644 index 0000000..b951707 --- /dev/null +++ b/resources/views/qr-codes/badges.blade.php @@ -0,0 +1,85 @@ +@php + $c = $qrCode->content(); + $evColor = $c['brand_color'] ?? '#4f46e5'; + $evName = $c['name'] ?? $qrCode->label; + $hasLogo = !empty($c['logo_path']); + $logoUrl = $hasLogo ? route('qr.public.event.logo', $qrCode->short_code) : null; + $size = $c['badge_size'] ?? '4x3'; + // Physical badge dimensions (inches) + [$bw, $bh] = match ($size) { + '4x6' => ['4in', '6in'], + 'cr80' => ['3.375in', '2.125in'], + default => ['4in', '3in'], + }; + $stack = $size === '4x6'; // tall badge → stack vertically +@endphp + + + + + @include('partials.favicon') + Badges — {{ $evName }} + + + +
+ + +
+ +
+ @forelse($registrations as $reg) + @php $badgeQr = 'https://api.qrserver.com/v1/create-qr-code/?size=120x120&margin=0&data=' . urlencode($reg->badge_code); @endphp +
+
+ @if($hasLogo)@endif + {{ $evName }} +
+
+
{{ $reg->attendee_name }}
+
{{ $reg->tier_name }}
+ @if(!empty($reg->badge_fields)) +
{{ collect($reg->badge_fields)->map(fn($v,$k) => $v)->implode(' · ') }}
+ @endif +
+
+ {{ $reg->badge_code }} + {{ $reg->badge_code }} +
+
+ @empty +

No confirmed attendees to print.

+ @endforelse +
+ + diff --git a/resources/views/qr-codes/create.blade.php b/resources/views/qr-codes/create.blade.php new file mode 100644 index 0000000..7214105 --- /dev/null +++ b/resources/views/qr-codes/create.blade.php @@ -0,0 +1,303 @@ + + @php + $createType = old('type', $requestedType ?? \App\Models\QrCode::TYPE_EVENT); + $isProgramme = $createType === \App\Models\QrCode::TYPE_ITINERARY; + $defaultStyle = \App\Support\Qr\QrStyleDefaults::merge(old('style') ?: ($accountDefaultStyle ?? null)); + @endphp + {{ $isProgramme ? 'New programme' : 'Create event' }} + +
+ + @if(session('error')) +
{{ session('error') }}
+ @endif + + {{-- Mobile page header --}} +
+ + + + + + {{ $isProgramme ? 'New programme' : 'Create event' }} +
+ + {{-- Desktop page header --}} + + + @unless($isProgramme) + {{-- Event creation wizard steps --}} +
+
    + @foreach(['Event details', 'QR code', 'Review & create'] as $i => $label) + @php $n = $i + 1; @endphp +
  1. + {{ $n }} + + @if($n < 3) + + @endif +
  2. + @endforeach +
+
+ @endunless + +
+ @csrf + + + {{-- QR preview (step 2 for events, always for programmes) --}} +
+ +
+ +
+ + {{-- Step 1 / Programme: Event details --}} +
+
+

{{ $isProgramme ? 'Programme details' : 'Event details' }}

+

{{ $isProgramme ? 'Title, schedule, and venue for this outline.' : 'Name, dates, ticketing, and registration settings.' }}

+
+ +
+
+ + +
+ +
+ +
+ + {{ \App\Models\QrCode::publicBaseUrl() }}/q/ + + +
+
+ + + + + +
+
+ +
+ @include('qr-codes.partials.type-fields-create') +
+ + @unless($isProgramme) +

+ You will design the QR code in the next step. Details can be updated anytime without reprinting. +

+ @else +

+ Attendees scan your Ladill event link — update details anytime without reprinting the QR. +

+ @endunless +
+
+ + {{-- Step 2: QR customization (events) or inline (programmes) --}} +
+ @include('qr-codes.partials.customization-fields', [ + 'style' => $defaultStyle, + 'moduleStyles' => $moduleStyles, + 'cornerOuterStyles' => $cornerOuterStyles, + 'cornerInnerStyles' => $cornerInnerStyles, + 'frameStyles' => $frameStyles, + ]) +
+ + {{-- Step 3: Review & create (events only) --}} +
+
+
+

Review your event

+

Confirm details before publishing. GHS {{ number_format($pricePerQr, 2) }} will be charged from your QR balance.

+
+
+
+ Event + +
+
+ Admin label + +
+
+ QR balance after + GHS +
+
+
+ +
+ @include('qr-codes.partials.qr-preview-card', ['showDownloads' => false]) +
+
+ + {{-- Desktop navigation --}} + +
+
+ + {{-- Mobile sticky action bar --}} +
+
+ + + + +
+ +
+ + {{-- Preview modal --}} +
+
+ Preview + +
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+ +
+
diff --git a/resources/views/qr-codes/index.blade.php b/resources/views/qr-codes/index.blade.php new file mode 100644 index 0000000..34feb37 --- /dev/null +++ b/resources/views/qr-codes/index.blade.php @@ -0,0 +1,86 @@ + + Events + +
+ @foreach(['success', 'error'] as $flash) + @if(session($flash)) +
+

{{ session($flash) }}

+
+ @endif + @endforeach + +
+
+
+
+
+
+ Tickets · Contributions · Free registration +
+

Your events

+

+ Create ticketed events, manage attendees, print badges, and attach programme outlines. +

+
+ +
+
+ +
+
+

GHS {{ number_format($wallet->spendableBalance(), 2) }}

+

Account balance

+
+
+

{{ $qrCodes->count() }}

+

Events

+
+
+

{{ number_format($totalRegistrations) }}

+

Registrations

+
+
+
+
+
+ +
+
+

Your events

+
+ @if($qrCodes->isEmpty()) +

No events yet. Create your first event to get started.

+ @else + + @endif +
+
+
diff --git a/resources/views/qr-codes/partials/customization-fields.blade.php b/resources/views/qr-codes/partials/customization-fields.blade.php new file mode 100644 index 0000000..132cb97 --- /dev/null +++ b/resources/views/qr-codes/partials/customization-fields.blade.php @@ -0,0 +1,530 @@ +@php + $style = \App\Support\Qr\QrStyleDefaults::merge($style ?? null); + $moduleStyles = $moduleStyles ?? \App\Support\Qr\QrModuleStyleCatalog::visible(); + $cornerOuterStyles = $cornerOuterStyles ?? \App\Support\Qr\QrCornerStyleCatalog::outerStyles(); + $cornerInnerStyles = $cornerInnerStyles ?? \App\Support\Qr\QrCornerStyleCatalog::innerStyles(); + $frameStyles = $frameStyles ?? \App\Support\Qr\QrFrameStyleCatalog::visible(); + $qrBits = [1,0,1,0,1, 0,1,1,0,0, 1,1,0,1,1, 0,0,1,0,1, 1,0,1,1,0]; +@endphp + +@include('qr-codes.partials.qr-customizer-script') + +{{-- ── Quick style presets ──────────────────────────────────────────── --}} +
+
+

Quick styles

+
+
+ @foreach([ + [ + 'key' => 'classic', + 'label' => 'Classic', + 'fg' => '#000000', + 'bg' => '#ffffff', + 'outer' => 'square', + 'inner' => 'square', + 'dot' => false, + ], + [ + 'key' => 'rounded', + 'label' => 'Rounded', + 'fg' => '#111827', + 'bg' => '#ffffff', + 'outer' => 'rounded', + 'inner' => 'square', + 'dot' => false, + ], + [ + 'key' => 'branded', + 'label' => 'Branded', + 'fg' => '#1d4ed8', + 'bg' => '#ffffff', + 'outer' => 'rounded', + 'inner' => 'square', + 'dot' => false, + ], + [ + 'key' => 'dots', + 'label' => 'Dots', + 'fg' => '#0f172a', + 'bg' => '#ffffff', + 'outer' => 'square', + 'inner' => 'dot', + 'dot' => true, + ], + ] as $preset) + @php + $outerRadius = match($preset['outer']) { 'rounded' => '3px', 'circle' => '50%', default => '1px' }; + $innerRadius = $preset['inner'] === 'dot' ? '50%' : '1px'; + $dotRadius = $preset['dot'] ? '50%' : '1px'; + @endphp + + @endforeach +
+
+ +
+ + {{-- Pill tab nav --}} +
+
+ @foreach([ + ['id' => 'body', 'label' => 'Body'], + ['id' => 'colors', 'label' => 'Colors'], + ['id' => 'eyes', 'label' => 'Eyes'], + ['id' => 'logo', 'label' => 'Logo'], + ['id' => 'frame', 'label' => 'Frame'], + ] as $tab) + + @endforeach +
+
+ +
+ + {{-- ── Body ─────────────────────────────────────────────────────── --}} +
+

Dot shape

+
+ @foreach($moduleStyles as $key => $meta) + + @endforeach +
+
+ + {{-- ── Colors ───────────────────────────────────────────────────── --}} +
+
+

Quick presets

+
+ @foreach([ + ['fg' => '#000000', 'bg' => '#ffffff', 'label' => 'Classic'], + ['fg' => '#1d4ed8', 'bg' => '#ffffff', 'label' => 'Blue'], + ['fg' => '#047857', 'bg' => '#ffffff', 'label' => 'Green'], + ['fg' => '#7c3aed', 'bg' => '#ffffff', 'label' => 'Purple'], + ['fg' => '#c2410c', 'bg' => '#fff7ed', 'label' => 'Orange'], + ['fg' => '#be185d', 'bg' => '#fdf4ff', 'label' => 'Pink'], + ['fg' => '#0f172a', 'bg' => '#ecfdf5', 'label' => 'Dark teal'], + ] as $preset) + + @endforeach +
+
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+

Gradient

+ +
+ @foreach([['none', 'None'], ['linear', 'Linear'], ['radial', 'Radial']] as [$val, $lbl]) + + @endforeach +
+
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ + +
+
+
+
+ + {{-- ── Eyes ─────────────────────────────────────────────────────── --}} +
+
+

Outer frame

+
+ @foreach([ + ['value' => 'square', 'label' => 'Square', 'shape' => 'h-7 w-7 border-[3px] border-slate-800'], + ['value' => 'rounded', 'label' => 'Rounded', 'shape' => 'h-7 w-7 rounded-[5px] border-[3px] border-slate-800'], + ['value' => 'circle', 'label' => 'Circle', 'shape' => 'h-7 w-7 rounded-full border-[3px] border-slate-800'], + ] as $opt) + + @endforeach +
+
+ +
+

Inner dot

+
+ @foreach([ + ['value' => 'square', 'label' => 'Square', 'shape' => 'h-5 w-5 rounded-[2px] bg-slate-800'], + ['value' => 'rounded', 'label' => 'Rounded', 'shape' => 'h-5 w-5 rounded-[4px] bg-slate-800'], + ['value' => 'dot', 'label' => 'Dot', 'shape' => 'h-5 w-5 rounded-full bg-slate-800'], + ] as $opt) + + @endforeach +
+
+
+ + {{-- ── Logo ─────────────────────────────────────────────────────── --}} +
+
+

Center logo

+

Placed in the center. Error correction is raised automatically when a logo is present.

+
+ + + {{-- Logo options: shown when a logo is present (uploaded or existing) --}} +
+ + {{-- Size --}} +
+ + + +
+ + {{-- Padding --}} +
+ + +
+ + {{-- White background toggle --}} +
+ White background + + +
+ + {{-- Shape --}} +
+

Shape

+
+ {{-- Original: landscape rectangle — logo keeps its natural aspect ratio --}} + + {{-- Rounded: square crop with rounded corners --}} + + {{-- Circle: square crop clipped to circle --}} + +
+ +
+ +
+ + @if(!empty($showRemoveLogo)) + + @endif +
+ + {{-- ── Frame ────────────────────────────────────────────────────── --}} +
+
+

Frame style

+
+ @foreach($frameStyles as $key => $meta) + + @endforeach +
+
+ + {{-- Frame colour --}} +
+ +
+ + +
+
+ + {{-- Frame text -- only for frames that show a CTA label --}} +
+ + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
diff --git a/resources/views/qr-codes/partials/qr-customizer-script.blade.php b/resources/views/qr-codes/partials/qr-customizer-script.blade.php new file mode 100644 index 0000000..d471090 --- /dev/null +++ b/resources/views/qr-codes/partials/qr-customizer-script.blade.php @@ -0,0 +1,669 @@ +@once('qr-customizer-alpine') + +@endonce diff --git a/resources/views/qr-codes/partials/qr-preview-card.blade.php b/resources/views/qr-codes/partials/qr-preview-card.blade.php new file mode 100644 index 0000000..30e2d1f --- /dev/null +++ b/resources/views/qr-codes/partials/qr-preview-card.blade.php @@ -0,0 +1,188 @@ +@php + $showDownloads = $showDownloads ?? true; +@endphp + +
+ + {{-- QR display area with gradient background --}} +
+ + {{-- Spinner overlay --}} +
+ + + + +
+ + {{-- Floating QR card --}} +
+
+ + {{-- Frame wrapper: adds padding + CTA for label/pill frames --}} +
+ {{-- QR code area — SVG renderer only, no static fallback --}} +
+ + {{-- Scan me label --}} +
+ +
+ + {{-- Tap to scan pill --}} +
+ +
+
+ +
+
+
+ + {{-- Scanability score --}} +
+
+ Scan quality + +
+
+
+
+ +
+ + {{-- Download + Share buttons --}} + @if($showDownloads && isset($qrCode)) + @php + $shareUrl = $qrCode->publicUrl(); + $shareEnc = urlencode($shareUrl); + $shareText = urlencode($qrCode->label . ' — scan my QR code'); + @endphp +
+

Download & Share

+
+ + + + + + PNG + + + + + + + SVG + + + + + + + PDF + + + {{-- Share button with popover --}} +
+ + +
+ + + + WhatsApp + + + + + X (Twitter) + + + + + Facebook + + +
+ + +
+
+ +
+
+ @endif + +
diff --git a/resources/views/qr-codes/partials/type-fields-create.blade.php b/resources/views/qr-codes/partials/type-fields-create.blade.php new file mode 100644 index 0000000..5515642 --- /dev/null +++ b/resources/views/qr-codes/partials/type-fields-create.blade.php @@ -0,0 +1,1049 @@ +@php + $ed = $accountEventDefaults ?? []; +@endphp + +{{-- URL --}} +
+ + +
+ +{{-- PDF --}} +
+
+ + +
+
+
+

Allow download

+

Show a Download button on the PDF viewer

+
+ +
+
+ +{{-- List of Links --}} +
+ +
+ +
+ +
+ +{{-- vCard --}} +
+ + {{-- Avatar --}} +
+
+
+ +
+ +
+
+
+
+ + +
+
+ + {{-- Name --}} +
+ + + + + + + + +
+ + {{-- Social links --}} +
+

Social links (optional)

+
+ @foreach([ + 'linkedin' => 'LinkedIn URL', + 'twitter' => 'X / Twitter URL', + 'instagram' => 'Instagram URL', + 'facebook' => 'Facebook URL', + 'tiktok' => 'TikTok URL', + 'youtube' => 'YouTube URL', + 'whatsapp' => 'WhatsApp link or number', + 'snapchat' => 'Snapchat username or URL', + ] as $platform => $placeholder) + + @endforeach +
+
+ +
+ +{{-- Business --}} +
+
+ + +
+ + +
+ + + +
+ + {{-- Branding --}} +
+

Branding (optional)

+
+
+ + +
+ +
+

Square image recommended.

+
+
+ + + +
+ +
+
+
+ + +
+
+
+ + {{-- Social links --}} +
+

Social links (optional)

+
+ @foreach([ + 'linkedin' => 'LinkedIn URL', + 'twitter' => 'X / Twitter URL', + 'instagram' => 'Instagram URL', + 'facebook' => 'Facebook URL', + 'tiktok' => 'TikTok URL', + 'youtube' => 'YouTube URL', + 'whatsapp' => 'WhatsApp link or number', + ] as $platform => $placeholder) + + @endforeach +
+
+
+ +{{-- Images --}} +
+ + +

Select one or more images to display as a gallery.

+
+ +{{-- Give (Church / NGO / School etc.) --}} +@php + $giveOldTypes = old('collection_types', ['Offering','Tithe','Donation','Harvest']); + $giveOldOrg = old('org_type', 'church'); +@endphp +
+ + {{-- Org type selector --}} +
+

Organisation type

+
+ @foreach([ + 'church' => ['Church', 'm21.941,12.493l-8.941-6.502v-1.991h1c.552,0,1-.448,1-1s-.448-1-1-1h-1v-1c0-.552-.448-1-1-1s-1,.448-1,1v1h-1c-.552,0-1,.448-1,1s.448,1,1,1h1v1.991L2.059,12.493c-1.289.938-2.059,2.45-2.059,4.044v3.463c0,2.206,1.794,4,4,4h16c2.206,0,4-1.794,4-4v-3.463c0-1.594-.77-3.106-2.059-4.044Zm-9.941,2.507c-1.654,0-3,1.346-3,3v4h-2v-10.627l5-3.636,5,3.636v10.627h-2v-4c0-1.654-1.346-3-3-3Zm-10,5v-3.463c0-.957.462-1.864,1.236-2.427l1.764-1.283v9.173h-1c-1.103,0-2-.897-2-2Zm9,2v-4c0-.551.449-1,1-1s1,.449,1,1v4h-2Zm11-2c0,1.103-.897,2-2,2h-1v-9.173l1.764,1.283c.774.563,1.236,1.47,1.236,2.427v3.463Z'], + 'school' => ['School', 'm21 6h-6.586l-1.159-1.159 3.745-2.341-4-2.5h-2v4.586l-1.414 1.414h-6.586a3 3 0 0 0 -3 3v15h24v-15a3 3 0 0 0 -3-3zm0 2a1 1 0 0 1 1 1v1h-3.586l-2-2zm-18 0h4.586l-2 2h-3.586v-1a1 1 0 0 1 1-1zm10 14h-2v-3a1 1 0 0 1 2 0zm2 0v-3a3 3 0 0 0 -6 0v3h-7v-10h4.414l5.586-5.586 5.586 5.586h4.414v10zm-11-8h3v2h-3zm0 4h3v2h-3zm13-4h3v2h-3zm0 4h3v2h-3zm-3-6a2 2 0 1 1 -2-2 2 2 0 0 1 2 2z'], + 'mosque' => ['Mosque', 'm21,22h-.949c1.228-1.354,1.949-2.91,1.949-4.39,0-4.285-3.708-6.157-6.415-7.523-.731-.369-1.718-.87-2.234-1.287-.032-.081-.229-.628-.312-1.807,1.193-.067,2.236-.683,2.887-1.599.202-.285-.038-.671-.383-.619-.175.026-.354.04-.537.04-2.111,0-3.822-1.711-3.822-3.822,0-.183.014-.362.04-.537.052-.345-.334-.585-.619-.383-1.051.747-1.708,2.009-1.593,3.422.106,1.301.884,2.414,1.976,3.019-.053,1.624-.31,2.248-.31,2.248l.008.007c-.505.422-1.524.939-2.272,1.316-2.707,1.367-6.415,3.239-6.415,7.523,0,1.48.721,3.036,1.949,4.39h-.949c-.553,0-1,.448-1,1s.447,1,1,1h18c.553,0,1-.448,1-1s-.447-1-1-1Zm-17-4.39c0-3.054,2.824-4.48,5.316-5.738,1.039-.524,2.003-1.011,2.684-1.597.681.586,1.645,1.072,2.684,1.597,2.492,1.258,5.316,2.684,5.316,5.738,0,1.457-1.171,3.169-2.987,4.39H6.987c-1.816-1.221-2.987-2.932-2.987-4.39Z'], + 'ngo' => ['NGO', 'm16,10v9c0,2.757-2.243,5-5,5h-6c-2.757,0-5-2.243-5-5L.007,4.872C.007,2.501,1.916.135,3.935.002c.538-.029,1.027.381,1.063.933.036.551-.381,1.027-.933,1.063-.899.059-2.059,1.463-2.059,2.874l-.007,14.128c0,1.654,1.346,3,3,3h6c1.654,0,3-1.346,3-3v-9c0-.553.448-1,1-1s1,.447,1,1Zm-12,1c0,.553.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1h-1c-.552,0-1,.447-1,1Zm7-1h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm-5,4h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm5,0h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm-5,4h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm5,0h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1ZM21.5,0c-1.381,0-2.5,1.119-2.5,2.5v3c0,1.381,1.119,2.5,2.5,2.5s2.5-1.119,2.5-2.5v-3c0-1.381-1.119-2.5-2.5-2.5Zm.9,5.5c0,.496-.404.9-.9.9s-.9-.404-.9-.9v-3c0-.496.404-.9.9-.9s.9.404.9.9v3Zm-6.9-1.25h0c0,.414.336.75.75.75h.15v.468c0,.479-.361.898-.838.93-.524.035-.962-.381-.962-.898v-2.971c0-.434.296-.825.722-.911.283-.057.535.025.726.184.147.123.328.198.519.198h.062c.721,0,1.068-.894.528-1.372C16.711.233,16.124-.005,15.481,0c-1.383.01-2.481,1.175-2.481,2.558v2.886c0,1.402,1.147,2.582,2.548,2.556,1.358-.026,2.452-1.135,2.452-2.5v-1.25c0-.414-.336-.75-.75-.75h-1c-.414,0-.75.336-.75.75ZM10.4.8v3.263l-1.545-3.486C8.7.226,8.353,0,7.969,0h0C7.434,0,7,.434,7,.969v6.231c0,.442.358.8.8.8h0c.442,0,.8-.358.8-.8v-3.254l1.544,3.478c.156.35.503.576.886.576h0c.535,0,.97-.434.97-.97V.8C12,.358,11.642,0,11.2,0h0C10.758,0,10.4.358,10.4.8Z'], + 'club' => ['Club', 'm13.5,12c0,2.206,1.794,4,4,4s4-1.794,4-4-1.794-4-4-4-4,1.794-4,4Zm4-2c1.103,0,2,.897,2,2s-.897,2-2,2-2-.897-2-2,.897-2,2-2Zm-5.5-2c2.206,0,4-1.794,4-4S14.206,0,12,0s-4,1.794-4,4,1.794,4,4,4Zm0-6c1.103,0,2,.897,2,2s-.897,2-2,2-2-.897-2-2,.897-2,2-2Zm-5.5,14c2.206,0,4-1.794,4-4s-1.794-4-4-4-4,1.794-4,4,1.794,4,4,4Zm0-6c1.103,0,2,.897,2,2s-.897,2-2,2-2-.897-2-2,.897-2,2-2Zm17.471,12.91c.05.55-.356,1.036-.906,1.086-.03.002-.061.004-.091.004-.512,0-.948-.391-.995-.91-.11-1.219-.737-2.355-1.721-3.118-.025-.02-.107-.011-.16.049l-1.841,2.133c-.189.22-.485.389-.757.347-.288,0-.562-.124-.752-.34l-1.865-2.126c-.051-.059-.133-.07-.16-.048-.986.774-1.591,1.876-1.701,3.104-.043.476-.413.82-.873.881-.029.005-.054.022-.084.025-.031.002-.061.004-.091.004-.01,0-.018-.005-.027-.005-.004,0-.007.002-.011.001-.011,0-.02-.008-.031-.009-.481-.034-.882-.402-.926-.897-.111-1.237-.722-2.344-1.72-3.118-.029-.021-.11-.011-.16.049l-1.841,2.133c-.19.221-.468.349-.757.347-.288,0-.562-.124-.752-.341l-1.865-2.126c-.052-.058-.134-.069-.16-.047-.987.774-1.592,1.876-1.702,3.104-.049.55-.528.948-1.086.906-.55-.05-.956-.536-.906-1.086.158-1.757,1.055-3.396,2.46-4.499.876-.686,2.148-.552,2.897.303l1.107,1.262,1.09-1.263c.745-.865,2.019-1.007,2.9-.322.608.471,1.115,1.047,1.516,1.685.396-.629.893-1.197,1.489-1.665.874-.685,2.148-.553,2.897.304l1.107,1.261,1.09-1.263c.745-.865,2.02-1.005,2.899-.323,1.421,1.102,2.327,2.749,2.487,4.519Z'], + ] as $orgKey => [$orgLabel, $iconPath]) + + @endforeach +
+
+ + {{-- Hidden org_type + all selected collection types --}} + + + + {{-- Core fields --}} +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+
+ + {{-- Branding --}} +
+

Branding (optional)

+
+
+ + +
+ +
+
+
+ + + +
+ +
+
+
+ + +
+
+
+ + {{-- Online Giving --}} +
+

Online Giving

+ + + {{-- Collection type manager --}} +
+

Collection types (shown as giving options)

+ + {{-- Current types as removable tags --}} +
+ +
+ + {{-- Presets from selected org type --}} +
+

Add from preset

+
+ +
+
+ + {{-- Add custom --}} +
+ + +
+
+
+
+ +{{-- Event --}} +
+
+ + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + {{-- Branding: optional logo + cover + color --}} +
+

Branding (optional)

+
+
+ + +
+
+
+ + + +
+
+
+ + +
+
+
+ + {{-- Mode: sell tickets, or collect cash contributions --}} + +
+

What is this event collecting?

+
+ + + +
+
+ + {{-- Ticket tiers --}} +
+

Ticket types (price 0 = free)

+
+ +
+ +
+ + {{-- Contribution categories --}} +
+

Contribution categories (what supporters can give towards)

+
+ +
+ +

Supporters choose a category and enter any cash amount to give.

+
+ + {{-- Badge fields --}} +
+

Extra registration fields (shown on badge — e.g. Company, Role)

+
+ + +
+
+ + {{-- Badge size + registration toggle --}} +
+
+ + +
+ +
+
+ +{{-- Itinerary --}} +
+
+ + +
+
+ + +
+ +
+ +
+ + {{-- Cover + brand color (no logo) --}} +
+
+ + + +
+ +
+
+
+ + +
+
+ + {{-- Programme builder --}} +
+

Programme

+ + +
+
+ +{{-- WiFi --}} +
+ + + + +
+ +{{-- App --}} +
+ +
+ +
+
+ +
+ +
+

Use a square image. Displayed as a rounded icon on the landing page.

+
+ + + +
+ +{{-- Book --}} +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +

Max 100 MB

+
+
+ + + +
+ +
+
+
+
+ +{{-- Menu --}} +
+ + {{-- Branding --}} +
+

Branding (optional)

+
+
+ + +
+ +
+

Use a square image for best results.

+
+
+ + + +
+ +
+
+
+ + +
+
+
+ + + + + + {{-- Delivery --}} +
+ + +
+
+ + +
+
+ + +

Waive the delivery fee when the cart reaches this amount. Enter 0 to always charge.

+
+
+
+ + + + +
+ +{{-- Shop --}} +
+ + {{-- Branding --}} +
+

Branding (optional)

+
+
+ + +
+ +
+

Use a square image for best results.

+
+
+ + + +
+ +
+
+
+ + +
+
+
+ +
+ + +
+ + + + {{-- Shipping --}} +
+ + +
+
+ + +
+
+ + +

Waive shipping when the cart total reaches this amount. Enter 0 to always charge.

+
+
+
+ + + + +
diff --git a/resources/views/qr-codes/partials/type-fields-edit.blade.php b/resources/views/qr-codes/partials/type-fields-edit.blade.php new file mode 100644 index 0000000..716b8c7 --- /dev/null +++ b/resources/views/qr-codes/partials/type-fields-edit.blade.php @@ -0,0 +1,1157 @@ +@php + $c = $qrCode->content(); + $style = $qrCode->style(); +@endphp + +
+ @if($qrCode->isUrlType()) +
+ + +
+ + @elseif($qrCode->isDocumentType()) +
+
+ + +
+
+
+

Allow download

+

Show a Download button on the PDF viewer

+
+ +
+
+ + @elseif($qrCode->type === \App\Models\QrCode::TYPE_LINK_LIST) + +
+ @foreach($c['links'] ?? [] as $i => $link) +
+ + +
+ @endforeach +
+ + @elseif($qrCode->type === \App\Models\QrCode::TYPE_VCARD) + @php $social = $c['social'] ?? []; @endphp +
+ + {{-- Avatar --}} +
+
+
+ + @if(!empty($c['avatar_path'])) + + @else +
+ +
+ @endif +
+
+
+ + +
+
+ + {{-- Name + contact --}} +
+ + + + + + + + +
+ + {{-- Social links --}} +
+

Social links (optional)

+
+ @foreach([ + 'linkedin' => 'LinkedIn URL', + 'twitter' => 'X / Twitter URL', + 'instagram' => 'Instagram URL', + 'facebook' => 'Facebook URL', + 'tiktok' => 'TikTok URL', + 'youtube' => 'YouTube URL', + 'whatsapp' => 'WhatsApp link or number', + 'snapchat' => 'Snapchat username or URL', + ] as $platform => $placeholder) + + @endforeach +
+
+ +
+ + @elseif($qrCode->type === \App\Models\QrCode::TYPE_BUSINESS) + @php $bizSocial = $c['social'] ?? []; @endphp +
+
+ + +
+ + +
+ + + +
+ + {{-- Branding --}} +
+

Branding (optional)

+
+
+ + +
+ + @if(!empty($c['logo_path'])) + Current logo + @endif +
+
+
+ + + +
+ + @if(!empty($c['cover_path'])) + Current cover + @endif +
+
+
+ + +
+
+
+ + {{-- Social links --}} +
+

Social links (optional)

+
+ @foreach([ + 'linkedin' => 'LinkedIn URL', + 'twitter' => 'X / Twitter URL', + 'instagram' => 'Instagram URL', + 'facebook' => 'Facebook URL', + 'tiktok' => 'TikTok URL', + 'youtube' => 'YouTube URL', + 'whatsapp' => 'WhatsApp link or number', + ] as $platform => $placeholder) + + @endforeach +
+
+
+ + @elseif($qrCode->isImageType()) +
+ + + @if(!empty($c['images'])) +

{{ count($c['images']) }} image(s) currently uploaded. Adding new images appends them.

+ @endif +
+ + @elseif($qrCode->type === \App\Models\QrCode::TYPE_CHURCH) + @php + $editOrgType = $c['org_type'] ?? 'church'; + $editOrgTypes = ['church','school','mosque','ngo','club']; + $editOrgLabels = ['church'=>'Church','school'=>'School','mosque'=>'Mosque','ngo'=>'NGO','club'=>'Club']; + $editTypes = array_values(array_filter($c['collection_types'] ?? ['Offering','Tithe','Donation','Harvest'])); + @endphp +
+ + {{-- Org type selector --}} +
+

Organisation type

+
+ @foreach([ + 'church' => ['Church', 'm21.941,12.493l-8.941-6.502v-1.991h1c.552,0,1-.448,1-1s-.448-1-1-1h-1v-1c0-.552-.448-1-1-1s-1,.448-1,1v1h-1c-.552,0-1,.448-1,1s.448,1,1,1h1v1.991L2.059,12.493c-1.289.938-2.059,2.45-2.059,4.044v3.463c0,2.206,1.794,4,4,4h16c2.206,0,4-1.794,4-4v-3.463c0-1.594-.77-3.106-2.059-4.044Zm-9.941,2.507c-1.654,0-3,1.346-3,3v4h-2v-10.627l5-3.636,5,3.636v10.627h-2v-4c0-1.654-1.346-3-3-3Zm-10,5v-3.463c0-.957.462-1.864,1.236-2.427l1.764-1.283v9.173h-1c-1.103,0-2-.897-2-2Zm9,2v-4c0-.551.449-1,1-1s1,.449,1,1v4h-2Zm11-2c0,1.103-.897,2-2,2h-1v-9.173l1.764,1.283c.774.563,1.236,1.47,1.236,2.427v3.463Z'], + 'school' => ['School', 'm21 6h-6.586l-1.159-1.159 3.745-2.341-4-2.5h-2v4.586l-1.414 1.414h-6.586a3 3 0 0 0 -3 3v15h24v-15a3 3 0 0 0 -3-3zm0 2a1 1 0 0 1 1 1v1h-3.586l-2-2zm-18 0h4.586l-2 2h-3.586v-1a1 1 0 0 1 1-1zm10 14h-2v-3a1 1 0 0 1 2 0zm2 0v-3a3 3 0 0 0 -6 0v3h-7v-10h4.414l5.586-5.586 5.586 5.586h4.414v10zm-11-8h3v2h-3zm0 4h3v2h-3zm13-4h3v2h-3zm0 4h3v2h-3zm-3-6a2 2 0 1 1 -2-2 2 2 0 0 1 2 2z'], + 'mosque' => ['Mosque', 'm21,22h-.949c1.228-1.354,1.949-2.91,1.949-4.39,0-4.285-3.708-6.157-6.415-7.523-.731-.369-1.718-.87-2.234-1.287-.032-.081-.229-.628-.312-1.807,1.193-.067,2.236-.683,2.887-1.599.202-.285-.038-.671-.383-.619-.175.026-.354.04-.537.04-2.111,0-3.822-1.711-3.822-3.822,0-.183.014-.362.04-.537.052-.345-.334-.585-.619-.383-1.051.747-1.708,2.009-1.593,3.422.106,1.301.884,2.414,1.976,3.019-.053,1.624-.31,2.248-.31,2.248l.008.007c-.505.422-1.524.939-2.272,1.316-2.707,1.367-6.415,3.239-6.415,7.523,0,1.48.721,3.036,1.949,4.39h-.949c-.553,0-1,.448-1,1s.447,1,1,1h18c.553,0,1-.448,1-1s-.447-1-1-1Zm-17-4.39c0-3.054,2.824-4.48,5.316-5.738,1.039-.524,2.003-1.011,2.684-1.597.681.586,1.645,1.072,2.684,1.597,2.492,1.258,5.316,2.684,5.316,5.738,0,1.457-1.171,3.169-2.987,4.39H6.987c-1.816-1.221-2.987-2.932-2.987-4.39Z'], + 'ngo' => ['NGO', 'm16,10v9c0,2.757-2.243,5-5,5h-6c-2.757,0-5-2.243-5-5L.007,4.872C.007,2.501,1.916.135,3.935.002c.538-.029,1.027.381,1.063.933.036.551-.381,1.027-.933,1.063-.899.059-2.059,1.463-2.059,2.874l-.007,14.128c0,1.654,1.346,3,3,3h6c1.654,0,3-1.346,3-3v-9c0-.553.448-1,1-1s1,.447,1,1Zm-12,1c0,.553.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1h-1c-.552,0-1,.447-1,1Zm7-1h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm-5,4h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm5,0h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm-5,4h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm5,0h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1ZM21.5,0c-1.381,0-2.5,1.119-2.5,2.5v3c0,1.381,1.119,2.5,2.5,2.5s2.5-1.119,2.5-2.5v-3c0-1.381-1.119-2.5-2.5-2.5Zm.9,5.5c0,.496-.404.9-.9.9s-.9-.404-.9-.9v-3c0-.496.404-.9.9-.9s.9.404.9.9v3Zm-6.9-1.25h0c0,.414.336.75.75.75h.15v.468c0,.479-.361.898-.838.93-.524.035-.962-.381-.962-.898v-2.971c0-.434.296-.825.722-.911.283-.057.535.025.726.184.147.123.328.198.519.198h.062c.721,0,1.068-.894.528-1.372C16.711.233,16.124-.005,15.481,0c-1.383.01-2.481,1.175-2.481,2.558v2.886c0,1.402,1.147,2.582,2.548,2.556,1.358-.026,2.452-1.135,2.452-2.5v-1.25c0-.414-.336-.75-.75-.75h-1c-.414,0-.75.336-.75.75ZM10.4.8v3.263l-1.545-3.486C8.7.226,8.353,0,7.969,0h0C7.434,0,7,.434,7,.969v6.231c0,.442.358.8.8.8h0c.442,0,.8-.358.8-.8v-3.254l1.544,3.478c.156.35.503.576.886.576h0c.535,0,.97-.434.97-.97V.8C12,.358,11.642,0,11.2,0h0C10.758,0,10.4.358,10.4.8Z'], + 'club' => ['Club', 'm13.5,12c0,2.206,1.794,4,4,4s4-1.794,4-4-1.794-4-4-4-4,1.794-4,4Zm4-2c1.103,0,2,.897,2,2s-.897,2-2,2-2-.897-2-2,.897-2,2-2Zm-5.5-2c2.206,0,4-1.794,4-4S14.206,0,12,0s-4,1.794-4,4,1.794,4,4,4Zm0-6c1.103,0,2,.897,2,2s-.897,2-2,2-2-.897-2-2,.897-2,2-2Zm-5.5,14c2.206,0,4-1.794,4-4s-1.794-4-4-4-4,1.794-4,4,1.794,4,4,4Zm0-6c1.103,0,2,.897,2,2s-.897,2-2,2-2-.897-2-2,.897-2,2-2Zm17.471,12.91c.05.55-.356,1.036-.906,1.086-.03.002-.061.004-.091.004-.512,0-.948-.391-.995-.91-.11-1.219-.737-2.355-1.721-3.118-.025-.02-.107-.011-.16.049l-1.841,2.133c-.189.22-.485.389-.757.347-.288,0-.562-.124-.752-.34l-1.865-2.126c-.051-.059-.133-.07-.16-.048-.986.774-1.591,1.876-1.701,3.104-.043.476-.413.82-.873.881-.029.005-.054.022-.084.025-.031.002-.061.004-.091.004-.01,0-.018-.005-.027-.005-.004,0-.007.002-.011.001-.011,0-.02-.008-.031-.009-.481-.034-.882-.402-.926-.897-.111-1.237-.722-2.344-1.72-3.118-.029-.021-.11-.011-.16.049l-1.841,2.133c-.19.221-.468.349-.757.347-.288,0-.562-.124-.752-.341l-1.865-2.126c-.052-.058-.134-.069-.16-.047-.987.774-1.592,1.876-1.702,3.104-.049.55-.528.948-1.086.906-.55-.05-.956-.536-.906-1.086.158-1.757,1.055-3.396,2.46-4.499.876-.686,2.148-.552,2.897.303l1.107,1.262,1.09-1.263c.745-.865,2.019-1.007,2.9-.322.608.471,1.115,1.047,1.516,1.685.396-.629.893-1.197,1.489-1.665.874-.685,2.148-.553,2.897.304l1.107,1.261,1.09-1.263c.745-.865,2.02-1.005,2.899-.323,1.421,1.102,2.327,2.749,2.487,4.519Z'], + ] as $orgKey => [$orgLabel, $iconPath]) + + @endforeach +
+
+ + {{-- Hidden org_type + collection types --}} + + + + {{-- Core fields --}} +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+
+ + {{-- Branding --}} +
+

Branding (optional)

+
+
+ + +
+ + @if(!empty($c['logo_path'])) + Current logo + @endif +
+
+
+ + + +
+ + @if(!empty($c['cover_path'])) + Current cover + @endif +
+
+
+ + +
+
+
+ + {{-- Online Giving --}} +
+

Online Giving

+ + +
+

Collection types

+
+ +
+
+

Add from preset

+
+ +
+
+
+ + +
+
+
+
+ + @elseif($qrCode->type === \App\Models\QrCode::TYPE_EVENT) + @php + $evTiers = $c['tiers'] ?? [['name' => 'General Admission', 'price' => '', 'capacity' => '']]; + $evBadgeFields = array_values($c['badge_fields'] ?? []); + $evMode = in_array($c['mode'] ?? 'ticketing', ['contributions', 'free'], true) ? ($c['mode'] ?? 'ticketing') : 'ticketing'; + $evCategories = array_values($c['contribution_categories'] ?? ['Contribution']); + @endphp +
+
+ + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + {{-- Branding --}} +
+

Branding (optional)

+
+
+ + +
+ + @if(!empty($c['logo_path']))Current logo@endif +
+
+
+ + + +
+ + @if(!empty($c['cover_path']))Current cover@endif +
+
+
+ + +
+
+
+ + {{-- Mode: sell tickets, or collect cash contributions --}} + +
+

What is this event collecting?

+
+ + + +
+
+ + {{-- Ticket tiers --}} +
+

Ticket types (price 0 = free)

+
+ +
+ +
+ + {{-- Contribution categories --}} +
+

Contribution categories (what supporters can give towards)

+
+ +
+ +

Supporters choose a category and enter any cash amount to give.

+
+ + {{-- Badge fields --}} +
+

Extra registration fields (shown on badge)

+
+ + +
+
+ + {{-- Badge size + registration toggle --}} +
+
+ + +
+ +
+ + {{-- Programme outline --}} + @php $itineraryOptions = $itineraryOptions ?? collect(); @endphp +
+ + @if($itineraryOptions->isEmpty()) +

+ Create an Itinerary QR code first to attach it here as the event programme. +

+ @else + +

Attendees can be sent this programme from the attendees page.

+ @endif +
+ + @if (Route::has('events.attendees')) +
+ + + Manage attendees + + @if (Route::has('events.badge-printing') && ($c['mode'] ?? 'ticketing') !== 'contributions') + + + Print badges + + @endif +
+ @endif +
+ + @elseif($qrCode->type === \App\Models\QrCode::TYPE_ITINERARY) + @php + $itinDays = $c['days'] ?? [['label' => 'Day 1', 'date' => '', 'items' => [['time' => '', 'title' => '', 'description' => '', 'location' => '', 'host' => '']]]]; + foreach ($itinDays as &$itinDay) { + $itinDay['date'] = \App\Support\Qr\QrDateFormatter::forInput($itinDay['date'] ?? null); + } + unset($itinDay); + @endphp +
+
+ + +
+
+ + +
+ +
+ +
+ + {{-- Cover + brand color --}} +
+
+ + + +
+ + @if(!empty($c['cover_path'])) + Current cover + @endif +
+
+
+ + +
+
+ + {{-- Programme builder --}} +
+

Programme

+ + +
+
+ + @elseif($qrCode->type === \App\Models\QrCode::TYPE_WIFI) +
+ + + + +
+ + @elseif($qrCode->type === \App\Models\QrCode::TYPE_APP) +
+ +
+ +
+
+ + @if(!empty($c['icon_path'])) + Current icon + @else +
+ + + +
+ @endif +
+ +
+

Use a square image. Displayed as a rounded icon on the landing page.

+
+ + + +
+ + @elseif($qrCode->type === \App\Models\QrCode::TYPE_BOOK) +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + + @if(!empty($c['file_path'])) +

Current: {{ strtoupper($c['file_type'] ?? 'file') }} · {{ number_format(($c['file_size'] ?? 0) / 1048576, 1) }} MB

+ @endif +
+
+ + + +
+ +
+ @if(!empty($c['cover_path'])) +
+

Current cover:

+ Current cover +
+ @endif +
+
+
+ + @elseif($qrCode->type === \App\Models\QrCode::TYPE_MENU) + {{-- Branding --}} +
+

Branding (optional)

+
+
+ + +
+ +
+

Use a square image for best results.

+ @if(!empty($c['logo_path'])) +
+ Current logo +
+ @endif +
+
+ + + +
+ +
+ @if(!empty($c['cover_path'])) +
+ Current cover +
+ @endif +
+
+ + +
+
+
+ + + + + + {{-- Delivery --}} +
+ + +
+
+ + +
+
+ + +

Waive the delivery fee when the cart reaches this amount. Enter 0 to always charge.

+
+
+
+ +
+ + + +
+ + @elseif($qrCode->type === \App\Models\QrCode::TYPE_SHOP) + {{-- Branding --}} +
+

Branding (optional)

+
+
+ + +
+ +
+

Use a square image for best results.

+ @if(!empty($c['logo_path'])) +
+ Current logo +
+ @endif +
+
+ + + +
+ +
+ @if(!empty($c['cover_path'])) +
+ Current cover +
+ @endif +
+
+ + +
+
+
+ +
+ + +
+ + + + {{-- Shipping --}} +
+ + +
+
+ + +
+
+ + +

Waive shipping when the cart total reaches this amount. Enter 0 to always charge.

+
+
+
+ +
+ + + +
+ + @endif +
diff --git a/resources/views/qr-codes/show.blade.php b/resources/views/qr-codes/show.blade.php new file mode 100644 index 0000000..1b27a1f --- /dev/null +++ b/resources/views/qr-codes/show.blade.php @@ -0,0 +1,343 @@ + + {{ $qrCode->label }} + + @php + $qrStyle = $qrCode->style(); + @endphp + +
+ + @foreach(['success', 'error'] as $flash) + @if(session($flash)) +
+ {{ session($flash) }} +
+ @endif + @endforeach + + {{-- Mobile page header --}} +
+ + + + + + {{ $qrCode->label }} +
+ + {{-- Desktop page header --}} + + + {{-- Main 2-column layout --}} +
+ @csrf + @method('PATCH') + + {{-- Left: Sticky preview + downloads (desktop only) --}} + + + {{-- Right: Controls --}} +
+ + {{-- Content first --}} +
+
+

Content

+

+ @if($qrCode->type === \App\Models\QrCode::TYPE_WIFI) + Auto-join is locked to the network set when this code was created. Editing updates your saved info but won't change the printed code. + @else + Edits are free. The printed QR stays the same. + @endif +

+
+
+
+ + +
+ @include('qr-codes.partials.type-fields-edit') + +
+
+ + {{-- Customizer --}} + @include('qr-codes.partials.customization-fields', [ + 'style' => $qrStyle, + 'moduleStyles' => $moduleStyles, + 'cornerOuterStyles' => $cornerOuterStyles, + 'cornerInnerStyles' => $cornerInnerStyles, + 'frameStyles' => $frameStyles, + 'showRemoveLogo' => ! empty($qrStyle['logo_path']), + ]) + + {{-- Desktop save button --}} + + +
+
+ + {{-- Mobile sticky action bar --}} +
+
+ + +
+ +
+ + {{-- Preview modal --}} +
+
+ Preview + +
+
+
+
+
+
+ +
+
+ +
+
+
+
+ {{-- Downloads pinned to bottom of modal --}} + @php + $mobileShareUrl = $qrCode->publicUrl(); + $mobileShareText = urlencode($qrCode->label . ' — scan my QR code'); + $mobileShareEnc = urlencode($mobileShareUrl); + @endphp +
+ + PNG + + + SVG + + + PDF + + +
+
+ + {{-- Orders (Book / Menu / Shop) --}} + @if(isset($orders) && !is_null($orders)) +
+
+

Orders

+ {{ $orders->count() }} paid +
+ +
+
+

{{ $orders->count() }}

+

Total orders

+
+
+

GHS {{ number_format((float) $orders->sum('amount_ghs'), 2) }}

+

Revenue earned

+
+
+ + @if($orders->isEmpty()) +
+

No paid orders yet.

+
+ @else +
+ + + + + + + + + + + @foreach($orders as $order) + + + + + + + @endforeach + +
CustomerAmountDate
+

{{ $order->customer_name ?: '—' }}

+

{{ $order->customer_email ?: '' }}

+
+ GHS {{ number_format((float) $order->amount_ghs, 2) }} + + {{ $order->paid_at?->format('M j, Y') }} +
+
+ @endif +
+ @endif + + {{-- Scan analytics --}} +
+

Analytics

+ +
+
+

{{ number_format($summary['total_scans']) }}

+

Total scans

+
+
+

{{ number_format($summary['unique_scans']) }}

+

Unique scans

+
+
+

{{ number_format($summary['scans_7d']) }}

+

Last 7 days

+
+
+

{{ number_format($summary['scans_30d']) }}

+

Last 30 days

+
+
+ +
+

Scans — last 30 days

+ @php $maxDaily = max(1, $dailyScans->max('total')); @endphp +
+ @foreach($dailyScans as $day) +
+
+
+ @endforeach +
+
+ +
+
+

Devices

+
    + @forelse($devices as $row) +
  • + {{ ucfirst($row['label']) }} + {{ $row['total'] }} +
  • + @empty +
  • No scans yet
  • + @endforelse +
+
+
+

Browsers

+
    + @forelse($browsers as $row) +
  • + {{ $row['label'] }} + {{ $row['total'] }} +
  • + @empty +
  • No scans yet
  • + @endforelse +
+
+
+
+ +
+
diff --git a/resources/views/qr/account/billing.blade.php b/resources/views/qr/account/billing.blade.php new file mode 100644 index 0000000..e29d615 --- /dev/null +++ b/resources/views/qr/account/billing.blade.php @@ -0,0 +1,13 @@ + + Billing + @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp +
+

Billing

+

QR code creation is billed from your Ladill wallet (GHS {{ number_format(config('qr.price_per_qr_ghs', 5), 2) }} per code).

+
+

Current balance

+

{{ $fmt($balanceMinor) }}

+ Top up wallet +
+
+
diff --git a/resources/views/qr/account/developers.blade.php b/resources/views/qr/account/developers.blade.php new file mode 100644 index 0000000..d506b9a --- /dev/null +++ b/resources/views/qr/account/developers.blade.php @@ -0,0 +1,87 @@ + + Developers +
+

Developers

+

API tokens to manage your QR codes programmatically.

+ + @if($newToken) +
+

Your new token — copy it now

+

This is the only time it will be shown.

+
+ {{ $newToken }} + +
+
+ @endif + +
+

Create a token

+
+ @csrf +
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+
+ +
+

Your tokens

+ @forelse($tokens as $token) +
+
+

{{ $token->name }}

+

+ Created {{ $token->created_at->diffForHumans() }} · + {{ $token->last_used_at ? 'last used '.$token->last_used_at->diffForHumans() : 'never used' }} +

+
+ + + + + +
+ @empty +

No tokens yet.

+ @endforelse +
+ +
+

Quick start

+

Authenticate with a Bearer token. Base URL:

+ {{ $apiBase }} +
curl {{ $apiBase }}/qr-codes \
+  -H "Authorization: Bearer <your-token>" \
+  -H "Accept: application/json"
+

Endpoints:

+
    +
  • GET /me — token user and acting account
  • +
  • GET /qr-codes — list your codes
  • +
  • GET /qr-codes/{id} — code details
  • +
  • GET /qr-codes/{id}/analytics — scan stats
  • +
  • POST /qr-codes — create (url, link_list, wifi, business, app)
  • +
  • PATCH /qr-codes/{id} — update label, content, pause/resume
  • +
+
curl -X POST {{ $apiBase }}/qr-codes \
+  -H "Authorization: Bearer <your-token>" \
+  -H "Content-Type: application/json" \
+  -d '{"label":"Homepage","type":"url","destination_url":"https://example.com"}'
+

Team access: pass X-Ladill-Account: <owner-user-id>. PDF codes need the web app. Regenerate tokens to get write access if yours only has read.

+
+
+
diff --git a/resources/views/qr/account/settings.blade.php b/resources/views/qr/account/settings.blade.php new file mode 100644 index 0000000..ace0e9f --- /dev/null +++ b/resources/views/qr/account/settings.blade.php @@ -0,0 +1,260 @@ + + Settings + + @php + $ed = $eventDefaults; + $badgeFieldLabels = old('event_defaults.badge_fields', $ed['badge_fields'] ?? ['Company', 'Role']); + @endphp + +
+

Settings

+

Notifications, defaults for new events, and QR preferences.

+ + @if (session('success')) +
{{ session('success') }}
+ @endif + +
+ @csrf + @method('PUT') + +
+

Notifications

+

Choose what we email you about your events and account.

+ +
+ + + @error('notify_email')

{{ $message }}

@enderror +
+ + + + + + + + +
+ +
+

New event defaults

+

Pre-fill the create flow so every new event starts with your usual setup.

+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +

Extra attendee fields shown on badges (e.g. Company, Role).

+
+ +
+ +
+ + +
+ +
+ +
+
+

QR code defaults

+

Style applied when you design event QR codes.

+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+ +

+ Profile, password, and security are managed on + account.ladill.com. +

+
+
diff --git a/resources/views/qr/account/team.blade.php b/resources/views/qr/account/team.blade.php new file mode 100644 index 0000000..cd3ec45 --- /dev/null +++ b/resources/views/qr/account/team.blade.php @@ -0,0 +1,92 @@ + + Team +
+

Team

+

Invite people to help manage this account’s QR codes.

+ + @if($canManage) +
+

Invite a teammate

+
+ @csrf +
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + +
+ +
+

Admins can manage QR codes and the team. Members can manage QR codes. Invitees join by signing in with that email.

+
+ @endif + +
+

Members

+
    +
  • +
    + {{ strtoupper(substr($account->name ?? $account->email, 0, 1)) }} +
    +

    + {{ $account->name ?? $account->email }} + @if($isOwner)(you)@endif +

    +

    {{ $account->email }}

    +
    +
    + Owner +
  • + + @forelse($members as $member) +
  • +
    + {{ strtoupper(substr($member->email, 0, 1)) }} +
    +

    {{ $member->member->name ?? $member->email }}

    +

    {{ $member->email }}

    +
    +
    +
    + @if($member->status === 'invited') + Invited + @endif + @if($canManage) +
    + @csrf @method('PATCH') + +
    + + + + + + @else + {{ $member->role }} + @endif +
    +
  • + @empty +
  • No teammates yet.
  • + @endforelse +
+
+
+
diff --git a/resources/views/qr/account/wallet.blade.php b/resources/views/qr/account/wallet.blade.php new file mode 100644 index 0000000..cc2074a --- /dev/null +++ b/resources/views/qr/account/wallet.blade.php @@ -0,0 +1,23 @@ + + Wallet + @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp +
+

Wallet

+

Your Ladill wallet funds QR Plus and other Ladill apps.

+
+

Balance

+

{{ $fmt($balanceMinor) }}

+ Add funds +
+
+
+

Spent on QR Plus

+

{{ $fmt($spentMinor) }}

+
+
+

Refunded / credited

+

{{ $fmt($creditedMinor) }}

+
+
+
+
diff --git a/resources/views/qr/dashboard.blade.php b/resources/views/qr/dashboard.blade.php new file mode 100644 index 0000000..68ed5a4 --- /dev/null +++ b/resources/views/qr/dashboard.blade.php @@ -0,0 +1,45 @@ + + Overview + @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp +
+
+

QR Plus

+

Dynamic utility QR codes — links, WiFi, business pages & more.

+
+
+
+

Wallet balance

+

{{ $fmt($balanceMinor) }}

+
+
+

Active codes

+

{{ $activeCount }}

+
+
+

Scans (30d)

+

{{ number_format($scans30d) }}

+
+
+
+
+

Recent codes

+ Create code +
+ @if($recentCodes->isEmpty()) +

No QR codes yet. Create your first code.

+ @else + + @endif +
+
+
diff --git a/resources/views/qr/signed-out.blade.php b/resources/views/qr/signed-out.blade.php new file mode 100644 index 0000000..b73841b --- /dev/null +++ b/resources/views/qr/signed-out.blade.php @@ -0,0 +1,15 @@ + + + + + + Signed out — Ladill Events + @include('partials.favicon') + + +
+

You have been signed out of Ladill Events.

+ Sign in again +
+ + diff --git a/resources/views/search/index.blade.php b/resources/views/search/index.blade.php new file mode 100644 index 0000000..48b5c11 --- /dev/null +++ b/resources/views/search/index.blade.php @@ -0,0 +1,13 @@ + + Search + + @include('partials.search-screen', [ + 'query' => $query, + 'results' => $results, + 'backUrl' => route('mini.dashboard'), + 'searchUrl' => route('mini.search'), + 'heading' => 'Find payments', + 'placeholder' => 'Search payer, note, reference, QR…', + 'emptyHint' => 'Use at least 2 characters to search payer names, notes, references, or QR labels.', + ]) + diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php new file mode 100644 index 0000000..d92ac19 --- /dev/null +++ b/resources/views/welcome.blade.php @@ -0,0 +1,278 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + @include('partials.favicon') + + + + + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
+ @if (Route::has('login')) + + @endif +
+
+
+
+

Let's get started

+

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

+ + +
+
+ {{-- Laravel Logo --}} + + + + + + + + + + + {{-- Light Mode 12 SVG --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{-- Dark Mode 12 SVG --}} + +
+
+
+
+ + @if (Route::has('login')) + + @endif + + diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..c546bca --- /dev/null +++ b/routes/api.php @@ -0,0 +1,76 @@ +group(function () { + // Mobile token auth (Ladill Mini Android). Public — proxies to the central + // Ladill identity API and issues a Sanctum token. + Route::post('/auth/login', [AuthController::class, 'login'])->middleware('throttle:10,1')->name('api.auth.login'); + Route::post('/auth/register', [AuthController::class, 'register'])->middleware('throttle:10,1')->name('api.auth.register'); + + Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->group(function () { + Route::post('/auth/logout', [AuthController::class, 'logout'])->name('api.auth.logout'); + Route::get('/me', MeController::class); + + Route::get('/qr-codes', [QrCodeController::class, 'index']); + Route::post('/qr-codes', [QrCodeController::class, 'store']); + Route::get('/qr-codes/{qrCode}', [QrCodeController::class, 'show'])->whereNumber('qrCode'); + Route::patch('/qr-codes/{qrCode}', [QrCodeController::class, 'update'])->whereNumber('qrCode'); + Route::get('/qr-codes/{qrCode}/analytics', [QrCodeController::class, 'analytics'])->whereNumber('qrCode'); + + // Ladill Mini — payments product. + Route::get('/mini/overview', MiniOverviewController::class)->name('api.mini.overview'); + + Route::get('/mini/notifications', [MiniNotificationController::class, 'index'])->name('api.mini.notifications.index'); + Route::get('/mini/notifications/unread-count', [MiniNotificationController::class, 'unreadCount'])->name('api.mini.notifications.unread-count'); + Route::post('/mini/notifications/{id}/read', [MiniNotificationController::class, 'markAsRead'])->name('api.mini.notifications.read'); + Route::post('/mini/notifications/mark-all-read', [MiniNotificationController::class, 'markAllAsRead'])->name('api.mini.notifications.mark-all-read'); + + Route::post('/mini/push-token', [MiniPushTokenController::class, 'store'])->name('api.mini.push-token.store'); + Route::delete('/mini/push-token', [MiniPushTokenController::class, 'destroy'])->name('api.mini.push-token.destroy'); + Route::post('/mini/afia/chat', [MiniAfiaController::class, 'chat'])->middleware('throttle:30,1')->name('api.mini.afia.chat'); + Route::get('/mini/payment-qrs', [MiniPaymentQrController::class, 'index'])->name('api.mini.payment-qrs.index'); + Route::post('/mini/payment-qrs', [MiniPaymentQrController::class, 'store'])->name('api.mini.payment-qrs.store'); + Route::get('/mini/payment-qrs/{paymentQr}', [MiniPaymentQrController::class, 'show'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.show'); + Route::patch('/mini/payment-qrs/{paymentQr}', [MiniPaymentQrController::class, 'update'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.update'); + Route::delete('/mini/payment-qrs/{paymentQr}', [MiniPaymentQrController::class, 'destroy'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.destroy'); + Route::get('/mini/payment-qrs/{paymentQr}/preview.png', [MiniPaymentQrController::class, 'preview'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.preview'); + + Route::get('/mini/payments', [MiniPaymentsController::class, 'index'])->name('api.mini.payments.index'); + Route::get('/mini/payouts', MiniPayoutsController::class)->name('api.mini.payouts'); + + // Account self-service (Settings, Wallet, Support) — keeps Mini usable + // entirely in-app without the website. + Route::get('/mini/account/settings', [MiniAccountController::class, 'settings'])->name('api.mini.account.settings'); + Route::put('/mini/account/settings', [MiniAccountController::class, 'updateSettings'])->name('api.mini.account.settings.update'); + Route::put('/mini/account/profile', [MiniAccountController::class, 'updateProfile'])->name('api.mini.account.profile'); + Route::post('/mini/account/avatar', [MiniAccountController::class, 'uploadAvatar'])->name('api.mini.account.avatar'); + Route::post('/mini/account/change-password', [MiniAccountController::class, 'changePassword'])->name('api.mini.account.change-password'); + + Route::get('/mini/wallet', [MiniWalletController::class, 'show'])->name('api.mini.wallet'); + Route::post('/mini/wallet/topup', [MiniWalletController::class, 'topup'])->name('api.mini.wallet.topup'); + Route::get('/mini/wallet/banks', [MiniWalletController::class, 'banks'])->name('api.mini.wallet.banks'); + Route::get('/mini/wallet/payout-account', [MiniWalletController::class, 'payoutAccount'])->name('api.mini.wallet.payout-account'); + Route::put('/mini/wallet/payout-account', [MiniWalletController::class, 'updatePayoutAccount'])->name('api.mini.wallet.payout-account.update'); + Route::get('/mini/wallet/withdrawals', [MiniWalletController::class, 'withdrawals'])->name('api.mini.wallet.withdrawals'); + Route::post('/mini/wallet/withdraw', [MiniWalletController::class, 'withdraw'])->name('api.mini.wallet.withdraw'); + Route::match(['put', 'post'], '/mini/wallet/auto-withdraw', [MiniWalletController::class, 'updateAutoWithdraw'])->name('api.mini.wallet.auto-withdraw'); + + Route::get('/mini/support/tickets', [MiniSupportController::class, 'tickets'])->name('api.mini.support.tickets'); + Route::post('/mini/support/tickets', [MiniSupportController::class, 'store'])->name('api.mini.support.tickets.store'); + Route::get('/mini/support/tickets/{ticket}', [MiniSupportController::class, 'ticket'])->whereNumber('ticket')->name('api.mini.support.ticket'); + }); +}); diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..c63efc4 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,20 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); + +Schedule::command('mini:cancel-stale-payments')->hourly()->withoutOverlapping(); +Schedule::command('mini:process-auto-withdrawals')->everyFiveMinutes()->withoutOverlapping(); + +Schedule::command('hosting:check-node-health --all')->everyFiveMinutes()->withoutOverlapping(); +Schedule::command('hosting:sync-account-usage')->hourly()->withoutOverlapping(); +Schedule::command('hosting:recalculate-node-capacity --shared-only')->daily()->withoutOverlapping(); +Schedule::command('hosting:process-expired-accounts')->daily()->withoutOverlapping(); +Schedule::command('hosting:notify-expiring')->daily()->withoutOverlapping(); +Schedule::command('hosting:retry-pending-fulfillment')->everyFifteenMinutes()->withoutOverlapping(); +Schedule::command('ssl:renew')->daily()->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..f04e3c8 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,57 @@ + auth()->check() + ? redirect()->route('pos.dashboard') + : redirect()->route('sso.connect'))->name('pos.root'); + +Route::get('/login', [SsoLoginController::class, 'connect'])->name('login'); +Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect'); +Route::get('/sso/callback', [SsoLoginController::class, 'callback'])->name('sso.callback'); +Route::post('/logout', [SsoLoginController::class, 'logout'])->name('logout'); +Route::get('/sso/logout-bridge', [SsoLoginController::class, 'logoutBridge'])->name('sso.logout-bridge'); +Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannelLogout'])->name('sso.logout-frontchannel'); +Route::get('/sso/platform-signed-out', [SsoLoginController::class, 'platformSignedOut'])->name('sso.platform-signed-out'); +Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('pos.dashboard') : view('auth.signed-out'))->name('pos.signed-out'); + +Route::get('/sales/{sale}/callback', [SaleController::class, 'callback'])->name('pos.sales.callback'); + +Route::middleware(['auth', 'platform.session'])->group(function () { + Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance'); + + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); + Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread'); + Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read'); + Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read'); + + Route::get('/dashboard', [DashboardController::class, 'index'])->name('pos.dashboard'); + + Route::get('/register', [RegisterController::class, 'index'])->name('pos.register'); + Route::post('/register/charge', [RegisterController::class, 'charge'])->name('pos.register.charge'); + + Route::get('/sales', [SaleController::class, 'index'])->name('pos.sales.index'); + Route::get('/sales/{sale}', [SaleController::class, 'show'])->name('pos.sales.show'); + + Route::get('/products', [ProductController::class, 'index'])->name('pos.products.index'); + Route::get('/products/create', [ProductController::class, 'create'])->name('pos.products.create'); + Route::post('/products', [ProductController::class, 'store'])->name('pos.products.store'); + Route::get('/products/{product}/edit', [ProductController::class, 'edit'])->name('pos.products.edit'); + Route::put('/products/{product}', [ProductController::class, 'update'])->name('pos.products.update'); + Route::delete('/products/{product}', [ProductController::class, 'destroy'])->name('pos.products.destroy'); + + Route::get('/settings', [SettingsController::class, 'index'])->name('pos.settings'); + Route::put('/settings', [SettingsController::class, 'update'])->name('pos.settings.update'); + Route::post('/settings/import-crm', [SettingsController::class, 'importCrm'])->name('pos.settings.import-crm'); + Route::post('/settings/import-merchant', [SettingsController::class, 'importMerchant'])->name('pos.settings.import-merchant'); + + Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('pos.wallet'); +}); diff --git a/tests/Feature/AfiaTest.php b/tests/Feature/AfiaTest.php new file mode 100644 index 0000000..7868306 --- /dev/null +++ b/tests/Feature/AfiaTest.php @@ -0,0 +1,54 @@ + (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']); + } + + public function test_requires_a_message(): void + { + config(['afia.api_key' => 'sk-test']); + $this->actingAs($this->user())->postJson('/afia/chat', [])->assertStatus(422); + } + + public function test_returns_503_when_not_configured(): void + { + config(['afia.api_key' => '']); + $this->actingAs($this->user())->postJson('/afia/chat', ['message' => 'hi'])->assertStatus(503); + } + + public function test_returns_reply_from_llm(): void + { + config(['afia.api_key' => 'sk-test', 'afia.provider' => 'openai', 'afia.model' => 'gpt-4o-mini']); + Http::fake([ + 'api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => 'Create a Business QR under My Codes.']]]]), + ]); + + $this->actingAs($this->user()) + ->postJson('/afia/chat', ['message' => 'How do I create a business QR?']) + ->assertOk() + ->assertJson(['reply' => 'Create a Business QR under My Codes.']); + + Http::assertSent(fn ($r) => str_contains($r->url(), 'openai.com') + && collect($r['messages'])->first()['role'] === 'system' + && str_contains(collect($r['messages'])->first()['content'], 'Ladill QR Plus')); + } +} diff --git a/tests/Feature/AssetLinksTest.php b/tests/Feature/AssetLinksTest.php new file mode 100644 index 0000000..6ec13e1 --- /dev/null +++ b/tests/Feature/AssetLinksTest.php @@ -0,0 +1,41 @@ + []]); + + $this->get('/.well-known/assetlinks.json')->assertNotFound(); + } + + public function test_assetlinks_returns_json_when_configured(): void + { + config([ + 'android_app_links.package_name' => 'com.ladill.mini', + 'android_app_links.sha256_cert_fingerprints' => [ + 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99', + ], + ]); + + $this->get('/.well-known/assetlinks.json') + ->assertOk() + ->assertHeader('Content-Type', 'application/json') + ->assertJson([ + [ + 'relation' => ['delegate_permission/common.handle_all_urls'], + 'target' => [ + 'namespace' => 'android_app', + 'package_name' => 'com.ladill.mini', + 'sha256_cert_fingerprints' => [ + 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99', + ], + ], + ], + ]); + } +} diff --git a/tests/Feature/ImportEventsCommandTest.php b/tests/Feature/ImportEventsCommandTest.php new file mode 100644 index 0000000..6bb83c8 --- /dev/null +++ b/tests/Feature/ImportEventsCommandTest.php @@ -0,0 +1,82 @@ +create(['public_id' => 'usr_events_owner']); + + $payload = [ + 'qr_wallets' => [], + 'qr_codes' => [ + [ + 'platform_id' => 10, + 'owner_public_id' => $owner->public_id, + 'owner_email' => $owner->email, + 'short_code' => 'demo-programme', + 'type' => QrCode::TYPE_ITINERARY, + 'label' => 'Demo Programme', + 'payload' => json_encode(['content' => ['title' => 'Demo Programme'], 'style' => []]), + 'is_active' => true, + 'scans_total' => 0, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + 'destination_updated_at' => now()->toDateTimeString(), + ], + [ + 'platform_id' => 11, + 'owner_public_id' => $owner->public_id, + 'owner_email' => $owner->email, + 'short_code' => 'demo-event', + 'type' => QrCode::TYPE_EVENT, + 'label' => 'Demo Event', + 'payload' => json_encode([ + 'content' => ['name' => 'Demo Event', 'programme_qr_id' => 10], + 'style' => [], + ]), + 'is_active' => true, + 'scans_total' => 0, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + 'destination_updated_at' => now()->toDateTimeString(), + ], + ], + 'qr_event_registrations' => [], + 'qr_scan_events' => [], + 'qr_transactions' => [], + ]; + + $exportPath = storage_path('framework/testing/events-import.json'); + if (! is_dir(dirname($exportPath))) { + mkdir(dirname($exportPath), 0777, true); + } + file_put_contents($exportPath, json_encode($payload)); + + Artisan::call('events:import', [ + 'file' => $exportPath, + '--commit' => true, + ]); + + $event = QrCode::where('short_code', 'demo-event')->first(); + $programme = QrCode::where('short_code', 'demo-programme')->first(); + + $this->assertNotNull($event); + $this->assertNotNull($programme); + $this->assertIsArray($event->payload); + $this->assertSame('Demo Event', $event->content()['name'] ?? null); + $this->assertSame($programme->id, $event->content()['programme_qr_id'] ?? null); + } +} diff --git a/tests/Feature/PosCommerceTest.php b/tests/Feature/PosCommerceTest.php new file mode 100644 index 0000000..f846a4c --- /dev/null +++ b/tests/Feature/PosCommerceTest.php @@ -0,0 +1,120 @@ +withoutMiddleware(EnsurePlatformSession::class); + $this->withoutVite(); + + config([ + 'crm.url' => 'https://crm.test/api', + 'crm.key' => 'test-crm-key', + ]); + } + + private function user(): User + { + return User::create([ + 'public_id' => 'u-'.uniqid(), + 'name' => 'Cashier', + 'email' => uniqid().'@example.com', + ]); + } + + public function test_cash_sale_pushes_crm_timeline(): void + { + Http::fake([ + 'crm.test/api/timeline' => Http::response(['id' => 1], 201), + ]); + + $user = $this->user(); + + $this->actingAs($user)->post(route('pos.register.charge'), [ + 'payment_method' => 'cash', + 'lines' => [ + ['name' => 'Tea', 'unit_price_minor' => 1000, 'quantity' => 1], + ], + ])->assertRedirect(); + + Http::assertSent(fn ($request) => $request->url() === 'https://crm.test/api/timeline' + && $request['event'] === 'order.paid' + && $request['owner'] === $user->public_id); + } + + public function test_crm_product_import_creates_local_products(): void + { + Http::fake([ + 'crm.test/api/products*' => Http::response([ + 'data' => [ + [ + 'name' => 'Imported Mug', + 'sku' => 'MUG-1', + 'unit_price_minor' => 2500, + 'currency' => 'GHS', + 'active' => true, + ], + ], + ], 200), + ]); + + $user = $this->user(); + + $this->actingAs($user) + ->post(route('pos.settings.import-crm')) + ->assertRedirect() + ->assertSessionHas('success'); + + $product = PosProduct::owned($user->public_id)->where('sku', 'MUG-1')->first(); + $this->assertNotNull($product); + $this->assertSame('Imported Mug', $product->name); + $this->assertSame(2500, $product->price_minor); + } + + public function test_paid_sale_links_to_invoice_prefill(): void + { + $user = $this->user(); + $sale = PosSale::create([ + 'owner_ref' => $user->public_id, + 'reference' => 'POS-TESTREF01', + 'status' => PosSale::STATUS_PAID, + 'payment_method' => PosSale::METHOD_CASH, + 'customer_name' => 'Ada', + 'total_minor' => 1500, + 'subtotal_minor' => 1500, + 'currency' => 'GHS', + 'paid_at' => now(), + ]); + $sale->lines()->create([ + 'name' => 'Coffee', + 'unit_price_minor' => 1500, + 'quantity' => 1, + 'line_total_minor' => 1500, + 'position' => 0, + ]); + + $url = app(CrossAppLinkService::class)->invoiceFromSale($sale->fresh('lines')); + + $this->assertStringContainsString('invoice.', $url); + $this->assertStringContainsString('invoices%2Fcreate', $url); + + $this->actingAs($user) + ->get(route('pos.sales.show', $sale)) + ->assertOk() + ->assertSee('Create invoice'); + } +} diff --git a/tests/Feature/PosRegisterTest.php b/tests/Feature/PosRegisterTest.php new file mode 100644 index 0000000..263eaa2 --- /dev/null +++ b/tests/Feature/PosRegisterTest.php @@ -0,0 +1,110 @@ + 'u-'.uniqid(), + 'name' => 'Cashier', + 'email' => uniqid().'@example.com', + ]); + } + + protected function setUp(): void + { + parent::setUp(); + $this->withoutMiddleware(EnsurePlatformSession::class); + $this->withoutVite(); + + config([ + 'crm.url' => 'https://crm.test/api', + 'crm.key' => 'test-crm-key', + ]); + + Http::fake([ + 'crm.test/api/customers*' => Http::response(['data' => []], 200), + ]); + } + + public function test_dashboard_renders_for_signed_in_user(): void + { + $this->actingAs($this->user()) + ->get(route('pos.dashboard')) + ->assertOk() + ->assertSee('Overview') + ->assertSee('favicon.svg', false); + } + + public function test_register_renders_products(): void + { + $user = $this->user(); + PosProduct::create([ + 'owner_ref' => $user->public_id, + 'name' => 'Coffee', + 'price_minor' => 1500, + 'currency' => 'GHS', + 'is_active' => true, + ]); + + $this->actingAs($user) + ->get(route('pos.register')) + ->assertOk() + ->assertSee('Coffee'); + } + + public function test_cash_sale_is_recorded(): void + { + $user = $this->user(); + + $this->actingAs($user)->post(route('pos.register.charge'), [ + 'payment_method' => 'cash', + 'lines' => [ + ['name' => 'Tea', 'unit_price_minor' => 1000, 'quantity' => 2], + ], + ])->assertRedirect(); + + $sale = PosSale::where('owner_ref', $user->public_id)->first(); + $this->assertNotNull($sale); + $this->assertSame(PosSale::STATUS_PAID, $sale->status); + $this->assertSame(PosSale::METHOD_CASH, $sale->payment_method); + $this->assertSame(2000, $sale->total_minor); + } + + public function test_pay_sale_redirects_to_checkout(): void + { + $user = $this->user(); + + $this->mock(PayClient::class, function ($mock) { + $mock->shouldReceive('createCheckout')->once()->andReturn([ + 'id' => 99, + 'reference' => 'LP-TESTREF', + 'checkout_url' => 'https://checkout.paystack.com/test', + ]); + }); + + $this->actingAs($user)->post(route('pos.register.charge'), [ + 'payment_method' => 'pay', + 'lines' => [ + ['name' => 'Snack', 'unit_price_minor' => 500, 'quantity' => 1], + ], + ])->assertRedirect('https://checkout.paystack.com/test'); + + $sale = PosSale::where('owner_ref', $user->public_id)->first(); + $this->assertSame('LP-TESTREF', $sale->payment_reference); + $this->assertSame(PosSale::STATUS_PENDING, $sale->status); + } +} diff --git a/tests/Feature/QrSettingsTest.php b/tests/Feature/QrSettingsTest.php new file mode 100644 index 0000000..07b870f --- /dev/null +++ b/tests/Feature/QrSettingsTest.php @@ -0,0 +1,108 @@ + (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']); + } + + public function test_settings_page_renders_for_authenticated_user(): void + { + $this->actingAs($this->user()) + ->get(route('account.settings')) + ->assertOk() + ->assertSee('favicon.ico', false) + ->assertSee('New event defaults') + ->assertSee('QR code defaults') + ->assertSee('New registrations') + ->assertSee('Notifications'); + } + + public function test_can_save_qr_settings(): void + { + $user = $this->user(); + + $this->actingAs($user) + ->put(route('account.settings.update'), [ + 'notify_email' => 'alerts@example.com', + 'product_updates' => '1', + 'low_balance_alerts' => '0', + 'notify_registrations' => '1', + 'notify_payouts' => '0', + 'event_defaults' => [ + 'currency' => 'USD', + 'mode' => 'free', + 'badge_size' => '4x6', + 'brand_color' => '#aabbcc', + 'organizer' => 'CAPBuSS', + 'registration_open' => '1', + 'badge_fields' => ['Company', 'Title'], + ], + 'default_style' => [ + 'foreground' => '#112233', + 'background' => '#ffffff', + 'module_style' => 'dots', + 'frame_style' => 'scan_me', + 'frame_color' => '#445566', + 'frame_text' => 'SCAN ME', + ], + ]) + ->assertRedirect(route('account.settings')); + + $settings = QrSetting::where('user_id', $user->id)->first(); + + $this->assertNotNull($settings); + $this->assertSame('alerts@example.com', $settings->notify_email); + $this->assertTrue($settings->product_updates); + $this->assertFalse($settings->low_balance_alerts); + $this->assertTrue($settings->notify_registrations); + $this->assertFalse($settings->notify_payouts); + $this->assertSame('USD', $settings->resolvedEventDefaults()['currency']); + $this->assertSame('free', $settings->resolvedEventDefaults()['mode']); + $this->assertSame('CAPBuSS', $settings->resolvedEventDefaults()['organizer']); + $this->assertSame('#112233', $settings->resolvedDefaultStyle()['foreground']); + $this->assertSame('scan_me', $settings->resolvedDefaultStyle()['frame_style']); + } + + public function test_create_page_uses_saved_defaults(): void + { + $user = $this->user(); + QrSetting::create([ + 'user_id' => $user->id, + 'default_type' => QrCode::TYPE_EVENT, + 'default_style' => ['foreground' => '#aabbcc', 'module_style' => 'dots'], + 'event_defaults' => ['mode' => 'contributions', 'organizer' => 'Test Org', 'brand_color' => '#ff00aa'], + ]); + + Http::fake([ + config('billing.api_url').'/balance*' => Http::response(['balance_minor' => 50000]), + ]); + + $this->actingAs($user) + ->get(route('events.create')) + ->assertOk() + ->assertSee('favicon.ico', false) + ->assertSee('#aabbcc', false) + ->assertSee('Test Org', false) + ->assertSee('"event"', false); + } +} diff --git a/tests/Feature/SearchTest.php b/tests/Feature/SearchTest.php new file mode 100644 index 0000000..7c87c64 --- /dev/null +++ b/tests/Feature/SearchTest.php @@ -0,0 +1,64 @@ + (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']); + } + + public function test_user_can_search_payments_by_payer_name(): void + { + $user = $this->user(); + + $qr = QrCode::query()->create([ + 'user_id' => $user->id, + 'short_code' => 'till01', + 'type' => QrCode::TYPE_PAYMENT, + 'label' => 'Main till', + 'destination_url' => 'https://example.com', + 'is_active' => true, + ]); + + MiniPayment::query()->create([ + 'qr_code_id' => $qr->id, + 'user_id' => $user->id, + 'reference' => 'pay-'.Str::uuid(), + 'amount_minor' => 5000, + 'currency' => 'GHS', + 'platform_fee_minor' => 250, + 'merchant_amount_minor' => 4750, + 'payer_name' => 'Ama Mensah', + 'status' => MiniPayment::STATUS_PAID, + 'paid_at' => now(), + ]); + + $response = $this->actingAs($user) + ->getJson(route('mini.search', ['q' => 'Ama'])); + + $response->assertOk(); + $response->assertJsonPath('results.0.type', 'payment'); + $response->assertJsonPath('results.0.title', 'Ama Mensah'); + } + + public function test_search_page_renders_for_authenticated_user(): void + { + $user = $this->user(); + + $this->actingAs($user) + ->get(route('mini.search')) + ->assertOk() + ->assertSee('Find payments', false); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..eece6d4 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,14 @@ +withoutVite(); + } +} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php new file mode 100644 index 0000000..5773b0c --- /dev/null +++ b/tests/Unit/ExampleTest.php @@ -0,0 +1,16 @@ +assertTrue(true); + } +} diff --git a/tests/Unit/Services/Qr/PaymentQrUpdateTest.php b/tests/Unit/Services/Qr/PaymentQrUpdateTest.php new file mode 100644 index 0000000..7949c3a --- /dev/null +++ b/tests/Unit/Services/Qr/PaymentQrUpdateTest.php @@ -0,0 +1,57 @@ + (string) Str::uuid(), + 'name' => 'Trader', + 'email' => 'trader@example.com', + ]); + + $qrCode = QrCode::create([ + 'user_id' => $user->id, + 'short_code' => 'paytest01', + 'type' => QrCode::TYPE_PAYMENT, + 'label' => 'Till 1', + 'payload' => [ + 'content' => [ + 'business_name' => 'Old Shop', + 'branch_label' => 'Main', + 'currency' => 'GHS', + ], + 'style' => [], + ], + 'is_active' => true, + ]); + + $manager = new QrCodeManagerService( + $this->createMock(QrWalletBillingService::class), + $this->createMock(QrImageGeneratorService::class), + new QrPayloadValidator(), + ); + + $updated = $manager->update($qrCode, [ + 'business_name' => 'New Shop', + 'branch_label' => 'Accra Mall', + ]); + + $this->assertSame('New Shop', $updated->content()['business_name']); + $this->assertSame('Accra Mall', $updated->content()['branch_label']); + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..f35b4e7 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +});