Migrate billing from Paddle to Lemon Squeezy
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
@keyframes guestCompassFlyIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(120px, 260px) scale(0.35) rotate(-90deg);
|
||||
transform: translate(0px, 240px) scale(0.35) rotate(-90deg);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
@@ -52,6 +52,77 @@
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@keyframes guestCompassFlyOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(0px, 0px) scale(1) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(0px, 240px) scale(0.35) rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.guest-compass-flyout {
|
||||
animation: guestCompassFlyOut 520ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
transform-origin: center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@keyframes guestTopbarTitleReveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.98);
|
||||
filter: blur(6px);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: translateY(0px) scale(1.03);
|
||||
filter: blur(0px);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0px) scale(1);
|
||||
filter: blur(0px);
|
||||
letter-spacing: 0em;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes guestTopbarChromaticSplit {
|
||||
0% {
|
||||
text-shadow: none;
|
||||
}
|
||||
40% {
|
||||
text-shadow: none;
|
||||
}
|
||||
100% {
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.guest-topbar-title {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
animation:
|
||||
guestTopbarTitleReveal 1800ms cubic-bezier(0.2, 0.8, 0.2, 1) both,
|
||||
guestTopbarChromaticSplit 2200ms ease-out both;
|
||||
transform-origin: left center;
|
||||
will-change: transform, opacity, filter;
|
||||
}
|
||||
|
||||
.guest-topbar-title::after {
|
||||
content: '';
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guest-topbar-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@source '../views';
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
|
||||
|
||||
@@ -557,7 +557,7 @@ export type DataExportSummary = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type PaddleTransactionSummary = {
|
||||
export type LemonSqueezyOrderSummary = {
|
||||
id: string | null;
|
||||
status: string | null;
|
||||
amount: number | null;
|
||||
@@ -1125,7 +1125,7 @@ export function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePaddleTransaction(entry: JsonValue): PaddleTransactionSummary {
|
||||
function normalizeLemonSqueezyOrder(entry: JsonValue): LemonSqueezyOrderSummary {
|
||||
const amountValue = entry.amount ?? entry.grand_total ?? (entry.totals && entry.totals.grand_total);
|
||||
const taxValue = entry.tax ?? (entry.totals && entry.totals.tax_total);
|
||||
|
||||
@@ -2348,8 +2348,8 @@ export type Package = {
|
||||
gallery_days: number | null;
|
||||
max_events_per_year?: number | null;
|
||||
included_package_slug?: string | null;
|
||||
paddle_price_id?: string | null;
|
||||
paddle_product_id?: string | null;
|
||||
lemonsqueezy_variant_id?: string | null;
|
||||
lemonsqueezy_product_id?: string | null;
|
||||
branding_allowed?: boolean | null;
|
||||
watermark_allowed?: boolean | null;
|
||||
features: string[] | Record<string, boolean> | null;
|
||||
@@ -2731,8 +2731,8 @@ export async function downloadTenantDataExport(downloadUrl: string): Promise<Blo
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
||||
data: PaddleTransactionSummary[];
|
||||
export async function getTenantLemonSqueezyTransactions(cursor?: string): Promise<{
|
||||
data: LemonSqueezyOrderSummary[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}> {
|
||||
@@ -2745,8 +2745,8 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to load Paddle transactions', response.status, payload);
|
||||
throw new Error('Failed to load Paddle transactions');
|
||||
console.error('[API] Failed to load Lemon Squeezy transactions', response.status, payload);
|
||||
throw new Error('Failed to load Lemon Squeezy transactions');
|
||||
}
|
||||
|
||||
const payload = await safeJson(response) ?? {};
|
||||
@@ -2754,17 +2754,17 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
||||
const meta = payload.meta ?? {};
|
||||
|
||||
return {
|
||||
data: entries.map(normalizePaddleTransaction),
|
||||
data: entries.map(normalizeLemonSqueezyOrder),
|
||||
nextCursor: typeof meta.next === 'string' ? meta.next : null,
|
||||
hasMore: Boolean(meta.has_more),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTenantPaddleCheckout(
|
||||
export async function createTenantLemonSqueezyCheckout(
|
||||
packageId: number,
|
||||
urls?: { success_url?: string; return_url?: string }
|
||||
): Promise<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
|
||||
const response = await authorizedFetch('/api/v1/tenant/packages/lemonsqueezy-checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -2796,15 +2796,15 @@ export async function createTenantBillingPortalSession(): Promise<{ url: string
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to create Paddle portal session', response.status, payload);
|
||||
throw new Error('Failed to create Paddle portal session');
|
||||
console.error('[API] Failed to create Lemon Squeezy portal session', response.status, payload);
|
||||
throw new Error('Failed to create Lemon Squeezy portal session');
|
||||
}
|
||||
|
||||
const payload = await safeJson(response);
|
||||
const url = payload?.url;
|
||||
|
||||
if (typeof url !== 'string' || url.length === 0) {
|
||||
throw new Error('Paddle portal session missing URL');
|
||||
throw new Error('Lemon Squeezy portal session missing URL');
|
||||
}
|
||||
|
||||
return { url };
|
||||
@@ -2848,13 +2848,13 @@ export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
|
||||
|
||||
export async function completeTenantPackagePurchase(params: {
|
||||
packageId: number;
|
||||
paddleTransactionId: string;
|
||||
orderId: string;
|
||||
}): Promise<void> {
|
||||
const { packageId, paddleTransactionId } = params;
|
||||
const { packageId, orderId } = params;
|
||||
const payload: Record<string, unknown> = { package_id: packageId };
|
||||
|
||||
if (paddleTransactionId) {
|
||||
payload.paddle_transaction_id = paddleTransactionId;
|
||||
if (orderId) {
|
||||
payload.lemonsqueezy_order_id = orderId;
|
||||
}
|
||||
|
||||
const response = await authorizedFetch('/api/v1/tenant/packages/complete', {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"actions": {
|
||||
"refresh": "Aktualisieren",
|
||||
"exportCsv": "Export als CSV",
|
||||
"portal": "Im Paddle-Portal verwalten",
|
||||
"portal": "Im Lemon Squeezy-Portal verwalten",
|
||||
"portalBusy": "Portal wird geöffnet...",
|
||||
"openPackages": "Pakete öffnen",
|
||||
"contactSupport": "Support kontaktieren"
|
||||
@@ -42,7 +42,7 @@
|
||||
"errors": {
|
||||
"load": "Paketdaten konnten nicht geladen werden.",
|
||||
"more": "Weitere Einträge konnten nicht geladen werden.",
|
||||
"portal": "Paddle-Portal konnte nicht geöffnet werden."
|
||||
"portal": "Lemon Squeezy-Portal konnte nicht geöffnet werden."
|
||||
},
|
||||
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
|
||||
"checkoutCancelled": "Checkout wurde abgebrochen.",
|
||||
@@ -62,8 +62,11 @@
|
||||
"checkoutActionBadge": "Aktion nötig",
|
||||
"checkoutActionButton": "Checkout fortsetzen",
|
||||
"checkoutFailureReasons": {
|
||||
"paddle_failed": "Die Zahlung wurde abgelehnt.",
|
||||
"paddle_cancelled": "Der Checkout wurde abgebrochen."
|
||||
"lemonsqueezy_failed": "Die Zahlung wurde abgelehnt.",
|
||||
"lemonsqueezy_cancelled": "Der Checkout wurde abgebrochen.",
|
||||
"lemonsqueezy_canceled": "Der Checkout wurde abgebrochen.",
|
||||
"lemonsqueezy_refunded": "Die Zahlung wurde erstattet.",
|
||||
"lemonsqueezy_voided": "Die Zahlung wurde storniert."
|
||||
},
|
||||
"sections": {
|
||||
"invoices": {
|
||||
@@ -125,9 +128,9 @@
|
||||
}
|
||||
},
|
||||
"transactions": {
|
||||
"title": "Paddle-Transaktionen",
|
||||
"description": "Neueste Paddle-Transaktionen für dieses Kundenkonto.",
|
||||
"empty": "Noch keine Paddle-Transaktionen.",
|
||||
"title": "Lemon Squeezy-Transaktionen",
|
||||
"description": "Neueste Lemon Squeezy-Transaktionen für dieses Kundenkonto.",
|
||||
"empty": "Noch keine Lemon Squeezy-Transaktionen.",
|
||||
"labels": {
|
||||
"transactionId": "Transaktion {{id}}",
|
||||
"checkoutId": "Checkout-ID: {{id}}",
|
||||
|
||||
@@ -192,25 +192,25 @@
|
||||
"failureTitle": "Aktivierung fehlgeschlagen",
|
||||
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden."
|
||||
},
|
||||
"paddle": {
|
||||
"sectionTitle": "Paddle",
|
||||
"heading": "Checkout mit Paddle",
|
||||
"genericError": "Der Paddle-Checkout konnte nicht geöffnet werden. Bitte versuche es erneut.",
|
||||
"errorTitle": "Paddle-Fehler",
|
||||
"processing": "Paddle-Checkout wird geöffnet …",
|
||||
"cta": "Paddle-Checkout öffnen",
|
||||
"hint": "Es öffnet sich ein neuer Tab über Paddle (Merchant of Record). Schließe dort die Zahlung ab und kehre anschließend zurück."
|
||||
"lemonsqueezy": {
|
||||
"sectionTitle": "Lemon Squeezy",
|
||||
"heading": "Checkout mit Lemon Squeezy",
|
||||
"genericError": "Der Lemon Squeezy-Checkout konnte nicht geöffnet werden. Bitte versuche es erneut.",
|
||||
"errorTitle": "Lemon Squeezy-Fehler",
|
||||
"processing": "Lemon Squeezy-Checkout wird geöffnet …",
|
||||
"cta": "Lemon Squeezy-Checkout öffnen",
|
||||
"hint": "Es öffnet sich ein neuer Tab über Lemon Squeezy (Merchant of Record). Schließe dort die Zahlung ab und kehre anschließend zurück."
|
||||
},
|
||||
"nextStepsTitle": "Nächste Schritte",
|
||||
"nextSteps": [
|
||||
"Optional: Abrechnung über Paddle im Billing-Bereich abschließen.",
|
||||
"Optional: Abrechnung über Lemon Squeezy im Billing-Bereich abschließen.",
|
||||
"Event-Setup durchlaufen und Fotoaufgaben, Team & Galerie konfigurieren.",
|
||||
"Vor dem Go-Live Event-Kontingent prüfen und Gäste-Link teilen."
|
||||
],
|
||||
"cta": {
|
||||
"billing": {
|
||||
"label": "Abrechnung starten",
|
||||
"description": "Öffnet den Billing-Bereich mit Paddle- und Kontingent-Optionen.",
|
||||
"description": "Öffnet den Billing-Bereich mit Lemon Squeezy- und Kontingent-Optionen.",
|
||||
"button": "Zu Billing & Zahlung"
|
||||
},
|
||||
"setup": {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"exportCsv": "Export CSV",
|
||||
"portal": "Manage in Paddle",
|
||||
"portal": "Manage in Lemon Squeezy",
|
||||
"portalBusy": "Opening portal...",
|
||||
"openPackages": "Open packages",
|
||||
"contactSupport": "Contact support"
|
||||
@@ -42,7 +42,7 @@
|
||||
"errors": {
|
||||
"load": "Unable to load package data.",
|
||||
"more": "Unable to load more entries.",
|
||||
"portal": "Unable to open the Paddle portal."
|
||||
"portal": "Unable to open the Lemon Squeezy portal."
|
||||
},
|
||||
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
|
||||
"checkoutCancelled": "Checkout was cancelled.",
|
||||
@@ -62,8 +62,11 @@
|
||||
"checkoutActionBadge": "Action needed",
|
||||
"checkoutActionButton": "Continue checkout",
|
||||
"checkoutFailureReasons": {
|
||||
"paddle_failed": "The payment was declined.",
|
||||
"paddle_cancelled": "The checkout was cancelled."
|
||||
"lemonsqueezy_failed": "The payment was declined.",
|
||||
"lemonsqueezy_cancelled": "The checkout was cancelled.",
|
||||
"lemonsqueezy_canceled": "The checkout was cancelled.",
|
||||
"lemonsqueezy_refunded": "The payment was refunded.",
|
||||
"lemonsqueezy_voided": "The payment was voided."
|
||||
},
|
||||
"sections": {
|
||||
"invoices": {
|
||||
@@ -125,9 +128,9 @@
|
||||
}
|
||||
},
|
||||
"transactions": {
|
||||
"title": "Paddle transactions",
|
||||
"description": "Recent Paddle transactions for this customer account.",
|
||||
"empty": "No Paddle transactions yet.",
|
||||
"title": "Lemon Squeezy transactions",
|
||||
"description": "Recent Lemon Squeezy transactions for this customer account.",
|
||||
"empty": "No Lemon Squeezy transactions yet.",
|
||||
"labels": {
|
||||
"transactionId": "Transaction {{id}}",
|
||||
"checkoutId": "Checkout ID: {{id}}",
|
||||
|
||||
@@ -192,25 +192,25 @@
|
||||
"failureTitle": "Activation failed",
|
||||
"errorMessage": "The free package could not be activated."
|
||||
},
|
||||
"paddle": {
|
||||
"sectionTitle": "Paddle",
|
||||
"heading": "Checkout with Paddle",
|
||||
"genericError": "The Paddle checkout could not be opened. Please try again.",
|
||||
"errorTitle": "Paddle error",
|
||||
"processing": "Opening the Paddle checkout …",
|
||||
"cta": "Open Paddle checkout",
|
||||
"hint": "A new tab opens via Paddle (merchant of record). Complete the payment there, then return to continue."
|
||||
"lemonsqueezy": {
|
||||
"sectionTitle": "Lemon Squeezy",
|
||||
"heading": "Checkout with Lemon Squeezy",
|
||||
"genericError": "The Lemon Squeezy checkout could not be opened. Please try again.",
|
||||
"errorTitle": "Lemon Squeezy error",
|
||||
"processing": "Opening the Lemon Squeezy checkout …",
|
||||
"cta": "Open Lemon Squeezy checkout",
|
||||
"hint": "A new tab opens via Lemon Squeezy (merchant of record). Complete the payment there, then return to continue."
|
||||
},
|
||||
"nextStepsTitle": "Next steps",
|
||||
"nextSteps": [
|
||||
"Optional: finish billing via Paddle inside the billing area.",
|
||||
"Optional: finish billing via Lemon Squeezy inside the billing area.",
|
||||
"Complete the event setup and configure photo tasks, team, and gallery.",
|
||||
"Check your event bundle before go-live and share your guest link."
|
||||
],
|
||||
"cta": {
|
||||
"billing": {
|
||||
"label": "Start billing",
|
||||
"description": "Opens the billing area with Paddle bundle options.",
|
||||
"description": "Opens the billing area with Lemon Squeezy bundle options.",
|
||||
"button": "Go to billing"
|
||||
},
|
||||
"setup": {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { TamaguiProvider, Theme } from '@tamagui/core';
|
||||
import { TamaguiProvider, Theme } from 'tamagui';
|
||||
import '@tamagui/core/reset.css';
|
||||
import tamaguiConfig from '../../../tamagui.config';
|
||||
import { AuthProvider } from './auth/context';
|
||||
|
||||
@@ -12,10 +12,10 @@ import { ContextHelpLink } from './components/ContextHelpLink';
|
||||
import {
|
||||
createTenantBillingPortalSession,
|
||||
getTenantPackagesOverview,
|
||||
getTenantPaddleTransactions,
|
||||
getTenantLemonSqueezyTransactions,
|
||||
getTenantPackageCheckoutStatus,
|
||||
TenantPackageSummary,
|
||||
PaddleTransactionSummary,
|
||||
LemonSqueezyOrderSummary,
|
||||
} from '../api';
|
||||
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
@@ -52,7 +52,7 @@ export default function MobileBillingPage() {
|
||||
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
|
||||
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
||||
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
|
||||
const [transactions, setTransactions] = React.useState<PaddleTransactionSummary[]>([]);
|
||||
const [transactions, setTransactions] = React.useState<LemonSqueezyOrderSummary[]>([]);
|
||||
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
@@ -78,7 +78,7 @@ export default function MobileBillingPage() {
|
||||
try {
|
||||
const [pkg, trx, addonHistory] = await Promise.all([
|
||||
getTenantPackagesOverview({ force: true }),
|
||||
getTenantPaddleTransactions().catch(() => ({ data: [] as PaddleTransactionSummary[] })),
|
||||
getTenantLemonSqueezyTransactions().catch(() => ({ data: [] as LemonSqueezyOrderSummary[] })),
|
||||
getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })),
|
||||
]);
|
||||
setPackages(pkg.packages ?? []);
|
||||
@@ -116,7 +116,7 @@ export default function MobileBillingPage() {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = getApiErrorMessage(err, t('billing.errors.portal', 'Konnte das Paddle-Portal nicht öffnen.'));
|
||||
const message = getApiErrorMessage(err, t('billing.errors.portal', 'Konnte das Lemon Squeezy-Portal nicht öffnen.'));
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setPortalBusy(false);
|
||||
@@ -388,7 +388,7 @@ export default function MobileBillingPage() {
|
||||
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={portalBusy ? t('billing.actions.portalBusy', 'Öffne Portal...') : t('billing.actions.portal', 'Manage in Paddle')}
|
||||
label={portalBusy ? t('billing.actions.portalBusy', 'Öffne Portal...') : t('billing.actions.portal', 'Manage in Lemon Squeezy')}
|
||||
onPress={openPortal}
|
||||
disabled={portalBusy}
|
||||
/>
|
||||
|
||||
@@ -1465,7 +1465,7 @@ function WatermarkPreview({
|
||||
background: ADMIN_GRADIENTS.softCard,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(180deg, ${overlay}, transparent)` }} />
|
||||
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(180deg, rgba(0,0,0,0.4), transparent)` }} />
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -222,7 +222,12 @@ export default function MobileDashboardPage() {
|
||||
return (
|
||||
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
|
||||
<DashboardCard padding="$0">
|
||||
<YStack padding="$3" gap="$2">
|
||||
<YStack
|
||||
padding="$3"
|
||||
gap="$2"
|
||||
animation="bouncy"
|
||||
enterStyle={{ opacity: 0, scale: 0.9 }}
|
||||
>
|
||||
<SectionHeader
|
||||
title={t('dashboard:overview.title', 'At a glance')}
|
||||
showSeparator={false}
|
||||
|
||||
@@ -224,7 +224,7 @@ function PackageShopCard({
|
||||
const isResellerCatalog = catalogType === 'reseller';
|
||||
const statusLabel = getPackageStatusLabel({ t, isActive, owned });
|
||||
const isSubdued = Boolean(!isResellerCatalog && (isDowngrade || !isUpgrade) && !isActive);
|
||||
const canSelect = isResellerCatalog ? Boolean(pkg.paddle_price_id) : canSelectPackage(isUpgrade, isActive);
|
||||
const canSelect = isResellerCatalog ? Boolean(pkg.lemonsqueezy_variant_id) : canSelectPackage(isUpgrade, isActive);
|
||||
const hasManageAction = Boolean(isActive && onManage);
|
||||
const includedTierLabel = resolveIncludedTierLabel(t, pkg.included_package_slug ?? null);
|
||||
const handlePress = isActive ? onManage : canSelect ? onSelect : undefined;
|
||||
@@ -524,7 +524,7 @@ function PackageShopCompareView({
|
||||
<YStack width={labelWidth} />
|
||||
{entries.map((entry) => {
|
||||
const isResellerCatalog = catalogType === 'reseller';
|
||||
const canSelect = isResellerCatalog ? Boolean(entry.pkg.paddle_price_id) : canSelectPackage(entry.isUpgrade, entry.isActive);
|
||||
const canSelect = isResellerCatalog ? Boolean(entry.pkg.lemonsqueezy_variant_id) : canSelectPackage(entry.isUpgrade, entry.isActive);
|
||||
const label = isResellerCatalog
|
||||
? canSelect
|
||||
? t('shop.partner.buy', 'Kaufen')
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, Bell, QrCode, ChevronsUpDown, Search } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { YStack, XStack, SizableText as Text, Image } from 'tamagui';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Image } from '@tamagui/image';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEventContext } from '../../context/EventContext';
|
||||
import { BottomNav, NavKey } from './BottomNav';
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Card } from '@tamagui/card';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Card, YStack, XStack, SizableText as Text, Tabs, Separator } from 'tamagui';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Tabs, Separator } from 'tamagui';
|
||||
import { useAdminTheme } from '../theme';
|
||||
import { withAlpha } from './colors';
|
||||
|
||||
@@ -16,7 +13,9 @@ export function MobileCard({
|
||||
const { surface, border, shadow, glassSurface, glassBorder, glassShadow } = useAdminTheme();
|
||||
return (
|
||||
<YStack
|
||||
className={['admin-fade-up', className].filter(Boolean).join(' ')}
|
||||
className={className}
|
||||
animation="bouncy"
|
||||
enterStyle={{ opacity: 0, y: 10, scale: 0.9 }}
|
||||
backgroundColor={glassSurface ?? surface}
|
||||
borderRadius={20}
|
||||
borderWidth={2}
|
||||
@@ -131,6 +130,8 @@ export function CTAButton({
|
||||
}}
|
||||
>
|
||||
<XStack
|
||||
animation="quick"
|
||||
pressStyle={{ scale: 0.97, opacity: 0.9 }}
|
||||
height={52}
|
||||
borderRadius={18}
|
||||
alignItems="center"
|
||||
@@ -335,7 +336,8 @@ export function ActionTile({
|
||||
disabled={disabled}
|
||||
>
|
||||
<YStack
|
||||
className="admin-fade-up"
|
||||
animation="lazy"
|
||||
pressStyle={{ scale: 0.96, opacity: 0.8 }}
|
||||
style={tileStyle}
|
||||
borderRadius={isCluster ? 14 : 16}
|
||||
padding="$3"
|
||||
|
||||
@@ -36,9 +36,6 @@ export function MobileSheet({
|
||||
const { surface, textStrong, muted, overlay, shadow, border } = useAdminTheme();
|
||||
const bottomOffset = `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)`;
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Sheet
|
||||
modal
|
||||
@@ -53,29 +50,33 @@ export function MobileSheet({
|
||||
dismissOnOverlayPress
|
||||
dismissOnSnapToBottom
|
||||
zIndex={100000}
|
||||
animation="lazy"
|
||||
>
|
||||
<Sheet.Overlay {...({ backgroundColor: `${overlay}66` } as any)} />
|
||||
<Sheet.Overlay
|
||||
animation="lazy"
|
||||
enterStyle={{ opacity: 0 }}
|
||||
exitStyle={{ opacity: 0 }}
|
||||
backgroundColor={overlay}
|
||||
/>
|
||||
<Sheet.Frame
|
||||
{...({
|
||||
width: '100%',
|
||||
maxWidth: 520,
|
||||
alignSelf: 'center',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
backgroundColor: surface,
|
||||
padding,
|
||||
paddingBottom,
|
||||
shadowColor: shadow,
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: -8 },
|
||||
} as any)}
|
||||
width="100%"
|
||||
maxWidth={520}
|
||||
alignSelf="center"
|
||||
borderTopLeftRadius={24}
|
||||
borderTopRightRadius={24}
|
||||
backgroundColor={surface}
|
||||
padding={padding}
|
||||
paddingBottom={paddingBottom}
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.12}
|
||||
shadowRadius={18}
|
||||
shadowOffset={{ width: 0, height: -8 }}
|
||||
style={{ marginBottom: bottomOffset }}
|
||||
>
|
||||
<Sheet.Handle height={5} width={48} backgroundColor={border} borderRadius={999} marginBottom="$3" />
|
||||
<Sheet.ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
{...({ contentContainerStyle: { paddingBottom: 6 } } as any)}
|
||||
contentContainerStyle={{ paddingBottom: 6 }}
|
||||
>
|
||||
<YStack gap={contentSpacing}>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { createTenantPaddleCheckout } from '../../api';
|
||||
import { createTenantLemonSqueezyCheckout } from '../../api';
|
||||
import { adminPath } from '../../constants';
|
||||
import { getApiErrorMessage } from '../../lib/apiError';
|
||||
import { storePendingCheckout } from '../lib/billingCheckout';
|
||||
@@ -33,7 +33,7 @@ export function usePackageCheckout(): {
|
||||
cancelUrl.searchParams.set('checkout', 'cancel');
|
||||
cancelUrl.searchParams.set('package_id', String(packageId));
|
||||
|
||||
const { checkout_url, checkout_session_id } = await createTenantPaddleCheckout(packageId, {
|
||||
const { checkout_url, checkout_session_id } = await createTenantLemonSqueezyCheckout(packageId, {
|
||||
success_url: successUrl.toString(),
|
||||
return_url: cancelUrl.toString(),
|
||||
});
|
||||
|
||||
@@ -161,7 +161,7 @@ export function useAdminTheme() {
|
||||
infoText: String(theme.blue10?.val ?? ADMIN_COLORS.primaryStrong),
|
||||
danger: String(theme.danger?.val ?? ADMIN_COLORS.danger),
|
||||
backdrop: String(theme.backgroundStrong?.val ?? ADMIN_COLORS.backdrop),
|
||||
overlay: withAlpha(String(theme.backgroundStrong?.val ?? ADMIN_COLORS.backdrop), 0.6),
|
||||
overlay: withAlpha(ADMIN_COLORS.backdrop, 0.7),
|
||||
shadow: String(theme.shadowColor?.val ?? 'rgba(15, 23, 42, 0.08)'),
|
||||
glassSurface,
|
||||
glassSurfaceStrong,
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function WelcomeSummaryPage() {
|
||||
{(t('summary.nextSteps', {
|
||||
returnObjects: true,
|
||||
defaultValue: [
|
||||
'Optional: finish billing via Paddle inside the billing area.',
|
||||
'Optional: finish billing via Lemon Squeezy inside the billing area.',
|
||||
'Complete the event setup and configure tasks, team, and gallery.',
|
||||
'Check your event slots before go-live and share your guest link.',
|
||||
],
|
||||
|
||||
@@ -40,26 +40,55 @@ vi.mock('../services/uploadApi', () => ({
|
||||
useUploadQueue: () => ({ items: [], loading: false, refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('../services/tasksApi', () => ({
|
||||
fetchTasks: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 12,
|
||||
title: 'Capture the dancefloor',
|
||||
description: 'Find the happiest crew.',
|
||||
instructions: 'Look for the brightest smiles.',
|
||||
duration: 5,
|
||||
emotion: { slug: 'freude', name: 'Joy' },
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock('../services/emotionsApi', () => ({
|
||||
fetchEmotions: vi.fn().mockResolvedValue([{ slug: 'freude', name: 'Joy' }]),
|
||||
}));
|
||||
|
||||
vi.mock('../services/photosApi', () => ({
|
||||
fetchGallery: vi.fn().mockResolvedValue({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, locale: 'de' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/LocaleContext', () => ({
|
||||
useLocale: () => ({ locale: 'de' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-appearance', () => ({
|
||||
useAppearance: () => ({ resolved: 'light' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
|
||||
useGuestTaskProgress: () => ({ isCompleted: () => false }),
|
||||
}));
|
||||
|
||||
import HomeScreen from '../screens/HomeScreen';
|
||||
|
||||
describe('HomeScreen', () => {
|
||||
it('shows prompt quest content when tasks are enabled', () => {
|
||||
it('shows task hero content when tasks are enabled', async () => {
|
||||
render(
|
||||
<EventDataProvider tasksEnabledFallback>
|
||||
<HomeScreen />
|
||||
</EventDataProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Prompt quest')).toBeInTheDocument();
|
||||
expect(screen.getByText('Start prompt')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Capture the dancefloor')).toBeInTheDocument();
|
||||
expect(screen.getByText("Let's go!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows capture-ready content when tasks are disabled', () => {
|
||||
|
||||
@@ -16,8 +16,8 @@ export default function AmbientBackground({ children }: AmbientBackgroundProps)
|
||||
position="relative"
|
||||
style={{
|
||||
backgroundImage: isDark
|
||||
? 'radial-gradient(circle at 15% 10%, rgba(255, 79, 216, 0.2), transparent 48%), radial-gradient(circle at 90% 20%, rgba(79, 209, 255, 0.18), transparent 40%), linear-gradient(180deg, rgba(6, 10, 22, 0.96), rgba(10, 15, 31, 1))'
|
||||
: 'radial-gradient(circle at 15% 10%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 28%, white), transparent 48%), radial-gradient(circle at 90% 20%, color-mix(in oklab, var(--guest-secondary, #F43F5E) 24%, white), transparent 40%), linear-gradient(180deg, var(--guest-background, #FFF8F5), color-mix(in oklab, var(--guest-background, #FFF8F5) 85%, white))',
|
||||
? 'radial-gradient(circle at 18% 12%, rgba(255, 110, 110, 0.22), transparent 46%), radial-gradient(circle at 82% 18%, rgba(78, 205, 196, 0.18), transparent 44%), linear-gradient(180deg, rgba(6, 9, 20, 0.96), rgba(10, 14, 28, 1))'
|
||||
: 'radial-gradient(circle at 18% 12%, color-mix(in oklab, var(--guest-primary, #FF6B6B) 24%, white), transparent 50%), radial-gradient(circle at 82% 18%, color-mix(in oklab, var(--guest-secondary, #4ECDC4) 20%, white), transparent 48%), linear-gradient(180deg, color-mix(in oklab, var(--guest-background, #FFF5F5) 96%, white), color-mix(in oklab, var(--guest-background, #FFF5F5) 72%, white))',
|
||||
backgroundSize: '140% 140%, 140% 140%, 100% 100%',
|
||||
animation: 'guestNightAmbientDrift 18s ease-in-out infinite',
|
||||
}}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import { YStack } from '@tamagui/stacks';
|
||||
import { Trophy, UploadCloud, Sparkles, Cast, Share2, Compass, Image, Camera, Settings, Home } from 'lucide-react';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { Sparkles, Share2, Compass, Image, Camera, Settings, Home } from 'lucide-react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import TopBar from './TopBar';
|
||||
import BottomDock from './BottomDock';
|
||||
import FloatingActionButton from './FloatingActionButton';
|
||||
import FabActionSheet from './FabActionSheet';
|
||||
import FabActionRing from './FabActionRing';
|
||||
import CompassHub, { type CompassAction } from './CompassHub';
|
||||
import AmbientBackground from './AmbientBackground';
|
||||
import NotificationSheet from './NotificationSheet';
|
||||
@@ -22,7 +22,7 @@ type AppShellProps = {
|
||||
};
|
||||
|
||||
export default function AppShell({ children }: AppShellProps) {
|
||||
const [sheetOpen, setSheetOpen] = React.useState(false);
|
||||
const [fabOpen, setFabOpen] = React.useState(false);
|
||||
const [compassOpen, setCompassOpen] = React.useState(false);
|
||||
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
||||
@@ -38,100 +38,24 @@ export default function AppShell({ children }: AppShellProps) {
|
||||
const showFab = !/\/photo\/\d+/.test(location.pathname);
|
||||
|
||||
const goTo = (path: string) => () => {
|
||||
setSheetOpen(false);
|
||||
setFabOpen(false);
|
||||
setCompassOpen(false);
|
||||
setNotificationsOpen(false);
|
||||
setSettingsOpen(false);
|
||||
navigate(buildEventPath(token, path));
|
||||
};
|
||||
|
||||
const openSheet = () => {
|
||||
setCompassOpen(false);
|
||||
setNotificationsOpen(false);
|
||||
setSettingsOpen(false);
|
||||
setSheetOpen(true);
|
||||
const target = buildEventPath(token, path);
|
||||
if (location.pathname === target) {
|
||||
return;
|
||||
}
|
||||
navigate(target);
|
||||
};
|
||||
|
||||
const openCompass = () => {
|
||||
setSheetOpen(false);
|
||||
setFabOpen(false);
|
||||
setNotificationsOpen(false);
|
||||
setSettingsOpen(false);
|
||||
setCompassOpen(true);
|
||||
};
|
||||
|
||||
const actions = [
|
||||
{
|
||||
key: 'upload',
|
||||
label: t('appShell.actions.upload.label', 'Upload / Take photo'),
|
||||
description: t('appShell.actions.upload.description', 'Add a moment from your device or camera.'),
|
||||
icon: <UploadCloud size={18} color={actionIconColor} />,
|
||||
onPress: goTo('/upload'),
|
||||
},
|
||||
{
|
||||
key: 'compass',
|
||||
label: t('appShell.actions.compass.label', 'Compass hub'),
|
||||
description: t('appShell.actions.compass.description', 'Quick jump to key areas.'),
|
||||
icon: <Compass size={18} color={actionIconColor} />,
|
||||
onPress: () => {
|
||||
setSheetOpen(false);
|
||||
openCompass();
|
||||
},
|
||||
},
|
||||
tasksEnabled
|
||||
? {
|
||||
key: 'task',
|
||||
label: t('appShell.actions.task.label', 'Start a task'),
|
||||
description: t('appShell.actions.task.description', 'Pick a challenge and capture it now.'),
|
||||
icon: <Sparkles size={18} color={actionIconColor} />,
|
||||
onPress: goTo('/tasks'),
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: 'live',
|
||||
label: t('appShell.actions.live.label', 'Live show'),
|
||||
description: t('appShell.actions.live.description', 'See the real-time highlight stream.'),
|
||||
icon: <Cast size={18} color={actionIconColor} />,
|
||||
onPress: () => {
|
||||
setSheetOpen(false);
|
||||
setCompassOpen(false);
|
||||
setNotificationsOpen(false);
|
||||
setSettingsOpen(false);
|
||||
if (token) {
|
||||
navigate(`/show/${encodeURIComponent(token)}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'slideshow',
|
||||
label: t('appShell.actions.slideshow.label', 'Slideshow'),
|
||||
description: t('appShell.actions.slideshow.description', 'Lean back and watch the gallery roll.'),
|
||||
icon: <Image size={18} color={actionIconColor} />,
|
||||
onPress: goTo('/slideshow'),
|
||||
},
|
||||
{
|
||||
key: 'share',
|
||||
label: t('appShell.actions.share.label', 'Share invite'),
|
||||
description: t('appShell.actions.share.description', 'Send the event link or QR code.'),
|
||||
icon: <Share2 size={18} color={actionIconColor} />,
|
||||
onPress: goTo('/share'),
|
||||
},
|
||||
tasksEnabled
|
||||
? {
|
||||
key: 'achievements',
|
||||
label: t('appShell.actions.achievements.label', 'Achievements'),
|
||||
description: t('appShell.actions.achievements.description', 'Track your photo streaks.'),
|
||||
icon: <Trophy size={18} color={actionIconColor} />,
|
||||
onPress: goTo('/achievements'),
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean) as Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
}>;
|
||||
|
||||
const compassQuadrants: [CompassAction, CompassAction, CompassAction, CompassAction] = [
|
||||
{
|
||||
key: 'home',
|
||||
@@ -166,6 +90,13 @@ export default function AppShell({ children }: AppShellProps) {
|
||||
},
|
||||
];
|
||||
|
||||
const fabActions = compassQuadrants.map((action) => ({
|
||||
key: action.key,
|
||||
label: action.label,
|
||||
icon: action.icon,
|
||||
onPress: action.onPress,
|
||||
}));
|
||||
|
||||
return (
|
||||
<AmbientBackground>
|
||||
<YStack minHeight="100vh" position="relative">
|
||||
@@ -176,23 +107,23 @@ export default function AppShell({ children }: AppShellProps) {
|
||||
right={0}
|
||||
zIndex={1000}
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(10, 14, 28, 0.72)' : 'rgba(255, 255, 255, 0.85)',
|
||||
backdropFilter: 'saturate(160%) blur(18px)',
|
||||
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
|
||||
backgroundColor: 'transparent',
|
||||
backdropFilter: 'saturate(120%) blur(8px)',
|
||||
WebkitBackdropFilter: 'saturate(120%) blur(8px)',
|
||||
}}
|
||||
>
|
||||
<TopBar
|
||||
eventName={event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
|
||||
onProfilePress={() => {
|
||||
setNotificationsOpen(false);
|
||||
setSheetOpen(false);
|
||||
setCompassOpen(false);
|
||||
setFabOpen(false);
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
onNotificationsPress={() => {
|
||||
setSettingsOpen(false);
|
||||
setSheetOpen(false);
|
||||
setCompassOpen(false);
|
||||
setFabOpen(false);
|
||||
setNotificationsOpen(true);
|
||||
}}
|
||||
notificationCount={notificationCenter?.unreadCount ?? 0}
|
||||
@@ -204,18 +135,58 @@ export default function AppShell({ children }: AppShellProps) {
|
||||
gap="$4"
|
||||
position="relative"
|
||||
zIndex={1}
|
||||
style={{ paddingTop: '88px', paddingBottom: '128px' }}
|
||||
style={{ paddingTop: '88px', paddingBottom: '112px' }}
|
||||
>
|
||||
{children}
|
||||
</YStack>
|
||||
{showFab ? <FloatingActionButton onPress={openSheet} onLongPress={openCompass} /> : null}
|
||||
<BottomDock />
|
||||
<FabActionSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={(next) => setSheetOpen(next)}
|
||||
title={t('appShell.fab.title', 'Create a moment')}
|
||||
actions={actions}
|
||||
/>
|
||||
{showFab ? (
|
||||
<>
|
||||
<Button
|
||||
size="$3"
|
||||
circular
|
||||
position="fixed"
|
||||
bottom={28}
|
||||
left="calc(50% - 84px)"
|
||||
zIndex={1100}
|
||||
backgroundColor={isDark ? 'rgba(12, 16, 32, 0.75)' : 'rgba(255, 255, 255, 0.9)'}
|
||||
borderWidth={1}
|
||||
borderColor={isDark ? 'rgba(255,255,255,0.16)' : 'rgba(15,23,42,0.12)'}
|
||||
onPress={goTo('/')}
|
||||
style={{ boxShadow: isDark ? '0 10px 20px rgba(2, 6, 23, 0.45)' : '0 8px 16px rgba(15, 23, 42, 0.14)' }}
|
||||
>
|
||||
<Home size={16} color={actionIconColor} />
|
||||
</Button>
|
||||
<FloatingActionButton
|
||||
onPress={() => {
|
||||
setCompassOpen(false);
|
||||
setNotificationsOpen(false);
|
||||
setSettingsOpen(false);
|
||||
setFabOpen((prev) => !prev);
|
||||
}}
|
||||
onLongPress={openCompass}
|
||||
/>
|
||||
<Button
|
||||
size="$3"
|
||||
circular
|
||||
position="fixed"
|
||||
bottom={28}
|
||||
left="calc(50% + 48px)"
|
||||
zIndex={1100}
|
||||
backgroundColor={isDark ? 'rgba(12, 16, 32, 0.75)' : 'rgba(255, 255, 255, 0.9)'}
|
||||
borderWidth={1}
|
||||
borderColor={isDark ? 'rgba(255,255,255,0.16)' : 'rgba(15,23,42,0.12)'}
|
||||
onPress={location.pathname.includes('/gallery') && tasksEnabled ? goTo('/tasks') : goTo('/gallery')}
|
||||
style={{ boxShadow: isDark ? '0 10px 20px rgba(2, 6, 23, 0.45)' : '0 8px 16px rgba(15, 23, 42, 0.14)' }}
|
||||
>
|
||||
{location.pathname.includes('/gallery') && tasksEnabled ? (
|
||||
<Sparkles size={16} color={actionIconColor} />
|
||||
) : (
|
||||
<Image size={16} color={actionIconColor} />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<FabActionRing open={fabOpen} onOpenChange={setFabOpen} actions={fabActions} />
|
||||
<CompassHub
|
||||
open={compassOpen}
|
||||
onOpenChange={setCompassOpen}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Sheet } from '@tamagui/sheet';
|
||||
import { YStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
@@ -42,57 +41,63 @@ export default function CompassHub({
|
||||
const close = () => onOpenChange(false);
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const [visible, setVisible] = React.useState(open);
|
||||
const [closing, setClosing] = React.useState(false);
|
||||
|
||||
if (!open) {
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setVisible(true);
|
||||
setClosing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!visible) return;
|
||||
setClosing(true);
|
||||
const timeout = window.setTimeout(() => {
|
||||
setVisible(false);
|
||||
setClosing(false);
|
||||
}, 520);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [open, visible]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
snapPoints={[100]}
|
||||
snapPointsMode="percent"
|
||||
dismissOnOverlayPress
|
||||
dismissOnSnapToBottom
|
||||
zIndex={100000}
|
||||
>
|
||||
<Sheet.Overlay
|
||||
{...({
|
||||
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.55)' : 'rgba(15, 23, 42, 0.22)',
|
||||
pointerEvents: 'auto',
|
||||
onClick: close,
|
||||
onMouseDown: close,
|
||||
onTouchStart: close,
|
||||
} as any)}
|
||||
<YStack position="fixed" inset={0} zIndex={100000} pointerEvents="box-none">
|
||||
<YStack
|
||||
position="absolute"
|
||||
inset={0}
|
||||
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.55)' : 'rgba(15, 23, 42, 0.22)'}
|
||||
pointerEvents="auto"
|
||||
onPress={close}
|
||||
onClick={close}
|
||||
onMouseDown={close}
|
||||
onTouchStart={close}
|
||||
/>
|
||||
<Sheet.Frame
|
||||
{...({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignSelf: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
padding: 24,
|
||||
pointerEvents: 'box-none',
|
||||
} as any)}
|
||||
<YStack
|
||||
position="absolute"
|
||||
inset={0}
|
||||
padding={24}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<YStack
|
||||
position="absolute"
|
||||
top={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
left={0}
|
||||
pointerEvents="auto"
|
||||
onPress={close}
|
||||
onClick={close}
|
||||
onTouchStart={close}
|
||||
/>
|
||||
<YStack flex={1} alignItems="center" justifyContent="center" gap="$3" pointerEvents="auto">
|
||||
<YStack alignItems="center" justifyContent="center" gap="$3" pointerEvents="auto">
|
||||
<Text fontSize="$5" fontFamily="$display" fontWeight="$8" color="$color">
|
||||
{title}
|
||||
</Text>
|
||||
<YStack width={280} height={280} position="relative" className="guest-compass-flyin">
|
||||
<YStack
|
||||
key={closing ? 'compass-out' : 'compass-in'}
|
||||
width={280}
|
||||
height={280}
|
||||
position="relative"
|
||||
className={closing ? 'guest-compass-flyout' : 'guest-compass-flyin'}
|
||||
>
|
||||
{quadrants.map((action, index) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
@@ -144,7 +149,7 @@ export default function CompassHub({
|
||||
Tap outside to close
|
||||
</Text>
|
||||
</YStack>
|
||||
</Sheet.Frame>
|
||||
</Sheet>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
94
resources/js/guest-v2/components/FabActionRing.tsx
Normal file
94
resources/js/guest-v2/components/FabActionRing.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
|
||||
type FabAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
type FabActionRingProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
actions: FabAction[];
|
||||
};
|
||||
|
||||
const positions = [
|
||||
{ x: 0, y: -120 },
|
||||
{ x: -70, y: -100 },
|
||||
{ x: -120, y: -24 },
|
||||
{ x: -92, y: 52 },
|
||||
];
|
||||
|
||||
export default function FabActionRing({ open, onOpenChange, actions }: FabActionRingProps) {
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const borderColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(15, 23, 42, 0.12)';
|
||||
const surfaceColor = isDark ? 'rgba(12, 16, 32, 0.92)' : 'rgba(255, 255, 255, 0.95)';
|
||||
const textColor = isDark ? '#F8FAFF' : '#0F172A';
|
||||
const shadow = isDark ? '0 16px 28px rgba(2, 6, 23, 0.55)' : '0 14px 24px rgba(15, 23, 42, 0.18)';
|
||||
const ringActions = actions.slice(0, positions.length);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<YStack position="fixed" inset={0} zIndex={1090} pointerEvents="box-none">
|
||||
<YStack
|
||||
position="absolute"
|
||||
inset={0}
|
||||
backgroundColor={isDark ? 'rgba(2,6,23,0.5)' : 'rgba(15,23,42,0.2)'}
|
||||
onPress={() => onOpenChange(false)}
|
||||
/>
|
||||
<YStack position="absolute" bottom={24} right={20} pointerEvents="box-none">
|
||||
{ringActions.map((action, index) => {
|
||||
const offset = positions[index];
|
||||
return (
|
||||
<XStack
|
||||
key={action.key}
|
||||
alignItems="center"
|
||||
gap="$2"
|
||||
position="absolute"
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px)`,
|
||||
opacity: open ? 1 : 0,
|
||||
transition: 'transform 260ms ease, opacity 200ms ease',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="$3"
|
||||
circular
|
||||
backgroundColor={surfaceColor}
|
||||
borderWidth={1}
|
||||
borderColor={borderColor}
|
||||
onPress={() => {
|
||||
onOpenChange(false);
|
||||
action.onPress?.();
|
||||
}}
|
||||
style={{ boxShadow: shadow }}
|
||||
>
|
||||
{action.icon}
|
||||
</Button>
|
||||
<XStack
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$2"
|
||||
borderRadius="$pill"
|
||||
backgroundColor={surfaceColor}
|
||||
borderWidth={1}
|
||||
borderColor={borderColor}
|
||||
style={{ boxShadow: shadow }}
|
||||
>
|
||||
<Text fontSize="$2" fontWeight="$6" color={textColor}>
|
||||
{action.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Flower } from 'lucide-react';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
|
||||
type FloatingActionButtonProps = {
|
||||
@@ -30,26 +30,27 @@ export default function FloatingActionButton({ onPress, onLongPress }: FloatingA
|
||||
onLongPress?.();
|
||||
}}
|
||||
position="fixed"
|
||||
bottom={88}
|
||||
right={20}
|
||||
bottom={20}
|
||||
left="50%"
|
||||
zIndex={1100}
|
||||
width={56}
|
||||
height={56}
|
||||
width={68}
|
||||
height={68}
|
||||
borderRadius={999}
|
||||
backgroundColor="$primary"
|
||||
borderWidth={0}
|
||||
elevation={4}
|
||||
shadowColor={isDark ? 'rgba(255, 79, 216, 0.5)' : 'rgba(15, 23, 42, 0.2)'}
|
||||
shadowOpacity={0.5}
|
||||
shadowRadius={18}
|
||||
shadowRadius={22}
|
||||
shadowOffset={{ width: 0, height: 10 }}
|
||||
style={{
|
||||
transform: 'translateX(-50%)',
|
||||
boxShadow: isDark
|
||||
? '0 18px 36px rgba(255, 79, 216, 0.35), 0 0 0 6px rgba(255, 79, 216, 0.15)'
|
||||
: '0 16px 28px rgba(15, 23, 42, 0.18), 0 0 0 6px rgba(255, 255, 255, 0.7)',
|
||||
? '0 20px 40px rgba(255, 79, 216, 0.38), 0 0 0 8px rgba(255, 79, 216, 0.16)'
|
||||
: '0 18px 32px rgba(15, 23, 42, 0.2), 0 0 0 8px rgba(255, 255, 255, 0.7)',
|
||||
}}
|
||||
>
|
||||
<Plus size={22} color="white" />
|
||||
<Flower size={26} color="white" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
364
resources/js/guest-v2/components/TaskHeroCard.tsx
Normal file
364
resources/js/guest-v2/components/TaskHeroCard.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import React from 'react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { Camera, CheckCircle2, Heart, RefreshCw, Sparkles, Timer as TimerIcon } from 'lucide-react';
|
||||
import PhotoFrameTile from './PhotoFrameTile';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '@/guest/lib/emotionTheme';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { getBentoSurfaceTokens } from '../lib/bento';
|
||||
|
||||
type TaskHeroEmotion = EmotionIdentity & { emoji?: string | null };
|
||||
|
||||
export type TaskHero = {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
instructions?: string | null;
|
||||
duration?: number | null;
|
||||
emotion?: TaskHeroEmotion | null;
|
||||
};
|
||||
|
||||
export type TaskHeroPhoto = {
|
||||
id: number;
|
||||
imageUrl: string;
|
||||
likesCount?: number;
|
||||
};
|
||||
|
||||
type TaskHeroCardProps = {
|
||||
task: TaskHero | null;
|
||||
loading: boolean;
|
||||
error?: string | null;
|
||||
hasSwiped: boolean;
|
||||
onSwiped: () => void;
|
||||
onStart: () => void;
|
||||
onShuffle: () => void;
|
||||
onViewSimilar: () => void;
|
||||
onRetry: () => void;
|
||||
onOpenPhoto: (photoId: number) => void;
|
||||
isCompleted: boolean;
|
||||
photos: TaskHeroPhoto[];
|
||||
photosLoading: boolean;
|
||||
photosError?: string | null;
|
||||
};
|
||||
|
||||
const SWIPE_THRESHOLD_PX = 40;
|
||||
|
||||
export default function TaskHeroCard({
|
||||
task,
|
||||
loading,
|
||||
error,
|
||||
hasSwiped,
|
||||
onSwiped,
|
||||
onStart,
|
||||
onShuffle,
|
||||
onViewSimilar,
|
||||
onRetry,
|
||||
onOpenPhoto,
|
||||
isCompleted,
|
||||
photos,
|
||||
photosLoading,
|
||||
photosError,
|
||||
}: TaskHeroCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const heroCardRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const theme = getEmotionTheme(task?.emotion ?? null);
|
||||
const emotionIcon = getEmotionIcon(task?.emotion ?? null);
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const bentoSurface = getBentoSurfaceTokens(isDark);
|
||||
|
||||
React.useEffect(() => {
|
||||
const card = heroCardRef.current;
|
||||
if (!card) return;
|
||||
let startX: number | null = null;
|
||||
let startY: number | null = null;
|
||||
|
||||
const onTouchStart = (event: TouchEvent) => {
|
||||
const touch = event.touches[0];
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
};
|
||||
|
||||
const onTouchEnd = (event: TouchEvent) => {
|
||||
if (startX === null || startY === null) return;
|
||||
const touch = event.changedTouches[0];
|
||||
const deltaX = touch.clientX - startX;
|
||||
const deltaY = touch.clientY - startY;
|
||||
if (Math.abs(deltaX) > SWIPE_THRESHOLD_PX && Math.abs(deltaY) < 60) {
|
||||
if (deltaX < 0) {
|
||||
onShuffle();
|
||||
} else {
|
||||
onViewSimilar();
|
||||
}
|
||||
onSwiped();
|
||||
}
|
||||
startX = null;
|
||||
startY = null;
|
||||
};
|
||||
|
||||
card.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
card.addEventListener('touchend', onTouchEnd);
|
||||
return () => {
|
||||
card.removeEventListener('touchstart', onTouchStart);
|
||||
card.removeEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
}, [onShuffle, onSwiped, onViewSimilar]);
|
||||
|
||||
if (loading && !task) {
|
||||
return (
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$bento"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
gap="$3"
|
||||
style={{ boxShadow: bentoSurface.shadow }}
|
||||
>
|
||||
<Text fontSize="$3" opacity={0.7}>
|
||||
{t('tasks.loading', 'Loading tasks...')}
|
||||
</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !task) {
|
||||
return (
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$bento"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
gap="$3"
|
||||
style={{ boxShadow: bentoSurface.shadow }}
|
||||
>
|
||||
<Text fontSize="$3">{error}</Text>
|
||||
<Button size="$3" borderRadius="$pill" onPress={onRetry}>
|
||||
{t('tasks.page.reloadButton', 'Reload')}
|
||||
</Button>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$bento"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
gap="$3"
|
||||
style={{ boxShadow: bentoSurface.shadow }}
|
||||
>
|
||||
<Text fontSize="$3">{t('tasks.page.emptyTitle', 'No matching task found')}</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack
|
||||
ref={heroCardRef}
|
||||
padding="$4"
|
||||
borderRadius="$bentoLg"
|
||||
gap="$3"
|
||||
backgroundColor="$surface"
|
||||
borderWidth={1}
|
||||
borderBottomWidth={3}
|
||||
borderColor="rgba(255, 255, 255, 0.2)"
|
||||
borderBottomColor="rgba(255, 255, 255, 0.35)"
|
||||
style={{
|
||||
backgroundImage: theme.gradientBackground,
|
||||
boxShadow: bentoSurface.shadow,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<XStack
|
||||
alignItems="center"
|
||||
gap="$2"
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$1"
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.32)"
|
||||
backgroundColor="rgba(255,255,255,0.18)"
|
||||
>
|
||||
<Sparkles size={14} color="rgba(255,255,255,0.9)" />
|
||||
<Text fontSize="$1" fontWeight="$7" color="rgba(255,255,255,0.85)" textTransform="uppercase" letterSpacing={1.4}>
|
||||
{emotionIcon} {task.emotion?.name ?? t('tasks.page.eyebrow', 'New mission')}
|
||||
</Text>
|
||||
</XStack>
|
||||
{task.duration ? (
|
||||
<XStack alignItems="center" gap="$1">
|
||||
<TimerIcon size={14} color="rgba(255,255,255,0.8)" />
|
||||
<Text fontSize="$2" color="rgba(255,255,255,0.8)">
|
||||
{task.duration} min
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$8" fontFamily="$display" fontWeight="$8" color="white">
|
||||
{task.title}
|
||||
</Text>
|
||||
{task.description ? (
|
||||
<Text fontSize="$3" color="rgba(255,255,255,0.8)">
|
||||
{task.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
{!hasSwiped ? (
|
||||
<Text fontSize="$2" color="rgba(255,255,255,0.7)" letterSpacing={2.4} textTransform="uppercase">
|
||||
{t('tasks.page.swipeHint', 'Swipe for more missions')}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{task.instructions ? (
|
||||
<YStack
|
||||
backgroundColor="rgba(255,255,255,0.16)"
|
||||
padding="$3"
|
||||
borderRadius="$bento"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.2)"
|
||||
>
|
||||
<Text fontSize="$2" color="rgba(255,255,255,0.9)">
|
||||
{task.instructions}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{isCompleted ? (
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<CheckCircle2 size={16} color="rgba(255,255,255,0.9)" />
|
||||
<Text fontSize="$2" color="rgba(255,255,255,0.85)">
|
||||
{t('tasks.page.completedLabel', 'Completed')}
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<Button
|
||||
size="$4"
|
||||
borderRadius="$bento"
|
||||
onPress={onStart}
|
||||
backgroundColor="rgba(255,255,255,0.92)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.9)"
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Camera size={18} color="rgba(15,23,42,0.9)" />
|
||||
<Text color="rgba(15,23,42,0.9)" fontWeight="$7">
|
||||
{t('tasks.page.ctaStart', "Let's go!")}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
<Button
|
||||
size="$4"
|
||||
borderRadius="$bento"
|
||||
onPress={onShuffle}
|
||||
backgroundColor="rgba(255,255,255,0.12)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.25)"
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<RefreshCw size={16} color="white" />
|
||||
<Text color="white" fontWeight="$6">
|
||||
{t('tasks.page.shuffleCta', 'Shuffle')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
</XStack>
|
||||
|
||||
{(photosLoading || photosError || photos.length > 0) && (
|
||||
<YStack
|
||||
backgroundColor="rgba(255,255,255,0.14)"
|
||||
padding="$3"
|
||||
borderRadius="$bento"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.2)"
|
||||
gap="$2"
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$2" color="rgba(255,255,255,0.85)">
|
||||
{t('tasks.page.inspirationTitle', 'Inspiration')}
|
||||
</Text>
|
||||
{photosLoading ? (
|
||||
<Text fontSize="$1" color="rgba(255,255,255,0.7)">
|
||||
{t('tasks.page.inspirationLoading', 'Loading')}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
{photosError && photos.length === 0 ? (
|
||||
<Text fontSize="$2" color="rgba(255,255,255,0.8)">
|
||||
{photosError}
|
||||
</Text>
|
||||
) : photos.length > 0 ? (
|
||||
<XStack
|
||||
gap="$2"
|
||||
style={{
|
||||
overflowX: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
paddingBottom: 4,
|
||||
}}
|
||||
>
|
||||
{photos.map((photo) => (
|
||||
<Button key={photo.id} unstyled onPress={() => onOpenPhoto(photo.id)}>
|
||||
<YStack width={64} height={64}>
|
||||
<PhotoFrameTile height={64} borderRadius="$bento">
|
||||
<YStack
|
||||
flex={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{
|
||||
backgroundImage: `url(${photo.imageUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
</PhotoFrameTile>
|
||||
</YStack>
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
size="$2"
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.35)"
|
||||
backgroundColor="rgba(255,255,255,0.08)"
|
||||
onPress={onViewSimilar}
|
||||
>
|
||||
{t('tasks.page.inspirationMore', 'More')}
|
||||
</Button>
|
||||
</XStack>
|
||||
) : (
|
||||
<Button
|
||||
size="$3"
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.3)"
|
||||
backgroundColor="rgba(255,255,255,0.12)"
|
||||
onPress={onStart}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Heart size={14} color="white" />
|
||||
<Text color="white">{t('tasks.page.inspirationEmptyTitle', 'Be the first')}</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
)}
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,11 @@ export default function TopBar({
|
||||
}: TopBarProps) {
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const [animationKey, setAnimationKey] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
setAnimationKey((prev) => prev + 1);
|
||||
}, [eventName]);
|
||||
|
||||
return (
|
||||
<XStack
|
||||
@@ -28,17 +33,21 @@ export default function TopBar({
|
||||
paddingHorizontal="$4"
|
||||
paddingVertical="$3"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(10, 14, 28, 0.72)' : 'rgba(255, 255, 255, 0.85)',
|
||||
backdropFilter: 'saturate(160%) blur(18px)',
|
||||
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
|
||||
backgroundColor: 'transparent',
|
||||
backdropFilter: 'saturate(120%) blur(8px)',
|
||||
WebkitBackdropFilter: 'saturate(120%) blur(8px)',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
fontSize="$6"
|
||||
key={animationKey}
|
||||
fontSize="$8"
|
||||
fontFamily="$display"
|
||||
fontWeight="$8"
|
||||
numberOfLines={1}
|
||||
style={{ textShadow: '0 6px 18px rgba(2, 6, 23, 0.7)' }}
|
||||
className="guest-topbar-title"
|
||||
flexShrink={1}
|
||||
minWidth={0}
|
||||
style={{ fontSize: 'clamp(20px, 4.6vw, 30px)', lineHeight: '1.1' }}
|
||||
>
|
||||
{eventName}
|
||||
</Text>
|
||||
|
||||
24
resources/js/guest-v2/lib/bento.ts
Normal file
24
resources/js/guest-v2/lib/bento.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type BentoSurfaceTokens = {
|
||||
borderColor: string;
|
||||
borderBottomColor: string;
|
||||
backgroundColor: string;
|
||||
shadow: string;
|
||||
};
|
||||
|
||||
export function getBentoSurfaceTokens(isDark: boolean): BentoSurfaceTokens {
|
||||
if (isDark) {
|
||||
return {
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
borderBottomColor: 'rgba(255, 255, 255, 0.28)',
|
||||
backgroundColor: 'rgba(14, 20, 34, 0.92)',
|
||||
shadow: '0 22px 36px rgba(2, 6, 23, 0.55), 0 6px 0 rgba(2, 6, 23, 0.35), inset 0 -4px 0 rgba(255, 255, 255, 0.08)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
borderColor: 'rgba(15, 23, 42, 0.1)',
|
||||
borderBottomColor: 'rgba(15, 23, 42, 0.18)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.96)',
|
||||
shadow: '0 22px 34px rgba(15, 23, 42, 0.18), 0 6px 0 rgba(15, 23, 42, 0.08), inset 0 -4px 0 rgba(15, 23, 42, 0.06)',
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@tamagui/button';
|
||||
import { Camera, Sparkles, Image as ImageIcon, Trophy, Star } from 'lucide-react';
|
||||
import AppShell from '../components/AppShell';
|
||||
import PhotoFrameTile from '../components/PhotoFrameTile';
|
||||
import TaskHeroCard, { type TaskHero, type TaskHeroPhoto } from '../components/TaskHeroCard';
|
||||
import { useEventData } from '../context/EventDataContext';
|
||||
import { buildEventPath } from '../lib/routes';
|
||||
import { useStaggeredReveal } from '../lib/useStaggeredReveal';
|
||||
@@ -14,8 +15,13 @@ import { fetchGallery } from '../services/photosApi';
|
||||
import { useUploadQueue } from '../services/uploadApi';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||
import { fetchTasks, type TaskItem } from '../services/tasksApi';
|
||||
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
|
||||
import { fetchEmotions } from '../services/emotionsApi';
|
||||
import { getBentoSurfaceTokens } from '../lib/bento';
|
||||
|
||||
type ActionRingProps = {
|
||||
type ActionTileProps = {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onPress: () => void;
|
||||
@@ -26,36 +32,47 @@ type GalleryPreview = {
|
||||
imageUrl: string;
|
||||
};
|
||||
|
||||
function ActionRing({
|
||||
type TaskPhoto = TaskHeroPhoto & {
|
||||
taskId?: number | null;
|
||||
};
|
||||
|
||||
function ActionTile({
|
||||
label,
|
||||
icon,
|
||||
onPress,
|
||||
isDark,
|
||||
}: ActionRingProps & { isDark: boolean }) {
|
||||
}: ActionTileProps & { isDark: boolean }) {
|
||||
const surface = getBentoSurfaceTokens(isDark);
|
||||
|
||||
return (
|
||||
<Button unstyled onPress={onPress}>
|
||||
<YStack alignItems="center" gap="$2">
|
||||
<YStack
|
||||
width={74}
|
||||
height={74}
|
||||
borderRadius={37}
|
||||
backgroundColor="$surface"
|
||||
borderWidth={2}
|
||||
borderColor="$primary"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
style={{
|
||||
backgroundImage: isDark
|
||||
? 'radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0.05))'
|
||||
: 'radial-gradient(circle at 30% 30%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 20%, white), rgba(255, 255, 255, 0.7))',
|
||||
boxShadow: isDark
|
||||
? '0 10px 24px rgba(255, 79, 216, 0.2)'
|
||||
: '0 10px 24px rgba(15, 23, 42, 0.12)',
|
||||
}}
|
||||
<Button unstyled onPress={onPress} pressStyle={{ y: 2, opacity: 0.96 }}>
|
||||
<YStack
|
||||
flex={1}
|
||||
minHeight={86}
|
||||
padding="$2.5"
|
||||
borderRadius="$bento"
|
||||
borderWidth={1}
|
||||
borderBottomWidth={3}
|
||||
borderColor={surface.borderColor}
|
||||
borderBottomColor={surface.borderBottomColor}
|
||||
backgroundColor={surface.backgroundColor}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
gap="$1.5"
|
||||
style={{
|
||||
boxShadow: surface.shadow,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Text
|
||||
fontSize={11}
|
||||
fontWeight="$7"
|
||||
textTransform="none"
|
||||
letterSpacing={0.2}
|
||||
color="$color"
|
||||
opacity={0.8}
|
||||
textAlign="center"
|
||||
>
|
||||
{icon}
|
||||
</YStack>
|
||||
<Text fontSize="$2" fontWeight="$6" color="$color" opacity={0.9}>
|
||||
{label}
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -75,8 +92,7 @@ function QuickStats({
|
||||
isDark: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
||||
const cardShadow = isDark ? '0 16px 30px rgba(2, 6, 23, 0.35)' : '0 14px 24px rgba(15, 23, 42, 0.12)';
|
||||
const surface = getBentoSurfaceTokens(isDark);
|
||||
return (
|
||||
<XStack
|
||||
gap="$3"
|
||||
@@ -88,38 +104,42 @@ function QuickStats({
|
||||
<YStack
|
||||
flex={1}
|
||||
padding="$3"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderRadius="$bento"
|
||||
backgroundColor={surface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={surface.borderColor}
|
||||
borderBottomColor={surface.borderBottomColor}
|
||||
gap="$1"
|
||||
style={{
|
||||
boxShadow: cardShadow,
|
||||
boxShadow: surface.shadow,
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$4" fontWeight="$8">
|
||||
{stats.onlineGuests}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
|
||||
{t('home.stats.online', 'Guests online')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<YStack
|
||||
flex={1}
|
||||
padding="$3"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderRadius="$bento"
|
||||
backgroundColor={surface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={surface.borderColor}
|
||||
borderBottomColor={surface.borderBottomColor}
|
||||
gap="$1"
|
||||
style={{
|
||||
boxShadow: cardShadow,
|
||||
boxShadow: surface.shadow,
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$4" fontWeight="$8">
|
||||
{queueCount}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
|
||||
{t('homeV2.stats.uploadsQueued', 'Uploads queued')}
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -150,95 +170,344 @@ export default function HomeScreen() {
|
||||
const revealStage = useStaggeredReveal({ steps: 4, intervalMs: 140, delayMs: 120 });
|
||||
const { stats } = usePollStats(token ?? null);
|
||||
const { items } = useUploadQueue();
|
||||
const [preview, setPreview] = React.useState<GalleryPreview[]>([]);
|
||||
const [previewLoading, setPreviewLoading] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { locale } = useLocale();
|
||||
const { isCompleted } = useGuestTaskProgress(token ?? undefined);
|
||||
const [galleryPhotos, setGalleryPhotos] = React.useState<TaskPhoto[]>([]);
|
||||
const [galleryLoading, setGalleryLoading] = React.useState(false);
|
||||
const [galleryError, setGalleryError] = React.useState<string | null>(null);
|
||||
const [tasks, setTasks] = React.useState<TaskHero[]>([]);
|
||||
const [currentTask, setCurrentTask] = React.useState<TaskHero | null>(null);
|
||||
const [taskLoading, setTaskLoading] = React.useState(false);
|
||||
const [taskError, setTaskError] = React.useState<string | null>(null);
|
||||
const [hasSwiped, setHasSwiped] = React.useState(false);
|
||||
const [emotionMap, setEmotionMap] = React.useState<Record<string, string>>({});
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
||||
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
||||
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
||||
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 32px rgba(15, 23, 42, 0.12)';
|
||||
const bentoSurface = getBentoSurfaceTokens(isDark);
|
||||
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.75)';
|
||||
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.08)';
|
||||
const cardShadow = bentoSurface.shadow;
|
||||
|
||||
const goTo = (path: string) => () => navigate(buildEventPath(token, path));
|
||||
|
||||
const recentTaskIdsRef = React.useRef<number[]>([]);
|
||||
|
||||
const rings = [
|
||||
tasksEnabled
|
||||
? {
|
||||
label: t('home.actions.items.tasks.label', 'Draw a task card'),
|
||||
icon: <Sparkles size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
icon: <Sparkles size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
path: '/tasks',
|
||||
}
|
||||
: {
|
||||
label: t('home.actions.items.upload.label', 'Upload photo'),
|
||||
icon: <Camera size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
icon: <Camera size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
path: '/upload',
|
||||
},
|
||||
{
|
||||
label: t('homeV2.rings.newUploads', 'New uploads'),
|
||||
icon: <ImageIcon size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
icon: <ImageIcon size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
path: '/gallery',
|
||||
},
|
||||
{
|
||||
label: t('homeV2.rings.topMoments', 'Top moments'),
|
||||
icon: <Star size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
icon: <Star size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
path: '/gallery',
|
||||
},
|
||||
{
|
||||
label: t('navigation.achievements', 'Achievements'),
|
||||
icon: <Trophy size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
icon: <Trophy size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
path: '/achievements',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mapTaskItem = React.useCallback((task: TaskItem): TaskHero | null => {
|
||||
const record = task as Record<string, unknown>;
|
||||
const id = Number(record.id ?? record.task_id ?? 0);
|
||||
const title =
|
||||
typeof record.title === 'string'
|
||||
? record.title
|
||||
: typeof record.name === 'string'
|
||||
? record.name
|
||||
: '';
|
||||
if (!id || !title) return null;
|
||||
|
||||
const description =
|
||||
typeof record.description === 'string'
|
||||
? record.description
|
||||
: typeof record.prompt === 'string'
|
||||
? record.prompt
|
||||
: null;
|
||||
const instructions =
|
||||
typeof record.instructions === 'string'
|
||||
? record.instructions
|
||||
: typeof record.instruction === 'string'
|
||||
? record.instruction
|
||||
: null;
|
||||
const durationValue = record.duration ?? record.time_limit ?? record.minutes ?? null;
|
||||
const duration = typeof durationValue === 'number' ? durationValue : Number(durationValue);
|
||||
const emotionValue = record.emotion;
|
||||
const slugValue = typeof record.emotion_slug === 'string' ? (record.emotion_slug as string) : undefined;
|
||||
const nameValue = typeof record.emotion_name === 'string'
|
||||
? (record.emotion_name as string)
|
||||
: typeof record.emotion_title === 'string'
|
||||
? (record.emotion_title as string)
|
||||
: slugValue
|
||||
? emotionMap[slugValue]
|
||||
: undefined;
|
||||
const emotion =
|
||||
typeof emotionValue === 'object' && emotionValue
|
||||
? {
|
||||
slug: typeof (emotionValue as Record<string, unknown>).slug === 'string'
|
||||
? (emotionValue as Record<string, unknown>).slug as string
|
||||
: undefined,
|
||||
name: typeof (emotionValue as Record<string, unknown>).name === 'string'
|
||||
? (emotionValue as Record<string, unknown>).name as string
|
||||
: undefined,
|
||||
emoji: typeof (emotionValue as Record<string, unknown>).emoji === 'string'
|
||||
? (emotionValue as Record<string, unknown>).emoji as string
|
||||
: undefined,
|
||||
}
|
||||
: {
|
||||
slug: slugValue,
|
||||
name: nameValue,
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
duration: Number.isFinite(duration) ? duration : null,
|
||||
emotion,
|
||||
};
|
||||
}, [emotionMap]);
|
||||
|
||||
const selectRandomTask = React.useCallback(
|
||||
(list: TaskHero[]) => {
|
||||
if (!list.length) {
|
||||
setCurrentTask(null);
|
||||
return;
|
||||
}
|
||||
const avoidIds = recentTaskIdsRef.current;
|
||||
const available = list.filter((task) => !isCompleted(task.id));
|
||||
const base = available.length ? available : list;
|
||||
let candidates = base.filter((task) => !avoidIds.includes(task.id));
|
||||
if (!candidates.length) {
|
||||
candidates = base;
|
||||
}
|
||||
const chosen = candidates[Math.floor(Math.random() * candidates.length)];
|
||||
setCurrentTask(chosen);
|
||||
recentTaskIdsRef.current = [...avoidIds.filter((id) => id !== chosen.id), chosen.id].slice(-3);
|
||||
},
|
||||
[isCompleted]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!token) {
|
||||
setPreview([]);
|
||||
setGalleryPhotos([]);
|
||||
setGalleryError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setPreviewLoading(true);
|
||||
setGalleryLoading(true);
|
||||
setGalleryError(null);
|
||||
|
||||
fetchGallery(token, { limit: 3 })
|
||||
fetchGallery(token, { limit: 72, locale })
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
const photos = Array.isArray(response.data) ? response.data : [];
|
||||
const mapped = photos
|
||||
.map((photo) => {
|
||||
const record = photo as Record<string, unknown>;
|
||||
const id = Number(record.id ?? 0);
|
||||
const imageUrl = normalizeImageUrl(
|
||||
(record.thumbnail_url as string | null | undefined)
|
||||
?? (record.thumbnail_path as string | null | undefined)
|
||||
?? (record.file_path as string | null | undefined)
|
||||
?? (record.full_url as string | null | undefined)
|
||||
?? (record.url as string | null | undefined)
|
||||
?? (record.image_url as string | null | undefined)
|
||||
);
|
||||
return { id, imageUrl };
|
||||
})
|
||||
.filter((item) => item.id && item.imageUrl);
|
||||
setPreview(mapped);
|
||||
const mapped = photos.map((photo) => {
|
||||
const record = photo as Record<string, unknown>;
|
||||
const id = Number(record.id ?? 0);
|
||||
const imageUrl = normalizeImageUrl(
|
||||
(record.thumbnail_url as string | null | undefined)
|
||||
?? (record.thumbnail_path as string | null | undefined)
|
||||
?? (record.file_path as string | null | undefined)
|
||||
?? (record.full_url as string | null | undefined)
|
||||
?? (record.url as string | null | undefined)
|
||||
?? (record.image_url as string | null | undefined)
|
||||
);
|
||||
const taskId = Number(record.task_id ?? record.taskId ?? 0);
|
||||
const likesCount =
|
||||
typeof record.likes_count === 'number'
|
||||
? record.likes_count
|
||||
: typeof record.likesCount === 'number'
|
||||
? record.likesCount
|
||||
: undefined;
|
||||
return {
|
||||
id,
|
||||
imageUrl,
|
||||
taskId: Number.isFinite(taskId) && taskId > 0 ? taskId : null,
|
||||
likesCount,
|
||||
};
|
||||
});
|
||||
setGalleryPhotos(mapped.filter((item) => item.id && item.imageUrl));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load gallery preview', error);
|
||||
if (active) {
|
||||
setPreview([]);
|
||||
setGalleryPhotos([]);
|
||||
setGalleryError(t('gallery.error', 'Gallery could not be loaded.'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setPreviewLoading(false);
|
||||
setGalleryLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token]);
|
||||
}, [locale, t, token]);
|
||||
|
||||
const reloadTasks = React.useCallback(() => {
|
||||
if (!token || !tasksEnabled) {
|
||||
setTasks([]);
|
||||
setCurrentTask(null);
|
||||
setTaskError(null);
|
||||
setTaskLoading(false);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
setTaskLoading(true);
|
||||
setTaskError(null);
|
||||
|
||||
return Promise.all([
|
||||
fetchTasks(token, { locale }),
|
||||
fetchEmotions(token, locale),
|
||||
])
|
||||
.then(([taskList, emotionList]) => {
|
||||
const nextMap: Record<string, string> = {};
|
||||
for (const emotion of emotionList) {
|
||||
const record = emotion as Record<string, unknown>;
|
||||
const slug = typeof record.slug === 'string' ? record.slug : '';
|
||||
const title = typeof record.title === 'string' ? record.title : typeof record.name === 'string' ? record.name : '';
|
||||
if (slug) {
|
||||
nextMap[slug] = title || slug;
|
||||
}
|
||||
}
|
||||
setEmotionMap(nextMap);
|
||||
const mapped = taskList.map(mapTaskItem).filter(Boolean) as TaskHero[];
|
||||
setTasks(mapped);
|
||||
if (!currentTask || !mapped.some((task) => task.id === currentTask.id)) {
|
||||
selectRandomTask(mapped);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load tasks', error);
|
||||
setTasks([]);
|
||||
setCurrentTask(null);
|
||||
setTaskError(t('tasks.error', 'Tasks could not be loaded.'));
|
||||
})
|
||||
.finally(() => {
|
||||
setTaskLoading(false);
|
||||
});
|
||||
}, [currentTask, locale, mapTaskItem, selectRandomTask, t, tasksEnabled, token]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
if (!token || !tasksEnabled) {
|
||||
setTasks([]);
|
||||
setCurrentTask(null);
|
||||
setTaskError(null);
|
||||
setTaskLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTaskLoading(true);
|
||||
setTaskError(null);
|
||||
|
||||
Promise.all([
|
||||
fetchTasks(token, { locale }),
|
||||
fetchEmotions(token, locale),
|
||||
])
|
||||
.then(([taskList, emotionList]) => {
|
||||
if (!active) return;
|
||||
const nextMap: Record<string, string> = {};
|
||||
for (const emotion of emotionList) {
|
||||
const record = emotion as Record<string, unknown>;
|
||||
const slug = typeof record.slug === 'string' ? record.slug : '';
|
||||
const title = typeof record.title === 'string' ? record.title : typeof record.name === 'string' ? record.name : '';
|
||||
if (slug) {
|
||||
nextMap[slug] = title || slug;
|
||||
}
|
||||
}
|
||||
setEmotionMap(nextMap);
|
||||
const mapped = taskList.map(mapTaskItem).filter(Boolean) as TaskHero[];
|
||||
setTasks(mapped);
|
||||
if (!currentTask || !mapped.some((task) => task.id === currentTask.id)) {
|
||||
selectRandomTask(mapped);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load tasks', error);
|
||||
if (active) {
|
||||
setTasks([]);
|
||||
setCurrentTask(null);
|
||||
setTaskError(t('tasks.error', 'Tasks could not be loaded.'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setTaskLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [currentTask, locale, mapTaskItem, selectRandomTask, t, tasksEnabled, token]);
|
||||
|
||||
const queueCount = items.filter((item) => item.status !== 'done').length;
|
||||
const preview = React.useMemo<GalleryPreview[]>(
|
||||
() =>
|
||||
galleryPhotos.slice(0, 4).map((photo) => ({
|
||||
id: photo.id,
|
||||
imageUrl: photo.imageUrl,
|
||||
})),
|
||||
[galleryPhotos]
|
||||
);
|
||||
const taskPhotos = React.useMemo<TaskHeroPhoto[]>(() => {
|
||||
if (!currentTask) return [];
|
||||
const matches = galleryPhotos.filter((photo) => photo.taskId === currentTask.id);
|
||||
if (matches.length >= 6) {
|
||||
return matches.slice(0, 6).map((photo) => ({
|
||||
id: photo.id,
|
||||
imageUrl: photo.imageUrl,
|
||||
likesCount: photo.likesCount,
|
||||
}));
|
||||
}
|
||||
const fallback = galleryPhotos.filter((photo) => photo.taskId !== currentTask.id);
|
||||
const combined = [...matches, ...fallback].slice(0, 6);
|
||||
return combined.map((photo) => ({
|
||||
id: photo.id,
|
||||
imageUrl: photo.imageUrl,
|
||||
likesCount: photo.likesCount,
|
||||
}));
|
||||
}, [currentTask, galleryPhotos]);
|
||||
const openTaskPhoto = React.useCallback(
|
||||
(photoId: number) => {
|
||||
if (!currentTask) return;
|
||||
navigate(buildEventPath(token, `/gallery?photoId=${photoId}&task=${currentTask.id}`));
|
||||
},
|
||||
[currentTask, navigate, token]
|
||||
);
|
||||
const handleStartTask = React.useCallback(() => {
|
||||
if (!currentTask) return;
|
||||
navigate(buildEventPath(token, `/upload?taskId=${currentTask.id}`));
|
||||
}, [currentTask, navigate, token]);
|
||||
const handleViewSimilar = React.useCallback(() => {
|
||||
if (!currentTask) return;
|
||||
navigate(buildEventPath(token, `/gallery?task=${currentTask.id}`));
|
||||
}, [currentTask, navigate, token]);
|
||||
const handleShuffle = React.useCallback(() => {
|
||||
selectRandomTask(tasks);
|
||||
setHasSwiped(true);
|
||||
}, [selectRandomTask, tasks]);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
@@ -250,10 +519,10 @@ export default function HomeScreen() {
|
||||
opacity={revealStage >= 1 ? 1 : 0}
|
||||
y={revealStage >= 1 ? 0 : 12}
|
||||
>
|
||||
<XStack gap="$2" justifyContent="space-between">
|
||||
<XStack gap="$2" flexWrap="nowrap">
|
||||
{rings.map((ring) => (
|
||||
<YStack key={ring.label} flex={1} alignItems="center">
|
||||
<ActionRing label={ring.label} icon={ring.icon} onPress={goTo(ring.path)} isDark={isDark} />
|
||||
<YStack key={ring.label} flex={1} minWidth={0}>
|
||||
<ActionTile label={ring.label} icon={ring.icon} onPress={goTo(ring.path)} isDark={isDark} />
|
||||
</YStack>
|
||||
))}
|
||||
</XStack>
|
||||
@@ -261,55 +530,34 @@ export default function HomeScreen() {
|
||||
|
||||
{tasksEnabled ? (
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
gap="$3"
|
||||
y={revealStage >= 2 ? 0 : 16}
|
||||
style={{
|
||||
backgroundImage: isDark
|
||||
? 'linear-gradient(135deg, rgba(255, 79, 216, 0.25), rgba(79, 209, 255, 0.12))'
|
||||
: 'linear-gradient(135deg, color-mix(in oklab, var(--guest-primary, #FF5A5F) 18%, white), color-mix(in oklab, var(--guest-secondary, #F43F5E) 10%, white))',
|
||||
boxShadow: cardShadow,
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Sparkles size={18} color="#FF4FD8" />
|
||||
<Text fontSize="$3" fontWeight="$7">
|
||||
{t('homeV2.promptQuest.label', 'Prompt quest')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$7" fontFamily="$display" fontWeight="$8">
|
||||
{t('homeV2.promptQuest.title', 'Capture the happiest laugh')}
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color" opacity={0.75}>
|
||||
{t('homeV2.promptQuest.subtitle', 'Earn points and keep the gallery lively.')}
|
||||
</Text>
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<Button size="$4" backgroundColor="$primary" borderRadius="$pill" onPress={goTo('/upload')}>
|
||||
{t('homeV2.promptQuest.ctaStart', 'Start prompt')}
|
||||
</Button>
|
||||
<Button
|
||||
size="$4"
|
||||
backgroundColor={mutedButton}
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor={mutedButtonBorder}
|
||||
onPress={goTo('/tasks')}
|
||||
>
|
||||
{t('homeV2.promptQuest.ctaBrowse', 'Browse tasks')}
|
||||
</Button>
|
||||
</XStack>
|
||||
<TaskHeroCard
|
||||
task={currentTask}
|
||||
loading={taskLoading}
|
||||
error={taskError}
|
||||
hasSwiped={hasSwiped}
|
||||
onSwiped={() => setHasSwiped(true)}
|
||||
onStart={handleStartTask}
|
||||
onShuffle={handleShuffle}
|
||||
onViewSimilar={handleViewSimilar}
|
||||
onRetry={reloadTasks}
|
||||
onOpenPhoto={openTaskPhoto}
|
||||
isCompleted={isCompleted(currentTask?.id)}
|
||||
photos={taskPhotos}
|
||||
photosLoading={galleryLoading}
|
||||
photosError={galleryError}
|
||||
/>
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderRadius="$bento"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
gap="$3"
|
||||
y={revealStage >= 2 ? 0 : 16}
|
||||
style={{
|
||||
@@ -346,10 +594,12 @@ export default function HomeScreen() {
|
||||
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderRadius="$bentoLg"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
gap="$3"
|
||||
animation="slow"
|
||||
animateOnly={['transform', 'opacity']}
|
||||
@@ -360,7 +610,7 @@ export default function HomeScreen() {
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$4" fontWeight="$7">
|
||||
<Text fontSize="$5" fontWeight="$8" fontFamily="$display">
|
||||
{t('homeV2.galleryPreview.title', 'Gallery preview')}
|
||||
</Text>
|
||||
<Button
|
||||
@@ -382,17 +632,17 @@ export default function HomeScreen() {
|
||||
paddingBottom: 6,
|
||||
}}
|
||||
>
|
||||
{(previewLoading || preview.length === 0 ? [1, 2, 3, 4] : preview).map((tile, index) => {
|
||||
{(galleryLoading || preview.length === 0 ? [1, 2, 3, 4] : preview).map((tile) => {
|
||||
if (typeof tile === 'number') {
|
||||
return (
|
||||
<YStack key={tile} flexShrink={0} width={140}>
|
||||
<PhotoFrameTile height={110} shimmer shimmerDelayMs={tile * 140} />
|
||||
<PhotoFrameTile height={110} borderRadius="$bento" shimmer shimmerDelayMs={tile * 140} />
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<YStack key={tile.id} flexShrink={0} width={140}>
|
||||
<PhotoFrameTile height={110}>
|
||||
<PhotoFrameTile height={110} borderRadius="$bento">
|
||||
<YStack
|
||||
flex={1}
|
||||
width="100%"
|
||||
|
||||
@@ -458,6 +458,19 @@ export default function UploadScreen() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cameraState === 'ready' && !previewUrl ? (
|
||||
<Button
|
||||
size="$4"
|
||||
circular
|
||||
position="absolute"
|
||||
bottom="$4"
|
||||
backgroundColor="$primary"
|
||||
onPress={handleCapture}
|
||||
aria-label={t('upload.captureButton', 'Capture')}
|
||||
>
|
||||
<Camera size={20} color="#FFFFFF" />
|
||||
</Button>
|
||||
) : null}
|
||||
{(cameraState === 'ready' || cameraState === 'starting' || cameraState === 'preview') ? (
|
||||
<Button
|
||||
size="$3"
|
||||
|
||||
@@ -555,6 +555,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
subtitle: 'Tippe irgendwo, um zu fokussieren.',
|
||||
cta: 'Jetzt aufnehmen',
|
||||
},
|
||||
galleryCta: 'Direkt aus Deiner Galerie hochladen',
|
||||
tools: {
|
||||
grid: 'Raster',
|
||||
flash: 'Blitz',
|
||||
@@ -1454,6 +1455,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
subtitle: 'Tap anywhere to focus.',
|
||||
cta: 'Capture now',
|
||||
},
|
||||
galleryCta: 'Upload directly from your gallery',
|
||||
tools: {
|
||||
grid: 'Grid',
|
||||
flash: 'Flash',
|
||||
|
||||
@@ -3,7 +3,7 @@ export type GiftVoucherTier = {
|
||||
label: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
paddle_price_id?: string | null;
|
||||
lemonsqueezy_variant_id?: string | null;
|
||||
can_checkout: boolean;
|
||||
};
|
||||
|
||||
@@ -78,14 +78,14 @@ export async function createGiftVoucherCheckout(data: GiftVoucherCheckoutRequest
|
||||
return payload as GiftVoucherCheckoutResponse;
|
||||
}
|
||||
|
||||
export async function fetchGiftVoucherByCheckout(checkoutId?: string | null, transactionId?: string | null): Promise<GiftVoucherLookupResponse | null> {
|
||||
if (!checkoutId && !transactionId) {
|
||||
export async function fetchGiftVoucherByCheckout(checkoutId?: string | null, orderId?: string | null): Promise<GiftVoucherLookupResponse | null> {
|
||||
if (!checkoutId && !orderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (checkoutId) params.set('checkout_id', checkoutId);
|
||||
if (transactionId) params.set('transaction_id', transactionId);
|
||||
if (orderId) params.set('order_id', orderId);
|
||||
|
||||
const response = await fetch(`/api/v1/marketing/gift-vouchers/lookup?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
|
||||
@@ -21,9 +21,9 @@ interface CheckoutWizardPageProps {
|
||||
error?: string | null;
|
||||
profile?: OAuthProfilePrefill | null;
|
||||
};
|
||||
paddle?: {
|
||||
environment?: string | null;
|
||||
client_token?: string | null;
|
||||
lemonsqueezy?: {
|
||||
store_id?: string | null;
|
||||
test_mode?: boolean | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ const CheckoutWizardPage: React.FC<CheckoutWizardPageProps> = ({
|
||||
privacyHtml,
|
||||
googleAuth,
|
||||
facebookAuth,
|
||||
paddle,
|
||||
lemonsqueezy,
|
||||
}) => {
|
||||
const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null }, flash?: { verification?: { status: string; title?: string; message?: string } } }>();
|
||||
const currentUser = page.props.auth?.user ?? null;
|
||||
@@ -93,7 +93,7 @@ const CheckoutWizardPage: React.FC<CheckoutWizardPageProps> = ({
|
||||
initialAuthUser={currentUser ? { id: currentUser.id, email: currentUser.email ?? '', name: currentUser.name ?? undefined, pending_purchase: Boolean(currentUser.pending_purchase) } : null}
|
||||
googleProfile={googleProfile}
|
||||
facebookProfile={facebookProfile}
|
||||
paddle={paddle ?? null}
|
||||
lemonsqueezy={lemonsqueezy ?? null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,9 +25,9 @@ const GiftSuccess: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const checkoutId = params.get('checkout_id') || sessionStorage.getItem('gift_checkout_id');
|
||||
const transactionId = params.get('transaction_id');
|
||||
const orderId = params.get('order_id');
|
||||
|
||||
fetchGiftVoucherByCheckout(checkoutId || undefined, transactionId || undefined)
|
||||
fetchGiftVoucherByCheckout(checkoutId || undefined, orderId || undefined)
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
setVoucher(data);
|
||||
|
||||
@@ -27,9 +27,9 @@ interface CheckoutWizardProps {
|
||||
initialStep?: CheckoutStepId;
|
||||
googleProfile?: OAuthProfilePrefill | null;
|
||||
facebookProfile?: OAuthProfilePrefill | null;
|
||||
paddle?: {
|
||||
environment?: string | null;
|
||||
client_token?: string | null;
|
||||
lemonsqueezy?: {
|
||||
store_id?: string | null;
|
||||
test_mode?: boolean | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
|
||||
initialStep,
|
||||
googleProfile,
|
||||
facebookProfile,
|
||||
paddle,
|
||||
lemonsqueezy,
|
||||
}) => {
|
||||
const [storedGoogleProfile, setStoredGoogleProfile] = useState<OAuthProfilePrefill | null>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -382,7 +382,7 @@ export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
|
||||
initialStep={initialStep}
|
||||
initialAuthUser={initialAuthUser ?? undefined}
|
||||
initialIsAuthenticated={Boolean(initialAuthUser)}
|
||||
paddle={paddle ?? null}
|
||||
lemonsqueezy={lemonsqueezy ?? null}
|
||||
>
|
||||
<WizardBody
|
||||
privacyHtml={privacyHtml}
|
||||
|
||||
@@ -21,9 +21,9 @@ interface CheckoutWizardContextType {
|
||||
currentStep: CheckoutStepId;
|
||||
isAuthenticated: boolean;
|
||||
authUser: unknown;
|
||||
paddleConfig?: {
|
||||
environment?: string | null;
|
||||
client_token?: string | null;
|
||||
lemonsqueezyConfig?: {
|
||||
store_id?: string | null;
|
||||
test_mode?: boolean | null;
|
||||
} | null;
|
||||
paymentCompleted: boolean;
|
||||
checkoutSessionId: string | null;
|
||||
@@ -119,9 +119,9 @@ interface CheckoutWizardProviderProps {
|
||||
initialStep?: CheckoutStepId;
|
||||
initialAuthUser?: unknown;
|
||||
initialIsAuthenticated?: boolean;
|
||||
paddle?: {
|
||||
environment?: string | null;
|
||||
client_token?: string | null;
|
||||
lemonsqueezy?: {
|
||||
store_id?: string | null;
|
||||
test_mode?: boolean | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ export function CheckoutWizardProvider({
|
||||
initialStep,
|
||||
initialAuthUser,
|
||||
initialIsAuthenticated,
|
||||
paddle,
|
||||
lemonsqueezy,
|
||||
}: CheckoutWizardProviderProps) {
|
||||
const customInitialState: CheckoutState = {
|
||||
...initialState,
|
||||
@@ -153,7 +153,7 @@ export function CheckoutWizardProvider({
|
||||
if (savedState) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedState);
|
||||
const hasValidPackage = parsed.selectedPackage && typeof parsed.selectedPackage.paddle_price_id === 'string' && parsed.selectedPackage.paddle_price_id !== '';
|
||||
const hasValidPackage = parsed.selectedPackage && typeof parsed.selectedPackage.lemonsqueezy_variant_id === 'string' && parsed.selectedPackage.lemonsqueezy_variant_id !== '';
|
||||
if (hasValidPackage && initialPackage && parsed.selectedPackage.id === initialPackage.id && parsed.currentStep !== 'confirmation') {
|
||||
// Restore state selectively
|
||||
if (parsed.selectedPackage) dispatch({ type: 'SELECT_PACKAGE', payload: parsed.selectedPackage });
|
||||
@@ -284,7 +284,7 @@ export function CheckoutWizardProvider({
|
||||
currentStep: state.currentStep,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
authUser: state.authUser,
|
||||
paddleConfig: paddle ?? null,
|
||||
lemonsqueezyConfig: lemonsqueezy ?? null,
|
||||
paymentCompleted: state.paymentCompleted,
|
||||
checkoutSessionId: state.checkoutSessionId,
|
||||
selectPackage,
|
||||
|
||||
@@ -9,7 +9,7 @@ const basePackage = {
|
||||
name: 'Starter',
|
||||
price: 49,
|
||||
type: 'endcustomer',
|
||||
paddle_price_id: 'pri_test_123',
|
||||
lemonsqueezy_variant_id: 'pri_test_123',
|
||||
};
|
||||
|
||||
describe('ConfirmationStep', () => {
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveCheckoutCsrfToken, resolvePaddleLocale } from '../steps/PaymentStep';
|
||||
import { resolveCheckoutCsrfToken, resolveCheckoutLocale } from '../steps/PaymentStep';
|
||||
|
||||
describe('resolvePaddleLocale', () => {
|
||||
describe('resolveCheckoutLocale', () => {
|
||||
it('returns short locale when given region-specific tag', () => {
|
||||
expect(resolvePaddleLocale('de-DE')).toBe('de');
|
||||
expect(resolvePaddleLocale('en-US')).toBe('en');
|
||||
expect(resolveCheckoutLocale('de-DE')).toBe('de');
|
||||
expect(resolveCheckoutLocale('en-US')).toBe('en');
|
||||
});
|
||||
|
||||
it('falls back to english when locale unsupported', () => {
|
||||
expect(resolvePaddleLocale('jp')).toBe('en');
|
||||
expect(resolvePaddleLocale('xx-YY')).toBe('en');
|
||||
expect(resolvePaddleLocale(undefined)).toBe('en');
|
||||
expect(resolveCheckoutLocale('jp')).toBe('en');
|
||||
expect(resolveCheckoutLocale('xx-YY')).toBe('en');
|
||||
expect(resolveCheckoutLocale(undefined)).toBe('en');
|
||||
});
|
||||
|
||||
it('keeps supported locale codes untouched', () => {
|
||||
expect(resolvePaddleLocale('fr')).toBe('fr');
|
||||
expect(resolvePaddleLocale('es')).toBe('es');
|
||||
expect(resolveCheckoutLocale('fr')).toBe('fr');
|
||||
expect(resolveCheckoutLocale('es')).toBe('es');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,21 +11,21 @@ vi.mock('@/hooks/useAnalytics', () => ({
|
||||
const basePackage = {
|
||||
id: 1,
|
||||
price: 49,
|
||||
paddle_price_id: 'pri_test_123',
|
||||
lemonsqueezy_variant_id: 'pri_test_123',
|
||||
};
|
||||
|
||||
describe('PaymentStep', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
window.Paddle = {
|
||||
Environment: { set: vi.fn() },
|
||||
Checkout: { open: vi.fn() },
|
||||
window.LemonSqueezy = {
|
||||
Setup: vi.fn(),
|
||||
Url: { Open: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete window.Paddle;
|
||||
delete window.LemonSqueezy;
|
||||
});
|
||||
|
||||
it('renders the payment experience without crashing', async () => {
|
||||
|
||||
@@ -19,22 +19,26 @@ import { useAnalytics } from '@/hooks/useAnalytics';
|
||||
|
||||
type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
|
||||
|
||||
type LemonSqueezyEvent = {
|
||||
event?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Paddle?: {
|
||||
Environment?: {
|
||||
set: (environment: string) => void;
|
||||
};
|
||||
Initialize?: (options: { token: string }) => void;
|
||||
Checkout: {
|
||||
open: (options: Record<string, unknown>) => void;
|
||||
createLemonSqueezy?: () => void;
|
||||
LemonSqueezy?: {
|
||||
Setup: (options: { eventHandler?: (event: LemonSqueezyEvent) => void }) => void;
|
||||
Refresh?: () => void;
|
||||
Url: {
|
||||
Open: (url: string) => void;
|
||||
Close?: () => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const PADDLE_SCRIPT_URL = 'https://cdn.paddle.com/paddle/v2/paddle.js';
|
||||
const PADDLE_SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pt', 'sv', 'da', 'fi', 'no'];
|
||||
const LEMON_SCRIPT_URL = 'https://app.lemonsqueezy.com/js/lemon.js';
|
||||
const PRIMARY_CTA_STYLES = 'min-w-[200px] disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted disabled:hover:text-muted-foreground';
|
||||
|
||||
const getCookieValue = (name: string): string | null => {
|
||||
@@ -101,73 +105,50 @@ function buildCheckoutHeaders(): HeadersInit {
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function resolvePaddleLocale(rawLocale?: string | null): string {
|
||||
export function resolveCheckoutLocale(rawLocale?: string | null): string {
|
||||
if (!rawLocale) {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
const normalized = rawLocale.toLowerCase();
|
||||
|
||||
if (PADDLE_SUPPORTED_LOCALES.includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const short = normalized.split('-')[0];
|
||||
if (short && PADDLE_SUPPORTED_LOCALES.includes(short)) {
|
||||
return short;
|
||||
}
|
||||
|
||||
return 'en';
|
||||
return short || 'en';
|
||||
}
|
||||
|
||||
type PaddleEnvironment = 'sandbox' | 'production';
|
||||
let lemonLoaderPromise: Promise<typeof window.LemonSqueezy | null> | null = null;
|
||||
|
||||
let paddleLoaderPromise: Promise<typeof window.Paddle | null> | null = null;
|
||||
|
||||
function configurePaddle(paddle: typeof window.Paddle | undefined | null, environment: PaddleEnvironment): typeof window.Paddle | null {
|
||||
if (!paddle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
paddle.Environment?.set?.(environment);
|
||||
} catch (error) {
|
||||
console.warn('[Paddle] Failed to set environment', error);
|
||||
}
|
||||
|
||||
return paddle;
|
||||
}
|
||||
|
||||
async function loadPaddle(environment: PaddleEnvironment): Promise<typeof window.Paddle | null> {
|
||||
async function loadLemonSqueezy(): Promise<typeof window.LemonSqueezy | null> {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (window.Paddle) {
|
||||
return configurePaddle(window.Paddle, environment);
|
||||
if (window.LemonSqueezy) {
|
||||
return window.LemonSqueezy;
|
||||
}
|
||||
|
||||
if (!paddleLoaderPromise) {
|
||||
paddleLoaderPromise = new Promise<typeof window.Paddle | null>((resolve, reject) => {
|
||||
if (!lemonLoaderPromise) {
|
||||
lemonLoaderPromise = new Promise<typeof window.LemonSqueezy | null>((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = PADDLE_SCRIPT_URL;
|
||||
script.async = true;
|
||||
script.onload = () => resolve(window.Paddle ?? null);
|
||||
script.src = LEMON_SCRIPT_URL;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
window.createLemonSqueezy?.();
|
||||
resolve(window.LemonSqueezy ?? null);
|
||||
};
|
||||
script.onerror = (error) => reject(error);
|
||||
document.head.appendChild(script);
|
||||
}).catch((error) => {
|
||||
console.error('Failed to load Paddle.js', error);
|
||||
paddleLoaderPromise = null;
|
||||
console.error('Failed to load Lemon.js', error);
|
||||
lemonLoaderPromise = null;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
const paddle = await paddleLoaderPromise;
|
||||
|
||||
return configurePaddle(paddle, environment);
|
||||
return lemonLoaderPromise;
|
||||
}
|
||||
|
||||
const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean; isProcessing: boolean; className?: string }> = ({ onCheckout, disabled, isProcessing, className }) => {
|
||||
const LemonSqueezyCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean; isProcessing: boolean; className?: string }> = ({ onCheckout, disabled, isProcessing, className }) => {
|
||||
const { t } = useTranslation('marketing');
|
||||
|
||||
return (
|
||||
@@ -178,7 +159,7 @@ const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean;
|
||||
onClick={onCheckout}
|
||||
>
|
||||
{isProcessing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('checkout.payment_step.pay_with_paddle')}
|
||||
{t('checkout.payment_step.pay_with_lemonsqueezy')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -189,7 +170,6 @@ export const PaymentStep: React.FC = () => {
|
||||
const {
|
||||
selectedPackage,
|
||||
nextStep,
|
||||
paddleConfig,
|
||||
authUser,
|
||||
isAuthenticated,
|
||||
goToStep,
|
||||
@@ -199,7 +179,6 @@ export const PaymentStep: React.FC = () => {
|
||||
} = useCheckoutWizard();
|
||||
const [status, setStatus] = useState<PaymentStatus>('idle');
|
||||
const [message, setMessage] = useState<string>('');
|
||||
const [initialised, setInitialised] = useState(false);
|
||||
const [inlineActive, setInlineActive] = useState(false);
|
||||
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
||||
const [consentError, setConsentError] = useState<string | null>(null);
|
||||
@@ -220,11 +199,10 @@ export const PaymentStep: React.FC = () => {
|
||||
const [couponError, setCouponError] = useState<string | null>(null);
|
||||
const [couponNotice, setCouponNotice] = useState<string | null>(null);
|
||||
const [couponLoading, setCouponLoading] = useState(false);
|
||||
const paddleRef = useRef<typeof window.Paddle | null>(null);
|
||||
const checkoutContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const eventCallbackRef = useRef<(event: Record<string, unknown>) => void>();
|
||||
const lemonRef = useRef<typeof window.LemonSqueezy | null>(null);
|
||||
const eventHandlerRef = useRef<(event: LemonSqueezyEvent) => void>();
|
||||
const lastCheckoutIdRef = useRef<string | null>(null);
|
||||
const hasAutoAppliedCoupon = useRef(false);
|
||||
const checkoutContainerClass = 'paddle-checkout-container';
|
||||
const [showWithdrawalModal, setShowWithdrawalModal] = useState(false);
|
||||
const [withdrawalHtml, setWithdrawalHtml] = useState<string | null>(null);
|
||||
const [withdrawalTitle, setWithdrawalTitle] = useState<string | null>(null);
|
||||
@@ -235,23 +213,23 @@ export const PaymentStep: React.FC = () => {
|
||||
const [isGiftVoucher, setIsGiftVoucher] = useState(false);
|
||||
const [freeActivationBusy, setFreeActivationBusy] = useState(false);
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState<{
|
||||
transactionId: string | null;
|
||||
orderId: string | null;
|
||||
checkoutId: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const paddleLocale = useMemo(() => {
|
||||
const checkoutLocale = useMemo(() => {
|
||||
const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null);
|
||||
return resolvePaddleLocale(sourceLocale ?? undefined);
|
||||
return resolveCheckoutLocale(sourceLocale ?? undefined);
|
||||
}, [i18n.language]);
|
||||
|
||||
const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]);
|
||||
|
||||
const confirmCheckoutSession = useCallback(async (payload: { transactionId: string | null; checkoutId: string | null }) => {
|
||||
const confirmCheckoutSession = useCallback(async (payload: { orderId: string | null; checkoutId: string | null }) => {
|
||||
if (!checkoutSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.transactionId && !payload.checkoutId) {
|
||||
if (!payload.orderId && !payload.checkoutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -262,12 +240,12 @@ export const PaymentStep: React.FC = () => {
|
||||
headers: buildCheckoutHeaders(),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
transaction_id: payload.transactionId,
|
||||
order_id: payload.orderId,
|
||||
checkout_id: payload.checkoutId,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to confirm Paddle session', error);
|
||||
console.warn('Failed to confirm Lemon Squeezy session', error);
|
||||
}
|
||||
}, [checkoutSessionId]);
|
||||
|
||||
@@ -398,14 +376,14 @@ export const PaymentStep: React.FC = () => {
|
||||
body: JSON.stringify({
|
||||
package_id: selectedPackage.id,
|
||||
accepted_terms: acceptedTerms,
|
||||
locale: paddleLocale,
|
||||
locale: checkoutLocale,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = payload?.errors?.accepted_waiver?.[0] || payload?.message || t('checkout.payment_step.paddle_error');
|
||||
const errorMessage = payload?.errors?.accepted_waiver?.[0] || payload?.message || t('checkout.payment_step.lemonsqueezy_error');
|
||||
setConsentError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
@@ -416,7 +394,7 @@ export const PaymentStep: React.FC = () => {
|
||||
nextStep();
|
||||
} catch (error) {
|
||||
console.error('Failed to activate free package', error);
|
||||
const fallbackMessage = t('checkout.payment_step.paddle_error');
|
||||
const fallbackMessage = t('checkout.payment_step.lemonsqueezy_error');
|
||||
setConsentError(fallbackMessage);
|
||||
toast.error(fallbackMessage);
|
||||
} finally {
|
||||
@@ -424,7 +402,7 @@ export const PaymentStep: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const startPaddleCheckout = async () => {
|
||||
const startLemonSqueezyCheckout = async () => {
|
||||
if (!selectedPackage) {
|
||||
return;
|
||||
}
|
||||
@@ -443,43 +421,29 @@ export const PaymentStep: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedPackage.paddle_price_id) {
|
||||
if (!selectedPackage.lemonsqueezy_variant_id) {
|
||||
setStatus('error');
|
||||
setMessage(t('checkout.payment_step.paddle_not_configured'));
|
||||
setMessage(t('checkout.payment_step.lemonsqueezy_not_configured'));
|
||||
return;
|
||||
}
|
||||
|
||||
setPaymentCompleted(false);
|
||||
setStatus('processing');
|
||||
setMessage(t('checkout.payment_step.paddle_preparing'));
|
||||
setMessage(t('checkout.payment_step.lemonsqueezy_preparing'));
|
||||
setInlineActive(false);
|
||||
setCheckoutSessionId(null);
|
||||
|
||||
try {
|
||||
await refreshCheckoutCsrfToken();
|
||||
const inlineSupported = initialised && !!paddleConfig?.client_token;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
console.info('[Checkout] Paddle inline status', {
|
||||
inlineSupported,
|
||||
initialised,
|
||||
hasClientToken: Boolean(paddleConfig?.client_token),
|
||||
environment: paddleConfig?.environment,
|
||||
paddlePriceId: selectedPackage.paddle_price_id,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('/paddle/create-checkout', {
|
||||
const response = await fetch('/lemonsqueezy/create-checkout', {
|
||||
method: 'POST',
|
||||
headers: buildCheckoutHeaders(),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
package_id: selectedPackage.id,
|
||||
locale: paddleLocale,
|
||||
locale: checkoutLocale,
|
||||
coupon_code: couponPreview?.coupon.code ?? undefined,
|
||||
accepted_terms: acceptedTerms,
|
||||
inline: inlineSupported,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -498,14 +462,14 @@ export const PaymentStep: React.FC = () => {
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody });
|
||||
console.info('[Checkout] Lemon Squeezy checkout response', { status: response.status, rawBody });
|
||||
}
|
||||
|
||||
let data: { checkout_url?: string; message?: string } | null = null;
|
||||
try {
|
||||
data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null;
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse Paddle checkout payload as JSON', parseError);
|
||||
console.warn('Failed to parse Lemon Squeezy checkout payload as JSON', parseError);
|
||||
data = null;
|
||||
}
|
||||
|
||||
@@ -528,73 +492,32 @@ export const PaymentStep: React.FC = () => {
|
||||
}
|
||||
|
||||
if (!response.ok || !checkoutUrl) {
|
||||
const message = data?.message || rawBody || 'Unable to create Paddle checkout.';
|
||||
if (response.ok && data && (data as { mode?: string }).mode === 'inline') {
|
||||
checkoutUrl = null;
|
||||
} else {
|
||||
throw new Error(message);
|
||||
}
|
||||
const message = data?.message || rawBody || 'Unable to create Lemon Squeezy checkout.';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (data && (data as { mode?: string }).mode === 'inline') {
|
||||
const paddle = paddleRef.current;
|
||||
if (data && typeof (data as { id?: string }).id === 'string') {
|
||||
lastCheckoutIdRef.current = (data as { id?: string }).id ?? null;
|
||||
}
|
||||
|
||||
if (!paddle || !paddle.Checkout || typeof paddle.Checkout.open !== 'function') {
|
||||
throw new Error('Inline Paddle checkout is not available.');
|
||||
}
|
||||
|
||||
const inlinePayload: Record<string, unknown> = {
|
||||
items: (data as { items?: unknown[] }).items ?? [],
|
||||
settings: {
|
||||
displayMode: 'inline',
|
||||
frameTarget: checkoutContainerClass,
|
||||
frameInitialHeight: '550',
|
||||
frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;',
|
||||
theme: 'light',
|
||||
locale: paddleLocale,
|
||||
},
|
||||
};
|
||||
|
||||
if ((data as { custom_data?: Record<string, unknown> }).custom_data) {
|
||||
inlinePayload.customData = (data as { custom_data?: Record<string, unknown> }).custom_data;
|
||||
}
|
||||
|
||||
if ((data as { customer?: Record<string, unknown> }).customer) {
|
||||
inlinePayload.customer = (data as { customer?: Record<string, unknown> }).customer;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
console.info('[Checkout] Opening inline Paddle checkout', inlinePayload);
|
||||
}
|
||||
|
||||
paddle.Checkout.open(inlinePayload);
|
||||
const lemon = await loadLemonSqueezy();
|
||||
|
||||
if (lemon?.Url?.Open) {
|
||||
lemon.Url.Open(checkoutUrl);
|
||||
setInlineActive(true);
|
||||
setStatus('ready');
|
||||
setMessage(t('checkout.payment_step.paddle_overlay_ready'));
|
||||
if (typeof window !== 'undefined' && checkoutContainerRef.current) {
|
||||
window.requestAnimationFrame(() => {
|
||||
const rect = checkoutContainerRef.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
const offset = 120;
|
||||
const target = Math.max(window.scrollY + rect.top - offset, 0);
|
||||
window.scrollTo({ top: target, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
setMessage(t('checkout.payment_step.lemonsqueezy_overlay_ready'));
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(checkoutUrl, '_blank', 'noopener');
|
||||
setInlineActive(false);
|
||||
setStatus('ready');
|
||||
setMessage(t('checkout.payment_step.paddle_ready'));
|
||||
setMessage(t('checkout.payment_step.lemonsqueezy_ready'));
|
||||
} catch (error) {
|
||||
console.error('Failed to start Paddle checkout', error);
|
||||
console.error('Failed to start Lemon Squeezy checkout', error);
|
||||
setStatus('error');
|
||||
setMessage(t('checkout.payment_step.paddle_error'));
|
||||
setMessage(t('checkout.payment_step.lemonsqueezy_error'));
|
||||
setInlineActive(false);
|
||||
setPaymentCompleted(false);
|
||||
}
|
||||
@@ -603,85 +526,50 @@ export const PaymentStep: React.FC = () => {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const environment = paddleConfig?.environment === 'sandbox' ? 'sandbox' : 'production';
|
||||
const clientToken = paddleConfig?.client_token ?? null;
|
||||
|
||||
eventCallbackRef.current = (event) => {
|
||||
if (!event?.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
console.debug('[Checkout] Paddle event', event);
|
||||
}
|
||||
|
||||
if (event.name === 'checkout.completed') {
|
||||
const transactionId = typeof event?.data?.transaction_id === 'string' ? event.data.transaction_id : null;
|
||||
const checkoutId = typeof event?.data?.id === 'string' ? event.data.id : null;
|
||||
setStatus('processing');
|
||||
setMessage(t('checkout.payment_step.processing_confirmation'));
|
||||
setInlineActive(false);
|
||||
setPaymentCompleted(false);
|
||||
setPendingConfirmation({ transactionId, checkoutId });
|
||||
toast.success(t('checkout.payment_step.toast_success'));
|
||||
setPaymentCompleted(true);
|
||||
nextStep();
|
||||
}
|
||||
|
||||
if (event.name === 'checkout.closed') {
|
||||
setStatus('idle');
|
||||
setMessage('');
|
||||
setInlineActive(false);
|
||||
}
|
||||
|
||||
if (event.name === 'checkout.error') {
|
||||
setStatus('error');
|
||||
setMessage(t('checkout.payment_step.paddle_error'));
|
||||
setInlineActive(false);
|
||||
setPaymentCompleted(false);
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const paddle = await loadPaddle(environment);
|
||||
const lemon = await loadLemonSqueezy();
|
||||
|
||||
if (cancelled || !paddle) {
|
||||
if (cancelled || !lemon) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let inlineReady = false;
|
||||
if (typeof paddle.Initialize === 'function' && clientToken) {
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
console.info('[Checkout] Initializing Paddle.js', { environment, hasToken: Boolean(clientToken) });
|
||||
eventHandlerRef.current = (event) => {
|
||||
if (!event?.event) {
|
||||
return;
|
||||
}
|
||||
|
||||
paddle.Initialize({
|
||||
token: clientToken,
|
||||
checkout: {
|
||||
settings: {
|
||||
displayMode: 'inline',
|
||||
frameTarget: checkoutContainerClass,
|
||||
frameInitialHeight: '550',
|
||||
frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;',
|
||||
locale: paddleLocale,
|
||||
},
|
||||
},
|
||||
eventCallback: (event: Record<string, unknown>) => eventCallbackRef.current?.(event),
|
||||
});
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
console.debug('[Checkout] Lemon Squeezy event', event);
|
||||
}
|
||||
|
||||
inlineReady = true;
|
||||
}
|
||||
if (event.event === 'Checkout.Success') {
|
||||
const data = event.data as { id?: string; identifier?: string; attributes?: { checkout_id?: string } } | undefined;
|
||||
const orderId = typeof data?.id === 'string' ? data.id : (typeof data?.identifier === 'string' ? data.identifier : null);
|
||||
const checkoutId = typeof data?.attributes?.checkout_id === 'string'
|
||||
? data?.attributes?.checkout_id
|
||||
: lastCheckoutIdRef.current;
|
||||
setStatus('processing');
|
||||
setMessage(t('checkout.payment_step.processing_confirmation'));
|
||||
setInlineActive(false);
|
||||
setPaymentCompleted(false);
|
||||
setPendingConfirmation({ orderId, checkoutId });
|
||||
toast.success(t('checkout.payment_step.toast_success'));
|
||||
setPaymentCompleted(true);
|
||||
nextStep();
|
||||
}
|
||||
};
|
||||
|
||||
paddleRef.current = paddle;
|
||||
setInitialised(inlineReady);
|
||||
lemon.Setup({
|
||||
eventHandler: (event) => eventHandlerRef.current?.(event),
|
||||
});
|
||||
|
||||
lemonRef.current = lemon;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Paddle', error);
|
||||
setInitialised(false);
|
||||
console.error('Failed to initialize Lemon.js', error);
|
||||
setStatus('error');
|
||||
setMessage(t('checkout.payment_step.paddle_error'));
|
||||
setMessage(t('checkout.payment_step.lemonsqueezy_error'));
|
||||
setPaymentCompleted(false);
|
||||
}
|
||||
})();
|
||||
@@ -689,7 +577,7 @@ export const PaymentStep: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [nextStep, paddleConfig?.environment, paddleConfig?.client_token, paddleLocale, setPaymentCompleted, t]);
|
||||
}, [nextStep, setPaymentCompleted, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setPaymentCompleted(false);
|
||||
@@ -747,7 +635,7 @@ export const PaymentStep: React.FC = () => {
|
||||
setWithdrawalError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/legal/widerrufsbelehrung?lang=${paddleLocale}`);
|
||||
const response = await fetch(`/api/v1/legal/widerrufsbelehrung?lang=${checkoutLocale}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load withdrawal page (${response.status})`);
|
||||
}
|
||||
@@ -759,7 +647,7 @@ export const PaymentStep: React.FC = () => {
|
||||
} finally {
|
||||
setWithdrawalLoading(false);
|
||||
}
|
||||
}, [paddleLocale, t, withdrawalHtml, withdrawalLoading]);
|
||||
}, [checkoutLocale, t, withdrawalHtml, withdrawalLoading]);
|
||||
|
||||
|
||||
if (!selectedPackage) {
|
||||
@@ -842,16 +730,10 @@ export const PaymentStep: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const PaddleLogo = () => (
|
||||
const LemonSqueezyLogo = () => (
|
||||
<div className="flex items-center gap-3 rounded-full bg-white/15 px-4 py-2 text-white shadow-inner backdrop-blur-sm">
|
||||
<img
|
||||
src="/paddle.logo.svg"
|
||||
alt="Paddle"
|
||||
className="h-6 w-auto brightness-0 invert"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span className="text-xs font-semibold">{t('checkout.payment_step.paddle_partner')}</span>
|
||||
<span className="text-sm font-semibold tracking-wide">Lemon Squeezy</span>
|
||||
<span className="text-xs font-semibold">{t('checkout.payment_step.lemonsqueezy_partner')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -863,7 +745,7 @@ export const PaymentStep: React.FC = () => {
|
||||
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-[#001835] via-[#002b55] to-[#00407c] p-6 text-white shadow-md">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-4">
|
||||
<PaddleLogo />
|
||||
<LemonSqueezyLogo />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-2xl font-semibold">{t('checkout.payment_step.guided_title')}</h3>
|
||||
<p className="text-sm text-white/80">{t('checkout.payment_step.guided_body')}</p>
|
||||
@@ -919,8 +801,8 @@ export const PaymentStep: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<PaddleCta
|
||||
onCheckout={startPaddleCheckout}
|
||||
<LemonSqueezyCta
|
||||
onCheckout={startLemonSqueezyCheckout}
|
||||
disabled={status === 'processing' || !acceptedTerms}
|
||||
isProcessing={status === 'processing'}
|
||||
className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)}
|
||||
@@ -1012,10 +894,10 @@ export const PaymentStep: React.FC = () => {
|
||||
{!inlineActive && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('checkout.payment_step.paddle_intro')}
|
||||
{t('checkout.payment_step.lemonsqueezy_intro')}
|
||||
</p>
|
||||
<PaddleCta
|
||||
onCheckout={startPaddleCheckout}
|
||||
<LemonSqueezyCta
|
||||
onCheckout={startLemonSqueezyCheckout}
|
||||
disabled={status === 'processing' || !acceptedTerms}
|
||||
isProcessing={status === 'processing'}
|
||||
className={PRIMARY_CTA_STYLES}
|
||||
@@ -1041,10 +923,8 @@ export const PaymentStep: React.FC = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div ref={checkoutContainerRef} className={`${checkoutContainerClass} min-h-[360px]`} />
|
||||
|
||||
<p className={`text-xs text-muted-foreground ${inlineActive ? 'text-center' : 'sm:text-right'}`}>
|
||||
{t('checkout.payment_step.paddle_disclaimer')}
|
||||
{t('checkout.payment_step.lemonsqueezy_disclaimer')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,8 +24,8 @@ export interface CheckoutPackage {
|
||||
type: 'endcustomer' | 'reseller';
|
||||
features: string[];
|
||||
limits?: Record<string, unknown>;
|
||||
paddle_price_id?: string | null;
|
||||
paddle_product_id?: string | null;
|
||||
lemonsqueezy_variant_id?: string | null;
|
||||
lemonsqueezy_product_id?: string | null;
|
||||
activates_immediately?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -186,9 +186,9 @@ return [
|
||||
'deleted' => 'Gelöscht',
|
||||
],
|
||||
],
|
||||
'paddle_health' => [
|
||||
'lemonsqueezy_health' => [
|
||||
'navigation' => [
|
||||
'label' => 'Paddle-Status',
|
||||
'label' => 'Lemon Squeezy-Status',
|
||||
],
|
||||
],
|
||||
'integrations_health' => [
|
||||
@@ -203,7 +203,7 @@ return [
|
||||
'unknown' => 'Unbekannt',
|
||||
],
|
||||
'heading' => 'Integrationen-Status',
|
||||
'help' => 'Operativer Überblick über Paddle/RevenueCat-Webhooks, Queue-Backlog und jüngste Fehler.',
|
||||
'help' => 'Operativer Überblick über Lemon Squeezy/RevenueCat-Webhooks, Queue-Backlog und jüngste Fehler.',
|
||||
'configured' => 'Konfiguriert',
|
||||
'unconfigured' => 'Nicht konfiguriert',
|
||||
'last_received' => 'Zuletzt empfangen',
|
||||
|
||||
@@ -90,7 +90,7 @@ return [
|
||||
'invoice_link' => 'Rechnung öffnen',
|
||||
'cta' => 'Zum Event-Admin',
|
||||
'provider' => [
|
||||
'paddle' => 'Paddle',
|
||||
'lemonsqueezy' => 'Lemon Squeezy',
|
||||
'manual' => 'Manuell',
|
||||
'free' => 'Kostenfrei',
|
||||
],
|
||||
@@ -123,7 +123,7 @@ return [
|
||||
'cta_hint_body' => 'Ihr Angebot bleibt bestehen – Sie können jederzeit nahtlos fortfahren.',
|
||||
'benefits_title' => 'Was Sie erwartet',
|
||||
'benefit1' => 'Premium Checkout in wenigen Minuten',
|
||||
'benefit2' => 'Sichere Zahlung mit Paddle',
|
||||
'benefit2' => 'Sichere Zahlung mit Lemon Squeezy',
|
||||
'benefit3' => 'Sofortige Aktivierung nach Zahlung',
|
||||
'benefit4' => 'Support durch das Die Fotospiel.App Team',
|
||||
'footer' => 'Wir helfen Ihnen gern weiter, falls Fragen offen sind.',
|
||||
|
||||
@@ -12,14 +12,14 @@ return [
|
||||
'contact' => 'Kontakt',
|
||||
'vat_id' => 'Umsatzsteuer-ID: DE123456789',
|
||||
'monetization' => 'Monetarisierung',
|
||||
'monetization_desc' => 'Wir monetarisieren über Packages (Einmalkäufe und Abos) via Paddle. Preise exkl. MwSt. Support: support@fotospiel.de',
|
||||
'monetization_desc' => 'Wir monetarisieren über Packages (Einmalkäufe und Abos) via Lemon Squeezy. Preise exkl. MwSt. Support: support@fotospiel.de',
|
||||
'register_court' => 'Registergericht: Amtsgericht Musterstadt',
|
||||
'commercial_register' => 'Handelsregister: HRB 12345',
|
||||
'datenschutz_intro' => 'Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.',
|
||||
'responsible' => 'Verantwortlich: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt',
|
||||
'data_collection' => 'Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.',
|
||||
'payments' => 'Zahlungen und Packages',
|
||||
'payments_desc' => 'Wir verarbeiten Zahlungen für Packages über Paddle. Zahlungsinformationen werden sicher und verschlüsselt durch Paddle als Merchant of Record verarbeitet.',
|
||||
'payments_desc' => 'Wir verarbeiten Zahlungen für Packages über Lemon Squeezy. Zahlungsinformationen werden sicher und verschlüsselt durch Lemon Squeezy als Merchant of Record verarbeitet.',
|
||||
'data_retention' => 'Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.',
|
||||
'rights' => 'Ihre Rechte: Auskunft, Löschung, Widerspruch.',
|
||||
'cookies' => 'Cookies: Nur funktionale Cookies für die PWA.',
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
"faq_q3": "Was passiert bei Ablauf?",
|
||||
"faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.",
|
||||
"faq_q4": "Zahlungssicher?",
|
||||
"faq_a4": "Ja, via Paddle – sicher und GDPR-konform.",
|
||||
"faq_a4": "Ja, via Lemon Squeezy – sicher und GDPR-konform.",
|
||||
"final_cta": "Bereit für Ihr nächstes Event?",
|
||||
"contact_us": "Kontaktieren Sie uns",
|
||||
"feature_live_slideshow": "Live-Slideshow",
|
||||
|
||||
@@ -30,7 +30,7 @@ return [
|
||||
'faq_q3' => 'Was passiert bei Ablauf?',
|
||||
'faq_a3' => 'Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.',
|
||||
'faq_q4' => 'Zahlungssicher?',
|
||||
'faq_a4' => 'Ja, via Paddle – sicher und GDPR-konform.',
|
||||
'faq_a4' => 'Ja, via Lemon Squeezy – sicher und GDPR-konform.',
|
||||
'final_cta' => 'Bereit für Ihr nächstes Event?',
|
||||
'contact_us' => 'Kontaktieren Sie uns',
|
||||
'feature_live_slideshow' => 'Live-Slideshow',
|
||||
@@ -64,12 +64,12 @@ return [
|
||||
'gallery_days_label' => 'Galerie-Tage',
|
||||
'recommended_usage_window' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||
'feature_overview' => 'Feature-Überblick',
|
||||
'order_hint' => 'Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.',
|
||||
'order_hint' => 'Sofort startklar – keine versteckten Kosten, sichere Zahlung über Lemon Squeezy.',
|
||||
'features_label' => 'Features',
|
||||
'breakdown_label' => 'Leistungsübersicht',
|
||||
'limits_label' => 'Limits & Kapazitäten',
|
||||
'paddle_not_configured' => 'Dieses Package ist noch nicht für den Paddle-Checkout konfiguriert. Bitte kontaktiere den Support.',
|
||||
'paddle_checkout_failed' => 'Der Paddle-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.',
|
||||
'lemonsqueezy_not_configured' => 'Dieses Package ist noch nicht für den Lemon Squeezy-Checkout konfiguriert. Bitte kontaktiere den Support.',
|
||||
'lemonsqueezy_checkout_failed' => 'Der Lemon Squeezy-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.',
|
||||
'package_not_found' => 'Dieses Package ist nicht verfügbar. Bitte wähle ein anderes aus.',
|
||||
],
|
||||
'nav' => [
|
||||
|
||||
@@ -186,9 +186,9 @@ return [
|
||||
'deleted' => 'Deleted',
|
||||
],
|
||||
],
|
||||
'paddle_health' => [
|
||||
'lemonsqueezy_health' => [
|
||||
'navigation' => [
|
||||
'label' => 'Paddle health',
|
||||
'label' => 'Lemon Squeezy health',
|
||||
],
|
||||
],
|
||||
'integrations_health' => [
|
||||
@@ -203,7 +203,7 @@ return [
|
||||
'unknown' => 'Unknown',
|
||||
],
|
||||
'heading' => 'Integrations health',
|
||||
'help' => 'Operational snapshot of Paddle/RevenueCat webhooks, queue backlog, and recent failures.',
|
||||
'help' => 'Operational snapshot of Lemon Squeezy/RevenueCat webhooks, queue backlog, and recent failures.',
|
||||
'configured' => 'Configured',
|
||||
'unconfigured' => 'Unconfigured',
|
||||
'last_received' => 'Last received',
|
||||
|
||||
@@ -89,7 +89,7 @@ return [
|
||||
'invoice_link' => 'Open invoice',
|
||||
'cta' => 'Open Event Admin',
|
||||
'provider' => [
|
||||
'paddle' => 'Paddle',
|
||||
'lemonsqueezy' => 'Lemon Squeezy',
|
||||
'manual' => 'Manual',
|
||||
'free' => 'Free',
|
||||
],
|
||||
@@ -122,7 +122,7 @@ return [
|
||||
'cta_hint_body' => 'Your selection stays locked—continue whenever you are ready.',
|
||||
'benefits_title' => 'What you get',
|
||||
'benefit1' => 'Premium checkout in minutes',
|
||||
'benefit2' => 'Secure payment with Paddle',
|
||||
'benefit2' => 'Secure payment with Lemon Squeezy',
|
||||
'benefit3' => 'Instant activation after payment',
|
||||
'benefit4' => 'Support from the Die Fotospiel.App team',
|
||||
'footer' => 'Let us know if you need anything.',
|
||||
|
||||
@@ -12,14 +12,14 @@ return [
|
||||
'contact' => 'Contact',
|
||||
'vat_id' => 'VAT ID: DE123456789',
|
||||
'monetization' => 'Monetization',
|
||||
'monetization_desc' => 'We monetize through Packages (one-time purchases and subscriptions) via Paddle. Prices excl. VAT. Support: support@fotospiel.de',
|
||||
'monetization_desc' => 'We monetize through Packages (one-time purchases and subscriptions) via Lemon Squeezy. Prices excl. VAT. Support: support@fotospiel.de',
|
||||
'register_court' => 'Register Court: District Court Musterstadt',
|
||||
'commercial_register' => 'Commercial Register: HRB 12345',
|
||||
'datenschutz_intro' => 'We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.',
|
||||
'responsible' => 'Responsible: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt',
|
||||
'data_collection' => 'Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.',
|
||||
'payments' => 'Payments and Packages',
|
||||
'payments_desc' => 'We process payments for Packages via Paddle. Payment information is handled securely and encrypted by Paddle as the merchant of record.',
|
||||
'payments_desc' => 'We process payments for Packages via Lemon Squeezy. Payment information is handled securely and encrypted by Lemon Squeezy as the merchant of record.',
|
||||
'data_retention' => 'Package data (limits, features) is anonymized and only required for functionality. Consent for payments and emails is obtained at purchase. Data is deleted after 10 years.',
|
||||
'rights' => 'Your rights: Information, deletion, objection. Contact us under Contact.',
|
||||
'cookies' => 'Cookies: Only functional cookies for the PWA.',
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
"faq_q3": "What happens when it expires?",
|
||||
"faq_a3": "The gallery remains readable, but uploads are blocked. Simply extend it.",
|
||||
"faq_q4": "Payment secure?",
|
||||
"faq_a4": "Yes, via Paddle – secure and GDPR-compliant.",
|
||||
"faq_a4": "Yes, via Lemon Squeezy – secure and GDPR-compliant.",
|
||||
"final_cta": "Ready for your next event?",
|
||||
"contact_us": "Contact Us",
|
||||
"feature_live_slideshow": "Live Slideshow",
|
||||
|
||||
@@ -30,7 +30,7 @@ return [
|
||||
'faq_q3' => 'What happens when it expires?',
|
||||
'faq_a3' => 'The gallery remains readable, but uploads are blocked. Simply extend it.',
|
||||
'faq_q4' => 'Payment secure?',
|
||||
'faq_a4' => 'Yes, via Paddle – secure and GDPR-compliant.',
|
||||
'faq_a4' => 'Yes, via Lemon Squeezy – secure and GDPR-compliant.',
|
||||
'final_cta' => 'Ready for your next event?',
|
||||
'contact_us' => 'Contact Us',
|
||||
'feature_live_slideshow' => 'Live Slideshow',
|
||||
@@ -64,12 +64,12 @@ return [
|
||||
'max_guests_label' => 'Max. guests',
|
||||
'gallery_days_label' => 'Gallery days',
|
||||
'feature_overview' => 'Feature overview',
|
||||
'order_hint' => 'Ready to launch instantly – secure Paddle checkout, no hidden fees.',
|
||||
'order_hint' => 'Ready to launch instantly – secure Lemon Squeezy checkout, no hidden fees.',
|
||||
'features_label' => 'Features',
|
||||
'breakdown_label' => 'At-a-glance',
|
||||
'limits_label' => 'Limits & Capacity',
|
||||
'paddle_not_configured' => 'This package is not ready for Paddle checkout. Please contact support.',
|
||||
'paddle_checkout_failed' => 'We could not start the Paddle checkout. Please try again later.',
|
||||
'lemonsqueezy_not_configured' => 'This package is not ready for Lemon Squeezy checkout. Please contact support.',
|
||||
'lemonsqueezy_checkout_failed' => 'We could not start the Lemon Squeezy checkout. Please try again later.',
|
||||
'package_not_found' => 'This package is no longer available. Please choose another one.',
|
||||
],
|
||||
'nav' => [
|
||||
|
||||
Reference in New Issue
Block a user