Migrate billing from Paddle to Lemon Squeezy

This commit is contained in:
Codex Agent
2026-02-03 10:59:54 +01:00
parent 2f4ebfefd4
commit a0ef90e13a
228 changed files with 4369 additions and 4067 deletions

View File

@@ -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', {

View File

@@ -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}}",

View File

@@ -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": {

View File

@@ -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}}",

View File

@@ -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": {

View File

@@ -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';

View File

@@ -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}
/>

View File

@@ -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',

View File

@@ -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}

View File

@@ -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')

View File

@@ -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';

View File

@@ -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"

View File

@@ -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">

View File

@@ -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(),
});

View File

@@ -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,

View File

@@ -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.',
],

View File

@@ -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', () => {

View File

@@ -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',
}}

View File

@@ -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}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>

View 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)',
};
}

View File

@@ -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%"

View File

@@ -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"

View File

@@ -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',

View File

@@ -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' },

View File

@@ -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>

View File

@@ -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);

View File

@@ -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}

View File

@@ -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,

View File

@@ -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', () => {

View File

@@ -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');
});
});

View File

@@ -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 () => {

View File

@@ -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>

View File

@@ -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;
}