Files
ladill-merchant/app/Http/Controllers/Merchant/StorefrontController.php
T
isaaccladandClaude Opus 4.8 b2aede5963
Deploy Ladill Merchant / deploy (push) Successful in 29s
Add opt-in custom domains with automatic SSL
Customers can connect their own domain to a merchant page (storefront/event):
add a domain, point an A record (apex + www) to the app server, click Verify —
DNS is checked, then Ladill Domains' central SSL service issues + installs the
Let's Encrypt cert and calls back to flip it live. The custom domain then serves
the mapped page (host resolution on /). Feature-gated: only active when a Domains
SSL API key is set, so this deploy is inert until wired.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:59:19 +00:00

241 lines
8.9 KiB
PHP

<?php
namespace App\Http\Controllers\Merchant;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Crm\CrmClient;
use App\Services\Qr\QrCodeManagerService;
use App\Services\Qr\QrImageGeneratorService;
use App\Services\Qr\QrPdfExporter;
use App\Support\Qr\QrTypeCatalog;
use App\Support\CrmPrefillCodec;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class StorefrontController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private QrImageGeneratorService $imageGenerator,
private QrPdfExporter $pdfExporter,
) {}
public function index(Request $request): View
{
$account = ladill_account();
$qrCodes = $account->qrCodes()
->whereIn('type', \App\Support\Qr\QrTypeCatalog::storefrontTypes())
->latest()
->get();
$previewDataUris = $qrCodes->mapWithKeys(function (QrCode $qr) {
return [$qr->id => $this->imageGenerator->previewDataUri($qr)];
});
return view('merchant.storefronts.index', [
'qrCodes' => $qrCodes,
'previewDataUris' => $previewDataUris,
]);
}
public function create(Request $request): View
{
$requestedType = (string) $request->query('type', QrCode::TYPE_SHOP);
if (! QrTypeCatalog::isValid($requestedType)) {
$requestedType = QrCode::TYPE_SHOP;
}
$prefill = CrmPrefillCodec::decode($request->query('prefill'));
if (($prefill['kind'] ?? null) === 'merchant_storefront' && ! empty($prefill['type'])) {
$requestedType = (string) $prefill['type'];
}
return view('merchant.storefronts.create', [
'requestedType' => $requestedType,
'prefill' => ($prefill['kind'] ?? null) === 'merchant_storefront' ? $prefill : null,
'catalog' => $this->catalogProducts(),
]);
}
/**
* Active products from the merchant's Ladill CRM catalog, shaped for the
* storefront editor's "add from catalog" picker. Resilient: returns [] if
* CRM is unreachable so the form still works.
*
* @return array<int,array{name:string,price:string,currency:string,description:string}>
*/
private function catalogProducts(): array
{
try {
$response = CrmClient::for((string) ladill_account()->public_id)
->products(['type' => 'product', 'active' => 1, 'per_page' => 200]);
} catch (\Throwable) {
return [];
}
return collect($response['data'] ?? [])
->map(fn ($p) => [
'name' => (string) ($p['name'] ?? ''),
'price' => number_format(((int) ($p['unit_price_minor'] ?? 0)) / 100, 2, '.', ''),
'currency' => strtoupper((string) ($p['currency'] ?? 'GHS')),
'description' => (string) ($p['description'] ?? ''),
])
->filter(fn ($p) => $p['name'] !== '')
->values()
->all();
}
public function store(Request $request): RedirectResponse
{
$account = ladill_account();
$request->validate([
'label' => ['required', 'string', 'max:120'],
'type' => ['required', 'in:'.implode(',', QrTypeCatalog::storefrontTypes())],
'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'item_images' => ['nullable', 'array'],
'item_images.*' => ['array'],
'item_images.*.*' => ['mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
]);
$data = array_merge($request->all(), [
'menu_logo' => $request->file('menu_logo'),
'menu_cover' => $request->file('menu_cover'),
]);
try {
$qrCode = $this->manager->create($account, $data);
} catch (RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
return redirect()->route('merchant.storefronts.show', $qrCode)
->with('success', QrTypeCatalog::label($qrCode->type).' storefront created.');
}
public function show(QrCode $storefront): View
{
$this->authorizeStorefront($storefront);
return view('merchant.storefronts.show', [
'qrCode' => $storefront->fresh(),
'previewDataUri' => $this->imageGenerator->previewDataUri($storefront),
'catalog' => $this->catalogProducts(),
'customDomains' => \App\Models\CustomDomain::where('qr_code_id', $storefront->id)->get(),
'customDomainsEnabled' => app(\App\Services\CustomDomain\CustomDomainService::class)->enabled(),
'customDomainServerIp' => config('customdomain.server_ip'),
]);
}
public function update(Request $request, QrCode $storefront): RedirectResponse
{
$this->authorizeStorefront($storefront);
$request->validate([
'label' => ['sometimes', 'string', 'max:120'],
'is_active' => ['sometimes', 'boolean'],
'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'item_images' => ['nullable', 'array'],
'item_images.*' => ['array'],
'item_images.*.*' => ['mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
]);
$data = array_merge($request->all(), [
'is_active' => $request->boolean('is_active', $storefront->is_active),
'menu_logo' => $request->file('menu_logo'),
'menu_cover' => $request->file('menu_cover'),
]);
try {
$this->manager->update($storefront, $data);
} catch (RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
return back()->with('success', 'Storefront updated.');
}
public function destroy(QrCode $storefront): RedirectResponse
{
$this->authorizeStorefront($storefront);
$this->manager->delete($storefront);
return redirect()
->route('merchant.storefronts.index')
->with('success', 'Storefront deleted.');
}
public function preview(QrCode $storefront): Response
{
$this->authorizeStorefront($storefront);
$qrCode = $this->imageGenerator->ensureValidImages($storefront);
$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 $storefront, string $format): StreamedResponse
{
$this->authorizeStorefront($storefront);
$qrCode = $this->imageGenerator->ensureValidImages($storefront);
$path = $format === 'svg' ? $qrCode->svg_path : $qrCode->png_path;
$filename = Str::slug($qrCode->label).'-qr.'.($format === 'svg' ? 'svg' : 'png');
if ($format === 'png') {
$bytes = $this->imageGenerator->normalizeStoredPng($path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
return response()->streamDownload(fn () => print($bytes), $filename, [
'Content-Type' => 'image/png',
]);
}
if ($format === 'pdf') {
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
$pdf = $this->pdfExporter->fromPng($bytes, $qrCode->label);
return response()->streamDownload(fn () => print($pdf), Str::slug($qrCode->label).'-qr.pdf', [
'Content-Type' => 'application/pdf',
]);
}
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->download($path, $filename);
}
private function authorizeStorefront(QrCode $storefront): void
{
abort_unless(in_array($storefront->type, \App\Support\Qr\QrTypeCatalog::storefrontTypes(), true), 404);
abort_unless($storefront->user_id === ladill_account()->id, 403);
}
}