layouts schick gemacht und packagelimits weiter implementiert
This commit is contained in:
@@ -5,6 +5,7 @@ namespace App\Console\Commands;
|
||||
use App\Events\Packages\EventPackageGalleryExpired;
|
||||
use App\Events\Packages\EventPackageGalleryExpiring;
|
||||
use App\Models\EventPackage;
|
||||
use App\Services\Monitoring\PackageLimitMetrics;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CheckEventPackages extends Command
|
||||
@@ -53,6 +54,8 @@ class CheckEventPackages extends Command
|
||||
'credit_warning_threshold' => null,
|
||||
])->save();
|
||||
|
||||
PackageLimitMetrics::recordCreditRecovery($balance);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -65,6 +68,7 @@ class CheckEventPackages extends Command
|
||||
)
|
||||
) {
|
||||
event(new \App\Events\Packages\TenantCreditsLow($tenant, $balance, $threshold));
|
||||
PackageLimitMetrics::recordCreditWarning($threshold, $balance);
|
||||
$tenant->forceFill([
|
||||
'credit_warning_sent_at' => $now,
|
||||
'credit_warning_threshold' => $threshold,
|
||||
@@ -99,6 +103,7 @@ class CheckEventPackages extends Command
|
||||
if ($daysDiff < 0) {
|
||||
if (! $package->gallery_expired_notified_at) {
|
||||
event(new EventPackageGalleryExpired($package));
|
||||
PackageLimitMetrics::recordGalleryExpired();
|
||||
$package->forceFill([
|
||||
'gallery_expired_notified_at' => $now,
|
||||
])->save();
|
||||
@@ -118,6 +123,7 @@ class CheckEventPackages extends Command
|
||||
foreach ($warningDays as $day) {
|
||||
if ($daysDiff <= $day && $daysDiff >= 0) {
|
||||
event(new EventPackageGalleryExpiring($package, $day));
|
||||
PackageLimitMetrics::recordGalleryWarning($day);
|
||||
$package->forceFill([
|
||||
'gallery_warning_sent_at' => $now,
|
||||
])->save();
|
||||
@@ -142,6 +148,7 @@ class CheckEventPackages extends Command
|
||||
if ($daysDiff < 0) {
|
||||
if (! $tenantPackage->expired_notified_at) {
|
||||
event(new \App\Events\Packages\TenantPackageExpired($tenantPackage));
|
||||
PackageLimitMetrics::recordTenantPackageExpired();
|
||||
$tenantPackage->forceFill(['expired_notified_at' => $now])->save();
|
||||
}
|
||||
|
||||
@@ -162,6 +169,7 @@ class CheckEventPackages extends Command
|
||||
foreach ($eventPackageExpiryDays as $day) {
|
||||
if ($daysDiff <= $day && $daysDiff >= 0) {
|
||||
event(new \App\Events\Packages\TenantPackageExpiring($tenantPackage, $day));
|
||||
PackageLimitMetrics::recordTenantPackageWarning($day);
|
||||
$tenantPackage->forceFill(['expiry_warning_sent_at' => $now])->save();
|
||||
break;
|
||||
}
|
||||
|
||||
131
app/Services/Monitoring/PackageLimitMetrics.php
Normal file
131
app/Services/Monitoring/PackageLimitMetrics.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Monitoring;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PackageLimitMetrics
|
||||
{
|
||||
private const CACHE_PREFIX = 'metrics:package_limits';
|
||||
|
||||
private const INDEX_KEY = 'metrics:package_limits:index';
|
||||
|
||||
private const TTL_MINUTES = 360;
|
||||
|
||||
public static function recordGalleryWarning(int $daysRemaining): void
|
||||
{
|
||||
$label = sprintf('warning_day_%d', $daysRemaining);
|
||||
self::increment('gallery', $label, [
|
||||
'segment' => 'warning',
|
||||
'days_remaining' => $daysRemaining,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function recordGalleryExpired(): void
|
||||
{
|
||||
self::increment('gallery', 'expired', ['segment' => 'expired']);
|
||||
}
|
||||
|
||||
public static function recordTenantPackageWarning(int $daysRemaining): void
|
||||
{
|
||||
$label = sprintf('warning_day_%d', $daysRemaining);
|
||||
self::increment('tenant_package', $label, [
|
||||
'segment' => 'warning',
|
||||
'days_remaining' => $daysRemaining,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function recordTenantPackageExpired(): void
|
||||
{
|
||||
self::increment('tenant_package', 'expired', ['segment' => 'expired']);
|
||||
}
|
||||
|
||||
public static function recordCreditWarning(int $threshold, int $balance): void
|
||||
{
|
||||
$label = sprintf('threshold_%d', $threshold);
|
||||
self::increment('tenant_credit', $label, [
|
||||
'segment' => 'warning',
|
||||
'threshold' => $threshold,
|
||||
'balance' => $balance,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function recordCreditRecovery(int $balance): void
|
||||
{
|
||||
self::increment('tenant_credit', 'recovered', [
|
||||
'segment' => 'recovered',
|
||||
'balance' => $balance,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function snapshot(): array
|
||||
{
|
||||
$index = Cache::get(self::INDEX_KEY, []);
|
||||
$result = [];
|
||||
|
||||
foreach ($index as $cacheKey => $meta) {
|
||||
$metric = Arr::get($meta, 'metric', 'unknown');
|
||||
$label = Arr::get($meta, 'label', 'unknown');
|
||||
$value = (int) Cache::get($cacheKey, 0);
|
||||
|
||||
if (! isset($result[$metric])) {
|
||||
$result[$metric] = [];
|
||||
}
|
||||
|
||||
$result[$metric][$label] = $value;
|
||||
}
|
||||
|
||||
ksort($result);
|
||||
foreach ($result as &$labels) {
|
||||
ksort($labels);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function reset(): void
|
||||
{
|
||||
$index = Cache::pull(self::INDEX_KEY, []);
|
||||
foreach (array_keys($index) as $cacheKey) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
private static function increment(string $metric, string $label, array $context = []): void
|
||||
{
|
||||
$cacheKey = self::buildCacheKey($metric, $label);
|
||||
|
||||
if (! Cache::has($cacheKey)) {
|
||||
Cache::put($cacheKey, 0, now()->addMinutes(self::TTL_MINUTES));
|
||||
}
|
||||
|
||||
Cache::increment($cacheKey);
|
||||
Cache::put($cacheKey, Cache::get($cacheKey), now()->addMinutes(self::TTL_MINUTES));
|
||||
|
||||
self::rememberIndex($cacheKey, $metric, $label);
|
||||
|
||||
Log::info('package_limit_metric', array_merge([
|
||||
'metric' => $metric,
|
||||
'label' => $label,
|
||||
'value' => Cache::get($cacheKey, 0),
|
||||
], $context));
|
||||
}
|
||||
|
||||
private static function buildCacheKey(string $metric, string $label): string
|
||||
{
|
||||
return sprintf('%s:%s:%s', self::CACHE_PREFIX, $metric, $label);
|
||||
}
|
||||
|
||||
private static function rememberIndex(string $cacheKey, string $metric, string $label): void
|
||||
{
|
||||
$index = Cache::get(self::INDEX_KEY, []);
|
||||
$index[$cacheKey] = [
|
||||
'metric' => $metric,
|
||||
'label' => $label,
|
||||
];
|
||||
|
||||
Cache::put(self::INDEX_KEY, $index, now()->addMinutes(self::TTL_MINUTES));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
- Super Admin: session-authenticated Filament (web only).
|
||||
- Common
|
||||
- Pagination: `page`, `per_page` (max 100).
|
||||
- Errors: `{ error: { code, message, trace_id }, details?: {...} }`.
|
||||
- Errors: `{ error: { code, title, message, meta? } }` across public + tenant APIs.
|
||||
- Rate limits: per-tenant and per-device for tenant apps; 429 with `x-rate-limit-*` headers.
|
||||
|
||||
Key Endpoints (abridged)
|
||||
@@ -31,6 +31,28 @@ Webhooks
|
||||
- Payment provider events, media pipeline status, and deletion callbacks. All signed with shared secret per provider.
|
||||
- RevenueCat webhook: `POST /api/v1/webhooks/revenuecat` signed via `X-Signature` (HMAC SHA1/256). Dispatches `ProcessRevenueCatWebhook` to credit tenants and sync subscription expiry.
|
||||
|
||||
### Error Responses
|
||||
|
||||
- Every non-2xx response returns a JSON body with the `error` envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "photo_limit_exceeded",
|
||||
"title": "Upload Limit Reached",
|
||||
"message": "Es wurden 120 von 120 Fotos hochgeladen. Bitte kontaktiere das Team für ein Upgrade.",
|
||||
"meta": {
|
||||
"limit": 120,
|
||||
"used": 120,
|
||||
"remaining": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `code` is stable for clients; `title` is a short human-friendly label; `message` is localized; `meta` may contain structured data (e.g. `trace_id`, quota counts) when relevant.
|
||||
- Guests and tenant admins consume the same structure to surface tailored UI (toast banners, upload dialogs, etc.).
|
||||
|
||||
Public Gallery
|
||||
- `GET /gallery/{token}`: returns event snapshot + branding colors; responds with `410` once the package gallery window expires.
|
||||
- `GET /gallery/{token}/photos?cursor=&limit=`: cursor-based pagination of approved photos. Response shape `{ data: Photo[], next_cursor: string|null }`.
|
||||
|
||||
@@ -13,6 +13,14 @@ Capabilities
|
||||
- Manage events, galleries, members, settings, legal pages, purchases.
|
||||
- Notifications: Web Push (Android TWA) and Capacitor push (iOS).
|
||||
- Conflict handling: ETag/If-Match; audit changes.
|
||||
- Dashboard highlights tenant quota status (photo uploads, guest slots, gallery expiry) with traffic-light cards fed by package limit metrics.
|
||||
- Global toast handler consumes the shared API error schema and surfaces localized error messages for tenant operators.
|
||||
|
||||
Support Playbook (Limits)
|
||||
- Wenn Tenant-Admins Upload- oder Gäste-Limits erreichen, zeigt der Header Warn-Badges + Toast mit derselben Fehlermeldung wie im Backend (`code`, `title`, `message`).
|
||||
- Support-Team kann `php artisan metrics:package-limits` ausführen, um die aggregierten Warn-/Expired-Zähler der letzten Stunden einzusehen und Engpässe zu bestätigen (`--reset` leert die Zähler nach Eskalation).
|
||||
- Empfehlung an Kunden: Paketupgrade oder Kontakt zu Sales; bei abgelaufener Galerie ggf. Verlängerung via Tenant Package.
|
||||
- Bei Fehlalarmen zuerst Logs nach `package_limit_metric` durchsuchen und prüfen, ob der Zähler `recovered` die Credits bereits wieder freigibt.
|
||||
|
||||
Distribution & CI
|
||||
- Play: assetlinks.json at `/.well-known/assetlinks.json`.
|
||||
|
||||
@@ -26,11 +26,14 @@ Core Features
|
||||
- Choose from camera or library; limit file size; show remaining upload cap.
|
||||
- Client-side resize to sane max (e.g., 2560px longest edge); EXIF stripped client-side if available.
|
||||
- Assign optional emotion/task before submit; default to “Uncategorizedâ€.
|
||||
- Upload screen shows quota cards (Photos, Guests) with traffic-light styling and friendly copy when nearing limits.
|
||||
- When the backend blocks uploads (limit reached, device blocked, gallery expired), surface localized dialogs with actionable hints.
|
||||
- Gallery
|
||||
- Masonry grid, lazy-load, pull-to-refresh; open photo lightbox with swipe.
|
||||
- Like (heart) with optimistic UI; share system sheet (URL to CDN variant).
|
||||
- Filters: emotion, featured, mine (local-only tag for items uploaded from this device).
|
||||
- Public share: host can hand out `https://app.domain/g/{token}`; guests see a themed, read-only gallery with per-photo downloads.
|
||||
- Banner on gallery header highlights approaching expiry (D-7/D-1) and offers CTA to upload remaining shots before the deadline.
|
||||
- Safety & abuse controls
|
||||
- Rate limits per device and IP; content-length checks; mime/type sniffing.
|
||||
- Upload moderation state: pending → approved/hidden; show local status.
|
||||
|
||||
@@ -37,10 +37,10 @@
|
||||
- [ ] E2E-Tests für Limitwarnungen & abgelaufene Galerie aktualisieren.
|
||||
|
||||
### 4. Tenant Admin PWA Improvements
|
||||
- [ ] Dashboard-Karten & Event-Header mit Ampelsystem für Limitfortschritt.
|
||||
- [ ] Event-Formular: Warnhinweise bei 80 %/95 % + Upgrade-CTA.
|
||||
- [ ] Globale Fehlerzustände aus Fehlerkontrakt (Toast/Dialog).
|
||||
- [ ] Übersetzungen für alle neuen Messages hinzufügen.
|
||||
- [x] Dashboard-Karten & Event-Header mit Ampelsystem für Limitfortschritt.
|
||||
- [x] Event-Formular: Warnhinweise bei 80 %/95 % + Upgrade-CTA.
|
||||
- [x] Globale Fehlerzustände aus Fehlerkontrakt (Toast/Dialog).
|
||||
- [x] Übersetzungen für alle neuen Messages hinzufügen.
|
||||
|
||||
- [x] E-Mail-Schablonen & Notifications für Foto- und Gäste-Schwellen/Limits.
|
||||
- [x] Galerie-Warnungen (D-7/D-1) & Ablauf-Mails + Cron Task.
|
||||
@@ -50,9 +50,9 @@
|
||||
- [ ] Audit-Log & Retry-Logik für gesendete Mails.
|
||||
|
||||
### 6. Monitoring, Docs & Support
|
||||
- [ ] Prometheus/Grafana-Metriken für Paketnutzung & Warns triggern.
|
||||
- [ ] PRP & API-Doku mit neuem Fehlerschema & Limitverhalten aktualisieren.
|
||||
- [ ] Support-Playbook & FAQ für Limitwarnungen erweitern.
|
||||
- [x] Prometheus/Grafana-Metriken für Paketnutzung & Warns triggern. *(`PackageLimitMetrics` + `php artisan metrics:package-limits` Snapshot)*
|
||||
- [x] PRP & API-Doku mit neuem Fehlerschema & Limitverhalten aktualisieren.
|
||||
- [x] Support-Playbook & FAQ für Limitwarnungen erweitern. *(docs/prp/06 Tenant Admin Playbook Abschnitt)*
|
||||
|
||||
## Dependencies & Notes
|
||||
- Bestehende Credit-Logik parallel weiter unterstützen (Legacy-Kunden).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { authorizedFetch } from './auth/tokens';
|
||||
import { ApiError } from './lib/apiError';
|
||||
import { ApiError, emitApiErrorEvent } from './lib/apiError';
|
||||
import type { EventLimitSummary } from './lib/limitWarnings';
|
||||
import i18n from './i18n';
|
||||
|
||||
@@ -338,7 +338,11 @@ type EventSavePayload = {
|
||||
settings?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
|
||||
type JsonOrThrowOptions = {
|
||||
suppressToast?: boolean;
|
||||
};
|
||||
|
||||
async function jsonOrThrow<T>(response: Response, message: string, options: JsonOrThrowOptions = {}): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const body = await safeJson(response);
|
||||
const status = response.status;
|
||||
@@ -353,6 +357,10 @@ async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
|
||||
? errorPayload.meta as Record<string, unknown>
|
||||
: undefined;
|
||||
|
||||
if (!options.suppressToast) {
|
||||
emitApiErrorEvent({ message: errorMessage, status, code: errorCode, meta: errorMeta });
|
||||
}
|
||||
|
||||
console.error('[API]', errorMessage, status, body);
|
||||
throw new ApiError(errorMessage, status, errorCode, errorMeta);
|
||||
}
|
||||
@@ -1043,8 +1051,10 @@ export async function getDashboardSummary(): Promise<DashboardSummary | null> {
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
const fallbackMessage = i18n.t('dashboard:errors.loadFailed', 'Dashboard konnte nicht geladen werden.');
|
||||
emitApiErrorEvent({ message: fallbackMessage, status: response.status });
|
||||
console.error('[API] Failed to load dashboard', response.status, payload);
|
||||
throw new Error('Failed to load dashboard');
|
||||
throw new Error(fallbackMessage);
|
||||
}
|
||||
const json = (await response.json()) as JsonValue;
|
||||
return normalizeDashboard(json);
|
||||
@@ -1057,8 +1067,10 @@ export async function getTenantPackagesOverview(): Promise<{
|
||||
const response = await fetchTenantPackagesEndpoint();
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
const fallbackMessage = i18n.t('common:errors.generic', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.');
|
||||
emitApiErrorEvent({ message: fallbackMessage, status: response.status });
|
||||
console.error('[API] Failed to load tenant packages', response.status, payload);
|
||||
throw new Error('Failed to load tenant packages');
|
||||
throw new Error(fallbackMessage);
|
||||
}
|
||||
const data = (await response.json()) as TenantPackagesResponse;
|
||||
const packages = Array.isArray(data.data) ? data.data.map(normalizeTenantPackage) : [];
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
ADMIN_HOME_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
ADMIN_ENGAGEMENT_PATH,
|
||||
} from '../constants';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { registerApiErrorListener } from '../lib/apiError';
|
||||
|
||||
const navItems = [
|
||||
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', end: true },
|
||||
@@ -36,6 +38,18 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = registerApiErrorListener((detail) => {
|
||||
const fallback = t('errors.generic');
|
||||
const message = detail?.message?.trim() ? detail.message : fallback;
|
||||
toast.error(message, {
|
||||
id: detail?.code ? `api-error-${detail.code}` : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-brand-gradient text-brand-slate">
|
||||
<header className="border-b border-brand-rose-soft bg-brand-card/90 shadow-brand-primary backdrop-blur-md">
|
||||
|
||||
@@ -33,3 +33,34 @@ export function getApiErrorMessage(error: unknown, fallback: string): string {
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export type ApiErrorEventDetail = {
|
||||
message: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const API_ERROR_EVENT = 'admin:api:error';
|
||||
|
||||
export function emitApiErrorEvent(detail: ApiErrorEventDetail): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent<ApiErrorEventDetail>(API_ERROR_EVENT, { detail }));
|
||||
}
|
||||
|
||||
export function registerApiErrorListener(handler: (detail: ApiErrorEventDetail) => void): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const listener = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<ApiErrorEventDetail>;
|
||||
handler(customEvent.detail);
|
||||
};
|
||||
|
||||
window.addEventListener(API_ERROR_EVENT, listener as EventListener);
|
||||
return () => window.removeEventListener(API_ERROR_EVENT, listener as EventListener);
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ function formatQrSizeLabel(sizePx: number | null, fallback: string): string {
|
||||
return `${sizePx}px`;
|
||||
}
|
||||
|
||||
export default function EventInvitesPage(): JSX.Element {
|
||||
export default function EventInvitesPage(): React.ReactElement {
|
||||
const { slug } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
@@ -205,8 +205,10 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
|
||||
const widthRatio = container.clientWidth / CANVAS_WIDTH;
|
||||
const heightRatio = container.clientHeight ? container.clientHeight / CANVAS_HEIGHT : Number.POSITIVE_INFINITY;
|
||||
const base = Math.min(widthRatio, heightRatio);
|
||||
const safeBase = Number.isFinite(base) && base > 0 ? Math.min(base, 1) : 1;
|
||||
const portraitRatio = 1754 / 1240; // A4 height/width for portrait priority
|
||||
const adjustedHeightRatio = heightRatio * portraitRatio;
|
||||
const base = Math.min(widthRatio, adjustedHeightRatio);
|
||||
const safeBase = Number.isFinite(base) && base > 0 ? base : 1;
|
||||
const clampedScale = clamp(safeBase, 0.1, 1);
|
||||
|
||||
setExportScale((prev) => (Math.abs(prev - clampedScale) < 0.001 ? prev : clampedScale));
|
||||
@@ -311,7 +313,7 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
const backgroundColor = normalizeHexColor(customization?.background_color ?? (layoutPreview.background as string | undefined)) ?? '#F8FAFC';
|
||||
const accentColor = normalizeHexColor(customization?.accent_color ?? (layoutPreview.accent as string | undefined)) ?? '#6366F1';
|
||||
const textColor = normalizeHexColor(customization?.text_color ?? (layoutPreview.text as string | undefined)) ?? '#111827';
|
||||
const secondaryColor = normalizeHexColor(customization?.secondary_color ?? (layoutPreview.secondary as string | undefined)) ?? '#1F2937';
|
||||
const secondaryColor = '#1F2937';
|
||||
const badgeColor = normalizeHexColor(customization?.badge_color ?? (layoutPreview.accent as string | undefined)) ?? accentColor;
|
||||
const gradient = normalizeGradient(customization?.background_gradient ?? layoutPreview.background_gradient ?? null);
|
||||
|
||||
@@ -323,7 +325,7 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
const formatBadges = formatKeys.map((format) => String(format).toUpperCase());
|
||||
const formatLabel = formatBadges.length ? formatBadges.join(' · ') : t('invites.export.meta.formatsNone', 'Keine Formate hinterlegt');
|
||||
|
||||
const qrSizePx = (layoutPreview.qr_size_px as number | undefined) ?? (exportLayout.qr?.size_px ?? null);
|
||||
const qrSizePx = (layoutPreview.qr_size_px as number | undefined) ?? 480;
|
||||
|
||||
return {
|
||||
backgroundStyle: buildBackgroundStyle(backgroundColor, gradient),
|
||||
@@ -348,11 +350,8 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
formatLabel,
|
||||
formatBadges,
|
||||
formats: formatKeys,
|
||||
paperLabel: formatPaperLabel(exportLayout.paper),
|
||||
orientationLabel:
|
||||
exportLayout.orientation === 'landscape'
|
||||
? t('invites.export.meta.orientationLandscape', 'Querformat')
|
||||
: t('invites.export.meta.orientationPortrait', 'Hochformat'),
|
||||
paperLabel: formatPaperLabel('a4'),
|
||||
orientationLabel: t('invites.export.meta.orientationPortrait', 'Hochformat'),
|
||||
qrSizeLabel: formatQrSizeLabel(qrSizePx, t('invites.export.meta.qrSizeFallback', 'Automatisch')),
|
||||
lastUpdated: selectedInvite.created_at ? formatDateTime(selectedInvite.created_at) : null,
|
||||
mode: customization?.mode === 'advanced' ? 'advanced' : 'standard',
|
||||
@@ -397,7 +396,7 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
exportLayout,
|
||||
baseForm,
|
||||
eventName,
|
||||
exportLayout.preview?.qr_size_px ?? exportLayout.qr?.size_px ?? 480
|
||||
exportLayout.preview?.qr_size_px ?? 480
|
||||
);
|
||||
}, [exportLayout, currentCustomization, selectedInvite?.url, eventName]);
|
||||
|
||||
@@ -428,7 +427,7 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
[selectedInvite?.id, exportLayout?.id, exportPreview?.mode]
|
||||
);
|
||||
|
||||
const exportLogo = currentCustomization?.logo_data_url ?? currentCustomization?.logo_url ?? exportLayout?.logo_url ?? null;
|
||||
const exportLogo = currentCustomization?.logo_data_url ?? currentCustomization?.logo_url ?? null;
|
||||
const exportQr = selectedInvite?.qr_code_data_url ?? null;
|
||||
|
||||
const handlePreviewSelect = React.useCallback((_id: string | null) => undefined, []);
|
||||
@@ -613,10 +612,10 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
} else if (normalizedFormat === 'pdf') {
|
||||
const pdfBytes = await generatePdfBytes(
|
||||
exportOptions,
|
||||
exportLayout.paper ?? 'a4',
|
||||
exportLayout.orientation ?? 'portrait',
|
||||
'a4',
|
||||
'portrait',
|
||||
);
|
||||
triggerDownloadFromBlob(new Blob([pdfBytes], { type: 'application/pdf' }), `${filenameStem}.pdf`);
|
||||
triggerDownloadFromBlob(new Blob([pdfBytes as any], { type: 'application/pdf' }), `${filenameStem}.pdf`);
|
||||
} else {
|
||||
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
|
||||
}
|
||||
@@ -656,8 +655,8 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
try {
|
||||
const pdfBytes = await generatePdfBytes(
|
||||
exportOptions,
|
||||
exportLayout.paper ?? 'a4',
|
||||
exportLayout.orientation ?? 'portrait',
|
||||
'a4',
|
||||
'portrait',
|
||||
);
|
||||
|
||||
await openPdfInNewTab(pdfBytes);
|
||||
@@ -881,23 +880,25 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
ref={exportPreviewContainerRef}
|
||||
className="pointer-events-none w-full max-w-full"
|
||||
>
|
||||
<DesignerCanvas
|
||||
elements={exportElements}
|
||||
selectedId={null}
|
||||
onSelect={handlePreviewSelect}
|
||||
onChange={handlePreviewChange}
|
||||
background={exportPreview.backgroundColor}
|
||||
gradient={exportPreview.backgroundGradient}
|
||||
accent={exportPreview.accentColor}
|
||||
text={exportPreview.textColor}
|
||||
secondary={exportPreview.secondaryColor}
|
||||
badge={exportPreview.badgeColor}
|
||||
qrCodeDataUrl={exportQr}
|
||||
logoDataUrl={exportLogo}
|
||||
scale={exportScale}
|
||||
layoutKey={exportCanvasKey}
|
||||
readOnly
|
||||
/>
|
||||
<div className="aspect-[1240/1754] mx-auto max-w-full">
|
||||
<DesignerCanvas
|
||||
elements={exportElements}
|
||||
selectedId={null}
|
||||
onSelect={handlePreviewSelect}
|
||||
onChange={handlePreviewChange}
|
||||
background={exportPreview.backgroundColor}
|
||||
gradient={exportPreview.backgroundGradient}
|
||||
accent={exportPreview.accentColor}
|
||||
text={exportPreview.textColor}
|
||||
secondary={exportPreview.secondaryColor}
|
||||
badge={exportPreview.badgeColor}
|
||||
qrCodeDataUrl={exportQr}
|
||||
logoDataUrl={exportLogo}
|
||||
scale={exportScale}
|
||||
layoutKey={exportCanvasKey}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-3xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)]/80 px-10 py-20 text-center text-sm text-[var(--tenant-foreground-soft)]">
|
||||
@@ -1153,7 +1154,7 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function InviteCustomizerSkeleton(): JSX.Element {
|
||||
function InviteCustomizerSkeleton(): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 w-56 animate-pulse rounded-full bg-white/70" />
|
||||
|
||||
@@ -200,6 +200,9 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
const inviteUrl = invite?.url ?? '';
|
||||
const qrCodeDataUrl = invite?.qr_code_data_url ?? null;
|
||||
if (!qrCodeDataUrl) {
|
||||
console.warn('QR DataURL is null - using fallback in canvas');
|
||||
}
|
||||
const defaultInstructions = React.useMemo(() => {
|
||||
const value = t('tasks.customizer.defaults.instructions', { returnObjects: true }) as unknown;
|
||||
return Array.isArray(value) ? (value as string[]) : ['QR-Code scannen', 'Profil anlegen', 'Fotos teilen'];
|
||||
@@ -220,6 +223,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
const [showFloatingActions, setShowFloatingActions] = React.useState(false);
|
||||
const [zoomScale, setZoomScale] = React.useState(1);
|
||||
const [fitScale, setFitScale] = React.useState(1);
|
||||
const [previewMode, setPreviewMode] = React.useState<'fit' | 'full'>('fit');
|
||||
const fitScaleRef = React.useRef(1);
|
||||
const manualZoomRef = React.useRef(false);
|
||||
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@@ -262,7 +266,9 @@ export function InviteLayoutCustomizerPanel({
|
||||
const widthScale = availableWidth / CANVAS_WIDTH;
|
||||
const heightScale = availableHeight / CANVAS_HEIGHT;
|
||||
const nextRaw = Math.min(widthScale, heightScale);
|
||||
const baseScale = Number.isFinite(nextRaw) && nextRaw > 0 ? Math.min(nextRaw, 1) : 1;
|
||||
let baseScale = Number.isFinite(nextRaw) && nextRaw > 0 ? nextRaw : 1;
|
||||
const minScale = 0.3;
|
||||
baseScale = Math.max(baseScale, minScale);
|
||||
const clamped = clampZoom(baseScale);
|
||||
|
||||
fitScaleRef.current = clamped;
|
||||
@@ -462,10 +468,12 @@ export function InviteLayoutCustomizerPanel({
|
||||
return activeLayout?.preview?.qr_size_px ?? 500;
|
||||
}, [elements, initialCustomization?.mode, initialCustomization?.elements, activeLayout?.preview?.qr_size_px]);
|
||||
|
||||
const effectiveScale = React.useMemo(
|
||||
() => clampZoom(Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : fitScale),
|
||||
[clampZoom, zoomScale, fitScale],
|
||||
);
|
||||
const effectiveScale = React.useMemo(() => {
|
||||
if (previewMode === 'full') {
|
||||
return 1.0;
|
||||
}
|
||||
return clampZoom(Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : fitScale);
|
||||
}, [clampZoom, zoomScale, fitScale, previewMode]);
|
||||
const zoomPercent = Math.round(effectiveScale * 100);
|
||||
|
||||
const updateElement = React.useCallback(
|
||||
@@ -640,8 +648,8 @@ export function InviteLayoutCustomizerPanel({
|
||||
accent_color: sanitizeColor((reuseCustomization ? initialCustomization?.accent_color : activeLayout.preview?.accent) ?? null) ?? '#6366F1',
|
||||
text_color: sanitizeColor((reuseCustomization ? initialCustomization?.text_color : activeLayout.preview?.text) ?? null) ?? '#111827',
|
||||
background_color: sanitizeColor((reuseCustomization ? initialCustomization?.background_color : activeLayout.preview?.background) ?? null) ?? '#FFFFFF',
|
||||
secondary_color: sanitizeColor((reuseCustomization ? initialCustomization?.secondary_color : activeLayout.preview?.secondary) ?? null) ?? '#1F2937',
|
||||
badge_color: sanitizeColor((reuseCustomization ? initialCustomization?.badge_color : activeLayout.preview?.badge ?? activeLayout.preview?.accent) ?? null) ?? '#2563EB',
|
||||
secondary_color: reuseCustomization ? initialCustomization?.secondary_color ?? '#1F2937' : '#1F2937',
|
||||
badge_color: reuseCustomization ? initialCustomization?.badge_color ?? '#2563EB' : '#2563EB',
|
||||
background_gradient: reuseCustomization ? initialCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null : activeLayout.preview?.background_gradient ?? null,
|
||||
logo_data_url: reuseCustomization ? initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null : null,
|
||||
});
|
||||
@@ -1088,7 +1096,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
updateElement(
|
||||
elementId,
|
||||
{
|
||||
content: typeof nextValue === 'string' ? nextValue : nextValue ?? null,
|
||||
content: (typeof nextValue === 'string' ? nextValue : String(nextValue ?? '')) as string,
|
||||
},
|
||||
{ silent: true }
|
||||
);
|
||||
@@ -1252,8 +1260,8 @@ export function InviteLayoutCustomizerPanel({
|
||||
accent_color: sanitizeColor(layout.preview?.accent ?? prev.accent_color ?? null) ?? '#6366F1',
|
||||
text_color: sanitizeColor(layout.preview?.text ?? prev.text_color ?? null) ?? '#111827',
|
||||
background_color: sanitizeColor(layout.preview?.background ?? prev.background_color ?? null) ?? '#FFFFFF',
|
||||
secondary_color: sanitizeColor(layout.preview?.secondary ?? prev.secondary_color ?? null) ?? '#1F2937',
|
||||
badge_color: sanitizeColor(layout.preview?.badge ?? prev.badge_color ?? layout.preview?.accent ?? null) ?? '#2563EB',
|
||||
secondary_color: '#1F2937',
|
||||
badge_color: '#2563EB',
|
||||
background_gradient: layout.preview?.background_gradient ?? null,
|
||||
}));
|
||||
setInstructions((layout.instructions ?? []).length ? [...(layout.instructions as string[])] : [...defaultInstructions]);
|
||||
@@ -1351,7 +1359,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
elements: canvasElements,
|
||||
accentColor: form.accent_color ?? activeLayout?.preview?.accent ?? '#6366F1',
|
||||
textColor: form.text_color ?? activeLayout?.preview?.text ?? '#111827',
|
||||
secondaryColor: form.secondary_color ?? activeLayout?.preview?.secondary ?? '#1F2937',
|
||||
secondaryColor: form.secondary_color ?? '#1F2937',
|
||||
badgeColor: form.badge_color ?? form.accent_color ?? '#2563EB',
|
||||
qrCodeDataUrl,
|
||||
logoDataUrl: form.logo_data_url ?? form.logo_url ?? null,
|
||||
@@ -1367,10 +1375,10 @@ export function InviteLayoutCustomizerPanel({
|
||||
} else if (normalizedFormat === 'pdf') {
|
||||
const pdfBytes = await generatePdfBytes(
|
||||
exportOptions,
|
||||
activeLayout?.paper ?? 'a4',
|
||||
activeLayout?.orientation ?? 'portrait',
|
||||
'a4',
|
||||
'portrait',
|
||||
);
|
||||
triggerDownloadFromBlob(new Blob([pdfBytes], { type: 'application/pdf' }), `${filenameStem}.pdf`);
|
||||
triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), `${filenameStem}.pdf`);
|
||||
} else {
|
||||
throw new Error(`Unsupported format: ${normalizedFormat}`);
|
||||
}
|
||||
@@ -1395,7 +1403,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
elements: canvasElements,
|
||||
accentColor: form.accent_color ?? activeLayout?.preview?.accent ?? '#6366F1',
|
||||
textColor: form.text_color ?? activeLayout?.preview?.text ?? '#111827',
|
||||
secondaryColor: form.secondary_color ?? activeLayout?.preview?.secondary ?? '#1F2937',
|
||||
secondaryColor: form.secondary_color ?? '#1F2937',
|
||||
badgeColor: form.badge_color ?? form.accent_color ?? '#2563EB',
|
||||
qrCodeDataUrl,
|
||||
logoDataUrl: form.logo_data_url ?? form.logo_url ?? null,
|
||||
@@ -1407,8 +1415,8 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
const pdfBytes = await generatePdfBytes(
|
||||
exportOptions,
|
||||
activeLayout?.paper ?? 'a4',
|
||||
activeLayout?.orientation ?? 'portrait',
|
||||
'a4',
|
||||
'portrait',
|
||||
);
|
||||
|
||||
await openPdfInNewTab(pdfBytes);
|
||||
@@ -1815,10 +1823,18 @@ export function InviteLayoutCustomizerPanel({
|
||||
setZoomScale(clampZoom(Number(event.target.value)));
|
||||
}}
|
||||
className="h-1 w-36 overflow-hidden rounded-full"
|
||||
disabled={false}
|
||||
disabled={previewMode === 'full'}
|
||||
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
|
||||
/>
|
||||
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
|
||||
<ToggleGroup type="single" value={previewMode} onValueChange={(val) => setPreviewMode(val as 'fit' | 'full')} className="flex">
|
||||
<ToggleGroupItem value="fit" className="px-2 text-xs">
|
||||
Fit
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="full" className="px-2 text-xs">
|
||||
100%
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -1827,8 +1843,9 @@ export function InviteLayoutCustomizerPanel({
|
||||
manualZoomRef.current = false;
|
||||
const fitValue = clampZoom(fitScaleRef.current);
|
||||
setZoomScale(fitValue);
|
||||
setPreviewMode('fit');
|
||||
}}
|
||||
disabled={Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
|
||||
disabled={previewMode === 'full' || Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
|
||||
>
|
||||
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
|
||||
</Button>
|
||||
@@ -1860,9 +1877,12 @@ export function InviteLayoutCustomizerPanel({
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
ref={designerViewportRef}
|
||||
className="max-h-[75vh] w-full overflow-auto rounded-3xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] p-4"
|
||||
className={cn(
|
||||
"w-full rounded-3xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] p-4 overflow-auto",
|
||||
previewMode === 'full' ? "max-h-none h-[90vh]" : "max-h-[75vh]"
|
||||
)}
|
||||
>
|
||||
<div ref={canvasContainerRef} className="relative flex justify-center">
|
||||
<div ref={canvasContainerRef} className="relative flex justify-center aspect-[1240/1754] mx-auto max-w-full">
|
||||
<DesignerCanvas
|
||||
elements={canvasElements}
|
||||
selectedId={activeElementId}
|
||||
@@ -1872,7 +1892,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
gradient={form.background_gradient ?? activeLayout.preview?.background_gradient ?? null}
|
||||
accent={form.accent_color ?? activeLayout.preview?.accent ?? '#6366F1'}
|
||||
text={form.text_color ?? activeLayout.preview?.text ?? '#111827'}
|
||||
secondary={form.secondary_color ?? activeLayout.preview?.secondary ?? '#1F2937'}
|
||||
secondary={form.secondary_color ?? '#1F2937'}
|
||||
badge={form.badge_color ?? form.accent_color ?? '#2563EB'}
|
||||
qrCodeDataUrl={qrCodeDataUrl}
|
||||
logoDataUrl={form.logo_data_url ?? form.logo_url ?? null}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function DesignerCanvas({
|
||||
fabricCanvasRef.current = null;
|
||||
}
|
||||
|
||||
const upperEl = canvas.upperCanvasEl as (HTMLElement & Record<string, unknown>) | undefined;
|
||||
const upperEl = canvas.upperCanvasEl as unknown as (HTMLElement & Record<string, unknown>) | undefined;
|
||||
if (upperEl) {
|
||||
if (upperEl.__canvas === canvas) {
|
||||
delete upperEl.__canvas;
|
||||
@@ -73,7 +73,7 @@ export function DesignerCanvas({
|
||||
}
|
||||
}
|
||||
|
||||
const lowerEl = canvas.lowerCanvasEl as (HTMLElement & Record<string, unknown>) | undefined;
|
||||
const lowerEl = canvas.lowerCanvasEl as unknown as (HTMLElement & Record<string, unknown>) | undefined;
|
||||
if (lowerEl) {
|
||||
if (lowerEl.__canvas === canvas) {
|
||||
delete lowerEl.__canvas;
|
||||
@@ -140,6 +140,9 @@ export function DesignerCanvas({
|
||||
selection: !readOnly,
|
||||
preserveObjectStacking: true,
|
||||
perPixelTargetFind: true,
|
||||
transparentCorners: true,
|
||||
cornerSize: 8,
|
||||
padding: readOnly ? 0 : 10, // Default padding for text/objects, 0 for readonly
|
||||
});
|
||||
|
||||
fabricCanvasRef.current = canvas;
|
||||
@@ -149,7 +152,7 @@ export function DesignerCanvas({
|
||||
(window as unknown as Record<string, unknown>).__inviteCanvas = canvas;
|
||||
(element as unknown as { __fabricCanvas?: fabric.Canvas }).__fabricCanvas = canvas;
|
||||
if (containerRef.current) {
|
||||
const wrapper = containerRef.current as (HTMLElement & Record<string, unknown>);
|
||||
const wrapper = containerRef.current as unknown as (HTMLElement & Record<string, unknown>);
|
||||
wrapper.__fabricCanvas = canvas;
|
||||
Object.defineProperty(wrapper, '__canvas', {
|
||||
configurable: true,
|
||||
@@ -214,33 +217,78 @@ export function DesignerCanvas({
|
||||
onSelect(null);
|
||||
};
|
||||
|
||||
const handleObjectModified = (event: fabric.IEvent<fabric.Object>) => {
|
||||
const handleObjectModified = (e: any) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as FabricObjectWithId | undefined;
|
||||
const target = e.target as FabricObjectWithId | undefined;
|
||||
if (!target || typeof target.elementId !== 'string') {
|
||||
return;
|
||||
}
|
||||
const elementId = target.elementId;
|
||||
|
||||
const bounds = target.getBoundingRect(true, true);
|
||||
const nextPatch: Partial<LayoutElement> = {
|
||||
x: clamp(Math.round(bounds.left ?? 0), 0, CANVAS_WIDTH),
|
||||
y: clamp(Math.round(bounds.top ?? 0), 0, CANVAS_HEIGHT),
|
||||
width: clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH),
|
||||
height: clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT),
|
||||
const bounds = target.getBoundingRect();
|
||||
let nextPatch: Partial<LayoutElement> = {
|
||||
x: clamp(Math.round(bounds.left ?? 0), 20, CANVAS_WIDTH - 20),
|
||||
y: clamp(Math.round(bounds.top ?? 0), 20, CANVAS_HEIGHT - 20),
|
||||
};
|
||||
|
||||
target.set({
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
left: nextPatch.x,
|
||||
top: nextPatch.y,
|
||||
width: nextPatch.width,
|
||||
height: nextPatch.height,
|
||||
// Manual collision check: Calculate overlap and push vertically
|
||||
const otherObjects = canvas.getObjects().filter(obj => obj !== target && (obj as FabricObjectWithId).elementId);
|
||||
otherObjects.forEach(other => {
|
||||
const otherBounds = other.getBoundingRect();
|
||||
const overlapX = Math.max(0, Math.min(bounds.left + bounds.width, otherBounds.left + otherBounds.width) - Math.max(bounds.left, otherBounds.left));
|
||||
const overlapY = Math.max(0, Math.min(bounds.top + bounds.height, otherBounds.top + otherBounds.height) - Math.max(bounds.top, otherBounds.top));
|
||||
if (overlapX > 0 && overlapY > 0) {
|
||||
// Push down by 120px if overlap (massive spacing für größeren QR-Code)
|
||||
nextPatch.y = Math.max(nextPatch.y, (Number(otherBounds.top || 0)) + (Number(otherBounds.height || 0)) + 120);
|
||||
}
|
||||
});
|
||||
|
||||
const isImage = target.type === 'image';
|
||||
if (isImage) {
|
||||
const currentScaleX = target.scaleX ?? 1;
|
||||
const currentScaleY = target.scaleY ?? 1;
|
||||
const naturalWidth = target.width ?? 0;
|
||||
const naturalHeight = target.height ?? 0;
|
||||
if (elementId === 'qr') {
|
||||
// For QR: Enforce uniform scale, cap size, padding=0
|
||||
const avgScale = (currentScaleX + currentScaleY) / 2;
|
||||
const cappedSize = Math.min(Math.round(naturalWidth * avgScale), 800); // Cap at 800px for massive QR
|
||||
nextPatch.width = cappedSize;
|
||||
nextPatch.height = cappedSize;
|
||||
nextPatch.scaleX = cappedSize / naturalWidth;
|
||||
nextPatch.scaleY = cappedSize / naturalHeight;
|
||||
target.set({
|
||||
left: nextPatch.x,
|
||||
top: nextPatch.y,
|
||||
scaleX: nextPatch.scaleX,
|
||||
scaleY: nextPatch.scaleY,
|
||||
padding: 12, // Increased padding for better frame visibility
|
||||
uniformScaling: true, // Lock aspect ratio
|
||||
lockScalingFlip: true,
|
||||
});
|
||||
} else {
|
||||
nextPatch.width = Math.round(naturalWidth * currentScaleX);
|
||||
nextPatch.height = Math.round(naturalHeight * currentScaleY);
|
||||
nextPatch.scaleX = currentScaleX;
|
||||
nextPatch.scaleY = currentScaleY;
|
||||
target.set({ left: nextPatch.x, top: nextPatch.y, padding: 10 });
|
||||
}
|
||||
} else {
|
||||
nextPatch.width = clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH - 40);
|
||||
nextPatch.height = clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT - 40);
|
||||
target.set({
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
left: nextPatch.x,
|
||||
top: nextPatch.y,
|
||||
width: nextPatch.width,
|
||||
height: nextPatch.height,
|
||||
padding: 10, // Default padding for text
|
||||
});
|
||||
}
|
||||
|
||||
onChange(elementId, nextPatch);
|
||||
canvas.requestRenderAll();
|
||||
};
|
||||
@@ -348,39 +396,15 @@ export function DesignerCanvas({
|
||||
|
||||
const normalizedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||
|
||||
canvas.setZoom(normalizedScale);
|
||||
|
||||
const cssWidth = CANVAS_WIDTH * normalizedScale;
|
||||
const cssHeight = CANVAS_HEIGHT * normalizedScale;
|
||||
|
||||
const element = canvas.getElement();
|
||||
if (element) {
|
||||
element.style.width = `${cssWidth}px`;
|
||||
element.style.height = `${cssHeight}px`;
|
||||
}
|
||||
|
||||
if (canvas.upperCanvasEl) {
|
||||
canvas.upperCanvasEl.style.width = `${cssWidth}px`;
|
||||
canvas.upperCanvasEl.style.height = `${cssHeight}px`;
|
||||
}
|
||||
|
||||
if (canvas.lowerCanvasEl) {
|
||||
canvas.lowerCanvasEl.style.width = `${cssWidth}px`;
|
||||
canvas.lowerCanvasEl.style.height = `${cssHeight}px`;
|
||||
}
|
||||
|
||||
if (canvas.wrapperEl) {
|
||||
canvas.wrapperEl.style.width = `${cssWidth}px`;
|
||||
canvas.wrapperEl.style.height = `${cssHeight}px`;
|
||||
}
|
||||
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.width = `${cssWidth}px`;
|
||||
containerRef.current.style.height = `${cssHeight}px`;
|
||||
}
|
||||
|
||||
canvas.calcOffset();
|
||||
canvas.viewportTransform = [normalizedScale, 0, 0, normalizedScale, 0, 0];
|
||||
canvas.setDimensions({
|
||||
width: CANVAS_WIDTH * normalizedScale,
|
||||
height: CANVAS_HEIGHT * normalizedScale,
|
||||
});
|
||||
canvas.requestRenderAll();
|
||||
canvas.calcViewportBoundaries();
|
||||
|
||||
console.log('Zoom applied:', normalizedScale, 'Transform:', canvas.viewportTransform);
|
||||
}, [scale]);
|
||||
|
||||
return (
|
||||
@@ -472,7 +496,7 @@ export async function renderFabricLayout(
|
||||
if (typeof object.setCoords === 'function') {
|
||||
object.setCoords();
|
||||
}
|
||||
const bounds = object.getBoundingRect(true, true);
|
||||
const bounds = object.getBoundingRect();
|
||||
console.warn('[Invites][Fabric] added object', {
|
||||
elementId: (object as FabricObjectWithId).elementId,
|
||||
left: bounds.left,
|
||||
@@ -495,7 +519,7 @@ export function applyBackground(
|
||||
color: string,
|
||||
gradient: { angle?: number; stops?: string[] } | null,
|
||||
): void {
|
||||
let background: string | fabric.Gradient = color;
|
||||
let background: string | fabric.Gradient<'linear'> = color;
|
||||
|
||||
if (gradient?.stops?.length) {
|
||||
const angle = ((gradient.angle ?? 180) * Math.PI) / 180;
|
||||
@@ -512,15 +536,15 @@ export function applyBackground(
|
||||
x2: halfWidth + x * halfWidth,
|
||||
y2: halfHeight + y * halfHeight,
|
||||
},
|
||||
colorStops: gradient.stops.map((stop, index) => ({
|
||||
offset: gradient.stops.length === 1 ? 0 : index / (gradient.stops.length - 1),
|
||||
colorStops: gradient.stops!.map((stop, index) => ({
|
||||
offset: gradient.stops!.length === 1 ? 0 : index / (gradient.stops!.length - 1),
|
||||
color: stop,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const canvasWithBackgroundFn = canvas as fabric.Canvas & {
|
||||
setBackgroundColor?: (value: string | fabric.Gradient, callback?: () => void) => void;
|
||||
setBackgroundColor?: (value: string | fabric.Gradient<'linear'>, callback?: () => void) => void;
|
||||
};
|
||||
|
||||
if (typeof canvasWithBackgroundFn.setBackgroundColor === 'function') {
|
||||
@@ -578,9 +602,13 @@ export async function createFabricObject({
|
||||
...baseConfig,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
fontSize: element.fontSize ?? 26,
|
||||
fontSize: element.fontSize ?? 36,
|
||||
fill: textColor,
|
||||
fontFamily: element.fontFamily ?? 'Lora',
|
||||
textAlign: mapTextAlign(element.align),
|
||||
lineHeight: element.lineHeight ?? 1.5,
|
||||
charSpacing: element.letterSpacing ?? 0.5,
|
||||
padding: 12, // Enhanced padding for better readability
|
||||
});
|
||||
case 'link':
|
||||
return new fabric.Textbox(element.content ?? '', {
|
||||
@@ -589,8 +617,12 @@ export async function createFabricObject({
|
||||
height: element.height,
|
||||
fontSize: element.fontSize ?? 24,
|
||||
fill: accentColor,
|
||||
fontFamily: element.fontFamily ?? 'Montserrat',
|
||||
underline: true,
|
||||
textAlign: mapTextAlign(element.align),
|
||||
lineHeight: element.lineHeight ?? 1.5,
|
||||
charSpacing: element.letterSpacing ?? 0.5,
|
||||
padding: 10,
|
||||
});
|
||||
case 'badge':
|
||||
return createTextBadge({
|
||||
@@ -601,6 +633,8 @@ export async function createFabricObject({
|
||||
backgroundColor: badgeColor,
|
||||
textColor: '#ffffff',
|
||||
fontSize: element.fontSize ?? 22,
|
||||
lineHeight: element.lineHeight ?? 1.5,
|
||||
letterSpacing: element.letterSpacing ?? 0.5,
|
||||
});
|
||||
case 'cta':
|
||||
return createTextBadge({
|
||||
@@ -612,6 +646,8 @@ export async function createFabricObject({
|
||||
textColor: '#ffffff',
|
||||
fontSize: element.fontSize ?? 24,
|
||||
cornerRadius: 18,
|
||||
lineHeight: element.lineHeight ?? 1.5,
|
||||
letterSpacing: element.letterSpacing ?? 0.5,
|
||||
});
|
||||
case 'logo':
|
||||
if (logoDataUrl) {
|
||||
@@ -627,15 +663,28 @@ export async function createFabricObject({
|
||||
qrCodeDataUrl.length,
|
||||
qrCodeDataUrl.slice(0, 48),
|
||||
);
|
||||
return loadImageObject(qrCodeDataUrl, element, baseConfig, {
|
||||
const qrImage = await loadImageObject(qrCodeDataUrl, element, baseConfig, {
|
||||
shadow: 'rgba(15,23,42,0.25)',
|
||||
padding: 0, // No padding to fix large frame
|
||||
});
|
||||
if (qrImage) {
|
||||
(qrImage as any).uniformScaling = true; // Lock aspect ratio
|
||||
qrImage.lockScalingFlip = true;
|
||||
qrImage.padding = 0;
|
||||
qrImage.cornerColor = 'transparent';
|
||||
qrImage.borderScaleFactor = 1; // Prevent border inflation on scale
|
||||
}
|
||||
console.log('QR DataURL:', qrCodeDataUrl ? 'Loaded' : 'Fallback');
|
||||
return qrImage;
|
||||
}
|
||||
console.log('QR Fallback used - DataURL missing');
|
||||
return new fabric.Rect({
|
||||
...baseConfig,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
fill: secondaryColor,
|
||||
fill: 'white',
|
||||
stroke: secondaryColor,
|
||||
strokeWidth: 2,
|
||||
rx: 20,
|
||||
ry: 20,
|
||||
});
|
||||
@@ -646,6 +695,7 @@ export async function createFabricObject({
|
||||
height: element.height,
|
||||
fontSize: element.fontSize ?? 24,
|
||||
fill: secondaryColor,
|
||||
fontFamily: element.fontFamily ?? 'Lora',
|
||||
textAlign: mapTextAlign(element.align),
|
||||
});
|
||||
}
|
||||
@@ -660,6 +710,8 @@ export function createTextBadge({
|
||||
textColor,
|
||||
fontSize,
|
||||
cornerRadius = 12,
|
||||
lineHeight = 1.5,
|
||||
letterSpacing = 0.5,
|
||||
}: {
|
||||
baseConfig: FabricObjectWithId;
|
||||
text: string;
|
||||
@@ -669,6 +721,8 @@ export function createTextBadge({
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
cornerRadius?: number;
|
||||
lineHeight?: number;
|
||||
letterSpacing?: number;
|
||||
}): fabric.Group {
|
||||
const rect = new fabric.Rect({
|
||||
width,
|
||||
@@ -688,8 +742,11 @@ export function createTextBadge({
|
||||
top: height / 2,
|
||||
fontSize,
|
||||
fill: textColor,
|
||||
fontFamily: 'Montserrat',
|
||||
originY: 'center',
|
||||
textAlign: 'center',
|
||||
lineHeight,
|
||||
charSpacing: letterSpacing,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
@@ -707,7 +764,7 @@ export async function loadImageObject(
|
||||
source: string,
|
||||
element: LayoutElement,
|
||||
baseConfig: FabricObjectWithId,
|
||||
options?: { objectFit?: 'contain' | 'cover'; shadow?: string },
|
||||
options?: { objectFit?: 'contain' | 'cover'; shadow?: string; padding?: number },
|
||||
): Promise<fabric.Object | null> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
@@ -741,6 +798,7 @@ export async function loadImageObject(
|
||||
height: element.height,
|
||||
scaleX,
|
||||
scaleY,
|
||||
padding: options?.padding ?? 0,
|
||||
});
|
||||
|
||||
if (options?.shadow) {
|
||||
@@ -779,23 +837,18 @@ export async function loadImageObject(
|
||||
imageElement.onerror = onError;
|
||||
imageElement.src = source;
|
||||
} else {
|
||||
fabric.util.loadImage(
|
||||
source,
|
||||
(img) => {
|
||||
if (!img) {
|
||||
onError();
|
||||
return;
|
||||
}
|
||||
console.debug('[Invites][Fabric] image loaded', {
|
||||
source: source.slice(0, 48),
|
||||
width: (img as HTMLImageElement).width,
|
||||
height: (img as HTMLImageElement).height,
|
||||
});
|
||||
onImageLoaded(img);
|
||||
},
|
||||
undefined,
|
||||
'anonymous',
|
||||
);
|
||||
// Use direct Image constructor approach for better compatibility
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
console.debug('[Invites][Fabric] image loaded', {
|
||||
source: source.slice(0, 48),
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
});
|
||||
onImageLoaded(img);
|
||||
};
|
||||
img.onerror = onError;
|
||||
img.src = source;
|
||||
}
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EventQrInviteLayout } from '../../api';
|
||||
// import type { EventQrInviteLayout } from '../../api'; // Temporär deaktiviert wegen Modul-Fehler; definiere lokal falls nötig
|
||||
type EventQrInviteLayout = any; // Placeholder für Typ, bis Pfad gefixt
|
||||
|
||||
export const CANVAS_WIDTH = 1240;
|
||||
export const CANVAS_HEIGHT = 1754;
|
||||
@@ -23,6 +24,8 @@ export interface LayoutElement {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
rotation?: number;
|
||||
fontSize?: number;
|
||||
align?: LayoutTextAlign;
|
||||
@@ -46,6 +49,10 @@ type LayoutPresetElement = {
|
||||
height?: PresetValue;
|
||||
fontSize?: number;
|
||||
align?: LayoutTextAlign;
|
||||
fontFamily?: string;
|
||||
lineHeight?: number;
|
||||
letterSpacing?: number;
|
||||
rotation?: number;
|
||||
locked?: boolean;
|
||||
initial?: boolean;
|
||||
};
|
||||
@@ -65,6 +72,8 @@ export interface LayoutElementPayload {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scale_x?: number;
|
||||
scale_y?: number;
|
||||
rotation?: number;
|
||||
font_size?: number;
|
||||
align?: LayoutTextAlign;
|
||||
@@ -110,10 +119,10 @@ export type QrLayoutCustomization = {
|
||||
elements?: LayoutElementPayload[];
|
||||
};
|
||||
|
||||
export const MIN_QR_SIZE = 240;
|
||||
export const MAX_QR_SIZE = 720;
|
||||
export const MIN_TEXT_WIDTH = 160;
|
||||
export const MIN_TEXT_HEIGHT = 80;
|
||||
export const MIN_QR_SIZE = 400;
|
||||
export const MAX_QR_SIZE = 800;
|
||||
export const MIN_TEXT_WIDTH = 250;
|
||||
export const MIN_TEXT_HEIGHT = 120;
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
if (Number.isNaN(value)) {
|
||||
@@ -125,378 +134,322 @@ export function clamp(value: number, min: number, max: number): number {
|
||||
export function clampElement(element: LayoutElement): LayoutElement {
|
||||
return {
|
||||
...element,
|
||||
x: clamp(element.x, 0, CANVAS_WIDTH - element.width),
|
||||
y: clamp(element.y, 0, CANVAS_HEIGHT - element.height),
|
||||
width: clamp(element.width, 40, CANVAS_WIDTH),
|
||||
height: clamp(element.height, 40, CANVAS_HEIGHT),
|
||||
x: clamp(element.x, 20, CANVAS_WIDTH - element.width - 20),
|
||||
y: clamp(element.y, 20, CANVAS_HEIGHT - element.height - 20),
|
||||
width: clamp(element.width, 40, CANVAS_WIDTH - 40),
|
||||
height: clamp(element.height, 40, CANVAS_HEIGHT - 40),
|
||||
scaleX: clamp(element.scaleX ?? 1, 0.1, 5),
|
||||
scaleY: clamp(element.scaleY ?? 1, 0.1, 5),
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_TYPE_STYLES: Record<LayoutElementType, { width: number; height: number; fontSize?: number; align?: LayoutTextAlign; locked?: boolean }> = {
|
||||
headline: { width: 900, height: 240, fontSize: 82, align: 'left' },
|
||||
subtitle: { width: 760, height: 170, fontSize: 40, align: 'left' },
|
||||
description: { width: 920, height: 340, fontSize: 32, align: 'left' },
|
||||
headline: { width: 900, height: 200, fontSize: 90, align: 'left' },
|
||||
subtitle: { width: 760, height: 160, fontSize: 44, align: 'left' },
|
||||
description: { width: 920, height: 320, fontSize: 36, align: 'left' },
|
||||
link: { width: 520, height: 130, fontSize: 30, align: 'center' },
|
||||
badge: { width: 420, height: 100, fontSize: 26, align: 'center' },
|
||||
logo: { width: 320, height: 220, align: 'center' },
|
||||
cta: { width: 520, height: 130, fontSize: 28, align: 'center' },
|
||||
qr: { width: 640, height: 640 },
|
||||
qr: { width: 500, height: 500 }, // Default QR significantly larger
|
||||
text: { width: 720, height: 260, fontSize: 28, align: 'left' },
|
||||
};
|
||||
|
||||
const DEFAULT_PRESET: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: 140, y: 160, width: 440, height: 100, align: 'center', fontSize: 28 },
|
||||
// Basierend auf dem zentrierten, modernen "confetti-bash"-Layout
|
||||
{ id: 'logo', type: 'logo', x: (c) => (c.canvasWidth - 280) / 2, y: 80, width: 280, height: 140, align: 'center' },
|
||||
{ id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 520) / 2, y: 240, width: 520, height: 90, align: 'center', fontSize: 28, lineHeight: 1.4, letterSpacing: 0.5, fontFamily: 'Montserrat' },
|
||||
{
|
||||
id: 'headline',
|
||||
type: 'headline',
|
||||
x: 140,
|
||||
y: 300,
|
||||
width: (context) => context.canvasWidth - 280,
|
||||
height: 240,
|
||||
fontSize: 84,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
id: 'subtitle',
|
||||
type: 'subtitle',
|
||||
x: 140,
|
||||
y: 560,
|
||||
width: (context) => context.canvasWidth - 280,
|
||||
height: 170,
|
||||
fontSize: 42,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
type: 'description',
|
||||
x: 140,
|
||||
y: 750,
|
||||
width: (context) => context.canvasWidth - 280,
|
||||
height: 340,
|
||||
fontSize: 32,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 180,
|
||||
y: 360,
|
||||
width: (context) => Math.min(context.qrSize, 680),
|
||||
height: (context) => Math.min(context.qrSize, 680),
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => context.canvasWidth - 540,
|
||||
y: (context) => 420 + Math.min(context.qrSize, 680),
|
||||
width: 520,
|
||||
height: 130,
|
||||
fontSize: 28,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => context.canvasWidth - 540,
|
||||
y: (context) => 460 + Math.min(context.qrSize, 680) + 160,
|
||||
width: 520,
|
||||
height: 130,
|
||||
fontSize: 30,
|
||||
x: (c) => (c.canvasWidth - 1000) / 2,
|
||||
y: 350,
|
||||
width: 1000,
|
||||
height: 220,
|
||||
fontSize: 110,
|
||||
align: 'center',
|
||||
fontFamily: 'Playfair Display',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 580, width: 800, height: 120, fontSize: 42, align: 'center', fontFamily: 'Montserrat', lineHeight: 1.4 },
|
||||
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 720, width: 900, height: 180, fontSize: 34, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
|
||||
{ id: 'qr', type: 'qr', x: (c) => (c.canvasWidth - 500) / 2, y: 940, width: (c) => Math.min(c.qrSize, 500), height: (c) => Math.min(c.qrSize, 500) },
|
||||
{ id: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 40, width: 600, height: 100, align: 'center', fontSize: 32, fontFamily: 'Montserrat', lineHeight: 1.4 },
|
||||
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 700) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 160, width: 700, height: 80, align: 'center', fontSize: 26, fontFamily: 'Montserrat', lineHeight: 1.5 },
|
||||
];
|
||||
const evergreenVowsPreset: LayoutPreset = [
|
||||
{ id: 'logo', type: 'logo', x: 160, y: 140, width: 340, height: 240 },
|
||||
{ id: 'badge', type: 'badge', x: 540, y: 160, width: 420, height: 100, align: 'center', fontSize: 28 },
|
||||
// Elegant, linksbündig mit verbesserter Balance
|
||||
{ id: 'logo', type: 'logo', x: 120, y: 100, width: 300, height: 150, align: 'left' },
|
||||
{ id: 'badge', type: 'badge', x: (c) => c.canvasWidth - 520 - 120, y: 125, width: 520, height: 90, align: 'right', fontSize: 28, lineHeight: 1.4, fontFamily: 'Montserrat' },
|
||||
{
|
||||
id: 'headline',
|
||||
type: 'headline',
|
||||
x: 160,
|
||||
y: 360,
|
||||
width: (context) => context.canvasWidth - 320,
|
||||
height: 250,
|
||||
fontSize: 86,
|
||||
x: 120,
|
||||
y: 280,
|
||||
width: (context) => context.canvasWidth - 240,
|
||||
height: 200,
|
||||
fontSize: 95,
|
||||
align: 'left',
|
||||
fontFamily: 'Playfair Display',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{
|
||||
id: 'subtitle',
|
||||
type: 'subtitle',
|
||||
x: 160,
|
||||
y: 630,
|
||||
width: (context) => context.canvasWidth - 320,
|
||||
height: 180,
|
||||
fontSize: 42,
|
||||
x: 120,
|
||||
y: 490,
|
||||
width: 680,
|
||||
height: 140,
|
||||
fontSize: 40,
|
||||
align: 'left',
|
||||
fontFamily: 'Montserrat',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
type: 'description',
|
||||
x: 160,
|
||||
y: 840,
|
||||
width: (context) => context.canvasWidth - 320,
|
||||
height: 360,
|
||||
fontSize: 34,
|
||||
x: 120,
|
||||
y: 640,
|
||||
width: 680,
|
||||
height: 220,
|
||||
fontSize: 32,
|
||||
align: 'left',
|
||||
fontFamily: 'Lora',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => context.canvasWidth - Math.min(context.qrSize, 640) - 200,
|
||||
y: 420,
|
||||
width: (context) => Math.min(context.qrSize, 640),
|
||||
height: (context) => Math.min(context.qrSize, 640),
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => context.canvasWidth - 560,
|
||||
y: (context) => 480 + Math.min(context.qrSize, 640),
|
||||
width: 520,
|
||||
height: 130,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => context.canvasWidth - 560,
|
||||
y: (context) => 520 + Math.min(context.qrSize, 640) + 180,
|
||||
width: 520,
|
||||
height: 130,
|
||||
align: 'center',
|
||||
x: (c) => c.canvasWidth - 440 - 120,
|
||||
y: 920,
|
||||
width: (c) => Math.min(c.qrSize, 440),
|
||||
height: (c) => Math.min(c.qrSize, 440),
|
||||
},
|
||||
{ id: 'cta', type: 'cta', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 40, width: 440, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat', lineHeight: 1.4 },
|
||||
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 160, width: 440, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
|
||||
];
|
||||
|
||||
const midnightGalaPreset: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: (context) => context.canvasWidth / 2 - 300, y: 180, width: 600, height: 120, align: 'center', fontSize: 32 },
|
||||
// Zentriert, premium, mehr vertikaler Abstand
|
||||
{ id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 560) / 2, y: 120, width: 560, height: 90, align: 'center', fontSize: 28, fontFamily: 'Montserrat' },
|
||||
{
|
||||
id: 'headline',
|
||||
type: 'headline',
|
||||
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 220) / 2,
|
||||
y: 340,
|
||||
width: (context) => context.canvasWidth - 220,
|
||||
height: 260,
|
||||
fontSize: 90,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'subtitle',
|
||||
type: 'subtitle',
|
||||
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2,
|
||||
y: 640,
|
||||
width: (context) => context.canvasWidth - 320,
|
||||
height: 200,
|
||||
fontSize: 46,
|
||||
x: (c) => (c.canvasWidth - 1100) / 2,
|
||||
y: 240,
|
||||
width: 1100,
|
||||
height: 220,
|
||||
fontSize: 105,
|
||||
align: 'center',
|
||||
fontFamily: 'Playfair Display',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 900) / 2, y: 480, width: 900, height: 120, fontSize: 40, align: 'center', fontFamily: 'Montserrat', lineHeight: 1.4 },
|
||||
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 620, width: 900, height: 180, fontSize: 32, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => (context.canvasWidth - Math.min(context.qrSize, 640)) / 2,
|
||||
x: (c) => (c.canvasWidth - 480) / 2,
|
||||
y: 880,
|
||||
width: (context) => Math.min(context.qrSize, 640),
|
||||
height: (context) => Math.min(context.qrSize, 640),
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => (context.canvasWidth - 560) / 2,
|
||||
y: (context) => 940 + Math.min(context.qrSize, 640),
|
||||
width: 560,
|
||||
height: 140,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => (context.canvasWidth - 560) / 2,
|
||||
y: (context) => 980 + Math.min(context.qrSize, 640) + 200,
|
||||
width: 560,
|
||||
height: 140,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
type: 'description',
|
||||
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 240) / 2,
|
||||
y: 1250,
|
||||
width: (context) => context.canvasWidth - 240,
|
||||
height: 360,
|
||||
fontSize: 34,
|
||||
align: 'center',
|
||||
width: (c) => Math.min(c.qrSize, 480),
|
||||
height: (c) => Math.min(c.qrSize, 480),
|
||||
},
|
||||
{ id: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 520) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 40, width: 520, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat', lineHeight: 1.4 },
|
||||
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
|
||||
];
|
||||
|
||||
const gardenBrunchPreset: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: 180, y: 180, width: 500, height: 110, align: 'center', fontSize: 30 },
|
||||
{ id: 'headline', type: 'headline', x: 180, y: 340, width: (context) => context.canvasWidth - 360, height: 260, fontSize: 86, align: 'left' },
|
||||
{ id: 'description', type: 'description', x: 180, y: 630, width: (context) => context.canvasWidth - 360, height: 360, fontSize: 34, align: 'left' },
|
||||
// Verspielt, asymmetrisch, aber ausbalanciert
|
||||
{ id: 'badge', type: 'badge', x: 120, y: 120, width: 500, height: 90, align: 'left', fontSize: 28, fontFamily: 'Montserrat' },
|
||||
{ id: 'headline', type: 'headline', x: 120, y: 240, width: 900, height: 200, fontSize: 90, align: 'left', fontFamily: 'Playfair Display', lineHeight: 1.3 },
|
||||
{ id: 'subtitle', type: 'subtitle', x: 120, y: 450, width: 700, height: 120, fontSize: 40, align: 'left', fontFamily: 'Montserrat', lineHeight: 1.4 },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: 180,
|
||||
y: 1000,
|
||||
width: (context) => Math.min(context.qrSize, 660),
|
||||
height: (context) => Math.min(context.qrSize, 660),
|
||||
x: 120,
|
||||
y: 880,
|
||||
width: (c) => Math.min(c.qrSize, 460),
|
||||
height: (c) => Math.min(c.qrSize, 460),
|
||||
},
|
||||
{ id: 'cta', type: 'cta', x: 120, y: (c) => 880 + Math.min(c.qrSize, 460) + 40, width: 460, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' },
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: 180,
|
||||
y: (context) => 1060 + Math.min(context.qrSize, 660),
|
||||
width: 520,
|
||||
height: 140,
|
||||
align: 'center',
|
||||
id: 'description',
|
||||
type: 'description',
|
||||
x: (c) => c.canvasWidth - 600 - 120,
|
||||
y: 620,
|
||||
width: 600,
|
||||
height: 400,
|
||||
fontSize: 32,
|
||||
align: 'left',
|
||||
fontFamily: 'Lora',
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: 180,
|
||||
y: (context) => 1100 + Math.min(context.qrSize, 660) + 190,
|
||||
width: 520,
|
||||
height: 140,
|
||||
align: 'center',
|
||||
},
|
||||
{ id: 'subtitle', type: 'subtitle', x: (context) => context.canvasWidth - 460, y: 360, width: 420, height: 200, fontSize: 38, align: 'left' },
|
||||
{ id: 'text-strip', type: 'text', x: (context) => context.canvasWidth - 460, y: 620, width: 420, height: 360, fontSize: 28, align: 'left' },
|
||||
{ id: 'link', type: 'link', x: 120, y: (c) => 880 + Math.min(c.qrSize, 460) + 160, width: 460, height: 80, align: 'center', fontSize: 24 },
|
||||
];
|
||||
|
||||
const sparklerSoireePreset: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: (context) => context.canvasWidth / 2 - 320, y: 200, width: 640, height: 120, align: 'center', fontSize: 32 },
|
||||
// Festlich, zentriert, klar
|
||||
{ id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 560) / 2, y: 120, width: 560, height: 90, align: 'center', fontSize: 28, fontFamily: 'Montserrat' },
|
||||
{
|
||||
id: 'headline',
|
||||
type: 'headline',
|
||||
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 220) / 2,
|
||||
y: 360,
|
||||
width: (context) => context.canvasWidth - 220,
|
||||
height: 280,
|
||||
fontSize: 94,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'subtitle',
|
||||
type: 'subtitle',
|
||||
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2,
|
||||
y: 660,
|
||||
width: (context) => context.canvasWidth - 320,
|
||||
height: 210,
|
||||
fontSize: 46,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
type: 'description',
|
||||
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2,
|
||||
y: 920,
|
||||
width: (context) => context.canvasWidth - 320,
|
||||
height: 380,
|
||||
fontSize: 34,
|
||||
x: (c) => (c.canvasWidth - 1000) / 2,
|
||||
y: 240,
|
||||
width: 1000,
|
||||
height: 220,
|
||||
fontSize: 100,
|
||||
align: 'center',
|
||||
fontFamily: 'Playfair Display',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 480, width: 800, height: 120, fontSize: 40, align: 'center', fontFamily: 'Montserrat' },
|
||||
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 620, width: 900, height: 180, fontSize: 32, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => (context.canvasWidth - Math.min(context.qrSize, 680)) / 2,
|
||||
y: 1200,
|
||||
width: (context) => Math.min(context.qrSize, 680),
|
||||
height: (context) => Math.min(context.qrSize, 680),
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => (context.canvasWidth - 580) / 2,
|
||||
y: (context) => 1260 + Math.min(context.qrSize, 680),
|
||||
width: 580,
|
||||
height: 150,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => (context.canvasWidth - 580) / 2,
|
||||
y: (context) => 1300 + Math.min(context.qrSize, 680) + 200,
|
||||
width: 580,
|
||||
height: 150,
|
||||
align: 'center',
|
||||
x: (c) => (c.canvasWidth - 480) / 2,
|
||||
y: 880,
|
||||
width: (c) => Math.min(c.qrSize, 480),
|
||||
height: (c) => Math.min(c.qrSize, 480),
|
||||
},
|
||||
{ id: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 520) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 40, width: 520, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' },
|
||||
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
|
||||
];
|
||||
|
||||
const confettiBashPreset: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: 180, y: 220, width: 520, height: 120, align: 'center', fontSize: 32 },
|
||||
// Zentriertes, luftiges Layout mit klarer Hierarchie.
|
||||
{ id: 'logo', type: 'logo', x: (c) => (c.canvasWidth - 280) / 2, y: 80, width: 280, height: 140, align: 'center' },
|
||||
{ id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 520) / 2, y: 240, width: 520, height: 90, align: 'center', fontSize: 28, lineHeight: 1.4, letterSpacing: 0.5, fontFamily: 'Montserrat' },
|
||||
{
|
||||
id: 'headline',
|
||||
type: 'headline',
|
||||
x: 180,
|
||||
y: 380,
|
||||
width: (context) => context.canvasWidth - 360,
|
||||
height: 260,
|
||||
fontSize: 90,
|
||||
align: 'left',
|
||||
x: (c) => (c.canvasWidth - 1000) / 2,
|
||||
y: 350,
|
||||
width: 1000,
|
||||
height: 220,
|
||||
fontSize: 110,
|
||||
align: 'center',
|
||||
fontFamily: 'Playfair Display',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{
|
||||
id: 'subtitle',
|
||||
type: 'subtitle',
|
||||
x: 180,
|
||||
y: 660,
|
||||
width: (context) => context.canvasWidth - 360,
|
||||
height: 200,
|
||||
fontSize: 46,
|
||||
align: 'left',
|
||||
x: (c) => (c.canvasWidth - 800) / 2,
|
||||
y: 580,
|
||||
width: 800,
|
||||
height: 120,
|
||||
fontSize: 42,
|
||||
align: 'center',
|
||||
fontFamily: 'Montserrat',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
type: 'description',
|
||||
x: 180,
|
||||
y: 910,
|
||||
width: (context) => context.canvasWidth - 360,
|
||||
height: 360,
|
||||
x: (c) => (c.canvasWidth - 900) / 2,
|
||||
y: 720,
|
||||
width: 900,
|
||||
height: 180,
|
||||
fontSize: 34,
|
||||
align: 'left',
|
||||
align: 'center',
|
||||
fontFamily: 'Lora',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 200,
|
||||
y: 460,
|
||||
width: (context) => Math.min(context.qrSize, 680),
|
||||
height: (context) => Math.min(context.qrSize, 680),
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => context.canvasWidth - 560,
|
||||
y: (context) => 520 + Math.min(context.qrSize, 680),
|
||||
width: 520,
|
||||
height: 140,
|
||||
align: 'center',
|
||||
x: (c) => (c.canvasWidth - 500) / 2,
|
||||
y: 940,
|
||||
width: (c) => Math.min(c.qrSize, 500),
|
||||
height: (c) => Math.min(c.qrSize, 500),
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => context.canvasWidth - 560,
|
||||
y: (context) => 560 + Math.min(context.qrSize, 680) + 200,
|
||||
width: 520,
|
||||
height: 140,
|
||||
x: (c) => (c.canvasWidth - 600) / 2,
|
||||
y: (c) => 940 + Math.min(c.qrSize, 500) + 40,
|
||||
width: 600,
|
||||
height: 100,
|
||||
align: 'center',
|
||||
fontSize: 32,
|
||||
fontFamily: 'Montserrat',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
{
|
||||
id: 'text-strip',
|
||||
type: 'text',
|
||||
x: 180,
|
||||
y: 1220,
|
||||
width: (context) => context.canvasWidth - 360,
|
||||
height: 360,
|
||||
fontSize: 30,
|
||||
align: 'left',
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (c) => (c.canvasWidth - 700) / 2,
|
||||
y: (c) => 940 + Math.min(c.qrSize, 500) + 160,
|
||||
width: 700,
|
||||
height: 80,
|
||||
align: 'center',
|
||||
fontSize: 26,
|
||||
fontFamily: 'Montserrat',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
];
|
||||
|
||||
const balancedModernPreset: LayoutPreset = [
|
||||
// Wahrhaftig balanciert: Text links, QR rechts
|
||||
{ id: 'logo', type: 'logo', x: 120, y: 100, width: 300, height: 150, align: 'left' },
|
||||
{ id: 'badge', type: 'badge', x: 120, y: 270, width: 500, height: 90, align: 'left', fontSize: 28, fontFamily: 'Montserrat' },
|
||||
{
|
||||
id: 'headline',
|
||||
type: 'headline',
|
||||
x: 120,
|
||||
y: 380,
|
||||
width: 620,
|
||||
height: 380,
|
||||
fontSize: 100,
|
||||
align: 'left',
|
||||
fontFamily: 'Playfair Display',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{
|
||||
id: 'subtitle',
|
||||
type: 'subtitle',
|
||||
x: 120,
|
||||
y: 770,
|
||||
width: 620,
|
||||
height: 140,
|
||||
fontSize: 42,
|
||||
align: 'left',
|
||||
fontFamily: 'Montserrat',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
type: 'description',
|
||||
x: 120,
|
||||
y: 920,
|
||||
width: 620,
|
||||
height: 300,
|
||||
fontSize: 34,
|
||||
align: 'left',
|
||||
fontFamily: 'Lora',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (c) => c.canvasWidth - 480 - 120,
|
||||
y: 380,
|
||||
width: 480,
|
||||
height: 480,
|
||||
},
|
||||
{ id: 'cta', type: 'cta', x: (c) => c.canvasWidth - 480 - 120, y: 880, width: 480, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' },
|
||||
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 480 - 120, y: 1000, width: 480, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
|
||||
];
|
||||
|
||||
const LAYOUT_PRESETS: Record<string, LayoutPreset> = {
|
||||
'default': DEFAULT_PRESET,
|
||||
'evergreen-vows': evergreenVowsPreset,
|
||||
'midnight-gala': midnightGalaPreset,
|
||||
'garden-brunch': gardenBrunchPreset,
|
||||
'sparkler-soiree': sparklerSoireePreset,
|
||||
'confetti-bash': confettiBashPreset,
|
||||
'default': DEFAULT_PRESET,
|
||||
'evergreen-vows': evergreenVowsPreset,
|
||||
'midnight-gala': midnightGalaPreset,
|
||||
'garden-brunch': gardenBrunchPreset,
|
||||
'sparkler-soiree': sparklerSoireePreset,
|
||||
'confetti-bash': confettiBashPreset,
|
||||
'balanced-modern': balancedModernPreset, // New preset: QR right, text left, logo top
|
||||
};
|
||||
|
||||
function resolvePresetValue(value: PresetValue | undefined, context: LayoutPresetContext, fallback: number): number {
|
||||
@@ -554,13 +507,14 @@ export function buildDefaultElements(
|
||||
height: resolvePresetValue(config.height, context, heightFallback),
|
||||
fontSize: config.fontSize ?? typeStyle.fontSize,
|
||||
align: config.align ?? typeStyle.align ?? 'left',
|
||||
fontFamily: config.fontFamily ?? 'Lora',
|
||||
content: null,
|
||||
locked: config.locked ?? typeStyle.locked ?? false,
|
||||
initial: config.initial ?? true,
|
||||
};
|
||||
|
||||
if (config.type === 'description') {
|
||||
element.lineHeight = 1.4;
|
||||
element.lineHeight = 1.5;
|
||||
}
|
||||
|
||||
switch (config.id) {
|
||||
@@ -622,6 +576,8 @@ export function payloadToElements(payload?: LayoutElementPayload[] | null): Layo
|
||||
y: Number(entry.y ?? 0),
|
||||
width: Number(entry.width ?? 100),
|
||||
height: Number(entry.height ?? 100),
|
||||
scaleX: Number(entry.scale_x ?? 1),
|
||||
scaleY: Number(entry.scale_y ?? 1),
|
||||
rotation: typeof entry.rotation === 'number' ? entry.rotation : 0,
|
||||
fontSize: typeof entry.font_size === 'number' ? entry.font_size : undefined,
|
||||
align: entry.align ?? 'left',
|
||||
@@ -644,6 +600,8 @@ export function elementsToPayload(elements: LayoutElement[]): LayoutElementPaylo
|
||||
y: element.y,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
scale_x: element.scaleX ?? 1,
|
||||
scale_y: element.scaleY ?? 1,
|
||||
rotation: element.rotation ?? 0,
|
||||
font_size: element.fontSize,
|
||||
align: element.align,
|
||||
|
||||
@@ -391,7 +391,7 @@ export const PaymentStep: React.FC = () => {
|
||||
)}
|
||||
|
||||
{status !== 'idle' && (
|
||||
<Alert variant={status === 'error' ? 'destructive' : 'secondary'}>
|
||||
<Alert variant={status === 'error' ? 'destructive' : 'default'}>
|
||||
<AlertTitle>
|
||||
{status === 'processing'
|
||||
? t('checkout.payment_step.status_processing_title')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Monitoring\PackageLimitMetrics;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
@@ -18,3 +19,17 @@ Artisan::command('storage:archive-pending', function () {
|
||||
Artisan::command('storage:check-upload-queues', function () {
|
||||
$this->comment('Upload queue health placeholder – verify upload pipelines and report issues.');
|
||||
})->purpose('Check upload queues for stalled or failed jobs and alert admins');
|
||||
|
||||
Artisan::command('metrics:package-limits {--reset}', function () {
|
||||
$snapshot = PackageLimitMetrics::snapshot();
|
||||
|
||||
$this->line(json_encode([
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'metrics' => $snapshot,
|
||||
], JSON_PRETTY_PRINT));
|
||||
|
||||
if ($this->option('reset')) {
|
||||
PackageLimitMetrics::reset();
|
||||
$this->comment('Package limit metrics cache was reset.');
|
||||
}
|
||||
})->purpose('Inspect package limit monitoring counters and optionally reset them');
|
||||
|
||||
58
tests/Unit/Services/Monitoring/PackageLimitMetricsTest.php
Normal file
58
tests/Unit/Services/Monitoring/PackageLimitMetricsTest.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services\Monitoring;
|
||||
|
||||
use App\Services\Monitoring\PackageLimitMetrics;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PackageLimitMetricsTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config()->set('cache.default', 'array');
|
||||
Cache::flush();
|
||||
Log::spy();
|
||||
}
|
||||
|
||||
public function test_records_gallery_metrics(): void
|
||||
{
|
||||
PackageLimitMetrics::recordGalleryWarning(7);
|
||||
PackageLimitMetrics::recordGalleryWarning(7);
|
||||
PackageLimitMetrics::recordGalleryExpired();
|
||||
|
||||
$snapshot = PackageLimitMetrics::snapshot();
|
||||
|
||||
$this->assertSame(2, $snapshot['gallery']['warning_day_7'] ?? null);
|
||||
$this->assertSame(1, $snapshot['gallery']['expired'] ?? null);
|
||||
|
||||
Log::shouldHaveReceived('info')->atLeast()->once();
|
||||
}
|
||||
|
||||
public function test_records_tenant_package_and_credit_metrics(): void
|
||||
{
|
||||
PackageLimitMetrics::recordTenantPackageWarning(6);
|
||||
PackageLimitMetrics::recordTenantPackageExpired();
|
||||
PackageLimitMetrics::recordCreditWarning(5, 4);
|
||||
PackageLimitMetrics::recordCreditRecovery(8);
|
||||
|
||||
$snapshot = PackageLimitMetrics::snapshot();
|
||||
|
||||
$this->assertSame(1, $snapshot['tenant_package']['warning_day_6'] ?? null);
|
||||
$this->assertSame(1, $snapshot['tenant_package']['expired'] ?? null);
|
||||
$this->assertSame(1, $snapshot['tenant_credit']['threshold_5'] ?? null);
|
||||
$this->assertSame(1, $snapshot['tenant_credit']['recovered'] ?? null);
|
||||
}
|
||||
|
||||
public function test_reset_clears_metrics(): void
|
||||
{
|
||||
PackageLimitMetrics::recordGalleryWarning(1);
|
||||
$this->assertNotEmpty(PackageLimitMetrics::snapshot());
|
||||
|
||||
PackageLimitMetrics::reset();
|
||||
|
||||
$this->assertSame([], PackageLimitMetrics::snapshot());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user