Initial Ladill Transfer app — file sharing with QR links.
Scaffolded from the Ladill mini/events extraction pattern: multi-file transfers, retention controls, public landing pages, analytics, and Gitea deploy workflow. 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,51 @@
|
||||
APP_NAME="Ladill Transfer"
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://transfer.ladill.com
|
||||
|
||||
PLATFORM_URL=https://ladill.com
|
||||
PLATFORM_DOMAIN=ladill.com
|
||||
AUTH_DOMAIN=auth.ladill.com
|
||||
ACCOUNT_DOMAIN=account.ladill.com
|
||||
TRANSFER_DOMAIN=transfer.ladill.com
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=ladill_transfer
|
||||
DB_USERNAME=ladill_transfer
|
||||
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_transfer
|
||||
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_TRANSFER=
|
||||
|
||||
IDENTITY_API_URL=https://ladill.com/api
|
||||
IDENTITY_API_KEY_TRANSFER=
|
||||
|
||||
TRANSFER_PRICE_PER_GB_MONTH=1.00
|
||||
TRANSFER_MAX_FILE_BYTES=524288000
|
||||
TRANSFER_MAX_FILES=20
|
||||
TRANSFER_DEFAULT_RETENTION_DAYS=30
|
||||
|
||||
AFIA_ENABLED=true
|
||||
AFIA_PRODUCT=transfer
|
||||
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 Transfer
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-transfer
|
||||
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-transfer-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||
WORKSPACE: /tmp/${{ gitea.repository_owner }}-transfer-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
LADILL_APP_ROOT: /var/www/ladill-transfer
|
||||
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-transfer-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 transfer.ladill.com vhost manually"
|
||||
exit 0
|
||||
fi
|
||||
if sudo -n bash "$NGINX_SCRIPT" transfer --app /var/www/ladill-transfer/current; then
|
||||
echo "nginx vhost updated for transfer.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,91 @@
|
||||
# Ladill Transfer — deploy & cutover runbook
|
||||
|
||||
Standalone app for **secure file sharing** at `transfer.ladill.com` — upload files,
|
||||
get a QR code + share link, control retention, track downloads. Storage bills at
|
||||
**GHS 1.00/GB/month** from the platform UserWallet.
|
||||
|
||||
Public scans stay at `ladill.com/q/<code>`. The platform forwards transfer codes
|
||||
to this app (`TransferQrForwarder` on the monolith).
|
||||
|
||||
---
|
||||
|
||||
## 0. Prerequisites
|
||||
|
||||
| Secret | Where |
|
||||
|---|---|
|
||||
| `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | `passport:client` on platform |
|
||||
| `BILLING_API_KEY_TRANSFER` | platform `.env` + this app |
|
||||
| `IDENTITY_API_KEY_TRANSFER` | platform `.env` + this app |
|
||||
|
||||
## 1. Gitea repo + CI
|
||||
|
||||
1. Repo: **ladill-transfer** on Gitea (`isaacclad/ladill-transfer`)
|
||||
2. Push to `main` triggers `.gitea/workflows/deploy.yml`.
|
||||
3. App root: `/var/www/ladill-transfer`
|
||||
|
||||
## 2. Server app-slot + database
|
||||
|
||||
```bash
|
||||
sudo install -d -o deploy -g www-data /var/www/ladill-transfer/{releases,shared}
|
||||
sudo mysql -e "CREATE DATABASE ladill_transfer CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
sudo mysql -e "CREATE USER 'ladill_transfer'@'127.0.0.1' IDENTIFIED BY '<pw>';"
|
||||
sudo mysql -e "GRANT ALL ON ladill_transfer.* TO 'ladill_transfer'@'127.0.0.1';"
|
||||
sudo mysql -e "GRANT SELECT ON ladilldb.platform_settings TO 'ladill_transfer'@'127.0.0.1';"
|
||||
sudo mysql -e "GRANT SELECT ON ladill_transfer.qr_codes TO 'ladilldb'@'127.0.0.1'; FLUSH PRIVILEGES;"
|
||||
```
|
||||
|
||||
Copy `deploy/shared.env.example` → `/var/www/ladill-transfer/shared/.env`, set
|
||||
`APP_KEY` (`php artisan key:generate --show`), DB creds, and SSO secrets.
|
||||
|
||||
## 3. Register OIDC client (platform)
|
||||
|
||||
```bash
|
||||
cd /var/www/ladill.com/current
|
||||
php artisan passport:client \
|
||||
--name="Ladill Transfer" \
|
||||
--redirect_uri="https://transfer.ladill.com/sso/callback"
|
||||
```
|
||||
|
||||
Generate billing/identity API keys (same random string in both platform `.env`
|
||||
and transfer `shared/.env`):
|
||||
|
||||
```bash
|
||||
php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'
|
||||
```
|
||||
|
||||
Platform `.env`:
|
||||
|
||||
```env
|
||||
BILLING_API_KEY_TRANSFER=<key>
|
||||
IDENTITY_API_KEY_TRANSFER=<key>
|
||||
TRANSFER_DB_DATABASE=ladill_transfer
|
||||
TRANSFER_DB_USERNAME=ladill_transfer
|
||||
TRANSFER_DB_PASSWORD=<pw>
|
||||
RP_TRANSFER_FRONTCHANNEL_LOGOUT=https://transfer.ladill.com/sso/logout-frontchannel
|
||||
```
|
||||
|
||||
## 4. nginx + TLS
|
||||
|
||||
Handled automatically by the deploy workflow. Manual:
|
||||
|
||||
```bash
|
||||
sudo deployment/setup-service-subdomain-nginx.sh transfer --app /var/www/ladill-transfer/current
|
||||
```
|
||||
|
||||
## 5. First deploy
|
||||
|
||||
Push to `main` on Gitea — the `deploy` runner builds assets, runs migrations,
|
||||
and switches `current`. Or manually:
|
||||
|
||||
```bash
|
||||
cd /var/www/ladill-transfer/current
|
||||
php artisan migrate --force
|
||||
php artisan config:cache route:cache view:cache
|
||||
```
|
||||
|
||||
## 6. Verify
|
||||
|
||||
- Sign in at `https://transfer.ladill.com`
|
||||
- Create a transfer (multi-file upload) → share link + QR
|
||||
- Open `ladill.com/q/<code>` → forwards to transfer landing
|
||||
- Home hub lists Transfer (no longer “Soon”)
|
||||
@@ -0,0 +1,6 @@
|
||||
# Ladill Transfer
|
||||
|
||||
Secure file sharing at [transfer.ladill.com](https://transfer.ladill.com) — upload files,
|
||||
share via link or QR code, control retention, track downloads.
|
||||
|
||||
Part of the [Ladill](https://ladill.com) platform. See [DEPLOY.md](DEPLOY.md) for deployment.
|
||||
@@ -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('transfer.dashboard'));
|
||||
|
||||
if (Auth::check()) {
|
||||
return $this->safeRedirect($intended, route('transfer.dashboard'));
|
||||
}
|
||||
|
||||
if ($this->attemptSilentRefresh($request, $intended)) {
|
||||
return $this->safeRedirect($intended, route('transfer.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('transfer.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('transfer.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,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,349 @@
|
||||
<?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->isTransferType()) {
|
||||
return redirect()->route('qr.public.transfer', $shortCode);
|
||||
}
|
||||
|
||||
if ($qrCode->isPaymentType()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
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,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Public;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\Transfer;
|
||||
use App\Models\TransferFile;
|
||||
use App\Services\Qr\QrScanRecorder;
|
||||
use App\Services\Transfer\TransferService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class TransferPublicController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private QrScanRecorder $scanRecorder,
|
||||
private TransferService $transfers,
|
||||
) {}
|
||||
|
||||
public function landing(Request $request, string $shortCode): RedirectResponse|View
|
||||
{
|
||||
$qrCode = QrCode::query()->where('short_code', $shortCode)->firstOrFail();
|
||||
$this->scanRecorder->record($qrCode, $request);
|
||||
|
||||
if (! $qrCode->is_active || ! $qrCode->isTransferType()) {
|
||||
return view('public.qr.inactive', ['qrCode' => $qrCode]);
|
||||
}
|
||||
|
||||
$transfer = $this->resolveTransfer($qrCode);
|
||||
abort_unless($transfer && $transfer->isActive(), 404);
|
||||
|
||||
if ($transfer->isPasswordProtected() && ! $request->session()->get("transfer_unlocked.{$transfer->id}")) {
|
||||
return view('public.transfer.password', [
|
||||
'qrCode' => $qrCode,
|
||||
'transfer' => $transfer,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('public.transfer.landing', [
|
||||
'qrCode' => $qrCode,
|
||||
'transfer' => $transfer->load('files'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function unlock(Request $request, string $shortCode): RedirectResponse|View
|
||||
{
|
||||
$qrCode = QrCode::query()->where('short_code', $shortCode)->firstOrFail();
|
||||
$transfer = $this->resolveTransfer($qrCode);
|
||||
abort_unless($transfer, 404);
|
||||
|
||||
$data = $request->validate(['password' => 'required|string|max:64']);
|
||||
|
||||
if (! $this->transfers->verifyPassword($transfer, $data['password'])) {
|
||||
return back()->with('error', 'Incorrect password.');
|
||||
}
|
||||
|
||||
$request->session()->put("transfer_unlocked.{$transfer->id}", true);
|
||||
|
||||
return redirect()->route('qr.public.transfer', $shortCode);
|
||||
}
|
||||
|
||||
public function download(Request $request, string $shortCode, TransferFile $file): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()->where('short_code', $shortCode)->firstOrFail();
|
||||
$transfer = $this->resolveTransfer($qrCode);
|
||||
abort_unless($transfer && $transfer->isActive() && $file->transfer_id === $transfer->id, 404);
|
||||
|
||||
if ($transfer->isPasswordProtected() && ! $request->session()->get("transfer_unlocked.{$transfer->id}")) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
abort_unless($file->existsOnDisk(), 404);
|
||||
|
||||
$this->transfers->recordDownload($transfer, $file, $request);
|
||||
|
||||
return Storage::disk($file->disk)->response(
|
||||
$file->path,
|
||||
$file->original_name,
|
||||
['Content-Type' => $file->mime_type],
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveTransfer(QrCode $qrCode): ?Transfer
|
||||
{
|
||||
$transferId = $qrCode->content()['transfer_id'] ?? null;
|
||||
|
||||
if ($transferId) {
|
||||
return Transfer::query()->with('files')->find($transferId);
|
||||
}
|
||||
|
||||
return Transfer::query()->where('qr_code_id', $qrCode->id)->with('files')->first();
|
||||
}
|
||||
}
|
||||
@@ -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('transfer.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('transfer.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,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
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
|
||||
{
|
||||
$like = '%'.$q.'%';
|
||||
|
||||
return ladill_account()->qrCodes()
|
||||
->whereIn('type', QrTypeCatalog::eventsTypes())
|
||||
->where(function ($query) use ($like): void {
|
||||
$query->where('label', 'like', $like)
|
||||
->orWhere('short_code', 'like', $like)
|
||||
->orWhere('destination_url', 'like', $like);
|
||||
})
|
||||
->orderByDesc('updated_at')
|
||||
->limit(15)
|
||||
->get()
|
||||
->map(function (QrCode $code): array {
|
||||
$content = $code->content();
|
||||
|
||||
return [
|
||||
'type' => $code->type === QrCode::TYPE_EVENT ? 'event' : 'programme',
|
||||
'title' => $content['name'] ?? $code->label,
|
||||
'subtitle' => $code->typeLabel().' · '.$code->publicUrl(),
|
||||
'url' => route('events.show', $code),
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Transfer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Transfer;
|
||||
use App\Models\TransferDownloadEvent;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AnalyticsController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$transferIds = Transfer::query()
|
||||
->where('user_id', $account->id)
|
||||
->pluck('id');
|
||||
|
||||
$downloads30d = TransferDownloadEvent::query()
|
||||
->whereIn('transfer_id', $transferIds)
|
||||
->where('downloaded_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$topTransfers = Transfer::query()
|
||||
->where('user_id', $account->id)
|
||||
->where('status', Transfer::STATUS_ACTIVE)
|
||||
->orderByDesc('downloads_total')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$recentDownloads = TransferDownloadEvent::query()
|
||||
->whereIn('transfer_id', $transferIds)
|
||||
->with(['transfer', 'file'])
|
||||
->latest('downloaded_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
return view('transfer.analytics.index', [
|
||||
'downloads30d' => $downloads30d,
|
||||
'topTransfers' => $topTransfers,
|
||||
'recentDownloads' => $recentDownloads,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Transfer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Transfer;
|
||||
use App\Models\TransferFile;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class FilesController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$files = TransferFile::query()
|
||||
->whereHas('transfer', fn ($q) => $q
|
||||
->where('user_id', $account->id)
|
||||
->where('status', Transfer::STATUS_ACTIVE))
|
||||
->with('transfer')
|
||||
->latest()
|
||||
->paginate(30);
|
||||
|
||||
$storageBytes = Transfer::query()
|
||||
->where('user_id', $account->id)
|
||||
->where('status', Transfer::STATUS_ACTIVE)
|
||||
->sum('storage_bytes');
|
||||
|
||||
return view('transfer.files.index', [
|
||||
'files' => $files,
|
||||
'storageBytes' => (int) $storageBytes,
|
||||
'storageGb' => round($storageBytes / (1024 * 1024 * 1024), 2),
|
||||
'pricePerGb' => (float) config('transfer.price_per_gb_month', 1.0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Transfer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Transfer;
|
||||
use App\Models\TransferDownloadEvent;
|
||||
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();
|
||||
|
||||
$transfers = Transfer::query()
|
||||
->where('user_id', $account->id)
|
||||
->where('status', Transfer::STATUS_ACTIVE);
|
||||
|
||||
$activeCount = (clone $transfers)->count();
|
||||
$storageBytes = (int) (clone $transfers)->sum('storage_bytes');
|
||||
|
||||
$transferIds = Transfer::query()
|
||||
->where('user_id', $account->id)
|
||||
->pluck('id');
|
||||
|
||||
$downloads30d = TransferDownloadEvent::query()
|
||||
->whereIn('transfer_id', $transferIds)
|
||||
->where('downloaded_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$expiringSoon = Transfer::query()
|
||||
->where('user_id', $account->id)
|
||||
->where('status', Transfer::STATUS_ACTIVE)
|
||||
->whereNotNull('expires_at')
|
||||
->whereBetween('expires_at', [now(), now()->addDays(7)])
|
||||
->count();
|
||||
|
||||
$recentTransfers = Transfer::query()
|
||||
->where('user_id', $account->id)
|
||||
->where('status', Transfer::STATUS_ACTIVE)
|
||||
->with('qrCode')
|
||||
->latest()
|
||||
->limit(6)
|
||||
->get();
|
||||
|
||||
$balanceMinor = 0;
|
||||
try {
|
||||
$balanceMinor = $this->billing->balanceMinor($account->public_id);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Transfer dashboard could not load wallet balance', [
|
||||
'user' => $account->public_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$storageGb = round($storageBytes / (1024 * 1024 * 1024), 2);
|
||||
$monthlyEstimateGhs = round($storageGb * (float) config('transfer.price_per_gb_month', 1.0), 2);
|
||||
|
||||
return view('transfer.dashboard', [
|
||||
'activeCount' => $activeCount,
|
||||
'storageBytes' => $storageBytes,
|
||||
'storageGb' => $storageGb,
|
||||
'downloads30d' => $downloads30d,
|
||||
'expiringSoon' => $expiringSoon,
|
||||
'recentTransfers' => $recentTransfers,
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'monthlyEstimateGhs' => $monthlyEstimateGhs,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Transfer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Transfer;
|
||||
use App\Services\Qr\QrImageGeneratorService;
|
||||
use App\Services\Qr\QrPdfExporter;
|
||||
use App\Services\Transfer\TransferService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class TransferController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private TransferService $transfers,
|
||||
private QrImageGeneratorService $imageGenerator,
|
||||
private QrPdfExporter $pdfExporter,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$transfers = Transfer::query()
|
||||
->where('user_id', $account->id)
|
||||
->where('status', Transfer::STATUS_ACTIVE)
|
||||
->with(['qrCode', 'files'])
|
||||
->latest()
|
||||
->paginate(20);
|
||||
|
||||
return view('transfer.transfers.index', compact('transfers'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('transfer.transfers.create', [
|
||||
'defaultRetentionDays' => (int) config('transfer.default_retention_days', 30),
|
||||
'maxFiles' => (int) config('transfer.max_files_per_transfer', 20),
|
||||
'pricePerGb' => (float) config('transfer.price_per_gb_month', 1.0),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:120',
|
||||
'message' => 'nullable|string|max:2000',
|
||||
'password' => 'nullable|string|min:4|max:64',
|
||||
'retention_days' => 'nullable|integer|min:1|max:365',
|
||||
'files' => 'required|array|min:1',
|
||||
'files.*' => 'required|file|max:'.((int) config('transfer.max_file_bytes', 524288000) / 1024),
|
||||
]);
|
||||
|
||||
try {
|
||||
$transfer = $this->transfers->create($account, $data, $request->file('files', []));
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('transfer.transfers.show', $transfer)
|
||||
->with('success', 'Transfer created. Share the link or QR code with recipients.');
|
||||
}
|
||||
|
||||
public function show(Transfer $transfer): View
|
||||
{
|
||||
$this->authorizeTransfer($transfer);
|
||||
|
||||
$transfer->load(['files', 'qrCode']);
|
||||
$previewDataUri = $transfer->qrCode
|
||||
? $this->imageGenerator->previewDataUri($transfer->qrCode)
|
||||
: null;
|
||||
|
||||
return view('transfer.transfers.show', [
|
||||
'transfer' => $transfer,
|
||||
'previewDataUri' => $previewDataUri,
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Transfer $transfer): RedirectResponse
|
||||
{
|
||||
$this->authorizeTransfer($transfer);
|
||||
|
||||
$this->transfers->delete($transfer);
|
||||
|
||||
return redirect()
|
||||
->route('transfer.transfers.index')
|
||||
->with('success', 'Transfer deleted.');
|
||||
}
|
||||
|
||||
public function preview(Transfer $transfer): Response
|
||||
{
|
||||
$this->authorizeTransfer($transfer);
|
||||
$qrCode = $transfer->qrCode;
|
||||
abort_unless($qrCode, 404);
|
||||
|
||||
$qrCode = $this->imageGenerator->ensureValidImages($qrCode);
|
||||
$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(Transfer $transfer, string $format): StreamedResponse
|
||||
{
|
||||
$this->authorizeTransfer($transfer);
|
||||
$qrCode = $transfer->qrCode;
|
||||
abort_unless($qrCode, 404);
|
||||
|
||||
$qrCode = $this->imageGenerator->ensureValidImages($qrCode);
|
||||
$filename = Str::slug($qrCode->label).'-qr.'.($format === 'svg' ? 'svg' : 'png');
|
||||
|
||||
if ($format === 'png') {
|
||||
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_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 === 'svg') {
|
||||
abort_unless($qrCode->svg_path, 404);
|
||||
|
||||
return response()->streamDownload(
|
||||
fn () => print(file_get_contents(storage_path('app/private/qr/'.$qrCode->svg_path))),
|
||||
$filename,
|
||||
['Content-Type' => 'image/svg+xml'],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->pdfExporter->download($qrCode);
|
||||
}
|
||||
|
||||
private function authorizeTransfer(Transfer $transfer): void
|
||||
{
|
||||
abort_unless($transfer->user_id === ladill_account()->id, 403);
|
||||
}
|
||||
}
|
||||
@@ -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,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,220 @@
|
||||
<?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;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
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';
|
||||
public const TYPE_TRANSFER = 'transfer';
|
||||
|
||||
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 transfer(): HasOne
|
||||
{
|
||||
return $this->hasOne(Transfer::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 isTransferType(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TRANSFER;
|
||||
}
|
||||
|
||||
public function usesLandingPage(): bool
|
||||
{
|
||||
return in_array($this->type, [
|
||||
self::TYPE_PAYMENT,
|
||||
self::TYPE_TRANSFER,
|
||||
], 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,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Transfer extends Model
|
||||
{
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
public const STATUS_DELETED = 'deleted';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'qr_code_id',
|
||||
'title',
|
||||
'message',
|
||||
'password_hash',
|
||||
'retention_days',
|
||||
'expires_at',
|
||||
'status',
|
||||
'downloads_total',
|
||||
'storage_bytes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'retention_days' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
'downloads_total' => 'integer',
|
||||
'storage_bytes' => 'integer',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function qrCode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QrCode::class);
|
||||
}
|
||||
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(TransferFile::class);
|
||||
}
|
||||
|
||||
public function downloadEvents(): HasMany
|
||||
{
|
||||
return $this->hasMany(TransferDownloadEvent::class);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->expires_at && $this->expires_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function isPasswordProtected(): bool
|
||||
{
|
||||
return $this->password_hash !== null && $this->password_hash !== '';
|
||||
}
|
||||
|
||||
public function storageGb(): float
|
||||
{
|
||||
return round($this->storage_bytes / (1024 * 1024 * 1024), 3);
|
||||
}
|
||||
|
||||
public function monthlyCostGhs(): float
|
||||
{
|
||||
return round($this->storageGb() * (float) config('transfer.price_per_gb_month', 1.0), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TransferDownloadEvent extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'transfer_id',
|
||||
'transfer_file_id',
|
||||
'downloaded_at',
|
||||
'ip_hash',
|
||||
'user_agent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'downloaded_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function transfer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Transfer::class);
|
||||
}
|
||||
|
||||
public function file(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TransferFile::class, 'transfer_file_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TransferFile extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'transfer_id',
|
||||
'original_name',
|
||||
'disk',
|
||||
'path',
|
||||
'mime_type',
|
||||
'size_bytes',
|
||||
'downloads_total',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'size_bytes' => 'integer',
|
||||
'downloads_total' => 'integer',
|
||||
];
|
||||
|
||||
public function transfer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Transfer::class);
|
||||
}
|
||||
|
||||
public function downloadEvents(): HasMany
|
||||
{
|
||||
return $this->hasMany(TransferDownloadEvent::class);
|
||||
}
|
||||
|
||||
public function humanSize(): string
|
||||
{
|
||||
$bytes = $this->size_bytes;
|
||||
if ($bytes >= 1073741824) {
|
||||
return number_format($bytes / 1073741824, 1).' GB';
|
||||
}
|
||||
if ($bytes >= 1048576) {
|
||||
return number_format($bytes / 1048576, 1).' MB';
|
||||
}
|
||||
if ($bytes >= 1024) {
|
||||
return number_format($bytes / 1024, 0).' KB';
|
||||
}
|
||||
|
||||
return $bytes.' B';
|
||||
}
|
||||
|
||||
public function existsOnDisk(): bool
|
||||
{
|
||||
return Storage::disk($this->disk)->exists($this->path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?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 transfers(): HasMany
|
||||
{
|
||||
return $this->hasMany(Transfer::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,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 ($type !== QrCode::TYPE_PAYMENT && ! $wallet->canCreateQr()) {
|
||||
throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.');
|
||||
}
|
||||
|
||||
$validated = $this->payloadValidator->validateForCreate($type, $data);
|
||||
$style = $type === QrCode::TYPE_PAYMENT
|
||||
? QrStyleDefaults::defaults()
|
||||
: QrStyleDefaults::merge($data['style'] ?? null);
|
||||
|
||||
return DB::transaction(function () use ($user, $wallet, $data, $type, $validated, $style) {
|
||||
$documentId = null;
|
||||
$content = $validated['content'];
|
||||
|
||||
if ($type === QrCode::TYPE_DOCUMENT) {
|
||||
$file = $data['document'] ?? null;
|
||||
if (! $file instanceof UploadedFile) {
|
||||
throw new RuntimeException('A PDF document is required for PDF QR codes.');
|
||||
}
|
||||
$documentId = $this->storeDocument($user, $file, $data['label'] ?? 'Document')->id;
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_IMAGE) {
|
||||
$images = $this->storeImages($user, $data);
|
||||
$content['images'] = $images;
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) {
|
||||
$content['avatar_path'] = $this->storeVcardAvatar($user, $data['avatar']);
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_BOOK) {
|
||||
$bookFile = $data['book_file'] ?? null;
|
||||
if (! $bookFile instanceof UploadedFile) {
|
||||
throw new RuntimeException('Upload the book file (PDF or EPUB).');
|
||||
}
|
||||
$bookData = $this->storeBookFile($user, $bookFile);
|
||||
$content['file_path'] = $bookData['path'];
|
||||
$content['file_type'] = $bookData['type'];
|
||||
$content['file_size'] = $bookData['size'];
|
||||
|
||||
if (($data['cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeBookCover($user, $data['cover']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) {
|
||||
$content['icon_path'] = $this->storeMenuBrandImage($user, $data['app_icon'], 'app-icons');
|
||||
}
|
||||
|
||||
if (in_array($type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) {
|
||||
$content['sections'] = $this->injectItemImages($user, $content['sections'] ?? [], $data, $type);
|
||||
if (($data['menu_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['menu_logo'], 'menu-logos');
|
||||
}
|
||||
if (($data['menu_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['menu_cover'], 'menu-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_BUSINESS) {
|
||||
if (($data['business_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['business_logo'], 'business-logos');
|
||||
}
|
||||
if (($data['business_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['business_cover'], 'business-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_CHURCH) {
|
||||
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['church_logo'], 'church-logos');
|
||||
}
|
||||
if (($data['church_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['church_cover'], 'church-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_EVENT) {
|
||||
if (($data['event_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['event_logo'], 'event-logos');
|
||||
}
|
||||
if (($data['event_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['event_cover'], 'event-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['itinerary_cover'], 'itinerary-covers');
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_PAYMENT && ($data['payment_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['payment_logo'], 'payment-logos');
|
||||
}
|
||||
|
||||
if ($style['logo_path'] === null && ($data['logo'] ?? null) instanceof UploadedFile && $type !== QrCode::TYPE_PAYMENT) {
|
||||
$style['logo_path'] = $this->storeLogo($user, $data['logo']);
|
||||
}
|
||||
|
||||
$shortCode = (isset($data['custom_short_code']) && $data['custom_short_code'] !== '')
|
||||
? (string) $data['custom_short_code']
|
||||
: $this->generateUniqueShortCode();
|
||||
|
||||
$payload = [
|
||||
'content' => $content,
|
||||
'style' => $style,
|
||||
];
|
||||
|
||||
// Freeze the WiFi auto-join payload so the printed code never changes on edit.
|
||||
if ($type === QrCode::TYPE_WIFI) {
|
||||
$payload['wifi_encoded'] = \App\Support\Qr\QrWifiPayload::encode($content);
|
||||
}
|
||||
|
||||
$qrCode = QrCode::create([
|
||||
'user_id' => $user->id,
|
||||
'short_code' => $shortCode,
|
||||
'type' => $type,
|
||||
'label' => trim((string) $data['label']),
|
||||
'destination_url' => $validated['destination_url'],
|
||||
'qr_document_id' => $documentId,
|
||||
'payload' => $payload,
|
||||
'is_active' => true,
|
||||
'destination_updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($type !== QrCode::TYPE_PAYMENT) {
|
||||
$this->billing->debitForQrCreation($wallet, $qrCode);
|
||||
}
|
||||
$this->imageGenerator->generateAndStore($qrCode);
|
||||
|
||||
return $qrCode->fresh(['document']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<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,190 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Transfer;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\Transfer;
|
||||
use App\Models\TransferDownloadEvent;
|
||||
use App\Models\TransferFile;
|
||||
use App\Models\User;
|
||||
use App\Services\Qr\QrImageGeneratorService;
|
||||
use App\Support\Qr\QrStyleDefaults;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class TransferService
|
||||
{
|
||||
public function __construct(
|
||||
private QrImageGeneratorService $imageGenerator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param list<UploadedFile> $files
|
||||
*/
|
||||
public function create(User $user, array $data, array $files): Transfer
|
||||
{
|
||||
if ($files === []) {
|
||||
throw new RuntimeException('Add at least one file to share.');
|
||||
}
|
||||
|
||||
$maxFiles = (int) config('transfer.max_files_per_transfer', 20);
|
||||
if (count($files) > $maxFiles) {
|
||||
throw new RuntimeException("You can upload up to {$maxFiles} files per transfer.");
|
||||
}
|
||||
|
||||
$maxBytes = (int) config('transfer.max_file_bytes', 524288000);
|
||||
$retentionDays = (int) ($data['retention_days'] ?? config('transfer.default_retention_days', 30));
|
||||
$retentionDays = max(1, min(365, $retentionDays));
|
||||
|
||||
return DB::transaction(function () use ($user, $data, $files, $maxBytes, $retentionDays) {
|
||||
$passwordHash = null;
|
||||
if (! empty($data['password'])) {
|
||||
$passwordHash = Hash::make((string) $data['password']);
|
||||
}
|
||||
|
||||
$transfer = Transfer::create([
|
||||
'user_id' => $user->id,
|
||||
'title' => trim((string) $data['title']),
|
||||
'message' => isset($data['message']) ? trim((string) $data['message']) : null,
|
||||
'password_hash' => $passwordHash,
|
||||
'retention_days' => $retentionDays,
|
||||
'expires_at' => now()->addDays($retentionDays),
|
||||
'status' => Transfer::STATUS_ACTIVE,
|
||||
]);
|
||||
|
||||
$totalBytes = 0;
|
||||
foreach ($files as $file) {
|
||||
if (! $file instanceof UploadedFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($file->getSize() > $maxBytes) {
|
||||
throw new RuntimeException($file->getClientOriginalName().' exceeds the maximum file size.');
|
||||
}
|
||||
|
||||
$stored = $this->storeFile($user, $transfer, $file);
|
||||
$totalBytes += $stored->size_bytes;
|
||||
}
|
||||
|
||||
if ($totalBytes === 0) {
|
||||
throw new RuntimeException('No valid files were uploaded.');
|
||||
}
|
||||
|
||||
$qrCode = $this->createQrCode($user, $transfer, $data);
|
||||
$transfer->update([
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'storage_bytes' => $totalBytes,
|
||||
]);
|
||||
|
||||
return $transfer->fresh(['files', 'qrCode']);
|
||||
});
|
||||
}
|
||||
|
||||
public function recordDownload(Transfer $transfer, ?TransferFile $file, Request $request): void
|
||||
{
|
||||
TransferDownloadEvent::create([
|
||||
'transfer_id' => $transfer->id,
|
||||
'transfer_file_id' => $file?->id,
|
||||
'downloaded_at' => now(),
|
||||
'ip_hash' => $request->ip() ? hash('sha256', $request->ip()) : null,
|
||||
'user_agent' => Str::limit((string) $request->userAgent(), 500, ''),
|
||||
]);
|
||||
|
||||
$transfer->increment('downloads_total');
|
||||
if ($file) {
|
||||
$file->increment('downloads_total');
|
||||
}
|
||||
}
|
||||
|
||||
public function verifyPassword(Transfer $transfer, ?string $password): bool
|
||||
{
|
||||
if (! $transfer->isPasswordProtected()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $password !== null && Hash::check($password, $transfer->password_hash);
|
||||
}
|
||||
|
||||
private function storeFile(User $user, Transfer $transfer, UploadedFile $file): TransferFile
|
||||
{
|
||||
$uuid = Str::uuid()->toString();
|
||||
$ext = $file->getClientOriginalExtension() ?: 'bin';
|
||||
$path = $user->id.'/transfers/'.$transfer->id.'/'.$uuid.'.'.$ext;
|
||||
|
||||
$file->storeAs('', $path, 'qr');
|
||||
|
||||
return TransferFile::create([
|
||||
'transfer_id' => $transfer->id,
|
||||
'original_name' => $file->getClientOriginalName() ?: 'file.'.$ext,
|
||||
'disk' => 'qr',
|
||||
'path' => $path,
|
||||
'mime_type' => $file->getMimeType() ?: 'application/octet-stream',
|
||||
'size_bytes' => (int) $file->getSize(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function createQrCode(User $user, Transfer $transfer, array $data): QrCode
|
||||
{
|
||||
$shortCode = $this->generateUniqueShortCode();
|
||||
$style = QrStyleDefaults::merge($data['style'] ?? null);
|
||||
|
||||
$qrCode = QrCode::create([
|
||||
'user_id' => $user->id,
|
||||
'short_code' => $shortCode,
|
||||
'type' => QrCode::TYPE_TRANSFER,
|
||||
'label' => $transfer->title,
|
||||
'payload' => [
|
||||
'content' => [
|
||||
'transfer_id' => $transfer->id,
|
||||
'message' => $transfer->message,
|
||||
],
|
||||
'style' => $style,
|
||||
],
|
||||
'is_active' => true,
|
||||
'destination_updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->imageGenerator->generateAndStore($qrCode);
|
||||
|
||||
return $qrCode;
|
||||
}
|
||||
|
||||
private function generateUniqueShortCode(): string
|
||||
{
|
||||
do {
|
||||
$code = Str::lower(Str::random(8));
|
||||
} while (QrCode::query()->where('short_code', $code)->exists());
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
public function delete(Transfer $transfer): void
|
||||
{
|
||||
DB::transaction(function () use ($transfer) {
|
||||
foreach ($transfer->files as $file) {
|
||||
Storage::disk($file->disk)->delete($file->path);
|
||||
}
|
||||
|
||||
if ($transfer->qrCode) {
|
||||
if ($transfer->qrCode->png_path) {
|
||||
Storage::disk('qr')->delete($transfer->qrCode->png_path);
|
||||
}
|
||||
if ($transfer->qrCode->svg_path) {
|
||||
Storage::disk('qr')->delete($transfer->qrCode->svg_path);
|
||||
}
|
||||
$transfer->qrCode->update(['is_active' => false]);
|
||||
}
|
||||
|
||||
$transfer->update(['status' => Transfer::STATUS_DELETED]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 Mini — static payment QR codes only.
|
||||
*/
|
||||
class QrTypeCatalog
|
||||
{
|
||||
/** @return list<string> */
|
||||
public static function paymentTypes(): array
|
||||
{
|
||||
return [QrCode::TYPE_PAYMENT];
|
||||
}
|
||||
|
||||
/** @return array<string, array{label: string, description: string, category: string, icon: string}> */
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
QrCode::TYPE_PAYMENT => [
|
||||
'label' => 'Payment QR',
|
||||
'description' => 'A static QR customers scan to pay you any amount',
|
||||
'category' => 'payments',
|
||||
'icon' => 'payment.svg',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<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/transfer",
|
||||
"type": "project",
|
||||
"description": "Ladill Transfer — secure file sharing with QR links at transfer.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 Transfer.
|
||||
'product' => env('AFIA_PRODUCT', 'transfer'),
|
||||
'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 Transfer'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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 Transfer (transfer.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')),
|
||||
'transfer_domain' => env('TRANSFER_DOMAIN', parse_url((string) env('APP_URL', 'https://transfer.ladill.com'), PHP_URL_HOST) ?: 'transfer.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_TRANSFER'),
|
||||
'service' => 'transfer',
|
||||
'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_TRANSFER'),
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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' => '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'],
|
||||
],
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user