geschenkgutscheine implementiert ("Paket verschenken"). Neuer Upload-Provider: Sparkbooth.
This commit is contained in:
@@ -149,13 +149,26 @@ export type PhotoboothStatusMetrics = {
|
||||
last_upload_at?: string | null;
|
||||
};
|
||||
|
||||
export type SparkboothStatus = {
|
||||
enabled: boolean;
|
||||
status: string | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
expires_at: string | null;
|
||||
upload_url: string | null;
|
||||
response_format: 'json' | 'xml';
|
||||
metrics?: PhotoboothStatusMetrics | null;
|
||||
};
|
||||
|
||||
export type PhotoboothStatus = {
|
||||
mode: 'ftp' | 'sparkbooth';
|
||||
enabled: boolean;
|
||||
status: string | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
path: string | null;
|
||||
ftp_url: string | null;
|
||||
upload_url: string | null;
|
||||
expires_at: string | null;
|
||||
rate_limit_per_minute: number;
|
||||
ftp: {
|
||||
@@ -163,6 +176,7 @@ export type PhotoboothStatus = {
|
||||
port: number;
|
||||
require_ftps: boolean;
|
||||
};
|
||||
sparkbooth?: SparkboothStatus | null;
|
||||
metrics?: PhotoboothStatusMetrics | null;
|
||||
};
|
||||
|
||||
@@ -1215,13 +1229,34 @@ function normalizePhotoboothStatus(payload: JsonValue): PhotoboothStatus {
|
||||
};
|
||||
}
|
||||
|
||||
const sparkRaw = (payload.sparkbooth ?? null) as JsonValue | null;
|
||||
let sparkbooth: SparkboothStatus | null = null;
|
||||
|
||||
if (sparkRaw && typeof sparkRaw === 'object') {
|
||||
sparkbooth = {
|
||||
enabled: Boolean((sparkRaw as JsonValue).enabled),
|
||||
status: typeof (sparkRaw as JsonValue).status === 'string' ? (sparkRaw as JsonValue).status : null,
|
||||
username: typeof (sparkRaw as JsonValue).username === 'string' ? (sparkRaw as JsonValue).username : null,
|
||||
password: typeof (sparkRaw as JsonValue).password === 'string' ? (sparkRaw as JsonValue).password : null,
|
||||
expires_at: typeof (sparkRaw as JsonValue).expires_at === 'string' ? (sparkRaw as JsonValue).expires_at : null,
|
||||
upload_url: typeof (sparkRaw as JsonValue).upload_url === 'string' ? (sparkRaw as JsonValue).upload_url : null,
|
||||
response_format:
|
||||
(sparkRaw as JsonValue).response_format === 'xml' ? 'xml' : 'json',
|
||||
metrics: normalizePhotoboothMetrics((sparkRaw as JsonValue).metrics),
|
||||
};
|
||||
}
|
||||
|
||||
const modeValue = typeof payload.mode === 'string' && payload.mode === 'sparkbooth' ? 'sparkbooth' : 'ftp';
|
||||
|
||||
return {
|
||||
mode: modeValue,
|
||||
enabled: Boolean(payload.enabled),
|
||||
status: typeof payload.status === 'string' ? payload.status : null,
|
||||
username: typeof payload.username === 'string' ? payload.username : null,
|
||||
password: typeof payload.password === 'string' ? payload.password : null,
|
||||
path: typeof payload.path === 'string' ? payload.path : null,
|
||||
ftp_url: typeof payload.ftp_url === 'string' ? payload.ftp_url : null,
|
||||
upload_url: typeof payload.upload_url === 'string' ? payload.upload_url : null,
|
||||
expires_at: typeof payload.expires_at === 'string' ? payload.expires_at : null,
|
||||
rate_limit_per_minute: Number(payload.rate_limit_per_minute ?? ftp.rate_limit_per_minute ?? 0),
|
||||
ftp: {
|
||||
@@ -1229,10 +1264,34 @@ function normalizePhotoboothStatus(payload: JsonValue): PhotoboothStatus {
|
||||
port: Number(ftp.port ?? payload.ftp_port ?? 0) || 0,
|
||||
require_ftps: Boolean(ftp.require_ftps ?? payload.require_ftps),
|
||||
},
|
||||
sparkbooth,
|
||||
metrics,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePhotoboothMetrics(raw: JsonValue | null | undefined): PhotoboothStatusMetrics | null {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = raw as Record<string, JsonValue>;
|
||||
const readNumber = (key: string): number | null => {
|
||||
const value = record[key];
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
return {
|
||||
uploads_last_hour: readNumber('uploads_last_hour') ?? readNumber('last_hour') ?? readNumber('hour'),
|
||||
uploads_today: readNumber('uploads_today') ?? readNumber('today'),
|
||||
uploads_total: readNumber('uploads_total') ?? readNumber('total'),
|
||||
last_upload_at: typeof record.last_upload_at === 'string' ? record.last_upload_at : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestPhotoboothStatus(slug: string, path = '', init: RequestInit = {}, errorMessage = 'Failed to fetch photobooth status'): Promise<PhotoboothStatus> {
|
||||
const response = await authorizedFetch(`${photoboothEndpoint(slug)}${path}`, init);
|
||||
const payload = await jsonOrThrow<JsonValue | { data: JsonValue }>(response, errorMessage);
|
||||
@@ -1648,16 +1707,40 @@ export async function getEventPhotoboothStatus(slug: string): Promise<Photobooth
|
||||
return requestPhotoboothStatus(slug, '', {}, 'Failed to load photobooth status');
|
||||
}
|
||||
|
||||
export async function enableEventPhotobooth(slug: string): Promise<PhotoboothStatus> {
|
||||
return requestPhotoboothStatus(slug, '/enable', { method: 'POST' }, 'Failed to enable photobooth access');
|
||||
export async function enableEventPhotobooth(slug: string, options?: { mode?: 'ftp' | 'sparkbooth' }): Promise<PhotoboothStatus> {
|
||||
const body = options?.mode ? JSON.stringify({ mode: options.mode }) : undefined;
|
||||
const headers = body ? { 'Content-Type': 'application/json' } : undefined;
|
||||
|
||||
return requestPhotoboothStatus(
|
||||
slug,
|
||||
'/enable',
|
||||
{ method: 'POST', body, headers },
|
||||
'Failed to enable photobooth access'
|
||||
);
|
||||
}
|
||||
|
||||
export async function rotateEventPhotobooth(slug: string): Promise<PhotoboothStatus> {
|
||||
return requestPhotoboothStatus(slug, '/rotate', { method: 'POST' }, 'Failed to rotate credentials');
|
||||
export async function rotateEventPhotobooth(slug: string, options?: { mode?: 'ftp' | 'sparkbooth' }): Promise<PhotoboothStatus> {
|
||||
const body = options?.mode ? JSON.stringify({ mode: options.mode }) : undefined;
|
||||
const headers = body ? { 'Content-Type': 'application/json' } : undefined;
|
||||
|
||||
return requestPhotoboothStatus(
|
||||
slug,
|
||||
'/rotate',
|
||||
{ method: 'POST', body, headers },
|
||||
'Failed to rotate credentials'
|
||||
);
|
||||
}
|
||||
|
||||
export async function disableEventPhotobooth(slug: string): Promise<PhotoboothStatus> {
|
||||
return requestPhotoboothStatus(slug, '/disable', { method: 'POST' }, 'Failed to disable photobooth access');
|
||||
export async function disableEventPhotobooth(slug: string, options?: { mode?: 'ftp' | 'sparkbooth' }): Promise<PhotoboothStatus> {
|
||||
const body = options?.mode ? JSON.stringify({ mode: options.mode }) : undefined;
|
||||
const headers = body ? { 'Content-Type': 'application/json' } : undefined;
|
||||
|
||||
return requestPhotoboothStatus(
|
||||
slug,
|
||||
'/disable',
|
||||
{ method: 'POST', body, headers },
|
||||
'Failed to disable photobooth access'
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitTenantFeedback(payload: {
|
||||
|
||||
@@ -937,16 +937,26 @@
|
||||
"inactive": "Noch keine Photobooth-Uploads angebunden.",
|
||||
"badgeActive": "AKTIV",
|
||||
"badgeInactive": "INAKTIV",
|
||||
"expiresAt": "Automatisches Abschalten am {{date}}"
|
||||
"expiresAt": "Automatisches Abschalten am {{date}}",
|
||||
"mode": "Modus"
|
||||
},
|
||||
"mode": {
|
||||
"title": "Photobooth-Typ auswählen",
|
||||
"description": "Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.",
|
||||
"active": "Aktuell: {{mode}}"
|
||||
},
|
||||
"credentials": {
|
||||
"heading": "FTP-Zugangsdaten",
|
||||
"description": "Teile die Zugangsdaten mit eurer Photobooth-Software.",
|
||||
"sparkboothTitle": "Sparkbooth-Upload (HTTP)",
|
||||
"sparkboothDescription": "Trage URL, Benutzername und Passwort in Sparkbooth ein. Antworten sind JSON (optional XML).",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"path": "Upload-Pfad"
|
||||
"path": "Upload-Pfad",
|
||||
"postUrl": "Upload-URL",
|
||||
"responseFormat": "Antwort-Format"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Photobooth aktivieren",
|
||||
@@ -992,7 +1002,7 @@
|
||||
"planTitle": "Planungsmodus",
|
||||
"planDescription": "Zugang bleibt deaktiviert, um Tests vorzubereiten.",
|
||||
"liveTitle": "Live-Modus",
|
||||
"liveDescription": "FTP ist aktiv und Uploads werden direkt angenommen.",
|
||||
"liveDescription": "Zugang bleibt aktiv (FTP oder Sparkbooth) und Uploads werden direkt verarbeitet.",
|
||||
"badgePlan": "Planung",
|
||||
"badgeLive": "Live",
|
||||
"current": "Aktiv",
|
||||
@@ -1598,4 +1608,4 @@
|
||||
"ctaFallback": "Events ansehen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,16 +702,26 @@
|
||||
"inactive": "No photobooth uploads connected yet.",
|
||||
"badgeActive": "ACTIVE",
|
||||
"badgeInactive": "INACTIVE",
|
||||
"expiresAt": "Will switch off automatically on {{date}}"
|
||||
"expiresAt": "Will switch off automatically on {{date}}",
|
||||
"mode": "Mode"
|
||||
},
|
||||
"mode": {
|
||||
"title": "Choose your photobooth type",
|
||||
"description": "Pick classic FTP or Sparkbooth HTTP upload. Switching regenerates credentials.",
|
||||
"active": "Current: {{mode}}"
|
||||
},
|
||||
"credentials": {
|
||||
"heading": "FTP credentials",
|
||||
"description": "Share these credentials with your photobooth software.",
|
||||
"sparkboothTitle": "Sparkbooth upload (HTTP)",
|
||||
"sparkboothDescription": "Enter URL, username and password in Sparkbooth. Responses default to JSON (XML optional).",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"path": "Upload path"
|
||||
"path": "Upload path",
|
||||
"postUrl": "Upload URL",
|
||||
"responseFormat": "Response format"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Activate photobooth",
|
||||
@@ -757,7 +767,7 @@
|
||||
"planTitle": "Planning mode",
|
||||
"planDescription": "Keep the FTP account disabled while preparing the booth.",
|
||||
"liveTitle": "Live mode",
|
||||
"liveDescription": "FTP access stays enabled and uploads are processed instantly.",
|
||||
"liveDescription": "Access stays enabled and uploads are processed instantly (FTP or Sparkbooth).",
|
||||
"badgePlan": "Planning",
|
||||
"badgeLive": "Live",
|
||||
"current": "Active",
|
||||
|
||||
@@ -40,6 +40,8 @@ export default function EventPhotoboothPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation(['management', 'common']);
|
||||
|
||||
const [mode, setMode] = React.useState<'ftp' | 'sparkbooth'>('ftp');
|
||||
|
||||
const [state, setState] = React.useState<State>({
|
||||
event: null,
|
||||
status: null,
|
||||
@@ -96,17 +98,27 @@ export default function EventPhotoboothPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function handleEnable(): Promise<void> {
|
||||
React.useEffect(() => {
|
||||
if (state.status?.mode) {
|
||||
setMode(state.status.mode);
|
||||
}
|
||||
}, [state.status?.mode]);
|
||||
|
||||
async function handleEnable(targetMode?: 'ftp' | 'sparkbooth'): Promise<void> {
|
||||
if (!slug) return;
|
||||
setState((prev) => ({ ...prev, updating: true, error: null }));
|
||||
|
||||
try {
|
||||
const result = await enableEventPhotobooth(slug);
|
||||
const selectedMode = targetMode ?? mode;
|
||||
const result = await enableEventPhotobooth(slug, { mode: selectedMode });
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
status: result,
|
||||
updating: false,
|
||||
}));
|
||||
if (result.mode) {
|
||||
setMode(result.mode);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({
|
||||
@@ -125,7 +137,7 @@ export default function EventPhotoboothPage() {
|
||||
setState((prev) => ({ ...prev, updating: true, error: null }));
|
||||
|
||||
try {
|
||||
const result = await rotateEventPhotobooth(slug);
|
||||
const result = await rotateEventPhotobooth(slug, { mode });
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
status: result,
|
||||
@@ -153,7 +165,7 @@ export default function EventPhotoboothPage() {
|
||||
setState((prev) => ({ ...prev, updating: true, error: null }));
|
||||
|
||||
try {
|
||||
const result = await disableEventPhotobooth(slug);
|
||||
const result = await disableEventPhotobooth(slug, { mode });
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
status: result,
|
||||
@@ -178,7 +190,7 @@ export default function EventPhotoboothPage() {
|
||||
: t('management.photobooth.title', 'Fotobox-Uploads');
|
||||
const subtitle = t(
|
||||
'management.photobooth.subtitle',
|
||||
'Erstelle einen einfachen FTP-Link für Photobooth-Software. Rate-Limit: 20 Fotos/Minute.'
|
||||
'Erstelle einen einfachen Photobooth-Link per FTP oder Sparkbooth-Upload. Rate-Limit: 20 Fotos/Minute.'
|
||||
);
|
||||
const eventTabs = React.useMemo(() => {
|
||||
if (!event || !slug) {
|
||||
@@ -192,7 +204,10 @@ export default function EventPhotoboothPage() {
|
||||
}, [event, slug, t]);
|
||||
|
||||
const recentPhotos = React.useMemo(() => toolkit?.photos?.recent ?? [], [toolkit?.photos?.recent]);
|
||||
const photoboothRecent = React.useMemo(() => recentPhotos.filter((photo) => photo.ingest_source === 'photobooth'), [recentPhotos]);
|
||||
const photoboothRecent = React.useMemo(
|
||||
() => recentPhotos.filter((photo) => photo.ingest_source === 'photobooth' || photo.ingest_source === 'sparkbooth'),
|
||||
[recentPhotos]
|
||||
);
|
||||
const effectiveRecentPhotos = React.useMemo(
|
||||
() => (photoboothRecent.length > 0 ? photoboothRecent : recentPhotos),
|
||||
[photoboothRecent, recentPhotos],
|
||||
@@ -260,6 +275,42 @@ export default function EventPhotoboothPage() {
|
||||
<PhotoboothSkeleton />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-3xl border border-slate-200/80 bg-white/70 p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{t('management.photobooth.mode.title', 'Photobooth-Typ auswählen')}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t(
|
||||
'management.photobooth.mode.description',
|
||||
'Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={mode === 'ftp' ? 'default' : 'outline'}
|
||||
onClick={() => handleEnable('ftp')}
|
||||
disabled={updating || mode === 'ftp'}
|
||||
>
|
||||
FTP (Classic)
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'sparkbooth' ? 'default' : 'outline'}
|
||||
onClick={() => handleEnable('sparkbooth')}
|
||||
disabled={updating || mode === 'sparkbooth'}
|
||||
>
|
||||
Sparkbooth (HTTP)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
{t('management.photobooth.mode.active', 'Aktuell: {{mode}}', {
|
||||
mode: mode === 'sparkbooth' ? 'Sparkbooth / HTTP' : 'FTP',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
||||
<StatusCard status={status} />
|
||||
<SetupChecklistCard status={status} />
|
||||
@@ -338,7 +389,7 @@ function ModePresetsCard({ status, updating, onEnable, onDisable, onRotate }: Mo
|
||||
{
|
||||
key: 'live' as const,
|
||||
title: t('photobooth.presets.liveTitle', 'Live-Modus'),
|
||||
description: t('photobooth.presets.liveDescription', 'FTP ist aktiv und Uploads werden direkt entgegen genommen.'),
|
||||
description: t('photobooth.presets.liveDescription', 'Uploads sind aktiv (FTP oder Sparkbooth) und werden direkt verarbeitet.'),
|
||||
badge: t('photobooth.presets.badgeLive', 'Live'),
|
||||
icon: <PlugZap className="h-5 w-5 text-emerald-500" />,
|
||||
},
|
||||
@@ -524,6 +575,7 @@ function StatusCard({ status }: { status: PhotoboothStatus | null }) {
|
||||
const isActive = Boolean(status?.enabled);
|
||||
const badgeColor = isActive ? 'bg-emerald-600 text-white' : 'bg-slate-300 text-slate-800';
|
||||
const icon = isActive ? <PlugZap className="h-5 w-5 text-emerald-500" /> : <Power className="h-5 w-5 text-slate-400" />;
|
||||
const modeLabel = status?.mode === 'sparkbooth' ? 'Sparkbooth / HTTP' : 'FTP';
|
||||
|
||||
return (
|
||||
<Card className="rounded-3xl border border-slate-200/80 shadow-sm">
|
||||
@@ -543,13 +595,18 @@ function StatusCard({ status }: { status: PhotoboothStatus | null }) {
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{status?.expires_at ? (
|
||||
<CardContent className="text-sm text-slate-600">
|
||||
{t('photobooth.status.expiresAt', 'Automatisches Abschalten am {{date}}', {
|
||||
date: new Date(status.expires_at).toLocaleString(),
|
||||
})}
|
||||
</CardContent>
|
||||
) : null}
|
||||
<CardContent className="space-y-1 text-sm text-slate-600">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">
|
||||
{t('photobooth.status.mode', 'Modus')}: {modeLabel}
|
||||
</p>
|
||||
{status?.expires_at ? (
|
||||
<p>
|
||||
{t('photobooth.status.expiresAt', 'Automatisches Abschalten am {{date}}', {
|
||||
date: new Date(status.expires_at).toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -565,27 +622,48 @@ type CredentialCardProps = {
|
||||
function CredentialsCard({ status, updating, onEnable, onRotate, onDisable }: CredentialCardProps) {
|
||||
const { t } = useTranslation('management');
|
||||
const isActive = Boolean(status?.enabled);
|
||||
const isSparkbooth = status?.mode === 'sparkbooth';
|
||||
|
||||
return (
|
||||
<Card className="rounded-3xl border border-rose-100/80 shadow-lg shadow-rose-100/40">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('photobooth.credentials.heading', 'FTP-Zugangsdaten')}</CardTitle>
|
||||
<CardTitle>
|
||||
{isSparkbooth ? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth-Upload (HTTP)') : t('photobooth.credentials.heading', 'FTP-Zugangsdaten')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
'photobooth.credentials.description',
|
||||
'Teile die Zugangsdaten mit der Photobooth-Software. Passwörter werden max. 8 Zeichen lang generiert.'
|
||||
)}
|
||||
{isSparkbooth
|
||||
? t(
|
||||
'photobooth.credentials.sparkboothDescription',
|
||||
'Trage URL, Benutzername und Passwort in Sparkbooth ein. Antworten erfolgen als JSON (optional XML).'
|
||||
)
|
||||
: t(
|
||||
'photobooth.credentials.description',
|
||||
'Teile die Zugangsdaten mit der Photobooth-Software. Passwörter werden max. 8 Zeichen lang generiert.'
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label={t('photobooth.credentials.host', 'Host')} value={status?.ftp.host ?? '—'} />
|
||||
<Field label={t('photobooth.credentials.port', 'Port')} value={String(status?.ftp.port ?? 2121)} />
|
||||
<Field label={t('photobooth.credentials.username', 'Benutzername')} value={status?.username ?? '—'} copyable />
|
||||
<Field label={t('photobooth.credentials.password', 'Passwort')} value={status?.password ?? '—'} copyable sensitive />
|
||||
<Field label={t('photobooth.credentials.path', 'Upload-Pfad')} value={status?.path ?? '/photobooth/...'} copyable />
|
||||
<Field label="FTP-Link" value={status?.ftp_url ?? '—'} copyable className="md:col-span-2" />
|
||||
</div>
|
||||
{isSparkbooth ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label={t('photobooth.credentials.username', 'Benutzername')} value={status?.username ?? '—'} copyable />
|
||||
<Field label={t('photobooth.credentials.password', 'Passwort')} value={status?.password ?? '—'} copyable sensitive />
|
||||
<Field label={t('photobooth.credentials.postUrl', 'Upload-URL')} value={status?.upload_url ?? '—'} copyable className="md:col-span-2" />
|
||||
<Field
|
||||
label={t('photobooth.credentials.responseFormat', 'Antwort-Format')}
|
||||
value={status?.sparkbooth?.response_format === 'xml' ? 'XML' : 'JSON'}
|
||||
className="md:col-span-2"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label={t('photobooth.credentials.host', 'Host')} value={status?.ftp.host ?? '—'} />
|
||||
<Field label={t('photobooth.credentials.port', 'Port')} value={String(status?.ftp.port ?? 2121)} />
|
||||
<Field label={t('photobooth.credentials.username', 'Benutzername')} value={status?.username ?? '—'} copyable />
|
||||
<Field label={t('photobooth.credentials.password', 'Passwort')} value={status?.password ?? '—'} copyable sensitive />
|
||||
<Field label={t('photobooth.credentials.path', 'Upload-Pfad')} value={status?.path ?? '/photobooth/...'} copyable />
|
||||
<Field label="FTP-Link" value={status?.ftp_url ?? '—'} copyable className="md:col-span-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{isActive ? (
|
||||
|
||||
@@ -16,6 +16,7 @@ const Footer: React.FC = () => {
|
||||
agb: localizedPath('/agb'),
|
||||
widerruf: localizedPath('/widerrufsbelehrung'),
|
||||
kontakt: localizedPath('/kontakt'),
|
||||
gift: localizedPath('/gutschein'),
|
||||
}), [localizedPath]);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
@@ -36,6 +37,17 @@ const Footer: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-50">
|
||||
{t('marketing:nav.gift', 'Paket verschenken (Gutschein)')}
|
||||
</p>
|
||||
<Link
|
||||
href={links.gift}
|
||||
className="text-sm text-pink-600 transition hover:text-pink-500 dark:text-pink-300 dark:hover:text-pink-200"
|
||||
>
|
||||
{t('marketing:nav.gift', 'Paket verschenken (Gutschein)')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -63,11 +75,6 @@ const Footer: React.FC = () => {
|
||||
{t('legal:widerrufsbelehrung')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={links.kontakt} className="transition-colors hover:text-pink-500 dark:hover:text-pink-300">
|
||||
{t('marketing:nav.contact')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
@@ -85,6 +92,11 @@ const Footer: React.FC = () => {
|
||||
{t('marketing:footer.social', 'Social')}
|
||||
</h3>
|
||||
<ul className="font-sans-marketing space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li>
|
||||
<Link href={links.kontakt} className="transition-colors hover:text-pink-500 dark:hover:text-pink-300">
|
||||
{t('marketing:nav.contact')}
|
||||
</Link>
|
||||
</li>
|
||||
<li><a href="#" className="hover:text-pink-500 dark:hover:text-pink-300">Instagram</a></li>
|
||||
<li><a href="#" className="hover:text-pink-500 dark:hover:text-pink-300">Facebook</a></li>
|
||||
<li><a href="#" className="hover:text-pink-500 dark:hover:text-pink-300">YouTube</a></li>
|
||||
|
||||
63
resources/js/lib/giftVouchers.ts
Normal file
63
resources/js/lib/giftVouchers.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export type GiftVoucherTier = {
|
||||
key: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
paddle_price_id?: string | null;
|
||||
can_checkout: boolean;
|
||||
};
|
||||
|
||||
export type GiftVoucherCheckoutRequest = {
|
||||
tier_key: string;
|
||||
purchaser_email: string;
|
||||
recipient_email?: string;
|
||||
recipient_name?: string;
|
||||
message?: string;
|
||||
success_url?: string;
|
||||
return_url?: string;
|
||||
};
|
||||
|
||||
export type GiftVoucherCheckoutResponse = {
|
||||
checkout_url: string | null;
|
||||
expires_at: string | null;
|
||||
id: string | null;
|
||||
};
|
||||
|
||||
export async function fetchGiftVoucherTiers(): Promise<GiftVoucherTier[]> {
|
||||
const response = await fetch('/api/v1/marketing/gift-vouchers/tiers', {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load gift voucher tiers');
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
return (payload?.data ?? []) as GiftVoucherTier[];
|
||||
}
|
||||
|
||||
export async function createGiftVoucherCheckout(data: GiftVoucherCheckoutRequest): Promise<GiftVoucherCheckoutResponse> {
|
||||
const response = await fetch('/api/v1/marketing/gift-vouchers/checkout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(payload?.errors && typeof payload.errors === 'object' && Object.values(payload.errors)[0]?.[0]) ||
|
||||
payload?.message ||
|
||||
'Unable to start checkout';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return payload as GiftVoucherCheckoutResponse;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { CheckCircle2, Sparkles } from 'lucide-react';
|
||||
@@ -18,6 +19,7 @@ interface DemoPageProps {
|
||||
const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
|
||||
const { t } = useTranslation('marketing');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const locale = useLocale();
|
||||
const embedUrl = demoToken ? `/e/${demoToken}` : '/e/demo?demo=1';
|
||||
|
||||
const demo = t('demo_page', { returnObjects: true }) as {
|
||||
@@ -50,6 +52,16 @@ const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
|
||||
<Button asChild size="lg" variant="ghost" className="text-pink-600 hover:text-pink-700 dark:text-pink-300">
|
||||
<Link href={localizedPath('/so-funktionierts')}>{demo.secondaryCta}</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-pink-200 text-pink-600 hover:bg-pink-50 dark:border-pink-800 dark:text-pink-200"
|
||||
>
|
||||
<Link href={localizedPath(locale === 'en' ? '/gift-card' : '/gutschein')}>
|
||||
{t('packages.gift_cta', 'Paket verschenken')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
|
||||
259
resources/js/pages/marketing/GiftVoucher.tsx
Normal file
259
resources/js/pages/marketing/GiftVoucher.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import React from 'react';
|
||||
import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Gift } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { fetchGiftVoucherTiers, createGiftVoucherCheckout, type GiftVoucherTier } from '@/lib/giftVouchers';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function useGiftVoucherTiers(initial: GiftVoucherTier[] = []) {
|
||||
const [tiers, setTiers] = React.useState<GiftVoucherTier[]>(initial);
|
||||
const [loading, setLoading] = React.useState(initial.length === 0);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initial.length > 0) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
fetchGiftVoucherTiers()
|
||||
.then(setTiers)
|
||||
.catch((err) => setError(err?.message || 'Failed to load tiers'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [initial]);
|
||||
|
||||
return { tiers, loading, error };
|
||||
}
|
||||
|
||||
function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[] }) {
|
||||
const { t } = useTranslation('marketing');
|
||||
const { locale } = useLocalizedRoutes();
|
||||
const { tiers, loading, error } = useGiftVoucherTiers(initialTiers);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [serverError, setServerError] = React.useState<string | null>(null);
|
||||
const [form, setForm] = React.useState({
|
||||
tier_key: initialTiers.find((t) => t.can_checkout)?.key ?? '',
|
||||
purchaser_email: '',
|
||||
recipient_email: '',
|
||||
recipient_name: '',
|
||||
message: '',
|
||||
accept_terms: false,
|
||||
});
|
||||
const [errors, setErrors] = React.useState<Record<string, string | null>>({});
|
||||
|
||||
const selectedTierKey = form.tier_key;
|
||||
|
||||
const updateField = (key: keyof typeof form, value: string | boolean) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const nextErrors: Record<string, string | null> = {};
|
||||
|
||||
if (!form.tier_key) {
|
||||
nextErrors.tier_key = t('gift.error_select_tier');
|
||||
}
|
||||
|
||||
if (!form.purchaser_email || !form.purchaser_email.includes('@')) {
|
||||
nextErrors.purchaser_email = t('gift.error_purchaser_email', 'Please enter a valid email.');
|
||||
}
|
||||
|
||||
if (form.recipient_email && !form.recipient_email.includes('@')) {
|
||||
nextErrors.recipient_email = t('gift.error_recipient_email', 'Please enter a valid email.');
|
||||
}
|
||||
|
||||
if (!form.accept_terms) {
|
||||
nextErrors.accept_terms = t('gift.accept_terms_required');
|
||||
}
|
||||
|
||||
setErrors(nextErrors);
|
||||
|
||||
return Object.keys(nextErrors).length === 0;
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setServerError(null);
|
||||
try {
|
||||
const successUrl = window.location.origin + `/${locale}/success?type=gift`;
|
||||
const returnUrl = window.location.origin + `/${locale}/gift-card`;
|
||||
const response = await createGiftVoucherCheckout({
|
||||
tier_key: form.tier_key,
|
||||
purchaser_email: form.purchaser_email,
|
||||
recipient_email: form.recipient_email || undefined,
|
||||
recipient_name: form.recipient_name || undefined,
|
||||
message: form.message || undefined,
|
||||
success_url: successUrl,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
|
||||
if (response.checkout_url) {
|
||||
window.location.assign(response.checkout_url);
|
||||
} else {
|
||||
setServerError(t('gift.error_checkout'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setServerError(err?.message || t('gift.error_checkout'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MarketingLayout title={t('gift.title')}>
|
||||
<section className="relative overflow-hidden bg-gradient-to-b from-background via-muted/40 to-background">
|
||||
<div className="absolute inset-0 opacity-10 blur-3xl bg-[radial-gradient(circle_at_20%_20%,#60a5fa,transparent_30%),radial-gradient(circle_at_80%_10%,#a855f7,transparent_25%),radial-gradient(circle_at_50%_80%,#22c55e,transparent_25%)]" />
|
||||
<div className="relative mx-auto flex max-w-5xl flex-col gap-10 px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-primary">
|
||||
<Gift className="h-4 w-4" />
|
||||
{t('gift.badge')}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold leading-tight text-foreground sm:text-5xl font-display">
|
||||
{t('gift.headline')}
|
||||
</h1>
|
||||
<p className="max-w-3xl text-lg text-muted-foreground">{t('gift.subline')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('gift.validity')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-md border border-destructive/50 bg-destructive/5 p-3 text-sm text-destructive">{error}</div>}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
{loading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<div key={idx} className="h-32 animate-pulse rounded-xl bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{tiers.map((tier) => (
|
||||
<Card
|
||||
key={tier.key}
|
||||
className={cn(
|
||||
'cursor-pointer transition hover:shadow-md',
|
||||
selectedTierKey === tier.key ? 'border-primary shadow-lg' : '',
|
||||
!tier.can_checkout && 'opacity-60'
|
||||
)}
|
||||
onClick={() => tier.can_checkout && setValue('tier_key', tier.key, { shouldValidate: true })}
|
||||
onClick={() => tier.can_checkout && updateField('tier_key', tier.key)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>{tier.label}</span>
|
||||
<span className="text-xl font-semibold">
|
||||
{tier.amount.toLocaleString()} {tier.currency}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>{t('gift.card_subline')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground space-y-1">
|
||||
<p>{t('gift.card_body')}</p>
|
||||
{!tier.can_checkout && <p className="text-destructive">{t('gift.not_available')}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{errors.tier_key && <p className="mt-2 text-sm text-destructive">{errors.tier_key}</p>}
|
||||
<div className="mt-6 rounded-xl border border-primary/20 bg-primary/5 px-5 py-4 text-sm text-foreground shadow-sm">
|
||||
<p className="font-semibold">{t('gift.withdrawal.title')}</p>
|
||||
<p className="text-muted-foreground">{t('gift.withdrawal.body')}</p>
|
||||
<Button asChild variant="link" className="px-0 text-primary">
|
||||
<a href={locale === 'en' ? '/en/withdrawal' : '/de/widerrufsbelehrung'}>
|
||||
{t('gift.withdrawal.link', 'Widerrufsbelehrung öffnen')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('gift.form_title')}</CardTitle>
|
||||
<CardDescription>{t('gift.form_subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="purchaser_email">{t('gift.purchaser_email')}</Label>
|
||||
<Input
|
||||
id="purchaser_email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={form.purchaser_email}
|
||||
onChange={(e) => updateField('purchaser_email', e.target.value)}
|
||||
/>
|
||||
{errors.purchaser_email && <p className="text-sm text-destructive">{errors.purchaser_email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="recipient_name">{t('gift.recipient_name')}</Label>
|
||||
<Input
|
||||
id="recipient_name"
|
||||
placeholder={t('gift.recipient_name_placeholder')}
|
||||
value={form.recipient_name}
|
||||
onChange={(e) => updateField('recipient_name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="recipient_email">{t('gift.recipient_email')}</Label>
|
||||
<Input
|
||||
id="recipient_email"
|
||||
type="email"
|
||||
placeholder="friend@example.com"
|
||||
value={form.recipient_email}
|
||||
onChange={(e) => updateField('recipient_email', e.target.value)}
|
||||
/>
|
||||
{errors.recipient_email && <p className="text-sm text-destructive">{errors.recipient_email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="message">{t('gift.message')}</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
rows={3}
|
||||
placeholder={t('gift.message_placeholder')}
|
||||
value={form.message}
|
||||
onChange={(e) => updateField('message', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="accept_terms"
|
||||
className="mt-1 h-4 w-4 rounded border-muted-foreground/50"
|
||||
checked={form.accept_terms}
|
||||
onChange={(e) => updateField('accept_terms', e.target.checked)}
|
||||
/>
|
||||
<Label htmlFor="accept_terms" className="text-sm leading-tight text-muted-foreground">
|
||||
{t('gift.accept_terms')}
|
||||
</Label>
|
||||
</div>
|
||||
{errors.accept_terms && <p className="text-sm text-destructive">{errors.accept_terms}</p>}
|
||||
{serverError && <p className="text-sm text-destructive">{serverError}</p>}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full" size="lg" disabled={submitting || loading} onClick={onSubmit}>
|
||||
{submitting ? t('gift.processing') : t('gift.cta')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</MarketingLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default GiftVoucherPage;
|
||||
|
||||
GiftVoucherPage.layout = (page: React.ReactNode) => page;
|
||||
@@ -3,6 +3,7 @@ import { Head, Link } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -40,6 +41,7 @@ const iconByUseCase: Record<string, React.ReactNode> = {
|
||||
const HowItWorks: React.FC = () => {
|
||||
const { t, ready } = useTranslation('marketing');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const locale = useLocale();
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
@@ -158,6 +160,16 @@ const HowItWorks: React.FC = () => {
|
||||
{hero.secondaryCta}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="text-pink-600 hover:bg-pink-50 dark:text-pink-200 dark:hover:bg-pink-900/30"
|
||||
>
|
||||
<Link href={localizedPath(locale === 'en' ? '/gift-card' : '/gutschein')}>
|
||||
{t('packages.gift_cta', 'Paket verschenken')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -14,6 +14,7 @@ import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||
import { useCtaExperiment } from '@/hooks/useCtaExperiment';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import { ArrowRight, Check, Star } from 'lucide-react';
|
||||
|
||||
interface Package {
|
||||
@@ -251,6 +252,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
||||
const mobileEndcustomerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mobileResellerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const locale = useLocale();
|
||||
const { t } = useTranslation('marketing');
|
||||
const { t: tCommon } = useTranslation('common');
|
||||
const {
|
||||
@@ -883,6 +885,16 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
||||
{t('packages.contact_us')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="rounded-full text-gray-900 hover:bg-white/60 dark:text-gray-100 dark:hover:bg-gray-800/70"
|
||||
>
|
||||
<Link href={localizedPath(locale === 'en' ? '/gift-card' : '/gutschein')}>
|
||||
{t('packages.gift_cta', 'Paket verschenken')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t('packages.hero_secondary')}
|
||||
|
||||
@@ -2,67 +2,74 @@ import React from 'react';
|
||||
import { usePage, router } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { Loader, CheckCircle } from 'lucide-react';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import { ADMIN_HOME_PATH } from '@/admin/constants';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const Success: React.FC = () => {
|
||||
const { auth } = usePage<{ auth: { user?: { email_verified_at?: string | null } } }>().props;
|
||||
type SuccessProps = {
|
||||
type?: string;
|
||||
};
|
||||
|
||||
const GiftSuccess: React.FC = () => {
|
||||
const { t } = useTranslation('marketing');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<MarketingLayout title={t('success.gift_title')}>
|
||||
<div className="min-h-screen bg-gradient-to-b from-background via-muted/30 to-background py-16">
|
||||
<div className="mx-auto max-w-3xl space-y-6 px-4 text-center sm:px-6">
|
||||
<CheckCircle className="mx-auto h-12 w-12 text-green-500" />
|
||||
<h1 className="text-3xl font-bold text-foreground">{t('success.gift_title')}</h1>
|
||||
<p className="text-muted-foreground">{t('success.gift_description')}</p>
|
||||
<div className="rounded-xl border bg-card p-6 text-left shadow-sm">
|
||||
<h2 className="text-lg font-semibold">{t('success.gift_bullets_title')}</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-5 text-muted-foreground">
|
||||
<li>{t('success.gift_bullet_email')}</li>
|
||||
<li>{t('success.gift_bullet_validity')}</li>
|
||||
<li>{t('success.gift_bullet_redeem')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button asChild size="lg">
|
||||
<a href={localizedPath('/', locale || undefined)}>{t('success.gift_cta_home')}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MarketingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const AuthRedirectSuccess: React.FC<{ emailVerified?: boolean | null }> = ({ emailVerified }) => {
|
||||
const { t } = useTranslation('success');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
|
||||
if (auth.user && auth.user.email_verified_at) {
|
||||
if (emailVerified) {
|
||||
router.visit(ADMIN_HOME_PATH, { preserveState: false });
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<Loader className="animate-spin inline-block w-8 h-8 border border-2 border-blue-600 border-t-transparent rounded-full mx-auto mb-2" />
|
||||
<Loader className="mx-auto mb-2 inline-block h-8 w-8 animate-spin rounded-full border border-2 border-blue-600 border-t-transparent" />
|
||||
<p className="text-gray-600">{t('redirecting')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.user && !auth.user.email_verified_at) {
|
||||
return (
|
||||
<MarketingLayout title={t('verify_email')}>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{t('verify_email')}</h2>
|
||||
<p className="text-gray-600 mb-6">{t('check_email')}</p>
|
||||
<form method="POST" action="/email/verification-notification">
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition duration-300"
|
||||
>
|
||||
{t('resend_verification')}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
{t('already_registered')}{' '}
|
||||
<a href={localizedPath('/login')} className="text-blue-600 hover:text-blue-500">
|
||||
{t('login')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MarketingLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MarketingLayout title={t('complete_purchase')}>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{t('complete_purchase')}</h2>
|
||||
<p className="text-gray-600 mb-6">{t('login_to_continue')}</p>
|
||||
<h2 className="mb-4 text-2xl font-bold text-gray-900">{t('complete_purchase')}</h2>
|
||||
<p className="mb-6 text-gray-600">{t('login_to_continue')}</p>
|
||||
<a
|
||||
href={localizedPath('/login')}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition duration-300 block mb-2"
|
||||
className="mb-2 block rounded-md bg-blue-600 px-6 py-2 font-medium text-white transition duration-300 hover:bg-blue-700"
|
||||
>
|
||||
{t('login')}
|
||||
</a>
|
||||
@@ -79,6 +86,18 @@ const Success: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const Success: React.FC<SuccessProps> = () => {
|
||||
const { auth } = usePage<{ auth: { user?: { email_verified_at?: string | null } } }>().props;
|
||||
const searchParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : new URLSearchParams();
|
||||
const type = searchParams.get('type');
|
||||
|
||||
if (type === 'gift') {
|
||||
return <GiftSuccess />;
|
||||
}
|
||||
|
||||
return <AuthRedirectSuccess emailVerified={auth.user?.email_verified_at} />;
|
||||
};
|
||||
|
||||
Success.layout = (page: React.ReactNode) => page;
|
||||
|
||||
export default Success;
|
||||
|
||||
Reference in New Issue
Block a user