Initial Ladill Give extraction — online giving pages at give.ladill.com.
Donation checkout via Ladill Pay (9% fee), church/giving QR pages, SSO, and platform scan forwarding. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,49 @@
|
||||
APP_NAME="Ladill Give"
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://give.ladill.com
|
||||
|
||||
PLATFORM_URL=https://ladill.com
|
||||
PLATFORM_DOMAIN=ladill.com
|
||||
AUTH_DOMAIN=auth.ladill.com
|
||||
ACCOUNT_DOMAIN=account.ladill.com
|
||||
GIVE_DOMAIN=give.ladill.com
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=ladill_give
|
||||
DB_USERNAME=ladill_give
|
||||
DB_PASSWORD=
|
||||
|
||||
# Read platform admin settings (Paystack keys live in ladilldb.platform_settings).
|
||||
PLATFORM_DB_HOST=127.0.0.1
|
||||
PLATFORM_DB_PORT=3306
|
||||
PLATFORM_DB_DATABASE=ladilldb
|
||||
PLATFORM_DB_USERNAME=ladill_give
|
||||
PLATFORM_DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_DOMAIN=.ladill.com
|
||||
|
||||
LADILL_SSO_CLIENT_ID=
|
||||
LADILL_SSO_CLIENT_SECRET=
|
||||
|
||||
BILLING_API_URL=https://ladill.com/api/billing
|
||||
BILLING_API_KEY_GIVE=
|
||||
|
||||
PAY_API_URL=https://ladill.com/api/pay
|
||||
PAY_API_KEY_GIVE=
|
||||
|
||||
IDENTITY_API_URL=https://ladill.com/api
|
||||
IDENTITY_API_KEY_GIVE=
|
||||
|
||||
AFIA_ENABLED=true
|
||||
AFIA_PRODUCT=give
|
||||
AFIA_PROVIDER=openai
|
||||
AFIA_MODEL=gpt-4o-mini
|
||||
AFIA_API_KEY=
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
@@ -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
|
||||
@@ -0,0 +1,110 @@
|
||||
name: Deploy Ladill Give
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-give
|
||||
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-give-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||
WORKSPACE: /tmp/${{ gitea.repository_owner }}-give-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
LADILL_APP_ROOT: /var/www/ladill-give
|
||||
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-give-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
|
||||
bash "$WORKSPACE/deploy/deploy.sh"
|
||||
|
||||
- name: Ensure nginx vhost
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
NGINX_SCRIPT="$WORKSPACE/deployment/setup-service-subdomain-nginx.sh"
|
||||
if [ ! -f "$NGINX_SCRIPT" ]; then
|
||||
NGINX_SCRIPT="/var/www/ladill.com/current/deployment/setup-service-subdomain-nginx.sh"
|
||||
fi
|
||||
if [ ! -f "$NGINX_SCRIPT" ]; then
|
||||
echo "WARN: setup-service-subdomain-nginx.sh not found — configure give.ladill.com vhost manually"
|
||||
exit 0
|
||||
fi
|
||||
if sudo -n bash "$NGINX_SCRIPT" give --app /var/www/ladill-give/current; then
|
||||
echo "nginx vhost updated for give.ladill.com"
|
||||
else
|
||||
echo "WARN: nginx vhost step skipped (deploy user cannot sudo) — vhost must already be provisioned"
|
||||
fi
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
shell: bash {0}
|
||||
run: |
|
||||
rm -rf "$WORKSPACE"
|
||||
rm -f "$RELEASE_ARCHIVE"
|
||||
+26
@@ -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
|
||||
@@ -0,0 +1,96 @@
|
||||
# Ladill Give — deploy & cutover runbook
|
||||
|
||||
Standalone app for **online giving pages** at `give.ladill.com` — branded donation
|
||||
pages with collection categories (Offering, Tithe, Building Fund…). Donations
|
||||
settle into the one platform UserWallet (9% fee), then withdraw via
|
||||
`account.ladill.com`.
|
||||
|
||||
Public scans stay at `ladill.com/q/<code>` (blueprint decision — printed codes
|
||||
never migrate). The platform host forwards church/giving codes to this app (see
|
||||
`GiveQrForwarder` on the monolith); grant the platform DB user read access:
|
||||
|
||||
```bash
|
||||
sudo mysql -e "GRANT SELECT ON ladill_give.qr_codes TO 'ladilldb'@'127.0.0.1'; FLUSH PRIVILEGES;"
|
||||
```
|
||||
|
||||
Set `GIVE_DB_*` on the platform `.env` if the lookup user differs from the
|
||||
main app DB user.
|
||||
|
||||
---
|
||||
|
||||
## 0. Prerequisites
|
||||
|
||||
| Secret | Where |
|
||||
|---|---|
|
||||
| `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | `passport:client` on platform |
|
||||
| `BILLING_API_KEY_GIVE` | platform `.env` + this app |
|
||||
| `PAY_API_KEY_GIVE` | platform `.env` + this app — Ladill Pay checkout (9% donations tier) |
|
||||
| `IDENTITY_API_KEY_GIVE` | platform `.env` + this app |
|
||||
| `PLATFORM_DB_*` | read `ladilldb.platform_settings` (Paystack keys from admin → Billing) |
|
||||
|
||||
## 1. Gitea repo + CI
|
||||
|
||||
1. Repo: **ladill-give** (create on Gitea)
|
||||
2. Push to `main` triggers `.gitea/workflows/deploy.yml`.
|
||||
3. App root: `/var/www/ladill-give`
|
||||
|
||||
## 2. Server app-slot + database
|
||||
|
||||
```bash
|
||||
sudo install -d -o deploy -g www-data /var/www/ladill-give
|
||||
sudo install -d -o deploy -g www-data /var/www/ladill-give/{releases,shared}
|
||||
sudo mysql -e "CREATE DATABASE ladill_give CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
sudo mysql -e "CREATE USER 'ladill_give'@'127.0.0.1' IDENTIFIED BY '<pw>';"
|
||||
sudo mysql -e "GRANT ALL ON ladill_give.* TO 'ladill_give'@'127.0.0.1'; FLUSH PRIVILEGES;"
|
||||
sudo mysql -e "GRANT SELECT ON ladilldb.platform_settings TO 'ladill_give'@'127.0.0.1'; FLUSH PRIVILEGES;"
|
||||
```
|
||||
|
||||
## 3. Register OIDC client (platform)
|
||||
|
||||
```bash
|
||||
php artisan passport:client \
|
||||
--name="Ladill Give" \
|
||||
--redirect_uri="https://give.ladill.com/sso/callback"
|
||||
```
|
||||
|
||||
## 4. Platform integration
|
||||
|
||||
```env
|
||||
BILLING_API_KEY_GIVE=<same>
|
||||
IDENTITY_API_KEY_GIVE=<same>
|
||||
PAY_API_KEY_GIVE=<same>
|
||||
RP_GIVE_FRONTCHANNEL_LOGOUT=https://give.ladill.com/sso/logout-frontchannel
|
||||
LADILL_GIVE_APP_URL=https://give.ladill.com
|
||||
GIVE_DB_DATABASE=ladill_give
|
||||
```
|
||||
|
||||
Add `give` to `config/pdns.php` service subdomains.
|
||||
|
||||
## 5. nginx + TLS
|
||||
|
||||
```bash
|
||||
sudo deployment/setup-service-subdomain-nginx.sh give --app /var/www/ladill-give/current
|
||||
```
|
||||
|
||||
## 6. First deploy
|
||||
|
||||
```bash
|
||||
cd /var/www/ladill-give/current
|
||||
php artisan migrate --force
|
||||
php artisan config:cache route:cache view:cache
|
||||
```
|
||||
|
||||
## 7. Verify
|
||||
|
||||
- SSO login at `give.ladill.com`
|
||||
- Create a giving page, download QR PNG
|
||||
- Scan `/q/<code>`, give test amount, confirm wallet credit (net of 9%)
|
||||
- Withdraw from `account.ladill.com/wallet`
|
||||
|
||||
## Product IA (blueprint)
|
||||
|
||||
- **Overview** — total raised, donors, active causes, recent donations
|
||||
- **Giving Pages** — create/manage branded giving pages + QR
|
||||
- **Donations** — incoming donation feed
|
||||
- **Payouts** — wallet-first settlement, withdraw to bank/MoMo
|
||||
- **Settings** — organisation profile
|
||||
@@ -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).
|
||||
@@ -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,243 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrTeamMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Response as HttpResponse;
|
||||
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('give.dashboard'));
|
||||
|
||||
if (Auth::check()) {
|
||||
return $this->safeRedirect($intended, route('give.dashboard'));
|
||||
}
|
||||
|
||||
if ($this->attemptSilentRefresh($request, $intended)) {
|
||||
return $this->safeRedirect($intended, route('give.dashboard'));
|
||||
}
|
||||
|
||||
$verifier = Str::random(64);
|
||||
$state = Str::random(40);
|
||||
$request->session()->put('sso.verifier', $verifier);
|
||||
$request->session()->put('sso.state', $state);
|
||||
$request->session()->put('sso.intended', $intended);
|
||||
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$query = [
|
||||
'response_type' => 'code',
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||
'scope' => 'openid profile email',
|
||||
'state' => $state,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
];
|
||||
|
||||
$loginHint = (string) $request->session()->get('sso.login_hint', '');
|
||||
if ($loginHint !== '') {
|
||||
$query['login_hint'] = $loginHint;
|
||||
}
|
||||
|
||||
if (! $request->boolean('interactive')) {
|
||||
$query['prompt'] = 'none';
|
||||
}
|
||||
|
||||
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('give.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,
|
||||
]);
|
||||
}
|
||||
|
||||
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
||||
if (! $user) {
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => $intended,
|
||||
'interactive' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
QrTeamMember::linkPendingInvitesFor($user);
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
|
||||
return $this->safeRedirect($intended, route('give.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 attemptSilentRefresh(Request $request, string $intended): bool
|
||||
{
|
||||
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
|
||||
if ($refreshToken === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||
'scope' => 'openid profile email',
|
||||
]);
|
||||
|
||||
if ($tokenRes->failed()) {
|
||||
$request->session()->forget('sso.refresh_token');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
||||
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->put('sso.intended', $intended);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
|
||||
{
|
||||
$refreshToken = (string) $tokenRes->json('refresh_token', '');
|
||||
if ($refreshToken !== '') {
|
||||
$request->session()->put('sso.refresh_token', $refreshToken);
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
|
||||
if ($claims->failed() || ! $claims->json('sub')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$email = (string) ($claims->json('email') ?: '');
|
||||
if ($email !== '') {
|
||||
$request->session()->put('sso.login_hint', $email);
|
||||
}
|
||||
|
||||
return User::updateOrCreate(
|
||||
['public_id' => (string) $claims->json('sub')],
|
||||
[
|
||||
'name' => $claims->json('name'),
|
||||
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
|
||||
'avatar_url' => $claims->json('picture'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function shouldLogoutForMailbox(Request $request): bool
|
||||
{
|
||||
$mailbox = strtolower(trim((string) $request->query('mailbox', '')));
|
||||
if ($mailbox === '' || ! str_contains($mailbox, '@')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtolower((string) $user->email) !== $mailbox;
|
||||
}
|
||||
|
||||
private function 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Give;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GiveDonation;
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DonationsController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$search = trim((string) $request->query('q', ''));
|
||||
|
||||
$qrIds = $account->qrCodes()
|
||||
->where('type', QrCode::TYPE_CHURCH)
|
||||
->pluck('id');
|
||||
|
||||
$donations = GiveDonation::query()
|
||||
->whereIn('qr_code_id', $qrIds)
|
||||
->when($search !== '', function ($query) use ($search) {
|
||||
$like = '%'.$search.'%';
|
||||
$query->where(function ($inner) use ($like) {
|
||||
$inner->where('payer_name', 'like', $like)
|
||||
->orWhere('payer_email', 'like', $like)
|
||||
->orWhere('collection_type', 'like', $like)
|
||||
->orWhere('reference', 'like', $like)
|
||||
->orWhere('payment_reference', 'like', $like)
|
||||
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
|
||||
});
|
||||
})
|
||||
->with('qrCode')
|
||||
->latest('created_at')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
return view('give.donations', [
|
||||
'donations' => $donations,
|
||||
'search' => $search,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Give;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Qr\QrCodeManagerService;
|
||||
use App\Services\Qr\QrImageGeneratorService;
|
||||
use App\Services\Qr\QrPdfExporter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class GivingPageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private QrCodeManagerService $manager,
|
||||
private QrImageGeneratorService $imageGenerator,
|
||||
private QrPdfExporter $pdfExporter,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$qrCodes = $account->qrCodes()
|
||||
->where('type', QrCode::TYPE_CHURCH)
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
$previewDataUris = $qrCodes->mapWithKeys(function (QrCode $qr) {
|
||||
return [$qr->id => $this->imageGenerator->previewDataUri($qr)];
|
||||
});
|
||||
|
||||
return view('give.giving-pages.index', [
|
||||
'qrCodes' => $qrCodes,
|
||||
'previewDataUris' => $previewDataUris,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('give.giving-pages.create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$request->validate([
|
||||
'label' => ['required', 'string', 'max:120'],
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'org_type' => ['required', 'in:church,school,mosque,ngo,club'],
|
||||
'denomination' => ['nullable', 'string', 'max:120'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'phone' => ['nullable', 'string', 'max:30'],
|
||||
'email' => ['nullable', 'email', 'max:200'],
|
||||
'website' => ['nullable', 'url', 'max:2048'],
|
||||
'address' => ['nullable', 'string', 'max:500'],
|
||||
'service_times' => ['nullable', 'string', 'max:200'],
|
||||
'brand_color' => ['nullable', 'string', 'max:20'],
|
||||
'collection_types' => ['nullable', 'array', 'min:1'],
|
||||
'collection_types.*' => ['string', 'max:80'],
|
||||
'accepts_payment' => ['nullable', 'boolean'],
|
||||
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
]);
|
||||
|
||||
$data = array_merge($request->all(), [
|
||||
'type' => QrCode::TYPE_CHURCH,
|
||||
'currency' => 'GHS',
|
||||
'accepts_payment' => $request->boolean('accepts_payment'),
|
||||
'church_logo' => $request->file('church_logo'),
|
||||
'church_cover' => $request->file('church_cover'),
|
||||
]);
|
||||
|
||||
try {
|
||||
$qrCode = $this->manager->create($account, $data);
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->route('give.giving-pages.show', $qrCode)->with('success', 'Giving page created.');
|
||||
}
|
||||
|
||||
public function show(QrCode $givingPage): View
|
||||
{
|
||||
$this->authorizeGivingPage($givingPage);
|
||||
|
||||
return view('give.giving-pages.show', [
|
||||
'qrCode' => $givingPage->fresh(),
|
||||
'previewDataUri' => $this->imageGenerator->previewDataUri($givingPage),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, QrCode $givingPage): RedirectResponse
|
||||
{
|
||||
$this->authorizeGivingPage($givingPage);
|
||||
|
||||
$request->validate([
|
||||
'label' => ['sometimes', 'string', 'max:120'],
|
||||
'name' => ['sometimes', 'string', 'max:120'],
|
||||
'org_type' => ['sometimes', 'in:church,school,mosque,ngo,club'],
|
||||
'denomination' => ['nullable', 'string', 'max:120'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'phone' => ['nullable', 'string', 'max:30'],
|
||||
'email' => ['nullable', 'email', 'max:200'],
|
||||
'website' => ['nullable', 'url', 'max:2048'],
|
||||
'address' => ['nullable', 'string', 'max:500'],
|
||||
'service_times' => ['nullable', 'string', 'max:200'],
|
||||
'brand_color' => ['nullable', 'string', 'max:20'],
|
||||
'collection_types' => ['nullable', 'array', 'min:1'],
|
||||
'collection_types.*' => ['string', 'max:80'],
|
||||
'accepts_payment' => ['nullable', 'boolean'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
]);
|
||||
|
||||
$data = array_merge($request->all(), [
|
||||
'accepts_payment' => $request->boolean('accepts_payment', (bool) ($givingPage->content()['accepts_payment'] ?? false)),
|
||||
'is_active' => $request->boolean('is_active', $givingPage->is_active),
|
||||
'church_logo' => $request->file('church_logo'),
|
||||
'church_cover' => $request->file('church_cover'),
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->manager->update($givingPage, $data);
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', 'Giving page updated.');
|
||||
}
|
||||
|
||||
public function destroy(QrCode $givingPage): RedirectResponse
|
||||
{
|
||||
$this->authorizeGivingPage($givingPage);
|
||||
|
||||
$this->manager->delete($givingPage);
|
||||
|
||||
return redirect()
|
||||
->route('give.giving-pages.index')
|
||||
->with('success', 'Giving page deleted.');
|
||||
}
|
||||
|
||||
public function preview(QrCode $givingPage): Response
|
||||
{
|
||||
$this->authorizeGivingPage($givingPage);
|
||||
|
||||
$qrCode = $this->imageGenerator->ensureValidImages($givingPage);
|
||||
$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 $givingPage, string $format): StreamedResponse
|
||||
{
|
||||
$this->authorizeGivingPage($givingPage);
|
||||
|
||||
$qrCode = $this->imageGenerator->ensureValidImages($givingPage);
|
||||
$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);
|
||||
|
||||
return response()->streamDownload(fn () => print($pdf), Str::slug($qrCode->label).'-qr.pdf', [
|
||||
'Content-Type' => 'application/pdf',
|
||||
]);
|
||||
}
|
||||
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->download($path, $filename);
|
||||
}
|
||||
|
||||
private function authorizeGivingPage(QrCode $givingPage): void
|
||||
{
|
||||
abort_unless($givingPage->type === QrCode::TYPE_CHURCH, 404);
|
||||
abort_unless($givingPage->user_id === ladill_account()->id, 403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Give;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GiveDonation;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Billing\BillingClient;
|
||||
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();
|
||||
|
||||
$qrIds = $account->qrCodes()
|
||||
->where('type', QrCode::TYPE_CHURCH)
|
||||
->pluck('id');
|
||||
|
||||
$todayStart = now()->startOfDay();
|
||||
|
||||
$todayDonations = GiveDonation::query()
|
||||
->whereIn('qr_code_id', $qrIds)
|
||||
->where('status', GiveDonation::STATUS_PAID)
|
||||
->where('paid_at', '>=', $todayStart);
|
||||
|
||||
$todayCount = (clone $todayDonations)->count();
|
||||
$todayMinor = (int) (clone $todayDonations)->sum('merchant_amount_minor');
|
||||
|
||||
$recentDonations = GiveDonation::query()
|
||||
->whereIn('qr_code_id', $qrIds)
|
||||
->where('status', GiveDonation::STATUS_PAID)
|
||||
->with('qrCode')
|
||||
->latest('paid_at')
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
$givingPageCount = $qrIds->count();
|
||||
$donorCount = GiveDonation::query()
|
||||
->whereIn('qr_code_id', $qrIds)
|
||||
->where('status', GiveDonation::STATUS_PAID)
|
||||
->distinct('payer_email')
|
||||
->count('payer_email');
|
||||
|
||||
$balanceMinor = 0;
|
||||
try {
|
||||
$balanceMinor = $this->billing->balanceMinor($account->public_id);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Give dashboard could not load wallet balance', [
|
||||
'user' => $account->public_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('give.dashboard', [
|
||||
'todayCount' => $todayCount,
|
||||
'todayMinor' => $todayMinor,
|
||||
'givingPageCount' => $givingPageCount,
|
||||
'donorCount' => $donorCount,
|
||||
'recentDonations' => $recentDonations,
|
||||
'balanceMinor' => $balanceMinor,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Give;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GiveDonation;
|
||||
use App\Models\QrCode;
|
||||
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();
|
||||
|
||||
$qrIds = $account->qrCodes()
|
||||
->where('type', QrCode::TYPE_CHURCH)
|
||||
->pluck('id');
|
||||
|
||||
$revenueMinor = (int) GiveDonation::query()
|
||||
->whereIn('qr_code_id', $qrIds)
|
||||
->where('status', GiveDonation::STATUS_PAID)
|
||||
->sum('merchant_amount_minor');
|
||||
|
||||
$balanceMinor = 0;
|
||||
try {
|
||||
$balanceMinor = $this->billing->balanceMinor($account->public_id);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Give payouts could not load wallet balance', [
|
||||
'user' => $account->public_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$accountWalletUrl = 'https://'.config('app.account_domain').'/wallet';
|
||||
|
||||
return view('give.payouts', [
|
||||
'revenueMinor' => $revenueMinor,
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'accountWalletUrl' => $accountWalletUrl,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Public;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Give\GiveDonationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
|
||||
class DonationController extends Controller
|
||||
{
|
||||
public function __construct(private GiveDonationService $donations) {}
|
||||
|
||||
public function store(Request $request, string $shortCode): JsonResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('type', QrCode::TYPE_CHURCH)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
if (! $qrCode->acceptsOrders()) {
|
||||
return response()->json(['error' => 'This giving page does not accept donations.'], 422);
|
||||
}
|
||||
|
||||
if (empty($qrCode->content()['accepts_payment'])) {
|
||||
return response()->json(['error' => 'Online giving is not enabled for this page.'], 422);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'customer_name' => ['required', 'string', 'max:120'],
|
||||
'customer_email' => ['required', 'email', 'max:200'],
|
||||
'customer_phone' => ['required', 'string', 'max:30'],
|
||||
'items' => ['required', 'array', 'min:1'],
|
||||
'items.*.name' => ['required', 'string', 'max:200'],
|
||||
'items.*.price' => ['required', 'numeric', 'min:0.01'],
|
||||
'items.*.qty' => ['required', 'integer', 'min:1', 'max:1'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->donations->initiateOrder($qrCode, $validated);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'checkout_url' => $result['checkout_url'],
|
||||
'callback_url' => $result['callback_url'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function callback(Request $request, string $shortCode): RedirectResponse|View
|
||||
{
|
||||
$reference = trim((string) $request->query('reference', ''));
|
||||
if ($reference === '') {
|
||||
return redirect('/q/'.$shortCode)->with('error', 'Missing payment reference.');
|
||||
}
|
||||
|
||||
try {
|
||||
$donation = $this->donations->complete($reference);
|
||||
} catch (RuntimeException $e) {
|
||||
return redirect('/q/'.$shortCode)->with('order_error', $e->getMessage());
|
||||
} catch (\Throwable) {
|
||||
return redirect('/q/'.$shortCode)->with('order_error', 'Payment could not be verified. Contact the organisation with reference: '.$reference);
|
||||
}
|
||||
|
||||
return view('public.qr.donation-confirmed', [
|
||||
'donation' => $donation,
|
||||
'qrCode' => $donation->qrCode,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
<?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 paymentLogo(string $shortCode): StreamedResponse
|
||||
{
|
||||
return $this->serveContentImage($shortCode, QrCode::TYPE_PAYMENT, 'logo_path');
|
||||
}
|
||||
|
||||
public function eventLogo(string $shortCode): StreamedResponse
|
||||
{
|
||||
return $this->serveContentImage($shortCode, QrCode::TYPE_EVENT, 'logo_path');
|
||||
}
|
||||
|
||||
public function eventCover(string $shortCode): StreamedResponse
|
||||
{
|
||||
return $this->serveContentImage($shortCode, QrCode::TYPE_EVENT, 'cover_path');
|
||||
}
|
||||
|
||||
public function itineraryCover(string $shortCode): StreamedResponse
|
||||
{
|
||||
return $this->serveContentImage($shortCode, QrCode::TYPE_ITINERARY, 'cover_path');
|
||||
}
|
||||
|
||||
private function serveContentImage(string $shortCode, string $type, string $key): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($qrCode->type === $type, 404);
|
||||
|
||||
$path = $qrCode->content()[$key] ?? null;
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
public function appIcon(string $shortCode): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($qrCode->type === QrCode::TYPE_APP, 404);
|
||||
|
||||
$path = $qrCode->content()['icon_path'] ?? null;
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
private function escapeVcard(string $value): string
|
||||
{
|
||||
return str_replace(["\n", "\r", ',', ';'], [' ', ' ', '\\,', '\\;'], $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrSetting;
|
||||
use App\Services\Billing\BillingClient;
|
||||
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, config('billing.service', 'mini'));
|
||||
|
||||
return [$balanceMinor, $ledger];
|
||||
}
|
||||
|
||||
public function wallet(): View
|
||||
{
|
||||
$user = ladill_account();
|
||||
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
|
||||
|
||||
return view('qr.account.wallet', [
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||
'topupUrl' => $this->topupUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function billing(): View
|
||||
{
|
||||
$user = ladill_account();
|
||||
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
|
||||
|
||||
return view('qr.account.billing', [
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||
'topupUrl' => $this->topupUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function settings(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$settings = $account->getOrCreateQrSetting();
|
||||
|
||||
return view('give.settings', [
|
||||
'account' => $account,
|
||||
'settings' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateSettings(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$data = $request->validate([
|
||||
'notify_email' => ['nullable', 'email', 'max:255'],
|
||||
'product_updates' => ['nullable', 'boolean'],
|
||||
'notify_registrations' => ['nullable', 'boolean'],
|
||||
'notify_payouts' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
QrSetting::updateOrCreate(
|
||||
['user_id' => $account->id],
|
||||
[
|
||||
'notify_email' => $data['notify_email'] ?? null,
|
||||
'product_updates' => $request->boolean('product_updates'),
|
||||
'notify_registrations' => $request->boolean('notify_registrations'),
|
||||
'notify_payouts' => $request->boolean('notify_payouts'),
|
||||
],
|
||||
);
|
||||
|
||||
return redirect()->route('account.settings')->with('success', 'Settings saved.');
|
||||
}
|
||||
}
|
||||
@@ -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 won’t be shown again.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $token): RedirectResponse
|
||||
{
|
||||
$request->user()->tokens()->whereKey($token)->delete();
|
||||
|
||||
return redirect()->route('account.developers')->with('success', 'Token revoked.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
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())
|
||||
->withCount(['eventRegistrations as registrations_count' => fn ($q) => $q->where('status', QrEventRegistration::STATUS_CONFIRMED)])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
$totalRegistrations = (int) $qrCodes->sum('registrations_count');
|
||||
|
||||
$ladillWalletBalance = 0.0;
|
||||
try {
|
||||
$ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100;
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Events index could not load Ladill wallet balance', [
|
||||
'user' => $account->public_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('qr-codes.index', [
|
||||
'wallet' => $wallet,
|
||||
'qrCodes' => $qrCodes,
|
||||
'totalRegistrations' => $totalRegistrations,
|
||||
'pricePerQr' => QrWallet::pricePerQr(),
|
||||
'minTopup' => QrWallet::minTopupGhs(),
|
||||
'ladillWalletBalance' => $ladillWalletBalance,
|
||||
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$wallet = $this->manager->walletFor($account);
|
||||
$qrSettings = $account->getOrCreateQrSetting();
|
||||
$requestedType = $request->query('type', QrCode::TYPE_EVENT);
|
||||
if (! QrTypeCatalog::isValid($requestedType)) {
|
||||
$requestedType = QrCode::TYPE_EVENT;
|
||||
}
|
||||
|
||||
return view('qr-codes.create', [
|
||||
'wallet' => $wallet,
|
||||
'requestedType' => $requestedType,
|
||||
'moduleStyles' => QrModuleStyleCatalog::visible(),
|
||||
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
|
||||
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
|
||||
'frameStyles' => QrFrameStyleCatalog::visible(),
|
||||
'pricePerQr' => QrWallet::pricePerQr(),
|
||||
'minTopup' => QrWallet::minTopupGhs(),
|
||||
'ladillWalletBalance' => $this->platformBilling->balanceMinor($account->public_id) / 100,
|
||||
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
||||
'accountDefaultStyle' => $qrSettings->resolvedDefaultStyle(),
|
||||
'accountEventDefaults' => $qrSettings->resolvedEventDefaults(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function checkSlug(Request $request): JsonResponse
|
||||
{
|
||||
$code = (string) $request->query('code', '');
|
||||
|
||||
if (! preg_match('/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', $code)) {
|
||||
return response()->json(['available' => false, 'reason' => 'invalid']);
|
||||
}
|
||||
|
||||
$taken = QrCode::where('short_code', $code)->exists();
|
||||
|
||||
return response()->json(['available' => ! $taken]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'label' => ['required', 'string', 'max:120'],
|
||||
'type' => ['required', 'in:' . implode(',', QrTypeCatalog::keys())],
|
||||
'custom_short_code' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', 'unique:qr_codes,short_code'],
|
||||
'destination_url' => ['nullable', 'url', 'max:2048'],
|
||||
'document' => ['nullable', 'file', 'mimes:pdf', 'max:102400'],
|
||||
'image' => ['nullable', 'image', 'max:10240'],
|
||||
'images' => ['nullable', 'array'],
|
||||
'images.*' => ['image', 'max:10240'],
|
||||
'item_images' => ['nullable', 'array'],
|
||||
'item_images.*' => ['array'],
|
||||
'item_images.*.*' => ['image', 'max:4096'],
|
||||
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
|
||||
'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'business_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'business_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'event_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'event_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'itinerary_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
...$this->styleRules(),
|
||||
]);
|
||||
|
||||
if ($validated['type'] === QrCode::TYPE_DOCUMENT && ! $request->hasFile('document')) {
|
||||
return back()->withInput()->with('error', 'Upload a PDF for PDF QR codes.');
|
||||
}
|
||||
|
||||
if ($validated['type'] === QrCode::TYPE_IMAGE && ! $request->hasFile('image') && ! $request->hasFile('images')) {
|
||||
return back()->withInput()->with('error', 'Upload at least one image.');
|
||||
}
|
||||
|
||||
try {
|
||||
$qrCode = $this->manager->create(ladill_account(), array_merge(
|
||||
$request->all(),
|
||||
[
|
||||
'style' => $validated['style'] ?? [],
|
||||
'custom_short_code' => $validated['custom_short_code'] ?? null,
|
||||
'avatar' => $request->file('avatar'),
|
||||
'book_file' => $request->file('book_file'),
|
||||
'cover' => $request->file('cover'),
|
||||
'menu_logo' => $request->file('menu_logo'),
|
||||
'menu_cover' => $request->file('menu_cover'),
|
||||
'business_logo' => $request->file('business_logo'),
|
||||
'business_cover' => $request->file('business_cover'),
|
||||
'church_logo' => $request->file('church_logo'),
|
||||
'church_cover' => $request->file('church_cover'),
|
||||
'event_logo' => $request->file('event_logo'),
|
||||
'event_cover' => $request->file('event_cover'),
|
||||
'itinerary_cover' => $request->file('itinerary_cover'),
|
||||
],
|
||||
));
|
||||
} catch (RuntimeException $e) {
|
||||
if (str_contains($e->getMessage(), 'Insufficient') || str_contains($e->getMessage(), 'Add at least')) {
|
||||
return back()->withInput()->with('open_topup_modal', 'qr');
|
||||
}
|
||||
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->route('events.show', $qrCode)
|
||||
->with('success', QrTypeCatalog::label($qrCode->type).' created.');
|
||||
}
|
||||
|
||||
public function show(Request $request, QrCode $event): View
|
||||
{
|
||||
$this->authorize('view', $event);
|
||||
|
||||
$previewDataUri = $this->imageGenerator->previewDataUri($event);
|
||||
$qrCode = $event->fresh();
|
||||
$qrStyle = $qrCode->style();
|
||||
$logoDataUri = ! empty($qrStyle['logo_path'])
|
||||
? $this->imageGenerator->logoDataUri($qrStyle['logo_path'])
|
||||
: null;
|
||||
$account = ladill_account();
|
||||
$wallet = $this->manager->walletFor($account);
|
||||
$summary = $this->analytics->summaryFor($qrCode);
|
||||
$dailyScans = $this->analytics->dailyScans($qrCode, 30);
|
||||
$devices = $this->analytics->breakdown($qrCode, 'device_type');
|
||||
$browsers = $this->analytics->breakdown($qrCode, 'browser');
|
||||
$recentScans = $this->analytics->recentScans($qrCode);
|
||||
|
||||
$ladillWalletBalance = 0.0;
|
||||
try {
|
||||
$ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100;
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('QR Plus show could not load Ladill wallet balance', [
|
||||
'user' => $account->public_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('qr-codes.show', [
|
||||
'qrCode' => $qrCode,
|
||||
'previewDataUri' => $previewDataUri,
|
||||
'logoDataUri' => $logoDataUri,
|
||||
'types' => QrTypeCatalog::all(),
|
||||
'moduleStyles' => QrModuleStyleCatalog::visible(),
|
||||
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
|
||||
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
|
||||
'frameStyles' => QrFrameStyleCatalog::visible(),
|
||||
'wallet' => $wallet,
|
||||
'summary' => $summary,
|
||||
'dailyScans' => $dailyScans,
|
||||
'devices' => $devices,
|
||||
'browsers' => $browsers,
|
||||
'recentScans' => $recentScans,
|
||||
'orders' => null,
|
||||
'pricePerQr' => QrWallet::pricePerQr(),
|
||||
'minTopup' => QrWallet::minTopupGhs(),
|
||||
'ladillWalletBalance' => $ladillWalletBalance,
|
||||
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, QrCode $event): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $event);
|
||||
$qrCode = $event;
|
||||
|
||||
$validated = $request->validate([
|
||||
'label' => ['sometimes', 'string', 'max:120'],
|
||||
'destination_url' => ['nullable', 'url', 'max:2048'],
|
||||
'document' => ['nullable', 'file', 'mimes:pdf', 'max:102400'],
|
||||
'image' => ['nullable', 'image', 'max:10240'],
|
||||
'images' => ['nullable', 'array'],
|
||||
'images.*' => ['image', 'max:10240'],
|
||||
'item_images' => ['nullable', 'array'],
|
||||
'item_images.*' => ['array'],
|
||||
'item_images.*.*' => ['image', 'max:4096'],
|
||||
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
|
||||
'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'business_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'business_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'event_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'event_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'itinerary_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'remove_logo' => ['sometimes', 'boolean'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
...$this->styleRules(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->manager->update($qrCode, array_merge(
|
||||
$request->all(),
|
||||
[
|
||||
'document' => $request->file('document'),
|
||||
'logo' => $request->file('logo'),
|
||||
'style' => $validated['style'] ?? null,
|
||||
'is_active' => $request->boolean('is_active', $qrCode->is_active),
|
||||
'remove_logo' => $request->boolean('remove_logo'),
|
||||
'avatar' => $request->file('avatar'),
|
||||
'book_file' => $request->file('book_file'),
|
||||
'cover' => $request->file('cover'),
|
||||
'menu_logo' => $request->file('menu_logo'),
|
||||
'menu_cover' => $request->file('menu_cover'),
|
||||
'business_logo' => $request->file('business_logo'),
|
||||
'business_cover' => $request->file('business_cover'),
|
||||
'church_logo' => $request->file('church_logo'),
|
||||
'church_cover' => $request->file('church_cover'),
|
||||
'event_logo' => $request->file('event_logo'),
|
||||
'event_cover' => $request->file('event_cover'),
|
||||
'itinerary_cover' => $request->file('itinerary_cover'),
|
||||
],
|
||||
));
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', QrTypeCatalog::label($qrCode->type).' updated.');
|
||||
}
|
||||
|
||||
public function stylePreview(Request $request): Response
|
||||
{
|
||||
$validated = $request->validate(array_merge($this->styleRules(), [
|
||||
'short_code' => ['nullable', 'string', 'max:16'],
|
||||
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
|
||||
'use_existing_logo' => ['nullable', 'boolean'],
|
||||
'existing_logo_path' => ['nullable', 'string', 'max:500'],
|
||||
]));
|
||||
|
||||
$shortCode = $validated['short_code'] ?? 'preview';
|
||||
$style = $validated['style'] ?? [];
|
||||
$tempLogoPath = null;
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$tempLogoPath = $request->file('logo')->store('tmp/previews', 'qr');
|
||||
$style['logo_path'] = $tempLogoPath;
|
||||
} elseif ($request->boolean('use_existing_logo') && ! empty($validated['existing_logo_path'])) {
|
||||
$existingPath = $validated['existing_logo_path'];
|
||||
$userPrefix = ladill_account()->id . '/';
|
||||
if (str_starts_with($existingPath, $userPrefix) && Storage::disk('qr')->exists($existingPath)) {
|
||||
$style['logo_path'] = $existingPath;
|
||||
}
|
||||
}
|
||||
|
||||
$png = $this->imageGenerator->renderPng(QrCode::publicBaseUrl() . '/q/' . $shortCode, $style);
|
||||
|
||||
if ($tempLogoPath) {
|
||||
Storage::disk('qr')->delete($tempLogoPath);
|
||||
}
|
||||
|
||||
return response($png, 200, [
|
||||
'Content-Type' => 'image/png',
|
||||
'Cache-Control' => 'no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<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]);
|
||||
}
|
||||
}
|
||||
@@ -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('give.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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\GiveDonation;
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse|View
|
||||
{
|
||||
$q = trim((string) $request->query('q'));
|
||||
$results = mb_strlen($q) >= 2 ? $this->results($q) : [];
|
||||
|
||||
if ($request->expectsJson() || $request->ajax() || $request->wantsJson()) {
|
||||
return response()->json(['results' => $results]);
|
||||
}
|
||||
|
||||
return view('search.index', [
|
||||
'query' => $q,
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<array{type: string, title: string, subtitle: string, url: string}> */
|
||||
private function results(string $q): array
|
||||
{
|
||||
$account = ladill_account();
|
||||
$like = '%'.$q.'%';
|
||||
|
||||
$qrIds = $account->qrCodes()
|
||||
->where('type', QrCode::TYPE_CHURCH)
|
||||
->pluck('id');
|
||||
|
||||
if ($qrIds->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return GiveDonation::query()
|
||||
->whereIn('qr_code_id', $qrIds)
|
||||
->with('qrCode')
|
||||
->where(function ($query) use ($like) {
|
||||
$query->where('payer_name', 'like', $like)
|
||||
->orWhere('payer_email', 'like', $like)
|
||||
->orWhere('collection_type', 'like', $like)
|
||||
->orWhere('reference', 'like', $like)
|
||||
->orWhere('payment_reference', 'like', $like)
|
||||
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
|
||||
})
|
||||
->latest('created_at')
|
||||
->limit(15)
|
||||
->get()
|
||||
->map(function (GiveDonation $donation): array {
|
||||
$amount = number_format(
|
||||
($donation->status === GiveDonation::STATUS_PAID ? $donation->merchant_amount_minor : $donation->amount_minor) / 100,
|
||||
2,
|
||||
);
|
||||
|
||||
return [
|
||||
'type' => 'donation',
|
||||
'title' => $donation->payer_name ?: 'Anonymous donor',
|
||||
'subtitle' => 'GHS '.$amount.' · '.($donation->collection_type ?: 'Donation').' · '.ucfirst($donation->status),
|
||||
'url' => route('give.donations.index', ['q' => $donation->reference]),
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GiveDonation extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PAID = 'paid';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
/** Platform fee on donations (Ladill Give tier). */
|
||||
public const PLATFORM_FEE_RATE = 0.09;
|
||||
|
||||
protected $fillable = [
|
||||
'pay_order_id',
|
||||
'qr_code_id',
|
||||
'user_id',
|
||||
'reference',
|
||||
'collection_type',
|
||||
'amount_minor',
|
||||
'currency',
|
||||
'platform_fee_minor',
|
||||
'merchant_amount_minor',
|
||||
'payer_name',
|
||||
'payer_email',
|
||||
'payer_phone',
|
||||
'payer_note',
|
||||
'status',
|
||||
'payment_reference',
|
||||
'paid_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount_minor' => 'integer',
|
||||
'platform_fee_minor' => 'integer',
|
||||
'merchant_amount_minor' => 'integer',
|
||||
'metadata' => 'array',
|
||||
'paid_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function qrCode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QrCode::class);
|
||||
}
|
||||
|
||||
public function merchant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function amountMajor(): float
|
||||
{
|
||||
return $this->amount_minor / 100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PlatformSetting extends Model
|
||||
{
|
||||
protected $connection = 'platform';
|
||||
|
||||
protected $fillable = [
|
||||
'group',
|
||||
'key',
|
||||
'label',
|
||||
'description',
|
||||
'type',
|
||||
'value',
|
||||
'is_secret',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'value' => 'array',
|
||||
'is_secret' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<?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';
|
||||
public const TYPE_PAYMENT = 'payment';
|
||||
|
||||
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 miniPayments(): HasMany
|
||||
{
|
||||
return $this->hasMany(GiveDonation::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
|
||||
{
|
||||
if ($this->isPaymentType()) {
|
||||
return QrStyleDefaults::defaults();
|
||||
}
|
||||
|
||||
return QrStyleDefaults::merge($this->payload['style'] ?? null);
|
||||
}
|
||||
|
||||
public function isUrlType(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_URL;
|
||||
}
|
||||
|
||||
public function isDocumentType(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_DOCUMENT;
|
||||
}
|
||||
|
||||
public function isImageType(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_IMAGE;
|
||||
}
|
||||
|
||||
public function isMenuType(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_MENU;
|
||||
}
|
||||
|
||||
public function isShopType(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_SHOP;
|
||||
}
|
||||
|
||||
public function isBookType(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_BOOK;
|
||||
}
|
||||
|
||||
public function acceptsOrders(): bool
|
||||
{
|
||||
return in_array($this->type, [self::TYPE_MENU, self::TYPE_SHOP, self::TYPE_CHURCH, self::TYPE_EVENT], true);
|
||||
}
|
||||
|
||||
public function isPaymentType(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_PAYMENT;
|
||||
}
|
||||
|
||||
public function usesLandingPage(): bool
|
||||
{
|
||||
return in_array($this->type, [
|
||||
self::TYPE_PAYMENT,
|
||||
], true);
|
||||
}
|
||||
|
||||
public function resolvesToRedirect(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_URL;
|
||||
}
|
||||
|
||||
public function redirectUrl(): ?string
|
||||
{
|
||||
if ($this->destination_url) {
|
||||
return $this->destination_url;
|
||||
}
|
||||
|
||||
return $this->content()['url'] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?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 Ladill Events preferences (notifications, event defaults, QR style). */
|
||||
class QrSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'notify_email',
|
||||
'product_updates',
|
||||
'low_balance_alerts',
|
||||
'notify_registrations',
|
||||
'notify_payouts',
|
||||
'default_type',
|
||||
'default_style',
|
||||
'event_defaults',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'product_updates' => 'boolean',
|
||||
'low_balance_alerts' => 'boolean',
|
||||
'notify_registrations' => 'boolean',
|
||||
'notify_payouts' => 'boolean',
|
||||
'default_style' => 'array',
|
||||
'event_defaults' => 'array',
|
||||
];
|
||||
|
||||
/** @return list<string> */
|
||||
public static function storableEventDefaultKeys(): array
|
||||
{
|
||||
return [
|
||||
'currency',
|
||||
'mode',
|
||||
'badge_size',
|
||||
'brand_color',
|
||||
'organizer',
|
||||
'registration_open',
|
||||
'badge_fields',
|
||||
];
|
||||
}
|
||||
|
||||
/** @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 resolvedEventDefaults(): array
|
||||
{
|
||||
$defaults = [
|
||||
'currency' => 'GHS',
|
||||
'mode' => 'ticketing',
|
||||
'badge_size' => '4x3',
|
||||
'brand_color' => '#4f46e5',
|
||||
'organizer' => '',
|
||||
'registration_open' => true,
|
||||
'badge_fields' => ['Company', 'Role'],
|
||||
];
|
||||
|
||||
$stored = collect($this->event_defaults ?? [])
|
||||
->only(self::storableEventDefaultKeys())
|
||||
->filter(fn ($value) => $value !== null && $value !== '')
|
||||
->all();
|
||||
|
||||
if (isset($stored['badge_fields']) && is_array($stored['badge_fields'])) {
|
||||
$stored['badge_fields'] = array_values(array_filter(
|
||||
array_map('strval', $stored['badge_fields']),
|
||||
fn (string $field) => trim($field) !== '',
|
||||
));
|
||||
if ($stored['badge_fields'] === []) {
|
||||
unset($stored['badge_fields']);
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge($defaults, $stored);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|null $input */
|
||||
public static function sanitizeEventDefaults(?array $input): ?array
|
||||
{
|
||||
if ($input === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$allowedModes = ['ticketing', 'contributions', 'free'];
|
||||
$allowedSizes = ['4x3', '4x6', 'cr80'];
|
||||
$allowedCurrencies = ['GHS', 'USD', 'NGN', 'KES'];
|
||||
|
||||
$mode = (string) ($input['mode'] ?? 'ticketing');
|
||||
$badgeSize = (string) ($input['badge_size'] ?? '4x3');
|
||||
$currency = strtoupper(trim((string) ($input['currency'] ?? 'GHS')));
|
||||
|
||||
$badgeFields = array_values(array_filter(
|
||||
array_map(fn ($field) => mb_substr(trim((string) $field), 0, 40), (array) ($input['badge_fields'] ?? [])),
|
||||
fn (string $field) => $field !== '',
|
||||
));
|
||||
|
||||
$brandColor = (string) ($input['brand_color'] ?? '#4f46e5');
|
||||
if (! preg_match('/^#[0-9a-fA-F]{6}$/', $brandColor)) {
|
||||
$brandColor = '#4f46e5';
|
||||
}
|
||||
|
||||
return [
|
||||
'currency' => in_array($currency, $allowedCurrencies, true) ? $currency : 'GHS',
|
||||
'mode' => in_array($mode, $allowedModes, true) ? $mode : 'ticketing',
|
||||
'badge_size' => in_array($badgeSize, $allowedSizes, true) ? $badgeSize : '4x3',
|
||||
'brand_color' => $brandColor,
|
||||
'organizer' => mb_substr(trim((string) ($input['organizer'] ?? '')), 0, 120),
|
||||
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
|
||||
'badge_fields' => $badgeFields !== [] ? $badgeFields : ['Company', 'Role'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Policies\QrCodePolicy;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Support\MobileTopbar;
|
||||
use Illuminate\Support\Facades\View;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Gate::policy(QrCode::class, QrCodePolicy::class);
|
||||
View::composer(['partials.topbar', 'partials.topbar-qr'], function ($view) {
|
||||
$view->with(MobileTopbar::resolve());
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?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 settingsConnection(): string
|
||||
{
|
||||
return (string) config('billing.platform_settings_connection', 'platform');
|
||||
}
|
||||
|
||||
protected function settingValue(string $key, mixed $fallback = null): mixed
|
||||
{
|
||||
try {
|
||||
$connection = $this->settingsConnection();
|
||||
|
||||
if (! Schema::connection($connection)->hasTable('platform_settings')) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$setting = PlatformSetting::query()
|
||||
->where('key', $key)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
return $setting ? ($setting->value['value'] ?? $fallback) : $fallback;
|
||||
} catch (\Throwable) {
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
protected function extractData(Response $response, string $message): array
|
||||
{
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (!is_array($json) || !($json['status'] ?? false)) {
|
||||
throw new RuntimeException((string) ($json['message'] ?? $message));
|
||||
}
|
||||
|
||||
$data = $json['data'] ?? null;
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List supported banks / mobile money providers.
|
||||
* @param string $country e.g. 'ghana', 'nigeria'
|
||||
* @param string $type 'nuban', 'mobile_money', or '' for all
|
||||
*/
|
||||
public function listBanks(string $country = 'ghana', string $type = '', ?string $currency = null): array
|
||||
{
|
||||
$query = ['perPage' => 200];
|
||||
if ($currency !== null && $currency !== '') {
|
||||
$query['currency'] = strtoupper($currency);
|
||||
} else {
|
||||
$query['country'] = $country;
|
||||
}
|
||||
if ($type !== '') {
|
||||
$query['type'] = $type;
|
||||
}
|
||||
$response = $this->request()->get('/bank', $query);
|
||||
$json = $response->json();
|
||||
return is_array($json['data'] ?? null) ? $json['data'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a transfer recipient (bank account or mobile money).
|
||||
* @param array{type: string, name: string, account_number: string, bank_code: string, currency?: string} $payload
|
||||
*/
|
||||
public function createTransferRecipient(array $payload): array
|
||||
{
|
||||
$response = $this->request()->post('/transferrecipient', $payload);
|
||||
return $this->extractData($response, 'Failed to create transfer recipient.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a transfer to a recipient.
|
||||
* @param array{source: string, amount: int, recipient: string, reason?: string, reference?: string} $payload
|
||||
*/
|
||||
public function initiateTransfer(array $payload): array
|
||||
{
|
||||
$response = $this->request()->post('/transfer', $payload);
|
||||
return $this->extractData($response, 'Failed to initiate transfer.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a transfer by reference.
|
||||
*/
|
||||
public function verifyTransfer(string $reference): array
|
||||
{
|
||||
$response = $this->request()->get('/transfer/verify/' . urlencode($reference));
|
||||
return $this->extractData($response, 'Failed to verify transfer.');
|
||||
}
|
||||
|
||||
protected function normalizeSecretKey(string $secret): string
|
||||
{
|
||||
$normalized = trim($secret);
|
||||
|
||||
if (str_starts_with(strtolower($normalized), 'bearer ')) {
|
||||
$normalized = trim(substr($normalized, 7));
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SmsService
|
||||
{
|
||||
public function send(string $to, string $message): void
|
||||
{
|
||||
$apiKey = config('services.termii.api_key', '');
|
||||
$senderId = config('services.termii.sender_id', config('app.name', 'LaDill'));
|
||||
|
||||
if ($apiKey === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$phone = preg_replace('/\D+/', '', $to);
|
||||
if (strlen($phone) < 9) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strlen($phone) === 9) {
|
||||
$phone = '233'.$phone;
|
||||
} elseif (str_starts_with($phone, '0')) {
|
||||
$phone = '233'.substr($phone, 1);
|
||||
}
|
||||
|
||||
try {
|
||||
Http::timeout(10)->post('https://v3.api.termii.com/api/sms/send', [
|
||||
'api_key' => $apiKey,
|
||||
'to' => $phone,
|
||||
'from' => $senderId,
|
||||
'sms' => $message,
|
||||
'type' => 'plain',
|
||||
'channel' => 'dnd',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SMS send failed', ['to' => $phone, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Give;
|
||||
|
||||
use App\Models\GiveDonation;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Billing\PaystackService;
|
||||
use App\Services\Billing\SmsService;
|
||||
use App\Services\Pay\PayClient;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class GiveDonationService
|
||||
{
|
||||
public function __construct(
|
||||
private PayClient $pay,
|
||||
private PaystackService $paystack,
|
||||
private BillingClient $billing,
|
||||
private SmsService $sms,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{customer_name: string, customer_email: string, customer_phone: string, items: list<array{name: string, price: float, qty: int}>} $data
|
||||
* @return array{donation: GiveDonation, checkout_url: string, callback_url: string}
|
||||
*/
|
||||
public function initiateOrder(QrCode $qrCode, array $data): array
|
||||
{
|
||||
if ($qrCode->type !== QrCode::TYPE_CHURCH) {
|
||||
throw new RuntimeException('This QR is not a giving page.');
|
||||
}
|
||||
|
||||
if (empty($qrCode->content()['accepts_payment'])) {
|
||||
throw new RuntimeException('Online giving is not enabled for this page.');
|
||||
}
|
||||
|
||||
$items = $data['items'] ?? [];
|
||||
if (empty($items)) {
|
||||
throw new RuntimeException('Enter an amount to give.');
|
||||
}
|
||||
|
||||
$item = $items[0];
|
||||
$amountGhs = round((float) ($item['price'] ?? 0), 2);
|
||||
if ($amountGhs <= 0) {
|
||||
throw new RuntimeException('Enter an amount greater than zero.');
|
||||
}
|
||||
|
||||
$collectionType = trim((string) ($item['name'] ?? 'Donation'));
|
||||
$qrCode->loadMissing('user');
|
||||
$amountMinor = (int) round($amountGhs * 100);
|
||||
$reference = 'GIVD-'.strtoupper(Str::random(16));
|
||||
$orgName = $qrCode->content()['name'] ?? $qrCode->label ?? 'Giving page';
|
||||
$callbackUrl = route('qr.public.order.callback', ['shortCode' => $qrCode->short_code]);
|
||||
|
||||
$donation = GiveDonation::create([
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'user_id' => $qrCode->user_id,
|
||||
'reference' => $reference,
|
||||
'collection_type' => $collectionType,
|
||||
'amount_minor' => $amountMinor,
|
||||
'currency' => $qrCode->content()['currency'] ?? 'GHS',
|
||||
'payer_name' => trim((string) ($data['customer_name'] ?? '')),
|
||||
'payer_email' => trim((string) ($data['customer_email'] ?? '')),
|
||||
'payer_phone' => trim((string) ($data['customer_phone'] ?? '')),
|
||||
'status' => GiveDonation::STATUS_PENDING,
|
||||
'payment_reference' => null,
|
||||
]);
|
||||
|
||||
$payOrder = $this->pay->createCheckout([
|
||||
'merchant' => $qrCode->user->public_id,
|
||||
'fee_tier' => 'donations',
|
||||
'source_service' => 'give',
|
||||
'source_ref' => (string) $qrCode->id,
|
||||
'callback_url' => $callbackUrl,
|
||||
'customer_name' => $donation->payer_name,
|
||||
'customer_email' => $donation->payer_email,
|
||||
'customer_phone' => $donation->payer_phone,
|
||||
'line_items' => [
|
||||
[
|
||||
'name' => $collectionType.' — '.$orgName,
|
||||
'unit_price_minor' => $amountMinor,
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'give_donation_id' => $donation->id,
|
||||
'give_reference' => $reference,
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'collection_type' => $collectionType,
|
||||
],
|
||||
]);
|
||||
|
||||
$donation->update([
|
||||
'pay_order_id' => $payOrder['id'] ?? null,
|
||||
'payment_reference' => $payOrder['reference'],
|
||||
'platform_fee_minor' => $payOrder['platform_fee_minor'] ?? null,
|
||||
'merchant_amount_minor' => $payOrder['merchant_amount_minor'] ?? null,
|
||||
]);
|
||||
|
||||
$checkoutUrl = (string) ($payOrder['checkout_url'] ?? '');
|
||||
if ($checkoutUrl === '') {
|
||||
throw new RuntimeException('Could not start checkout. Please try again.');
|
||||
}
|
||||
|
||||
return [
|
||||
'donation' => $donation->fresh(),
|
||||
'checkout_url' => $checkoutUrl,
|
||||
'callback_url' => $callbackUrl,
|
||||
];
|
||||
}
|
||||
|
||||
public function complete(string $paymentReference): GiveDonation
|
||||
{
|
||||
if (str_starts_with($paymentReference, 'LP-')) {
|
||||
return $this->completeLadillPay($paymentReference);
|
||||
}
|
||||
|
||||
return $this->completeLegacy($paymentReference);
|
||||
}
|
||||
|
||||
private function completeLadillPay(string $reference): GiveDonation
|
||||
{
|
||||
$donation = GiveDonation::where('payment_reference', $reference)
|
||||
->where('status', GiveDonation::STATUS_PENDING)
|
||||
->firstOrFail();
|
||||
|
||||
$payOrder = $this->pay->verify($reference);
|
||||
|
||||
$donation->update([
|
||||
'status' => GiveDonation::STATUS_PAID,
|
||||
'amount_minor' => (int) ($payOrder['amount_minor'] ?? $donation->amount_minor),
|
||||
'platform_fee_minor' => (int) ($payOrder['platform_fee_minor'] ?? 0),
|
||||
'merchant_amount_minor' => (int) ($payOrder['merchant_amount_minor'] ?? 0),
|
||||
'pay_order_id' => $payOrder['id'] ?? $donation->pay_order_id,
|
||||
'paid_at' => now(),
|
||||
'metadata' => array_merge((array) $donation->metadata, ['ladill_pay' => $payOrder]),
|
||||
]);
|
||||
|
||||
$this->notifyDonor($donation->fresh(['qrCode', 'merchant']));
|
||||
|
||||
return $donation;
|
||||
}
|
||||
|
||||
private function completeLegacy(string $paymentReference): GiveDonation
|
||||
{
|
||||
$donation = GiveDonation::where('payment_reference', $paymentReference)
|
||||
->where('status', GiveDonation::STATUS_PENDING)
|
||||
->firstOrFail();
|
||||
|
||||
$data = $this->paystack->verifyTransaction($paymentReference);
|
||||
|
||||
if (($data['status'] ?? '') !== 'success') {
|
||||
$donation->update(['status' => GiveDonation::STATUS_FAILED]);
|
||||
throw new RuntimeException('Payment was not successful.');
|
||||
}
|
||||
|
||||
$paidMinor = (int) ($data['amount'] ?? $donation->amount_minor);
|
||||
$platformFeeMinor = (int) round($paidMinor * GiveDonation::PLATFORM_FEE_RATE);
|
||||
$merchantMinor = $paidMinor - $platformFeeMinor;
|
||||
|
||||
$donation->update([
|
||||
'status' => GiveDonation::STATUS_PAID,
|
||||
'amount_minor' => $paidMinor,
|
||||
'platform_fee_minor' => $platformFeeMinor,
|
||||
'merchant_amount_minor' => $merchantMinor,
|
||||
'paid_at' => now(),
|
||||
'metadata' => array_merge((array) $donation->metadata, ['paystack' => $data, 'legacy' => true]),
|
||||
]);
|
||||
|
||||
$orgName = $donation->qrCode?->content()['name'] ?? $donation->qrCode?->label ?? 'Giving page';
|
||||
$this->billing->credit(
|
||||
$donation->merchant->public_id,
|
||||
$merchantMinor,
|
||||
'give',
|
||||
'pay',
|
||||
$paymentReference,
|
||||
$donation->id,
|
||||
sprintf('Donation via %s', $orgName),
|
||||
);
|
||||
|
||||
$this->notifyDonor($donation->fresh(['qrCode', 'merchant']));
|
||||
|
||||
return $donation;
|
||||
}
|
||||
|
||||
private function notifyDonor(GiveDonation $donation): void
|
||||
{
|
||||
if (! $donation->payer_phone) {
|
||||
return;
|
||||
}
|
||||
|
||||
$orgName = $donation->qrCode?->content()['name'] ?? $donation->qrCode?->label ?? 'Giving page';
|
||||
|
||||
$this->sms->send(
|
||||
$donation->payer_phone,
|
||||
sprintf(
|
||||
'Thank you! Your %s %s donation to %s is confirmed. Ref: %s',
|
||||
$donation->currency,
|
||||
number_format($donation->amount_minor / 100, 2),
|
||||
$orgName,
|
||||
$donation->reference
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Pay;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Client for the platform Ladill Pay HTTP API — checkout and settlement for
|
||||
* merchant↔buyer money. Paystack is the processor today; settlement lands in
|
||||
* the one UserWallet via Platform Billing.
|
||||
*/
|
||||
class PayClient
|
||||
{
|
||||
private function base(): string
|
||||
{
|
||||
return rtrim((string) config('pay.api_url'), '/');
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return (string) (config('pay.api_key') ?? '');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public function createCheckout(array $payload): array
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/checkouts', $payload);
|
||||
$res->throw();
|
||||
|
||||
return (array) $res->json();
|
||||
}
|
||||
|
||||
public function verify(string $reference): array
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/checkouts/verify', [
|
||||
'reference' => $reference,
|
||||
]);
|
||||
$res->throw();
|
||||
|
||||
return (array) $res->json();
|
||||
}
|
||||
|
||||
public function show(string $reference): array
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().'/orders/'.$reference);
|
||||
$res->throw();
|
||||
|
||||
return (array) $res->json();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
<?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);
|
||||
$type = (string) ($data['type'] ?? '');
|
||||
|
||||
if (! in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_CHURCH], true) && ! $wallet->canCreateQr()) {
|
||||
throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.');
|
||||
}
|
||||
|
||||
$validated = $this->payloadValidator->validateForCreate($type, $data);
|
||||
$style = in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_CHURCH], true)
|
||||
? QrStyleDefaults::defaults()
|
||||
: QrStyleDefaults::merge($data['style'] ?? null);
|
||||
|
||||
return DB::transaction(function () use ($user, $wallet, $data, $type, $validated, $style) {
|
||||
$documentId = null;
|
||||
$content = $validated['content'];
|
||||
|
||||
if ($type === QrCode::TYPE_DOCUMENT) {
|
||||
$file = $data['document'] ?? null;
|
||||
if (! $file instanceof UploadedFile) {
|
||||
throw new RuntimeException('A PDF document is required for PDF QR codes.');
|
||||
}
|
||||
$documentId = $this->storeDocument($user, $file, $data['label'] ?? 'Document')->id;
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_IMAGE) {
|
||||
$images = $this->storeImages($user, $data);
|
||||
$content['images'] = $images;
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) {
|
||||
$content['avatar_path'] = $this->storeVcardAvatar($user, $data['avatar']);
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_BOOK) {
|
||||
$bookFile = $data['book_file'] ?? null;
|
||||
if (! $bookFile instanceof UploadedFile) {
|
||||
throw new RuntimeException('Upload the book file (PDF or EPUB).');
|
||||
}
|
||||
$bookData = $this->storeBookFile($user, $bookFile);
|
||||
$content['file_path'] = $bookData['path'];
|
||||
$content['file_type'] = $bookData['type'];
|
||||
$content['file_size'] = $bookData['size'];
|
||||
|
||||
if (($data['cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeBookCover($user, $data['cover']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) {
|
||||
$content['icon_path'] = $this->storeMenuBrandImage($user, $data['app_icon'], 'app-icons');
|
||||
}
|
||||
|
||||
if (in_array($type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) {
|
||||
$content['sections'] = $this->injectItemImages($user, $content['sections'] ?? [], $data, $type);
|
||||
if (($data['menu_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['menu_logo'], 'menu-logos');
|
||||
}
|
||||
if (($data['menu_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['menu_cover'], 'menu-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_BUSINESS) {
|
||||
if (($data['business_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['business_logo'], 'business-logos');
|
||||
}
|
||||
if (($data['business_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['business_cover'], 'business-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_CHURCH) {
|
||||
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['church_logo'], 'church-logos');
|
||||
}
|
||||
if (($data['church_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['church_cover'], 'church-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_EVENT) {
|
||||
if (($data['event_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['event_logo'], 'event-logos');
|
||||
}
|
||||
if (($data['event_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['event_cover'], 'event-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['itinerary_cover'], 'itinerary-covers');
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_PAYMENT && ($data['payment_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['payment_logo'], 'payment-logos');
|
||||
}
|
||||
|
||||
if ($style['logo_path'] === null && ($data['logo'] ?? null) instanceof UploadedFile && $type !== QrCode::TYPE_PAYMENT) {
|
||||
$style['logo_path'] = $this->storeLogo($user, $data['logo']);
|
||||
}
|
||||
|
||||
$shortCode = (isset($data['custom_short_code']) && $data['custom_short_code'] !== '')
|
||||
? (string) $data['custom_short_code']
|
||||
: $this->generateUniqueShortCode();
|
||||
|
||||
$payload = [
|
||||
'content' => $content,
|
||||
'style' => $style,
|
||||
];
|
||||
|
||||
// Freeze the WiFi auto-join payload so the printed code never changes on edit.
|
||||
if ($type === QrCode::TYPE_WIFI) {
|
||||
$payload['wifi_encoded'] = \App\Support\Qr\QrWifiPayload::encode($content);
|
||||
}
|
||||
|
||||
$qrCode = QrCode::create([
|
||||
'user_id' => $user->id,
|
||||
'short_code' => $shortCode,
|
||||
'type' => $type,
|
||||
'label' => trim((string) $data['label']),
|
||||
'destination_url' => $validated['destination_url'],
|
||||
'qr_document_id' => $documentId,
|
||||
'payload' => $payload,
|
||||
'is_active' => true,
|
||||
'destination_updated_at' => now(),
|
||||
]);
|
||||
|
||||
if (! in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_CHURCH], true)) {
|
||||
$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_PAYMENT && ($data['payment_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['payment_logo'], 'payment-logos');
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_EVENT) {
|
||||
if (($data['event_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['event_logo'], 'event-logos');
|
||||
}
|
||||
if (($data['event_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['event_cover'], 'event-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['itinerary_cover'], 'itinerary-covers');
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_PAYMENT) {
|
||||
$style = QrStyleDefaults::defaults();
|
||||
} else {
|
||||
if (isset($data['style']) && is_array($data['style'])) {
|
||||
$style = QrStyleDefaults::merge(array_merge($style, $data['style']));
|
||||
$regenerate = true;
|
||||
}
|
||||
|
||||
if (($data['logo'] ?? null) instanceof UploadedFile) {
|
||||
$style['logo_path'] = $this->storeLogo($qrCode->user, $data['logo']);
|
||||
$regenerate = true;
|
||||
}
|
||||
|
||||
if (($data['remove_logo'] ?? false) && $style['logo_path']) {
|
||||
$style['logo_path'] = null;
|
||||
$regenerate = true;
|
||||
}
|
||||
}
|
||||
|
||||
$qrCode->destination_url = $destinationUrl;
|
||||
// Preserve frozen keys (e.g. wifi_encoded) so the printed WiFi code stays put.
|
||||
$payload = (array) ($qrCode->payload ?? []);
|
||||
$payload['content'] = $content;
|
||||
$payload['style'] = $style;
|
||||
$qrCode->payload = $payload;
|
||||
$qrCode->save();
|
||||
|
||||
if ($regenerate) {
|
||||
$this->imageGenerator->generateAndStore($qrCode);
|
||||
}
|
||||
|
||||
return $qrCode->fresh(['document']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject uploaded item images into the sections array.
|
||||
* Form field: item_images[sectionIndex][itemIndex] (UploadedFile)
|
||||
*
|
||||
* @param array<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'],
|
||||
QrCode::TYPE_PAYMENT => ['business_name', 'branch_label', 'currency'],
|
||||
default => [],
|
||||
};
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function storeDocument(User $user, UploadedFile $file, string $title): QrDocument
|
||||
{
|
||||
$maxBytes = (int) config('qr.max_pdf_bytes', 104857600);
|
||||
|
||||
if ($file->getSize() > $maxBytes) {
|
||||
throw new RuntimeException('PDF must be 100 MB or smaller.');
|
||||
}
|
||||
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! in_array($mime, ['application/pdf', 'application/x-pdf'], true)) {
|
||||
throw new RuntimeException('Only PDF documents are supported.');
|
||||
}
|
||||
|
||||
$uuid = Str::uuid()->toString();
|
||||
$path = $user->id . '/documents/' . $uuid . '.pdf';
|
||||
|
||||
$file->storeAs('', $path, 'qr');
|
||||
|
||||
return QrDocument::create([
|
||||
'user_id' => $user->id,
|
||||
'title' => $title,
|
||||
'disk' => 'qr',
|
||||
'path' => $path,
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => (int) $file->getSize(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<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.');
|
||||
}
|
||||
|
||||
public function delete(QrCode $qrCode): void
|
||||
{
|
||||
$paths = array_filter([$qrCode->png_path, $qrCode->svg_path]);
|
||||
|
||||
$logoPath = $qrCode->content()['logo_path'] ?? null;
|
||||
if (is_string($logoPath) && $logoPath !== '') {
|
||||
$paths[] = $logoPath;
|
||||
}
|
||||
|
||||
foreach ($paths as $path) {
|
||||
Storage::disk('qr')->delete($path);
|
||||
}
|
||||
|
||||
$qrCode->delete();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
<?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),
|
||||
QrCode::TYPE_PAYMENT => $this->validatePayment($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.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validatePayment(array $input): array
|
||||
{
|
||||
$businessName = trim((string) ($input['business_name'] ?? ''));
|
||||
if ($businessName === '') {
|
||||
throw new RuntimeException('Enter your business or display name.');
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'business_name' => mb_substr($businessName, 0, 120),
|
||||
'branch_label' => mb_substr(trim((string) ($input['branch_label'] ?? '')), 0, 80) ?: null,
|
||||
'currency' => strtoupper(trim((string) ($input['currency'] ?? 'GHS'))) ?: 'GHS',
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) ?? '');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class MobileTopbar
|
||||
{
|
||||
public static function resolve(): array
|
||||
{
|
||||
$appName = config('mobile-topbar.app_name', 'Ladill');
|
||||
|
||||
$title = $appName === 'Ladill'
|
||||
? 'Ladill'
|
||||
: "Ladill {$appName}";
|
||||
|
||||
return [
|
||||
'mobileTopbarTitle' => $title,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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) ?? '');
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
|
||||
/**
|
||||
* Ladill Give — church / NGO giving pages only.
|
||||
*/
|
||||
class QrTypeCatalog
|
||||
{
|
||||
/** @return list<string> */
|
||||
public static function givingTypes(): array
|
||||
{
|
||||
return [QrCode::TYPE_CHURCH];
|
||||
}
|
||||
|
||||
/** @return array<string, array{label: string, description: string, category: string, icon: string}> */
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
QrCode::TYPE_CHURCH => [
|
||||
'label' => 'Give',
|
||||
'description' => 'A branded giving page with online donations',
|
||||
'category' => 'giving',
|
||||
'icon' => 'give.svg',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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, '/') : '');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
];
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "ladill/give",
|
||||
"type": "project",
|
||||
"description": "Ladill Give — online giving pages and donation payouts at give.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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Afia — in-app AI assistant scoped to Ladill Give.
|
||||
'product' => env('AFIA_PRODUCT', 'mini'),
|
||||
'enabled' => (bool) env('AFIA_ENABLED', true),
|
||||
'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic
|
||||
'model' => env('AFIA_MODEL', 'gpt-4o-mini'),
|
||||
'api_key' => env('AFIA_API_KEY'),
|
||||
];
|
||||
+134
@@ -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 Give'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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 Give (give.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')),
|
||||
'give_domain' => env('GIVE_DOMAIN', parse_url((string) env('APP_URL', 'https://give.ladill.com'), PHP_URL_HOST) ?: 'give.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
@@ -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),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
|
||||
'api_key' => env('BILLING_API_KEY_GIVE'),
|
||||
'service' => 'give',
|
||||
'platform_settings_connection' => env('BILLING_PLATFORM_SETTINGS_CONNECTION', 'platform'),
|
||||
];
|
||||
@@ -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-'),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,204 @@
|
||||
<?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'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
// Read-only access to platform admin settings (Paystack keys, etc.).
|
||||
'platform' => [
|
||||
'driver' => 'mysql',
|
||||
'host' => env('PLATFORM_DB_HOST', env('DB_HOST', '127.0.0.1')),
|
||||
'port' => env('PLATFORM_DB_PORT', env('DB_PORT', '3306')),
|
||||
'database' => env('PLATFORM_DB_DATABASE', 'ladilldb'),
|
||||
'username' => env('PLATFORM_DB_USERNAME', env('DB_USERNAME', 'root')),
|
||||
'password' => env('PLATFORM_DB_PASSWORD', env('DB_PASSWORD', '')),
|
||||
'unix_socket' => env('PLATFORM_DB_SOCKET', env('DB_SOCKET', '')),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'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),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api_url' => env('DOMAIN_API_URL', 'https://ladill.com/api/domains'),
|
||||
'api_key' => env('DOMAIN_API_KEY_HOSTING'),
|
||||
];
|
||||
@@ -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
|
||||
],
|
||||
];
|
||||
@@ -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'),
|
||||
];
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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),
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api_url' => env('IDENTITY_API_URL', 'https://ladill.com/api'),
|
||||
'api_key' => env('IDENTITY_API_KEY_GIVE'),
|
||||
];
|
||||
@@ -0,0 +1,39 @@
|
||||
<?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.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
|
||||
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
|
||||
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
|
||||
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
|
||||
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
|
||||
],
|
||||
];
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user