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\EventPackageGalleryExpired;
|
||||||
use App\Events\Packages\EventPackageGalleryExpiring;
|
use App\Events\Packages\EventPackageGalleryExpiring;
|
||||||
use App\Models\EventPackage;
|
use App\Models\EventPackage;
|
||||||
|
use App\Services\Monitoring\PackageLimitMetrics;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class CheckEventPackages extends Command
|
class CheckEventPackages extends Command
|
||||||
@@ -53,6 +54,8 @@ class CheckEventPackages extends Command
|
|||||||
'credit_warning_threshold' => null,
|
'credit_warning_threshold' => null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
PackageLimitMetrics::recordCreditRecovery($balance);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +68,7 @@ class CheckEventPackages extends Command
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
event(new \App\Events\Packages\TenantCreditsLow($tenant, $balance, $threshold));
|
event(new \App\Events\Packages\TenantCreditsLow($tenant, $balance, $threshold));
|
||||||
|
PackageLimitMetrics::recordCreditWarning($threshold, $balance);
|
||||||
$tenant->forceFill([
|
$tenant->forceFill([
|
||||||
'credit_warning_sent_at' => $now,
|
'credit_warning_sent_at' => $now,
|
||||||
'credit_warning_threshold' => $threshold,
|
'credit_warning_threshold' => $threshold,
|
||||||
@@ -99,6 +103,7 @@ class CheckEventPackages extends Command
|
|||||||
if ($daysDiff < 0) {
|
if ($daysDiff < 0) {
|
||||||
if (! $package->gallery_expired_notified_at) {
|
if (! $package->gallery_expired_notified_at) {
|
||||||
event(new EventPackageGalleryExpired($package));
|
event(new EventPackageGalleryExpired($package));
|
||||||
|
PackageLimitMetrics::recordGalleryExpired();
|
||||||
$package->forceFill([
|
$package->forceFill([
|
||||||
'gallery_expired_notified_at' => $now,
|
'gallery_expired_notified_at' => $now,
|
||||||
])->save();
|
])->save();
|
||||||
@@ -118,6 +123,7 @@ class CheckEventPackages extends Command
|
|||||||
foreach ($warningDays as $day) {
|
foreach ($warningDays as $day) {
|
||||||
if ($daysDiff <= $day && $daysDiff >= 0) {
|
if ($daysDiff <= $day && $daysDiff >= 0) {
|
||||||
event(new EventPackageGalleryExpiring($package, $day));
|
event(new EventPackageGalleryExpiring($package, $day));
|
||||||
|
PackageLimitMetrics::recordGalleryWarning($day);
|
||||||
$package->forceFill([
|
$package->forceFill([
|
||||||
'gallery_warning_sent_at' => $now,
|
'gallery_warning_sent_at' => $now,
|
||||||
])->save();
|
])->save();
|
||||||
@@ -142,6 +148,7 @@ class CheckEventPackages extends Command
|
|||||||
if ($daysDiff < 0) {
|
if ($daysDiff < 0) {
|
||||||
if (! $tenantPackage->expired_notified_at) {
|
if (! $tenantPackage->expired_notified_at) {
|
||||||
event(new \App\Events\Packages\TenantPackageExpired($tenantPackage));
|
event(new \App\Events\Packages\TenantPackageExpired($tenantPackage));
|
||||||
|
PackageLimitMetrics::recordTenantPackageExpired();
|
||||||
$tenantPackage->forceFill(['expired_notified_at' => $now])->save();
|
$tenantPackage->forceFill(['expired_notified_at' => $now])->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +169,7 @@ class CheckEventPackages extends Command
|
|||||||
foreach ($eventPackageExpiryDays as $day) {
|
foreach ($eventPackageExpiryDays as $day) {
|
||||||
if ($daysDiff <= $day && $daysDiff >= 0) {
|
if ($daysDiff <= $day && $daysDiff >= 0) {
|
||||||
event(new \App\Events\Packages\TenantPackageExpiring($tenantPackage, $day));
|
event(new \App\Events\Packages\TenantPackageExpiring($tenantPackage, $day));
|
||||||
|
PackageLimitMetrics::recordTenantPackageWarning($day);
|
||||||
$tenantPackage->forceFill(['expiry_warning_sent_at' => $now])->save();
|
$tenantPackage->forceFill(['expiry_warning_sent_at' => $now])->save();
|
||||||
break;
|
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).
|
- Super Admin: session-authenticated Filament (web only).
|
||||||
- Common
|
- Common
|
||||||
- Pagination: `page`, `per_page` (max 100).
|
- 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.
|
- Rate limits: per-tenant and per-device for tenant apps; 429 with `x-rate-limit-*` headers.
|
||||||
|
|
||||||
Key Endpoints (abridged)
|
Key Endpoints (abridged)
|
||||||
@@ -31,6 +31,28 @@ Webhooks
|
|||||||
- Payment provider events, media pipeline status, and deletion callbacks. All signed with shared secret per provider.
|
- 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.
|
- 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
|
Public Gallery
|
||||||
- `GET /gallery/{token}`: returns event snapshot + branding colors; responds with `410` once the package gallery window expires.
|
- `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 }`.
|
- `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.
|
- Manage events, galleries, members, settings, legal pages, purchases.
|
||||||
- Notifications: Web Push (Android TWA) and Capacitor push (iOS).
|
- Notifications: Web Push (Android TWA) and Capacitor push (iOS).
|
||||||
- Conflict handling: ETag/If-Match; audit changes.
|
- 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
|
Distribution & CI
|
||||||
- Play: assetlinks.json at `/.well-known/assetlinks.json`.
|
- 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.
|
- 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.
|
- 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â€.
|
- 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
|
- Gallery
|
||||||
- Masonry grid, lazy-load, pull-to-refresh; open photo lightbox with swipe.
|
- Masonry grid, lazy-load, pull-to-refresh; open photo lightbox with swipe.
|
||||||
- Like (heart) with optimistic UI; share system sheet (URL to CDN variant).
|
- 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).
|
- 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.
|
- 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
|
- Safety & abuse controls
|
||||||
- Rate limits per device and IP; content-length checks; mime/type sniffing.
|
- Rate limits per device and IP; content-length checks; mime/type sniffing.
|
||||||
- Upload moderation state: pending → approved/hidden; show local status.
|
- Upload moderation state: pending → approved/hidden; show local status.
|
||||||
|
|||||||
@@ -37,10 +37,10 @@
|
|||||||
- [ ] E2E-Tests für Limitwarnungen & abgelaufene Galerie aktualisieren.
|
- [ ] E2E-Tests für Limitwarnungen & abgelaufene Galerie aktualisieren.
|
||||||
|
|
||||||
### 4. Tenant Admin PWA Improvements
|
### 4. Tenant Admin PWA Improvements
|
||||||
- [ ] Dashboard-Karten & Event-Header mit Ampelsystem für Limitfortschritt.
|
- [x] Dashboard-Karten & Event-Header mit Ampelsystem für Limitfortschritt.
|
||||||
- [ ] Event-Formular: Warnhinweise bei 80 %/95 % + Upgrade-CTA.
|
- [x] Event-Formular: Warnhinweise bei 80 %/95 % + Upgrade-CTA.
|
||||||
- [ ] Globale Fehlerzustände aus Fehlerkontrakt (Toast/Dialog).
|
- [x] Globale Fehlerzustände aus Fehlerkontrakt (Toast/Dialog).
|
||||||
- [ ] Übersetzungen für alle neuen Messages hinzufügen.
|
- [x] Übersetzungen für alle neuen Messages hinzufügen.
|
||||||
|
|
||||||
- [x] E-Mail-Schablonen & Notifications für Foto- und Gäste-Schwellen/Limits.
|
- [x] E-Mail-Schablonen & Notifications für Foto- und Gäste-Schwellen/Limits.
|
||||||
- [x] Galerie-Warnungen (D-7/D-1) & Ablauf-Mails + Cron Task.
|
- [x] Galerie-Warnungen (D-7/D-1) & Ablauf-Mails + Cron Task.
|
||||||
@@ -50,9 +50,9 @@
|
|||||||
- [ ] Audit-Log & Retry-Logik für gesendete Mails.
|
- [ ] Audit-Log & Retry-Logik für gesendete Mails.
|
||||||
|
|
||||||
### 6. Monitoring, Docs & Support
|
### 6. Monitoring, Docs & Support
|
||||||
- [ ] Prometheus/Grafana-Metriken für Paketnutzung & Warns triggern.
|
- [x] Prometheus/Grafana-Metriken für Paketnutzung & Warns triggern. *(`PackageLimitMetrics` + `php artisan metrics:package-limits` Snapshot)*
|
||||||
- [ ] PRP & API-Doku mit neuem Fehlerschema & Limitverhalten aktualisieren.
|
- [x] PRP & API-Doku mit neuem Fehlerschema & Limitverhalten aktualisieren.
|
||||||
- [ ] Support-Playbook & FAQ für Limitwarnungen erweitern.
|
- [x] Support-Playbook & FAQ für Limitwarnungen erweitern. *(docs/prp/06 Tenant Admin Playbook Abschnitt)*
|
||||||
|
|
||||||
## Dependencies & Notes
|
## Dependencies & Notes
|
||||||
- Bestehende Credit-Logik parallel weiter unterstützen (Legacy-Kunden).
|
- Bestehende Credit-Logik parallel weiter unterstützen (Legacy-Kunden).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { authorizedFetch } from './auth/tokens';
|
import { authorizedFetch } from './auth/tokens';
|
||||||
import { ApiError } from './lib/apiError';
|
import { ApiError, emitApiErrorEvent } from './lib/apiError';
|
||||||
import type { EventLimitSummary } from './lib/limitWarnings';
|
import type { EventLimitSummary } from './lib/limitWarnings';
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
|
|
||||||
@@ -338,7 +338,11 @@ type EventSavePayload = {
|
|||||||
settings?: Record<string, unknown>;
|
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) {
|
if (!response.ok) {
|
||||||
const body = await safeJson(response);
|
const body = await safeJson(response);
|
||||||
const status = response.status;
|
const status = response.status;
|
||||||
@@ -353,6 +357,10 @@ async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
|
|||||||
? errorPayload.meta as Record<string, unknown>
|
? errorPayload.meta as Record<string, unknown>
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
if (!options.suppressToast) {
|
||||||
|
emitApiErrorEvent({ message: errorMessage, status, code: errorCode, meta: errorMeta });
|
||||||
|
}
|
||||||
|
|
||||||
console.error('[API]', errorMessage, status, body);
|
console.error('[API]', errorMessage, status, body);
|
||||||
throw new ApiError(errorMessage, status, errorCode, errorMeta);
|
throw new ApiError(errorMessage, status, errorCode, errorMeta);
|
||||||
}
|
}
|
||||||
@@ -1043,8 +1051,10 @@ export async function getDashboardSummary(): Promise<DashboardSummary | null> {
|
|||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const payload = await safeJson(response);
|
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);
|
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;
|
const json = (await response.json()) as JsonValue;
|
||||||
return normalizeDashboard(json);
|
return normalizeDashboard(json);
|
||||||
@@ -1057,8 +1067,10 @@ export async function getTenantPackagesOverview(): Promise<{
|
|||||||
const response = await fetchTenantPackagesEndpoint();
|
const response = await fetchTenantPackagesEndpoint();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const payload = await safeJson(response);
|
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);
|
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 data = (await response.json()) as TenantPackagesResponse;
|
||||||
const packages = Array.isArray(data.data) ? data.data.map(normalizeTenantPackage) : [];
|
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 { NavLink } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import {
|
import {
|
||||||
ADMIN_HOME_PATH,
|
ADMIN_HOME_PATH,
|
||||||
ADMIN_EVENTS_PATH,
|
ADMIN_EVENTS_PATH,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
ADMIN_ENGAGEMENT_PATH,
|
ADMIN_ENGAGEMENT_PATH,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||||
|
import { registerApiErrorListener } from '../lib/apiError';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', end: true },
|
{ 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 (
|
return (
|
||||||
<div className="min-h-screen bg-brand-gradient text-brand-slate">
|
<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">
|
<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;
|
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`;
|
return `${sizePx}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventInvitesPage(): JSX.Element {
|
export default function EventInvitesPage(): React.ReactElement {
|
||||||
const { slug } = useParams<{ slug?: string }>();
|
const { slug } = useParams<{ slug?: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
@@ -205,8 +205,10 @@ export default function EventInvitesPage(): JSX.Element {
|
|||||||
|
|
||||||
const widthRatio = container.clientWidth / CANVAS_WIDTH;
|
const widthRatio = container.clientWidth / CANVAS_WIDTH;
|
||||||
const heightRatio = container.clientHeight ? container.clientHeight / CANVAS_HEIGHT : Number.POSITIVE_INFINITY;
|
const heightRatio = container.clientHeight ? container.clientHeight / CANVAS_HEIGHT : Number.POSITIVE_INFINITY;
|
||||||
const base = Math.min(widthRatio, heightRatio);
|
const portraitRatio = 1754 / 1240; // A4 height/width for portrait priority
|
||||||
const safeBase = Number.isFinite(base) && base > 0 ? Math.min(base, 1) : 1;
|
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);
|
const clampedScale = clamp(safeBase, 0.1, 1);
|
||||||
|
|
||||||
setExportScale((prev) => (Math.abs(prev - clampedScale) < 0.001 ? prev : clampedScale));
|
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 backgroundColor = normalizeHexColor(customization?.background_color ?? (layoutPreview.background as string | undefined)) ?? '#F8FAFC';
|
||||||
const accentColor = normalizeHexColor(customization?.accent_color ?? (layoutPreview.accent as string | undefined)) ?? '#6366F1';
|
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 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 badgeColor = normalizeHexColor(customization?.badge_color ?? (layoutPreview.accent as string | undefined)) ?? accentColor;
|
||||||
const gradient = normalizeGradient(customization?.background_gradient ?? layoutPreview.background_gradient ?? null);
|
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 formatBadges = formatKeys.map((format) => String(format).toUpperCase());
|
||||||
const formatLabel = formatBadges.length ? formatBadges.join(' · ') : t('invites.export.meta.formatsNone', 'Keine Formate hinterlegt');
|
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 {
|
return {
|
||||||
backgroundStyle: buildBackgroundStyle(backgroundColor, gradient),
|
backgroundStyle: buildBackgroundStyle(backgroundColor, gradient),
|
||||||
@@ -348,11 +350,8 @@ export default function EventInvitesPage(): JSX.Element {
|
|||||||
formatLabel,
|
formatLabel,
|
||||||
formatBadges,
|
formatBadges,
|
||||||
formats: formatKeys,
|
formats: formatKeys,
|
||||||
paperLabel: formatPaperLabel(exportLayout.paper),
|
paperLabel: formatPaperLabel('a4'),
|
||||||
orientationLabel:
|
orientationLabel: t('invites.export.meta.orientationPortrait', 'Hochformat'),
|
||||||
exportLayout.orientation === 'landscape'
|
|
||||||
? t('invites.export.meta.orientationLandscape', 'Querformat')
|
|
||||||
: t('invites.export.meta.orientationPortrait', 'Hochformat'),
|
|
||||||
qrSizeLabel: formatQrSizeLabel(qrSizePx, t('invites.export.meta.qrSizeFallback', 'Automatisch')),
|
qrSizeLabel: formatQrSizeLabel(qrSizePx, t('invites.export.meta.qrSizeFallback', 'Automatisch')),
|
||||||
lastUpdated: selectedInvite.created_at ? formatDateTime(selectedInvite.created_at) : null,
|
lastUpdated: selectedInvite.created_at ? formatDateTime(selectedInvite.created_at) : null,
|
||||||
mode: customization?.mode === 'advanced' ? 'advanced' : 'standard',
|
mode: customization?.mode === 'advanced' ? 'advanced' : 'standard',
|
||||||
@@ -397,7 +396,7 @@ export default function EventInvitesPage(): JSX.Element {
|
|||||||
exportLayout,
|
exportLayout,
|
||||||
baseForm,
|
baseForm,
|
||||||
eventName,
|
eventName,
|
||||||
exportLayout.preview?.qr_size_px ?? exportLayout.qr?.size_px ?? 480
|
exportLayout.preview?.qr_size_px ?? 480
|
||||||
);
|
);
|
||||||
}, [exportLayout, currentCustomization, selectedInvite?.url, eventName]);
|
}, [exportLayout, currentCustomization, selectedInvite?.url, eventName]);
|
||||||
|
|
||||||
@@ -428,7 +427,7 @@ export default function EventInvitesPage(): JSX.Element {
|
|||||||
[selectedInvite?.id, exportLayout?.id, exportPreview?.mode]
|
[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 exportQr = selectedInvite?.qr_code_data_url ?? null;
|
||||||
|
|
||||||
const handlePreviewSelect = React.useCallback((_id: string | null) => undefined, []);
|
const handlePreviewSelect = React.useCallback((_id: string | null) => undefined, []);
|
||||||
@@ -613,10 +612,10 @@ export default function EventInvitesPage(): JSX.Element {
|
|||||||
} else if (normalizedFormat === 'pdf') {
|
} else if (normalizedFormat === 'pdf') {
|
||||||
const pdfBytes = await generatePdfBytes(
|
const pdfBytes = await generatePdfBytes(
|
||||||
exportOptions,
|
exportOptions,
|
||||||
exportLayout.paper ?? 'a4',
|
'a4',
|
||||||
exportLayout.orientation ?? 'portrait',
|
'portrait',
|
||||||
);
|
);
|
||||||
triggerDownloadFromBlob(new Blob([pdfBytes], { type: 'application/pdf' }), `${filenameStem}.pdf`);
|
triggerDownloadFromBlob(new Blob([pdfBytes as any], { type: 'application/pdf' }), `${filenameStem}.pdf`);
|
||||||
} else {
|
} else {
|
||||||
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
|
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
|
||||||
}
|
}
|
||||||
@@ -656,8 +655,8 @@ export default function EventInvitesPage(): JSX.Element {
|
|||||||
try {
|
try {
|
||||||
const pdfBytes = await generatePdfBytes(
|
const pdfBytes = await generatePdfBytes(
|
||||||
exportOptions,
|
exportOptions,
|
||||||
exportLayout.paper ?? 'a4',
|
'a4',
|
||||||
exportLayout.orientation ?? 'portrait',
|
'portrait',
|
||||||
);
|
);
|
||||||
|
|
||||||
await openPdfInNewTab(pdfBytes);
|
await openPdfInNewTab(pdfBytes);
|
||||||
@@ -881,23 +880,25 @@ export default function EventInvitesPage(): JSX.Element {
|
|||||||
ref={exportPreviewContainerRef}
|
ref={exportPreviewContainerRef}
|
||||||
className="pointer-events-none w-full max-w-full"
|
className="pointer-events-none w-full max-w-full"
|
||||||
>
|
>
|
||||||
<DesignerCanvas
|
<div className="aspect-[1240/1754] mx-auto max-w-full">
|
||||||
elements={exportElements}
|
<DesignerCanvas
|
||||||
selectedId={null}
|
elements={exportElements}
|
||||||
onSelect={handlePreviewSelect}
|
selectedId={null}
|
||||||
onChange={handlePreviewChange}
|
onSelect={handlePreviewSelect}
|
||||||
background={exportPreview.backgroundColor}
|
onChange={handlePreviewChange}
|
||||||
gradient={exportPreview.backgroundGradient}
|
background={exportPreview.backgroundColor}
|
||||||
accent={exportPreview.accentColor}
|
gradient={exportPreview.backgroundGradient}
|
||||||
text={exportPreview.textColor}
|
accent={exportPreview.accentColor}
|
||||||
secondary={exportPreview.secondaryColor}
|
text={exportPreview.textColor}
|
||||||
badge={exportPreview.badgeColor}
|
secondary={exportPreview.secondaryColor}
|
||||||
qrCodeDataUrl={exportQr}
|
badge={exportPreview.badgeColor}
|
||||||
logoDataUrl={exportLogo}
|
qrCodeDataUrl={exportQr}
|
||||||
scale={exportScale}
|
logoDataUrl={exportLogo}
|
||||||
layoutKey={exportCanvasKey}
|
scale={exportScale}
|
||||||
readOnly
|
layoutKey={exportCanvasKey}
|
||||||
/>
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</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)]">
|
<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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="h-8 w-56 animate-pulse rounded-full bg-white/70" />
|
<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 inviteUrl = invite?.url ?? '';
|
||||||
const qrCodeDataUrl = invite?.qr_code_data_url ?? null;
|
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 defaultInstructions = React.useMemo(() => {
|
||||||
const value = t('tasks.customizer.defaults.instructions', { returnObjects: true }) as unknown;
|
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'];
|
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 [showFloatingActions, setShowFloatingActions] = React.useState(false);
|
||||||
const [zoomScale, setZoomScale] = React.useState(1);
|
const [zoomScale, setZoomScale] = React.useState(1);
|
||||||
const [fitScale, setFitScale] = React.useState(1);
|
const [fitScale, setFitScale] = React.useState(1);
|
||||||
|
const [previewMode, setPreviewMode] = React.useState<'fit' | 'full'>('fit');
|
||||||
const fitScaleRef = React.useRef(1);
|
const fitScaleRef = React.useRef(1);
|
||||||
const manualZoomRef = React.useRef(false);
|
const manualZoomRef = React.useRef(false);
|
||||||
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
|
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
@@ -262,7 +266,9 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
const widthScale = availableWidth / CANVAS_WIDTH;
|
const widthScale = availableWidth / CANVAS_WIDTH;
|
||||||
const heightScale = availableHeight / CANVAS_HEIGHT;
|
const heightScale = availableHeight / CANVAS_HEIGHT;
|
||||||
const nextRaw = Math.min(widthScale, heightScale);
|
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);
|
const clamped = clampZoom(baseScale);
|
||||||
|
|
||||||
fitScaleRef.current = clamped;
|
fitScaleRef.current = clamped;
|
||||||
@@ -462,10 +468,12 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
return activeLayout?.preview?.qr_size_px ?? 500;
|
return activeLayout?.preview?.qr_size_px ?? 500;
|
||||||
}, [elements, initialCustomization?.mode, initialCustomization?.elements, activeLayout?.preview?.qr_size_px]);
|
}, [elements, initialCustomization?.mode, initialCustomization?.elements, activeLayout?.preview?.qr_size_px]);
|
||||||
|
|
||||||
const effectiveScale = React.useMemo(
|
const effectiveScale = React.useMemo(() => {
|
||||||
() => clampZoom(Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : fitScale),
|
if (previewMode === 'full') {
|
||||||
[clampZoom, zoomScale, fitScale],
|
return 1.0;
|
||||||
);
|
}
|
||||||
|
return clampZoom(Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : fitScale);
|
||||||
|
}, [clampZoom, zoomScale, fitScale, previewMode]);
|
||||||
const zoomPercent = Math.round(effectiveScale * 100);
|
const zoomPercent = Math.round(effectiveScale * 100);
|
||||||
|
|
||||||
const updateElement = React.useCallback(
|
const updateElement = React.useCallback(
|
||||||
@@ -640,8 +648,8 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
accent_color: sanitizeColor((reuseCustomization ? initialCustomization?.accent_color : activeLayout.preview?.accent) ?? null) ?? '#6366F1',
|
accent_color: sanitizeColor((reuseCustomization ? initialCustomization?.accent_color : activeLayout.preview?.accent) ?? null) ?? '#6366F1',
|
||||||
text_color: sanitizeColor((reuseCustomization ? initialCustomization?.text_color : activeLayout.preview?.text) ?? null) ?? '#111827',
|
text_color: sanitizeColor((reuseCustomization ? initialCustomization?.text_color : activeLayout.preview?.text) ?? null) ?? '#111827',
|
||||||
background_color: sanitizeColor((reuseCustomization ? initialCustomization?.background_color : activeLayout.preview?.background) ?? null) ?? '#FFFFFF',
|
background_color: sanitizeColor((reuseCustomization ? initialCustomization?.background_color : activeLayout.preview?.background) ?? null) ?? '#FFFFFF',
|
||||||
secondary_color: sanitizeColor((reuseCustomization ? initialCustomization?.secondary_color : activeLayout.preview?.secondary) ?? null) ?? '#1F2937',
|
secondary_color: reuseCustomization ? initialCustomization?.secondary_color ?? '#1F2937' : '#1F2937',
|
||||||
badge_color: sanitizeColor((reuseCustomization ? initialCustomization?.badge_color : activeLayout.preview?.badge ?? activeLayout.preview?.accent) ?? null) ?? '#2563EB',
|
badge_color: reuseCustomization ? initialCustomization?.badge_color ?? '#2563EB' : '#2563EB',
|
||||||
background_gradient: reuseCustomization ? initialCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null : activeLayout.preview?.background_gradient ?? null,
|
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,
|
logo_data_url: reuseCustomization ? initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null : null,
|
||||||
});
|
});
|
||||||
@@ -1088,7 +1096,7 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
updateElement(
|
updateElement(
|
||||||
elementId,
|
elementId,
|
||||||
{
|
{
|
||||||
content: typeof nextValue === 'string' ? nextValue : nextValue ?? null,
|
content: (typeof nextValue === 'string' ? nextValue : String(nextValue ?? '')) as string,
|
||||||
},
|
},
|
||||||
{ silent: true }
|
{ silent: true }
|
||||||
);
|
);
|
||||||
@@ -1252,8 +1260,8 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
accent_color: sanitizeColor(layout.preview?.accent ?? prev.accent_color ?? null) ?? '#6366F1',
|
accent_color: sanitizeColor(layout.preview?.accent ?? prev.accent_color ?? null) ?? '#6366F1',
|
||||||
text_color: sanitizeColor(layout.preview?.text ?? prev.text_color ?? null) ?? '#111827',
|
text_color: sanitizeColor(layout.preview?.text ?? prev.text_color ?? null) ?? '#111827',
|
||||||
background_color: sanitizeColor(layout.preview?.background ?? prev.background_color ?? null) ?? '#FFFFFF',
|
background_color: sanitizeColor(layout.preview?.background ?? prev.background_color ?? null) ?? '#FFFFFF',
|
||||||
secondary_color: sanitizeColor(layout.preview?.secondary ?? prev.secondary_color ?? null) ?? '#1F2937',
|
secondary_color: '#1F2937',
|
||||||
badge_color: sanitizeColor(layout.preview?.badge ?? prev.badge_color ?? layout.preview?.accent ?? null) ?? '#2563EB',
|
badge_color: '#2563EB',
|
||||||
background_gradient: layout.preview?.background_gradient ?? null,
|
background_gradient: layout.preview?.background_gradient ?? null,
|
||||||
}));
|
}));
|
||||||
setInstructions((layout.instructions ?? []).length ? [...(layout.instructions as string[])] : [...defaultInstructions]);
|
setInstructions((layout.instructions ?? []).length ? [...(layout.instructions as string[])] : [...defaultInstructions]);
|
||||||
@@ -1351,7 +1359,7 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
elements: canvasElements,
|
elements: canvasElements,
|
||||||
accentColor: form.accent_color ?? activeLayout?.preview?.accent ?? '#6366F1',
|
accentColor: form.accent_color ?? activeLayout?.preview?.accent ?? '#6366F1',
|
||||||
textColor: form.text_color ?? activeLayout?.preview?.text ?? '#111827',
|
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',
|
badgeColor: form.badge_color ?? form.accent_color ?? '#2563EB',
|
||||||
qrCodeDataUrl,
|
qrCodeDataUrl,
|
||||||
logoDataUrl: form.logo_data_url ?? form.logo_url ?? null,
|
logoDataUrl: form.logo_data_url ?? form.logo_url ?? null,
|
||||||
@@ -1367,10 +1375,10 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
} else if (normalizedFormat === 'pdf') {
|
} else if (normalizedFormat === 'pdf') {
|
||||||
const pdfBytes = await generatePdfBytes(
|
const pdfBytes = await generatePdfBytes(
|
||||||
exportOptions,
|
exportOptions,
|
||||||
activeLayout?.paper ?? 'a4',
|
'a4',
|
||||||
activeLayout?.orientation ?? 'portrait',
|
'portrait',
|
||||||
);
|
);
|
||||||
triggerDownloadFromBlob(new Blob([pdfBytes], { type: 'application/pdf' }), `${filenameStem}.pdf`);
|
triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), `${filenameStem}.pdf`);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported format: ${normalizedFormat}`);
|
throw new Error(`Unsupported format: ${normalizedFormat}`);
|
||||||
}
|
}
|
||||||
@@ -1395,7 +1403,7 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
elements: canvasElements,
|
elements: canvasElements,
|
||||||
accentColor: form.accent_color ?? activeLayout?.preview?.accent ?? '#6366F1',
|
accentColor: form.accent_color ?? activeLayout?.preview?.accent ?? '#6366F1',
|
||||||
textColor: form.text_color ?? activeLayout?.preview?.text ?? '#111827',
|
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',
|
badgeColor: form.badge_color ?? form.accent_color ?? '#2563EB',
|
||||||
qrCodeDataUrl,
|
qrCodeDataUrl,
|
||||||
logoDataUrl: form.logo_data_url ?? form.logo_url ?? null,
|
logoDataUrl: form.logo_data_url ?? form.logo_url ?? null,
|
||||||
@@ -1407,8 +1415,8 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
|
|
||||||
const pdfBytes = await generatePdfBytes(
|
const pdfBytes = await generatePdfBytes(
|
||||||
exportOptions,
|
exportOptions,
|
||||||
activeLayout?.paper ?? 'a4',
|
'a4',
|
||||||
activeLayout?.orientation ?? 'portrait',
|
'portrait',
|
||||||
);
|
);
|
||||||
|
|
||||||
await openPdfInNewTab(pdfBytes);
|
await openPdfInNewTab(pdfBytes);
|
||||||
@@ -1815,10 +1823,18 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
setZoomScale(clampZoom(Number(event.target.value)));
|
setZoomScale(clampZoom(Number(event.target.value)));
|
||||||
}}
|
}}
|
||||||
className="h-1 w-36 overflow-hidden rounded-full"
|
className="h-1 w-36 overflow-hidden rounded-full"
|
||||||
disabled={false}
|
disabled={previewMode === 'full'}
|
||||||
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
|
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
|
||||||
/>
|
/>
|
||||||
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1827,8 +1843,9 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
manualZoomRef.current = false;
|
manualZoomRef.current = false;
|
||||||
const fitValue = clampZoom(fitScaleRef.current);
|
const fitValue = clampZoom(fitScaleRef.current);
|
||||||
setZoomScale(fitValue);
|
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')}
|
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1860,9 +1877,12 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div
|
<div
|
||||||
ref={designerViewportRef}
|
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
|
<DesignerCanvas
|
||||||
elements={canvasElements}
|
elements={canvasElements}
|
||||||
selectedId={activeElementId}
|
selectedId={activeElementId}
|
||||||
@@ -1872,7 +1892,7 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
gradient={form.background_gradient ?? activeLayout.preview?.background_gradient ?? null}
|
gradient={form.background_gradient ?? activeLayout.preview?.background_gradient ?? null}
|
||||||
accent={form.accent_color ?? activeLayout.preview?.accent ?? '#6366F1'}
|
accent={form.accent_color ?? activeLayout.preview?.accent ?? '#6366F1'}
|
||||||
text={form.text_color ?? activeLayout.preview?.text ?? '#111827'}
|
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'}
|
badge={form.badge_color ?? form.accent_color ?? '#2563EB'}
|
||||||
qrCodeDataUrl={qrCodeDataUrl}
|
qrCodeDataUrl={qrCodeDataUrl}
|
||||||
logoDataUrl={form.logo_data_url ?? form.logo_url ?? null}
|
logoDataUrl={form.logo_data_url ?? form.logo_url ?? null}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function DesignerCanvas({
|
|||||||
fabricCanvasRef.current = null;
|
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) {
|
||||||
if (upperEl.__canvas === canvas) {
|
if (upperEl.__canvas === canvas) {
|
||||||
delete upperEl.__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) {
|
||||||
if (lowerEl.__canvas === canvas) {
|
if (lowerEl.__canvas === canvas) {
|
||||||
delete lowerEl.__canvas;
|
delete lowerEl.__canvas;
|
||||||
@@ -140,6 +140,9 @@ export function DesignerCanvas({
|
|||||||
selection: !readOnly,
|
selection: !readOnly,
|
||||||
preserveObjectStacking: true,
|
preserveObjectStacking: true,
|
||||||
perPixelTargetFind: true,
|
perPixelTargetFind: true,
|
||||||
|
transparentCorners: true,
|
||||||
|
cornerSize: 8,
|
||||||
|
padding: readOnly ? 0 : 10, // Default padding for text/objects, 0 for readonly
|
||||||
});
|
});
|
||||||
|
|
||||||
fabricCanvasRef.current = canvas;
|
fabricCanvasRef.current = canvas;
|
||||||
@@ -149,7 +152,7 @@ export function DesignerCanvas({
|
|||||||
(window as unknown as Record<string, unknown>).__inviteCanvas = canvas;
|
(window as unknown as Record<string, unknown>).__inviteCanvas = canvas;
|
||||||
(element as unknown as { __fabricCanvas?: fabric.Canvas }).__fabricCanvas = canvas;
|
(element as unknown as { __fabricCanvas?: fabric.Canvas }).__fabricCanvas = canvas;
|
||||||
if (containerRef.current) {
|
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;
|
wrapper.__fabricCanvas = canvas;
|
||||||
Object.defineProperty(wrapper, '__canvas', {
|
Object.defineProperty(wrapper, '__canvas', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -214,33 +217,78 @@ export function DesignerCanvas({
|
|||||||
onSelect(null);
|
onSelect(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleObjectModified = (event: fabric.IEvent<fabric.Object>) => {
|
const handleObjectModified = (e: any) => {
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = event.target as FabricObjectWithId | undefined;
|
const target = e.target as FabricObjectWithId | undefined;
|
||||||
if (!target || typeof target.elementId !== 'string') {
|
if (!target || typeof target.elementId !== 'string') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const elementId = target.elementId;
|
const elementId = target.elementId;
|
||||||
|
|
||||||
const bounds = target.getBoundingRect(true, true);
|
const bounds = target.getBoundingRect();
|
||||||
const nextPatch: Partial<LayoutElement> = {
|
let nextPatch: Partial<LayoutElement> = {
|
||||||
x: clamp(Math.round(bounds.left ?? 0), 0, CANVAS_WIDTH),
|
x: clamp(Math.round(bounds.left ?? 0), 20, CANVAS_WIDTH - 20),
|
||||||
y: clamp(Math.round(bounds.top ?? 0), 0, CANVAS_HEIGHT),
|
y: clamp(Math.round(bounds.top ?? 0), 20, CANVAS_HEIGHT - 20),
|
||||||
width: clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH),
|
|
||||||
height: clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
target.set({
|
// Manual collision check: Calculate overlap and push vertically
|
||||||
scaleX: 1,
|
const otherObjects = canvas.getObjects().filter(obj => obj !== target && (obj as FabricObjectWithId).elementId);
|
||||||
scaleY: 1,
|
otherObjects.forEach(other => {
|
||||||
left: nextPatch.x,
|
const otherBounds = other.getBoundingRect();
|
||||||
top: nextPatch.y,
|
const overlapX = Math.max(0, Math.min(bounds.left + bounds.width, otherBounds.left + otherBounds.width) - Math.max(bounds.left, otherBounds.left));
|
||||||
width: nextPatch.width,
|
const overlapY = Math.max(0, Math.min(bounds.top + bounds.height, otherBounds.top + otherBounds.height) - Math.max(bounds.top, otherBounds.top));
|
||||||
height: nextPatch.height,
|
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);
|
onChange(elementId, nextPatch);
|
||||||
canvas.requestRenderAll();
|
canvas.requestRenderAll();
|
||||||
};
|
};
|
||||||
@@ -348,39 +396,15 @@ export function DesignerCanvas({
|
|||||||
|
|
||||||
const normalizedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
const normalizedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||||
|
|
||||||
canvas.setZoom(normalizedScale);
|
canvas.viewportTransform = [normalizedScale, 0, 0, normalizedScale, 0, 0];
|
||||||
|
canvas.setDimensions({
|
||||||
const cssWidth = CANVAS_WIDTH * normalizedScale;
|
width: CANVAS_WIDTH * normalizedScale,
|
||||||
const cssHeight = CANVAS_HEIGHT * normalizedScale;
|
height: 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.requestRenderAll();
|
canvas.requestRenderAll();
|
||||||
|
canvas.calcViewportBoundaries();
|
||||||
|
|
||||||
|
console.log('Zoom applied:', normalizedScale, 'Transform:', canvas.viewportTransform);
|
||||||
}, [scale]);
|
}, [scale]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -472,7 +496,7 @@ export async function renderFabricLayout(
|
|||||||
if (typeof object.setCoords === 'function') {
|
if (typeof object.setCoords === 'function') {
|
||||||
object.setCoords();
|
object.setCoords();
|
||||||
}
|
}
|
||||||
const bounds = object.getBoundingRect(true, true);
|
const bounds = object.getBoundingRect();
|
||||||
console.warn('[Invites][Fabric] added object', {
|
console.warn('[Invites][Fabric] added object', {
|
||||||
elementId: (object as FabricObjectWithId).elementId,
|
elementId: (object as FabricObjectWithId).elementId,
|
||||||
left: bounds.left,
|
left: bounds.left,
|
||||||
@@ -495,7 +519,7 @@ export function applyBackground(
|
|||||||
color: string,
|
color: string,
|
||||||
gradient: { angle?: number; stops?: string[] } | null,
|
gradient: { angle?: number; stops?: string[] } | null,
|
||||||
): void {
|
): void {
|
||||||
let background: string | fabric.Gradient = color;
|
let background: string | fabric.Gradient<'linear'> = color;
|
||||||
|
|
||||||
if (gradient?.stops?.length) {
|
if (gradient?.stops?.length) {
|
||||||
const angle = ((gradient.angle ?? 180) * Math.PI) / 180;
|
const angle = ((gradient.angle ?? 180) * Math.PI) / 180;
|
||||||
@@ -512,15 +536,15 @@ export function applyBackground(
|
|||||||
x2: halfWidth + x * halfWidth,
|
x2: halfWidth + x * halfWidth,
|
||||||
y2: halfHeight + y * halfHeight,
|
y2: halfHeight + y * halfHeight,
|
||||||
},
|
},
|
||||||
colorStops: gradient.stops.map((stop, index) => ({
|
colorStops: gradient.stops!.map((stop, index) => ({
|
||||||
offset: gradient.stops.length === 1 ? 0 : index / (gradient.stops.length - 1),
|
offset: gradient.stops!.length === 1 ? 0 : index / (gradient.stops!.length - 1),
|
||||||
color: stop,
|
color: stop,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvasWithBackgroundFn = canvas as fabric.Canvas & {
|
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') {
|
if (typeof canvasWithBackgroundFn.setBackgroundColor === 'function') {
|
||||||
@@ -578,9 +602,13 @@ export async function createFabricObject({
|
|||||||
...baseConfig,
|
...baseConfig,
|
||||||
width: element.width,
|
width: element.width,
|
||||||
height: element.height,
|
height: element.height,
|
||||||
fontSize: element.fontSize ?? 26,
|
fontSize: element.fontSize ?? 36,
|
||||||
fill: textColor,
|
fill: textColor,
|
||||||
|
fontFamily: element.fontFamily ?? 'Lora',
|
||||||
textAlign: mapTextAlign(element.align),
|
textAlign: mapTextAlign(element.align),
|
||||||
|
lineHeight: element.lineHeight ?? 1.5,
|
||||||
|
charSpacing: element.letterSpacing ?? 0.5,
|
||||||
|
padding: 12, // Enhanced padding for better readability
|
||||||
});
|
});
|
||||||
case 'link':
|
case 'link':
|
||||||
return new fabric.Textbox(element.content ?? '', {
|
return new fabric.Textbox(element.content ?? '', {
|
||||||
@@ -589,8 +617,12 @@ export async function createFabricObject({
|
|||||||
height: element.height,
|
height: element.height,
|
||||||
fontSize: element.fontSize ?? 24,
|
fontSize: element.fontSize ?? 24,
|
||||||
fill: accentColor,
|
fill: accentColor,
|
||||||
|
fontFamily: element.fontFamily ?? 'Montserrat',
|
||||||
underline: true,
|
underline: true,
|
||||||
textAlign: mapTextAlign(element.align),
|
textAlign: mapTextAlign(element.align),
|
||||||
|
lineHeight: element.lineHeight ?? 1.5,
|
||||||
|
charSpacing: element.letterSpacing ?? 0.5,
|
||||||
|
padding: 10,
|
||||||
});
|
});
|
||||||
case 'badge':
|
case 'badge':
|
||||||
return createTextBadge({
|
return createTextBadge({
|
||||||
@@ -601,6 +633,8 @@ export async function createFabricObject({
|
|||||||
backgroundColor: badgeColor,
|
backgroundColor: badgeColor,
|
||||||
textColor: '#ffffff',
|
textColor: '#ffffff',
|
||||||
fontSize: element.fontSize ?? 22,
|
fontSize: element.fontSize ?? 22,
|
||||||
|
lineHeight: element.lineHeight ?? 1.5,
|
||||||
|
letterSpacing: element.letterSpacing ?? 0.5,
|
||||||
});
|
});
|
||||||
case 'cta':
|
case 'cta':
|
||||||
return createTextBadge({
|
return createTextBadge({
|
||||||
@@ -612,6 +646,8 @@ export async function createFabricObject({
|
|||||||
textColor: '#ffffff',
|
textColor: '#ffffff',
|
||||||
fontSize: element.fontSize ?? 24,
|
fontSize: element.fontSize ?? 24,
|
||||||
cornerRadius: 18,
|
cornerRadius: 18,
|
||||||
|
lineHeight: element.lineHeight ?? 1.5,
|
||||||
|
letterSpacing: element.letterSpacing ?? 0.5,
|
||||||
});
|
});
|
||||||
case 'logo':
|
case 'logo':
|
||||||
if (logoDataUrl) {
|
if (logoDataUrl) {
|
||||||
@@ -627,15 +663,28 @@ export async function createFabricObject({
|
|||||||
qrCodeDataUrl.length,
|
qrCodeDataUrl.length,
|
||||||
qrCodeDataUrl.slice(0, 48),
|
qrCodeDataUrl.slice(0, 48),
|
||||||
);
|
);
|
||||||
return loadImageObject(qrCodeDataUrl, element, baseConfig, {
|
const qrImage = await loadImageObject(qrCodeDataUrl, element, baseConfig, {
|
||||||
shadow: 'rgba(15,23,42,0.25)',
|
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({
|
return new fabric.Rect({
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
width: element.width,
|
width: element.width,
|
||||||
height: element.height,
|
height: element.height,
|
||||||
fill: secondaryColor,
|
fill: 'white',
|
||||||
|
stroke: secondaryColor,
|
||||||
|
strokeWidth: 2,
|
||||||
rx: 20,
|
rx: 20,
|
||||||
ry: 20,
|
ry: 20,
|
||||||
});
|
});
|
||||||
@@ -646,6 +695,7 @@ export async function createFabricObject({
|
|||||||
height: element.height,
|
height: element.height,
|
||||||
fontSize: element.fontSize ?? 24,
|
fontSize: element.fontSize ?? 24,
|
||||||
fill: secondaryColor,
|
fill: secondaryColor,
|
||||||
|
fontFamily: element.fontFamily ?? 'Lora',
|
||||||
textAlign: mapTextAlign(element.align),
|
textAlign: mapTextAlign(element.align),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -660,6 +710,8 @@ export function createTextBadge({
|
|||||||
textColor,
|
textColor,
|
||||||
fontSize,
|
fontSize,
|
||||||
cornerRadius = 12,
|
cornerRadius = 12,
|
||||||
|
lineHeight = 1.5,
|
||||||
|
letterSpacing = 0.5,
|
||||||
}: {
|
}: {
|
||||||
baseConfig: FabricObjectWithId;
|
baseConfig: FabricObjectWithId;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -669,6 +721,8 @@ export function createTextBadge({
|
|||||||
textColor: string;
|
textColor: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
cornerRadius?: number;
|
cornerRadius?: number;
|
||||||
|
lineHeight?: number;
|
||||||
|
letterSpacing?: number;
|
||||||
}): fabric.Group {
|
}): fabric.Group {
|
||||||
const rect = new fabric.Rect({
|
const rect = new fabric.Rect({
|
||||||
width,
|
width,
|
||||||
@@ -688,8 +742,11 @@ export function createTextBadge({
|
|||||||
top: height / 2,
|
top: height / 2,
|
||||||
fontSize,
|
fontSize,
|
||||||
fill: textColor,
|
fill: textColor,
|
||||||
|
fontFamily: 'Montserrat',
|
||||||
originY: 'center',
|
originY: 'center',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
lineHeight,
|
||||||
|
charSpacing: letterSpacing,
|
||||||
selectable: false,
|
selectable: false,
|
||||||
evented: false,
|
evented: false,
|
||||||
});
|
});
|
||||||
@@ -707,7 +764,7 @@ export async function loadImageObject(
|
|||||||
source: string,
|
source: string,
|
||||||
element: LayoutElement,
|
element: LayoutElement,
|
||||||
baseConfig: FabricObjectWithId,
|
baseConfig: FabricObjectWithId,
|
||||||
options?: { objectFit?: 'contain' | 'cover'; shadow?: string },
|
options?: { objectFit?: 'contain' | 'cover'; shadow?: string; padding?: number },
|
||||||
): Promise<fabric.Object | null> {
|
): Promise<fabric.Object | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
@@ -741,6 +798,7 @@ export async function loadImageObject(
|
|||||||
height: element.height,
|
height: element.height,
|
||||||
scaleX,
|
scaleX,
|
||||||
scaleY,
|
scaleY,
|
||||||
|
padding: options?.padding ?? 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options?.shadow) {
|
if (options?.shadow) {
|
||||||
@@ -779,23 +837,18 @@ export async function loadImageObject(
|
|||||||
imageElement.onerror = onError;
|
imageElement.onerror = onError;
|
||||||
imageElement.src = source;
|
imageElement.src = source;
|
||||||
} else {
|
} else {
|
||||||
fabric.util.loadImage(
|
// Use direct Image constructor approach for better compatibility
|
||||||
source,
|
const img = new Image();
|
||||||
(img) => {
|
img.onload = () => {
|
||||||
if (!img) {
|
console.debug('[Invites][Fabric] image loaded', {
|
||||||
onError();
|
source: source.slice(0, 48),
|
||||||
return;
|
width: img.width,
|
||||||
}
|
height: img.height,
|
||||||
console.debug('[Invites][Fabric] image loaded', {
|
});
|
||||||
source: source.slice(0, 48),
|
onImageLoaded(img);
|
||||||
width: (img as HTMLImageElement).width,
|
};
|
||||||
height: (img as HTMLImageElement).height,
|
img.onerror = onError;
|
||||||
});
|
img.src = source;
|
||||||
onImageLoaded(img);
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
'anonymous',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onError(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_WIDTH = 1240;
|
||||||
export const CANVAS_HEIGHT = 1754;
|
export const CANVAS_HEIGHT = 1754;
|
||||||
@@ -23,6 +24,8 @@ export interface LayoutElement {
|
|||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
scaleX?: number;
|
||||||
|
scaleY?: number;
|
||||||
rotation?: number;
|
rotation?: number;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
align?: LayoutTextAlign;
|
align?: LayoutTextAlign;
|
||||||
@@ -46,6 +49,10 @@ type LayoutPresetElement = {
|
|||||||
height?: PresetValue;
|
height?: PresetValue;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
align?: LayoutTextAlign;
|
align?: LayoutTextAlign;
|
||||||
|
fontFamily?: string;
|
||||||
|
lineHeight?: number;
|
||||||
|
letterSpacing?: number;
|
||||||
|
rotation?: number;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
initial?: boolean;
|
initial?: boolean;
|
||||||
};
|
};
|
||||||
@@ -65,6 +72,8 @@ export interface LayoutElementPayload {
|
|||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
scale_x?: number;
|
||||||
|
scale_y?: number;
|
||||||
rotation?: number;
|
rotation?: number;
|
||||||
font_size?: number;
|
font_size?: number;
|
||||||
align?: LayoutTextAlign;
|
align?: LayoutTextAlign;
|
||||||
@@ -110,10 +119,10 @@ export type QrLayoutCustomization = {
|
|||||||
elements?: LayoutElementPayload[];
|
elements?: LayoutElementPayload[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MIN_QR_SIZE = 240;
|
export const MIN_QR_SIZE = 400;
|
||||||
export const MAX_QR_SIZE = 720;
|
export const MAX_QR_SIZE = 800;
|
||||||
export const MIN_TEXT_WIDTH = 160;
|
export const MIN_TEXT_WIDTH = 250;
|
||||||
export const MIN_TEXT_HEIGHT = 80;
|
export const MIN_TEXT_HEIGHT = 120;
|
||||||
|
|
||||||
export function clamp(value: number, min: number, max: number): number {
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
if (Number.isNaN(value)) {
|
if (Number.isNaN(value)) {
|
||||||
@@ -125,378 +134,322 @@ export function clamp(value: number, min: number, max: number): number {
|
|||||||
export function clampElement(element: LayoutElement): LayoutElement {
|
export function clampElement(element: LayoutElement): LayoutElement {
|
||||||
return {
|
return {
|
||||||
...element,
|
...element,
|
||||||
x: clamp(element.x, 0, CANVAS_WIDTH - element.width),
|
x: clamp(element.x, 20, CANVAS_WIDTH - element.width - 20),
|
||||||
y: clamp(element.y, 0, CANVAS_HEIGHT - element.height),
|
y: clamp(element.y, 20, CANVAS_HEIGHT - element.height - 20),
|
||||||
width: clamp(element.width, 40, CANVAS_WIDTH),
|
width: clamp(element.width, 40, CANVAS_WIDTH - 40),
|
||||||
height: clamp(element.height, 40, CANVAS_HEIGHT),
|
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 }> = {
|
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' },
|
headline: { width: 900, height: 200, fontSize: 90, align: 'left' },
|
||||||
subtitle: { width: 760, height: 170, fontSize: 40, align: 'left' },
|
subtitle: { width: 760, height: 160, fontSize: 44, align: 'left' },
|
||||||
description: { width: 920, height: 340, fontSize: 32, align: 'left' },
|
description: { width: 920, height: 320, fontSize: 36, align: 'left' },
|
||||||
link: { width: 520, height: 130, fontSize: 30, align: 'center' },
|
link: { width: 520, height: 130, fontSize: 30, align: 'center' },
|
||||||
badge: { width: 420, height: 100, fontSize: 26, align: 'center' },
|
badge: { width: 420, height: 100, fontSize: 26, align: 'center' },
|
||||||
logo: { width: 320, height: 220, align: 'center' },
|
logo: { width: 320, height: 220, align: 'center' },
|
||||||
cta: { width: 520, height: 130, fontSize: 28, 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' },
|
text: { width: 720, height: 260, fontSize: 28, align: 'left' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PRESET: LayoutPreset = [
|
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',
|
id: 'headline',
|
||||||
type: 'headline',
|
type: 'headline',
|
||||||
x: 140,
|
x: (c) => (c.canvasWidth - 1000) / 2,
|
||||||
y: 300,
|
y: 350,
|
||||||
width: (context) => context.canvasWidth - 280,
|
width: 1000,
|
||||||
height: 240,
|
height: 220,
|
||||||
fontSize: 84,
|
fontSize: 110,
|
||||||
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,
|
|
||||||
align: 'center',
|
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 = [
|
const evergreenVowsPreset: LayoutPreset = [
|
||||||
{ id: 'logo', type: 'logo', x: 160, y: 140, width: 340, height: 240 },
|
// Elegant, linksbündig mit verbesserter Balance
|
||||||
{ id: 'badge', type: 'badge', x: 540, y: 160, width: 420, height: 100, align: 'center', fontSize: 28 },
|
{ 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',
|
id: 'headline',
|
||||||
type: 'headline',
|
type: 'headline',
|
||||||
x: 160,
|
x: 120,
|
||||||
y: 360,
|
y: 280,
|
||||||
width: (context) => context.canvasWidth - 320,
|
width: (context) => context.canvasWidth - 240,
|
||||||
height: 250,
|
height: 200,
|
||||||
fontSize: 86,
|
fontSize: 95,
|
||||||
align: 'left',
|
align: 'left',
|
||||||
|
fontFamily: 'Playfair Display',
|
||||||
|
lineHeight: 1.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'subtitle',
|
id: 'subtitle',
|
||||||
type: 'subtitle',
|
type: 'subtitle',
|
||||||
x: 160,
|
x: 120,
|
||||||
y: 630,
|
y: 490,
|
||||||
width: (context) => context.canvasWidth - 320,
|
width: 680,
|
||||||
height: 180,
|
height: 140,
|
||||||
fontSize: 42,
|
fontSize: 40,
|
||||||
align: 'left',
|
align: 'left',
|
||||||
|
fontFamily: 'Montserrat',
|
||||||
|
lineHeight: 1.4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'description',
|
id: 'description',
|
||||||
type: 'description',
|
type: 'description',
|
||||||
x: 160,
|
x: 120,
|
||||||
y: 840,
|
y: 640,
|
||||||
width: (context) => context.canvasWidth - 320,
|
width: 680,
|
||||||
height: 360,
|
height: 220,
|
||||||
fontSize: 34,
|
fontSize: 32,
|
||||||
align: 'left',
|
align: 'left',
|
||||||
|
fontFamily: 'Lora',
|
||||||
|
lineHeight: 1.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'qr',
|
id: 'qr',
|
||||||
type: 'qr',
|
type: 'qr',
|
||||||
x: (context) => context.canvasWidth - Math.min(context.qrSize, 640) - 200,
|
x: (c) => c.canvasWidth - 440 - 120,
|
||||||
y: 420,
|
y: 920,
|
||||||
width: (context) => Math.min(context.qrSize, 640),
|
width: (c) => Math.min(c.qrSize, 440),
|
||||||
height: (context) => Math.min(context.qrSize, 640),
|
height: (c) => Math.min(c.qrSize, 440),
|
||||||
},
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
|
{ 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 = [
|
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',
|
id: 'headline',
|
||||||
type: 'headline',
|
type: 'headline',
|
||||||
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 220) / 2,
|
x: (c) => (c.canvasWidth - 1100) / 2,
|
||||||
y: 340,
|
y: 240,
|
||||||
width: (context) => context.canvasWidth - 220,
|
width: 1100,
|
||||||
height: 260,
|
height: 220,
|
||||||
fontSize: 90,
|
fontSize: 105,
|
||||||
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,
|
|
||||||
align: 'center',
|
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',
|
id: 'qr',
|
||||||
type: 'qr',
|
type: 'qr',
|
||||||
x: (context) => (context.canvasWidth - Math.min(context.qrSize, 640)) / 2,
|
x: (c) => (c.canvasWidth - 480) / 2,
|
||||||
y: 880,
|
y: 880,
|
||||||
width: (context) => Math.min(context.qrSize, 640),
|
width: (c) => Math.min(c.qrSize, 480),
|
||||||
height: (context) => Math.min(context.qrSize, 640),
|
height: (c) => Math.min(c.qrSize, 480),
|
||||||
},
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
|
{ 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 = [
|
const gardenBrunchPreset: LayoutPreset = [
|
||||||
{ id: 'badge', type: 'badge', x: 180, y: 180, width: 500, height: 110, align: 'center', fontSize: 30 },
|
// Verspielt, asymmetrisch, aber ausbalanciert
|
||||||
{ id: 'headline', type: 'headline', x: 180, y: 340, width: (context) => context.canvasWidth - 360, height: 260, fontSize: 86, align: 'left' },
|
{ id: 'badge', type: 'badge', x: 120, y: 120, width: 500, height: 90, align: 'left', fontSize: 28, fontFamily: 'Montserrat' },
|
||||||
{ id: 'description', type: 'description', x: 180, y: 630, width: (context) => context.canvasWidth - 360, height: 360, fontSize: 34, align: 'left' },
|
{ 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',
|
id: 'qr',
|
||||||
type: 'qr',
|
type: 'qr',
|
||||||
x: 180,
|
x: 120,
|
||||||
y: 1000,
|
y: 880,
|
||||||
width: (context) => Math.min(context.qrSize, 660),
|
width: (c) => Math.min(c.qrSize, 460),
|
||||||
height: (context) => Math.min(context.qrSize, 660),
|
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',
|
id: 'description',
|
||||||
type: 'link',
|
type: 'description',
|
||||||
x: 180,
|
x: (c) => c.canvasWidth - 600 - 120,
|
||||||
y: (context) => 1060 + Math.min(context.qrSize, 660),
|
y: 620,
|
||||||
width: 520,
|
width: 600,
|
||||||
height: 140,
|
height: 400,
|
||||||
align: 'center',
|
fontSize: 32,
|
||||||
|
align: 'left',
|
||||||
|
fontFamily: 'Lora',
|
||||||
|
lineHeight: 1.6,
|
||||||
},
|
},
|
||||||
{
|
{ id: 'link', type: 'link', x: 120, y: (c) => 880 + Math.min(c.qrSize, 460) + 160, width: 460, height: 80, align: 'center', fontSize: 24 },
|
||||||
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' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const sparklerSoireePreset: LayoutPreset = [
|
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',
|
id: 'headline',
|
||||||
type: 'headline',
|
type: 'headline',
|
||||||
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 220) / 2,
|
x: (c) => (c.canvasWidth - 1000) / 2,
|
||||||
y: 360,
|
y: 240,
|
||||||
width: (context) => context.canvasWidth - 220,
|
width: 1000,
|
||||||
height: 280,
|
height: 220,
|
||||||
fontSize: 94,
|
fontSize: 100,
|
||||||
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,
|
|
||||||
align: 'center',
|
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',
|
id: 'qr',
|
||||||
type: 'qr',
|
type: 'qr',
|
||||||
x: (context) => (context.canvasWidth - Math.min(context.qrSize, 680)) / 2,
|
x: (c) => (c.canvasWidth - 480) / 2,
|
||||||
y: 1200,
|
y: 880,
|
||||||
width: (context) => Math.min(context.qrSize, 680),
|
width: (c) => Math.min(c.qrSize, 480),
|
||||||
height: (context) => Math.min(context.qrSize, 680),
|
height: (c) => Math.min(c.qrSize, 480),
|
||||||
},
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
|
{ 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 = [
|
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',
|
id: 'headline',
|
||||||
type: 'headline',
|
type: 'headline',
|
||||||
x: 180,
|
x: (c) => (c.canvasWidth - 1000) / 2,
|
||||||
y: 380,
|
y: 350,
|
||||||
width: (context) => context.canvasWidth - 360,
|
width: 1000,
|
||||||
height: 260,
|
height: 220,
|
||||||
fontSize: 90,
|
fontSize: 110,
|
||||||
align: 'left',
|
align: 'center',
|
||||||
|
fontFamily: 'Playfair Display',
|
||||||
|
lineHeight: 1.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'subtitle',
|
id: 'subtitle',
|
||||||
type: 'subtitle',
|
type: 'subtitle',
|
||||||
x: 180,
|
x: (c) => (c.canvasWidth - 800) / 2,
|
||||||
y: 660,
|
y: 580,
|
||||||
width: (context) => context.canvasWidth - 360,
|
width: 800,
|
||||||
height: 200,
|
height: 120,
|
||||||
fontSize: 46,
|
fontSize: 42,
|
||||||
align: 'left',
|
align: 'center',
|
||||||
|
fontFamily: 'Montserrat',
|
||||||
|
lineHeight: 1.4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'description',
|
id: 'description',
|
||||||
type: 'description',
|
type: 'description',
|
||||||
x: 180,
|
x: (c) => (c.canvasWidth - 900) / 2,
|
||||||
y: 910,
|
y: 720,
|
||||||
width: (context) => context.canvasWidth - 360,
|
width: 900,
|
||||||
height: 360,
|
height: 180,
|
||||||
fontSize: 34,
|
fontSize: 34,
|
||||||
align: 'left',
|
align: 'center',
|
||||||
|
fontFamily: 'Lora',
|
||||||
|
lineHeight: 1.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'qr',
|
id: 'qr',
|
||||||
type: 'qr',
|
type: 'qr',
|
||||||
x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 200,
|
x: (c) => (c.canvasWidth - 500) / 2,
|
||||||
y: 460,
|
y: 940,
|
||||||
width: (context) => Math.min(context.qrSize, 680),
|
width: (c) => Math.min(c.qrSize, 500),
|
||||||
height: (context) => Math.min(context.qrSize, 680),
|
height: (c) => Math.min(c.qrSize, 500),
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'link',
|
|
||||||
type: 'link',
|
|
||||||
x: (context) => context.canvasWidth - 560,
|
|
||||||
y: (context) => 520 + Math.min(context.qrSize, 680),
|
|
||||||
width: 520,
|
|
||||||
height: 140,
|
|
||||||
align: 'center',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cta',
|
id: 'cta',
|
||||||
type: 'cta',
|
type: 'cta',
|
||||||
x: (context) => context.canvasWidth - 560,
|
x: (c) => (c.canvasWidth - 600) / 2,
|
||||||
y: (context) => 560 + Math.min(context.qrSize, 680) + 200,
|
y: (c) => 940 + Math.min(c.qrSize, 500) + 40,
|
||||||
width: 520,
|
width: 600,
|
||||||
height: 140,
|
height: 100,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
fontSize: 32,
|
||||||
|
fontFamily: 'Montserrat',
|
||||||
|
lineHeight: 1.4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'text-strip',
|
id: 'link',
|
||||||
type: 'text',
|
type: 'link',
|
||||||
x: 180,
|
x: (c) => (c.canvasWidth - 700) / 2,
|
||||||
y: 1220,
|
y: (c) => 940 + Math.min(c.qrSize, 500) + 160,
|
||||||
width: (context) => context.canvasWidth - 360,
|
width: 700,
|
||||||
height: 360,
|
height: 80,
|
||||||
fontSize: 30,
|
align: 'center',
|
||||||
align: 'left',
|
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> = {
|
const LAYOUT_PRESETS: Record<string, LayoutPreset> = {
|
||||||
'default': DEFAULT_PRESET,
|
'default': DEFAULT_PRESET,
|
||||||
'evergreen-vows': evergreenVowsPreset,
|
'evergreen-vows': evergreenVowsPreset,
|
||||||
'midnight-gala': midnightGalaPreset,
|
'midnight-gala': midnightGalaPreset,
|
||||||
'garden-brunch': gardenBrunchPreset,
|
'garden-brunch': gardenBrunchPreset,
|
||||||
'sparkler-soiree': sparklerSoireePreset,
|
'sparkler-soiree': sparklerSoireePreset,
|
||||||
'confetti-bash': confettiBashPreset,
|
'confetti-bash': confettiBashPreset,
|
||||||
|
'balanced-modern': balancedModernPreset, // New preset: QR right, text left, logo top
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolvePresetValue(value: PresetValue | undefined, context: LayoutPresetContext, fallback: number): number {
|
function resolvePresetValue(value: PresetValue | undefined, context: LayoutPresetContext, fallback: number): number {
|
||||||
@@ -554,13 +507,14 @@ export function buildDefaultElements(
|
|||||||
height: resolvePresetValue(config.height, context, heightFallback),
|
height: resolvePresetValue(config.height, context, heightFallback),
|
||||||
fontSize: config.fontSize ?? typeStyle.fontSize,
|
fontSize: config.fontSize ?? typeStyle.fontSize,
|
||||||
align: config.align ?? typeStyle.align ?? 'left',
|
align: config.align ?? typeStyle.align ?? 'left',
|
||||||
|
fontFamily: config.fontFamily ?? 'Lora',
|
||||||
content: null,
|
content: null,
|
||||||
locked: config.locked ?? typeStyle.locked ?? false,
|
locked: config.locked ?? typeStyle.locked ?? false,
|
||||||
initial: config.initial ?? true,
|
initial: config.initial ?? true,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.type === 'description') {
|
if (config.type === 'description') {
|
||||||
element.lineHeight = 1.4;
|
element.lineHeight = 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (config.id) {
|
switch (config.id) {
|
||||||
@@ -622,6 +576,8 @@ export function payloadToElements(payload?: LayoutElementPayload[] | null): Layo
|
|||||||
y: Number(entry.y ?? 0),
|
y: Number(entry.y ?? 0),
|
||||||
width: Number(entry.width ?? 100),
|
width: Number(entry.width ?? 100),
|
||||||
height: Number(entry.height ?? 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,
|
rotation: typeof entry.rotation === 'number' ? entry.rotation : 0,
|
||||||
fontSize: typeof entry.font_size === 'number' ? entry.font_size : undefined,
|
fontSize: typeof entry.font_size === 'number' ? entry.font_size : undefined,
|
||||||
align: entry.align ?? 'left',
|
align: entry.align ?? 'left',
|
||||||
@@ -644,6 +600,8 @@ export function elementsToPayload(elements: LayoutElement[]): LayoutElementPaylo
|
|||||||
y: element.y,
|
y: element.y,
|
||||||
width: element.width,
|
width: element.width,
|
||||||
height: element.height,
|
height: element.height,
|
||||||
|
scale_x: element.scaleX ?? 1,
|
||||||
|
scale_y: element.scaleY ?? 1,
|
||||||
rotation: element.rotation ?? 0,
|
rotation: element.rotation ?? 0,
|
||||||
font_size: element.fontSize,
|
font_size: element.fontSize,
|
||||||
align: element.align,
|
align: element.align,
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ export const PaymentStep: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{status !== 'idle' && (
|
{status !== 'idle' && (
|
||||||
<Alert variant={status === 'error' ? 'destructive' : 'secondary'}>
|
<Alert variant={status === 'error' ? 'destructive' : 'default'}>
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
{status === 'processing'
|
{status === 'processing'
|
||||||
? t('checkout.payment_step.status_processing_title')
|
? t('checkout.payment_step.status_processing_title')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Monitoring\PackageLimitMetrics;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
|
||||||
@@ -18,3 +19,17 @@ Artisan::command('storage:archive-pending', function () {
|
|||||||
Artisan::command('storage:check-upload-queues', function () {
|
Artisan::command('storage:check-upload-queues', function () {
|
||||||
$this->comment('Upload queue health placeholder – verify upload pipelines and report issues.');
|
$this->comment('Upload queue health placeholder – verify upload pipelines and report issues.');
|
||||||
})->purpose('Check upload queues for stalled or failed jobs and alert admins');
|
})->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