Extract Ladill Events as a standalone events suite at events.ladill.com.
Deploy Ladill QR Plus / deploy (push) Successful in 28s

Full control center for ticketed events, contributions, attendees, badges,
and programmes — not a QR utility clone. Includes SSO shell, import command,
and platform cutover runbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-07 09:39:53 +00:00
co-authored by Cursor
commit d8dbc83e2d
246 changed files with 34279 additions and 0 deletions
+18
View File
@@ -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
+39
View File
@@ -0,0 +1,39 @@
APP_NAME="Ladill Events"
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://events.ladill.com
PLATFORM_URL=https://ladill.com
PLATFORM_DOMAIN=ladill.com
AUTH_DOMAIN=auth.ladill.com
ACCOUNT_DOMAIN=account.ladill.com
EVENTS_DOMAIN=events.ladill.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=ladill_events
DB_USERNAME=ladill_events
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_EVENTS=
IDENTITY_API_URL=https://ladill.com/api
IDENTITY_API_KEY_EVENTS=
AFIA_ENABLED=true
AFIA_PRODUCT=events
AFIA_PROVIDER=openai
AFIA_MODEL=gpt-4o-mini
AFIA_API_KEY=
VITE_APP_NAME="${APP_NAME}"
+11
View File
@@ -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
+92
View File
@@ -0,0 +1,92 @@
name: Deploy Ladill QR Plus
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-qr-plus
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-qr-plus-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-qr-plus-${{ gitea.run_id }}-${{ gitea.run_attempt }}
LADILL_APP_ROOT: /var/www/ladill-qr-plus
steps:
- name: Checkout
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-qr-plus-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: Cleanup
if: always()
shell: bash {0}
run: |
rm -rf "$WORKSPACE"
rm -f "$RELEASE_ARCHIVE"
+26
View File
@@ -0,0 +1,26 @@
*.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
+89
View File
@@ -0,0 +1,89 @@
# Ladill Events — deploy & cutover runbook
Standalone app for the **full events suite** at `events.ladill.com` — ticketed
events, contributions, free registration, attendee check-in, badge printing, and
programme/itinerary outlines. Not a QR utility app; this is the events control
center (Overview, Events, Programmes, Payments & Payouts).
Trusts `auth.ladill.com` for SSO. Ticket/contribution money settles via Ladill
Pay into the one platform UserWallet. Public scans stay at `ladill.com/q/<code>`
(blueprint decision — printed codes never migrate).
---
## 0. Prerequisites
| Secret | Where |
|---|---|
| `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | `passport:client` on platform |
| `BILLING_API_KEY_EVENTS` | platform `.env` + this app |
| `IDENTITY_API_KEY_EVENTS` | platform `.env` + this app |
## 1. Gitea repo + CI
1. Repo: **ladill-events** (create on Gitea)
2. Push to `main` triggers `.gitea/workflows/deploy.yml`.
3. App root: `/var/www/ladill-events`
## 2. Server app-slot + database
```bash
sudo install -d -o deploy -g www-data /var/www/ladill-events
sudo mysql -e "CREATE DATABASE ladill_events CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
sudo mysql -e "CREATE USER 'ladill_events'@'127.0.0.1' IDENTIFIED BY '<pw>';"
sudo mysql -e "GRANT ALL ON ladill_events.* TO 'ladill_events'@'127.0.0.1'; FLUSH PRIVILEGES;"
```
## 3. Register OIDC client (platform)
```bash
php artisan passport:client \
--name="Ladill Events" \
--redirect_uri="https://events.ladill.com/sso/callback"
```
## 4. Platform integration
```env
BILLING_API_KEY_EVENTS=<same>
IDENTITY_API_KEY_EVENTS=<same>
RP_EVENTS_FRONTCHANNEL_LOGOUT=https://events.ladill.com/sso/logout-frontchannel
LADILL_EVENTS_APP_URL=https://events.ladill.com
```
## 5. nginx + TLS
```bash
sudo deployment/setup-service-subdomain-nginx.sh events --app /var/www/ladill-events/current
```
Public `/q/*` event landings can stay on the monolith initially; nginx can proxy
event codes to this app after import.
## 6. First deploy
```bash
cd /var/www/ladill-events/current
php artisan migrate --force
php artisan config:cache route:cache view:cache
```
## 7. Data migration
```bash
# platform
php artisan events:export --out=/tmp/events-export.json
# events app
php artisan events:import /tmp/events-export.json \
--storage-source=/var/www/ladill/current/storage/app/private/qr \
--commit
```
## 8. Verify
- Sign in at `https://events.ladill.com`
- Overview shows registrations, revenue, check-in rate
- Events list + create (tickets / contributions / free)
- Attendees, badges, programme share
- Account portal `/qr-codes/{id}/attendees` redirects here
+59
View File
@@ -0,0 +1,59 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
+1
View File
@@ -0,0 +1 @@
manual-deploy
@@ -0,0 +1,268 @@
<?php
namespace App\Console\Commands;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Models\QrScanEvent;
use App\Models\QrTransaction;
use App\Models\QrWallet;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class ImportEventsCommand extends Command
{
protected $signature = 'events:import
{file : Path to events-export.json from the platform}
{--commit : Actually write changes}
{--storage-source= : Platform qr disk root (storage/app/private/qr) to copy event images}';
protected $description = 'Import Events codes and registrations from the platform export';
/** @var array<int,int> platform qr_code id → local id */
private array $codeMap = [];
public function handle(): int
{
$commit = (bool) $this->option('commit');
if (! $commit) {
$this->warn('DRY RUN — no changes will be written. Re-run with --commit to apply.');
}
$path = $this->argument('file');
if (! is_file($path)) {
$this->error("File not found: {$path}");
return self::FAILURE;
}
$payload = json_decode((string) file_get_contents($path), true);
if (! is_array($payload)) {
$this->error('Invalid JSON export file.');
return self::FAILURE;
}
$apply = function (callable $callback) use ($commit): void {
if ($commit) {
DB::transaction($callback);
} else {
$callback();
}
};
$apply(function () use ($payload, $commit): void {
foreach ($payload['qr_wallets'] ?? [] as $row) {
$this->importWallet($row, $commit);
}
foreach ($payload['qr_codes'] ?? [] as $row) {
$this->importCode($row, $commit);
}
foreach ($payload['qr_event_registrations'] ?? [] as $row) {
$this->importRegistration($row, $commit);
}
foreach ($payload['qr_scan_events'] ?? [] as $row) {
$this->importScanEvent($row, $commit);
}
foreach ($payload['qr_transactions'] ?? [] as $row) {
$this->importTransaction($row, $commit);
}
});
if (! $this->option('storage-source')) {
$this->warn('Pass --storage-source=/path/to/platform/storage/app/private/qr to copy event cover/logo files.');
}
$this->info(($commit ? 'Imported' : 'Would import').' '.count($this->codeMap).' event code(s).');
return self::SUCCESS;
}
private function resolveUser(array $row): ?User
{
$publicId = $row['owner_public_id'] ?? null;
if (! $publicId) {
return null;
}
return User::firstOrCreate(
['public_id' => $publicId],
['email' => $row['owner_email'] ?? $publicId.'@users.ladill.com', 'name' => 'Imported user'],
);
}
/** @param array<string,mixed> $row */
private function importWallet(array $row, bool $commit): void
{
$user = $this->resolveUser($row);
if (! $user) {
return;
}
$attrs = collect($row)->except(['platform_id', 'owner_public_id', 'owner_email', 'id', 'user_id'])->all();
if ($commit) {
QrWallet::updateOrCreate(['user_id' => $user->id], $attrs);
}
}
/** @param array<string,mixed> $row */
private function importCode(array $row, bool $commit): void
{
$user = $this->resolveUser($row);
if (! $user) {
return;
}
$platformId = (int) ($row['platform_id'] ?? 0);
$shortCode = (string) ($row['short_code'] ?? '');
$attrs = collect($row)->except([
'platform_id',
'owner_public_id',
'owner_email',
'id',
'user_id',
])->all();
$attrs['user_id'] = $user->id;
if ($commit) {
$code = QrCode::updateOrCreate(['short_code' => $shortCode], $attrs);
$this->codeMap[$platformId] = $code->id;
$this->remapProgrammeLinks($code);
$this->copyCodeAssets($code);
} else {
$this->codeMap[$platformId] = $platformId;
}
}
private function remapProgrammeLinks(QrCode $code): void
{
if ($code->type !== QrCode::TYPE_EVENT) {
return;
}
$content = $code->content();
$programmeId = (int) ($content['programme_qr_id'] ?? 0);
if ($programmeId <= 0 || ! isset($this->codeMap[$programmeId])) {
return;
}
$content['programme_qr_id'] = $this->codeMap[$programmeId];
$code->update(['content' => $content]);
}
/** @param array<string,mixed> $row */
private function importRegistration(array $row, bool $commit): void
{
$platformCodeId = (int) ($row['platform_qr_code_id'] ?? 0);
$localCodeId = $this->codeMap[$platformCodeId] ?? null;
if (! $localCodeId) {
return;
}
$reference = (string) ($row['reference'] ?? '');
$attrs = collect($row)->except(['platform_id', 'platform_qr_code_id', 'id', 'qr_code_id', 'user_id'])->all();
$attrs['qr_code_id'] = $localCodeId;
$attrs['user_id'] = QrCode::find($localCodeId)?->user_id;
if ($commit && $reference !== '') {
QrEventRegistration::updateOrCreate(['reference' => $reference], $attrs);
}
}
/** @param array<string,mixed> $row */
private function importScanEvent(array $row, bool $commit): void
{
$platformCodeId = (int) ($row['platform_qr_code_id'] ?? 0);
$localCodeId = $this->codeMap[$platformCodeId] ?? null;
if (! $localCodeId) {
return;
}
$attrs = collect($row)->except(['platform_id', 'platform_qr_code_id', 'id', 'qr_code_id'])->all();
$attrs['qr_code_id'] = $localCodeId;
if ($commit) {
QrScanEvent::updateOrCreate(
['qr_code_id' => $localCodeId, 'created_at' => $attrs['created_at'] ?? now()],
$attrs,
);
}
}
/** @param array<string,mixed> $row */
private function importTransaction(array $row, bool $commit): void
{
$user = $this->resolveUser($row);
if (! $user) {
return;
}
$wallet = QrWallet::firstOrCreate(['user_id' => $user->id]);
$reference = (string) ($row['reference'] ?? '');
$attrs = collect($row)->except(['platform_id', 'owner_public_id', 'platform_qr_code_id', 'id', 'user_id', 'qr_wallet_id', 'qr_code_id'])->all();
$attrs['user_id'] = $user->id;
$attrs['qr_wallet_id'] = $wallet->id;
$platformCodeId = (int) ($row['platform_qr_code_id'] ?? 0);
if ($platformCodeId && isset($this->codeMap[$platformCodeId])) {
$attrs['qr_code_id'] = $this->codeMap[$platformCodeId];
}
if ($commit && $reference !== '') {
QrTransaction::updateOrCreate(['reference' => $reference], $attrs);
}
}
private function copyCodeAssets(QrCode $code): void
{
$sourceRoot = $this->option('storage-source');
if (! is_string($sourceRoot) || $sourceRoot === '' || ! is_dir($sourceRoot)) {
return;
}
$paths = array_filter([
$code->png_path,
$code->svg_path,
]);
$content = $code->content();
foreach (['logo_path', 'cover_path'] as $key) {
if (! empty($content[$key])) {
$paths[] = $content[$key];
}
}
foreach (array_unique($paths) as $relativePath) {
$this->copyStorageFile($sourceRoot, (string) $relativePath);
}
}
private function copyStorageFile(string $sourceRoot, string $relativePath): void
{
if ($relativePath === '') {
return;
}
$sourceFile = rtrim($sourceRoot, '/').'/'.$relativePath;
if (! is_file($sourceFile)) {
$this->warn("Missing file on platform storage: {$sourceFile}");
return;
}
if (Storage::disk('qr')->exists($relativePath)) {
return;
}
$directory = dirname($relativePath);
if ($directory !== '.' && $directory !== '') {
Storage::disk('qr')->makeDirectory($directory);
}
Storage::disk('qr')->put($relativePath, (string) file_get_contents($sourceFile));
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MeController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$user = $request->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(),
],
]);
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Qr\QrAnalyticsService;
use App\Services\Qr\QrCodeManagerService;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
class QrCodeController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private QrAnalyticsService $analytics,
) {}
public function index(Request $request): JsonResponse
{
$codes = $this->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<QrCode> */
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<string, mixed> */
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;
}
}
@@ -0,0 +1,180 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
/**
* "Sign in with Ladill" Authorization Code + PKCE against auth.ladill.com.
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
*/
class SsoLoginController extends Controller
{
public function connect(Request $request): RedirectResponse
{
$intended = (string) $request->query('redirect', route('events.dashboard'));
if (Auth::check()) {
return $this->safeRedirect($intended, route('events.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',
];
if (! $request->boolean('interactive')) {
$query['prompt'] = 'none';
}
return redirect()->away(rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query));
}
public function callback(Request $request): RedirectResponse
{
$intended = (string) $request->session()->get('sso.intended', route('events.dashboard'));
if ($request->filled('error')) {
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
&& ! $request->boolean('interactive')) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
if (! $request->filled('code')
|| $request->query('state') !== $request->session()->pull('sso.state')) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
$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 redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
if ($claims->failed() || ! $claims->json('sub')) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
$user = User::updateOrCreate(
['public_id' => (string) $claims->json('sub')],
[
'name' => $claims->json('name'),
'email' => $claims->json('email') ?: (string) $claims->json('sub').'@users.ladill.com',
'avatar_url' => $claims->json('picture'),
],
);
QrTeamMember::linkPendingInvitesFor($user);
Auth::login($user, remember: true);
$request->session()->regenerate();
return $this->safeRedirect($intended, route('events.dashboard'));
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$home = 'https://'.config('app.platform_domain');
$endSession = 'https://'.config('app.auth_domain').'/logout/sso?redirect='.urlencode($home);
return redirect()->away($endSession);
}
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 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 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);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Notifications\EventProgrammeSharedNotification;
use App\Services\Billing\SmsService;
use App\Support\Events\EventBadgeZpl;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Notification;
use Illuminate\View\View;
class AttendeeController extends Controller
{
public function __construct(private readonly SmsService $sms) {}
public function index(Request $request, QrCode $event): View
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$query = $event->eventRegistrations()->latest();
if ($search = trim((string) $request->query('search', ''))) {
$query->where(function ($q) use ($search) {
$q->where('attendee_name', 'like', "%{$search}%")
->orWhere('attendee_email', 'like', "%{$search}%")
->orWhere('badge_code', 'like', "%{$search}%");
});
}
if ($status = $request->query('status')) {
$query->where('status', $status);
}
$registrations = $query->paginate(40)->withQueryString();
$stats = [
'total' => $event->eventRegistrations()->where('status', QrEventRegistration::STATUS_CONFIRMED)->count(),
'checked_in' => $event->eventRegistrations()->whereNotNull('checked_in_at')->count(),
'revenue' => $event->eventRegistrations()->where('status', QrEventRegistration::STATUS_CONFIRMED)->sum('amount_minor') / 100,
];
$programme = $this->programmeFor($event);
return view('events.attendees', [
'qrCode' => $event,
'event' => $event,
'registrations' => $registrations,
'stats' => $stats,
'programme' => $programme,
]);
}
public function shareProgramme(QrCode $event): RedirectResponse
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$programme = $this->programmeFor($event);
if (! $programme) {
return back()->with('error', 'No programme outline is attached to this event yet.');
}
$recipients = $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->get();
$eventName = $event->content()['name'] ?? $event->label;
$programmeUrl = $programme->publicUrl();
$emailed = 0;
$texted = 0;
foreach ($recipients as $reg) {
if ($reg->attendee_email) {
Notification::route('mail', $reg->attendee_email)
->notify(new EventProgrammeSharedNotification($event, $programme, $reg->attendee_name));
$emailed++;
}
if ($reg->attendee_phone) {
$this->sms->send(
$reg->attendee_phone,
sprintf(
'Hi %s, the programme for %s is here: %s',
explode(' ', $reg->attendee_name ?? 'there')[0],
$eventName,
$programmeUrl
)
);
$texted++;
}
}
if ($emailed === 0 && $texted === 0) {
return back()->with('error', 'No confirmed attendees with contact details to share with yet.');
}
return back()->with('success', "Programme shared — {$emailed} email(s) and {$texted} SMS sent.");
}
private function programmeFor(QrCode $event): ?QrCode
{
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
if ($programmeId <= 0) {
return null;
}
return QrCode::where('id', $programmeId)
->where('user_id', $event->user_id)
->where('type', QrCode::TYPE_ITINERARY)
->first();
}
public function badges(Request $request, QrCode $event): View
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$registrations = $this->selectedRegistrations($request, $event);
return view('events.badges', [
'qrCode' => $event,
'event' => $event,
'registrations' => $registrations,
'auto' => $request->boolean('auto'),
]);
}
public function badgesZpl(Request $request, QrCode $event): Response
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$registrations = $this->selectedRegistrations($request, $event);
$zpl = EventBadgeZpl::forRegistrations($event, $registrations);
return response($zpl, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="badges-' . $event->short_code . '.zpl"',
]);
}
public function checkIn(QrCode $event, QrEventRegistration $registration): RedirectResponse
{
$this->authorize('view', $event);
abort_unless($registration->qr_code_id === $event->id, 404);
$registration->update([
'checked_in_at' => $registration->checked_in_at ? null : now(),
]);
return back();
}
/** @return \Illuminate\Support\Collection<int, QrEventRegistration> */
private function selectedRegistrations(Request $request, QrCode $event)
{
$query = $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->latest();
$ids = array_filter((array) $request->query('ids', []));
if (! empty($ids)) {
$query->whereIn('id', $ids);
}
return $query->get();
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class OverviewController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function index(Request $request): View
{
$account = ladill_account();
$events = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->get();
$eventIds = $events->pluck('id');
$upcomingCount = $events->filter(function (QrCode $event) {
$startsAt = $event->content()['starts_at'] ?? null;
if (! $startsAt) {
return true;
}
return strtotime((string) $startsAt) >= now()->startOfDay()->timestamp;
})->count();
$registrationsQuery = QrEventRegistration::query()
->whereIn('qr_code_id', $eventIds)
->where('status', QrEventRegistration::STATUS_CONFIRMED);
$ticketsSold = (clone $registrationsQuery)->count();
$revenueMinor = (int) (clone $registrationsQuery)->sum('amount_minor');
$checkedIn = QrEventRegistration::query()
->whereIn('qr_code_id', $eventIds)
->whereNotNull('checked_in_at')
->count();
$checkInRate = $ticketsSold > 0 ? round(($checkedIn / $ticketsSold) * 100) : 0;
$recentEvents = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->latest()
->limit(5)
->get()
->map(function (QrCode $event) {
$event->confirmed_count = $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->count();
return $event;
});
$programmeCount = $account->qrCodes()
->whereIn('type', QrTypeCatalog::programmeTypes())
->count();
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Events dashboard could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('events.dashboard', [
'upcomingCount' => $upcomingCount,
'ticketsSold' => $ticketsSold,
'revenueMinor' => $revenueMinor,
'checkInRate' => $checkInRate,
'recentEvents' => $recentEvents,
'programmeCount' => $programmeCount,
'balanceMinor' => $balanceMinor,
]);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class PayoutsController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function index(): View
{
$account = ladill_account();
$eventIds = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->pluck('id');
$revenueMinor = (int) QrEventRegistration::query()
->whereIn('qr_code_id', $eventIds)
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->sum('amount_minor');
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Events payouts could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('events.payouts', [
'revenueMinor' => $revenueMinor,
'balanceMinor' => $balanceMinor,
]);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProgrammeController extends Controller
{
public function index(Request $request): View
{
$account = ladill_account();
$programmes = $account->qrCodes()
->whereIn('type', QrTypeCatalog::programmeTypes())
->latest()
->get();
return view('events.programmes', compact('programmes'));
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationController extends Controller
{
public function index(Request $request): View
{
$notifications = $request->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]);
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Events\EventRegistrationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;
class EventRegistrationController extends Controller
{
public function __construct(private EventRegistrationService $eventService) {}
public function register(Request $request, string $shortCode): JsonResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_EVENT, 404);
$request->validate([
'tier' => ['required', 'string', 'max:80'],
'amount' => ['nullable', 'numeric', 'min:1', 'max:1000000'],
'attendee_name' => ['required', 'string', 'max:120'],
'attendee_email' => ['required', 'email', 'max:200'],
'attendee_phone' => ['nullable', 'string', 'max:30'],
'badge_fields' => ['nullable', 'array'],
]);
try {
$result = $this->eventService->register($qrCode, $request->all());
} catch (RuntimeException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
return response()->json([
'paid' => $result['paid'],
'checkout_url' => $result['checkout_url'],
'badge_code' => $result['registration']->badge_code,
'success_url' => route('qr.public.event.confirmed', [
'shortCode' => $qrCode->short_code,
'ref' => $result['registration']->reference,
]),
]);
}
public function callback(Request $request, string $shortCode): RedirectResponse|View
{
$reference = (string) $request->query('reference', '');
if ($reference === '') {
return redirect('/q/' . $shortCode)->with('error', 'Missing payment reference.');
}
try {
$registration = $this->eventService->complete($reference);
return redirect()->route('qr.public.event.confirmed', [
'shortCode' => $shortCode,
'ref' => $registration->reference,
]);
} catch (\Throwable $e) {
return redirect('/q/' . $shortCode)->with('order_error', 'Payment could not be verified. Reference: ' . $reference);
}
}
public function confirmed(string $shortCode, string $ref): View
{
$registration = QrEventRegistration::query()
->where('reference', $ref)
->whereHas('qrCode', fn ($q) => $q->where('short_code', $shortCode))
->firstOrFail();
return view('public.qr.event-confirmed', [
'registration' => $registration,
'qrCode' => $registration->qrCode,
]);
}
}
@@ -0,0 +1,336 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Qr\QrScanRecorder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class QrScanController extends Controller
{
public function __construct(
private QrScanRecorder $scanRecorder,
) {}
public function resolve(Request $request, string $shortCode): RedirectResponse|View
{
$qrCode = QrCode::query()->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->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 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);
}
}
@@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrSetting;
use App\Services\Billing\BillingClient;
use App\Support\Qr\QrCornerStyleCatalog;
use App\Support\Qr\QrFrameStyleCatalog;
use App\Support\Qr\QrModuleStyleCatalog;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AccountController extends Controller
{
public function __construct(private BillingClient $billing) {}
private function topupUrl(): string
{
return 'https://'.config('app.account_domain').'/wallet';
}
/** @return array{0: int, 1: array<string, mixed>} */
private function billingSnapshot(?string $publicId): array
{
if (! $publicId) {
return [0, []];
}
$balanceMinor = $this->billing->balanceMinor($publicId);
$ledger = $this->billing->serviceLedger($publicId, 'qr');
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();
$style = $settings->resolvedDefaultStyle();
return view('qr.account.settings', [
'account' => $account,
'settings' => $settings,
'style' => $style,
'types' => QrTypeCatalog::all(),
'moduleStyles' => QrModuleStyleCatalog::visible(),
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
'frameStyles' => QrFrameStyleCatalog::visible(),
]);
}
public function updateSettings(Request $request): RedirectResponse
{
$account = ladill_account();
$data = $request->validate([
'notify_email' => ['nullable', 'email', 'max:255'],
'product_updates' => ['nullable', 'boolean'],
'low_balance_alerts' => ['nullable', 'boolean'],
'default_type' => ['nullable', 'in:'.implode(',', QrTypeCatalog::eventsTypes())],
'default_style' => ['nullable', 'array'],
'default_style.foreground' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
'default_style.background' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
'default_style.error_correction' => ['nullable', 'in:L,M,Q,H'],
'default_style.margin' => ['nullable', 'integer', 'min:0', 'max:10'],
'default_style.module_style' => ['nullable', 'in:'.implode(',', QrModuleStyleCatalog::keys())],
'default_style.finder_outer' => ['nullable', 'string', 'max:32'],
'default_style.finder_inner' => ['nullable', 'string', 'max:32'],
'default_style.frame_style' => ['nullable', 'string', 'max:32'],
'default_style.frame_text' => ['nullable', 'string', 'max:100'],
'default_style.frame_color' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
'default_style.gradient_type' => ['nullable', 'in:none,linear,radial'],
'default_style.gradient_color1' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
'default_style.gradient_color2' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
'default_style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'],
]);
QrSetting::updateOrCreate(
['user_id' => $account->id],
[
'notify_email' => $data['notify_email'] ?? null,
'product_updates' => $request->boolean('product_updates'),
'low_balance_alerts' => $request->boolean('low_balance_alerts'),
'default_type' => $data['default_type'] ?? null,
'default_style' => QrSetting::sanitizeDefaultStyle($data['default_style'] ?? null),
],
);
return redirect()->route('account.settings')->with('success', 'Settings saved.');
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Afia\AfiaService;
use App\Services\Billing\BillingClient;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AfiaController extends Controller
{
public function chat(Request $request, AfiaService $afia): JsonResponse
{
$validated = $request->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<string,mixed> */
private function context(): array
{
$account = ladill_account();
if (! $account) {
return ['signed_in' => 'no'];
}
$types = QrTypeCatalog::eventTypes();
$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;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Developers personal API tokens for the Ladill QR Plus API (Sanctum). Tokens
* belong to the signed-in user and authorize calls to /api/v1/*. The plaintext
* token is shown once on create.
*/
class DeveloperController extends Controller
{
public function index(Request $request): View
{
return view('qr.account.developers', [
'tokens' => $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 wont 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.');
}
}
@@ -0,0 +1,450 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrWallet;
use App\Services\Billing\BillingClient;
use App\Services\Qr\QrAnalyticsService;
use App\Services\Qr\QrCodeManagerService;
use App\Services\Qr\QrImageGeneratorService;
use App\Services\Qr\QrPdfExporter;
use App\Services\Qr\QrWalletBillingService;
use App\Support\Qr\QrCornerStyleCatalog;
use App\Support\Qr\QrFrameStyleCatalog;
use App\Support\Qr\QrModuleStyleCatalog;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use RuntimeException;
use Throwable;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class QrCodeController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private QrWalletBillingService $billing,
private QrAnalyticsService $analytics,
private QrImageGeneratorService $imageGenerator,
private QrPdfExporter $pdfExporter,
private BillingClient $platformBilling,
) {}
public function index(Request $request): View
{
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$qrCodes = $account->qrCodes()
->whereIn('type', QrTypeCatalog::eventTypes())
->latest()
->get();
$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,
'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,
'types' => collect(QrTypeCatalog::all())->only([$requestedType])->all(),
'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',
'defaultType' => $qrSettings->resolvedDefaultType(),
'accountDefaultStyle' => $qrSettings->resolvedDefaultStyle(),
]);
}
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<string, mixed> */
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]);
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
class TeamController extends Controller
{
public function index(Request $request): View
{
$account = ladill_account();
$members = QrTeamMember::query()
->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('events.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();
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class SetActingAccount
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->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);
}
}
+219
View File
@@ -0,0 +1,219 @@
<?php
namespace App\Models;
use App\Support\Qr\QrStyleDefaults;
use App\Support\Qr\QrTypeCatalog;
use App\Support\Qr\QrWifiPayload;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QrCode extends Model
{
public const TYPE_URL = 'url';
public const TYPE_DOCUMENT = 'document';
public const TYPE_LINK_LIST = 'link_list';
public const TYPE_VCARD = 'vcard';
public const TYPE_BUSINESS = 'business';
public const TYPE_IMAGE = 'image';
public const TYPE_CHURCH = 'church';
public const TYPE_MENU = 'menu';
public const TYPE_SHOP = 'shop';
public const TYPE_APP = 'app';
public const TYPE_BOOK = 'book';
public const TYPE_WIFI = 'wifi';
public const TYPE_COUPON = 'coupon';
public const TYPE_EVENT = 'event';
public const TYPE_ITINERARY = 'itinerary';
protected $fillable = [
'user_id',
'short_code',
'type',
'label',
'destination_url',
'qr_document_id',
'payload',
'is_active',
'png_path',
'svg_path',
'scans_total',
'unique_scans_total',
'last_scanned_at',
'destination_updated_at',
];
protected $casts = [
'payload' => '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 saleOrders(): HasMany
{
return $this->hasMany(QrSaleOrder::class);
}
public function eventRegistrations(): HasMany
{
return $this->hasMany(QrEventRegistration::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<string, mixed> */
public function content(): array
{
return (array) ($this->payload['content'] ?? []);
}
/** @return array<string, mixed> */
public function style(): array
{
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 usesLandingPage(): bool
{
return in_array($this->type, [
self::TYPE_DOCUMENT,
self::TYPE_LINK_LIST,
self::TYPE_VCARD,
self::TYPE_BUSINESS,
self::TYPE_CHURCH,
self::TYPE_IMAGE,
self::TYPE_MENU,
self::TYPE_SHOP,
self::TYPE_APP,
self::TYPE_BOOK,
self::TYPE_WIFI,
self::TYPE_EVENT,
self::TYPE_ITINERARY,
], 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;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QrDocument extends Model
{
protected $fillable = [
'user_id',
'title',
'disk',
'path',
'mime_type',
'size_bytes',
'page_count',
];
protected $casts = [
'size_bytes' => 'integer',
'page_count' => 'integer',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function qrCodes(): HasMany
{
return $this->hasMany(QrCode::class);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QrEventRegistration extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_CONFIRMED = 'confirmed';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'qr_code_id',
'user_id',
'reference',
'badge_code',
'tier_name',
'amount_minor',
'currency',
'attendee_name',
'attendee_email',
'attendee_phone',
'badge_fields',
'status',
'payment_reference',
'paid_at',
'checked_in_at',
'metadata',
];
protected $casts = [
'badge_fields' => 'array',
'metadata' => 'array',
'amount_minor' => 'integer',
'paid_at' => 'datetime',
'checked_in_at' => 'datetime',
];
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
public function organizer(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function amountCedis(): float
{
return round($this->amount_minor / 100, 2);
}
public function isPaid(): bool
{
return $this->amount_minor > 0;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QrScanEvent extends Model
{
protected $fillable = [
'qr_code_id',
'scanned_at',
'ip_hash',
'user_agent',
'device_type',
'browser',
'os',
'country_code',
'referrer',
'is_unique',
];
protected $casts = [
'scanned_at' => 'datetime',
'is_unique' => 'boolean',
];
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use App\Support\Qr\QrStyleDefaults;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** Per-account QR Plus preferences (notifications and new-code defaults). */
class QrSetting extends Model
{
protected $fillable = [
'user_id',
'notify_email',
'product_updates',
'low_balance_alerts',
'default_type',
'default_style',
];
protected $casts = [
'product_updates' => 'boolean',
'low_balance_alerts' => 'boolean',
'default_style' => 'array',
];
/** @return list<string> */
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<string, mixed> */
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<string, mixed>|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();
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** Membership linking a user to an account (owner) whose QR codes they can manage. */
class QrTeamMember extends Model
{
public const ROLE_ADMIN = 'admin';
public const ROLE_MEMBER = 'member';
public const STATUS_INVITED = 'invited';
public const STATUS_ACTIVE = 'active';
protected $fillable = ['account_id', 'user_id', 'email', 'role', 'status', 'token', 'accepted_at'];
protected $casts = ['accepted_at' => '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,
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QrTransaction extends Model
{
public const TYPE_CREDIT = 'credit';
public const TYPE_DEBIT = 'debit';
protected $fillable = [
'user_id',
'qr_wallet_id',
'qr_code_id',
'type',
'amount_ghs',
'balance_after_ghs',
'reference',
'status',
'description',
'metadata',
];
protected $casts = [
'amount_ghs' => '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);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QrWallet extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_SUSPENDED = 'suspended';
protected $fillable = [
'user_id',
'credit_balance',
'qr_codes_total',
'scans_total',
'status',
];
protected $casts = [
'credit_balance' => '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;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Laravel\Sanctum\HasApiTokens;
/**
* Thin local mirror of the platform identity (auth.ladill.com owns users).
*/
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return ['email_verified_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<int, User> */
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 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;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Notifications;
use App\Models\Domain;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class DomainVerifiedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly Domain $domain
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->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),
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Notifications;
use App\Models\QrCode;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class EventProgrammeSharedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly QrCode $event,
private readonly QrCode $programme,
private readonly ?string $attendeeName = null,
) {}
public function via(mixed $notifiable): array
{
return ['mail'];
}
public function toMail(mixed $notifiable): MailMessage
{
$eventName = $this->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,
]);
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class HostingActivatedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly string $planName,
private readonly string $activationDate,
private readonly ?string $domainName = null,
private readonly ?string $manageUrl = null
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->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'),
];
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class HostingDeveloperAddedNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* @param array<int, string> $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'),
];
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Notifications;
use App\Models\HostingAccount;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class HostingExpiringNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly HostingAccount $account,
private readonly int $daysRemaining,
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->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',
};
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Notifications;
use App\Models\HostingAccount;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class HostingResourceWarningNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly HostingAccount $account,
private readonly string $subjectLine,
private readonly string $messageBody
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->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),
];
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Notifications;
use App\Models\HostingAccount;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class HostingSuspendedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly HostingAccount $account,
private readonly string $reason,
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->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),
];
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Notifications;
use App\Models\Domain;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class SslExpiringNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly Domain $domain,
private readonly int $daysUntilExpiry
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->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),
];
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Notifications;
use App\Models\Domain;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class SslProvisionedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly Domain $domain
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->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),
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Policies;
use App\Models\QrCode;
use App\Models\User;
class QrCodePolicy
{
public function view(User $user, QrCode $qrCode): bool
{
return $user->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);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Providers;
use App\Models\QrCode;
use App\Policies\QrCodePolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Gate::policy(QrCode::class, QrCodePolicy::class);
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Services\Afia;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Afia Ladill in-app AI assistant scoped to a product (QR Plus).
*/
class AfiaService
{
public function enabled(): bool
{
return (bool) config('afia.enabled', true) && (string) config('afia.api_key', '') !== '';
}
/**
* @param array<int,array{role:string,text:string}> $history
* @param array<string,mixed> $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<string,mixed> $context */
private function systemPrompt(array $context): string
{
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
return match ((string) config('afia.product', 'qr')) {
'qr' => $this->qrSystemPrompt($ctx),
default => $this->qrSystemPrompt($ctx),
};
}
private function qrSystemPrompt(string $ctx): string
{
return <<<PROMPT
You are Afia, the assistant inside Ladill QR Plus (qrplus.ladill.com).
Help users create and manage dynamic QR codes, understand scans and billing, and print codes that work reliably.
Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
What Ladill QR Plus does and where things live:
- Overview (Dashboard): recent codes, active code count, scans in the last 30 days, wallet balance.
- My Codes: list, create, and edit QR codes. Types: Link (URL), PDF, List of Links, Business profile, WiFi, App download.
- Creating a code: pick a type, set content, customize style (colors, logo, frame), then download PNG/SVG/PDF.
- Dynamic codes: destination can change after printing the printed QR always points to ladill.com/q/{code}.
- Business QR: name, tagline, contact, hours, logo, cover, social links shown as a mobile landing page.
- WiFi QR: guests scan to join; network name and password are encoded in the code.
- PDF QR: hosts a PDF with optional download button on the viewer.
- Billing: each new code debits the Ladill wallet (account portal Wallet). Top up at account.ladill.com/wallet.
- Team: invite teammates to manage codes together (sidebar Team).
- Developers: API tokens for programmatic code creation (sidebar Developers).
Rules:
- Only answer questions about Ladill QR Plus code types, styling, downloads, scans, wallet billing, team, and API.
- If asked about domains, hosting, email, or payments/commerce QR (shop, events, donations), briefly say those live in other Ladill apps and suggest the app launcher.
- Never invent prices, short codes, or wallet balances use the user context below or tell them where to check.
- For print tips: recommend high contrast, adequate quiet zone, test-scan before mass printing.
Current user context:
{$ctx}
PROMPT;
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
/**
* Client for the platform Billing HTTP API the one UserWallet lives on the
* platform; Bird only consumes it. Amounts are integer minor units; the user is
* identified by public_id. Authenticates with the per-consumer config('billing.service') key.
* Contract validated in the monolith (smtp-extraction-runbook §4-A).
*/
class BillingClient
{
private function base(): string
{
return rtrim((string) config('billing.api_url'), '/');
}
private function token(): string
{
return (string) (config('billing.api_key') ?? '');
}
private function get(string $path, array $query): array
{
$res = Http::withToken($this->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();
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace App\Services\Billing;
use App\Models\PlatformSetting;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class PaystackService
{
public function publicKey(): string
{
return (string) $this->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 settingValue(string $key, mixed $fallback = null): mixed
{
try {
if (!Schema::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;
}
}
@@ -0,0 +1,242 @@
<?php
namespace App\Services\Events;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Models\UserWalletTransaction;
use App\Services\Billing\PaystackService;
use App\Services\Billing\SmsService;
use App\Services\Billing\WalletService;
use Illuminate\Support\Str;
use RuntimeException;
class EventRegistrationService
{
/** Platform fee on paid ticket sales. */
public const PLATFORM_FEE_RATE_TICKETING = 0.15;
/** Reduced platform fee on contribution / donation funds. */
public const PLATFORM_FEE_RATE_CONTRIBUTIONS = 0.09;
public function __construct(
private PaystackService $paystack,
private WalletService $wallet,
private SmsService $sms,
) {}
/**
* Register an attendee. Free tiers confirm instantly; paid tiers return a
* Paystack checkout URL and stay pending until payment completes.
*
* @param array<string, mixed> $data
* @return array{registration: QrEventRegistration, paid: bool, checkout_url: ?string}
*/
public function register(QrCode $qrCode, array $data): array
{
$content = $qrCode->content();
$mode = ($content['mode'] ?? 'ticketing') === 'contributions' ? 'contributions' : 'ticketing';
if (empty($content['registration_open'])) {
throw new RuntimeException($mode === 'contributions'
? 'Contributions for this event are closed.'
: 'Registration for this event is closed.');
}
$tierName = trim((string) ($data['tier'] ?? ''));
$tier = null;
if ($mode === 'contributions') {
if (! in_array($tierName, $content['contribution_categories'] ?? [], true)) {
throw new RuntimeException('Select a valid contribution category.');
}
} else {
$tier = collect($content['tiers'] ?? [])->firstWhere('name', $tierName);
if (! $tier) {
throw new RuntimeException('Select a valid ticket type.');
}
}
$name = trim((string) ($data['attendee_name'] ?? ''));
if ($name === '') {
throw new RuntimeException('Enter your full name.');
}
$email = trim((string) ($data['attendee_email'] ?? ''));
if ($email === '' || ! str_contains($email, '@')) {
throw new RuntimeException('Enter a valid email address.');
}
// Capacity guard (0 = unlimited) — ticketing only.
if ($mode === 'ticketing') {
$capacity = (int) ($tier['capacity'] ?? 0);
if ($capacity > 0) {
$taken = QrEventRegistration::query()
->where('qr_code_id', $qrCode->id)
->where('tier_name', $tierName)
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->count();
if ($taken >= $capacity) {
throw new RuntimeException('Sorry, "' . $tierName . '" is sold out.');
}
}
}
$badgeFields = [];
foreach ($content['badge_fields'] ?? [] as $label) {
$value = trim((string) (($data['badge_fields'] ?? [])[$label] ?? ''));
if ($value !== '') {
$badgeFields[$label] = mb_substr($value, 0, 120);
}
}
if ($mode === 'contributions') {
$priceGhs = round((float) ($data['amount'] ?? 0), 2);
if ($priceGhs <= 0) {
throw new RuntimeException('Enter a contribution amount.');
}
} else {
$priceGhs = round((float) ($tier['price'] ?? 0), 2);
}
$amountMinor = (int) round($priceGhs * 100);
$registration = QrEventRegistration::create([
'qr_code_id' => $qrCode->id,
'user_id' => $qrCode->user_id,
'reference' => 'QRE-' . strtoupper(Str::random(16)),
'badge_code' => $this->uniqueBadgeCode(),
'tier_name' => $tierName,
'amount_minor' => $amountMinor,
'currency' => $content['currency'] ?? 'GHS',
'attendee_name' => mb_substr($name, 0, 120),
'attendee_email' => $email,
'attendee_phone' => trim((string) ($data['attendee_phone'] ?? '')) ?: null,
'badge_fields' => $badgeFields ?: null,
'status' => $amountMinor > 0 ? QrEventRegistration::STATUS_PENDING : QrEventRegistration::STATUS_CONFIRMED,
]);
// Free ticket — done.
if ($amountMinor === 0) {
$this->notifyConfirmed($registration->fresh('qrCode'));
return ['registration' => $registration, 'paid' => false, 'checkout_url' => null];
}
// Paid ticket — start Paystack.
$reference = 'QREP-' . strtoupper(Str::random(16));
$registration->update(['payment_reference' => $reference]);
$paystackData = $this->paystack->initializeTransaction([
'email' => $email,
'amount' => $amountMinor,
'currency' => $registration->currency,
'reference' => $reference,
'callback_url' => route('qr.public.event.callback', ['shortCode' => $qrCode->short_code]),
'metadata' => [
'registration_id' => $registration->id,
'qr_code_id' => $qrCode->id,
'merchant_id' => $qrCode->user_id,
'attendee_name' => $registration->attendee_name,
'attendee_email' => $email,
],
]);
return [
'registration' => $registration,
'paid' => true,
'checkout_url' => 'https://checkout.paystack.com/' . $paystackData['access_code'],
];
}
public function complete(string $reference): QrEventRegistration
{
$registration = QrEventRegistration::where('payment_reference', $reference)
->where('status', QrEventRegistration::STATUS_PENDING)
->firstOrFail();
$data = $this->paystack->verifyTransaction($reference);
if (($data['status'] ?? '') !== 'success') {
$registration->update(['status' => QrEventRegistration::STATUS_FAILED]);
throw new RuntimeException('Payment was not successful.');
}
$feeRate = ($registration->qrCode?->content()['mode'] ?? 'ticketing') === 'contributions'
? self::PLATFORM_FEE_RATE_CONTRIBUTIONS
: self::PLATFORM_FEE_RATE_TICKETING;
$paidAmount = round(($data['amount'] ?? 0) / 100, 2);
$platformFee = round($paidAmount * $feeRate, 2);
$organizerAmount = round($paidAmount - $platformFee, 2);
$registration->update([
'status' => QrEventRegistration::STATUS_CONFIRMED,
'paid_at' => now(),
'metadata' => array_merge((array) $registration->metadata, ['paystack' => $data, 'platform_fee_ghs' => $platformFee]),
]);
$this->wallet->credit(
$registration->organizer,
$organizerAmount,
UserWalletTransaction::SOURCE_SERVICE_TRANSFER,
$reference,
sprintf(
'%s — %s (%s)',
($registration->qrCode->content()['mode'] ?? 'ticketing') === 'contributions' ? 'Event contribution' : 'Event ticket',
$registration->qrCode->label,
$registration->tier_name
),
[
'source' => 'qr_event',
'registration_id' => $registration->id,
'qr_code_id' => $registration->qr_code_id,
'platform_fee_ghs' => $platformFee,
]
);
$this->notifyConfirmed($registration->fresh('qrCode'));
return $registration;
}
private function notifyConfirmed(QrEventRegistration $registration): void
{
if (! $registration->attendee_phone) {
return;
}
$content = $registration->qrCode?->content() ?? [];
$eventName = $content['name'] ?? $registration->qrCode?->label ?? config('app.name');
$firstName = explode(' ', $registration->attendee_name)[0];
if (($content['mode'] ?? 'ticketing') === 'contributions') {
$message = sprintf(
'Hi %s, thank you for your %s of %s %s to %s. Ref: %s',
$firstName,
$registration->tier_name,
$registration->currency,
number_format($registration->amountCedis(), 2),
$eventName,
$registration->badge_code
);
} else {
$message = sprintf(
'Hi %s, you are registered for %s (%s). Badge code: %s',
$firstName,
$eventName,
$registration->tier_name,
$registration->badge_code
);
}
$this->sms->send($registration->attendee_phone, $message);
}
private function uniqueBadgeCode(): string
{
do {
$code = strtoupper(Str::random(8));
} while (QrEventRegistration::where('badge_code', $code)->exists());
return $code;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Services\Identity;
use Illuminate\Support\Facades\Http;
class IdentityClient
{
/** @return array<string, mixed> */
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<string, mixed> */
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<string, mixed> */
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;
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace App\Services\Qr;
use App\Models\QrCode;
use App\Models\QrScanEvent;
use App\Models\QrWallet;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class QrAnalyticsService
{
/**
* @return array<string, mixed>
*/
public function 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<int, object{date: string, total: int}>
*/
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<int, array{label: string, total: int}>
*/
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<int, QrScanEvent>
*/
public function recentScans(QrCode $qrCode, int $limit = 20)
{
return QrScanEvent::query()
->where('qr_code_id', $qrCode->id)
->latest('scanned_at')
->limit($limit)
->get();
}
}
+523
View File
@@ -0,0 +1,523 @@
<?php
namespace App\Services\Qr;
use App\Models\QrCode;
use App\Models\QrDocument;
use App\Models\QrWallet;
use App\Models\User;
use App\Support\Qr\QrStyleDefaults;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
class QrCodeManagerService
{
public function __construct(
private QrWalletBillingService $billing,
private QrImageGeneratorService $imageGenerator,
private QrPayloadValidator $payloadValidator,
) {}
public function walletFor(User $user): QrWallet
{
return $user->qrWallet()->firstOrCreate(
[],
['credit_balance' => 0, 'status' => QrWallet::STATUS_ACTIVE],
);
}
/**
* @param array<string, mixed> $data
*/
public function create(User $user, array $data): QrCode
{
$wallet = $this->walletFor($user);
if (! $wallet->canCreateQr()) {
throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.');
}
$type = (string) ($data['type'] ?? '');
$validated = $this->payloadValidator->validateForCreate($type, $data);
$style = 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 ($style['logo_path'] === null && ($data['logo'] ?? null) instanceof UploadedFile) {
$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(),
]);
$this->billing->debitForQrCreation($wallet, $qrCode);
$this->imageGenerator->generateAndStore($qrCode);
return $qrCode->fresh(['document']);
});
}
/**
* @param array<string, mixed> $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_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 (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<int, mixed> $sections
* @param array<string, mixed> $data
* @return array<int, mixed>
*/
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<string, mixed> $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'],
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<string, mixed> $data
* @return list<array{path: string, title: string}>
*/
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.');
}
}
+488
View File
@@ -0,0 +1,488 @@
<?php
namespace App\Services\Qr;
use App\Models\QrCode as QrCodeModel;
use App\Support\Qr\QrFrameStyleCatalog;
use App\Support\Qr\QrModuleStyleCatalog;
use App\Support\Qr\QrStyleDefaults;
use chillerlan\QRCode\Common\EccLevel;
use chillerlan\QRCode\Data\QRMatrix;
use chillerlan\QRCode\QROptions;
use chillerlan\QRCode\QRCode;
use Illuminate\Support\Facades\Storage;
class QrImageGeneratorService
{
public function generateAndStore(QrCodeModel $qrCode): void
{
$url = $qrCode->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<string, mixed> $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<string, mixed> $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<string, mixed> $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<int> */
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<string, mixed> $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('/<svg[\s>]/i', $svg)) {
return null;
}
// Drop XML declaration / doctype.
$svg = preg_replace('/<\?xml.*?\?>/is', '', $svg);
$svg = preg_replace('/<!DOCTYPE.*?>/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('/<image\b(?:(?!href)[^>])*(?:xlink:)?href\s*=\s*"(?!data:)[^"]*"[^>]*>/is', '', $svg);
$svg = trim($svg);
if (! str_contains($svg, '<svg')) {
return null;
}
// Guarantee namespaces + an XML prolog so the file renders in strict SVG
// viewers (Illustrator, Inkscape, macOS Preview, print pipelines), not just
// browsers. qr-code-styling's DOM serialization can omit xmlns:xlink, which
// browsers tolerate but standalone viewers reject (logo/refs fail to render).
if (preg_match('/<svg\b[^>]*>/i', $svg, $m)) {
$open = $m[0];
$fixed = $open;
if (! preg_match('/\sxmlns\s*=/i', $fixed)) {
$fixed = preg_replace('/<svg\b/i', '<svg xmlns="http://www.w3.org/2000/svg"', $fixed, 1);
}
if (str_contains($svg, 'xlink:') && ! preg_match('/\sxmlns:xlink\s*=/i', $fixed)) {
$fixed = preg_replace('/<svg\b/i', '<svg xmlns:xlink="http://www.w3.org/1999/xlink"', $fixed, 1);
}
if ($fixed !== $open) {
$svg = substr_replace($svg, $fixed, strpos($svg, $open), strlen($open));
}
}
if (! preg_match('/^\s*<\?xml/i', $svg)) {
$svg = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . $svg;
}
return $svg;
}
public function isValidPngBinary(string $bytes): bool
{
if ($bytes === '') {
return false;
}
if (str_starts_with($bytes, "\x89PNG\r\n\x1a\n")) {
return true;
}
// Legacy bug: PNG was stored as a base64 string instead of raw bytes.
if (str_starts_with($bytes, 'iVBORw0KGgo')) {
return false;
}
return false;
}
public function normalizeStoredPng(?string $path): ?string
{
if (! $path || ! Storage::disk('qr')->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;
}
}
+644
View File
@@ -0,0 +1,644 @@
<?php
namespace App\Services\Qr;
use App\Models\QrCode;
use App\Support\Qr\QrDateFormatter;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\UploadedFile;
use RuntimeException;
class QrPayloadValidator
{
/**
* @param array<string, mixed> $input
* @return array{content: array<string, mixed>, 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),
default => throw new RuntimeException('Unsupported QR type.'),
};
}
/**
* @param array<string, mixed> $input
* @return array{content: array<string, mixed>, 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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<int, array{price: float}> $tiers */
private function eventHasPaidTier(array $tiers): bool
{
foreach ($tiers as $tier) {
if ((float) ($tier['price'] ?? 0) > 0) {
return true;
}
}
return false;
}
/** @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<array{title: string, url: string}> */
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<array{name: string, items: list<array{name: string, description: string, price: string, image_path: ?string}>}>
*/
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.');
}
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Services\Qr;
class QrPdfExporter
{
public function fromPng(string $pngBinary, string $title): string
{
if (! extension_loaded('gd')) {
throw new \RuntimeException('PDF export requires the GD extension.');
}
$image = imagecreatefromstring($pngBinary);
if ($image === false) {
throw new \RuntimeException('Could not read QR image for PDF export.');
}
$width = imagesx($image);
$height = imagesy($image);
$canvas = imagecreatetruecolor($width, $height);
$white = imagecolorallocate($canvas, 255, 255, 255);
imagefilledrectangle($canvas, 0, 0, $width, $height, $white);
imagecopy($canvas, $image, 0, 0, 0, 0, $width, $height);
imagedestroy($image);
ob_start();
imagejpeg($canvas, null, 95);
$jpeg = (string) ob_get_clean();
imagedestroy($canvas);
return $this->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);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Services\Qr;
use App\Models\QrCode;
use App\Models\QrScanEvent;
use App\Models\QrWallet;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class QrScanRecorder
{
public function record(QrCode $qrCode, Request $request): QrScanEvent
{
$parsed = $this->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,
];
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Services\Qr;
use App\Models\QrCode;
use App\Models\QrTransaction;
use App\Models\QrWallet;
use App\Models\User;
use App\Services\Billing\BillingClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
/**
* QR Plus billing consumes the platform UserWallet via the Billing API.
*/
class QrWalletBillingService
{
public function __construct(
private BillingClient $billing,
) {}
public function balanceCedis(User $user): float
{
return $this->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],
]);
});
}
}
+369
View File
@@ -0,0 +1,369 @@
<?php
namespace App\Support;
use App\Services\Domain\DomainPricingService;
class DomainConfig
{
private const PRIORITY_TLDS = [
'com', 'org', 'net', 'ai', 'io', 'co', 'app', 'dev',
'store', 'shop', 'online', 'site', 'tech', 'cloud', 'pro', 'me',
'xyz', 'club', 'live', 'com.ng', 'com.gh', 'co.za', 'co.uk', 'info', 'biz',
];
private const DEFAULT_PAGE_SIZE = 20;
/**
* Get featured TLDs for initial display (lightweight).
*
* @return list<string>
*/
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<string>
*/
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<string>, 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<string>
*/
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<string>, 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<int, array{domain: string, ...}> $results
* @return array<int, array{domain: string, ...}>
*/
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<string, mixed> $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<string> $tlds
* @return list<string>
*/
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<string, string>
*/
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<string>
*/
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<string, string>
*/
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<string>
*/
private static function livePricedTlds(): array
{
try {
return app(DomainPricingService::class)->getLivePricedTlds();
} catch (\Throwable) {
return [];
}
}
/**
* @param list<string> $tlds
* @return array<string, array{register: int, renew: int, transfer: int, currency: string}>
*/
private static function pricingMapForTlds(array $tlds): array
{
try {
return app(DomainPricingService::class)->getPricingForTlds($tlds);
} catch (\Throwable) {
return [];
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Support;
final class DomainGlobeIcon
{
public const VIEW_BOX = '0 0 14 14';
/** 14×14 globe for UI icons (inherits color via currentColor). */
public const ICON_ASSET = 'images/ladill-icons/domain.svg';
public static function paths(): string
{
return '<path d="M7 13.5C10.5899 13.5 13.5 10.5899 13.5 7C13.5 3.41015 10.5899 0.5 7 0.5C3.41015 0.5 0.5 3.41015 0.5 7C0.5 10.5899 3.41015 13.5 7 13.5Z" stroke-linecap="round" stroke-linejoin="round"/>'
.'<path d="M0.5 7H13.5" stroke-linecap="round" stroke-linejoin="round"/>'
.'<path d="M9.5 7C9.3772 9.37699 8.50168 11.6533 7 13.5C5.49832 11.6533 4.6228 9.37699 4.5 7C4.6228 4.62301 5.49832 2.34665 7 0.5C8.50168 2.34665 9.3772 4.62301 9.5 7V7Z" stroke-linecap="round" stroke-linejoin="round"/>';
}
public static function svg(string $class = 'h-5 w-5'): string
{
return sprintf(
'<svg class="%s" viewBox="%s" fill="none" stroke="currentColor" stroke-width="1" aria-hidden="true">%s</svg>',
e($class),
self::VIEW_BOX,
self::paths()
);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Support\Events;
use App\Models\QrCode;
use Illuminate\Support\Collection;
/**
* Generates ZPL II label code for Zebra-style badge/label printers.
* One ^XA…^XZ label block per attendee, sized to the chosen badge size at 203 dpi.
*/
class EventBadgeZpl
{
/** @param Collection<int, \App\Models\QrEventRegistration> $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) ?? '');
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Support;
/** Storage-tier pricing for mailboxes (config/email.php quota_tiers). */
class MailboxPricing
{
/** @return array<int,array{mb:int,price_minor:int,label:string}> */
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';
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Support\Qr;
use App\Models\QrCode;
use Illuminate\Support\Collection;
/**
* Generates ZPL II label code for Zebra-style badge/label printers.
* One ^XA…^XZ label block per attendee, sized to the chosen badge size at 203 dpi.
*/
class EventBadgeZpl
{
/** @param Collection<int, \App\Models\QrEventRegistration> $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) ?? '');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Support\Qr;
class QrCornerStyleCatalog
{
/** @return array<string, array{label: string}> */
public static function outerStyles(): array
{
return [
'square' => ['label' => 'Square eye'],
'rounded' => ['label' => 'Rounded'],
'circle' => ['label' => 'Circular'],
];
}
/** @return array<string, array{label: string}> */
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]);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Support\Qr;
class QrCoverImageSpec
{
/** Wide hero banners (business, church, event, itinerary, menu, shop). */
public const BANNER = '1920×1080 px (16:9 landscape)';
/** Book product cover on the landing page. */
public const BOOK = '800×1067 px (3:4 portrait)';
public static function label(string $variant = 'banner'): string
{
return match ($variant) {
'book' => self::BOOK,
default => self::BANNER,
};
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Support\Qr;
use Carbon\Carbon;
class QrDateFormatter
{
public static function forInput(?string $value): string
{
if ($value === null || trim($value) === '') {
return '';
}
try {
return Carbon::parse($value)->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);
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Support\Qr;
class QrFrameStyleCatalog
{
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
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<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
public static function visible(): array
{
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
}
/** @return list<string> */
public static function keys(): array
{
return array_keys(self::all());
}
public static function isValid(string $style): bool
{
return isset(self::all()[$style]);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Support\Qr;
class QrModuleStyleCatalog
{
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
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<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
public static function visible(): array
{
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
}
/** @return list<string> */
public static function keys(): array
{
return array_keys(self::all());
}
public static function isValid(string $style): bool
{
return isset(self::all()[$style]);
}
/** @return array<string, mixed> */
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);
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Support\Qr;
class QrScanReliability
{
/** @param array<string, mixed> $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<string, mixed> $style
* @return array{level: string, messages: list<string>}
*/
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<string, mixed> $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]);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Support\Qr;
class QrStyleDefaults
{
/** @return array<string, mixed> */
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<string, mixed>|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<string, mixed>|null $style */
public static function mergeForRender(?array $style): array
{
return QrStyleNormalizer::normalize(self::merge($style));
}
/** @param array<string, mixed> $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;
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Support\Qr;
class QrStyleNormalizer
{
/**
* Silently tune styles so more phone cameras (including Samsung) can read the code.
*
* @param array<string, mixed> $style
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $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]);
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Support\Qr;
use App\Models\QrCode;
/**
* Ladill Events product types events and programme/itinerary outlines only.
*/
class QrTypeCatalog
{
/** @return list<string> */
public static function eventsTypes(): array
{
return [
QrCode::TYPE_EVENT,
QrCode::TYPE_ITINERARY,
];
}
/** @return list<string> */
public static function eventTypes(): array
{
return [QrCode::TYPE_EVENT];
}
/** @return list<string> */
public static function programmeTypes(): array
{
return [QrCode::TYPE_ITINERARY];
}
/** @return array<string, array{label: string, description: string, category: string, icon: string}> */
public static function all(): array
{
$catalog = [
QrCode::TYPE_EVENT => [
'label' => 'Event',
'description' => 'Sell tickets, take contributions, or register attendees for free',
'category' => 'events',
'icon' => 'event.svg',
],
QrCode::TYPE_ITINERARY => [
'label' => 'Programme',
'description' => 'Share a multi-day event schedule or programme outline',
'category' => 'programmes',
'icon' => 'itinerary.svg',
],
];
return array_intersect_key($catalog, array_flip(self::eventsTypes()));
}
/** @return list<string> */
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;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Support\Qr;
class QrWifiPayload
{
/**
* Build a WiFi QR payload (ZXing MECARD format) for one-tap network join on scan.
*
* @param array<string, mixed> $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
);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Support;
use App\Models\DomainOrder;
use App\Models\RcServiceOrder;
use Illuminate\Support\Collection;
use RuntimeException;
/**
* ResellerClub is legacy-only for new sales. Renewals and management remain available
* for customers with existing ResellerClub services.
*/
final class ResellerClubLegacy
{
public static function integrationEnabled(): bool
{
return (bool) config('hosting.legacy.rc_enabled', true);
}
public static function newOrdersEnabled(): bool
{
return self::integrationEnabled()
&& (bool) config('hosting.legacy.rc_new_orders_enabled', false);
}
public static function renewalsEnabled(): bool
{
return self::integrationEnabled()
&& (bool) config('hosting.legacy.rc_renewals_enabled', true);
}
public static function isRenewalRcServiceOrder(RcServiceOrder $order): bool
{
if ($order->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<int, RcServiceOrder>|iterable<int, RcServiceOrder> $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.';
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
use App\Models\User;
if (! function_exists('ladill_account')) {
/**
* The account the current request acts within the owner User. Defaults to
* the authenticated user; a team member who switched accounts gets the owner.
* Set by the SetActingAccount middleware.
*/
function ladill_account(): ?User
{
$request = request();
return $request->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('ladill_servers_url')) {
function ladill_servers_url(string $path = ''): string
{
return 'https://'.config('app.servers_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class UserLayout extends Component
{
public function render(): View
{
return view('layouts.user');
}
}
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);
+25
View File
@@ -0,0 +1,25 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->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,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+7
View File
@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];
+95
View File
@@ -0,0 +1,95 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "ladill/events",
"type": "project",
"description": "Ladill Events — tickets, attendees, badges & programmes at events.ladill.com",
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"require": {
"php": "^8.2",
"chillerlan/php-qrcode": "^5.0",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1",
"phpseclib/phpseclib": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.50"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
},
"files": [
"app/Support/helpers.php"
]
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
Generated
+8858
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
// Afia — in-app AI assistant scoped to Ladill Events.
'product' => env('AFIA_PRODUCT', 'events'),
'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'),
];
+134
View File
@@ -0,0 +1,134 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Ladill Events'),
/*
|--------------------------------------------------------------------------
| 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 Events (events.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')),
'events_domain' => env('EVENTS_DOMAIN', parse_url((string) env('APP_URL', 'https://events.ladill.com'), PHP_URL_HOST) ?: 'events.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')),
];
+117
View File
@@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'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),
];
+7
View File
@@ -0,0 +1,7 @@
<?php
return [
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
'api_key' => env('BILLING_API_KEY_EVENTS'),
'service' => 'events',
];
+117
View File
@@ -0,0 +1,117 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => 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-'),
];
+184
View File
@@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => 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'),
]) : [],
],
'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),
],
],
];
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'api_url' => env('DOMAIN_API_URL', 'https://ladill.com/api/domains'),
'api_key' => env('DOMAIN_API_KEY_HOSTING'),
];
+21
View File
@@ -0,0 +1,21 @@
<?php
return [
/*
| Email product plans. Mailboxes are priced purely by the storage tier chosen
| at creation (and changeable later via Upgrade). The 1 GB tier is FREE,
| forever, for every mailbox; larger tiers are billed monthly from the one
| Ladill wallet (§4-A). Money is in minor units (pesewas).
*/
'currency' => 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
],
];
+7
View File
@@ -0,0 +1,7 @@
<?php
return [
// Consumer config for the shared Email-Domain verification API (§4-D).
'api_url' => env('EMAILDOMAIN_API_URL', 'https://ladill.com/api/email-domains'),
'api_key' => env('EMAILDOMAIN_API_KEY_EMAIL'),
];
+87
View File
@@ -0,0 +1,87 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => 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'),
],
];
+703
View File
@@ -0,0 +1,703 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Contabo API Configuration
|--------------------------------------------------------------------------
|
| Credentials for the Contabo Compute Management API.
| Used for provisioning VPS and VDS instances.
|
*/
'contabo' => [
'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),
],
];
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'api_url' => env('IDENTITY_API_URL', 'https://ladill.com/api'),
'api_key' => env('IDENTITY_API_KEY_EVENTS'),
];
+36
View File
@@ -0,0 +1,36 @@
<?php
/*
|--------------------------------------------------------------------------
| Ladill App Launcher SHARED, IDENTICAL across every app/service repo
|--------------------------------------------------------------------------
|
| Lists ONLY fully-extracted apps (each on its own subdomain). To replicate the
| launcher in a new app, copy these three things verbatim into that repo:
| - config/ladill_launcher.php (this file)
| - resources/views/partials/launcher.blade.php
| - public/images/launcher-icons/* (the icon set)
|
| When a service becomes FULLY extracted: add one row below AND drop its icon in
| public/images/launcher-icons/ in EVERY repo. That's the only edit needed.
| Do NOT list a service here until it is fully extracted to its own subdomain.
|
| URLs are absolute (built from the platform root) so this file is byte-identical
| in every app; the blade omits whichever row matches the app's APP_URL host.
| Icons are served from /images/launcher-icons/<icon>.
*/
$root = config('app.platform_domain', 'ladill.com');
return [
'apps' => [
['name' => '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' => '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, 'icon' => 'qrplus.svg'],
['name' => 'Events', 'url' => 'https://events.'.$root, 'icon' => 'events.svg'],
],
];
+132
View File
@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => 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'),
],
],
];
+118
View File
@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => 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')),
],
];
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
// Consumer config for the platform Mailbox provisioning API (§4). Mail infra
// (Postfix/Dovecot via mail DB + pools) lives on the platform; this app
// provisions through the API. The user is identified by public_id.
'api_url' => env('MAILBOX_API_URL', 'https://ladill.com/api/mailboxes'),
'api_key' => env('MAILBOX_API_KEY_EMAIL'),
];
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
'price_per_qr_ghs' => (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),
];
+129
View File
@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => 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',
],
];

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