diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 628c476..34a8f76 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -213,15 +213,26 @@ jobs: # We also need to (1) ship the standalone loader script # `scripts/start-standalone.cjs`, (2) copy `.next/static/` into # `.next/standalone/.next/static/` so the standalone server can - # serve client-side JS/CSS chunks, and (3) load `.env` via a - # Node loader — bash's `set -a; . ./.env` truncates DATABASE_URL - # at the `&` in `&channel_binding=require`. + # serve client-side JS/CSS chunks, (3) copy `public/` into + # `.next/standalone/public/` (REQUIRED for Next.js standalone — + # without it, `/_next/image?url=/brand-logos/...` returns 400 + # because the optimizer can't resolve local relative paths), + # and (4) load `.env` via a Node loader — bash's + # `set -a; . ./.env` truncates DATABASE_URL at the `&` in + # `&channel_binding=require`. scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/start-standalone.cjs tyler@route.crispygoat.com:$APP_DIR/scripts/start-standalone.cjs 2>/dev/null || true ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "set -e; cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && \ # Standalone server reads .next/static/ relative to itself. mkdir -p .next/standalone/.next && \ rm -rf .next/standalone/.next/static && \ cp -r .next/static .next/standalone/.next/static && \ + # Next.js standalone REQUIRES public/ in the standalone tree; + # without it, the image optimizer returns 400 on local-relative + # image URLs (e.g. /brand-logos/.../logo.png) and storefront + # logos / favicons go blank. Mirror public/ → .next/standalone/public/. + rm -rf .next/standalone/public && \ + mkdir -p .next/standalone/public && \ + cp -r public/. .next/standalone/public/ && \ # Start with the wrapper that loads .env and exec's the server. # `pm2 start` is idempotent via `pm2 restart` after the first run. pm2 delete route-commerce 2>/dev/null || true; \ diff --git a/scripts/regression-brand-logos.mjs b/scripts/regression-brand-logos.mjs new file mode 100644 index 0000000..d034cbd --- /dev/null +++ b/scripts/regression-brand-logos.mjs @@ -0,0 +1,82 @@ +// Regression test: brand logo (and all /brand-logos/... images) must load +// through Next.js Image optimizer on the tuxedo storefront. +// +// Pre-fix state: +// GET https://water.tuxedocorn.com/_next/image?url=%2Fbrand-logos%2F64294306-...%2Flogo.png +// → HTTP 400, body: "url parameter is not allowed" +// +// Post-fix expectation: +// → HTTP 200, content-type: image/* and non-empty body +// +// Root cause identified: Next.js standalone build is missing the +// `.next/standalone/public/` folder. The deploy workflow copies +// `.next/static` but not `public/`. Without `public/` in standalone, the +// image optimizer can't resolve local-relative URLs. + +import { setTimeout as sleep } from 'node:timers/promises'; + +const URL = 'https://water.tuxedocorn.com/tuxedo?_=' + Date.now(); +const LOGO_PROXY = + 'https://water.tuxedocorn.com/_next/image?url=%2Fbrand-logos%2F64294306-5f42-463d-a5e8-2ad6c81a96de%2Flogo.png&w=256&q=75'; + +const failures = []; + +// 1. Hit the image proxy directly +const r = await fetch(LOGO_PROXY, { + headers: { 'User-Agent': 'regression-brand-logos/1.0' }, +}); +if (r.status !== 200) { + failures.push(`Logo proxy returned HTTP ${r.status} (expected 200)`); +} else { + const ct = r.headers.get('content-type') ?? ''; + if (!ct.startsWith('image/')) failures.push(`Logo proxy content-type was ${ct} (expected image/*)`); + const buf = new Uint8Array(await r.arrayBuffer()); + if (buf.length < 1000) failures.push(`Logo proxy body too small: ${buf.length} bytes`); + console.log(` Logo proxy: HTTP ${r.status} ${ct} ${buf.length}B`); +} + +// 2. Verify via headless browser — visit page, check that the logo actually rendered. +const { chromium } = await import('playwright'); +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); +const page = await ctx.newPage(); +const failed = []; +page.on('response', (resp) => { + const u = resp.url(); + if (u.includes('brand-logos') && resp.status() >= 400) failed.push(`HTTP ${resp.status()} ${u.slice(0, 120)}`); +}); +await page.goto(URL, { waitUntil: 'networkidle', timeout: 30000 }); +// Scroll to bottom so footer logo loads. +await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' })); +await sleep(4000); +await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); +await sleep(2000); + +const logoInfo = await page.evaluate(() => { + const imgs = Array.from(document.querySelectorAll('img')); + const logos = imgs.filter((i) => (i.currentSrc || i.src || '').includes('brand-logos')); + return logos.map((i) => ({ + alt: i.alt.slice(0, 40), + currentSrc: (i.currentSrc || i.src || '').slice(0, 130), + naturalWidth: i.naturalWidth, + naturalHeight: i.naturalHeight, + complete: i.complete, + visible: i.getBoundingClientRect().height > 0, + })); +}); +console.log('\n LOGO IMAGES ON PAGE:'); +for (const l of logoInfo) console.log(' ', JSON.stringify(l)); + +const anyLogoBlank = logoInfo.some((l) => l.naturalWidth === 0 || !l.complete); +if (anyLogoBlank) failures.push(`Logo image(s) failed to render: ${JSON.stringify(logoInfo)}`); +if (failed.length > 0) failures.push(`Network failed for brand-logos URLs: ${failed.join(' | ')}`); + +await browser.close(); + +if (failures.length > 0) { + console.error('\n❌ REGRESSION FAILED:'); + for (const f of failures) console.error(' -', f); + process.exit(1); +} else { + console.log('\n✅ Brand logos serve correctly through Next.js Image optimizer.'); +} diff --git a/src/actions/water-log/auth.ts b/src/actions/water-log/auth.ts index 3bf0555..07169a2 100644 --- a/src/actions/water-log/auth.ts +++ b/src/actions/water-log/auth.ts @@ -40,11 +40,16 @@ import { // ── Result types ──────────────────────────────────────────────────────── -// `FieldSession` is now defined in `@/lib/water-log/session.ts` so the -// `FieldSession | { ok: false, error }` shape is shared with +// `FieldSession` is now defined in `@/lib/water-log/session-server.ts` +// so the `FieldSession | { ok: false, error }` shape is shared with // `lib/water-log/pin-login.ts` (Phase 2) and the rest of the module -// surface. Re-export for back-compat with existing call sites. -export type { FieldSession }; +// surface. Consumers should import as: +// `import type { FieldSession } from "@/lib/water-log/session-server";` +// The previous `export type { FieldSession }` re-export here caused +// Next.js 16.2's RSC bundler to fail with "Export FieldSession doesn't +// exist in target module" for pages that reference it indirectly +// through server actions — so we removed the re-export and let +// downstream consumers point at the source. export type SiteAdminPermission = | { ok: true; adminUser: NonNullable>> } diff --git a/src/actions/water-log/field.ts b/src/actions/water-log/field.ts index b8c6c6b..a00e019 100644 --- a/src/actions/water-log/field.ts +++ b/src/actions/water-log/field.ts @@ -32,10 +32,10 @@ import { verifyPin, validatePin } from "@/lib/water-log-pin"; import { logAlert } from "@/lib/water-log-audit"; import { requireFieldSession, - type FieldSession, } from "@/actions/water-log/auth"; import { WL_SESSION_COOKIE, + type FieldSession, clearSessionCookie, readSessionCookie, setSessionCookie, @@ -45,7 +45,11 @@ import { triggerSyncForEntry } from "@/actions/water-log/smartsheet"; // Re-export so existing call sites that import from // `@/actions/water-log/field` keep working. -export type { FieldSession }; +// Note: the re-export was moved to `@/lib/water-log/session-server` +// in commit context; consumers that need `FieldSession` as a type +// should now import it directly from `@/lib/water-log/session-server`. +// This avoids Next.js 16.2's RSC bundler bug that treats type-only +// re-exports through server-action wrappers as runtime exports. // Field sessions last 8h — a working day in the field. Long enough // that a single sign-in covers a morning shift + afternoon shift. diff --git a/src/app/tuxedo/page.tsx b/src/app/tuxedo/page.tsx index 6ca182e..5c64277 100644 --- a/src/app/tuxedo/page.tsx +++ b/src/app/tuxedo/page.tsx @@ -202,6 +202,7 @@ function PullQuoteBand() { fill sizes="100vw" quality={88} + priority className="object-cover -z-10" /> {/* Layered tints: top darkens the sky, bottom grounds the type. */} @@ -343,6 +344,7 @@ function FounderStrip() { fill sizes="(max-width: 1024px) 100vw, 42vw" quality={88} + priority className="object-cover" /> {/* Soft amber wash on the bottom — matches The Corn plate. */}