From 049c4f82b983dc6b2ada02a97958a5eb6619ca52 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 4 Feb 2026 10:39:53 +0100 Subject: [PATCH] Allowing local lemonsqueezy payment skip and emulate success response --- .../LemonSqueezyCheckoutController.php | 38 +++ .../js/components/consent/CookieBanner.tsx | 35 +++ .../consent/__tests__/CookieBanner.test.tsx | 77 ++++++ .../__tests__/PaymentStep.render.test.tsx | 82 +++++- .../marketing/checkout/steps/PaymentStep.tsx | 22 +- scripts/capture-mobile-pwa-screenshots.mjs | 240 ++++++++++++++++++ .../LemonSqueezyCheckoutControllerTest.php | 42 +++ 7 files changed, 531 insertions(+), 5 deletions(-) create mode 100644 resources/js/components/consent/__tests__/CookieBanner.test.tsx create mode 100644 scripts/capture-mobile-pwa-screenshots.mjs diff --git a/app/Http/Controllers/LemonSqueezyCheckoutController.php b/app/Http/Controllers/LemonSqueezyCheckoutController.php index 2393f422..c085fefb 100644 --- a/app/Http/Controllers/LemonSqueezyCheckoutController.php +++ b/app/Http/Controllers/LemonSqueezyCheckoutController.php @@ -65,6 +65,14 @@ class LemonSqueezyCheckoutController extends Controller $this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']); } + if (app()->environment('local')) { + $checkout = $this->simulateLocalCheckout($session, $package, $couponCode); + + return response()->json(array_merge($checkout, [ + 'checkout_session_id' => $session->id, + ])); + } + $checkout = $this->checkout->createCheckout($tenant, $package, [ 'success_url' => $data['success_url'] ?? null, 'return_url' => $data['return_url'] ?? null, @@ -93,6 +101,36 @@ class LemonSqueezyCheckoutController extends Controller ])); } + /** + * @return array{checkout_url: string|null, id: string, order_id: string, simulated: bool} + */ + protected function simulateLocalCheckout(CheckoutSession $session, Package $package, string $couponCode): array + { + $checkoutId = 'chk_'.Str::uuid(); + $orderId = 'order_'.Str::uuid(); + + $session->forceFill([ + 'lemonsqueezy_checkout_id' => $checkoutId, + 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ + 'lemonsqueezy_checkout_id' => $checkoutId, + 'lemonsqueezy_order_id' => $orderId, + 'lemonsqueezy_status' => 'paid', + 'lemonsqueezy_local_simulated_at' => now()->toIso8601String(), + 'coupon_code' => $couponCode ?: null, + ])), + ])->save(); + + return [ + 'checkout_url' => route('marketing.success', [ + 'locale' => app()->getLocale(), + 'packageId' => $package->id, + ], absolute: true), + 'id' => $checkoutId, + 'order_id' => $orderId, + 'simulated' => true, + ]; + } + protected function resolveLegalVersion(): string { return config('app.legal_version', now()->toDateString()); diff --git a/resources/js/components/consent/CookieBanner.tsx b/resources/js/components/consent/CookieBanner.tsx index 6249e9bd..941a6b83 100644 --- a/resources/js/components/consent/CookieBanner.tsx +++ b/resources/js/components/consent/CookieBanner.tsx @@ -13,6 +13,8 @@ import { Switch } from '@/components/ui/switch'; import { Separator } from '@/components/ui/separator'; import { useConsent, ConsentPreferences } from '@/contexts/consent'; +const SKIP_BANNER_STORAGE_KEY = 'fotospiel.consent.skip'; + const CookieBanner: React.FC = () => { const { t } = useTranslation('common'); const { @@ -28,6 +30,35 @@ const CookieBanner: React.FC = () => { const [draftPreferences, setDraftPreferences] = useState(preferences); + const shouldSkipBanner = useMemo(() => { + if (typeof window === 'undefined') { + return false; + } + + if (!import.meta.env.DEV) { + return false; + } + + const params = new URLSearchParams(window.location.search); + const hasSkipParam = params.get('consent') === 'skip' || params.get('cookieBanner') === 'off'; + + if (hasSkipParam) { + try { + window.localStorage.setItem(SKIP_BANNER_STORAGE_KEY, '1'); + } catch (error) { + console.warn('[Consent] Failed to persist skip flag', error); + } + return true; + } + + try { + return window.localStorage.getItem(SKIP_BANNER_STORAGE_KEY) === '1'; + } catch (error) { + console.warn('[Consent] Failed to read skip flag', error); + return false; + } + }, []); + useEffect(() => { if (isPreferencesOpen) { setDraftPreferences(preferences); @@ -51,6 +82,10 @@ const CookieBanner: React.FC = () => { closePreferences(); }; + if (shouldSkipBanner) { + return null; + } + return ( <> {showBanner && !isPreferencesOpen && ( diff --git a/resources/js/components/consent/__tests__/CookieBanner.test.tsx b/resources/js/components/consent/__tests__/CookieBanner.test.tsx new file mode 100644 index 00000000..4ba893ba --- /dev/null +++ b/resources/js/components/consent/__tests__/CookieBanner.test.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +const consentMock = vi.fn(); + +vi.mock('@/contexts/consent', () => ({ + useConsent: () => consentMock(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, ...props }: { children: React.ReactNode }) => ( + + ), +})); + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@/components/ui/switch', () => ({ + Switch: () => , +})); + +vi.mock('@/components/ui/separator', () => ({ + Separator: () =>
, +})); + +import CookieBanner from '../CookieBanner'; + +describe('CookieBanner', () => { + beforeEach(() => { + consentMock.mockReturnValue({ + showBanner: true, + acceptAll: vi.fn(), + rejectAll: vi.fn(), + preferences: { analytics: false, functional: true }, + savePreferences: vi.fn(), + isPreferencesOpen: false, + openPreferences: vi.fn(), + closePreferences: vi.fn(), + }); + + window.localStorage.removeItem('fotospiel.consent.skip'); + }); + + afterEach(() => { + window.localStorage.removeItem('fotospiel.consent.skip'); + }); + + it('renders the banner by default', () => { + render(); + + expect(screen.getByText('consent.banner.title')).toBeInTheDocument(); + }); + + it('hides the banner when the dev skip flag is set', () => { + window.localStorage.setItem('fotospiel.consent.skip', '1'); + + render(); + + expect(screen.queryByText('consent.banner.title')).not.toBeInTheDocument(); + }); +}); diff --git a/resources/js/pages/marketing/checkout/__tests__/PaymentStep.render.test.tsx b/resources/js/pages/marketing/checkout/__tests__/PaymentStep.render.test.tsx index ca5238af..cd36541f 100644 --- a/resources/js/pages/marketing/checkout/__tests__/PaymentStep.render.test.tsx +++ b/resources/js/pages/marketing/checkout/__tests__/PaymentStep.render.test.tsx @@ -1,8 +1,9 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render, screen } from '@testing-library/react'; -import { CheckoutWizardProvider } from '../WizardContext'; +import { CheckoutWizardProvider, useCheckoutWizard } from '../WizardContext'; import { PaymentStep } from '../steps/PaymentStep'; +import { fireEvent, waitFor } from '@testing-library/react'; vi.mock('@/hooks/useAnalytics', () => ({ useAnalytics: () => ({ trackEvent: vi.fn() }), @@ -14,6 +15,21 @@ const basePackage = { lemonsqueezy_variant_id: 'pri_test_123', }; +const StepIndicator = () => { + const { currentStep } = useCheckoutWizard(); + return
{currentStep}
; +}; + +const AuthSeeder = () => { + const { setAuthUser } = useCheckoutWizard(); + + useEffect(() => { + setAuthUser({ id: 1, email: 'demo@example.com' }); + }, [setAuthUser]); + + return null; +}; + describe('PaymentStep', () => { beforeEach(() => { localStorage.clear(); @@ -21,11 +37,13 @@ describe('PaymentStep', () => { Setup: vi.fn(), Url: { Open: vi.fn() }, }; + vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { cleanup(); delete window.LemonSqueezy; + vi.unstubAllGlobals(); }); it('renders the payment experience without crashing', async () => { @@ -38,4 +56,64 @@ describe('PaymentStep', () => { expect(await screen.findByText('checkout.payment_step.guided_title')).toBeInTheDocument(); expect(screen.queryByText('checkout.legal.checkbox_digital_content_label')).not.toBeInTheDocument(); }); + + it('skips Lemon Squeezy payment when backend simulates checkout', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementation(async (input) => { + if (typeof input === 'string' && input.includes('/sanctum/csrf-cookie')) { + return { + ok: true, + status: 204, + redirected: false, + url: '', + text: async () => '', + } as Response; + } + + if (typeof input === 'string' && input.includes('/lemonsqueezy/create-checkout')) { + return { + ok: true, + status: 200, + redirected: false, + url: '', + text: async () => JSON.stringify({ + simulated: true, + checkout_session_id: 'session_123', + order_id: 'order_123', + id: 'chk_123', + }), + } as Response; + } + + return { + ok: true, + status: 200, + redirected: false, + url: '', + text: async () => '', + } as Response; + }); + + render( + + + + + , + ); + + fireEvent.click(await screen.findByRole('checkbox')); + const ctas = await screen.findAllByText('checkout.payment_step.pay_with_lemonsqueezy'); + fireEvent.click(ctas[0]); + + await waitFor(() => { + expect(screen.getByTestId('current-step')).toHaveTextContent('confirmation'); + }); + + expect(window.LemonSqueezy?.Url.Open).not.toHaveBeenCalled(); + }); }); diff --git a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx index 9710f356..a1c0f0fd 100644 --- a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx @@ -465,7 +465,7 @@ export const PaymentStep: React.FC = () => { console.info('[Checkout] Lemon Squeezy checkout response', { status: response.status, rawBody }); } - let data: { checkout_url?: string; message?: string } | null = null; + let data: { checkout_url?: string; message?: string; simulated?: boolean; order_id?: string; checkout_id?: string; id?: string; checkout_session_id?: string } | null = null; try { data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null; } catch (parseError) { @@ -473,8 +473,24 @@ export const PaymentStep: React.FC = () => { data = null; } - if (data && typeof (data as { checkout_session_id?: string }).checkout_session_id === 'string') { - setCheckoutSessionId((data as { checkout_session_id?: string }).checkout_session_id ?? null); + const checkoutSession = data?.checkout_session_id ?? null; + if (checkoutSession && typeof checkoutSession === 'string') { + setCheckoutSessionId(checkoutSession); + } + + if (data?.simulated) { + const orderId = typeof data.order_id === 'string' ? data.order_id : null; + const checkoutId = typeof data.checkout_id === 'string' + ? data.checkout_id + : (typeof data.id === 'string' ? data.id : null); + + setStatus('processing'); + setMessage(t('checkout.payment_step.processing_confirmation')); + setInlineActive(false); + setPendingConfirmation({ orderId, checkoutId }); + setPaymentCompleted(true); + nextStep(); + return; } let checkoutUrl: string | null = typeof data?.checkout_url === 'string' ? data.checkout_url : null; diff --git a/scripts/capture-mobile-pwa-screenshots.mjs b/scripts/capture-mobile-pwa-screenshots.mjs new file mode 100644 index 00000000..bc8ac192 --- /dev/null +++ b/scripts/capture-mobile-pwa-screenshots.mjs @@ -0,0 +1,240 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { chromium, devices } from 'playwright'; + +const baseUrl = process.env.BASE_URL ?? 'http://fotospiel-app.test'; +const eventSlug = process.env.EVENT_SLUG ?? 'demo-starter-wedding'; +const notificationFallback = process.env.NOTIFICATION_ID ?? 'demo-notification'; +const helpFallback = process.env.HELP_SLUG ?? 'demo-help'; +const outputDir = + process.env.OUTPUT_DIR + ?? path.join(process.cwd(), 'test-results', 'mobile-pwa-screenshots', timestamp()); + +const skipParam = 'consent=skip'; + +const publicRoutes = [ + '/event-admin', + '/event-admin/login', + '/event-admin/mobile/login', + '/event-admin/help', + '/event-admin/forgot-password', + '/event-admin/reset-password/demo-token', + '/event-admin/start', + '/event-admin/logout', + '/event-admin/auth/callback', +]; + +const authedRoutes = [ + '/event-admin/dashboard', + '/event-admin/live', + '/event-admin/events', + '/event-admin/events/new', + `/event-admin/events/${eventSlug}`, + `/event-admin/events/${eventSlug}/recap`, + `/event-admin/events/${eventSlug}/edit`, + `/event-admin/events/${eventSlug}/photos`, + `/event-admin/events/${eventSlug}/members`, + `/event-admin/events/${eventSlug}/tasks`, + `/event-admin/events/${eventSlug}/invites`, + `/event-admin/events/${eventSlug}/branding`, + `/event-admin/events/${eventSlug}/photobooth`, + `/event-admin/events/${eventSlug}/guest-notifications`, + `/event-admin/events/${eventSlug}/toolkit`, + '/event-admin/mobile/events', + `/event-admin/mobile/events/${eventSlug}`, + `/event-admin/mobile/events/${eventSlug}/branding`, + '/event-admin/mobile/events/new', + `/event-admin/mobile/events/${eventSlug}/edit`, + `/event-admin/mobile/events/${eventSlug}/qr`, + `/event-admin/mobile/events/${eventSlug}/qr/customize`, + `/event-admin/mobile/events/${eventSlug}/control-room`, + `/event-admin/mobile/events/${eventSlug}/photos`, + `/event-admin/mobile/events/${eventSlug}/live-show`, + `/event-admin/mobile/events/${eventSlug}/live-show/settings`, + `/event-admin/mobile/events/${eventSlug}/recap`, + `/event-admin/mobile/events/${eventSlug}/analytics`, + `/event-admin/mobile/events/${eventSlug}/members`, + `/event-admin/mobile/events/${eventSlug}/tasks`, + `/event-admin/mobile/events/${eventSlug}/photobooth`, + `/event-admin/mobile/events/${eventSlug}/guest-notifications`, + '/event-admin/mobile/notifications', + '/event-admin/mobile/profile', + '/event-admin/mobile/profile/account', + '/event-admin/mobile/help', + '/event-admin/mobile/billing', + '/event-admin/mobile/billing/shop', + '/event-admin/mobile/settings', + '/event-admin/mobile/exports', + '/event-admin/mobile/dashboard', + '/event-admin/mobile/tasks', + '/event-admin/mobile/uploads', +]; + +const welcomeRoutes = [ + '/event-admin/mobile/welcome', + '/event-admin/mobile/welcome/packages', + '/event-admin/mobile/welcome/summary', + '/event-admin/mobile/welcome/event', +]; + +const device = devices['iPhone 12']; + +function timestamp() { + const now = new Date(); + const pad = (value) => String(value).padStart(2, '0'); + return [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + '-', + pad(now.getHours()), + pad(now.getMinutes()), + pad(now.getSeconds()), + ].join(''); +} + +function withSkip(url) { + if (url.includes(skipParam)) { + return url; + } + return url.includes('?') ? `${url}&${skipParam}` : `${url}?${skipParam}`; +} + +function slugify(input) { + return input + .replace(/^https?:\/\//, '') + .replace(/\?.*$/, '') + .replace(/\s+/g, '-') + .replace(/[^a-zA-Z0-9-_./]/g, '') + .replace(/[\\/]+/g, '_') + .replace(/_{2,}/g, '_') + .replace(/^_+|_+$/g, ''); +} + +async function ensureDir(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +async function waitForDemoAuth(page) { + await page.waitForFunction(() => Boolean(window.fotospielDemoAuth?.loginAs), null, { + timeout: 10000, + }); +} + +async function loginAs(page, tenantKey) { + await page.goto(withSkip(`${baseUrl}/event-admin/login`), { waitUntil: 'load' }); + await waitForDemoAuth(page); + await page.evaluate(async (key) => { + await window.fotospielDemoAuth?.loginAs(key); + }, tenantKey); + await page.waitForLoadState('networkidle'); +} + +async function captureRoute(page, route, tag, log) { + const url = withSkip(`${baseUrl}${route}`); + try { + await page.goto(url, { waitUntil: 'load' }); + await page.waitForTimeout(700); + const file = path.join(outputDir, `${tag}__${slugify(route)}.png`); + await page.screenshot({ path: file, fullPage: true }); + log.push({ url, file, status: 'ok' }); + } catch (error) { + log.push({ url, status: 'error', error: String(error) }); + } +} + +async function captureDerivedRoute(page, baseRoute, matchPrefix, fallback, tag, log) { + const baseUrlWithSkip = withSkip(`${baseUrl}${baseRoute}`); + await page.goto(baseUrlWithSkip, { waitUntil: 'load' }); + await page.waitForTimeout(700); + + const href = await page.evaluate((prefix) => { + const anchors = Array.from(document.querySelectorAll('a[href]')); + const match = anchors.map((anchor) => anchor.getAttribute('href') || '').find((value) => value.includes(prefix)); + return match || ''; + }, matchPrefix); + + const route = href && href.startsWith('/') ? href : fallback; + await captureRoute(page, route, tag, log); +} + +async function main() { + await ensureDir(outputDir); + + const log = []; + const browser = await chromium.launch(); + const context = await browser.newContext({ ...device, locale: 'de-DE' }); + await context.addInitScript(() => { + try { + window.localStorage.setItem('fotospiel.consent.skip', '1'); + window.localStorage.setItem('fotospiel-dev-switcher-collapsed', '1'); + } catch (error) { + // ignore storage errors + } + }); + + const page = await context.newPage(); + + for (const route of publicRoutes) { + await captureRoute(page, route, 'public', log); + } + + await loginAs(page, process.env.TENANT_KEY ?? 'cust-starter-wedding'); + + for (const route of authedRoutes) { + await captureRoute(page, route, 'authed', log); + } + + await captureDerivedRoute( + page, + '/event-admin/mobile/notifications', + '/event-admin/mobile/notifications/', + `/event-admin/mobile/notifications/${notificationFallback}`, + 'authed', + log, + ); + + await captureDerivedRoute( + page, + '/event-admin/mobile/help', + '/event-admin/mobile/help/', + `/event-admin/mobile/help/${helpFallback}`, + 'authed', + log, + ); + + await context.close(); + + const emptyContext = await browser.newContext({ ...device, locale: 'de-DE' }); + await emptyContext.addInitScript(() => { + try { + window.localStorage.setItem('fotospiel.consent.skip', '1'); + window.localStorage.setItem('fotospiel-dev-switcher-collapsed', '1'); + } catch (error) { + // ignore storage errors + } + }); + + const emptyPage = await emptyContext.newPage(); + await loginAs(emptyPage, process.env.EMPTY_TENANT_KEY ?? 'cust-standard-empty'); + + for (const route of welcomeRoutes) { + await captureRoute(emptyPage, route, 'welcome', log); + } + + await emptyContext.close(); + await browser.close(); + + fs.writeFileSync(path.join(outputDir, 'run-log.json'), JSON.stringify(log, null, 2)); + const okCount = log.filter((entry) => entry.status === 'ok').length; + console.log(`Saved ${okCount} screenshots to ${outputDir}`); +} + +main().catch((error) => { + console.error('Screenshot capture failed', error); + process.exit(1); +}); diff --git a/tests/Feature/LemonSqueezyCheckoutControllerTest.php b/tests/Feature/LemonSqueezyCheckoutControllerTest.php index 625afe41..f35f1b75 100644 --- a/tests/Feature/LemonSqueezyCheckoutControllerTest.php +++ b/tests/Feature/LemonSqueezyCheckoutControllerTest.php @@ -94,4 +94,46 @@ class LemonSqueezyCheckoutControllerTest extends TestCase 'coupon_code' => 'SAVE15', ]); } + + public function test_local_environment_simulates_checkout_without_api_call(): void + { + $this->app->detectEnvironment(fn () => 'local'); + $this->withoutMiddleware(); + + $tenant = Tenant::factory()->create([ + 'lemonsqueezy_customer_id' => 'cus_123', + ]); + + $user = User::factory()->for($tenant)->create(); + + $package = Package::factory()->create([ + 'lemonsqueezy_variant_id' => 'pri_123', + 'lemonsqueezy_product_id' => 'pro_123', + 'price' => 120, + ]); + + $checkoutServiceMock = Mockery::mock(LemonSqueezyCheckoutService::class); + $checkoutServiceMock->shouldNotReceive('createCheckout'); + $this->instance(LemonSqueezyCheckoutService::class, $checkoutServiceMock); + + $this->be($user); + + $response = $this->postJson(route('lemonsqueezy.checkout.create'), [ + 'package_id' => $package->id, + 'accepted_terms' => true, + ]); + + $response->assertOk() + ->assertJsonPath('simulated', true) + ->assertJsonStructure([ + 'checkout_session_id', + 'order_id', + 'id', + ]); + + $this->assertDatabaseHas('checkout_sessions', [ + 'package_id' => $package->id, + 'lemonsqueezy_checkout_id' => $response->json('id'), + ]); + } }