Bootstrap Ladill Link from QR Plus extract template.

This commit is contained in:
isaacclad
2026-06-27 10:54:31 +00:00
commit 04e4f6ab51
243 changed files with 26587 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Support;
final class CrmPrefillCodec
{
/** @return array<string, mixed>|null */
public static function decode(?string $token): ?array
{
if (! is_string($token) || $token === '') {
return null;
}
$padded = $token.str_repeat('=', (4 - strlen($token) % 4) % 4);
$json = base64_decode(strtr($padded, '-_', '+/'), true);
if ($json === false) {
return null;
}
try {
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($data) ? $data : null;
}
}
+369
View File
@@ -0,0 +1,369 @@
<?php
namespace App\Support;
use App\Services\Domain\DomainPricingService;
class DomainConfig
{
private const PRIORITY_TLDS = [
'com', 'org', 'net', 'ai', 'io', 'co', 'app', 'dev',
'store', 'shop', 'online', 'site', 'tech', 'cloud', 'pro', 'me',
'xyz', 'club', 'live', 'com.ng', 'com.gh', 'co.za', 'co.uk', 'info', 'biz',
];
private const DEFAULT_PAGE_SIZE = 20;
/**
* Get featured TLDs for initial display (lightweight).
*
* @return list<string>
*/
public static function featuredTlds(): array
{
$allTlds = self::allTlds();
$featured = array_values(array_intersect(self::PRIORITY_TLDS, $allTlds));
if ($featured !== []) {
return $featured;
}
return array_slice($allTlds, 0, self::DEFAULT_PAGE_SIZE);
}
/**
* Get all available TLDs sorted by priority.
*
* @return list<string>
*/
public static function allTlds(): array
{
$current = self::normalizeTlds(config('domain.search_tlds', []));
$legacy = self::normalizeTlds(config('mailinfra.domain_search_tlds', []));
$tlds = $legacy !== [] ? $legacy : $current;
$livePricedTlds = self::livePricedTlds();
if ($livePricedTlds === []) {
return [];
}
$livePricedLookup = array_flip($livePricedTlds);
$tlds = array_values(array_filter(
$tlds,
static fn (string $tld): bool => isset($livePricedLookup[$tld])
));
return self::sortWithPriority($tlds);
}
/**
* Get paginated TLDs (excluding featured ones).
*
* @return array{tlds: list<string>, hasMore: bool, total: int}
*/
public static function paginatedTlds(int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array
{
$allTlds = self::allTlds();
$featured = self::featuredTlds();
$remaining = array_values(array_filter($allTlds, fn ($tld) => !in_array($tld, $featured, true)));
$total = count($remaining);
$offset = ($page - 1) * $perPage;
$tlds = array_slice($remaining, $offset, $perPage);
return [
'tlds' => $tlds,
'hasMore' => ($offset + $perPage) < $total,
'total' => $total,
];
}
/**
* @deprecated Use featuredTlds() or allTlds() instead
* @return list<string>
*/
public static function searchTlds(): array
{
return self::featuredTlds();
}
/**
* Build the TLD list and query term for a search page request.
*
* @return array{query: string, tlds: list<string>, hasMore: bool, exact_domain: string|null}
*/
public static function searchPageConfig(string $query, int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array
{
$query = strtolower(trim($query));
$allTlds = self::allTlds();
if ($allTlds === []) {
return [
'query' => $query,
'tlds' => [],
'hasMore' => false,
'exact_domain' => null,
];
}
$exactDomain = self::normalizeExactDomain($query);
if ($exactDomain === null) {
if ($page === 1) {
$featured = self::featuredTlds();
$paginatedData = self::paginatedTlds(1, $perPage);
return [
'query' => $query,
'tlds' => $featured,
'hasMore' => $paginatedData['hasMore'] || count($paginatedData['tlds']) > 0,
'exact_domain' => null,
];
}
$paginatedData = self::paginatedTlds($page - 1, $perPage);
return [
'query' => $query,
'tlds' => $paginatedData['tlds'],
'hasMore' => $paginatedData['hasMore'],
'exact_domain' => null,
];
}
$exactTld = self::extractTld($exactDomain);
$keyword = explode('.', $exactDomain, 2)[0] ?? $exactDomain;
$firstPageTlds = array_values(array_unique(array_filter([
$exactTld,
...self::featuredTlds(),
])));
$remainingTlds = array_values(array_filter(
$allTlds,
static fn (string $tld): bool => ! in_array($tld, $firstPageTlds, true)
));
if ($page === 1) {
return [
'query' => $keyword,
'tlds' => $firstPageTlds,
'hasMore' => $remainingTlds !== [],
'exact_domain' => $exactDomain,
];
}
$offset = max(0, ($page - 2) * $perPage);
return [
'query' => $keyword,
'tlds' => array_slice($remainingTlds, $offset, $perPage),
'hasMore' => ($offset + $perPage) < count($remainingTlds),
'exact_domain' => $exactDomain,
];
}
/**
* Sort domain search results by TLD priority.
*
* @param array<int, array{domain: string, ...}> $results
* @return array<int, array{domain: string, ...}>
*/
public static function sortResultsByTldPriority(array $results, ?string $query = null): array
{
$priorityMap = array_flip(self::PRIORITY_TLDS);
$exactDomain = self::normalizeExactDomain((string) $query);
usort($results, static function (array $a, array $b) use ($priorityMap, $exactDomain): int {
$aDomain = strtolower((string) ($a['domain'] ?? ''));
$bDomain = strtolower((string) ($b['domain'] ?? ''));
$aExact = $exactDomain !== null && $aDomain === $exactDomain ? 0 : 1;
$bExact = $exactDomain !== null && $bDomain === $exactDomain ? 0 : 1;
if ($aExact !== $bExact) {
return $aExact <=> $bExact;
}
$aPremium = self::isPremiumResult($a) ? 0 : 1;
$bPremium = self::isPremiumResult($b) ? 0 : 1;
if ($aPremium !== $bPremium) {
return $aPremium <=> $bPremium;
}
$aAvailable = ! empty($a['available']) ? 0 : 1;
$bAvailable = ! empty($b['available']) ? 0 : 1;
if ($aAvailable !== $bAvailable) {
return $aAvailable <=> $bAvailable;
}
$aTld = self::extractTld($a['domain'] ?? '');
$bTld = self::extractTld($b['domain'] ?? '');
$aPriority = $priorityMap[$aTld] ?? PHP_INT_MAX;
$bPriority = $priorityMap[$bTld] ?? PHP_INT_MAX;
if ($aPriority !== $bPriority) {
return $aPriority <=> $bPriority;
}
return $aDomain <=> $bDomain;
});
return $results;
}
/**
* Extract TLD from a domain name.
*/
private static function extractTld(string $domain): string
{
$parts = explode('.', strtolower($domain), 2);
return $parts[1] ?? '';
}
private static function normalizeExactDomain(string $query): ?string
{
$query = strtolower(trim($query));
if ($query === '' || str_starts_with($query, '.') || str_ends_with($query, '.') || ! str_contains($query, '.')) {
return null;
}
return preg_match('/^(?=.{1,253}$)(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/', $query) === 1
? $query
: null;
}
/**
* @param array<string, mixed> $result
*/
private static function isPremiumResult(array $result): bool
{
$premium = $result['premium'] ?? false;
if (is_bool($premium)) {
return $premium;
}
return in_array(strtolower(trim((string) $premium)), ['1', 'yes', 'true', 'premium'], true);
}
/**
* @param list<string> $tlds
* @return list<string>
*/
private static function sortWithPriority(array $tlds): array
{
$priorityMap = array_flip(self::PRIORITY_TLDS);
$pricingMap = self::pricingMapForTlds($tlds);
usort($tlds, static function (string $a, string $b) use ($priorityMap, $pricingMap): int {
$aPriority = $priorityMap[$a] ?? PHP_INT_MAX;
$bPriority = $priorityMap[$b] ?? PHP_INT_MAX;
if ($aPriority !== $bPriority) {
return $aPriority <=> $bPriority;
}
$aPrice = (int) ($pricingMap[$a]['register'] ?? 0);
$bPrice = (int) ($pricingMap[$b]['register'] ?? 0);
if ($aPrice !== $bPrice) {
return $bPrice <=> $aPrice;
}
return $a <=> $b;
});
return $tlds;
}
/**
* @return array<string, string>
*/
public static function resellerClubTldProductKeys(): array
{
return array_replace(
self::normalizeProductKeyMap(config('domain.resellerclub.tld_product_keys', [])),
self::normalizeProductKeyMap(config('mailinfra.resellerclub_tld_product_keys', []))
);
}
public static function resellerClubProductKeyCacheTtlSeconds(): int
{
$ttl = (int) config('domain.resellerclub.product_key_cache_ttl_seconds', 86400);
return $ttl > 0 ? $ttl : 86400;
}
/**
* @param mixed $value
* @return list<string>
*/
private static function normalizeTlds(mixed $value): array
{
if (! is_array($value)) {
return [];
}
return array_values(array_unique(array_filter(array_map(
static fn ($tld): string => ltrim(strtolower(trim((string) $tld)), '.'),
$value
))));
}
/**
* @param mixed $value
* @return array<string, string>
*/
private static function normalizeProductKeyMap(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$map = [];
foreach ($value as $tld => $productKey) {
$normalizedTld = ltrim(strtolower(trim((string) $tld)), '.');
$normalizedProductKey = trim((string) $productKey);
if ($normalizedTld === '' || $normalizedProductKey === '') {
continue;
}
$map[$normalizedTld] = $normalizedProductKey;
}
return $map;
}
/**
* @return list<string>
*/
private static function livePricedTlds(): array
{
try {
return app(DomainPricingService::class)->getLivePricedTlds();
} catch (\Throwable) {
return [];
}
}
/**
* @param list<string> $tlds
* @return array<string, array{register: int, renew: int, transfer: int, currency: string}>
*/
private static function pricingMapForTlds(array $tlds): array
{
try {
return app(DomainPricingService::class)->getPricingForTlds($tlds);
} catch (\Throwable) {
return [];
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Support;
final class DomainGlobeIcon
{
public const VIEW_BOX = '0 0 14 14';
/** 14×14 globe for UI icons (inherits color via currentColor). */
public const ICON_ASSET = 'images/ladill-icons/domain.svg';
public static function paths(): string
{
return '<path d="M7 13.5C10.5899 13.5 13.5 10.5899 13.5 7C13.5 3.41015 10.5899 0.5 7 0.5C3.41015 0.5 0.5 3.41015 0.5 7C0.5 10.5899 3.41015 13.5 7 13.5Z" stroke-linecap="round" stroke-linejoin="round"/>'
.'<path d="M0.5 7H13.5" stroke-linecap="round" stroke-linejoin="round"/>'
.'<path d="M9.5 7C9.3772 9.37699 8.50168 11.6533 7 13.5C5.49832 11.6533 4.6228 9.37699 4.5 7C4.6228 4.62301 5.49832 2.34665 7 0.5C8.50168 2.34665 9.3772 4.62301 9.5 7V7Z" stroke-linecap="round" stroke-linejoin="round"/>';
}
public static function svg(string $class = 'h-5 w-5'): string
{
return sprintf(
'<svg class="%s" viewBox="%s" fill="none" stroke="currentColor" stroke-width="1" aria-hidden="true">%s</svg>',
e($class),
self::VIEW_BOX,
self::paths()
);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Support;
/** Storage-tier pricing for mailboxes (config/email.php quota_tiers). */
class MailboxPricing
{
/** @return array<int,array{mb:int,price_minor:int,label:string}> */
public static function tiers(): array
{
return array_map(fn ($t) => [
'mb' => (int) $t['mb'],
'price_minor' => (int) $t['price_minor'],
'label' => self::label((int) $t['mb']),
], (array) config('email.quota_tiers', []));
}
public static function isValidQuota(int $mb): bool
{
foreach (self::tiers() as $t) {
if ($t['mb'] === $mb) {
return true;
}
}
return false;
}
public static function priceMinorFor(int $mb): int
{
foreach (self::tiers() as $t) {
if ($t['mb'] === $mb) {
return $t['price_minor'];
}
}
return 0;
}
/** A tier is free when its monthly price is zero (the 1 GB plan). */
public static function isFree(int $mb): bool
{
return self::priceMinorFor($mb) === 0;
}
/** The smallest free tier's quota (the default for new mailboxes). */
public static function freeQuotaMb(): int
{
foreach (self::tiers() as $t) {
if ($t['price_minor'] === 0) {
return $t['mb'];
}
}
return self::tiers()[0]['mb'] ?? 1024;
}
public static function defaultQuotaMb(): int
{
$default = (int) config('email.default_quota_mb', 1024);
return self::isValidQuota($default) ? $default : self::freeQuotaMb();
}
public static function label(int $mb): string
{
return $mb % 1024 === 0 ? ($mb / 1024).' GB' : $mb.' MB';
}
}
+19
View File
@@ -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,
];
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Support\Qr;
use App\Models\QrCode;
use Illuminate\Support\Collection;
/**
* Generates ZPL II label code for Zebra-style badge/label printers.
* One ^XA…^XZ label block per attendee, sized to the chosen badge size at 203 dpi.
*/
class EventBadgeZpl
{
/** @param Collection<int, \App\Models\QrEventRegistration> $registrations */
public static function forRegistrations(QrCode $qrCode, Collection $registrations): string
{
$content = $qrCode->content();
$eventName = self::sanitize($content['name'] ?? $qrCode->label);
$size = $content['badge_size'] ?? '4x3';
// Label dimensions in dots @ 203 dpi.
[$widthDots, $heightDots] = match ($size) {
'4x6' => [812, 1218],
'cr80' => [685, 431],
default => [812, 609], // 4x3
};
$labels = [];
foreach ($registrations as $reg) {
$name = self::sanitize($reg->attendee_name);
$tier = self::sanitize($reg->tier_name);
$extra = collect($reg->badge_fields ?? [])
->map(fn ($v, $k) => self::sanitize($k . ': ' . $v))
->implode(' | ');
$zpl = "^XA\n";
$zpl .= "^PW{$widthDots}\n";
$zpl .= "^LL{$heightDots}\n";
$zpl .= "^CI28\n"; // UTF-8
// Event name (top)
$zpl .= "^FO40,40^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$eventName}^FS\n";
// Attendee name (large, centered)
$zpl .= "^FO40,150^A0N,80,80^FB" . ($widthDots - 80) . ",2,0,C^FD{$name}^FS\n";
// Tier
$zpl .= "^FO40,320^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$tier}^FS\n";
// Extra fields
if ($extra !== '') {
$zpl .= "^FO40,375^A0N,28,28^FB" . ($widthDots - 80) . ",2,0,C^FD{$extra}^FS\n";
}
// QR of the badge code (bottom)
$qrX = (int) (($widthDots / 2) - 70);
$zpl .= "^FO{$qrX}," . ($heightDots - 200) . "^BQN,2,5^FDLA,{$reg->badge_code}^FS\n";
// Badge code text
$zpl .= "^FO40," . ($heightDots - 60) . "^A0N,34,34^FB" . ($widthDots - 80) . ",1,0,C^FD{$reg->badge_code}^FS\n";
$zpl .= "^XZ\n";
$labels[] = $zpl;
}
return implode("\n", $labels);
}
private static function sanitize(string $value): string
{
// Escape ZPL control chars (^ and ~) and collapse whitespace.
$value = str_replace(['^', '~'], [' ', ' '], $value);
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Support\Qr;
class QrCornerStyleCatalog
{
/** @return array<string, array{label: string}> */
public static function outerStyles(): array
{
return [
'square' => ['label' => 'Square eye'],
'rounded' => ['label' => 'Rounded'],
'circle' => ['label' => 'Circular'],
];
}
/** @return array<string, array{label: string}> */
public static function innerStyles(): array
{
return [
'square' => ['label' => 'Square'],
'rounded' => ['label' => 'Rounded'],
'dot' => ['label' => 'Dot'],
];
}
public static function isValidOuter(string $style): bool
{
return isset(self::outerStyles()[$style]);
}
public static function isValidInner(string $style): bool
{
return isset(self::innerStyles()[$style]);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Support\Qr;
class QrCoverImageSpec
{
/** Wide hero banners (business, church, event, itinerary, menu, shop). */
public const BANNER = '1920×1080 px (16:9 landscape)';
/** Book product cover on the landing page. */
public const BOOK = '800×1067 px (3:4 portrait)';
public static function label(string $variant = 'banner'): string
{
return match ($variant) {
'book' => self::BOOK,
default => self::BANNER,
};
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Support\Qr;
use Carbon\Carbon;
class QrDateFormatter
{
public static function forInput(?string $value): string
{
if ($value === null || trim($value) === '') {
return '';
}
try {
return Carbon::parse($value)->format('Y-m-d');
} catch (\Throwable) {
return '';
}
}
public static function forDisplay(?string $value): string
{
if ($value === null || trim($value) === '') {
return '';
}
$value = trim($value);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
try {
return Carbon::createFromFormat('Y-m-d', $value)->format('D, j M Y');
} catch (\Throwable) {
return $value;
}
}
return $value;
}
public static function normalize(?string $value, int $maxLength = 60): string
{
if ($value === null || trim($value) === '') {
return '';
}
$value = trim($value);
try {
return Carbon::parse($value)->format('Y-m-d');
} catch (\Throwable) {
return mb_substr($value, 0, $maxLength);
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Support\Qr;
class QrFrameStyleCatalog
{
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
public static function all(): array
{
return [
'none' => [
'label' => 'None',
'description' => 'Code only',
'border_px' => 0,
'mode' => 'none',
],
'thin' => [
'label' => 'Border',
'description' => 'Simple white sticker edge',
'border_px' => 8,
'mode' => 'border',
],
'bold' => [
'label' => 'Wide border',
'description' => 'Legacy wide border',
'border_px' => 16,
'mode' => 'border',
'visible' => false,
],
'scan_me' => [
'label' => 'Scan me',
'description' => 'CTA sticker below code',
'border_px' => 14,
'mode' => 'label',
'cta' => 'SCAN ME',
],
'tap_to_scan' => [
'label' => 'Tap to scan',
'description' => 'Rounded CTA sticker',
'border_px' => 14,
'mode' => 'pill',
'cta' => 'TAP TO SCAN',
],
];
}
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
public static function visible(): array
{
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
}
/** @return list<string> */
public static function keys(): array
{
return array_keys(self::all());
}
public static function isValid(string $style): bool
{
return isset(self::all()[$style]);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Support\Qr;
class QrModuleStyleCatalog
{
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
public static function all(): array
{
return [
'square' => [
'label' => 'Standard',
'description' => 'Traditional square QR modules',
'circular' => false,
'circle_radius' => 0.4,
'connect_paths' => false,
'scan_risk' => 'low',
],
'soft' => [
'label' => 'Rounded',
'description' => 'Soft rounded modules',
'circular' => true,
'circle_radius' => 0.36,
'connect_paths' => false,
'scan_risk' => 'medium',
'visible' => false,
],
'dots' => [
'label' => 'Dots',
'description' => 'Round dot modules',
'circular' => true,
'circle_radius' => 0.45,
'connect_paths' => false,
'scan_risk' => 'medium',
],
'bubble' => [
'label' => 'Large dots',
'description' => 'Bolder round dot modules',
'circular' => true,
'circle_radius' => 0.52,
'connect_paths' => false,
'scan_risk' => 'high',
'visible' => false,
],
'fluid' => [
'label' => 'Fluid',
'description' => 'Legacy decorative style',
'circular' => true,
'circle_radius' => 0.38,
'connect_paths' => true,
'scan_risk' => 'high',
'visible' => false,
],
'bold' => [
'label' => 'Bold',
'description' => 'Legacy thick square modules',
'circular' => false,
'circle_radius' => 0.4,
'connect_paths' => false,
'scan_risk' => 'low',
'visible' => false,
],
];
}
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
public static function visible(): array
{
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
}
/** @return list<string> */
public static function keys(): array
{
return array_keys(self::all());
}
public static function isValid(string $style): bool
{
return isset(self::all()[$style]);
}
/** @return array<string, mixed> */
public static function optionsFor(string $style): array
{
return self::all()[$style] ?? self::all()['square'];
}
public static function isDecorative(string $style): bool
{
return in_array(self::optionsFor($style)['scan_risk'], ['medium', 'high'], true);
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Support\Qr;
class QrScanReliability
{
/** @param array<string, mixed> $style */
public static function contrastRatio(array $style): float
{
$fg = self::relativeLuminance((string) ($style['foreground'] ?? '#000000'));
$bg = self::relativeLuminance((string) ($style['background'] ?? '#ffffff'));
$lighter = max($fg, $bg);
$darker = min($fg, $bg);
return ($lighter + 0.05) / ($darker + 0.05);
}
/**
* @param array<string, mixed> $style
* @return array{level: string, messages: list<string>}
*/
public static function assess(array $style): array
{
$style = QrStyleDefaults::merge($style);
$messages = [];
$level = 'good';
$contrast = self::contrastRatio($style);
$isClassic = self::isClassicBlackOnWhite($style);
$moduleStyle = (string) ($style['module_style'] ?? 'square');
$moduleMeta = QrModuleStyleCatalog::optionsFor($moduleStyle);
if ($moduleMeta['scan_risk'] === 'medium') {
$messages[] = 'This module style may not scan on every phone camera. Square is the safest choice.';
$level = 'fair';
}
if ($moduleMeta['scan_risk'] === 'high') {
$messages[] = 'Decorative styles like ' . $moduleMeta['label'] . ' often fail on Samsung Camera. Use square for print and signage.';
$level = 'poor';
}
if (! $isClassic) {
$messages[] = 'Colored QR codes often fail on Samsung Camera and other basic scanners. Black on white works everywhere.';
$level = $level === 'good' ? 'fair' : 'poor';
}
if ($contrast < 4.5) {
$messages[] = sprintf(
'Contrast is low (%.1f:1). Aim for at least 4.5:1 — dark foreground, white background.',
$contrast,
);
$level = 'poor';
}
if ($moduleMeta['scan_risk'] !== 'low' && ! $isClassic) {
$level = 'poor';
}
if ($messages === []) {
$messages[] = 'Black square modules on white give the best compatibility with Samsung, iPhone, and printed codes.';
}
return ['level' => $level, 'messages' => $messages];
}
/** @param array<string, mixed> $style */
public static function isClassicBlackOnWhite(array $style): bool
{
$fg = strtolower(ltrim((string) ($style['foreground'] ?? ''), '#'));
$bg = strtolower(ltrim((string) ($style['background'] ?? ''), '#'));
$fg = strlen($fg) === 3 ? $fg[0] . $fg[0] . $fg[1] . $fg[1] . $fg[2] . $fg[2] : $fg;
$bg = strlen($bg) === 3 ? $bg[0] . $bg[0] . $bg[1] . $bg[1] . $bg[2] . $bg[2] : $bg;
return in_array($fg, ['000000', '0f172a', '111111', '1a1a1a'], true)
&& in_array($bg, ['ffffff', 'fff'], true);
}
private static function relativeLuminance(string $hex): float
{
$hex = ltrim(trim($hex), '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
return 0.0;
}
$channels = [];
foreach ([0, 2, 4] as $offset) {
$value = hexdec(substr($hex, $offset, 2)) / 255;
$channels[] = $value <= 0.03928
? $value / 12.92
: (($value + 0.055) / 1.055) ** 2.4;
}
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Support\Qr;
class QrStyleDefaults
{
/** @return array<string, mixed> */
public static function defaults(): array
{
return [
'foreground' => '#000000',
'background' => '#ffffff',
'error_correction' => 'M',
'margin' => 4,
'module_style' => 'square',
'finder_outer' => 'square',
'finder_inner' => 'square',
'frame_style' => 'none',
'frame_text' => '',
'frame_color' => '#000000',
'scale' => 8,
'logo_path' => null,
'gradient_type' => 'none',
'gradient_color1' => '#000000',
'gradient_color2' => '#7c3aed',
'gradient_rotation' => 45,
'logo_size' => 0.3,
'logo_margin' => 5,
'logo_white_bg' => false,
'logo_shape' => 'none',
];
}
/** @param array<string, mixed>|null $style */
public static function merge(?array $style): array
{
$merged = array_merge(self::defaults(), $style ?? []);
if (! in_array($merged['error_correction'], ['L', 'M', 'Q', 'H'], true)) {
$merged['error_correction'] = 'M';
}
if (! in_array($merged['module_style'], QrModuleStyleCatalog::keys(), true)) {
$merged['module_style'] = 'square';
}
if (! QrCornerStyleCatalog::isValidOuter((string) ($merged['finder_outer'] ?? 'square'))) {
$merged['finder_outer'] = 'square';
}
if (! QrCornerStyleCatalog::isValidInner((string) ($merged['finder_inner'] ?? 'square'))) {
$merged['finder_inner'] = 'square';
}
if (! QrFrameStyleCatalog::isValid((string) ($merged['frame_style'] ?? 'none'))) {
$merged['frame_style'] = 'none';
}
$merged['frame_text'] = substr(trim((string) ($merged['frame_text'] ?? '')), 0, 100);
$frameColor = trim((string) ($merged['frame_color'] ?? '#000000'));
$merged['frame_color'] = preg_match('/^#[0-9a-fA-F]{6}$/', $frameColor) ? $frameColor : '#000000';
if (! in_array($merged['gradient_type'], ['none', 'linear', 'radial'], true)) {
$merged['gradient_type'] = 'none';
}
$merged['logo_size'] = max(0.1, min(0.4, (float) ($merged['logo_size'] ?? 0.3)));
$merged['logo_margin'] = max(0, min(15, (int) ($merged['logo_margin'] ?? 5)));
if (! in_array($merged['logo_shape'], ['none', 'rounded', 'circle'], true)) {
$merged['logo_shape'] = 'none';
}
$merged['logo_white_bg'] = (bool) ($merged['logo_white_bg'] ?? false);
if ($merged['module_style'] === 'bold') {
$merged['scale'] = min(16, (int) $merged['scale'] + 2);
}
$merged['margin'] = max(0, min(10, (int) $merged['margin']));
$merged['scale'] = max(4, min(16, (int) $merged['scale']));
return $merged;
}
/** @param array<string, mixed>|null $style */
public static function mergeForRender(?array $style): array
{
return QrStyleNormalizer::normalize(self::merge($style));
}
/** @param array<string, mixed> $style */
public static function recommendedEcc(array $style): string
{
$ecc = (string) ($style['error_correction'] ?? 'M');
$order = ['L' => 0, 'M' => 1, 'Q' => 2, 'H' => 3];
$minimum = 'M';
$moduleStyle = (string) ($style['module_style'] ?? 'square');
if (in_array($moduleStyle, ['bubble', 'fluid'], true)) {
$minimum = 'H';
} elseif (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
$minimum = 'Q';
}
if (! empty($style['logo_path'])) {
$minimum = 'H';
}
if (QrScanReliability::contrastRatio($style) < 4.5) {
$minimum = 'H';
}
return ($order[$minimum] ?? 1) > ($order[$ecc] ?? 1) ? $minimum : $ecc;
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Support\Qr;
class QrStyleNormalizer
{
/**
* Silently tune styles so more phone cameras (including Samsung) can read the code.
*
* @param array<string, mixed> $style
* @return array<string, mixed>
*/
public static function normalize(array $style): array
{
$style = self::ensureContrast($style);
$style = self::ensureQuietZone($style);
$style = self::ensureRenderScale($style);
$style['error_correction'] = QrStyleDefaults::recommendedEcc($style);
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureContrast(array $style): array
{
$target = 4.5;
$attempts = 0;
while (QrScanReliability::contrastRatio($style) < $target && $attempts < 16) {
$fg = self::hexToRgb((string) $style['foreground']);
$bg = self::hexToRgb((string) $style['background']);
if (self::relativeLuminance($fg) > self::relativeLuminance($bg)) {
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.18));
} else {
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.15));
$style['background'] = self::rgbToHex(self::lightenToward($bg, 0.15));
}
$attempts++;
}
if (QrScanReliability::contrastRatio($style) < $target) {
$style['foreground'] = '#000000';
$style['background'] = '#ffffff';
}
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureQuietZone(array $style): array
{
$margin = (int) ($style['margin'] ?? 4);
$moduleStyle = (string) ($style['module_style'] ?? 'square');
if (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
$margin = max($margin, 5);
}
$style['margin'] = max(4, min(10, $margin));
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureRenderScale(array $style): array
{
$scale = (int) ($style['scale'] ?? 8);
$module = QrModuleStyleCatalog::optionsFor((string) ($style['module_style'] ?? 'square'));
if ($module['circular'] ?? false) {
$scale = max($scale, 10);
}
if (! empty($style['logo_path'])) {
$scale = max($scale, 10);
}
$style['scale'] = max(6, min(16, $scale));
return $style;
}
/** @return array{0: int, 1: int, 2: int} */
private static function hexToRgb(string $hex): array
{
$hex = ltrim(trim($hex), '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
return [0, 0, 0];
}
return [hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2))];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function rgbToHex(array $rgb): string
{
return sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function darkenToward(array $rgb, float $amount): array
{
return [
(int) max(0, $rgb[0] * (1 - $amount)),
(int) max(0, $rgb[1] * (1 - $amount)),
(int) max(0, $rgb[2] * (1 - $amount)),
];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function lightenToward(array $rgb, float $amount): array
{
return [
(int) min(255, $rgb[0] + ((255 - $rgb[0]) * $amount)),
(int) min(255, $rgb[1] + ((255 - $rgb[1]) * $amount)),
(int) min(255, $rgb[2] + ((255 - $rgb[2]) * $amount)),
];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function relativeLuminance(array $rgb): float
{
$channels = [];
foreach ($rgb as $value) {
$v = $value / 255;
$channels[] = $v <= 0.03928 ? $v / 12.92 : (($v + 0.055) / 1.055) ** 2.4;
}
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Support\Qr;
use App\Models\QrCode;
/**
* QR Plus utility types only. vCard and commerce types live in other products.
*/
class QrTypeCatalog
{
/** @return list<string> */
public static function plusTypes(): array
{
return [
QrCode::TYPE_URL,
QrCode::TYPE_DOCUMENT,
QrCode::TYPE_LINK_LIST,
QrCode::TYPE_BUSINESS,
QrCode::TYPE_WIFI,
QrCode::TYPE_APP,
];
}
/** @return array<string, array{label: string, description: string, category: string, icon: string}> */
public static function all(): array
{
$catalog = [
QrCode::TYPE_URL => [
'label' => 'Link',
'description' => 'Redirect to any URL',
'category' => 'basic',
'icon' => 'website.svg',
],
QrCode::TYPE_DOCUMENT => [
'label' => 'PDF',
'description' => 'Hosted PDF reader',
'category' => 'basic',
'icon' => 'terms.svg',
],
QrCode::TYPE_LINK_LIST => [
'label' => 'List of Links',
'description' => 'Landing page with multiple links',
'category' => 'basic',
'icon' => 'list.svg',
],
QrCode::TYPE_BUSINESS => [
'label' => 'Business',
'description' => 'Business profile page',
'category' => 'contact',
'icon' => 'business-profile.svg',
],
QrCode::TYPE_WIFI => [
'label' => 'WiFi',
'description' => 'Network name and password',
'category' => 'contact',
'icon' => 'wifi.svg',
],
QrCode::TYPE_APP => [
'label' => 'App',
'description' => 'App Store and Play Store links',
'category' => 'business',
'icon' => 'app.svg',
],
];
return array_intersect_key($catalog, array_flip(self::plusTypes()));
}
/** @return list<string> */
public static function keys(): array
{
return array_keys(self::all());
}
public static function label(string $type): string
{
return self::all()[$type]['label'] ?? ucfirst(str_replace('_', ' ', $type));
}
public static function isValid(string $type): bool
{
return isset(self::all()[$type]);
}
public static function iconUrl(string $icon): string
{
$path = public_path('images/qr-icons/'.$icon);
$url = '/images/qr-icons/'.$icon;
if (is_file($path)) {
return $url.'?v='.filemtime($path);
}
return $url;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Support\Qr;
class QrWifiPayload
{
/**
* Build a WiFi QR payload (ZXing MECARD format) for one-tap network join on scan.
*
* @param array<string, mixed> $content
*/
public static function encode(array $content): string
{
$encryption = strtoupper(trim((string) ($content['encryption'] ?? 'WPA')));
$auth = $encryption === 'NOPASS' ? 'nopass' : $encryption;
if (! in_array($auth, ['WPA', 'WEP', 'nopass'], true)) {
$auth = 'WPA';
}
$ssid = self::escape(trim((string) ($content['ssid'] ?? '')));
$payload = 'WIFI:T:' . $auth . ';S:' . $ssid;
if ($auth !== 'nopass') {
$password = trim((string) ($content['password'] ?? ''));
if ($password !== '') {
$payload .= ';P:' . self::escape($password);
}
}
if (filter_var($content['hidden'] ?? false, FILTER_VALIDATE_BOOL)) {
$payload .= ';H:true';
}
return $payload . ';;';
}
private static function escape(string $value): string
{
return str_replace(
['\\', ';', ',', '"', ':'],
['\\\\', '\\;', '\\,', '\\"', '\\:'],
$value
);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Support;
use App\Models\DomainOrder;
use App\Models\RcServiceOrder;
use Illuminate\Support\Collection;
use RuntimeException;
/**
* ResellerClub is legacy-only for new sales. Renewals and management remain available
* for customers with existing ResellerClub services.
*/
final class ResellerClubLegacy
{
public static function integrationEnabled(): bool
{
return (bool) config('hosting.legacy.rc_enabled', true);
}
public static function newOrdersEnabled(): bool
{
return self::integrationEnabled()
&& (bool) config('hosting.legacy.rc_new_orders_enabled', false);
}
public static function renewalsEnabled(): bool
{
return self::integrationEnabled()
&& (bool) config('hosting.legacy.rc_renewals_enabled', true);
}
public static function isRenewalRcServiceOrder(RcServiceOrder $order): bool
{
if ($order->order_type === RcServiceOrder::TYPE_RENEWAL) {
return true;
}
return (bool) data_get($order->meta, 'is_renewal', false);
}
public static function isRenewalDomainOrder(DomainOrder $order): bool
{
return $order->order_type === DomainOrder::TYPE_RENEWAL;
}
public static function canAutomateFulfillment(RcServiceOrder $order): bool
{
if (! self::integrationEnabled()) {
return false;
}
if (self::newOrdersEnabled()) {
return true;
}
return self::renewalsEnabled() && self::isRenewalRcServiceOrder($order);
}
/**
* @param Collection<int, RcServiceOrder>|iterable<int, RcServiceOrder> $items
*/
public static function assertCartCheckoutAllowed(iterable $items): void
{
$items = $items instanceof Collection ? $items : collect($items);
if ($items->isEmpty()) {
throw new RuntimeException('Your cart is empty.');
}
if (self::newOrdersEnabled()) {
return;
}
if (! self::renewalsEnabled()) {
throw new RuntimeException(self::renewalsDisabledMessage());
}
if ($items->contains(fn (RcServiceOrder $item) => ! self::isRenewalRcServiceOrder($item))) {
throw new RuntimeException(
'ResellerClub checkout is limited to renewals for existing services. '
.'New product purchases use our current domain and hosting products instead.'
);
}
}
public static function assertNewSaleAllowed(): void
{
if (self::newOrdersEnabled()) {
return;
}
throw new RuntimeException(
'ResellerClub is no longer available for new product purchases. '
.'Use our domain and hosting products instead, or renew an existing ResellerClub service from your dashboard.'
);
}
public static function assertRenewalsAllowed(): void
{
if (! self::renewalsEnabled()) {
throw new RuntimeException(self::renewalsDisabledMessage());
}
}
/** @deprecated Use assertNewSaleAllowed() */
public static function assertNewOrdersAllowed(): void
{
self::assertNewSaleAllowed();
}
public static function renewalsDisabledMessage(): string
{
return 'ResellerClub renewals are not available at this time. Please contact support if you need assistance.';
}
public static function fulfillmentDisabledMessage(): string
{
return 'Automated ResellerClub fulfillment for new purchases is no longer available. '
.'Open a support ticket if you need help with a legacy order.';
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace App\Support;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Route;
class UserProfileMenu
{
/**
* Standard signed-in profile links for Ladill product apps.
* Not used by Ladill Mail (mail.ladill.com) that app keeps a mailbox-specific menu.
*
* @return list<array<string, mixed>>
*/
public static function items(?Authenticatable $user = null): array
{
$user ??= auth()->user();
if (! $user) {
return [];
}
$items = [];
foreach ([
['label' => 'Home', 'path' => '', 'host' => 'home'],
['label' => 'Profile', 'path' => 'profile'],
['label' => 'Account Settings', 'path' => 'account-settings'],
['label' => 'Dashboard', 'path' => 'dashboard'],
['label' => 'Billing', 'path' => 'billing'],
] as $link) {
$href = self::platformUrl($link['host'] ?? 'account', $link['path']);
if (self::isCurrentMenuLink($link, $href)) {
continue;
}
$items[] = [
'type' => 'link',
'label' => $link['label'],
'href' => $href,
];
}
if (Route::has((string) config("billing.wallet_balance_route", "wallet.balance"))) {
$items[] = ["type" => "wallet"];
}
if (self::userIsAdmin($user)) {
$adminHref = self::platformUrl('account', 'admin');
if (! self::isCurrentMenuLink(['path' => 'admin', 'host' => 'account'], $adminHref)) {
$items[] = [
'type' => 'link',
'label' => 'Admin',
'href' => $adminHref,
];
}
}
if (Route::has('logout')) {
$items[] = [
'type' => 'logout',
'label' => 'Logout',
'action' => route('logout'),
];
}
return $items;
}
private static function platformUrl(string $host, string $path = ''): string
{
if ($host === 'home') {
if (function_exists('ladill_home_url')) {
return ladill_home_url($path);
}
$domain = config('app.home_domain');
if (! $domain) {
$platform = (string) config('app.platform_domain', parse_url((string) config('app.url', ''), PHP_URL_HOST) ?: '');
$domain = $platform !== '' ? 'home.'.$platform : (string) config('app.account_domain');
}
return self::absoluteUrl((string) $domain, $path);
}
if (function_exists('ladill_account_url')) {
return ladill_account_url($path);
}
return self::absoluteUrl((string) config('app.account_domain'), $path);
}
/**
* Hide a menu link when the signed-in user is already on that destination.
*
* @param array{label?: string, path?: string, host?: string} $link
*/
private static function isCurrentMenuLink(array $link, string $href): bool
{
if (app()->runningInConsole() && ! app()->runningUnitTests()) {
return false;
}
$request = request();
if (! $request) {
return false;
}
$linkHost = strtolower((string) parse_url($href, PHP_URL_HOST));
$currentHost = strtolower($request->getHost());
if ($linkHost === '' || $linkHost !== $currentHost) {
return false;
}
$currentPath = trim($request->getPathInfo(), '/');
$targetPath = trim((string) parse_url($href, PHP_URL_PATH), '/');
if (($link['host'] ?? 'account') === 'home') {
return $currentPath === '';
}
if (($link['path'] ?? '') === 'dashboard') {
return in_array($currentPath, ['dashboard', 'account'], true)
|| ($targetPath !== '' && self::pathMatchesSection($currentPath, $targetPath));
}
return self::pathMatchesSection($currentPath, $targetPath);
}
private static function pathMatchesSection(string $currentPath, string $targetPath): bool
{
if ($targetPath === '') {
return $currentPath === '';
}
return $currentPath === $targetPath
|| str_starts_with($currentPath, $targetPath.'/');
}
private static function absoluteUrl(string $domain, string $path = ''): string
{
$base = 'https://'.trim($domain, '/');
if ($path === '') {
return $base;
}
return $base.'/'.ltrim($path, '/');
}
private static function userIsAdmin(Authenticatable $user): bool
{
if (method_exists($user, 'isAdmin')) {
return $user->isAdmin();
}
return (bool) ($user->is_admin ?? false);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
use App\Models\User;
if (! function_exists('ladill_account')) {
/**
* The account the current request acts within the owner User. Defaults to
* the authenticated user; a team member who switched accounts gets the owner.
* Set by the SetActingAccount middleware.
*/
function ladill_account(): ?User
{
$request = request();
return $request->attributes->get('actingAccount') ?? $request->user();
}
}
if (! function_exists('ladill_domains_url')) {
function ladill_domains_url(string $path = ''): string
{
return 'https://'.config('app.domains_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
}
}
if (! function_exists('ladill_account_url')) {
function ladill_account_url(string $path = ''): string
{
return 'https://'.config('app.account_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
}
}
if (! function_exists('ladill_servers_url')) {
function ladill_servers_url(string $path = ''): string
{
return 'https://'.config('app.servers_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
}
}